@plannotator/ui 0.53.1 → 0.55.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 (149) hide show
  1. package/HANDOFF.md +274 -14
  2. package/README.md +114 -11
  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 +22 -5
  12. package/components/CodeFilePopout.tsx +139 -4
  13. package/components/CodeSnippetPreview.tsx +174 -0
  14. package/components/CommentPopover.tsx +44 -8
  15. package/components/DecisionControl.tsx +31 -5
  16. package/components/DeferredMarkdownContentsRail.tsx +38 -0
  17. package/components/DiagramBlock.tsx +17 -12
  18. package/components/DiffFileTree.tsx +7 -6
  19. package/components/DocumentContents.tsx +74 -0
  20. package/components/EditorAnnotationCard.tsx +1 -1
  21. package/components/ExportModal.tsx +77 -7
  22. package/components/HostTitle.tsx +0 -4
  23. package/components/ImageAnnotator/Toolbar.tsx +25 -1
  24. package/components/ImageAnnotator/index.tsx +24 -4
  25. package/components/ImageLightbox.tsx +722 -126
  26. package/components/ImagePeek.tsx +38 -4
  27. package/components/ImageRegions.tsx +369 -0
  28. package/components/ImageThumbnail.tsx +1 -1
  29. package/components/ImageZoomStage.tsx +796 -0
  30. package/components/InboxAnnouncementDialog.tsx +202 -0
  31. package/components/InlineMarkdown.tsx +167 -174
  32. package/components/KeyboardShortcuts.tsx +23 -0
  33. package/components/LazyBlockBoundary.tsx +100 -0
  34. package/components/ListMarker.tsx +3 -3
  35. package/components/LookAndFeelAnnouncementDialog.tsx +12 -2
  36. package/components/MarkdownContentsRail.tsx +777 -0
  37. package/components/MenuVersionSection.tsx +31 -9
  38. package/components/PlanDecisionMenuExtras.tsx +129 -0
  39. package/components/PlanHeaderMenu.tsx +15 -1
  40. package/components/ProviderIcons.tsx +8 -0
  41. package/components/RefHoverCard.tsx +913 -0
  42. package/components/SendFailureToast.tsx +126 -0
  43. package/components/Settings.tsx +617 -126
  44. package/components/TableOfContents.tsx +20 -6
  45. package/components/TerminalToolsAnnouncementDialog.tsx +1 -1
  46. package/components/ThemeProvider.tsx +14 -1
  47. package/components/ThemeTab.tsx +16 -24
  48. package/components/Tooltip.tsx +49 -18
  49. package/components/Viewer.tsx +288 -43
  50. package/components/ai/DocumentAIChatPanel.tsx +29 -3
  51. package/components/ai/NewChatButton.tsx +74 -0
  52. package/components/blocks/CodeBlock.tsx +1 -1
  53. package/components/blocks/HtmlBlock.tsx +7 -1
  54. package/components/blocks/QuestionBlock.tsx +532 -62
  55. package/components/diagram/DiagramComposer.tsx +5 -1
  56. package/components/diagram/DiagramPending.tsx +12 -2
  57. package/components/diagram/DiagramViewer.tsx +19 -3
  58. package/components/html-embed/HtmlEmbedBlock.tsx +374 -0
  59. package/components/html-embed/SvgFenceBlock.tsx +46 -0
  60. package/components/html-embed/index.ts +3 -0
  61. package/components/html-embed/languages.ts +15 -0
  62. package/components/html-embed/loadGate.ts +107 -0
  63. package/components/html-viewer/HtmlViewer.tsx +82 -6
  64. package/components/html-viewer/bridge-script.asset.js +173 -1
  65. package/components/html-viewer/bridge-script.ts +173 -1
  66. package/components/html-viewer/useHtmlAnnotation.ts +125 -0
  67. package/components/imageZoom.ts +276 -0
  68. package/components/plan-diff/PlanCleanDiffView.tsx +1 -1
  69. package/components/plan-diff/PlanDiffViewer.tsx +13 -2
  70. package/components/plan-diff/PlanRawDiffView.tsx +1 -1
  71. package/components/sidebar/FileBrowser.tsx +16 -2
  72. package/components/sidebar/SidebarTabs.tsx +3 -0
  73. package/config/index.ts +3 -0
  74. package/config/reviewView.ts +41 -1
  75. package/config/settings.ts +155 -4
  76. package/configure.ts +28 -0
  77. package/contents-rail.css +337 -0
  78. package/hooks/pfm/useCodeFilePopout.ts +7 -1
  79. package/hooks/useAIChat.ts +72 -25
  80. package/hooks/useAnnotationDraft.ts +71 -6
  81. package/hooks/useAnnotationHighlighter.ts +57 -3
  82. package/hooks/useCodeAnnotationDraft.ts +113 -17
  83. package/hooks/useCookieHostSetting.ts +96 -0
  84. package/hooks/useDocumentFont.ts +17 -0
  85. package/hooks/useDocumentTextSize.ts +20 -0
  86. package/hooks/useFileBrowser.ts +10 -1
  87. package/hooks/useHeadingScrollSpy.ts +77 -0
  88. package/hooks/useHoverPreview.ts +94 -0
  89. package/hooks/useLinkedDoc.ts +226 -13
  90. package/hooks/usePackageChangesSetting.ts +90 -0
  91. package/hooks/usePlanLookDialog.ts +67 -0
  92. package/hooks/usePrefersReducedMotion.ts +23 -0
  93. package/hooks/useSharing.ts +11 -7
  94. package/hooks/useSwipeStep.ts +597 -0
  95. package/hooks/useThemePaletteHalves.ts +37 -0
  96. package/hooks/useUpdateCheck.ts +22 -4
  97. package/hooks/useUpdateToast.ts +1 -1
  98. package/package.json +26 -23
  99. package/shortcuts/code-review/annotationToolbar.shortcuts.ts +1 -0
  100. package/shortcuts/code-review/explorer.shortcuts.ts +82 -0
  101. package/shortcuts/core.ts +63 -4
  102. package/shortcuts/index.ts +1 -0
  103. package/shortcuts/plan-review/annotationPanel.shortcuts.ts +1 -1
  104. package/shortcuts/plan-review/commentPopover.shortcuts.ts +1 -0
  105. package/shortcuts/runtime.ts +4 -3
  106. package/styles.css +1 -1
  107. package/theme.css +179 -1
  108. package/themes/plannotator-graphite.css +66 -0
  109. package/types.ts +26 -9
  110. package/utils/agentSwitch.ts +41 -20
  111. package/utils/agentToolSetting.ts +1 -1
  112. package/utils/aiChatFormat.ts +23 -7
  113. package/utils/annotationHelpers.ts +32 -4
  114. package/utils/annotationOwnsHighlight.ts +4 -2
  115. package/utils/asciidocParser.ts +570 -0
  116. package/utils/autoUpdateNotice.ts +24 -0
  117. package/utils/belowTitleSlot.ts +174 -0
  118. package/utils/blockParse.ts +45 -0
  119. package/utils/codeLineScroll.ts +100 -0
  120. package/utils/commentSubmitKey.ts +48 -0
  121. package/utils/cssFontFamily.ts +65 -0
  122. package/utils/decisionSpec.ts +190 -2
  123. package/utils/defaultNotesApp.ts +1 -1
  124. package/utils/diffFonts.ts +4 -1
  125. package/utils/documentFont.ts +68 -0
  126. package/utils/documentTextSize.ts +54 -0
  127. package/utils/fontDetect.ts +63 -0
  128. package/utils/headingScrollSpy.ts +92 -0
  129. package/utils/inboxAnnouncement.ts +78 -0
  130. package/utils/lookAndFeelAnnouncement.ts +82 -5
  131. package/utils/markdownExtensions.ts +2 -2
  132. package/utils/mermaid.ts +20 -3
  133. package/utils/mermaidTheme.ts +3 -51
  134. package/utils/notion.ts +58 -0
  135. package/utils/parser.ts +28 -1
  136. package/utils/platform.ts +46 -0
  137. package/utils/refCard.ts +82 -0
  138. package/utils/richClipboard.ts +380 -0
  139. package/utils/richClipboardLists.ts +57 -0
  140. package/utils/richCopyPolicy.ts +22 -0
  141. package/utils/scrollAnchor.ts +82 -0
  142. package/utils/sectionRefs.ts +123 -0
  143. package/utils/selectionBoundary.ts +40 -0
  144. package/utils/sendFailure.ts +85 -0
  145. package/utils/sharing.ts +5 -2
  146. package/utils/themeRegistry.ts +10 -0
  147. package/utils/themeTokens.ts +74 -0
  148. package/utils/updateNotice.ts +30 -8
  149. package/utils/vscodePanel.ts +9 -0
@@ -51,6 +51,14 @@ export interface ImagePeekProps {
51
51
  /** An id for the panel, for the opener's `aria-controls`. */
52
52
  id?: string;
53
53
  className?: string;
54
+ /**
55
+ * The sheet only: controls drawn above the grid, outside it (the Inbox's
56
+ * Previous / Next). Shift+Tab from the grid moves into them; Escape in them,
57
+ * or Shift+Tab out of the first, closes the sheet (`onClose('key')`), so
58
+ * focus never leaves it for the page behind. Absent (the default): the
59
+ * sheet is unchanged.
60
+ */
61
+ sheetHeader?: React.ReactNode;
54
62
  }
55
63
 
56
64
  const PaperclipIcon = () => (
@@ -94,8 +102,9 @@ export interface ImagePeekHandle {
94
102
  handleKey: (event: { key: string; isComposing?: boolean }) => boolean;
95
103
  }
96
104
 
97
- export const ImagePeek = forwardRef<ImagePeekHandle, ImagePeekProps>(function ImagePeek({ rows, current, onPick, onClose, variant = 'popover', label, id, className = '' }, ref) {
105
+ export const ImagePeek = forwardRef<ImagePeekHandle, ImagePeekProps>(function ImagePeek({ rows, current, onPick, onClose, variant = 'popover', label, id, className = '', sheetHeader }, ref) {
98
106
  const sheet = variant === 'sheet';
107
+ const header = sheet && sheetHeader ? sheetHeader : null;
99
108
  const gridRef = useRef<HTMLDivElement>(null);
100
109
  const autoId = useId();
101
110
  const labelId = `${(id ?? `pn-peek-${autoId}`).replace(/:/g, '')}-label`;
@@ -162,6 +171,8 @@ export const ImagePeek = forwardRef<ImagePeekHandle, ImagePeekProps>(function Im
162
171
  useImperativeHandle(ref, () => ({ handleKey: (event) => handleKeyRef.current(event) }), []);
163
172
 
164
173
  const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
174
+ // With a header above the grid, Shift+Tab moves up into it instead of closing the sheet.
175
+ if (header && event.key === 'Tab' && event.shiftKey) return;
165
176
  if (!handleKey({ key: event.key, isComposing: event.nativeEvent.isComposing })) return;
166
177
  event.preventDefault();
167
178
  event.stopPropagation();
@@ -177,14 +188,16 @@ export const ImagePeek = forwardRef<ImagePeekHandle, ImagePeekProps>(function Im
177
188
  data-image-peek={variant}
178
189
  className={
179
190
  sheet
180
- ? `pointer-events-auto max-h-[78vh] w-full overflow-y-auto rounded-t-2xl border-t border-border bg-popover px-3 pb-[calc(16px+env(safe-area-inset-bottom))] pt-2 text-popover-foreground shadow-2xl ${className}`
191
+ ? header
192
+ ? `pointer-events-auto max-h-[70vh] w-full overflow-y-auto bg-popover px-3 pb-[calc(16px+env(safe-area-inset-bottom))] text-popover-foreground ${className}`
193
+ : `pointer-events-auto max-h-[78vh] w-full overflow-y-auto rounded-t-[12px] border-t border-border bg-popover px-3 pb-[calc(16px+env(safe-area-inset-bottom))] pt-2 text-popover-foreground shadow-2xl ${className}`
181
194
  : `max-h-[min(460px,62vh)] w-[min(600px,calc(100vw-24px))] overflow-y-auto rounded-xl border border-border bg-popover p-2 text-popover-foreground shadow-[0_14px_36px_rgba(0,0,0,0.28)] ${className}`
182
195
  }
183
196
  >
184
197
  <span id={labelId} className="sr-only">
185
198
  {label}
186
199
  </span>
187
- {sheet && <div className="mx-auto mb-2 h-1.5 w-10 rounded-full bg-muted-foreground/30" aria-hidden="true" />}
200
+ {sheet && !header && <div className="mx-auto mb-2 h-1.5 w-10 rounded-full bg-muted-foreground/30" aria-hidden="true" />}
188
201
  {rows.map((row, r) => (
189
202
  <div
190
203
  key={row.key}
@@ -249,7 +262,28 @@ export const ImagePeek = forwardRef<ImagePeekHandle, ImagePeekProps>(function Im
249
262
  return (
250
263
  <div className="fixed inset-0 z-[220] flex flex-col justify-end" data-image-peek-sheet="">
251
264
  <div className="absolute inset-0 bg-black/40" aria-hidden="true" onClick={() => onClose('outside')} data-peek-backdrop="" />
252
- <div className="relative">{panel}</div>
265
+ <div className={header ? 'relative overflow-hidden rounded-t-[12px] border-t border-border bg-popover shadow-2xl' : 'relative'}>
266
+ {header && (
267
+ <div
268
+ className="px-3 pt-2 text-popover-foreground"
269
+ onKeyDown={(event) => {
270
+ // Escape closes the sheet; so does Shift+Tab out of its first control (focus leaving the sheet
271
+ // closes it, as Tab out of the grid does: the host puts focus back on its opener).
272
+ const first = event.currentTarget.querySelector<HTMLElement>('button, a[href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
273
+ const leaving = event.key === 'Tab' && event.shiftKey && event.target === first;
274
+ if (event.key !== 'Escape' && !leaving) return;
275
+ event.preventDefault();
276
+ event.stopPropagation();
277
+ onClose('key');
278
+ }}
279
+ data-peek-sheet-header=""
280
+ >
281
+ <div className="mx-auto mb-2 h-1.5 w-10 rounded-full bg-muted-foreground/30" aria-hidden="true" />
282
+ {header}
283
+ </div>
284
+ )}
285
+ {panel}
286
+ </div>
253
287
  </div>
254
288
  );
255
289
  });
@@ -0,0 +1,369 @@
1
+ import React, { useMemo } from 'react';
2
+ import { imageRegionFileName, imageRegionSize, parseImageRegion, type ImageRegion } from '@plannotator/core/image-region';
3
+ import type { Annotation } from '../types';
4
+ import type { NaturalBox, StageRect, ZoomSize, ZoomViewport } from './imageZoom';
5
+ import { naturalBoxToStage } from './imageZoom';
6
+ import { getImageSrc } from './ImageThumbnail';
7
+
8
+ /**
9
+ * The URL a markdown image's src (as written) loads from, the way the
10
+ * document renders it: http(s), data: and blob: as written, anything else
11
+ * through the host's image route relative to `baseDir`. For the crop
12
+ * thumbnails of region cards in the document's annotations panel.
13
+ */
14
+ export function loadableImageSrc(src: string, baseDir?: string): string {
15
+ return /^(https?:\/\/|data:|blob:)/i.test(src) ? src : getImageSrc(src, baseDir);
16
+ }
17
+
18
+ /**
19
+ * The pieces of image region comments (#2100, approval
20
+ * "image-region-comments", variant C): the Move | Region strip, the boxes
21
+ * drawn over the image in the viewer, the crop thumbnail and the card body
22
+ * the annotations panel shows. The lightbox (`ImageLightbox`) composes them.
23
+ */
24
+
25
+ /** The validated region of an annotation, or null (a malformed region is no region). */
26
+ export function regionOf(annotation: Pick<Annotation, 'imageRegion'>): ImageRegion | null {
27
+ return annotation.imageRegion == null ? null : parseImageRegion(annotation.imageRegion);
28
+ }
29
+
30
+ /**
31
+ * Each region comment's number on its image: 1, 2, … in the order they were
32
+ * made, counted per image (the src as written). Replies are not regions.
33
+ */
34
+ export function imageRegionNumbers(annotations: readonly Annotation[]): Map<string, number> {
35
+ const bySrc = new Map<string, Annotation[]>();
36
+ for (const ann of annotations) {
37
+ const region = regionOf(ann);
38
+ if (!region) continue;
39
+ const list = bySrc.get(region.src) ?? [];
40
+ list.push(ann);
41
+ bySrc.set(region.src, list);
42
+ }
43
+ const out = new Map<string, number>();
44
+ for (const list of bySrc.values()) {
45
+ [...list].sort((a, b) => a.createdA - b.createdA).forEach((ann, i) => out.set(ann.id, i + 1));
46
+ }
47
+ return out;
48
+ }
49
+
50
+ const ICON = { viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round' as const, strokeLinejoin: 'round' as const, 'aria-hidden': true };
51
+
52
+ export const HandIcon: React.FC<{ size?: number }> = ({ size = 15 }) => (
53
+ <svg {...ICON} width={size} height={size}>
54
+ <path d="M8 13V5.5a1.5 1.5 0 0 1 3 0V12M11 11.5v-7a1.5 1.5 0 0 1 3 0V12M14 11.5V6.5a1.5 1.5 0 0 1 3 0V15a6 6 0 0 1-6 6h-1a6 6 0 0 1-4.7-2.3L3.6 15.4a1.6 1.6 0 0 1 2.4-2l2 2" />
55
+ </svg>
56
+ );
57
+
58
+ export const RegionIcon: React.FC<{ size?: number }> = ({ size = 15 }) => (
59
+ <svg {...ICON} width={size} height={size}>
60
+ <path d="M4 8V5a1 1 0 0 1 1-1h3M16 4h3a1 1 0 0 1 1 1v3M20 16v3a1 1 0 0 1-1 1h-3M8 20H5a1 1 0 0 1-1-1v-3" />
61
+ <path d="M12 9v6M9 12h6" />
62
+ </svg>
63
+ );
64
+
65
+ export const ChatIcon: React.FC<{ size?: number }> = ({ size = 15 }) => (
66
+ <svg {...ICON} width={size} height={size}>
67
+ <path d="M7.5 8.25h9m-9 3H12M3 20.25V6a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 6v9a2.25 2.25 0 0 1-2.25 2.25H7.5L3 20.25Z" />
68
+ </svg>
69
+ );
70
+
71
+ /**
72
+ * A crop of the image showing the region, as a CSS background (no canvas, no
73
+ * second request: the browser reuses the image it already loaded). The crop
74
+ * keeps the region's shape within `maxWidth` × `maxHeight`.
75
+ */
76
+ export const RegionThumb: React.FC<{
77
+ src: string;
78
+ region: ImageRegion;
79
+ maxWidth?: number;
80
+ maxHeight?: number;
81
+ }> = ({ src, region, maxWidth = 76, maxHeight = 48 }) => {
82
+ const { box, naturalWidth, naturalHeight } = region;
83
+ const s = Math.min(maxWidth / box.w, maxHeight / box.h);
84
+ const width = Math.max(4, Math.round(box.w * s));
85
+ const height = Math.max(4, Math.round(box.h * s));
86
+ return (
87
+ <div
88
+ aria-hidden="true"
89
+ data-region-thumb=""
90
+ className="flex-none rounded-[5px] bg-white bg-no-repeat ring-1 ring-border/80"
91
+ style={{
92
+ width,
93
+ height,
94
+ backgroundImage: `url(${JSON.stringify(src)})`,
95
+ backgroundSize: `${naturalWidth * s}px ${naturalHeight * s}px`,
96
+ backgroundPosition: `${-box.x * s}px ${-box.y * s}px`,
97
+ }}
98
+ />
99
+ );
100
+ };
101
+
102
+ /** The number badge of a region (the same number the box on the image carries). */
103
+ export const RegionNumber: React.FC<{ n: number; selected?: boolean; className?: string }> = ({ n, selected = false, className = '' }) => (
104
+ <span
105
+ aria-hidden="true"
106
+ className={`inline-flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[9.5px] font-bold tabular-nums text-white ${selected ? 'bg-primary' : 'bg-annotation-comment'} ${className}`}
107
+ >
108
+ {n}
109
+ </span>
110
+ );
111
+
112
+ /**
113
+ * What a region card shows in place of a quote: the crop, the file, the
114
+ * box's size in the image's own pixels and the image's line.
115
+ */
116
+ export const RegionCardInfo: React.FC<{ region: ImageRegion; thumbSrc?: string | null }> = ({ region, thumbSrc }) => (
117
+ <div className="mb-1.5 flex items-center gap-2.5" data-region-card-info="">
118
+ {thumbSrc && <RegionThumb src={thumbSrc} region={region} />}
119
+ <div className="min-w-0 font-mono text-[10.5px] leading-normal text-muted-foreground">
120
+ <div className="truncate">{imageRegionFileName(region.src)}</div>
121
+ <div className="truncate">
122
+ {imageRegionSize(region)}
123
+ {region.sourceLine !== null && <> · line {region.sourceLine}</>}
124
+ </div>
125
+ </div>
126
+ </div>
127
+ );
128
+
129
+ /** One region drawn on the image. */
130
+ export interface RegionBoxItem {
131
+ id: string;
132
+ n: number;
133
+ box: NaturalBox;
134
+ /** Its comment, for the box's accessible name. */
135
+ text?: string;
136
+ }
137
+
138
+ const boxStyle = (rect: StageRect, eased: boolean): React.CSSProperties => ({
139
+ position: 'absolute',
140
+ left: rect.left,
141
+ top: rect.top,
142
+ width: rect.width,
143
+ height: rect.height,
144
+ transition: eased ? 'left 180ms cubic-bezier(0.2, 0, 0, 1), top 180ms cubic-bezier(0.2, 0, 0, 1), width 180ms cubic-bezier(0.2, 0, 0, 1), height 180ms cubic-bezier(0.2, 0, 0, 1), opacity 150ms' : 'opacity 150ms',
145
+ });
146
+
147
+ /**
148
+ * The boxes over the image, in stage pixels (so a stroke and a badge keep
149
+ * their size at every zoom): the saved regions (numbered, the comment
150
+ * colour), the selected one (ringed, the rest and the image dimmed), the box
151
+ * being drawn (dashed, with its size in the image's own pixels) and the box
152
+ * waiting for its comment (solid, numbered).
153
+ */
154
+ export const RegionBoxLayer: React.FC<{
155
+ view: ZoomViewport | null;
156
+ stage: ZoomSize | null;
157
+ eased: boolean;
158
+ regions: readonly RegionBoxItem[];
159
+ selectedId: string | null;
160
+ /** The box being drawn now. */
161
+ draft: NaturalBox | null;
162
+ /** The box waiting for its comment, and its number. */
163
+ pending: { box: NaturalBox; n: number } | null;
164
+ /** Saved boxes take a click (Move mode); in Region mode the drag draws through them. */
165
+ interactive: boolean;
166
+ onPick: (id: string) => void;
167
+ /** The pending box's element, the comment box's anchor. */
168
+ pendingRef?: React.Ref<HTMLDivElement>;
169
+ }> = ({ view, stage, eased, regions, selectedId, draft, pending, interactive, onPick, pendingRef }) => {
170
+ if (!view || !stage) return null;
171
+ const anySelected = selectedId !== null && regions.some((r) => r.id === selectedId);
172
+ const badge = 'absolute -left-3 -top-3 flex h-[22px] min-w-[22px] items-center justify-center rounded-full px-1 text-[11.5px] font-bold tabular-nums text-white shadow-[0_0_0_2px_var(--background),0_2px_6px_-2px_rgb(0_0_0/0.4)]';
173
+ return (
174
+ <div className="pointer-events-none absolute inset-0" data-region-layer="">
175
+ {regions.map((r) => {
176
+ const rect = naturalBoxToStage(view, r.box);
177
+ const selected = r.id === selectedId;
178
+ return (
179
+ <div
180
+ key={r.id}
181
+ data-region-box={r.id}
182
+ data-region-selected={selected ? '' : undefined}
183
+ aria-hidden="true"
184
+ onPointerDown={interactive ? (e) => e.stopPropagation() : undefined}
185
+ onClick={interactive ? (e) => { e.stopPropagation(); onPick(r.id); } : undefined}
186
+ className={
187
+ selected
188
+ ? 'rounded-[3px] border-[3px] border-primary shadow-[0_0_0_9999px_rgb(15_20_30/0.3),0_0_0_6px_color-mix(in_oklch,var(--primary)_30%,transparent)]'
189
+ : `rounded-[3px] border-2 border-annotation-comment bg-annotation-comment/10 ${anySelected ? 'opacity-35' : ''}`
190
+ }
191
+ style={{ ...boxStyle(rect, eased), pointerEvents: interactive ? 'auto' : 'none', cursor: interactive ? 'pointer' : undefined }}
192
+ >
193
+ <span className={`${badge} ${selected ? 'bg-primary' : 'bg-annotation-comment'}`}>{r.n}</span>
194
+ </div>
195
+ );
196
+ })}
197
+ {pending && (() => {
198
+ const rect = naturalBoxToStage(view, pending.box);
199
+ return (
200
+ <div ref={pendingRef} data-region-pending="" aria-hidden="true" className="rounded-[3px] border-2 border-primary bg-primary/[0.07]" style={boxStyle(rect, eased)}>
201
+ <span className={`${badge} bg-primary`}>{pending.n}</span>
202
+ </div>
203
+ );
204
+ })()}
205
+ {draft && (() => {
206
+ const rect = naturalBoxToStage(view, draft);
207
+ const below = rect.top + rect.height + 30 <= stage.height;
208
+ return (
209
+ <>
210
+ <div data-region-draft="" aria-hidden="true" className="rounded-[3px] border-2 border-dashed border-primary bg-primary/[0.07]" style={boxStyle(rect, false)} />
211
+ <div
212
+ data-region-size=""
213
+ aria-hidden="true"
214
+ className="absolute whitespace-nowrap rounded-[5px] bg-[rgb(28_32_42/0.88)] px-1.5 py-0.5 font-mono text-[11px] tabular-nums text-white"
215
+ style={{
216
+ left: Math.max(4, rect.left + rect.width - 2),
217
+ top: below ? rect.top + rect.height + 8 : Math.max(4, rect.top - 26),
218
+ transform: 'translateX(-100%)',
219
+ }}
220
+ >
221
+ {draft.w} × {draft.h}
222
+ </div>
223
+ </>
224
+ );
225
+ })()}
226
+ </div>
227
+ );
228
+ };
229
+
230
+ /**
231
+ * The Move | Region switch and the comment count (approval variant C):
232
+ * floating over the image on a desktop, a bottom bar on a phone. The switch
233
+ * is a radio group (arrows move between the two, as a segmented control);
234
+ * the count opens the drawer of this image's region comments.
235
+ */
236
+ export const RegionToolStrip: React.FC<{
237
+ tool: 'move' | 'region';
238
+ onToolChange: (tool: 'move' | 'region') => void;
239
+ /** False on a read-only document: only the count shows. */
240
+ canDraw: boolean;
241
+ count: number;
242
+ drawerOpen: boolean;
243
+ onToggleDrawer: () => void;
244
+ drawerId: string;
245
+ phone: boolean;
246
+ countRef?: React.Ref<HTMLButtonElement>;
247
+ }> = ({ tool, onToolChange, canDraw, count, drawerOpen, onToggleDrawer, drawerId, phone, countRef }) => {
248
+ const moveRef = React.useRef<HTMLButtonElement>(null);
249
+ const regionRef = React.useRef<HTMLButtonElement>(null);
250
+ const seg = phone ? 'h-10 px-3.5 text-[13px]' : 'h-7 px-2.5 text-[12px]';
251
+ const choose = (next: 'move' | 'region') => {
252
+ onToolChange(next);
253
+ (next === 'move' ? moveRef : regionRef).current?.focus();
254
+ };
255
+ const onKeyDown = (e: React.KeyboardEvent) => {
256
+ if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
257
+ e.preventDefault();
258
+ choose(tool === 'move' ? 'region' : 'move');
259
+ } else if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
260
+ e.preventDefault();
261
+ choose(tool === 'move' ? 'region' : 'move');
262
+ } else if (e.key === 'Home') {
263
+ e.preventDefault();
264
+ choose('move');
265
+ } else if (e.key === 'End') {
266
+ e.preventDefault();
267
+ choose('region');
268
+ }
269
+ };
270
+ const radio = (value: 'move' | 'region', label: string, icon: React.ReactNode, ref: React.Ref<HTMLButtonElement>) => {
271
+ const on = tool === value;
272
+ return (
273
+ <button
274
+ ref={ref}
275
+ type="button"
276
+ role="radio"
277
+ aria-checked={on}
278
+ tabIndex={on ? 0 : -1}
279
+ data-region-tool={value}
280
+ onClick={() => onToolChange(value)}
281
+ className={`inline-flex items-center gap-1.5 rounded-[7px] font-medium transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring ${seg} ${
282
+ on ? `bg-card shadow-[0_1px_2px_rgb(0_0_0/0.2)] ${value === 'region' ? 'text-primary' : 'text-foreground'}` : 'text-muted-foreground hover:text-foreground'
283
+ }`}
284
+ >
285
+ {icon}
286
+ {label}
287
+ </button>
288
+ );
289
+ };
290
+ return (
291
+ <div
292
+ data-region-strip=""
293
+ className={`pointer-events-auto flex items-center gap-1.5 rounded-xl border border-border bg-popover p-1 text-popover-foreground shadow-[0_10px_30px_-16px_rgb(0_0_0/0.45)]`}
294
+ onPointerDown={(e) => e.stopPropagation()}
295
+ >
296
+ {canDraw && (
297
+ <>
298
+ <div
299
+ role="radiogroup"
300
+ aria-label="Image tool"
301
+ onKeyDown={onKeyDown}
302
+ className="inline-flex gap-0.5 rounded-[9px] border border-border/30 bg-muted/50 p-0.5"
303
+ >
304
+ {radio('move', 'Move', <HandIcon size={phone ? 17 : 15} />, moveRef)}
305
+ {radio('region', 'Region', <RegionIcon size={phone ? 17 : 15} />, regionRef)}
306
+ </div>
307
+ <span className="h-5 w-px bg-border" aria-hidden="true" />
308
+ </>
309
+ )}
310
+ <button
311
+ ref={countRef}
312
+ type="button"
313
+ data-region-count=""
314
+ aria-expanded={drawerOpen}
315
+ aria-controls={drawerId}
316
+ aria-label={`${count} ${count === 1 ? 'comment' : 'comments'} on this image`}
317
+ onClick={onToggleDrawer}
318
+ className={`inline-flex items-center gap-1.5 rounded-[7px] font-medium tabular-nums transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring ${seg} ${
319
+ drawerOpen ? 'bg-primary/12 text-primary' : 'text-muted-foreground hover:text-foreground'
320
+ }`}
321
+ >
322
+ <ChatIcon size={phone ? 17 : 15} />
323
+ {count}
324
+ </button>
325
+ </div>
326
+ );
327
+ };
328
+
329
+ /** The annotations among `all` that are region comments on the image `src` (as written). */
330
+ export function useImageRegionAnnotations(all: readonly Annotation[], src: string | null | undefined): Annotation[] {
331
+ return useMemo(() => {
332
+ if (!src) return [];
333
+ return all.filter((ann) => regionOf(ann)?.src === src).sort((a, b) => a.createdA - b.createdA);
334
+ }, [all, src]);
335
+ }
336
+
337
+ /**
338
+ * The document line an image is written on: its block's first line plus the
339
+ * line breaks before `](src` in the block's text (a paragraph, a list item, a
340
+ * table, a quote's own markdown). The block's first line when the reference
341
+ * cannot be found; null when the block has no line.
342
+ */
343
+ export function imageSourceLine(block: { content: string; startLine?: number }, src: string): number | null {
344
+ if (typeof block.startLine !== 'number' || block.startLine < 1) return null;
345
+ let at = imageReferenceIndex(block.content, src);
346
+ if (at < 0) at = block.content.indexOf(src);
347
+ if (at < 0) return block.startLine;
348
+ return block.startLine + (block.content.slice(0, at).match(/\n/g)?.length ?? 0);
349
+ }
350
+
351
+ /**
352
+ * Where the markdown image reference to `src` starts in `text` (`](src)`,
353
+ * `](src "title")`, `](<src>)`), or -1. The src must be the whole reference:
354
+ * `a.png` never matches `](data.png)` or `](a.png.bak)`.
355
+ */
356
+ export function imageReferenceIndex(text: string, src: string): number {
357
+ if (!src) return -1;
358
+ for (const opener of [`](${src}`, `](<${src}>`]) {
359
+ let from = 0;
360
+ for (;;) {
361
+ const at = text.indexOf(opener, from);
362
+ if (at < 0) break;
363
+ const next = text.charAt(at + opener.length);
364
+ if (next === ')' || /\s/.test(next)) return at;
365
+ from = at + 1;
366
+ }
367
+ }
368
+ return -1;
369
+ }
@@ -114,7 +114,7 @@ export const ImageThumbnail: React.FC<ImageThumbnailProps> = ({
114
114
  e.stopPropagation();
115
115
  onRemove();
116
116
  }}
117
- className="absolute -top-1 -right-1 w-4 h-4 bg-destructive text-destructive-foreground rounded-full opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
117
+ className="absolute -top-1 -right-1 w-4 h-4 bg-destructive text-destructive-foreground rounded-full opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 [@media(hover:none)]:opacity-100 transition-opacity flex items-center justify-center"
118
118
  >
119
119
  <svg className="w-2.5 h-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
120
120
  <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />