Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ jobs:
packages/ui/components/ThemeProvider.favicon.test.tsx
packages/ui/components/MermaidBlock.theme.test.tsx
packages/ui/components/DiagramBlock.anchor.test.tsx
packages/ui/components/ImageLightbox.test.tsx
packages/ui/components/blocks/QuestionBlock.test.tsx
packages/ui/components/blocks/QuestionBlock.host.test.tsx
packages/ui/hooks/useAnnotationHighlighter.questionSkip.test.tsx
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -1449,7 +1449,7 @@ These surfaces are **comment-only**. `redline` (auto-DELETION) and `quickLabel`

**HTML Refresh (#1232).** A local rendered-HTML session can re-read its file from disk without reloading the tab, for the loop where an agent edits the page while the reviewer keeps annotating. The header **Refresh** button (left of the eye, `data-html-refresh`, titled "Refresh HTML from disk") fetches the active document through `/api/doc`, hands the bytes to the app, and remounts the viewer under a bumped `reloadGeneration` key (`packages/editor/App.tsx`, viewer `key`). The engine is the published `useHtmlRefresh` (`packages/ui/hooks/useHtmlRefresh.ts`: superseded and cross-document fetches are dropped, one restore acknowledgement per generation) and Plannotator's binding over `fetchHtmlDocumentSnapshot` is `packages/editor/hooks/useHtmlRefresh.ts` (toasts for refreshed, missing, and unavailable). Committed annotations survive on their durable anchors: the remounted viewer re-resolves every element selector and text snapshot against the new page, and the ones it cannot re-anchor are reported once (`onUnanchoredChange` to `reportAnnotationRestore`), toasted, and marked with an **Unanchored** chip in the annotations panel (`htmlUnanchoredIds` in App, cleared when the document changes); their comments stay in the panel and still export. A refresh keeps the version diff: for the root document `/api/doc` carries `previousPlan`/`versionInfo`/`diffHtml` recomputed against the bytes just read (see the annotate `/api/plan` row), `applyRefreshedHtml` sets them and resets `isPlanDiffActive`, so the view returns to normal mode with "Show changes" still available; a tab reload converges on the same state because `/api/plan` serves the current bytes and recomputes the same diff. `/api/share-html` shares the current bytes too. Only local files refresh: `canRefresh` is false for `http(s)` paths and live-app sessions, and the control is absent on read-only (archive) documents. The compact touch shell renders no header controls (`HtmlSurfaceControls` returns null when `compact`), so its Options menu offers "Refresh from disk" beside the Show/Hide tools and Interact/Annotate actions (`compactDocumentActions` in App, disabled while a refresh is in flight); a host that passes `canRefresh` and `onRefresh` to `HtmlSurfaceControls` gets the Refresh button with or without the eye.

**Links between local HTML documents.** A srcdoc document has no URL of its own — its base URL is the PARENT page's, which is the Plannotator server — so an ordinary `<a href="02-detail.html">` used to resolve onto the server, hit the catch-all, and render the whole editor inside the annotated frame; an in-page `#section` link navigated for the same reason. The bridge therefore **never lets the srcdoc frame navigate itself**: a capture-phase click handler registered before the pinpoint handler (and never stopping propagation, so armed clicks still pin the link element) `preventDefault`s every link click, scrolls in-page `#fragment`s locally, and posts the RAW href to the parent as `link-click`. `javascript:` hrefs are left to the page. The parent is the trust boundary: `parseBridgeMessage` bounds the href (2048 chars, no control characters) and `resolveHtmlLinkIntent` (`packages/ui/utils/htmlLinkNavigation.ts`, pure) decides what it means — a relative or `../` path resolves against the CURRENT document's directory and opens as a **linked document** through `/api/doc` exactly like a relative markdown link, a root-relative path (and the same path spelled with the server's own origin, the shape an author writes as `http://localhost:<port>/01-entry-point.html`) resolves against the directory the session was opened from, another origin opens in a **new tab** (`noopener,noreferrer`, the frame never navigates), a local file outside the annotatable set (`.pdf`, `.zip`) raises a toast and is never fetched, and every other scheme (`data:`, `file:`, `mailto:`, `blob:`) is dropped. The query string is stripped and the fragment is kept: it rides to the new document as `HtmlViewer`'s `initialFragment` and is replayed once that document's bridge is ready (`scroll-to-fragment`). Folder sessions route the open through the file-browser selection handler so the active file, the sidebar and the linked doc stay in step. Annotations stay per document (the `useLinkedDoc` cache), and the Interact/Annotate state and the chrome rules keep applying. **Navigating between HTML documents never opens the sidebar**: the page owns the viewport on this surface (it opens with the tools hidden and the sidebar closed), so a link click leaves the sidebar exactly as the reviewer had it — closed stays closed, open stays on its tab. `useLinkedDoc.open` / `openLoaded` take `revealSidebar` for this (default `true`, so every markdown caller is unchanged), and App passes `false` only when the target renders as HTML — `resolveHtmlLinkIntent`'s `rendersHtml`, which is false for markdown targets and for `.html` under `--markdown`, so a markdown target keeps the markdown convention of opening the Contents tab. The way back is therefore a **Back control in the header** (`data-html-back`, leftmost of the HTML surface controls, before Refresh/eye/pen), rendered only while a linked HTML document is open and named after the document it returns to ("Back to index.html"; `useLinkedDoc` keeps one root snapshot rather than a stack, so Back from any depth lands on the session's root). It deliberately claims **no keyboard shortcut** — `Alt`+`Left` and the browser's own Back belong to the user — and the compact touch shell offers the same action in its Options menu. The sidebar's "Viewing / Back to …" header still works for anyone who opens the sidebar, and `TableOfContents` now renders it even when the document has no headings at all, the normal case for raw HTML. The chrome restore-on-entry effect does not re-run on html→html navigation (`shouldRestoreHtmlChrome` in `packages/ui/utils/htmlChrome.ts` is false whenever the previous surface was HTML), so following a link cannot slam the tools back to hidden or re-apply a remembered sidebar state mid-session. **Armed pinpoint clicks still annotate**: navigation is suppressed in both states, but only Interact follows the link (`Esc`, the header pen, or `Mod+Shift+A` gets there). Live app sessions (`mode: "annotate-app"`) are excluded outright — the interceptor lives inside the bridge's `!LIVE` guard — because they navigate the proxied app for real. Known limitation, pre-existing and unchanged for LINKED documents (`/api/doc` mints a token per HTML file's own directory and refuses `..`, so `sub/page.html` opened as a linked document loads assets below it but not ones it reaches with `../`); **embedded** documents are not affected, because they load from the embedding page's token root — see the next paragraph.
**Links between local HTML documents.** A srcdoc document has no URL of its own — its base URL is the PARENT page's, which is the Plannotator server — so an ordinary `<a href="02-detail.html">` used to resolve onto the server, hit the catch-all, and render the whole editor inside the annotated frame; an in-page `#section` link navigated for the same reason. The bridge therefore **never lets the srcdoc frame navigate itself**: a capture-phase click handler registered before the pinpoint handler (and never stopping propagation, so armed clicks still pin the link element) `preventDefault`s every link click, scrolls in-page `#fragment`s locally, and posts the RAW href to the parent as `link-click`. `javascript:` hrefs are left to the page. The parent is the trust boundary: `parseBridgeMessage` bounds the href (2048 chars, no control characters) and `resolveHtmlLinkIntent` (`packages/ui/utils/htmlLinkNavigation.ts`, pure) decides what it means — a relative or `../` path resolves against the CURRENT document's directory and opens as a **linked document** through `/api/doc` exactly like a relative markdown link, a root-relative path (and the same path spelled with the server's own origin, the shape an author writes as `http://localhost:<port>/01-entry-point.html`) resolves against the directory the session was opened from, another origin opens in a **new tab** (`noopener,noreferrer`, the frame never navigates), a link to a local **image** (`png jpg jpeg gif webp svg avif bmp ico apng`, the set code review previews, `isReviewImagePath`) opens in the shared `ImageLightbox` (`packages/ui/components/ImageLightbox.tsx`, the same overlay the markdown Viewer uses), read through the page's OWN `/api/html-assets/<token>/…` route taken from the `<base href>` the server installed (`htmlAssetRouteFromDocument`) so nothing becomes readable that the page could not already load, and only when the image resolves inside the current page's directory (one reached by `../` or a root-relative path outside it toasts "Images open only from this page's own folder"; a page with no asset route, such as a share link, toasts too). The lightbox shows the image only through `<img src>` (an SVG never runs), takes focus when it opens (pulling it out of the iframe), and swallows every key on the window's capture phase while open, so `Esc` closes it before the HTML Esc ladder runs and `Mod+Enter` cannot submit a decision behind it. Any other local file outside the annotatable set (`.pdf`, `.zip`) raises a toast and is never fetched, and every other scheme (`data:`, `file:`, `mailto:`, `blob:`) is dropped. The query string is stripped and the fragment is kept: it rides to the new document as `HtmlViewer`'s `initialFragment` and is replayed once that document's bridge is ready (`scroll-to-fragment`). Folder sessions route the open through the file-browser selection handler so the active file, the sidebar and the linked doc stay in step. Annotations stay per document (the `useLinkedDoc` cache), and the Interact/Annotate state and the chrome rules keep applying. **Navigating between HTML documents never opens the sidebar**: the page owns the viewport on this surface (it opens with the tools hidden and the sidebar closed), so a link click leaves the sidebar exactly as the reviewer had it — closed stays closed, open stays on its tab. `useLinkedDoc.open` / `openLoaded` take `revealSidebar` for this (default `true`, so every markdown caller is unchanged), and App passes `false` only when the target renders as HTML — `resolveHtmlLinkIntent`'s `rendersHtml`, which is false for markdown targets and for `.html` under `--markdown`, so a markdown target keeps the markdown convention of opening the Contents tab. The way back is therefore a **Back control in the header** (`data-html-back`, leftmost of the HTML surface controls, before Refresh/eye/pen), rendered only while a linked HTML document is open and named after the document it returns to ("Back to index.html"; `useLinkedDoc` keeps one root snapshot rather than a stack, so Back from any depth lands on the session's root). It deliberately claims **no keyboard shortcut** — `Alt`+`Left` and the browser's own Back belong to the user — and the compact touch shell offers the same action in its Options menu. The sidebar's "Viewing / Back to …" header still works for anyone who opens the sidebar, and `TableOfContents` now renders it even when the document has no headings at all, the normal case for raw HTML. The chrome restore-on-entry effect does not re-run on html→html navigation (`shouldRestoreHtmlChrome` in `packages/ui/utils/htmlChrome.ts` is false whenever the previous surface was HTML), so following a link cannot slam the tools back to hidden or re-apply a remembered sidebar state mid-session. **Armed pinpoint clicks still annotate**: navigation is suppressed in both states, but only Interact follows the link (`Esc`, the header pen, or `Mod+Shift+A` gets there); image links follow the same rule, so a click on a thumbnail pins it while armed and opens the lightbox in Interact. Live app sessions (`mode: "annotate-app"`) are excluded outright — the interceptor lives inside the bridge's `!LIVE` guard — because they navigate the proxied app for real. Known limitation, pre-existing and unchanged for LINKED documents (`/api/doc` mints a token per HTML file's own directory and refuses `..`, so `sub/page.html` opened as a linked document loads assets below it but not ones it reaches with `../`); **embedded** documents are not affected, because they load from the embedding page's token root — see the next paragraph.

**Embedded local documents.** The same "a srcdoc has no URL of its own" fact broke `<iframe src="prototype.html">` far worse than it broke links: the embed resolved onto the server, the catch-all answered with the app, and every embed in a report rendered a second Plannotator (#1554 — five of them, cookie-less because a nested context inherits the parent's sandbox flags). Links could be fixed by intercepting clicks; embeds cannot, because the `src` is routinely assigned by SCRIPT at runtime (`<iframe data-src="…" loading="lazy">` plus a loader) and may carry a query string, so no serve-time attribute rewrite ever sees it. The fix changes **resolution** instead of markup: `rewriteHtmlAssetReferences` installs a `<base href="/api/html-assets/<token>/">` first in `<head>`, so every relative URL the document produces — `<iframe>`, `<embed>`, `<object data>`, `<frame>`, a runtime `el.src = …`, a `fetch('./data.json')`, a `new URL(x, document.baseURI)` — lands in the document's own directory whatever writes it and whenever. The base is root-relative because a srcdoc resolves its own `<base href>` against the parent's URL (this server), so the port need not be known; an author's own `<base href>` is re-anchored when relative and left untouched when absolute or root-relative (they pinned an origin deliberately). `/api/html-assets` now serves `.html`/`.htm` as real documents, so an embedded page's own relative assets and nested relative embeds resolve against ITS position under the token root — including `../` back up to the root, which is why the linked-document limitation above does not apply here. Query strings and fragments survive to the embedded document untouched (`mock-decisions.html?step=result` sees its own `location.search`); the route only ever reads `url.pathname`.

Expand Down
32 changes: 30 additions & 2 deletions packages/editor/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ import { toast, Toaster } from 'sonner';
import { type Origin, getAgentName } from '@plannotator/shared/agents';
import { diagramRenderKindForPath, isDiagramRenderKind, shouldStripFrontmatter } from '@plannotator/shared/annotatable';
import { setExtraMarkdownExtensions } from '@plannotator/ui/utils/markdownExtensions';
import { documentRendersHtml, resolveHtmlLinkIntent } from '@plannotator/ui/utils/htmlLinkNavigation';
import { documentRendersHtml, htmlAssetRouteFromDocument, resolveHtmlLinkIntent } from '@plannotator/ui/utils/htmlLinkNavigation';
import { ImageLightbox } from '@plannotator/ui/components/ImageLightbox';
import { createPortal } from 'react-dom';
import { annotateFileFeedback, annotateMessageFeedback, wrapFeedbackForClipboard, type AnnotateFeedbackTemplates } from '@plannotator/shared/feedback-templates';
import { diagramDocumentBlocks, parseMarkdownToBlocks, exportAnnotations, exportLinkedDocAnnotations, exportEditorAnnotations, exportCodeFileAnnotations, exportMessageAnnotations, extractFrontmatter, wrapFeedbackForAgent, Frontmatter, type LinkedDocAnnotationEntry, type MessageAnnotationEntry } from '@plannotator/ui/utils/parser';
import { primeSkillCatalog, primeSkillContentsForExport } from '@plannotator/ui/utils/skillCatalog';
Expand Down Expand Up @@ -1972,22 +1974,37 @@ const App: React.FC = () => {
// A link click inside a raw-HTML document (the bridge swallowed the
// navigation; see resolveHtmlLinkIntent for what the href means).
const [htmlLinkFragment, setHtmlLinkFragment] = useState<{ path: string; hash: string } | null>(null);
// A link to a local image inside the page opens in the image lightbox.
const [htmlImageLightbox, setHtmlImageLightbox] = useState<{ src: string; alt: string } | null>(null);
const handleHtmlLinkClick = React.useCallback((href: string) => {
// The page's own asset route (the `<base href>` the server installed) and
// the directory it serves: an image link is shown through that route, so
// the lightbox reads only what the page itself can already load.
const assetRouteUrl = htmlAssetRouteFromDocument(rawHtml);
const intent = resolveHtmlLinkIntent(href, {
baseDir: activeDocBaseDir,
// Server-absolute links (`/x.html`, or the same spelled with this
// server's own origin) mean "the site root this session opened from".
rootDir: imageBaseDir?.includes('/') ? imageBaseDir : activeDocBaseDir,
serverOrigin: window.location.origin,
convertHtml,
assetRoot: assetRouteUrl && activeDocBaseDir ? { dir: activeDocBaseDir, url: assetRouteUrl } : null,
});
if (intent.kind === 'external') {
window.open(intent.url, '_blank', 'noopener,noreferrer');
return;
}
if (intent.kind === 'image') {
setHtmlImageLightbox({ src: intent.url, alt: intent.label });
return;
}
if (intent.kind === 'unsupported') {
toast(`Can't open ${intent.label}`, {
description: 'Only markdown, text and HTML documents open in Plannotator.',
description: intent.reason === 'outside-asset-root'
? "Images open only from this page's own folder."
: intent.reason === 'no-asset-root'
? "This page's local images aren't available here."
: 'Only markdown, text and HTML documents open in Plannotator.',
});
return;
}
Expand All @@ -2011,6 +2028,7 @@ const App: React.FC = () => {
}, [
activeDocBaseDir,
convertHtml,
rawHtml,
imageBaseDir,
fileBrowser.dirs,
fileBrowser.activeDirPath,
Expand Down Expand Up @@ -7346,6 +7364,16 @@ const App: React.FC = () => {
variant="warning"
/>

{/* An image link inside a raw-HTML page (see handleHtmlLinkClick) */}
{htmlImageLightbox && createPortal(
<ImageLightbox
src={htmlImageLightbox.src}
alt={htmlImageLightbox.alt}
onClose={() => setHtmlImageLightbox(null)}
/>,
document.body,
)}

<Toaster
position="top-right"
offset={64}
Expand Down
1 change: 1 addition & 0 deletions packages/shared/html-assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ function escapeHtmlText(value: string): string {
const CONTENT_TYPES_BY_EXT: Record<string, string> = {
".apng": "image/apng",
".avif": "image/avif",
".bmp": "image/bmp",
".css": "text/css; charset=utf-8",
".gif": "image/gif",
".ico": "image/x-icon",
Expand Down
Loading
Loading