@plannotator/ui 0.52.1 → 0.53.1
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 +284 -29
- package/README.md +45 -5
- package/components/AnnotationPanel.tsx +195 -118
- package/components/AnnotationToolbar.tsx +17 -14
- package/components/AnnotationToolstrip.tsx +54 -40
- package/components/BlockRenderer.tsx +20 -14
- package/components/CodeFilePopout.tsx +132 -69
- package/components/CommentPopover.tsx +8 -349
- package/components/ComposerTokenTextarea.tsx +379 -0
- package/components/ExportModal.tsx +6 -1
- package/components/HostTitle.tsx +108 -0
- package/components/HtmlSurfaceControls.tsx +2 -1
- package/components/ImageLightbox.tsx +288 -17
- package/components/ImagePeek.tsx +255 -0
- package/components/ImageResolverContext.tsx +31 -0
- package/components/InlineMarkdown.tsx +123 -15
- package/components/LocalLinkContext.tsx +49 -0
- package/components/MentionPicker.tsx +51 -0
- package/components/MenuVersionSection.tsx +51 -25
- package/components/OpenInAppButton.tsx +18 -49
- package/components/PlanHeaderMenu.tsx +2 -8
- package/components/ProviderIcons.tsx +10 -4
- package/components/RenderedMarkdown.tsx +40 -4
- package/components/Settings.tsx +33 -5
- package/components/SkillReferenceMenu.tsx +67 -18
- package/components/Tooltip.tsx +51 -4
- package/components/Viewer.tsx +220 -49
- package/components/ai/AIProviderBar.tsx +11 -0
- package/components/ai/AskSessionSetupHint.tsx +12 -0
- package/components/ai/DocumentAIChatPanel.tsx +17 -5
- package/components/ai/SessionAskNotice.tsx +4 -3
- package/components/blocks/BlockquoteBlock.tsx +159 -0
- package/components/blocks/HtmlBlock.tsx +121 -9
- package/components/blocks/QuestionBlock.tsx +183 -96
- package/components/blocks/QuestionContext.tsx +3 -0
- package/components/diagram/DiagramViewer.tsx +41 -4
- package/components/diagram/index.ts +1 -1
- package/components/html-viewer/HtmlViewer.tsx +95 -3
- package/components/html-viewer/bridge-script.asset.js +413 -7
- package/components/html-viewer/bridge-script.lite.ts +1 -1
- package/components/html-viewer/bridge-script.ts +454 -7
- package/components/html-viewer/useHtmlAnnotation.ts +51 -3
- package/components/icons/AgentIcons.tsx +9 -7
- package/components/plan-diff/PlanCleanDiffView.tsx +239 -36
- package/components/plan-diff/PlanDiffViewer.tsx +6 -1
- package/configure.ts +27 -1
- package/hooks/useAgentJobs.ts +8 -3
- package/hooks/useAnnotationHighlighter.ts +128 -23
- package/hooks/useExternalAnnotations.ts +6 -0
- package/hooks/useMentionAutocomplete.ts +1 -1
- package/hooks/useOpenInApps.ts +88 -0
- package/hooks/useSkillReferenceAutocomplete.ts +7 -3
- package/hooks/useUpdateCheck.ts +38 -98
- package/hooks/useUpdateCheckSetting.ts +43 -0
- package/hooks/useUpdateToast.ts +101 -0
- package/hooks/useVimSelection.ts +9 -0
- package/package.json +2 -2
- package/shortcuts/code-review/markdownPreview.shortcuts.ts +18 -0
- package/shortcuts/index.ts +1 -0
- package/shortcuts/runtime.ts +10 -3
- package/styles.css +1 -1
- package/types.ts +48 -0
- package/utils/agentClosed.ts +26 -0
- package/utils/agentToolSetting.ts +16 -9
- package/utils/aiProvider.ts +7 -0
- package/utils/askSessionSetupHint.ts +27 -0
- package/utils/blockTargeting.ts +14 -3
- package/utils/mentions.ts +34 -5
- package/utils/parser.ts +65 -51
- package/utils/pasteInsert.ts +234 -0
- package/utils/planDiffEngine.ts +272 -2
- package/utils/skillCatalog.ts +15 -2
- package/utils/skillReferences.ts +50 -13
- package/utils/storage.ts +5 -0
- package/utils/textCut.ts +41 -0
- package/utils/updateNotice.ts +120 -0
|
@@ -1,4 +1,57 @@
|
|
|
1
|
-
import React, { useEffect, useRef } from 'react';
|
|
1
|
+
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { ImagePeek, type ImagePeekHandle, type ImagePeekRow } from './ImagePeek';
|
|
3
|
+
|
|
4
|
+
/** One image of a document, for the lightbox's stepping (`images`). */
|
|
5
|
+
export interface LightboxImage {
|
|
6
|
+
src: string;
|
|
7
|
+
alt: string;
|
|
8
|
+
/** The heading the image sits under ("Options"), for "2 of 4 · in “Options”" and the peek's rows. */
|
|
9
|
+
section?: string | null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
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.
|
|
15
|
+
*/
|
|
16
|
+
export function lightboxImageName(image: Pick<LightboxImage, 'src' | 'alt'>): string {
|
|
17
|
+
try {
|
|
18
|
+
const base = typeof window !== 'undefined' && /^https?:/.test(window.location.href) ? window.location.href : 'http://localhost/';
|
|
19
|
+
const url = new URL(image.src, base);
|
|
20
|
+
const named = url.searchParams.get('path') ?? (url.protocol === 'data:' || url.protocol === 'blob:' ? '' : url.pathname);
|
|
21
|
+
const last = named.split(/[\\/]/).filter(Boolean).pop();
|
|
22
|
+
if (last) return decodeURIComponent(last);
|
|
23
|
+
} catch {
|
|
24
|
+
// Not a URL: fall back to the alt text.
|
|
25
|
+
}
|
|
26
|
+
return image.alt || 'Image';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Every image a rendered document shows, in document order, each with the
|
|
31
|
+
* heading it sits under: what the lightbox steps through (plan review and
|
|
32
|
+
* annotate's light version of the Inbox's one viewer, approval
|
|
33
|
+
* "inbox-image-viewer"). Only images that open the lightbox count
|
|
34
|
+
* (`.cursor-zoom-in`).
|
|
35
|
+
*/
|
|
36
|
+
export function collectDocumentImages(container: ParentNode | null): LightboxImage[] {
|
|
37
|
+
if (!container) return [];
|
|
38
|
+
const out: LightboxImage[] = [];
|
|
39
|
+
let section: string | null = null;
|
|
40
|
+
for (const el of container.querySelectorAll<HTMLElement>('h1, h2, h3, h4, h5, h6, img')) {
|
|
41
|
+
if (el.tagName !== 'IMG') {
|
|
42
|
+
section = (el.textContent ?? '').trim() || section;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const img = el as HTMLImageElement;
|
|
46
|
+
if (!img.classList.contains('cursor-zoom-in')) continue;
|
|
47
|
+
const src = img.getAttribute('src');
|
|
48
|
+
if (!src || out.some((i) => i.src === src)) continue;
|
|
49
|
+
out.push({ src, alt: img.getAttribute('alt') ?? '', section });
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const ICON = { width: 16, height: 16, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round' as const, strokeLinejoin: 'round' as const, 'aria-hidden': true };
|
|
2
55
|
|
|
3
56
|
/**
|
|
4
57
|
* Overlay for viewing one image enlarged. Shared by the markdown Viewer
|
|
@@ -15,24 +68,96 @@ import React, { useEffect, useRef } from 'react';
|
|
|
15
68
|
* overlay when it opens, which also pulls focus out of an HTML page's iframe
|
|
16
69
|
* (whose keys the parent would otherwise never see), and goes back to where
|
|
17
70
|
* it was when it closes.
|
|
71
|
+
*
|
|
72
|
+
* With `images` (two or more) it is the light version of the Inbox's one
|
|
73
|
+
* viewer: full screen, `2 of 4 · in “Options”`, ← → (and `]` `[`) through the
|
|
74
|
+
* document's images, stopping at the ends, Actual size, and the same peek
|
|
75
|
+
* 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.
|
|
18
78
|
*/
|
|
19
|
-
export const ImageLightbox: React.FC<{
|
|
79
|
+
export const ImageLightbox: React.FC<{
|
|
80
|
+
src: string;
|
|
81
|
+
alt: string;
|
|
82
|
+
onClose: () => void;
|
|
83
|
+
/** The document's images in order; with two or more the lightbox steps through them. Additive: absent keeps the single image overlay. */
|
|
84
|
+
images?: readonly LightboxImage[];
|
|
85
|
+
}> = ({ src, alt, onClose, images }) => {
|
|
20
86
|
const overlayRef = useRef<HTMLDivElement>(null);
|
|
21
87
|
const onCloseRef = useRef(onClose);
|
|
22
88
|
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');
|
|
92
|
+
const [peekOpen, setPeekOpen] = useState(false);
|
|
93
|
+
const [endNote, setEndNote] = useState<string | null>(null);
|
|
94
|
+
const [announced, setAnnounced] = useState('');
|
|
95
|
+
const peekRef = useRef<ImagePeekHandle>(null);
|
|
96
|
+
const pillRef = useRef<HTMLButtonElement>(null);
|
|
97
|
+
const endTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
98
|
+
const current = stepping ? images![index]! : { src, alt, section: null };
|
|
99
|
+
const name = lightboxImageName(current);
|
|
100
|
+
|
|
101
|
+
const step = (dir: 1 | -1) => {
|
|
102
|
+
if (!stepping) return;
|
|
103
|
+
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);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
go(next);
|
|
113
|
+
};
|
|
114
|
+
const go = (next: number) => {
|
|
115
|
+
setIndex(next);
|
|
116
|
+
setFit('fit');
|
|
117
|
+
const image = images![next]!;
|
|
118
|
+
setAnnounced(`${next + 1} of ${images!.length}, ${lightboxImageName(image)}${image.section ? `, in “${image.section}”` : ''}`);
|
|
119
|
+
};
|
|
120
|
+
const stepRef = useRef(step);
|
|
121
|
+
stepRef.current = step;
|
|
122
|
+
const peekOpenRef = useRef(peekOpen);
|
|
123
|
+
peekOpenRef.current = peekOpen;
|
|
124
|
+
|
|
125
|
+
useEffect(() => () => {
|
|
126
|
+
if (endTimer.current) clearTimeout(endTimer.current);
|
|
127
|
+
}, []);
|
|
23
128
|
|
|
24
129
|
useEffect(() => {
|
|
25
130
|
const previous = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
26
131
|
overlayRef.current?.focus({ preventScroll: true });
|
|
27
132
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
28
|
-
//
|
|
29
|
-
|
|
133
|
+
// The peek owns the keys while it is open (it is inside this modal, so they reach it from here).
|
|
134
|
+
if (peekOpenRef.current) {
|
|
135
|
+
e.stopImmediatePropagation();
|
|
136
|
+
if (peekRef.current?.handleKey({ key: e.key, isComposing: e.isComposing })) e.preventDefault();
|
|
137
|
+
else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) e.preventDefault();
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
// Tab stays native: the overlay's own controls take it.
|
|
30
141
|
if (e.key === 'Tab') return;
|
|
31
142
|
e.stopImmediatePropagation();
|
|
32
143
|
if (e.key === 'Escape' || (e.key === 'Enter' && (e.metaKey || e.ctrlKey))) {
|
|
33
144
|
e.preventDefault();
|
|
34
145
|
}
|
|
35
|
-
if (e.key === 'Escape')
|
|
146
|
+
if (e.key === 'Escape') {
|
|
147
|
+
onCloseRef.current();
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (e.metaKey || e.ctrlKey || e.altKey || e.isComposing) return;
|
|
151
|
+
if (e.key === 'ArrowRight' || e.key === ']') {
|
|
152
|
+
e.preventDefault();
|
|
153
|
+
stepRef.current(1);
|
|
154
|
+
} else if (e.key === 'ArrowLeft' || e.key === '[') {
|
|
155
|
+
e.preventDefault();
|
|
156
|
+
stepRef.current(-1);
|
|
157
|
+
} else if (e.key === 'ArrowDown' && e.target === pillRef.current) {
|
|
158
|
+
e.preventDefault();
|
|
159
|
+
setPeekOpen(true);
|
|
160
|
+
}
|
|
36
161
|
};
|
|
37
162
|
window.addEventListener('keydown', handleKeyDown, true);
|
|
38
163
|
return () => {
|
|
@@ -41,26 +166,172 @@ export const ImageLightbox: React.FC<{ src: string; alt: string; onClose: () =>
|
|
|
41
166
|
};
|
|
42
167
|
}, []);
|
|
43
168
|
|
|
169
|
+
const rows = useMemo<ImagePeekRow[]>(() => {
|
|
170
|
+
if (!stepping) return [];
|
|
171
|
+
const out: ImagePeekRow[] = [];
|
|
172
|
+
images!.forEach((image, i) => {
|
|
173
|
+
const label = image.section ?? 'Document';
|
|
174
|
+
let row = out.at(-1);
|
|
175
|
+
if (!row || row.label !== label) {
|
|
176
|
+
row = { key: `row-${i}`, label, items: [] };
|
|
177
|
+
out.push(row);
|
|
178
|
+
}
|
|
179
|
+
row.items.push({ key: String(i), name: lightboxImageName(image), thumb: image.src });
|
|
180
|
+
});
|
|
181
|
+
return out;
|
|
182
|
+
}, [stepping, images]);
|
|
183
|
+
|
|
184
|
+
if (!stepping) {
|
|
185
|
+
return (
|
|
186
|
+
<div
|
|
187
|
+
ref={overlayRef}
|
|
188
|
+
tabIndex={-1}
|
|
189
|
+
role="dialog"
|
|
190
|
+
aria-modal="true"
|
|
191
|
+
aria-label={alt || 'Image'}
|
|
192
|
+
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}
|
|
195
|
+
>
|
|
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
|
+
/>
|
|
202
|
+
{alt && (
|
|
203
|
+
<div className="mt-3 text-sm text-white/70 max-w-[90vw] text-center truncate">{alt}</div>
|
|
204
|
+
)}
|
|
205
|
+
</div>
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const total = images!.length;
|
|
210
|
+
const first = index === 0;
|
|
211
|
+
const last = index === total - 1;
|
|
212
|
+
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';
|
|
44
213
|
return (
|
|
45
214
|
<div
|
|
46
215
|
ref={overlayRef}
|
|
47
216
|
tabIndex={-1}
|
|
48
217
|
role="dialog"
|
|
49
218
|
aria-modal="true"
|
|
50
|
-
aria-label={
|
|
219
|
+
aria-label={`${name}, image ${index + 1} of ${total}`}
|
|
51
220
|
data-image-lightbox=""
|
|
52
|
-
|
|
53
|
-
|
|
221
|
+
data-image-lightbox-stepping=""
|
|
222
|
+
className="fixed inset-0 z-[200] flex flex-col bg-background text-foreground outline-none"
|
|
54
223
|
>
|
|
55
|
-
<
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
224
|
+
<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>
|
|
228
|
+
{current.section && (
|
|
229
|
+
<span className="min-w-0 truncate text-[14px] text-muted-foreground" data-lightbox-section="">
|
|
230
|
+
in “{current.section}”
|
|
231
|
+
</span>
|
|
232
|
+
)}
|
|
233
|
+
<span className="flex-1" />
|
|
234
|
+
<button
|
|
235
|
+
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'}
|
|
239
|
+
data-lightbox-fit=""
|
|
240
|
+
>
|
|
241
|
+
{fit === 'fit' ? 'Actual size' : 'Fit'}
|
|
242
|
+
</button>
|
|
243
|
+
<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
|
+
<svg {...ICON}>
|
|
245
|
+
<path d="M6 18 18 6M6 6l12 12" />
|
|
246
|
+
</svg>
|
|
247
|
+
</button>
|
|
248
|
+
</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
|
+
/>
|
|
328
|
+
</div>
|
|
329
|
+
)}
|
|
330
|
+
</div>
|
|
331
|
+
</div>
|
|
332
|
+
<div className="sr-only" aria-live="polite" data-lightbox-live="">
|
|
333
|
+
{announced}
|
|
334
|
+
</div>
|
|
64
335
|
</div>
|
|
65
336
|
);
|
|
66
337
|
};
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import React, { forwardRef, useEffect, useId, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The picture list a stepper opens ("A · Peek", approval "inbox-image-viewer",
|
|
5
|
+
* 2026-10-15): one row per group (a message, a section of a document) with
|
|
6
|
+
* its label, each item a thumbnail with its name under it, the open one
|
|
7
|
+
* ringed. It floats over what is open and moves nothing under it; on a phone
|
|
8
|
+
* it is a bottom sheet, three across.
|
|
9
|
+
*
|
|
10
|
+
* Keys only while it is open: ← → along the items, ↑ ↓ between rows (the
|
|
11
|
+
* nearest column), Home / End, Enter or Space opens the item (`onPick`), Esc or
|
|
12
|
+
* Tab closes it (`onClose`). The host puts focus back on the control that
|
|
13
|
+
* opened it. Focus starts on the open item.
|
|
14
|
+
*
|
|
15
|
+
* Images are only ever drawn through `<img src>`.
|
|
16
|
+
*/
|
|
17
|
+
export interface ImagePeekItem {
|
|
18
|
+
/** Unique across the peek. */
|
|
19
|
+
key: string;
|
|
20
|
+
name: string;
|
|
21
|
+
/** An image URL for the thumbnail; absent for a file drawn as a type tile. */
|
|
22
|
+
thumb?: string | null;
|
|
23
|
+
/** The type tile's short label ("MD", "PDF") when there is no thumbnail. */
|
|
24
|
+
tile?: string;
|
|
25
|
+
/** Comments on it, shown as a count badge when more than 0. */
|
|
26
|
+
count?: number;
|
|
27
|
+
/** A paperclip badge (an attached file, as opposed to an image in the text). */
|
|
28
|
+
attached?: boolean;
|
|
29
|
+
/** Extra words for a screen reader ("changed since it was sent"). */
|
|
30
|
+
detail?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ImagePeekRow {
|
|
34
|
+
key: string;
|
|
35
|
+
/** "11:22 AM", or a section heading. */
|
|
36
|
+
label: string;
|
|
37
|
+
items: ImagePeekItem[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ImagePeekProps {
|
|
41
|
+
rows: readonly ImagePeekRow[];
|
|
42
|
+
/** The open item's key: ringed, and where focus starts. */
|
|
43
|
+
current: string | null;
|
|
44
|
+
onPick: (key: string) => void;
|
|
45
|
+
/** Esc or Tab (`key`: the host puts focus back on its opener), or a press outside / the sheet's backdrop (`outside`). */
|
|
46
|
+
onClose: (reason: 'key' | 'outside') => void;
|
|
47
|
+
/** `popover`: a panel the host positions above its bar. `sheet`: a phone's bottom sheet, three across. */
|
|
48
|
+
variant?: 'popover' | 'sheet';
|
|
49
|
+
/** The grid's accessible name. */
|
|
50
|
+
label: string;
|
|
51
|
+
/** An id for the panel, for the opener's `aria-controls`. */
|
|
52
|
+
id?: string;
|
|
53
|
+
className?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const PaperclipIcon = () => (
|
|
57
|
+
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
58
|
+
<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48" />
|
|
59
|
+
</svg>
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
/** Where focus may move from `at` (row, column) for a key, or null when the key is not the grid's. */
|
|
63
|
+
export function peekMove(rows: readonly { items: readonly unknown[] }[], at: { row: number; col: number }, key: string): { row: number; col: number } | null {
|
|
64
|
+
const flat: { row: number; col: number }[] = [];
|
|
65
|
+
rows.forEach((row, r) => row.items.forEach((_, c) => flat.push({ row: r, col: c })));
|
|
66
|
+
if (flat.length === 0) return null;
|
|
67
|
+
const index = flat.findIndex((p) => p.row === at.row && p.col === at.col);
|
|
68
|
+
switch (key) {
|
|
69
|
+
case 'ArrowRight':
|
|
70
|
+
return flat[Math.min(flat.length - 1, index + 1)]!;
|
|
71
|
+
case 'ArrowLeft':
|
|
72
|
+
return flat[Math.max(0, index - 1)]!;
|
|
73
|
+
case 'Home':
|
|
74
|
+
return flat[0]!;
|
|
75
|
+
case 'End':
|
|
76
|
+
return flat[flat.length - 1]!;
|
|
77
|
+
case 'ArrowDown':
|
|
78
|
+
case 'ArrowUp': {
|
|
79
|
+
const step = key === 'ArrowDown' ? 1 : -1;
|
|
80
|
+
for (let r = at.row + step; r >= 0 && r < rows.length; r += step) {
|
|
81
|
+
const count = rows[r]!.items.length;
|
|
82
|
+
if (count > 0) return { row: r, col: Math.min(at.col, count - 1) };
|
|
83
|
+
}
|
|
84
|
+
return at;
|
|
85
|
+
}
|
|
86
|
+
default:
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A host that intercepts keys before they reach the peek (a modal's capture listener) hands them over here. */
|
|
92
|
+
export interface ImagePeekHandle {
|
|
93
|
+
/** Handle one key as the peek would; true when it was the peek's (the caller prevents its default). */
|
|
94
|
+
handleKey: (event: { key: string; isComposing?: boolean }) => boolean;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export const ImagePeek = forwardRef<ImagePeekHandle, ImagePeekProps>(function ImagePeek({ rows, current, onPick, onClose, variant = 'popover', label, id, className = '' }, ref) {
|
|
98
|
+
const sheet = variant === 'sheet';
|
|
99
|
+
const gridRef = useRef<HTMLDivElement>(null);
|
|
100
|
+
const autoId = useId();
|
|
101
|
+
const labelId = `${(id ?? `pn-peek-${autoId}`).replace(/:/g, '')}-label`;
|
|
102
|
+
const start = useMemo(() => {
|
|
103
|
+
for (let r = 0; r < rows.length; r++) {
|
|
104
|
+
const c = rows[r]!.items.findIndex((item) => item.key === current);
|
|
105
|
+
if (c >= 0) return { row: r, col: c };
|
|
106
|
+
}
|
|
107
|
+
const r = rows.findIndex((row) => row.items.length > 0);
|
|
108
|
+
return { row: Math.max(0, r), col: 0 };
|
|
109
|
+
}, [rows, current]);
|
|
110
|
+
const [focus, setFocus] = useState(start);
|
|
111
|
+
const onCloseRef = useRef(onClose);
|
|
112
|
+
onCloseRef.current = onClose;
|
|
113
|
+
|
|
114
|
+
const cellOf = (at: { row: number; col: number }) => gridRef.current?.querySelector<HTMLElement>(`[data-peek-cell="${at.row}:${at.col}"]`) ?? null;
|
|
115
|
+
|
|
116
|
+
// Focus starts on the open item, and the open item is in view.
|
|
117
|
+
useLayoutEffect(() => {
|
|
118
|
+
const cell = cellOf(start);
|
|
119
|
+
cell?.focus({ preventScroll: true });
|
|
120
|
+
cell?.scrollIntoView?.({ block: 'nearest' });
|
|
121
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
122
|
+
}, []);
|
|
123
|
+
|
|
124
|
+
// A press outside closes it (the opener toggles it itself).
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
if (sheet) return;
|
|
127
|
+
const onDown = (event: PointerEvent) => {
|
|
128
|
+
const target = event.target instanceof Node ? event.target : null;
|
|
129
|
+
if (!target || gridRef.current?.contains(target)) return;
|
|
130
|
+
if (target instanceof Element && target.closest('[data-peek-opener]')) return;
|
|
131
|
+
onCloseRef.current('outside');
|
|
132
|
+
};
|
|
133
|
+
document.addEventListener('pointerdown', onDown, true);
|
|
134
|
+
return () => document.removeEventListener('pointerdown', onDown, true);
|
|
135
|
+
}, [sheet]);
|
|
136
|
+
|
|
137
|
+
const focusRef = useRef(focus);
|
|
138
|
+
focusRef.current = focus;
|
|
139
|
+
const handleKey = (event: { key: string; isComposing?: boolean }): boolean => {
|
|
140
|
+
if (event.isComposing) return false;
|
|
141
|
+
if (event.key === 'Escape' || event.key === 'Tab') {
|
|
142
|
+
onClose('key');
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
const at = focusRef.current;
|
|
146
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
147
|
+
const item = rows[at.row]?.items[at.col];
|
|
148
|
+
if (item) onPick(item.key);
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
const next = peekMove(rows, at, event.key);
|
|
152
|
+
if (!next) return false;
|
|
153
|
+
focusRef.current = next;
|
|
154
|
+
setFocus(next);
|
|
155
|
+
const cell = cellOf(next);
|
|
156
|
+
cell?.focus({ preventScroll: true });
|
|
157
|
+
cell?.scrollIntoView?.({ block: 'nearest' });
|
|
158
|
+
return true;
|
|
159
|
+
};
|
|
160
|
+
const handleKeyRef = useRef(handleKey);
|
|
161
|
+
handleKeyRef.current = handleKey;
|
|
162
|
+
useImperativeHandle(ref, () => ({ handleKey: (event) => handleKeyRef.current(event) }), []);
|
|
163
|
+
|
|
164
|
+
const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
|
165
|
+
if (!handleKey({ key: event.key, isComposing: event.nativeEvent.isComposing })) return;
|
|
166
|
+
event.preventDefault();
|
|
167
|
+
event.stopPropagation();
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const panel = (
|
|
171
|
+
<div
|
|
172
|
+
ref={gridRef}
|
|
173
|
+
id={id}
|
|
174
|
+
role="grid"
|
|
175
|
+
aria-labelledby={labelId}
|
|
176
|
+
onKeyDown={onKeyDown}
|
|
177
|
+
data-image-peek={variant}
|
|
178
|
+
className={
|
|
179
|
+
sheet
|
|
180
|
+
? `pointer-events-auto max-h-[78vh] w-full overflow-y-auto rounded-t-2xl border-t border-border bg-popover px-3 pb-[calc(16px+env(safe-area-inset-bottom))] pt-2 text-popover-foreground shadow-2xl ${className}`
|
|
181
|
+
: `max-h-[min(460px,62vh)] w-[min(600px,calc(100vw-24px))] overflow-y-auto rounded-xl border border-border bg-popover p-2 text-popover-foreground shadow-[0_14px_36px_rgba(0,0,0,0.28)] ${className}`
|
|
182
|
+
}
|
|
183
|
+
>
|
|
184
|
+
<span id={labelId} className="sr-only">
|
|
185
|
+
{label}
|
|
186
|
+
</span>
|
|
187
|
+
{sheet && <div className="mx-auto mb-2 h-1.5 w-10 rounded-full bg-muted-foreground/30" aria-hidden="true" />}
|
|
188
|
+
{rows.map((row, r) => (
|
|
189
|
+
<div
|
|
190
|
+
key={row.key}
|
|
191
|
+
role="row"
|
|
192
|
+
className={`${sheet ? 'flex flex-col gap-2 py-2' : 'grid grid-cols-[84px_1fr] gap-2 px-1 py-2'} ${r > 0 ? 'border-t border-border/60' : ''}`}
|
|
193
|
+
data-peek-row={row.key}
|
|
194
|
+
>
|
|
195
|
+
<span role="rowheader" className={`text-[12.5px] text-muted-foreground ${sheet ? 'px-1 text-[14px]' : 'pt-1 tabular-nums'}`}>
|
|
196
|
+
{row.label}
|
|
197
|
+
</span>
|
|
198
|
+
<div role="presentation" className={sheet ? 'grid grid-cols-3 gap-2' : 'grid grid-cols-[repeat(auto-fill,minmax(132px,1fr))] gap-2'}>
|
|
199
|
+
{row.items.map((item, c) => {
|
|
200
|
+
const open = item.key === current;
|
|
201
|
+
const focused = focus.row === r && focus.col === c;
|
|
202
|
+
const words = [item.name, item.attached ? 'attached' : null, item.count ? `${item.count} comment${item.count === 1 ? '' : 's'}` : null, item.detail ?? null, open ? 'open now' : null]
|
|
203
|
+
.filter(Boolean)
|
|
204
|
+
.join(', ');
|
|
205
|
+
return (
|
|
206
|
+
<div
|
|
207
|
+
key={item.key}
|
|
208
|
+
role="gridcell"
|
|
209
|
+
tabIndex={focused ? 0 : -1}
|
|
210
|
+
aria-selected={open}
|
|
211
|
+
aria-label={words}
|
|
212
|
+
data-peek-cell={`${r}:${c}`}
|
|
213
|
+
data-peek-item={item.key}
|
|
214
|
+
data-peek-open={open ? '' : undefined}
|
|
215
|
+
onClick={() => onPick(item.key)}
|
|
216
|
+
onFocus={() => setFocus({ row: r, col: c })}
|
|
217
|
+
className="group flex min-w-0 cursor-pointer flex-col gap-1 rounded-lg p-1 outline-none hover:bg-muted/70 focus-visible:bg-muted focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-dashed focus-visible:outline-foreground/45"
|
|
218
|
+
>
|
|
219
|
+
<span
|
|
220
|
+
className={`relative block w-full overflow-hidden rounded-md border bg-muted ${sheet ? 'h-[99px]' : 'aspect-[16/10]'} ${open ? 'border-primary ring-2 ring-primary' : 'border-border/70'}`}
|
|
221
|
+
>
|
|
222
|
+
{item.thumb ? (
|
|
223
|
+
<img src={item.thumb} alt="" loading="lazy" decoding="async" draggable={false} className="h-full w-full object-cover object-top" />
|
|
224
|
+
) : (
|
|
225
|
+
<span className="flex h-full w-full items-center justify-center font-mono text-[12px] font-semibold tracking-wide text-muted-foreground">{item.tile ?? 'FILE'}</span>
|
|
226
|
+
)}
|
|
227
|
+
{item.attached && (
|
|
228
|
+
<span className="absolute bottom-1 left-1 flex h-[18px] w-[18px] items-center justify-center rounded-full bg-card text-foreground shadow-sm" aria-hidden="true" data-peek-attached="">
|
|
229
|
+
<PaperclipIcon />
|
|
230
|
+
</span>
|
|
231
|
+
)}
|
|
232
|
+
{item.count ? (
|
|
233
|
+
<span className="absolute right-1 top-1 flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-primary px-1 text-[11px] font-semibold text-primary-foreground" aria-hidden="true" data-peek-count="">
|
|
234
|
+
{item.count}
|
|
235
|
+
</span>
|
|
236
|
+
) : null}
|
|
237
|
+
</span>
|
|
238
|
+
<span className={`line-clamp-2 [overflow-wrap:anywhere] text-[12.5px] leading-tight ${open ? 'font-semibold text-foreground' : 'text-foreground/85'}`}>{item.name}</span>
|
|
239
|
+
</div>
|
|
240
|
+
);
|
|
241
|
+
})}
|
|
242
|
+
</div>
|
|
243
|
+
</div>
|
|
244
|
+
))}
|
|
245
|
+
</div>
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
if (!sheet) return panel;
|
|
249
|
+
return (
|
|
250
|
+
<div className="fixed inset-0 z-[220] flex flex-col justify-end" data-image-peek-sheet="">
|
|
251
|
+
<div className="absolute inset-0 bg-black/40" aria-hidden="true" onClick={() => onClose('outside')} data-peek-backdrop="" />
|
|
252
|
+
<div className="relative">{panel}</div>
|
|
253
|
+
</div>
|
|
254
|
+
);
|
|
255
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { createContext, useContext } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What a host makes of an image a document references (``,
|
|
5
|
+
* or an `<img src>` in a raw HTML block):
|
|
6
|
+
*
|
|
7
|
+
* - `src`: load the image from this URL instead of the default resolution.
|
|
8
|
+
* - `unavailable`: draw a small inert placeholder (the alt text, `title` as
|
|
9
|
+
* its tooltip) instead of an `<img>`, so nothing is fetched.
|
|
10
|
+
* - `null` from the resolver: the renderer's default for that image.
|
|
11
|
+
*/
|
|
12
|
+
export type ImageResolution =
|
|
13
|
+
| { kind: "src"; src: string }
|
|
14
|
+
| { kind: "unavailable"; title: string };
|
|
15
|
+
|
|
16
|
+
/** Called with the image source exactly as the document wrote it. */
|
|
17
|
+
export type ImageResolver = (src: string) => ImageResolution | null;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Provided by `RenderedMarkdown` from its optional `resolveImage` prop.
|
|
21
|
+
* Absent (null), every image renders exactly as it always has.
|
|
22
|
+
*/
|
|
23
|
+
export const ImageResolverContext = createContext<ImageResolver | null>(null);
|
|
24
|
+
|
|
25
|
+
export function useImageResolver(): ImageResolver | null {
|
|
26
|
+
return useContext(ImageResolverContext);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Classes for the inert placeholder an `unavailable` image renders as. */
|
|
30
|
+
export const UNAVAILABLE_IMAGE_CLASS =
|
|
31
|
+
"inline-flex items-center gap-1 my-1 rounded border border-dashed border-border px-2 py-0.5 text-xs text-muted-foreground";
|