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