@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.
Files changed (76) hide show
  1. package/HANDOFF.md +284 -29
  2. package/README.md +45 -5
  3. package/components/AnnotationPanel.tsx +195 -118
  4. package/components/AnnotationToolbar.tsx +17 -14
  5. package/components/AnnotationToolstrip.tsx +54 -40
  6. package/components/BlockRenderer.tsx +20 -14
  7. package/components/CodeFilePopout.tsx +132 -69
  8. package/components/CommentPopover.tsx +8 -349
  9. package/components/ComposerTokenTextarea.tsx +379 -0
  10. package/components/ExportModal.tsx +6 -1
  11. package/components/HostTitle.tsx +108 -0
  12. package/components/HtmlSurfaceControls.tsx +2 -1
  13. package/components/ImageLightbox.tsx +288 -17
  14. package/components/ImagePeek.tsx +255 -0
  15. package/components/ImageResolverContext.tsx +31 -0
  16. package/components/InlineMarkdown.tsx +123 -15
  17. package/components/LocalLinkContext.tsx +49 -0
  18. package/components/MentionPicker.tsx +51 -0
  19. package/components/MenuVersionSection.tsx +51 -25
  20. package/components/OpenInAppButton.tsx +18 -49
  21. package/components/PlanHeaderMenu.tsx +2 -8
  22. package/components/ProviderIcons.tsx +10 -4
  23. package/components/RenderedMarkdown.tsx +40 -4
  24. package/components/Settings.tsx +33 -5
  25. package/components/SkillReferenceMenu.tsx +67 -18
  26. package/components/Tooltip.tsx +51 -4
  27. package/components/Viewer.tsx +220 -49
  28. package/components/ai/AIProviderBar.tsx +11 -0
  29. package/components/ai/AskSessionSetupHint.tsx +12 -0
  30. package/components/ai/DocumentAIChatPanel.tsx +17 -5
  31. package/components/ai/SessionAskNotice.tsx +4 -3
  32. package/components/blocks/BlockquoteBlock.tsx +159 -0
  33. package/components/blocks/HtmlBlock.tsx +121 -9
  34. package/components/blocks/QuestionBlock.tsx +183 -96
  35. package/components/blocks/QuestionContext.tsx +3 -0
  36. package/components/diagram/DiagramViewer.tsx +41 -4
  37. package/components/diagram/index.ts +1 -1
  38. package/components/html-viewer/HtmlViewer.tsx +95 -3
  39. package/components/html-viewer/bridge-script.asset.js +413 -7
  40. package/components/html-viewer/bridge-script.lite.ts +1 -1
  41. package/components/html-viewer/bridge-script.ts +454 -7
  42. package/components/html-viewer/useHtmlAnnotation.ts +51 -3
  43. package/components/icons/AgentIcons.tsx +9 -7
  44. package/components/plan-diff/PlanCleanDiffView.tsx +239 -36
  45. package/components/plan-diff/PlanDiffViewer.tsx +6 -1
  46. package/configure.ts +27 -1
  47. package/hooks/useAgentJobs.ts +8 -3
  48. package/hooks/useAnnotationHighlighter.ts +128 -23
  49. package/hooks/useExternalAnnotations.ts +6 -0
  50. package/hooks/useMentionAutocomplete.ts +1 -1
  51. package/hooks/useOpenInApps.ts +88 -0
  52. package/hooks/useSkillReferenceAutocomplete.ts +7 -3
  53. package/hooks/useUpdateCheck.ts +38 -98
  54. package/hooks/useUpdateCheckSetting.ts +43 -0
  55. package/hooks/useUpdateToast.ts +101 -0
  56. package/hooks/useVimSelection.ts +9 -0
  57. package/package.json +2 -2
  58. package/shortcuts/code-review/markdownPreview.shortcuts.ts +18 -0
  59. package/shortcuts/index.ts +1 -0
  60. package/shortcuts/runtime.ts +10 -3
  61. package/styles.css +1 -1
  62. package/types.ts +48 -0
  63. package/utils/agentClosed.ts +26 -0
  64. package/utils/agentToolSetting.ts +16 -9
  65. package/utils/aiProvider.ts +7 -0
  66. package/utils/askSessionSetupHint.ts +27 -0
  67. package/utils/blockTargeting.ts +14 -3
  68. package/utils/mentions.ts +34 -5
  69. package/utils/parser.ts +65 -51
  70. package/utils/pasteInsert.ts +234 -0
  71. package/utils/planDiffEngine.ts +272 -2
  72. package/utils/skillCatalog.ts +15 -2
  73. package/utils/skillReferences.ts +50 -13
  74. package/utils/storage.ts +5 -0
  75. package/utils/textCut.ts +41 -0
  76. 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<{ src: string; alt: string; onClose: () => void }> = ({ src, alt, onClose }) => {
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
- // Tab stays native: the overlay is the only focusable thing that matters
29
- // here, and trapping it buys nothing for a click-to-close surface.
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') onCloseRef.current();
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={alt || 'Image'}
219
+ aria-label={`${name}, image ${index + 1} of ${total}`}
51
220
  data-image-lightbox=""
52
- className="fixed inset-0 z-[200] flex flex-col items-center justify-center bg-black/80 backdrop-blur-sm cursor-zoom-out outline-none"
53
- onClick={onClose}
221
+ data-image-lightbox-stepping=""
222
+ className="fixed inset-0 z-[200] flex flex-col bg-background text-foreground outline-none"
54
223
  >
55
- <img
56
- src={src}
57
- alt={alt}
58
- className="max-w-[90vw] max-h-[85vh] object-contain rounded-lg shadow-2xl"
59
- onClick={(e) => e.stopPropagation()}
60
- />
61
- {alt && (
62
- <div className="mt-3 text-sm text-white/70 max-w-[90vw] text-center truncate">{alt}</div>
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 (`![shot](img/a.png)`,
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";