@plannotator/ui 0.53.1 → 0.54.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/HANDOFF.md +217 -11
  2. package/README.md +65 -10
  3. package/components/AgentToolAnnouncementDialog.tsx +1 -1
  4. package/components/AgentsTab.tsx +1 -1
  5. package/components/AnnotationPanel.tsx +142 -24
  6. package/components/AnnotationSidebar.tsx +1 -1
  7. package/components/AnnotationToolbar.tsx +64 -9
  8. package/components/ApproveDropdown.tsx +7 -9
  9. package/components/AskSessionAnnouncementDialog.tsx +1 -1
  10. package/components/AttachmentsButton.tsx +1 -1
  11. package/components/BlockRenderer.tsx +19 -5
  12. package/components/CodeFilePopout.tsx +139 -4
  13. package/components/CommentPopover.tsx +44 -8
  14. package/components/DecisionControl.tsx +31 -5
  15. package/components/DiagramBlock.tsx +17 -12
  16. package/components/EditorAnnotationCard.tsx +1 -1
  17. package/components/ExportModal.tsx +77 -7
  18. package/components/HostTitle.tsx +0 -4
  19. package/components/ImageAnnotator/Toolbar.tsx +25 -1
  20. package/components/ImageAnnotator/index.tsx +24 -4
  21. package/components/ImageLightbox.tsx +658 -120
  22. package/components/ImagePeek.tsx +38 -4
  23. package/components/ImageRegions.tsx +369 -0
  24. package/components/ImageThumbnail.tsx +1 -1
  25. package/components/ImageZoomStage.tsx +796 -0
  26. package/components/InboxAnnouncementDialog.tsx +202 -0
  27. package/components/InlineMarkdown.tsx +152 -24
  28. package/components/KeyboardShortcuts.tsx +23 -0
  29. package/components/LazyBlockBoundary.tsx +100 -0
  30. package/components/ListMarker.tsx +3 -3
  31. package/components/LookAndFeelAnnouncementDialog.tsx +12 -2
  32. package/components/MenuVersionSection.tsx +31 -9
  33. package/components/PlanDecisionMenuExtras.tsx +129 -0
  34. package/components/PlanHeaderMenu.tsx +15 -1
  35. package/components/ProviderIcons.tsx +8 -0
  36. package/components/RefHoverCard.tsx +913 -0
  37. package/components/SendFailureToast.tsx +126 -0
  38. package/components/Settings.tsx +617 -126
  39. package/components/TerminalToolsAnnouncementDialog.tsx +1 -1
  40. package/components/ThemeProvider.tsx +14 -1
  41. package/components/ThemeTab.tsx +16 -24
  42. package/components/Tooltip.tsx +49 -18
  43. package/components/Viewer.tsx +280 -41
  44. package/components/ai/DocumentAIChatPanel.tsx +29 -3
  45. package/components/ai/NewChatButton.tsx +74 -0
  46. package/components/blocks/CodeBlock.tsx +1 -1
  47. package/components/blocks/HtmlBlock.tsx +7 -1
  48. package/components/blocks/QuestionBlock.tsx +522 -62
  49. package/components/diagram/DiagramComposer.tsx +5 -1
  50. package/components/diagram/DiagramPending.tsx +12 -2
  51. package/components/diagram/DiagramViewer.tsx +19 -3
  52. package/components/html-embed/HtmlEmbedBlock.tsx +374 -0
  53. package/components/html-embed/SvgFenceBlock.tsx +46 -0
  54. package/components/html-embed/index.ts +3 -0
  55. package/components/html-embed/languages.ts +15 -0
  56. package/components/html-embed/loadGate.ts +107 -0
  57. package/components/html-viewer/HtmlViewer.tsx +47 -6
  58. package/components/html-viewer/bridge-script.asset.js +173 -1
  59. package/components/html-viewer/bridge-script.ts +173 -1
  60. package/components/html-viewer/useHtmlAnnotation.ts +125 -0
  61. package/components/imageZoom.ts +265 -0
  62. package/components/plan-diff/PlanCleanDiffView.tsx +1 -1
  63. package/components/plan-diff/PlanDiffViewer.tsx +13 -2
  64. package/components/plan-diff/PlanRawDiffView.tsx +1 -1
  65. package/components/sidebar/FileBrowser.tsx +16 -2
  66. package/components/sidebar/SidebarTabs.tsx +3 -0
  67. package/config/index.ts +3 -0
  68. package/config/reviewView.ts +41 -1
  69. package/config/settings.ts +155 -4
  70. package/configure.ts +28 -0
  71. package/hooks/pfm/useCodeFilePopout.ts +7 -1
  72. package/hooks/useAIChat.ts +72 -25
  73. package/hooks/useAnnotationDraft.ts +71 -6
  74. package/hooks/useAnnotationHighlighter.ts +57 -3
  75. package/hooks/useCodeAnnotationDraft.ts +113 -17
  76. package/hooks/useCookieHostSetting.ts +96 -0
  77. package/hooks/useDocumentFont.ts +17 -0
  78. package/hooks/useDocumentTextSize.ts +20 -0
  79. package/hooks/useFileBrowser.ts +10 -1
  80. package/hooks/useLinkedDoc.ts +226 -13
  81. package/hooks/usePackageChangesSetting.ts +90 -0
  82. package/hooks/usePlanLookDialog.ts +67 -0
  83. package/hooks/useSharing.ts +11 -7
  84. package/hooks/useThemePaletteHalves.ts +37 -0
  85. package/hooks/useUpdateCheck.ts +22 -4
  86. package/hooks/useUpdateToast.ts +1 -1
  87. package/package.json +23 -22
  88. package/shortcuts/code-review/annotationToolbar.shortcuts.ts +1 -0
  89. package/shortcuts/code-review/explorer.shortcuts.ts +82 -0
  90. package/shortcuts/core.ts +63 -4
  91. package/shortcuts/index.ts +1 -0
  92. package/shortcuts/plan-review/annotationPanel.shortcuts.ts +1 -1
  93. package/shortcuts/plan-review/commentPopover.shortcuts.ts +1 -0
  94. package/shortcuts/runtime.ts +4 -3
  95. package/styles.css +1 -1
  96. package/theme.css +179 -1
  97. package/themes/plannotator-graphite.css +66 -0
  98. package/types.ts +26 -9
  99. package/utils/agentSwitch.ts +41 -20
  100. package/utils/agentToolSetting.ts +1 -1
  101. package/utils/aiChatFormat.ts +23 -7
  102. package/utils/annotationOwnsHighlight.ts +4 -2
  103. package/utils/asciidocParser.ts +570 -0
  104. package/utils/autoUpdateNotice.ts +24 -0
  105. package/utils/belowTitleSlot.ts +174 -0
  106. package/utils/codeLineScroll.ts +100 -0
  107. package/utils/commentSubmitKey.ts +48 -0
  108. package/utils/cssFontFamily.ts +65 -0
  109. package/utils/decisionSpec.ts +190 -2
  110. package/utils/defaultNotesApp.ts +1 -1
  111. package/utils/diffFonts.ts +4 -1
  112. package/utils/documentFont.ts +68 -0
  113. package/utils/documentTextSize.ts +54 -0
  114. package/utils/fontDetect.ts +63 -0
  115. package/utils/inboxAnnouncement.ts +78 -0
  116. package/utils/lookAndFeelAnnouncement.ts +82 -5
  117. package/utils/markdownExtensions.ts +2 -2
  118. package/utils/mermaid.ts +20 -3
  119. package/utils/mermaidTheme.ts +3 -51
  120. package/utils/notion.ts +58 -0
  121. package/utils/parser.ts +28 -1
  122. package/utils/platform.ts +46 -0
  123. package/utils/refCard.ts +82 -0
  124. package/utils/richClipboard.ts +380 -0
  125. package/utils/richClipboardLists.ts +57 -0
  126. package/utils/richCopyPolicy.ts +22 -0
  127. package/utils/scrollAnchor.ts +82 -0
  128. package/utils/sectionRefs.ts +123 -0
  129. package/utils/selectionBoundary.ts +40 -0
  130. package/utils/sendFailure.ts +85 -0
  131. package/utils/sharing.ts +5 -2
  132. package/utils/themeRegistry.ts +10 -0
  133. package/utils/themeTokens.ts +74 -0
  134. package/utils/updateNotice.ts +30 -8
  135. package/utils/vscodePanel.ts +9 -0
package/README.md CHANGED
@@ -36,6 +36,8 @@ configurePlannotatorUI({
36
36
  alertIconRenderer, // (name) => ReactNode | null for a GitHub alert title line's <!-- icon: name --> (default: null, the type's icon)
37
37
  fenceTheme, // (colorTheme, mode) => Shiki theme name | undefined for code fences (default: SHIKI_THEME_MAP, else pierre-dark/light)
38
38
  componentTooltips, // true: ui's own hover text (components/HostTitle) draws as ui's Tooltip, not a native title (ui 0.53.0; default false)
39
+ digitHexIssueRefs, // true: digit-only #123456 / #12345678 link as issue refs (when a ref link can be built), not hex swatches (default false)
40
+ refCardTransport, // (href, ref, signal) => card data for an issue / PR / MR link; with one, useRefHoverCards(rootRef) draws hover cards (default: none, no card, no request). refCardRouteTransport(route) builds one over a server route
39
41
  loadSettingsFromBackend, // true: re-hydrate settings from your (synchronous) storageBackend after install, applied last
40
42
  });
41
43
  ```
@@ -62,10 +64,10 @@ Building your own tooltip and removing the built-in double-click reset are host-
62
64
  The Mermaid runtime, the Graphviz engine, KaTeX and the username dictionary are off the static import graph of `Viewer`, so a host that bundles by route does not download them for a plain markdown read. Graphviz has its own slot since 0.41.0 (`utils/graphviz`, the same shape as Mermaid's: lazy `import('@viz-js/viz')` on the first dot fence, or `setGraphvizRuntime` from the host) and the block shows the source fence under a status until the SVG lands. Mermaid, Graphviz, KaTeX and the dictionary sit behind synchronous slots:
63
65
 
64
66
  - **Math.** Without registration, a math node renders its TeX as text in the same wrapper (same `data-math-tex` / `data-math-display` / `aria-label` / class names), loads KaTeX via `import('katex')`, and re-renders typeset. To keep math typeset on the very first commit, as Plannotator does, add one line to your entry: `import "@plannotator/ui/utils/math-eager";`. To put KaTeX and its stylesheet on one lazy chunk instead, pass `mathRendererLoader`. The stylesheet remains your job either way (see "Consuming it", step 3). The default `import('katex')` is the only runtime mention of `katex` in the package and lives in `utils/math-default-loader` (0.33.0), called only while no loader is registered; a registered loader is never backfilled by it, though a default load already in flight at registration still fills the slot (pre-existing), so register the loader before the first math render. Chunk emission is static, so a bundler still emits that chunk (never requested) unless you alias the module away; see HANDOFF.md "Lazy renderers and eager entries" for the two-line alias. The Mermaid runtime has its own `import("katex")` for `$$` labels, which leaves a second, shared KaTeX chunk in a host build even with the alias; since 0.34.0 a host redirects that one import (for importers inside the `mermaid` package only) to `@plannotator/ui/utils/mermaid-math-slot`, which typesets the labels through your registered renderer, so one KaTeX chunk remains and it is yours. Recipe and measurement in HANDOFF.md, same section. `resetMathRenderer()` empties the slot only and keeps a registered loader (0.34.0); `setMathRendererLoader(null)` is the explicit way back to the package default.
65
- - **Mermaid.** Without registration, the first diagram on a page fetches the runtime through `import('mermaid')` and the block shows the source fence under a "Rendering diagram" status until the SVG lands; a failed import is dropped from the memo, re-attempted once after a short delay, and the error panel (with the source) offers Retry, which issues another fresh attempt. **Since 0.40.0 (Mermaid 12.0.0, ELK layout by default, Safari 17.4+) this lazy path is Plannotator's own**: the plan editor no longer imports the eager entry, so a document with no diagram never downloads the runtime in a chunked build. `import "@plannotator/ui/utils/mermaid-eager";` in your entry registers and initializes the runtime at startup instead, if you would rather it never fail separately from the app. Mermaid 12 lays flowchart, state, class, ER and requirement diagrams out with ELK (orthogonal edges, different packing); every generated SVG `id` keeps its 11.x shape, but children of `g.edgePaths` are now in declaration order, so select edges by id (`{renderId}-L_{from}_{to}_{n}`, `{renderId}-edge{n}`), never by DOM position. Details in HANDOFF.md "Mermaid 12 (0.40.0)". **Diagrams follow the colour theme.** Before every render `MermaidBlock` calls `applyMermaidTheme` (`utils/mermaidTheme`), which reads the theme tokens off the document (`--background`, `--foreground`, `--card`, `--border`, `--muted`, `--muted-foreground`, `--primary`, the accent tokens and `--font-sans`; `readThemeTokens`), derives a complete `themeVariables` set for every diagram family from them (`buildMermaidThemeVariables(tokens, mode)`, pure; base theme `dark` under a dark resolved mode, `default` under light; every text-on-fill pair guarded to WCAG 4.5:1 and every line 3:1, rule in the module doc) and runs the global `mermaid.initialize` once per `(palette, mode, shadow amount)` key, re-rendering mounted diagrams when the key changes. The key comes from `useTheme()`, so a host that mounts `ThemeProvider` and ships `theme.css` gets diagrams in its palette with nothing to configure. **The node shadow follows the palette too.** Mermaid 12's neo look shadows every node from a fixed `rgba(185,185,185,1)` grey; the mapping keeps the look and replaces that filter, setting `themeVariables.dropShadow` from the page's own ground at `options.shadowAmount` (0..1, default `DEFAULT_MERMAID_SHADOW_AMOUNT` = **0.7** of Mermaid's geometry; 1 reproduces its `1px 2px 2px` exactly, 0 publishes `dropShadow: false` and the neo rules render `filter: none`). Its polarity follows the page — lighter than the ground on a dark page, darker on a light one, which is what Mermaid's own `insertLookDefs` does and what keeps a shadow visible at all. A host that wants Mermaid's grey passes its own `themeVariables.dropShadow` after ours; one that wants none passes `{ shadowAmount: 0 }`. `mermaidThemeKey(palette, mode, amount?)` appends the amount only when it is not the default, so a host that names none keeps the key it had. **Fallback contract:** with no tokens on the document (no `ThemeProvider`, no `theme.css`) `readThemeTokens` returns `undefined`, nothing is re-initialized, and the runtime keeps the static `MERMAID_CONFIG` it was initialized with, so such a host renders byte-identically to 0.39.0. `MERMAID_CONFIG` keeps its value and meaning (`securityLevel: 'strict'` pinned); the new exports are additive. Honest limit of any in-page retry: a browser records a failed module fetch in its module map for the page lifetime, so a fresh `import()` of the same chunk URL rejects without a request; the retry recovers failures after the fetch (engine instantiation, initialize) and hosts that version chunk URLs. A host that needs recovery from a failed first fetch uses versioned chunk URLs or a `vite:preloadError` reload at app level.
67
+ - **Mermaid.** Without registration, the first diagram on a page fetches the runtime through `import('mermaid')` and the block shows the source fence under a "Rendering diagram" status until the SVG lands; a failed import is dropped from the memo, re-attempted once after a short delay, and the error panel (with the source) offers Retry, which issues another fresh attempt. **Since 0.40.0 (Mermaid 12.0.0, ELK layout by default, Safari 17.4+) this lazy path is Plannotator's own**: the plan editor no longer imports the eager entry, so a document with no diagram never downloads the runtime in a chunked build. `import "@plannotator/ui/utils/mermaid-eager";` in your entry registers and initializes the runtime at startup instead, if you would rather it never fail separately from the app. Mermaid 12 lays flowchart, state, class, ER and requirement diagrams out with ELK (orthogonal edges, different packing); every generated SVG `id` keeps its 11.x shape, but children of `g.edgePaths` are now in declaration order, so select edges by id (`{renderId}-L_{from}_{to}_{n}`, `{renderId}-edge{n}`), never by DOM position. Details in HANDOFF.md "Mermaid 12 (0.40.0)". **Diagrams follow the colour theme.** Before every render `MermaidBlock` calls `applyMermaidTheme` (`utils/mermaidTheme`), which reads the theme tokens off the document (`--background`, `--foreground`, `--card`, `--border`, `--muted`, `--muted-foreground`, `--primary`, the accent tokens and `--font-sans`; `readThemeTokens`), derives a complete `themeVariables` set for every diagram family from them (`buildMermaidThemeVariables(tokens, mode)`, pure; base theme `dark` under a dark resolved mode, `default` under light; every text-on-fill pair guarded to WCAG 4.5:1 and every line 3:1, rule in the module doc) and runs the global `mermaid.initialize` once per `(palette, mode, shadow amount)` key, re-rendering mounted diagrams when the key changes. The key comes from `useTheme()`, so a host that mounts `ThemeProvider` and ships `theme.css` gets diagrams in its palette with nothing to configure. **The node shadow follows the palette too.** Mermaid 12's neo look shadows every node from a fixed `rgba(185,185,185,1)` grey; the mapping keeps the look and replaces that filter, setting `themeVariables.dropShadow` from the page's own ground at `options.shadowAmount` (0..1, default `DEFAULT_MERMAID_SHADOW_AMOUNT` = **0.7** of Mermaid's geometry; 1 reproduces its `1px 2px 2px` exactly, 0 publishes `dropShadow: false` and the neo rules render `filter: none`). Its polarity follows the page — lighter than the ground on a dark page, darker on a light one, which is what Mermaid's own `insertLookDefs` does and what keeps a shadow visible at all. A host that wants Mermaid's grey passes its own `themeVariables.dropShadow` after ours; one that wants none passes `{ shadowAmount: 0 }`. `mermaidThemeKey(palette, mode, amount?)` appends the amount only when it is not the default, so a host that names none keeps the key it had. **Fallback contract:** with no tokens on the document (no `ThemeProvider`, no `theme.css`) `readThemeTokens` returns `undefined`, nothing is re-initialized, and the runtime keeps the static `MERMAID_CONFIG` it was initialized with, so such a host renders byte-identically to 0.39.0. `MERMAID_CONFIG` keeps its value and meaning (`securityLevel: 'strict'` pinned); the new exports are additive. Honest limit of any in-page retry: a browser records a failed module fetch in its module map for the page lifetime, so a fresh `import()` of the same chunk URL rejects without a request; the retry recovers failures after the fetch (engine instantiation, initialize) and hosts that version chunk URLs. A host that needs recovery from a failed first fetch uses versioned chunk URLs or a `vite:preloadError` reload at app level. The `Viewer`'s lazy blocks (Mermaid, Graphviz, HTML and SVG embeds), the diagram popout and the Source pane each sit in `components/LazyBlockBoundary`: when a chunk cannot load, that one block shows its source under a quiet "Couldn't load this view. Reload" line ("This page was updated. Reload to see this" when the chunk answers 404), and the rest of the page stays mounted.
66
68
  - **Identity.** With an `identityProvider` the generator is never called and the word lists stay out of your bundle. Without one, default names come from a small built-in pool of the same `adjective-noun-tater` shape; `import "@plannotator/ui/utils/identity-tater";` registers the full dictionary, or pass your own `identityGenerator`.
67
69
 
68
- Plannotator's own entries import the eager math and identity modules (`math-eager` and `identity-tater` in both `packages/editor/App.tsx` and `packages/review-editor/App.tsx`), which is what keeps math typeset on the first commit and names minted from the full dictionary; neither app imports `mermaid-eager` since 0.40.0, so the Mermaid runtime rides a lazy chunk on the share portal and is inlined by `inlineDynamicImports` in the single-file builds. `tests/entry-assets.test.ts` fails if an eager math/identity import is dropped or an eager Mermaid import creeps back. See HANDOFF.md "Lazy renderers and eager entries" and "Mermaid 12 (0.40.0)".
70
+ Plannotator's own entries import the eager math and identity modules (`math-eager` and `identity-tater` in both `packages/editor/App.tsx` and `packages/review-editor/App.tsx`), which is what keeps math typeset on the first commit and names minted from the full dictionary; neither app imports `mermaid-eager` since 0.40.0, so the Mermaid runtime rides a lazy chunk, both on the share portal and in the plan and review apps (which since #1618 ship as one packed file whose server answers each chunk at `/assets/*`, instead of inlining them). `tests/entry-assets.test.ts` fails if an eager math/identity import is dropped or an eager Mermaid import creeps back. See HANDOFF.md "Lazy renderers and eager entries" and "Mermaid 12 (0.40.0)".
69
71
 
70
72
  ### Markdown editor extensions + wiki links (`MarkdownEditor` / `InlineMarkdown`)
71
73
 
@@ -237,9 +239,40 @@ exactly. The standalone `StickyHeaderLane` remains supported for Plannotator's
237
239
  hidden-at-rest ghost lane, but its always-visible mode is an overlay and is not
238
240
  the in-flow host integration.
239
241
 
242
+ #### Content under the title (`Viewer.belowTitle`; ui 0.54.0)
243
+
244
+ `belowTitle?: ReactNode` draws a host's node right under the document's
245
+ title, e.g. a references block (pull requests, depends on, links, referenced
246
+ by). Use it instead of inserting into the Viewer's DOM.
247
+
248
+ - **Where.** When the body's first block is a level-1 heading, right after
249
+ that heading (`data-placement="below-title"`). Otherwise (a body that opens
250
+ with a paragraph, a `## heading`, or a `# title` further down) at the top of
251
+ the body, before the first block and after the frontmatter card
252
+ (`data-placement="top"`). The heading itself is unchanged.
253
+ - **Outside the document text.** The wrapper `[data-viewer-below-title]` is
254
+ `annotation-exclude` (no comment starts in it, its text is never searched
255
+ or quoted), `data-pinpoint-ignore` (pinpoint and Vim skip it),
256
+ `data-print-hide`, and carries no `data-block-id`; heading slugs, the TOC,
257
+ exports and share links come from `blocks` and never see it.
258
+ - **Comment positions ignore it.** web-highlighter's `startMeta`/`endMeta`
259
+ count elements by tag across the whole article, so a `<p>` or `<a>` in the
260
+ slot would renumber later comments. The highlighter stores and restores
261
+ positions as if the slot were not there, so the same comment has the same
262
+ metas with or without the slot, and with any slot contents. A selection
263
+ from the title that ends in the slot is cut back to the end of the title.
264
+ Only the wrapper the Viewer draws counts: its attribute carries a per-load
265
+ value, so a `<div data-viewer-below-title>` an author writes as raw HTML is
266
+ never taken for it. Match the wrapper by presence
267
+ (`[data-viewer-below-title]`), not by value.
268
+ - **Absent is absent.** `undefined`, `null` and booleans draw no wrapper; the
269
+ DOM is exactly the one without the prop.
270
+
271
+ See HANDOFF.md § "Viewer `belowTitle` (published in ui 0.54.0)".
272
+
240
273
  ### Diagram engine (`components/diagram`; 0.41.0)
241
274
 
242
- One renderer slot and one canvas render every Mermaid and Graphviz diagram — the fences in `Viewer` (`MermaidBlock`, `GraphvizBlock`), their popout, and whatever a host renders itself. `DiagramViewer` from `@plannotator/ui/components/diagram` takes `kind`, `source`, `theme` (`{ colorTheme, mode, shadowAmount? }`), `comments: DiagramComment[]` and `onCreateComment(anchor, text, additionalTargets)`; pass `onSave(source) => Promise<{ status: 'ok' } | { status: 'stale', currentSource }>` to get the Source pane (left of the canvas on desktop, under it on the phone; `readOnlySource` shows it without Save), and `sourceOpen` to toggle it. Zoom, pan and fit are the canvas's (wheel, drag, `+` `-` `0`, arrow keys); a click opens the composer at the part beneath while a drag pans (4 px threshold, 10 px for a finger), nothing highlights on a plain mouse-over (the ring under the pointer needs the platform modifier held), every edge carries an invisible 14 px hit path in one top layer and a click is resolved by priority over everything under the pointer (node, then edge, then cluster; an edge label is its edge), a click on no part comments on the whole diagram (kind `diagram`), sequence diagrams are addressable (actors, messages, notes, frames), a saved comment paints a ring and a numbered badge from the array order, and every comment re-resolves against each render through the engine's finder (id, then label, then unanchored — reported through `onUnanchoredChange`). The anchor is `DiagramAnchor` from `@plannotator/core/diagram-anchor` (`{ v: 1, family, kind, id | from + to, label, sourceLine }`, document lines), and Plannotator stores it as `Annotation.diagramAnchor`. Runtime slots: `utils/mermaid` (as before) and `utils/graphviz` (new, same shape, `@viz-js/viz` pinned `3.30.0`), each lazy on first use or filled by the host. `DiagramPopout` is the full-size viewer in the `PopoutDialog` chrome. See HANDOFF.md § "Diagram engine (0.41.0)" for every export, the adapter, the sanitizer and the migration notes.
275
+ One renderer slot and one canvas render every Mermaid and Graphviz diagram — the fences in `Viewer` (`MermaidBlock`, `GraphvizBlock`), their popout, and whatever a host renders itself. `DiagramViewer` from `@plannotator/ui/components/diagram` takes `kind`, `source`, `theme` (`{ colorTheme, mode, shadowAmount? }`), `comments: DiagramComment[]` and `onCreateComment(anchor, text, additionalTargets)`; pass `onSave(source) => Promise<{ status: 'ok' } | { status: 'stale', currentSource }>` to get the Source pane (left of the canvas on desktop, under it on the phone; `readOnlySource` shows it without Save), and `sourceOpen` to toggle it. Zoom, pan and fit are the canvas's (wheel, drag, `+` `-` `0`, arrow keys); a click opens the composer at the part beneath while a drag pans (4 px threshold, 10 px for a finger), nothing highlights on a plain mouse-over (the ring under the pointer needs the platform modifier held), every edge carries an invisible 14 px hit path in one top layer and a click is resolved by priority over everything under the pointer (node, then edge, then cluster; an edge label is its edge), a click on no part comments on the whole diagram (kind `diagram`), sequence diagrams are addressable (actors, messages, notes, frames), a saved comment paints a ring and a numbered badge from the array order, and every comment re-resolves against each render through the engine's finder (id, then label, then unanchored — reported through `onUnanchoredChange`). The anchor is `DiagramAnchor` from `@plannotator/core/diagram-anchor` (`{ v: 1, family, kind, id | from + to, label, sourceLine }`, document lines), and Plannotator stores it as `Annotation.diagramAnchor`. Runtime slots: `utils/mermaid` (as before) and `utils/graphviz` (new, same shape, `@viz-js/viz` pinned `3.31.0`), each lazy on first use or filled by the host. `DiagramPopout` is the full-size viewer in the `PopoutDialog` chrome. See HANDOFF.md § "Diagram engine (0.41.0)" for every export, the adapter, the sanitizer and the migration notes.
243
276
 
244
277
  ### Host toolbar seams (0.43.0)
245
278
 
@@ -368,17 +401,37 @@ slot and mentions on the edit box (0.45.0)".
368
401
  their own (the Inbox sorts a video's comments by the moment they are at).
369
402
  Ties keep creation order; replies still thread under their parent. Omit it
370
403
  and the panel sorts by creation exactly as before.
371
- - **`title?`, `titleNote?`, `cardParts?: (annotation) => { quote?, images? }`**
372
- (ui 0.53.0) — the header's title (default "Annotations") and a quiet note
373
- at its right, and per card whether the built-in quote and image row are
374
- drawn (the Inbox's video comments draw the moment and the frame in
375
- `renderCardHeader` instead). Omit them and the panel is as before.
404
+ - **`title?`, `titleNote?`, `cardParts?: (annotation) => { quote?, images?, text? }`**
405
+ (ui 0.53.0; `text` ui 0.54.0) — the header's title (default "Annotations")
406
+ and a quiet note at its right, and per card whether the built-in quote,
407
+ image row and comment text are drawn (the Inbox's video comments draw the
408
+ moment and the frame in `renderCardHeader` instead). `text: false` treats
409
+ the stored text as the host's stand-in, not the person's words: it is not
410
+ drawn and the card's edit box starts empty (the Inbox's drawing with no
411
+ words, whose text "See the marked-up image." is for the agent). Omit them
412
+ and the panel is as before.
413
+ - **`renderCardBody?: (annotation) => ReactNode`** (ui 0.54.0) — the host's
414
+ own body for a plan-annotation card, drawn where the comment text goes,
415
+ inside the card (a click on it selects the card). The Inbox draws a
416
+ drawing with no words as its picture. Omit it and nothing extra is drawn.
376
417
  - **Export: `exportLocation`** (ui 0.53.0) — a string a host sets on the
377
418
  COPY of an annotation it hands the exporters (`exportAnnotations`,
378
419
  `exportLinkedDocAnnotations`): one line printed under the entry's heading
379
420
  (the Inbox's `At 1:42 (102.4 s of 7:41)`). Never stored; an annotation
380
421
  without it exports byte-identically.
381
422
 
423
+ ### Image annotator seams (`components/ImageAnnotator`; ui 0.54.0)
424
+
425
+ - **`escapeCancels?: boolean`** — Escape closes the drawing screen and accepts
426
+ nothing; Enter and the ✓ accept; a click outside closes only an empty
427
+ drawing (strokes are never lost to a stray click); the ✓'s title reads
428
+ "Save (Enter)". The Inbox passes it (the owner's rule, 2026-10-26). Omit it
429
+ and Escape, Enter and a click outside all accept, under "Save (Esc)", as
430
+ plan review has always done.
431
+ - **`hint?: ReactNode`** — the line under the image in place of "Press Esc or
432
+ Enter or click outside to accept" (the Inbox: "Enter or ✓ adds to your
433
+ reply · Esc cancels"). Omit it and the line is as before.
434
+
382
435
  ### WebMCP provider (`@plannotator/ui/webmcp`; 0.32.0)
383
436
 
384
437
  The engine that lets a browser-integrated agent (Chrome/Edge WebMCP, `document.modelContext`) call in-page tools on a document surface. Feature-detected once; a browser without the API sees no registration, no DOM, no network, no timers. Seam: `configurePlannotatorUI({ webmcp: { enabled, namePrefix } })`, default enabled with the `plannotator.` prefix; pass `enabled: false` to keep a host page tool-free, or your own prefix to namespace the tools beside your own. There is deliberately no confirmation seam: the catalog is read-and-comment only (no approve / submit / close tools), and the agent may only edit or remove comments stamped `source: "browser-agent"`.
@@ -387,7 +440,7 @@ The engine that lets a browser-integrated agent (Chrome/Edge WebMCP, `document.m
387
440
  - `useToolset({ id, active, build, deps, hooks })` attaches a named tool set to the document registry; handlers read through refs, so re-renders never re-register, and `active: false` aborts every registration (what Plannotator's Settings opt-out drives).
388
441
  - `AnnotationChangeTracker` / `buildNudges` are pure (no DOM): per-annotation `seq`, tombstones, a per-tab watermark with `since` override, and the nudge vocabulary every response carries.
389
442
  - A host with its own document state builds the same adapter-driven catalog Plannotator uses (`packages/editor/webmcp/documentTools.ts`, `buildDocumentTools(adapter, state, options)`) over its own getters and actions; multi-document pages should register one set whose tools take `path` (the folder-session shape) rather than one set per viewer (duplicate names across sets are skipped with a warning, never replaced).
390
- - Never register tools inside an untrusted iframe: the raw-HTML viewer's `sandbox="allow-scripts"` frame and the live-app frame carry no `allow="tools"`, and that is what keeps a framed page from impersonating the host's tools.
443
+ - Never register tools inside an untrusted iframe: the raw-HTML viewer's `sandbox="allow-scripts allow-modals"` frame and the live-app frame carry no `allow="tools"`, and that is what keeps a framed page from impersonating the host's tools.
391
444
 
392
445
  The one additive data-model change that rides with it: `Annotation.inReplyTo` (threaded replies; the panel indents them under the parent, the export nests them, share links drop them).
393
446
 
@@ -479,12 +532,14 @@ npm install @plannotator/ui @plannotator/core
479
532
  4. Import components: `import { Viewer } from "@plannotator/ui/components/Viewer";`
480
533
  5. Build with a bundler that compiles TS/TSX (Vite + React 19 + Tailwind v4). The packages ship **source**, so your bundler compiles them — set `moduleResolution: "bundler"`, `allowImportingTsExtensions`, `jsx: "react-jsx"`.
481
534
 
535
+ **Upgrade note: the plan look defaults to Clean.** `gridEnabled` (storage key `plannotator-grid-enabled`) now defaults to `false`, and a stored value counts only with the v2 marker `plannotator-plan-look-v2 = 'resolved'`. Plannotator's chooser and its Settings switch write it. On the first settings read, a v1 marker (`plannotator-plan-look-choice-resolved = 'true'`, or the legacy `plannotator-look-feel-announcement-seen = '2'`) next to a stored `'true'` is moved to Clean once (the stored value is rewritten to `'false'` and the v2 key set to `'notice'`); a v1 marker with Clean becomes `'resolved'` silently. Plannotator's apps then show a one-time notice. That includes the share portal's root and demo pages; only a shared session puts it off. A host that renders no chooser (Workspaces) shows nothing, so its users simply open in Clean, and every user with no marker opens in Clean too. To keep stored values instead, call `markLookAndFeelChoiceResolved()` from `@plannotator/ui/utils/lookAndFeelAnnouncement` before the first settings read, or seed `plannotator-plan-look-v2 = 'resolved'` in your `storageBackend`. A host that writes `gridEnabled` from its own UI should call `markLookAndFeelChoiceResolved()` with it, or the value will not survive a reload.
536
+
482
537
  ## Packages & publishing
483
538
 
484
539
  - `@plannotator/core` — pure utils + types, zero deps, browser-safe (CI enforces no `node:` imports). Published.
485
540
  - `@plannotator/ui` — React components/hooks + theme + `configure()`. Depends on an exact published `@plannotator/core` version. Published.
486
541
  - `@plannotator/shared`, `@plannotator/ai` — stay private to the monorepo; `shared` re-exports `core`'s modules via shims so Plannotator's internals are untouched.
487
- - Currently `@plannotator/ui` 0.48.0 depends exactly on `@plannotator/core` 0.25.8. `core` is bumped only when something under `packages/core` changes, so `ui` can advance alone. Keep the published core version exact in `packages/ui/package.json`; do not use a `workspace:` protocol there, because a directly published manifest must remain installable outside this monorepo. Bun still links the matching local workspace during development. When both packages change, publish `core` first, then build and publish the UI tarball. See HANDOFF.md "Publishing & versioning" for the verification command.
542
+ - Currently `@plannotator/ui` 0.54.0 depends exactly on `@plannotator/core` 0.25.14. `core` is bumped only when something under `packages/core` changes, so `ui` can advance alone. Keep the published core version exact in `packages/ui/package.json`; do not use a `workspace:` protocol there, because a directly published manifest must remain installable outside this monorepo. Bun still links the matching local workspace during development. When both packages change, publish `core` first, then build and publish the UI tarball. `bun run check:sibling-deps @plannotator/ui` (from the repo root; it reads npm) fails while the pinned core isn't on npm. See HANDOFF.md "Publishing & versioning" for the verification command.
488
543
 
489
544
  ## The one rule
490
545
 
@@ -193,7 +193,7 @@ export function AgentToolAnnouncementDialog({
193
193
  </p>
194
194
  </div>
195
195
  <p className="mt-3 text-pretty text-xs leading-relaxed text-muted-foreground" data-agent-tool-cost>
196
- Adds about 780 tokens to each request.{save.kind === 'saved' ? '' : ` ${startsLine(host)}`}
196
+ Adds about 800 tokens to each request.{save.kind === 'saved' ? '' : ` ${startsLine(host)}`}
197
197
  </p>
198
198
 
199
199
  <div className="mt-5 flex flex-col gap-3">
@@ -423,7 +423,7 @@ function JobCard({
423
423
  e.stopPropagation();
424
424
  onKill();
425
425
  }}
426
- className="absolute top-1.5 right-1.5 rounded p-1 text-muted-foreground opacity-0 transition-all hover:bg-destructive/10 hover:text-destructive group-hover:opacity-100"
426
+ className="absolute top-1.5 right-1.5 rounded p-1 text-muted-foreground opacity-0 transition-all hover:bg-destructive/10 hover:text-destructive group-hover:opacity-100 group-focus-within:opacity-100 [@media(hover:none)]:opacity-100"
427
427
  title="Kill agent"
428
428
  >
429
429
  <X size={12} />
@@ -1,8 +1,9 @@
1
- import React, { useState, useRef, useEffect, useId } from 'react';
1
+ import React, { createContext, useContext, useMemo, useState, useRef, useEffect, useId } from 'react';
2
2
  import { AnnotationType, type Annotation, type Block, type CodeAnnotation, type EditorAnnotation } from '../types';
3
3
  import type { MentionSource } from '../utils/mentions';
4
4
  import { useMentionAutocomplete } from '../hooks/useMentionAutocomplete';
5
5
  import { MentionAutocompleteMenu, mentionActiveOptionId } from './MentionAutocomplete';
6
+ import { buildSectionRefIndex, parseSectionRefs } from '../utils/sectionRefs';
6
7
  import { isCurrentUser } from '../utils/identity';
7
8
  import { ImageThumbnail } from './ImageThumbnail';
8
9
  import { EditorAnnotationCard } from './EditorAnnotationCard';
@@ -16,6 +17,15 @@ import type { AnnotationScope, AnnotationDocumentGroup } from '../utils/annotati
16
17
  import type { QuestionPanelRow } from '../utils/questionAnswers';
17
18
  import { QuestionsPanelSection } from './QuestionsPanelSection';
18
19
  import { HostTitle } from './HostTitle';
20
+ import { isCommentSubmitKey } from '../utils/commentSubmitKey';
21
+ import { useConfigValue } from '../config/useConfig';
22
+ import { imageRegionNumbers, RegionCardInfo, RegionNumber, regionOf } from './ImageRegions';
23
+
24
+ /** What a region card (#2100) needs from its panel: each region's number on
25
+ * its image, and how to load the image for the crop thumbnail. */
26
+ const RegionCardContext = createContext<{ numbers: ReadonlyMap<string, number>; thumbSrc?: (src: string) => string | null }>({
27
+ numbers: new Map(),
28
+ });
19
29
 
20
30
  // Card type-word colors. Deletion uses `destructive` (reliably red on every
21
31
  // theme, matching the in-document .deletion highlight). Comment uses the
@@ -120,6 +130,8 @@ interface PanelProps {
120
130
  editorAnnotations?: EditorAnnotation[];
121
131
  onDeleteEditorAnnotation?: (id: string) => void;
122
132
  onClose?: () => void;
133
+ /** Navigate the document to a heading anchor, for `#Heading` references in comment text. */
134
+ onNavigateAnchor?: (hash: string) => void;
123
135
  /** Copy the full feedback payload. May resolve a success boolean; resolving
124
136
  * `false` suppresses the "Copied" flash. A void resolution (existing hosts)
125
137
  * is treated as success, preserving the original behavior. */
@@ -213,13 +225,30 @@ interface PanelProps {
213
225
  * the frame in `renderCardHeader`) turns them off per card. Absent: both
214
226
  * drawn, exactly as before.
215
227
  */
216
- cardParts?: (annotation: Annotation) => { quote?: boolean; images?: boolean };
228
+ cardParts?: (annotation: Annotation) => { quote?: boolean; images?: boolean; text?: boolean };
229
+ /**
230
+ * Optional: the host's own body for a plan-annotation card, drawn where the
231
+ * comment text goes, inside the card (a click on it selects the card). The
232
+ * Inbox draws a drawing that has no words as its picture, with `cardParts`
233
+ * `text: false` (its stored text is a stand-in for the agent, not the
234
+ * person's words: not drawn, and the edit box starts empty). Absent:
235
+ * nothing extra, exactly as before.
236
+ */
237
+ renderCardBody?: (annotation: Annotation) => React.ReactNode;
238
+ /**
239
+ * Comments on part of an image (`imageRegion`, #2100) draw a crop of their
240
+ * box in place of the quote; this turns an image's src as written into a
241
+ * URL the browser can load (null: no crop, the file and size still show).
242
+ * Absent: no crop.
243
+ */
244
+ imageRegionSrc?: (src: string) => string | null;
217
245
  }
218
246
 
219
247
  export const AnnotationPanel: React.FC<PanelProps> = ({
220
248
  emptyHint = 'Select text to annotate',
221
249
  isOpen,
222
250
  annotations,
251
+ blocks,
223
252
  onSelect,
224
253
  onDelete,
225
254
  onEdit,
@@ -256,7 +285,19 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
256
285
  title = 'Annotations',
257
286
  titleNote,
258
287
  cardParts,
288
+ renderCardBody,
289
+ imageRegionSrc,
290
+ onNavigateAnchor,
259
291
  }) => {
292
+ // `#Heading` links in comment text; nothing is parsed unless the host can navigate.
293
+ const sectionRefs = useMemo(
294
+ () => (onNavigateAnchor ? buildSectionRefIndex(blocks) : undefined),
295
+ [blocks, onNavigateAnchor],
296
+ );
297
+ const regionCards = useMemo(
298
+ () => ({ numbers: imageRegionNumbers(annotations), thumbSrc: imageRegionSrc }),
299
+ [annotations, imageRegionSrc],
300
+ );
260
301
  const isMobile = useIsMobile();
261
302
  const embedded = presentation === 'embedded';
262
303
  const mobilePanel = isMobile && !embedded;
@@ -517,8 +558,11 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
517
558
  footer={group.isCurrent ? renderCardFooter?.(annotation) : undefined}
518
559
  header={group.isCurrent ? renderCardHeader?.(annotation) : undefined}
519
560
  parts={group.isCurrent ? cardParts?.(annotation) : undefined}
561
+ body={group.isCurrent ? renderCardBody?.(annotation) : undefined}
520
562
  mentionSource={mentionSource}
521
563
  unanchored={group.isCurrent ? (unanchoredIds?.has(annotation.id) ?? false) : false}
564
+ sectionRefs={group.isCurrent ? sectionRefs : undefined}
565
+ onNavigateAnchor={onNavigateAnchor}
522
566
  />
523
567
  );
524
568
  return isReply ? (
@@ -623,6 +667,8 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
623
667
  >
624
668
  <AnnotationCard
625
669
  annotation={entry.annotation}
670
+ sectionRefs={sectionRefs}
671
+ onNavigateAnchor={onNavigateAnchor}
626
672
  isSelected={selectedId === entry.annotation.id}
627
673
  isMe={isCurrentUser(entry.annotation.author)}
628
674
  onSelect={() => onSelect(entry.annotation.id)}
@@ -632,6 +678,7 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
632
678
  footer={renderCardFooter?.(entry.annotation)}
633
679
  header={renderCardHeader?.(entry.annotation)}
634
680
  parts={cardParts?.(entry.annotation)}
681
+ body={renderCardBody?.(entry.annotation)}
635
682
  mentionSource={mentionSource}
636
683
  unanchored={unanchoredIds?.has(entry.annotation.id) ?? false}
637
684
  />
@@ -640,6 +687,8 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
640
687
  <AnnotationCard
641
688
  key={entry.annotation.id}
642
689
  annotation={entry.annotation}
690
+ sectionRefs={sectionRefs}
691
+ onNavigateAnchor={onNavigateAnchor}
643
692
  isSelected={selectedId === entry.annotation.id}
644
693
  isMe={isCurrentUser(entry.annotation.author)}
645
694
  onSelect={() => onSelect(entry.annotation.id)}
@@ -649,6 +698,7 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
649
698
  footer={renderCardFooter?.(entry.annotation)}
650
699
  header={renderCardHeader?.(entry.annotation)}
651
700
  parts={cardParts?.(entry.annotation)}
701
+ body={renderCardBody?.(entry.annotation)}
652
702
  mentionSource={mentionSource}
653
703
  unanchored={unanchoredIds?.has(entry.annotation.id) ?? false}
654
704
  />
@@ -691,7 +741,7 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
691
741
  </OverlayScrollArea>
692
742
 
693
743
  {/* Quick Actions Footer */}
694
- {headerCount > 0 && (
744
+ {headerCount > 0 && (onQuickCopy || (sharingEnabled && onShare)) && (
695
745
  <div className="border-t border-border/50 px-3 py-2 flex gap-1.5">
696
746
  {onQuickCopy && (
697
747
  <button
@@ -745,12 +795,12 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
745
795
  className="fixed inset-0 z-[59] bg-background/60 backdrop-blur-sm"
746
796
  onClick={onClose}
747
797
  />
748
- {panel}
798
+ <RegionCardContext.Provider value={regionCards}>{panel}</RegionCardContext.Provider>
749
799
  </>
750
800
  );
751
801
  }
752
802
 
753
- return panel;
803
+ return <RegionCardContext.Provider value={regionCards}>{panel}</RegionCardContext.Provider>;
754
804
  };
755
805
 
756
806
  function formatTimestamp(ts: number): string {
@@ -899,6 +949,44 @@ const DirectEditsCard: React.FC<{
899
949
  );
900
950
  };
901
951
 
952
+ /** Comment text, with each `#Heading text` as a link that scrolls to that heading. */
953
+ const AnnotationText: React.FC<{
954
+ text: string;
955
+ className: string;
956
+ sectionRefs?: Map<string, string>;
957
+ onNavigateAnchor?: (hash: string) => void;
958
+ }> = ({ text, className, sectionRefs, onNavigateAnchor }) => {
959
+ const parts = React.useMemo(
960
+ () => (sectionRefs && onNavigateAnchor ? parseSectionRefs(text, sectionRefs) : [text]),
961
+ [text, sectionRefs, onNavigateAnchor],
962
+ );
963
+
964
+ return (
965
+ <p className={className}>
966
+ {parts.map((part, i) =>
967
+ typeof part === 'string' ? (
968
+ part
969
+ ) : (
970
+ <a
971
+ key={i}
972
+ href={`#${part.anchor}`}
973
+ className="rounded-sm font-medium text-primary underline-offset-2 hover:underline focus-visible:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
974
+ onClick={(e) => {
975
+ // The host scrolls; the card's own onSelect would scroll to the
976
+ // annotation instead, and the URL hash stays as it was.
977
+ e.preventDefault();
978
+ e.stopPropagation();
979
+ onNavigateAnchor!(`#${part.anchor}`);
980
+ }}
981
+ >
982
+ {part.label}
983
+ </a>
984
+ ),
985
+ )}
986
+ </p>
987
+ );
988
+ };
989
+
902
990
  /**
903
991
  * The plan-annotation card's edit box.
904
992
  *
@@ -929,6 +1017,7 @@ const AnnotationEditComposer: React.FC<{
929
1017
  const mention = useMentionAutocomplete({ text: value, setText: onChange, textareaRef, source: mentionSource });
930
1018
  const listboxId = `annotation-card-mentions-${useId().replace(/:/g, '')}`;
931
1019
  const menuOpen = mention.menu !== null;
1020
+ const enterSavesComment = useConfigValue('enterSavesComment');
932
1021
 
933
1022
  // Focus-on-open, unchanged: this component mounts exactly when editing starts.
934
1023
  useEffect(() => {
@@ -943,7 +1032,7 @@ const AnnotationEditComposer: React.FC<{
943
1032
  // (and only a second Escape cancels the edit), and an arrowed-to row
944
1033
  // takes Enter. Mod+Enter is never consumed by the menu.
945
1034
  if (mention.onKeyDown(e)) return;
946
- if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && !e.nativeEvent.isComposing) {
1035
+ if (isCommentSubmitKey(e, enterSavesComment)) {
947
1036
  e.preventDefault();
948
1037
  onSave(mention.mentionIds);
949
1038
  } else if (e.key === 'Escape') {
@@ -996,21 +1085,32 @@ const AnnotationCard: React.FC<{
996
1085
  /** The annotation has no live location in the document (host-reported). */
997
1086
  unanchored?: boolean;
998
1087
  /** Built-in parts to draw (see `cardParts`). */
999
- parts?: { quote?: boolean; images?: boolean };
1000
- }> = ({ annotation, isSelected, isMe, onSelect, onDelete, onEdit, readOnly = false, footer, header, mentionSource, unanchored = false, parts }) => {
1088
+ parts?: { quote?: boolean; images?: boolean; text?: boolean };
1089
+ /** Host body, drawn where the comment text goes (see `renderCardBody`). */
1090
+ body?: React.ReactNode;
1091
+ sectionRefs?: Map<string, string>;
1092
+ onNavigateAnchor?: (hash: string) => void;
1093
+ }> = ({ annotation, isSelected, isMe, onSelect, onDelete, onEdit, readOnly = false, footer, header, mentionSource, unanchored = false, parts, body, sectionRefs, onNavigateAnchor }) => {
1094
+ // `text: false`: the stored text is the host's stand-in, never the person's words; the edit box starts empty.
1095
+ const shownText = parts?.text === false ? '' : annotation.text || '';
1001
1096
  const [isEditing, setIsEditing] = useState(false);
1002
- const [editText, setEditText] = useState(annotation.text || '');
1097
+ const [editText, setEditText] = useState(shownText);
1098
+ // A comment on part of an image (#2100): numbered like its box, with the
1099
+ // crop, file, size and line in place of the quote.
1100
+ const regionCards = useContext(RegionCardContext);
1101
+ const region = regionOf(annotation);
1102
+ const regionNumber = region ? regionCards.numbers.get(annotation.id) : undefined;
1003
1103
 
1004
1104
  // Update editText when annotation.text changes
1005
1105
  useEffect(() => {
1006
1106
  if (!isEditing) {
1007
- setEditText(annotation.text || '');
1107
+ setEditText(shownText);
1008
1108
  }
1009
- }, [annotation.text, isEditing]);
1109
+ }, [shownText, isEditing]);
1010
1110
 
1011
1111
  const handleStartEdit = (e: React.MouseEvent) => {
1012
1112
  e.stopPropagation();
1013
- setEditText(annotation.text || '');
1113
+ setEditText(shownText);
1014
1114
  setIsEditing(true);
1015
1115
  };
1016
1116
 
@@ -1027,7 +1127,7 @@ const AnnotationCard: React.FC<{
1027
1127
  };
1028
1128
 
1029
1129
  const handleCancelEdit = () => {
1030
- setEditText(annotation.text || '');
1130
+ setEditText(shownText);
1031
1131
  setIsEditing(false);
1032
1132
  };
1033
1133
 
@@ -1066,6 +1166,12 @@ const AnnotationCard: React.FC<{
1066
1166
  {/* Header: type word + author · time + actions */}
1067
1167
  <div className="mb-1.5 flex items-center gap-1.5">
1068
1168
  <span className={cn('text-[11px] font-medium', typeColor)}>{typeLabel}</span>
1169
+ {region && regionNumber !== undefined && <RegionNumber n={regionNumber} selected={isSelected} />}
1170
+ {region && (
1171
+ <span data-annotation-region="true" className="text-[9px] px-1.5 py-0.5 rounded font-medium bg-muted text-muted-foreground">
1172
+ Region
1173
+ </span>
1174
+ )}
1069
1175
  {annotation.diffContext && (
1070
1176
  <span className="text-[9px] px-1.5 py-0.5 rounded font-medium bg-muted text-muted-foreground">
1071
1177
  diff
@@ -1100,7 +1206,7 @@ const AnnotationCard: React.FC<{
1100
1206
  </div>
1101
1207
  )}
1102
1208
  {!readOnly && (
1103
- <div className="ml-auto flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 [@media(hover:none)]:opacity-100">
1209
+ <div className="ml-auto flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100 [@media(hover:none)]:opacity-100">
1104
1210
  {onEdit && annotation.type !== AnnotationType.DELETION && !isEditing && (
1105
1211
  <HostTitle title="Edit annotation" name>
1106
1212
  <button
@@ -1130,14 +1236,21 @@ const AnnotationCard: React.FC<{
1130
1236
  isEditing ? (
1131
1237
  editComposer
1132
1238
  ) : (
1133
- <p className="whitespace-pre-wrap [overflow-wrap:anywhere] text-[13px] leading-relaxed text-foreground/90">
1134
- {annotation.text}
1135
- </p>
1239
+ <AnnotationText
1240
+ text={annotation.text || ''}
1241
+ className="whitespace-pre-wrap [overflow-wrap:anywhere] text-[13px] leading-relaxed text-foreground/90"
1242
+ sectionRefs={sectionRefs}
1243
+ onNavigateAnchor={onNavigateAnchor}
1244
+ />
1136
1245
  )
1137
1246
  ) : (
1138
1247
  <>
1248
+ {/* A region shows its crop, file, size and line instead of a quote. */}
1249
+ {region && parts?.quote !== false && (
1250
+ <RegionCardInfo region={region} thumbSrc={regionCards.thumbSrc?.(region.src) ?? null} />
1251
+ )}
1139
1252
  {/* Quote — the annotated text */}
1140
- {parts?.quote !== false && (
1253
+ {!region && parts?.quote !== false && (
1141
1254
  <p className="mb-1.5 line-clamp-2 whitespace-pre-wrap [overflow-wrap:anywhere] font-mono text-[11px] leading-relaxed text-muted-foreground/80">
1142
1255
  "{annotation.originalText}"
1143
1256
  </p>
@@ -1148,13 +1261,17 @@ const AnnotationCard: React.FC<{
1148
1261
  isEditing ? (
1149
1262
  editComposer
1150
1263
  ) : (
1151
- annotation.text && (
1152
- <p className="whitespace-pre-wrap [overflow-wrap:anywhere] text-[13px] leading-relaxed text-foreground/90">
1153
- {annotation.text}
1154
- </p>
1264
+ shownText && (
1265
+ <AnnotationText
1266
+ text={shownText}
1267
+ className="whitespace-pre-wrap [overflow-wrap:anywhere] text-[13px] leading-relaxed text-foreground/90"
1268
+ sectionRefs={sectionRefs}
1269
+ onNavigateAnchor={onNavigateAnchor}
1270
+ />
1155
1271
  )
1156
1272
  )
1157
1273
  )}
1274
+ {body != null && body !== false && body}
1158
1275
  </>
1159
1276
  )}
1160
1277
 
@@ -1203,6 +1320,7 @@ const CodeAnnotationCard: React.FC<{
1203
1320
  }> = ({ annotation, isSelected, isMe, onSelect, onDelete, onEdit, readOnly = false }) => {
1204
1321
  const [isEditing, setIsEditing] = useState(false);
1205
1322
  const [editText, setEditText] = useState(annotation.text || '');
1323
+ const enterSavesComment = useConfigValue('enterSavesComment');
1206
1324
  const textareaRef = useRef<HTMLTextAreaElement>(null);
1207
1325
 
1208
1326
  useEffect(() => {
@@ -1253,7 +1371,7 @@ const CodeAnnotationCard: React.FC<{
1253
1371
  <span className="text-[11px] font-medium text-primary">Code</span>
1254
1372
  <CardByline author={annotation.author} isMe={isMe} timestamp={annotation.createdAt} />
1255
1373
  {!readOnly && (
1256
- <div className="ml-auto flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 [@media(hover:none)]:opacity-100">
1374
+ <div className="ml-auto flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100 [@media(hover:none)]:opacity-100">
1257
1375
  {onEdit && !isEditing && (
1258
1376
  <HostTitle title="Edit annotation" name>
1259
1377
  <button
@@ -1299,7 +1417,7 @@ const CodeAnnotationCard: React.FC<{
1299
1417
  value={editText}
1300
1418
  onChange={(e) => setEditText(e.target.value)}
1301
1419
  onKeyDown={(e) => {
1302
- if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && !e.nativeEvent.isComposing) {
1420
+ if (isCommentSubmitKey(e, enterSavesComment)) {
1303
1421
  e.preventDefault();
1304
1422
  handleSaveEdit();
1305
1423
  } else if (e.key === 'Escape') {
@@ -61,7 +61,7 @@ export const AnnotationSidebar: React.FC<SidebarProps> = ({
61
61
  </span>
62
62
  <button
63
63
  onClick={(e) => { e.stopPropagation(); onDelete(ann.id); }}
64
- className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-destructive transition-all p-1 rounded hover:bg-destructive/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
64
+ className="opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 [@media(hover:none)]:opacity-100 text-muted-foreground hover:text-destructive transition-all p-1 rounded hover:bg-destructive/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
65
65
  title="Remove Annotation"
66
66
  >
67
67
  <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>