@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
@@ -1,19 +1,84 @@
1
- import React, { useEffect, useMemo, useRef, useState } from 'react';
1
+ import React, { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
2
+ import { imageRegionFileName, imageRegionLocationLines, parseImageRegion, type ImageRegion } from '@plannotator/core/image-region';
3
+ import { AnnotationType, type Annotation, type ImageAttachment } from '../types';
4
+ import type { MentionSource } from '../utils/mentions';
5
+ import { getIdentity } from '../utils/identity';
6
+ import { THUMBS_UP_LABEL } from '../utils/quickLabels';
7
+ import { AnnotationPanel } from './AnnotationPanel';
8
+ import { CommentPopover, type CommentAskAIHandler } from './CommentPopover';
2
9
  import { ImagePeek, type ImagePeekHandle, type ImagePeekRow } from './ImagePeek';
10
+ import { RegionBoxLayer, RegionToolStrip, regionOf, type RegionBoxItem } from './ImageRegions';
11
+ import { ImageZoomStage, type ImageZoomStageHandle, type WheelHintGate, type ZoomDraw, type ZoomMode } from './ImageZoomStage';
12
+ import { canPanToward, type NaturalBox, type ZoomSize, type ZoomViewport } from './imageZoom';
13
+ import { useIsMobile } from '../hooks/useIsMobile';
14
+ import { useSwipeStep } from '../hooks/useSwipeStep';
3
15
 
4
16
  /** One image of a document, for the lightbox's stepping (`images`). */
5
17
  export interface LightboxImage {
6
18
  src: string;
7
19
  alt: string;
20
+ /** What the viewer calls the image (a picture choice: "B · Quiet hover menu"). Absent: the file name its src names. */
21
+ name?: string;
8
22
  /** The heading the image sits under ("Options"), for "2 of 4 · in “Options”" and the peek's rows. */
9
23
  section?: string | null;
24
+ /**
25
+ * The image's src AS WRITTEN in the markdown (not the URL the browser
26
+ * loads), its document line and its block: what a comment on part of the
27
+ * image records (#2100). Absent: the image takes no region comments.
28
+ */
29
+ writtenSrc?: string;
30
+ line?: number | null;
31
+ blockId?: string;
10
32
  }
11
33
 
12
34
  /**
13
- * The file name an image's src names: the `path` a host route carries
14
- * (`/api/image?path=…`), else the URL's last segment, else the alt text.
35
+ * Comments on part of an image (#2100, approval "image-region-comments",
36
+ * variant C). Given, the viewer shows a Move | Region switch and a comment
37
+ * count over each markdown image (one with a `writtenSrc` and `blockId`):
38
+ * Region turns a drag into a box, the comment box opens beside it, Save adds
39
+ * an ordinary COMMENT carrying `imageRegion`, and the count opens a drawer of
40
+ * the image's region comments (a card zooms to its box). Absent: the viewer
41
+ * is unchanged.
15
42
  */
16
- export function lightboxImageName(image: Pick<LightboxImage, 'src' | 'alt'>): string {
43
+ export interface LightboxRegionComments {
44
+ /** The document's annotations; the viewer shows the ones carrying `imageRegion`. */
45
+ annotations: readonly Annotation[];
46
+ /** Whether new boxes may be drawn (false on a read-only document: boxes and the drawer only). */
47
+ canCreate: boolean;
48
+ onAdd: (annotation: Annotation) => void;
49
+ onDelete?: (id: string) => void;
50
+ onEdit?: (id: string, updates: Partial<Annotation>) => void;
51
+ /** Open on this comment: its image, zoomed to its box, its card selected (a card picked in the document's panel). */
52
+ focusId?: string | null;
53
+ /** Ask AI from the comment box (the viewer closes once a question is taken, as the diagram popout does). */
54
+ onAskAI?: CommentAskAIHandler;
55
+ mentionSource?: MentionSource;
56
+ }
57
+
58
+ /** `Region 3 · 356 × 486 px`, the comment box's header. */
59
+ function regionHeader(n: number, box: NaturalBox): string {
60
+ return `Region ${n} · ${box.w} × ${box.h} px`;
61
+ }
62
+
63
+ function newRegionAnnotationId(): string {
64
+ const c = globalThis.crypto;
65
+ if (c && typeof c.randomUUID === 'function') return c.randomUUID();
66
+ return `ann-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
67
+ }
68
+
69
+ function isTextField(el: EventTarget | null): boolean {
70
+ if (!(el instanceof HTMLElement)) return false;
71
+ const tag = el.tagName;
72
+ return tag === 'INPUT' || tag === 'TEXTAREA' || el.isContentEditable;
73
+ }
74
+
75
+ /**
76
+ * What the viewer calls an image: its `name` when the host gave one, else the
77
+ * file name its src names (the `path` a host route carries,
78
+ * `/api/image?path=…`, else the URL's last segment), else the alt text.
79
+ */
80
+ export function lightboxImageName(image: Pick<LightboxImage, 'src' | 'alt' | 'name'>): string {
81
+ if (image.name) return image.name;
17
82
  try {
18
83
  const base = typeof window !== 'undefined' && /^https?:/.test(window.location.href) ? window.location.href : 'http://localhost/';
19
84
  const url = new URL(image.src, base);
@@ -31,7 +96,8 @@ export function lightboxImageName(image: Pick<LightboxImage, 'src' | 'alt'>): st
31
96
  * heading it sits under: what the lightbox steps through (plan review and
32
97
  * annotate's light version of the Inbox's one viewer, approval
33
98
  * "inbox-image-viewer"). Only images that open the lightbox count
34
- * (`.cursor-zoom-in`).
99
+ * (`.cursor-zoom-in`, or `[data-zoomable]`: a question card's picture
100
+ * choices, whose picture picks and whose separate zoom button opens it).
35
101
  */
36
102
  export function collectDocumentImages(container: ParentNode | null): LightboxImage[] {
37
103
  if (!container) return [];
@@ -43,10 +109,19 @@ export function collectDocumentImages(container: ParentNode | null): LightboxIma
43
109
  continue;
44
110
  }
45
111
  const img = el as HTMLImageElement;
46
- if (!img.classList.contains('cursor-zoom-in')) continue;
112
+ if (!img.classList.contains('cursor-zoom-in') && !img.hasAttribute('data-zoomable')) continue;
47
113
  const src = img.getAttribute('src');
48
114
  if (!src || out.some((i) => i.src === src)) continue;
49
- out.push({ src, alt: img.getAttribute('alt') ?? '', section });
115
+ const image: LightboxImage = { src, alt: img.getAttribute('alt') ?? '', section };
116
+ // A markdown image says how it was written (InlineMarkdown's
117
+ // `data-image-src`) and sits in a block: what region comments need.
118
+ const written = img.getAttribute('data-image-src');
119
+ const blockId = img.closest<HTMLElement>('[data-block-id]')?.dataset.blockId;
120
+ if (written && blockId) {
121
+ image.writtenSrc = written;
122
+ image.blockId = blockId;
123
+ }
124
+ out.push(image);
50
125
  }
51
126
  return out;
52
127
  }
@@ -73,8 +148,16 @@ const ICON = { width: 16, height: 16, viewBox: '0 0 24 24', fill: 'none', stroke
73
148
  * viewer: full screen, `2 of 4 · in “Options”`, ← → (and `]` `[`) through the
74
149
  * document's images, stopping at the ends, Actual size, and the same peek
75
150
  * from the pill under the image. No pen, no comments: there the person
76
- * annotates the document itself. Without `images` it is exactly the single
77
- * image overlay it always was.
151
+ * annotates the document itself. Without `images` it is the single image
152
+ * overlay it always was (a click beside the image closes it).
153
+ *
154
+ * Both zoom (#2067, `ImageZoomStage`): Ctrl/⌘ + wheel and a trackpad pinch
155
+ * zoom about the pointer, a plain wheel scrolls an image larger than the
156
+ * screen and zooms one that fits, drag (or one finger) pans, two fingers
157
+ * pinch, a double click toggles Fit and 2×, Fit to 800%. The stepping header
158
+ * shows the scale beside Fit / Actual size, which resets it. No keys were
159
+ * added: the focused stage takes the browser's own scroll keys, as the
160
+ * scroller it replaced did. Stepping to another image starts at Fit.
78
161
  */
79
162
  export const ImageLightbox: React.FC<{
80
163
  src: string;
@@ -82,45 +165,292 @@ export const ImageLightbox: React.FC<{
82
165
  onClose: () => void;
83
166
  /** The document's images in order; with two or more the lightbox steps through them. Additive: absent keeps the single image overlay. */
84
167
  images?: readonly LightboxImage[];
85
- }> = ({ src, alt, onClose, images }) => {
168
+ /** Comments on part of an image (#2100). Additive: absent, no region tools. */
169
+ regions?: LightboxRegionComments;
170
+ /**
171
+ * A touch screen's swipe through `images` (`useSwipeStep`, the Inbox
172
+ * phone viewer's, approval "inbox-image-swipe"): the image follows the
173
+ * finger and steps past a third of the width or on a flick, a zoomed image
174
+ * pans to its edge first, and a drag down at Fit closes. Additive: absent,
175
+ * no swipe.
176
+ */
177
+ swipe?: boolean;
178
+ }> = ({ src, alt, onClose, images, regions, swipe }) => {
86
179
  const overlayRef = useRef<HTMLDivElement>(null);
87
180
  const onCloseRef = useRef(onClose);
88
181
  onCloseRef.current = onClose;
89
- const stepping = !!images && images.length > 1;
90
- const [index, setIndex] = useState(() => Math.max(0, images?.findIndex((i) => i.src === src) ?? 0));
91
- const [fit, setFit] = useState<'fit' | 'actual'>('fit');
182
+ // With region comments a single image still gets the framed viewer (its
183
+ // header, the strip); without, it keeps the dark one-image overlay.
184
+ const list = images && images.length > 0 && (images.length > 1 || images[0]!.src === src) ? images : undefined;
185
+ const stepping = !!list && list.length > 1;
186
+ const [index, setIndex] = useState(() => {
187
+ if (!list) return 0;
188
+ const focus = regions?.focusId ? regions.annotations.find((a) => a.id === regions.focusId) : undefined;
189
+ const focusSrc = focus ? regionOf(focus)?.src : undefined;
190
+ const byFocus = focusSrc ? list.findIndex((i) => i.writtenSrc === focusSrc) : -1;
191
+ return Math.max(0, byFocus >= 0 ? byFocus : list.findIndex((i) => i.src === src));
192
+ });
193
+ const [zoomMode, setZoomMode] = useState<ZoomMode>('fit');
194
+ const [scale, setScale] = useState<number | null>(null);
92
195
  const [peekOpen, setPeekOpen] = useState(false);
93
196
  const [endNote, setEndNote] = useState<string | null>(null);
94
197
  const [announced, setAnnounced] = useState('');
95
198
  const peekRef = useRef<ImagePeekHandle>(null);
199
+ const stageRef = useRef<ImageZoomStageHandle>(null);
200
+ // The "⌘ + scroll to zoom" hint shows once while the lightbox is open, whichever image a plain wheel first scrolls (#2127).
201
+ const wheelHint = useRef<WheelHintGate>({ shown: false }).current;
202
+ const refocusStage = useRef(false);
96
203
  const pillRef = useRef<HTMLButtonElement>(null);
97
204
  const endTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
98
- const current = stepping ? images![index]! : { src, alt, section: null };
205
+ const current: LightboxImage = list ? list[index]! : { src, alt, section: null };
99
206
  const name = lightboxImageName(current);
100
207
 
208
+ // --- Region comments (#2100) -------------------------------------------
209
+ const phone = useIsMobile();
210
+ const regionsOn = !!regions && !!current.writtenSrc && !!current.blockId;
211
+ const framed = stepping || regionsOn;
212
+ const canDraw = regionsOn && regions!.canCreate;
213
+ const [tool, setTool] = useState<'move' | 'region'>('move');
214
+ const [draft, setDraft] = useState<NaturalBox | null>(null);
215
+ const [pending, setPending] = useState<NaturalBox | null>(null);
216
+ const [pendingEl, setPendingEl] = useState<HTMLDivElement | null>(null);
217
+ const [drawerOpen, setDrawerOpen] = useState(() => !!regions?.focusId);
218
+ const [scope, setScope] = useState<'image' | 'all'>('image');
219
+ const [selectedId, setSelectedId] = useState<string | null>(() => regions?.focusId ?? null);
220
+ const [view, setView] = useState<{ view: ZoomViewport; stage: ZoomSize; eased: boolean } | null>(null);
221
+ const [dims, setDims] = useState<ZoomSize | null>(null);
222
+ /** A comment to zoom to once the image it is on has loaded (a card, or `focusId`). */
223
+ const zoomOnLoad = useRef<string | null>(regions?.focusId ?? null);
224
+ const countRef = useRef<HTMLButtonElement>(null);
225
+ const drawerRef = useRef<HTMLElement>(null);
226
+ const drawerId = useId();
227
+
228
+ const allRegions = useMemo(() => {
229
+ const out: Array<{ ann: Annotation; region: ImageRegion }> = [];
230
+ for (const ann of regions?.annotations ?? []) {
231
+ const region = regionOf(ann);
232
+ if (region) out.push({ ann, region });
233
+ }
234
+ return out.sort((a, b) => a.ann.createdA - b.ann.createdA);
235
+ }, [regions?.annotations]);
236
+ const here = useMemo(
237
+ () => (regionsOn ? allRegions.filter((r) => r.region.src === current.writtenSrc) : []),
238
+ [allRegions, current.writtenSrc, regionsOn],
239
+ );
240
+ const boxes = useMemo<RegionBoxItem[]>(() => here.map((r, i) => ({ id: r.ann.id, n: i + 1, box: r.region.box, text: r.ann.text })), [here]);
241
+ // The images of this viewer whose src as written another image of the list shows, for "All" and its crops.
242
+ const writtenToLoaded = useMemo(() => {
243
+ const map = new Map<string, string>();
244
+ for (const image of list ?? [current]) if (image.writtenSrc && !map.has(image.writtenSrc)) map.set(image.writtenSrc, image.src);
245
+ return map;
246
+ }, [list, current]);
247
+ const drawerAnnotations = useMemo(
248
+ () => (scope === 'image' ? here : allRegions.filter((r) => writtenToLoaded.has(r.region.src))).map((r) => r.ann),
249
+ [allRegions, here, scope, writtenToLoaded],
250
+ );
251
+ const thumbFor = useMemo(() => (written: string) => writtenToLoaded.get(written) ?? null, [writtenToLoaded]);
252
+
253
+ const zoomToComment = (id: string, animate = true) => {
254
+ const hit = here.find((r) => r.ann.id === id);
255
+ if (hit) stageRef.current?.zoomTo(hit.region.box, animate);
256
+ };
257
+
258
+ // Once an image has loaded, zoom to the comment that asked for it.
259
+ useEffect(() => {
260
+ if (!dims || !zoomOnLoad.current) return;
261
+ const id = zoomOnLoad.current;
262
+ if (!here.some((r) => r.ann.id === id)) return;
263
+ zoomOnLoad.current = null;
264
+ zoomToComment(id, false);
265
+ // eslint-disable-next-line react-hooks/exhaustive-deps
266
+ }, [dims, here]);
267
+
268
+ // A card picked in the document's panel while the viewer is open.
269
+ const lastFocus = useRef(regions?.focusId ?? null);
270
+ useEffect(() => {
271
+ const focus = regions?.focusId ?? null;
272
+ if (focus === lastFocus.current) return;
273
+ lastFocus.current = focus;
274
+ if (focus) pickComment(focus);
275
+ // eslint-disable-next-line react-hooks/exhaustive-deps
276
+ }, [regions?.focusId]);
277
+
278
+ /** A card (or a box) was picked: select it, show its image, zoom to its box. */
279
+ const pickComment = (id: string) => {
280
+ const hit = allRegions.find((r) => r.ann.id === id);
281
+ if (!hit || waitingComment()) return;
282
+ setSelectedId(id);
283
+ setPending(null);
284
+ setDraft(null);
285
+ if (hit.region.src === current.writtenSrc) {
286
+ zoomToComment(id);
287
+ return;
288
+ }
289
+ const target = list?.findIndex((i) => i.writtenSrc === hit.region.src) ?? -1;
290
+ if (target < 0) return;
291
+ zoomOnLoad.current = id;
292
+ go(target);
293
+ };
294
+
295
+ const onDraw = (event: ZoomDraw) => {
296
+ // A box waiting for its comment keeps it: a new drag never throws away
297
+ // words being written (Save, or close the comment box, first).
298
+ if (pending) return;
299
+ if (event.phase === 'move') {
300
+ setSelectedId(null);
301
+ setPending(null);
302
+ setDraft(event.box);
303
+ } else if (event.phase === 'end' && event.box) {
304
+ setDraft(null);
305
+ setPending(event.box);
306
+ setAnnounced(`Box drawn, ${event.box.w} by ${event.box.h} pixels. Write the comment.`);
307
+ } else {
308
+ setDraft(null);
309
+ }
310
+ };
311
+
312
+ /** Keyboard: Enter in Region mode comments on the part of the image on screen. */
313
+ const boxFromScreen = () => {
314
+ const box = stageRef.current?.visibleBox();
315
+ if (!box) return;
316
+ setSelectedId(null);
317
+ setPending(box);
318
+ setAnnounced(`Commenting on the part of the image on screen, ${box.w} by ${box.h} pixels.`);
319
+ };
320
+
321
+ const saveRegion = (text: string, attachments?: ImageAttachment[], mentions?: readonly string[], quick = false) => {
322
+ if (!regions || !pending || !dims || !current.writtenSrc || !current.blockId) return;
323
+ const region = parseImageRegion({
324
+ v: 1,
325
+ src: current.writtenSrc,
326
+ sourceLine: current.line ?? null,
327
+ box: pending,
328
+ naturalWidth: dims.width,
329
+ naturalHeight: dims.height,
330
+ });
331
+ if (!region) return;
332
+ const n = here.length + 1;
333
+ const annotation: Annotation = {
334
+ id: newRegionAnnotationId(),
335
+ blockId: current.blockId,
336
+ startOffset: 0,
337
+ endOffset: 0,
338
+ type: AnnotationType.COMMENT,
339
+ text,
340
+ originalText: current.writtenSrc,
341
+ createdA: Date.now(),
342
+ author: getIdentity(),
343
+ ...(attachments && attachments.length > 0 ? { images: attachments } : {}),
344
+ ...(mentions && mentions.length > 0 ? { mentions } : {}),
345
+ ...(quick ? { isQuickLabel: true } : {}),
346
+ imageRegion: region,
347
+ };
348
+ regions.onAdd(annotation);
349
+ setPending(null);
350
+ setAnnounced(`Comment saved on region ${n}.${tool === 'region' ? ' Region is still on: drag to draw another box, or press Escape for Move.' : ''}`);
351
+ };
352
+
353
+ const askAI: CommentAskAIHandler | undefined = regions?.onAskAI
354
+ ? async (question, context) => {
355
+ const accepted = await regions.onAskAI!(question, context);
356
+ if (accepted !== false) onCloseRef.current();
357
+ return accepted;
358
+ }
359
+ : undefined;
360
+
361
+ const showNote = (note: string) => {
362
+ setEndNote(note);
363
+ setAnnounced(note);
364
+ if (endTimer.current) clearTimeout(endTimer.current);
365
+ endTimer.current = setTimeout(() => setEndNote(null), 1800);
366
+ };
367
+ /** A box waiting for its comment keeps the viewer on it: stepping away would throw the words away. */
368
+ const waitingComment = () => {
369
+ if (!pending) return false;
370
+ showNote('Save or close the comment first');
371
+ return true;
372
+ };
101
373
  const step = (dir: 1 | -1) => {
102
374
  if (!stepping) return;
103
375
  const next = index + dir;
104
- if (next < 0 || next >= images!.length) {
105
- const note = dir > 0 ? 'Last image in this document' : 'First image in this document';
106
- setEndNote(note);
107
- setAnnounced(note);
108
- if (endTimer.current) clearTimeout(endTimer.current);
109
- endTimer.current = setTimeout(() => setEndNote(null), 1800);
376
+ if (next < 0 || next >= list!.length) {
377
+ showNote(dir > 0 ? 'Last image in this document' : 'First image in this document');
110
378
  return;
111
379
  }
112
380
  go(next);
113
381
  };
114
382
  const go = (next: number) => {
383
+ if (waitingComment()) return;
384
+ // The stage is keyed by image, so stepping remounts it: keep focus on it
385
+ // when it had focus (its scroll keys, and Tab staying inside the dialog).
386
+ refocusStage.current = !!stageRef.current?.element && document.activeElement === stageRef.current.element;
115
387
  setIndex(next);
116
- setFit('fit');
117
- const image = images![next]!;
118
- setAnnounced(`${next + 1} of ${images!.length}, ${lightboxImageName(image)}${image.section ? `, in “${image.section}”` : ''}`);
388
+ setZoomMode('fit');
389
+ setScale(null);
390
+ setDims(null);
391
+ setView(null);
392
+ setDraft(null);
393
+ setPending(null);
394
+ if (zoomOnLoad.current === null) setSelectedId(null);
395
+ const image = list![next]!;
396
+ setAnnounced(`${next + 1} of ${list!.length}, ${lightboxImageName(image)}${image.section ? `, in “${image.section}”` : ''}`);
119
397
  };
398
+ useLayoutEffect(() => {
399
+ if (!refocusStage.current) return;
400
+ refocusStage.current = false;
401
+ stageRef.current?.element?.focus({ preventScroll: true });
402
+ }, [index]);
120
403
  const stepRef = useRef(step);
121
404
  stepRef.current = step;
405
+ // A touch screen's swipe (`swipe`): heard on the image area, which stays put (so a finger that comes back while the
406
+ // last step is still sliding is heard), and only the picture follows the finger (`swipeTrackRef`), not the region
407
+ // strip over it. A box being drawn, Region, or the peek keep the touch. One image alone: a drag down still closes.
408
+ const swipeAreaRef = useRef<HTMLDivElement>(null);
409
+ const swipeTrackRef = useRef<HTMLDivElement>(null);
410
+ /** The zoom stage, inside the track that follows the finger with `swipe`; without it, as before (no wrapper). */
411
+ const tracked = (stage: React.ReactNode) =>
412
+ swipe ? (
413
+ <div ref={swipeTrackRef} className="absolute inset-0" data-lightbox-track="">
414
+ {stage}
415
+ </div>
416
+ ) : (
417
+ stage
418
+ );
419
+ const dimsRef = useRef(dims);
420
+ dimsRef.current = dims;
421
+ const zoomModeRef = useRef(zoomMode);
422
+ zoomModeRef.current = zoomMode;
423
+ useSwipeStep(framed ? swipeAreaRef : overlayRef, {
424
+ enabled: !!swipe && !(canDraw && tool === 'region') && !peekOpen && !drawerOpen,
425
+ canStep: (dir) => stepping && index + dir >= 0 && index + dir < (list?.length ?? 0),
426
+ move: () => [swipeTrackRef.current],
427
+ onStep: (dir) => stepRef.current(dir),
428
+ wouldAsk: () => pending !== null,
429
+ keeps: (dir) => !stepping || canPanToward(stageRef.current?.visibleBox() ?? null, dimsRef.current?.width ?? 0, dir),
430
+ dismiss: {
431
+ can: () => {
432
+ if (zoomModeRef.current !== 'fit') return false;
433
+ const box = stageRef.current?.visibleBox() ?? null;
434
+ return box === null || box.y <= 1;
435
+ },
436
+ onDismiss: () => onCloseRef.current(),
437
+ onProgress: (share) => {
438
+ const overlay = overlayRef.current;
439
+ if (!overlay) return;
440
+ // The framed viewer's page colour, or the one-image overlay's dark veil, fades with the drag.
441
+ const veil = framed ? `color-mix(in oklch, var(--background) ${Math.round((1 - share) * 100)}%, transparent)` : `rgb(0 0 0 / ${(0.8 * (1 - share)).toFixed(3)})`;
442
+ overlay.style.backgroundColor = share > 0 ? veil : '';
443
+ },
444
+ },
445
+ });
446
+ const steppingRef = useRef(stepping);
447
+ steppingRef.current = stepping;
122
448
  const peekOpenRef = useRef(peekOpen);
123
449
  peekOpenRef.current = peekOpen;
450
+ // The Escape ladder and Enter-to-comment read the region state through a ref
451
+ // (the key listener is installed once).
452
+ const regionKeys = useRef({ pending: false, draft: false, tool, drawerOpen, canDraw, boxFromScreen, setTool, setDrawerOpen, setPending, setDraft });
453
+ regionKeys.current = { pending: pending !== null, draft: draft !== null, tool, drawerOpen, canDraw, boxFromScreen, setTool, setDrawerOpen, setPending, setDraft };
124
454
 
125
455
  useEffect(() => () => {
126
456
  if (endTimer.current) clearTimeout(endTimer.current);
@@ -139,15 +469,60 @@ export const ImageLightbox: React.FC<{
139
469
  }
140
470
  // Tab stays native: the overlay's own controls take it.
141
471
  if (e.key === 'Tab') return;
472
+ const target = e.target instanceof Element ? e.target : null;
473
+ const r = regionKeys.current;
474
+ // The comment box owns its keys (Escape closes it, Mod+Enter saves);
475
+ // the overlay's React handler keeps them from the page behind.
476
+ if (target?.closest('[data-comment-popover]')) return;
477
+ // The strip's radio group takes its arrows; a text field in the drawer
478
+ // (a card's edit box) takes everything but Escape-less app keys, and
479
+ // the drawer's cards take Enter / Space. Escape climbs the ladder below.
480
+ const inOwnUi = target?.closest('[data-region-strip], [data-region-drawer]');
481
+ if (inOwnUi && (isTextField(target) || (e.key !== 'Escape' && !(e.key === 'Enter' && (e.metaKey || e.ctrlKey))))) return;
142
482
  e.stopImmediatePropagation();
143
483
  if (e.key === 'Escape' || (e.key === 'Enter' && (e.metaKey || e.ctrlKey))) {
144
484
  e.preventDefault();
145
485
  }
146
486
  if (e.key === 'Escape') {
487
+ // One thing per press: the box being drawn or waiting for its
488
+ // comment, the drawer when focus is in it, Region (back to Move),
489
+ // the drawer, then the viewer.
490
+ if (r.draft || r.pending) {
491
+ r.setDraft(null);
492
+ r.setPending(null);
493
+ return;
494
+ }
495
+ if (r.drawerOpen && target?.closest('[data-region-drawer]')) {
496
+ r.setDrawerOpen(false);
497
+ countRef.current?.focus({ preventScroll: true });
498
+ return;
499
+ }
500
+ if (r.tool === 'region') {
501
+ r.setTool('move');
502
+ setAnnounced('Move: drag moves the image.');
503
+ return;
504
+ }
505
+ if (r.drawerOpen) {
506
+ r.setDrawerOpen(false);
507
+ return;
508
+ }
147
509
  onCloseRef.current();
148
510
  return;
149
511
  }
150
512
  if (e.metaKey || e.ctrlKey || e.altKey || e.isComposing) return;
513
+ const stage = stageRef.current;
514
+ // Region mode, the stage focused: Enter comments on what is on screen.
515
+ if (e.key === 'Enter' && r.canDraw && r.tool === 'region' && stage?.element && e.target === stage.element) {
516
+ e.preventDefault();
517
+ r.boxFromScreen();
518
+ return;
519
+ }
520
+ // The focused stage takes the browser's scroll keys, as the scroller it
521
+ // replaced did; ← → stay stepping when there is more than one image.
522
+ if (stage?.element && e.target === stage.element && stage.scrollKey(e.key, e.shiftKey, !steppingRef.current)) {
523
+ e.preventDefault();
524
+ return;
525
+ }
151
526
  if (e.key === 'ArrowRight' || e.key === ']') {
152
527
  e.preventDefault();
153
528
  stepRef.current(1);
@@ -166,10 +541,28 @@ export const ImageLightbox: React.FC<{
166
541
  };
167
542
  }, []);
168
543
 
544
+ // Ctrl + wheel (a pinch in Chrome and Firefox) and Safari's gesture events
545
+ // anywhere over the lightbox, the header and buttons included, must not
546
+ // zoom the page behind it. The stage zooms the image from its own listeners.
547
+ useEffect(() => {
548
+ const overlay = overlayRef.current;
549
+ if (!overlay) return;
550
+ const onWheel = (e: WheelEvent) => {
551
+ if (e.ctrlKey) e.preventDefault();
552
+ };
553
+ const onGesture = (e: Event) => e.preventDefault();
554
+ overlay.addEventListener('wheel', onWheel, { passive: false });
555
+ for (const type of ['gesturestart', 'gesturechange', 'gestureend']) overlay.addEventListener(type, onGesture, { passive: false });
556
+ return () => {
557
+ overlay.removeEventListener('wheel', onWheel);
558
+ for (const type of ['gesturestart', 'gesturechange', 'gestureend']) overlay.removeEventListener(type, onGesture);
559
+ };
560
+ }, [framed]);
561
+
169
562
  const rows = useMemo<ImagePeekRow[]>(() => {
170
563
  if (!stepping) return [];
171
564
  const out: ImagePeekRow[] = [];
172
- images!.forEach((image, i) => {
565
+ list!.forEach((image, i) => {
173
566
  const label = image.section ?? 'Document';
174
567
  let row = out.at(-1);
175
568
  if (!row || row.label !== label) {
@@ -179,9 +572,9 @@ export const ImageLightbox: React.FC<{
179
572
  row.items.push({ key: String(i), name: lightboxImageName(image), thumb: image.src });
180
573
  });
181
574
  return out;
182
- }, [stepping, images]);
575
+ }, [stepping, list]);
183
576
 
184
- if (!stepping) {
577
+ if (!framed) {
185
578
  return (
186
579
  <div
187
580
  ref={overlayRef}
@@ -190,55 +583,173 @@ export const ImageLightbox: React.FC<{
190
583
  aria-modal="true"
191
584
  aria-label={alt || 'Image'}
192
585
  data-image-lightbox=""
193
- className="fixed inset-0 z-[200] flex flex-col items-center justify-center bg-black/80 backdrop-blur-sm cursor-zoom-out outline-none"
194
- onClick={onClose}
586
+ className="fixed inset-0 z-[200] bg-black/80 text-white backdrop-blur-sm outline-none"
195
587
  >
196
- <img
197
- src={src}
198
- alt={alt}
199
- className="max-w-[90vw] max-h-[85vh] object-contain rounded-lg shadow-2xl"
200
- onClick={(e) => e.stopPropagation()}
201
- />
588
+ {tracked(
589
+ <ImageZoomStage
590
+ ref={stageRef}
591
+ key={src}
592
+ src={src}
593
+ alt={alt}
594
+ label={alt ? `Image: ${alt}` : 'Image'}
595
+ mode={zoomMode}
596
+ onModeChange={setZoomMode}
597
+ inset={{ x: 0.9, y: 0.85 }}
598
+ onBackgroundTap={onClose}
599
+ className="absolute inset-0"
600
+ imageClassName="shadow-2xl"
601
+ radius={8}
602
+ stageAttributes={{ 'data-lightbox-stage': zoomMode }}
603
+ wheelHint={wheelHint}
604
+ />
605
+ )}
202
606
  {alt && (
203
- <div className="mt-3 text-sm text-white/70 max-w-[90vw] text-center truncate">{alt}</div>
607
+ <div className="pointer-events-none absolute inset-x-0 bottom-[2.5vh] mx-auto max-w-[90vw] truncate text-center text-sm text-white/70">{alt}</div>
204
608
  )}
205
609
  </div>
206
610
  );
207
611
  }
208
612
 
209
- const total = images!.length;
613
+ const total = list?.length ?? 1;
210
614
  const first = index === 0;
211
615
  const last = index === total - 1;
212
616
  const circle = 'flex h-11 w-11 items-center justify-center rounded-full border border-border bg-card text-foreground shadow-sm hover:bg-muted disabled:opacity-40 aria-disabled:opacity-35';
617
+ const drawing = canDraw && tool === 'region';
618
+ const pendingNumber = here.length + 1;
619
+ const strip = regionsOn ? (
620
+ <RegionToolStrip
621
+ tool={tool}
622
+ onToolChange={(next) => {
623
+ setTool(next);
624
+ setDraft(null);
625
+ if (next === 'region') setSelectedId(null);
626
+ setAnnounced(next === 'region'
627
+ ? 'Region: drag on the image to draw a box. Escape goes back to Move.'
628
+ : 'Move: drag moves the image.');
629
+ }}
630
+ canDraw={canDraw}
631
+ count={here.length}
632
+ drawerOpen={drawerOpen}
633
+ onToggleDrawer={() => setDrawerOpen((v) => !v)}
634
+ drawerId={drawerId}
635
+ phone={phone}
636
+ countRef={countRef}
637
+ />
638
+ ) : null;
639
+ const drawer = regionsOn && drawerOpen ? (
640
+ <aside
641
+ ref={drawerRef}
642
+ id={drawerId}
643
+ data-region-drawer=""
644
+ aria-label="Comments on this image"
645
+ className={phone
646
+ ? 'fixed inset-x-0 bottom-0 z-[205] flex h-[46vh] flex-col rounded-t-2xl border-t border-border bg-card shadow-[0_-12px_40px_-16px_rgb(0_0_0/0.5)]'
647
+ : 'flex w-[320px] flex-none flex-col border-l border-border/50 bg-card'}
648
+ >
649
+ {phone && <div className="mx-auto mb-0.5 mt-2 h-[5px] w-9 flex-none rounded-full bg-border" aria-hidden="true" />}
650
+ <div className="flex h-11 flex-none items-center gap-2 border-b border-border/50 px-3">
651
+ <h2 className="text-[13px] font-medium">{phone ? 'Comments on this image' : 'Comments'}</h2>
652
+ <span className="flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-primary/10 px-1 font-mono text-[10px] font-medium tabular-nums text-primary">
653
+ {drawerAnnotations.length}
654
+ </span>
655
+ {stepping && (
656
+ <div role="group" aria-label="Show comments on" className="ml-auto flex items-center gap-0.5 rounded-md bg-muted/60 p-0.5">
657
+ {(['image', 'all'] as const).map((value) => (
658
+ <button
659
+ key={value}
660
+ type="button"
661
+ data-region-scope={value}
662
+ aria-pressed={scope === value}
663
+ onClick={() => setScope(value)}
664
+ className={`rounded px-2 py-0.5 text-[11px] font-medium transition-colors ${scope === value ? 'bg-card text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'}`}
665
+ >
666
+ {value === 'image' ? 'This image' : 'All'}
667
+ </button>
668
+ ))}
669
+ </div>
670
+ )}
671
+ <button
672
+ type="button"
673
+ aria-label="Close comments"
674
+ data-region-drawer-close=""
675
+ onClick={() => {
676
+ setDrawerOpen(false);
677
+ countRef.current?.focus({ preventScroll: true });
678
+ }}
679
+ className={`${stepping ? '' : 'ml-auto '}flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground`}
680
+ >
681
+ <svg {...ICON} width={14} height={14}>
682
+ <path d="M6 18 18 6M6 6l12 12" />
683
+ </svg>
684
+ </button>
685
+ </div>
686
+ <div className="min-h-0 flex-1">
687
+ <AnnotationPanel
688
+ presentation="embedded"
689
+ isOpen
690
+ annotations={drawerAnnotations}
691
+ blocks={[]}
692
+ selectedId={selectedId}
693
+ onSelect={pickComment}
694
+ onDelete={(id) => {
695
+ regions!.onDelete?.(id);
696
+ if (id === selectedId) setSelectedId(null);
697
+ }}
698
+ onEdit={regions!.onEdit}
699
+ readOnly={!regions!.onDelete}
700
+ imageRegionSrc={thumbFor}
701
+ mentionSource={regions!.mentionSource}
702
+ emptyHint={canDraw ? 'Choose Region, then drag over the image to comment on part of it' : 'No comments on this image'}
703
+ />
704
+ </div>
705
+ </aside>
706
+ ) : null;
707
+
213
708
  return (
214
709
  <div
215
710
  ref={overlayRef}
216
711
  tabIndex={-1}
217
712
  role="dialog"
218
713
  aria-modal="true"
219
- aria-label={`${name}, image ${index + 1} of ${total}`}
714
+ aria-label={stepping ? `${name}, image ${index + 1} of ${total}` : name}
220
715
  data-image-lightbox=""
221
- data-image-lightbox-stepping=""
222
- className="fixed inset-0 z-[200] flex flex-col bg-background text-foreground outline-none"
716
+ data-image-lightbox-stepping={stepping ? '' : undefined}
717
+ // On a phone the drawer is a sheet over the lower part: the image sits above it.
718
+ className={`fixed inset-0 z-[200] flex flex-col bg-background text-foreground outline-none ${phone && drawer ? 'pb-[46vh]' : ''}`}
719
+ // Keys the capture listener let through (the comment box, the strip,
720
+ // the drawer) still never reach the page behind.
721
+ onKeyDown={(e) => e.stopPropagation()}
223
722
  >
224
723
  <div className="flex h-14 flex-none items-center gap-3 px-5">
225
- <span className="text-[15px] font-semibold tabular-nums" data-lightbox-count="">
226
- {index + 1} of {total}
227
- </span>
724
+ {stepping ? (
725
+ <span className="text-[15px] font-semibold tabular-nums" data-lightbox-count="">
726
+ {index + 1} of {total}
727
+ </span>
728
+ ) : (
729
+ <span className="min-w-0 truncate text-[15px] font-semibold" data-lightbox-name="">
730
+ {name}
731
+ </span>
732
+ )}
228
733
  {current.section && (
229
734
  <span className="min-w-0 truncate text-[14px] text-muted-foreground" data-lightbox-section="">
230
735
  in “{current.section}”
231
736
  </span>
232
737
  )}
233
738
  <span className="flex-1" />
739
+ {scale !== null && (
740
+ <span className="text-[13px] tabular-nums text-muted-foreground" data-lightbox-zoom="">
741
+ <span className="sr-only">Zoom </span>
742
+ {Math.round(scale * 100)}%
743
+ </span>
744
+ )}
234
745
  <button
235
746
  type="button"
236
- className="h-9 rounded-md border border-border bg-card px-3 text-[13.5px] hover:bg-muted"
237
- onClick={() => setFit((f) => (f === 'fit' ? 'actual' : 'fit'))}
238
- aria-pressed={fit === 'actual'}
747
+ className="h-9 whitespace-nowrap rounded-md border border-border bg-card px-3 text-[13.5px] hover:bg-muted"
748
+ onClick={() => setZoomMode((m) => (m === 'fit' ? 'actual' : 'fit'))}
749
+ aria-pressed={zoomMode !== 'fit'}
239
750
  data-lightbox-fit=""
240
751
  >
241
- {fit === 'fit' ? 'Actual size' : 'Fit'}
752
+ {zoomMode === 'fit' ? 'Actual size' : 'Fit'}
242
753
  </button>
243
754
  <button type="button" className="flex h-9 w-9 items-center justify-center rounded-md border border-border bg-card hover:bg-muted" onClick={onClose} aria-label="Close" data-lightbox-close="">
244
755
  <svg {...ICON}>
@@ -246,89 +757,174 @@ export const ImageLightbox: React.FC<{
246
757
  </svg>
247
758
  </button>
248
759
  </div>
249
- <div className="relative flex min-h-0 flex-1 items-center gap-3 px-4">
250
- <button type="button" className={circle} onClick={() => step(-1)} aria-label="Previous image" aria-disabled={first || undefined} data-lightbox-prev="">
251
- <svg {...ICON}>
252
- <path d="m15.75 19.5-7.5-7.5 7.5-7.5" />
253
- </svg>
254
- </button>
255
- <div className={`flex min-h-0 min-w-0 flex-1 self-stretch ${fit === 'fit' ? 'items-center justify-center' : 'overflow-auto'}`} data-lightbox-stage={fit}>
256
- <img
257
- key={current.src}
258
- src={current.src}
259
- alt={current.alt}
260
- draggable={false}
261
- className={fit === 'fit' ? 'max-h-full max-w-full rounded-md object-contain shadow-xl' : 'max-w-none rounded-md'}
262
- data-lightbox-image=""
263
- />
264
- </div>
265
- <button type="button" className={circle} onClick={() => step(1)} aria-label="Next image" aria-disabled={last || undefined} data-lightbox-next="">
266
- <svg {...ICON}>
267
- <path d="m8.25 4.5 7.5 7.5-7.5 7.5" />
268
- </svg>
269
- </button>
270
- </div>
271
- <div className="flex flex-none flex-col items-center gap-2 px-4 pb-4 pt-3">
272
- {current.alt && <div className="max-w-[90vw] truncate text-[14px] font-medium">{current.alt}</div>}
273
- <div className="relative flex items-center gap-3">
274
- <span className="inline-flex items-center gap-px rounded-lg border border-border/70 bg-muted/40 p-0.5">
275
- <button type="button" className="flex h-7 w-7 items-center justify-center rounded-md hover:bg-muted" onClick={() => step(-1)} aria-label="Previous image" tabIndex={-1}>
276
- <svg {...ICON} width={14} height={14}>
277
- <path d="m15.75 19.5-7.5-7.5 7.5-7.5" />
278
- </svg>
279
- </button>
280
- <button
281
- ref={pillRef}
282
- type="button"
283
- className="flex h-7 max-w-[60vw] items-center gap-1.5 rounded-md px-2 text-[13.5px] hover:bg-muted"
284
- aria-haspopup="grid"
285
- aria-expanded={peekOpen}
286
- aria-label={`${index + 1} of ${total}, ${name}. Show every image in this document`}
287
- onClick={() => setPeekOpen((v) => !v)}
288
- data-peek-opener=""
289
- data-lightbox-pill=""
290
- >
291
- <span className="tabular-nums text-muted-foreground">
292
- {index + 1} of {total}
293
- </span>
294
- <span className="text-muted-foreground" aria-hidden="true">
295
- ·
296
- </span>
297
- <span className="truncate font-semibold">{name}</span>
298
- <svg {...ICON} width={13} height={13} className={peekOpen ? 'rotate-180' : ''}>
299
- <path d="m19.5 8.25-7.5 7.5-7.5-7.5" />
300
- </svg>
301
- </button>
302
- <button type="button" className="flex h-7 w-7 items-center justify-center rounded-md hover:bg-muted" onClick={() => step(1)} aria-label="Next image" tabIndex={-1}>
303
- <svg {...ICON} width={14} height={14}>
304
- <path d="m8.25 4.5 7.5 7.5-7.5 7.5" />
305
- </svg>
306
- </button>
307
- </span>
308
- <span className={`whitespace-nowrap rounded-md bg-muted px-2 py-1 text-[12px] text-muted-foreground transition-opacity ${endNote ? 'opacity-100' : 'opacity-0'}`} aria-hidden="true" data-lightbox-end-note="">
309
- {endNote}
310
- </span>
311
- {peekOpen && (
312
- <div className="absolute bottom-[calc(100%+8px)] left-1/2 z-10 -translate-x-1/2">
313
- <ImagePeek
314
- ref={peekRef}
315
- rows={rows}
316
- current={String(index)}
317
- label="Images in this document"
318
- onPick={(key) => {
319
- setPeekOpen(false);
320
- go(Number(key));
321
- pillRef.current?.focus({ preventScroll: true });
322
- }}
323
- onClose={(reason) => {
324
- setPeekOpen(false);
325
- if (reason === 'key') pillRef.current?.focus({ preventScroll: true });
326
- }}
327
- />
760
+ <div className="flex min-h-0 flex-1">
761
+ <div className="flex min-w-0 flex-1 flex-col">
762
+ <div className="relative flex min-h-0 flex-1 items-center gap-3 px-4">
763
+ {stepping && (
764
+ <button type="button" className={circle} onClick={() => step(-1)} aria-label="Previous image" aria-disabled={first || undefined} data-lightbox-prev="">
765
+ <svg {...ICON}>
766
+ <path d="m15.75 19.5-7.5-7.5 7.5-7.5" />
767
+ </svg>
768
+ </button>
769
+ )}
770
+ <div ref={swipeAreaRef} className="relative min-h-0 min-w-0 flex-1 self-stretch" data-lightbox-swipe={swipe ? '' : undefined}>
771
+ {tracked(
772
+ <ImageZoomStage
773
+ ref={stageRef}
774
+ key={`${index}:${current.src}`}
775
+ src={current.src}
776
+ alt={current.alt}
777
+ label={`Image: ${name}`}
778
+ description={drawing ? 'Region: drag to draw a box and comment on it. Or frame the spot with Actual size and the scroll keys, then press Enter to comment on what is on screen.' : undefined}
779
+ mode={zoomMode}
780
+ onModeChange={setZoomMode}
781
+ onScaleChange={setScale}
782
+ onImageLoad={setDims}
783
+ // A single image keeps #2067's tap beside the image to close
784
+ // (its click swallowed so the page beneath never gets it), in
785
+ // Move only: in Region a press there may start a box.
786
+ onBackgroundTap={!stepping && !drawing ? onClose : undefined}
787
+ onViewChange={regionsOn ? (v, stage, eased) => setView({ view: v, stage, eased }) : undefined}
788
+ drawing={drawing}
789
+ onDraw={drawing ? onDraw : undefined}
790
+ className="absolute inset-0"
791
+ imageClassName="shadow-xl"
792
+ stageAttributes={{ 'data-lightbox-stage': zoomMode, ...(regionsOn ? { 'data-region-tool-active': tool } : {}) }}
793
+ wheelHint={wheelHint}
794
+ >
795
+ {regionsOn && (
796
+ <RegionBoxLayer
797
+ view={view?.view ?? null}
798
+ stage={view?.stage ?? null}
799
+ eased={view?.eased ?? false}
800
+ regions={boxes}
801
+ selectedId={selectedId}
802
+ draft={draft}
803
+ pending={pending ? { box: pending, n: pendingNumber } : null}
804
+ interactive={!drawing}
805
+ onPick={(id) => {
806
+ setDrawerOpen(true);
807
+ setSelectedId(id);
808
+ }}
809
+ pendingRef={setPendingEl}
810
+ />
811
+ )}
812
+ </ImageZoomStage>
813
+ )}
814
+ {strip && (
815
+ <div className={`pointer-events-none absolute inset-x-0 z-[3] flex justify-center ${phone ? 'bottom-2.5' : 'top-2.5'}`}>{strip}</div>
816
+ )}
817
+ {regionsOn && phone && drawing && (
818
+ <div className="pointer-events-none absolute left-1/2 top-2.5 z-[3] -translate-x-1/2 whitespace-nowrap rounded-full border border-border bg-popover px-3 py-1.5 text-[12px] shadow-[0_8px_24px_-12px_rgb(0_0_0/0.4)]" data-region-hint="">
819
+ One finger draws · two fingers move the image
820
+ </div>
821
+ )}
328
822
  </div>
329
- )}
823
+ {stepping && (
824
+ <button type="button" className={circle} onClick={() => step(1)} aria-label="Next image" aria-disabled={last || undefined} data-lightbox-next="">
825
+ <svg {...ICON}>
826
+ <path d="m8.25 4.5 7.5 7.5-7.5 7.5" />
827
+ </svg>
828
+ </button>
829
+ )}
830
+ </div>
831
+ <div className="flex flex-none flex-col items-center gap-2 px-4 pb-4 pt-3">
832
+ {current.alt && <div className="max-w-[90vw] truncate text-[14px] font-medium">{current.alt}</div>}
833
+ {stepping && (
834
+ <div className="relative flex items-center gap-3">
835
+ <span className="inline-flex items-center gap-px rounded-lg border border-border/70 bg-muted/40 p-0.5">
836
+ <button type="button" className="flex h-7 w-7 items-center justify-center rounded-md hover:bg-muted" onClick={() => step(-1)} aria-label="Previous image" tabIndex={-1}>
837
+ <svg {...ICON} width={14} height={14}>
838
+ <path d="m15.75 19.5-7.5-7.5 7.5-7.5" />
839
+ </svg>
840
+ </button>
841
+ <button
842
+ ref={pillRef}
843
+ type="button"
844
+ className="flex h-7 max-w-[60vw] items-center gap-1.5 rounded-md px-2 text-[13.5px] hover:bg-muted"
845
+ aria-haspopup="grid"
846
+ aria-expanded={peekOpen}
847
+ aria-label={`${index + 1} of ${total}, ${name}. Show every image in this document`}
848
+ onClick={() => setPeekOpen((v) => !v)}
849
+ data-peek-opener=""
850
+ data-lightbox-pill=""
851
+ >
852
+ <span className="tabular-nums text-muted-foreground">
853
+ {index + 1} of {total}
854
+ </span>
855
+ <span className="text-muted-foreground" aria-hidden="true">
856
+ ·
857
+ </span>
858
+ <span className="truncate font-semibold">{name}</span>
859
+ <svg {...ICON} width={13} height={13} className={peekOpen ? 'rotate-180' : ''}>
860
+ <path d="m19.5 8.25-7.5 7.5-7.5-7.5" />
861
+ </svg>
862
+ </button>
863
+ <button type="button" className="flex h-7 w-7 items-center justify-center rounded-md hover:bg-muted" onClick={() => step(1)} aria-label="Next image" tabIndex={-1}>
864
+ <svg {...ICON} width={14} height={14}>
865
+ <path d="m8.25 4.5 7.5 7.5-7.5 7.5" />
866
+ </svg>
867
+ </button>
868
+ </span>
869
+ <span className={`whitespace-nowrap rounded-md bg-muted px-2 py-1 text-[12px] text-muted-foreground transition-opacity ${endNote ? 'opacity-100' : 'opacity-0'}`} aria-hidden="true" data-lightbox-end-note="">
870
+ {endNote}
871
+ </span>
872
+ {peekOpen && (
873
+ <div className="absolute bottom-[calc(100%+8px)] left-1/2 z-10 -translate-x-1/2">
874
+ <ImagePeek
875
+ ref={peekRef}
876
+ rows={rows}
877
+ current={String(index)}
878
+ label="Images in this document"
879
+ onPick={(key) => {
880
+ setPeekOpen(false);
881
+ go(Number(key));
882
+ pillRef.current?.focus({ preventScroll: true });
883
+ }}
884
+ onClose={(reason) => {
885
+ setPeekOpen(false);
886
+ if (reason === 'key') pillRef.current?.focus({ preventScroll: true });
887
+ }}
888
+ />
889
+ </div>
890
+ )}
891
+ </div>
892
+ )}
893
+ </div>
330
894
  </div>
895
+ {drawer}
331
896
  </div>
897
+ {regionsOn && pending && pendingEl && canDraw && (
898
+ <CommentPopover
899
+ key={`${pending.x},${pending.y},${pending.w},${pending.h}`}
900
+ anchorEl={pendingEl}
901
+ contextText={current.writtenSrc!}
902
+ headerText={regionHeader(pendingNumber, pending)}
903
+ isGlobal={false}
904
+ placement="side"
905
+ zIndex={210}
906
+ allowImages={false}
907
+ mentionSource={regions!.mentionSource}
908
+ onAskAI={askAI}
909
+ askAIContext={askAI ? {
910
+ kind: 'selection',
911
+ label: `Region ${pendingNumber} of ${imageRegionFileName(current.writtenSrc!)}`,
912
+ text: current.writtenSrc!,
913
+ detail: imageRegionLocationLines({
914
+ v: 1,
915
+ src: current.writtenSrc!,
916
+ sourceLine: current.line ?? null,
917
+ box: pending,
918
+ naturalWidth: dims?.width ?? pending.x + pending.w,
919
+ naturalHeight: dims?.height ?? pending.y + pending.h,
920
+ }),
921
+ ...(current.line ? { lineStart: current.line, lineEnd: current.line } : {}),
922
+ } : undefined}
923
+ onSubmit={(text, attachments, mentions) => saveRegion(text, attachments, mentions)}
924
+ onQuickLookGood={() => saveRegion(`${THUMBS_UP_LABEL.emoji} ${THUMBS_UP_LABEL.text}`, undefined, undefined, true)}
925
+ onClose={() => setPending(null)}
926
+ />
927
+ )}
332
928
  <div className="sr-only" aria-live="polite" data-lightbox-live="">
333
929
  {announced}
334
930
  </div>