@plannotator/ui 0.54.0 → 0.55.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,13 +9,16 @@ import { CommentPopover, type CommentAskAIHandler } from './CommentPopover';
9
9
  import { ImagePeek, type ImagePeekHandle, type ImagePeekRow } from './ImagePeek';
10
10
  import { RegionBoxLayer, RegionToolStrip, regionOf, type RegionBoxItem } from './ImageRegions';
11
11
  import { ImageZoomStage, type ImageZoomStageHandle, type WheelHintGate, type ZoomDraw, type ZoomMode } from './ImageZoomStage';
12
- import type { NaturalBox, ZoomSize, ZoomViewport } from './imageZoom';
12
+ import { canPanToward, type NaturalBox, type ZoomSize, type ZoomViewport } from './imageZoom';
13
13
  import { useIsMobile } from '../hooks/useIsMobile';
14
+ import { useSwipeStep } from '../hooks/useSwipeStep';
14
15
 
15
16
  /** One image of a document, for the lightbox's stepping (`images`). */
16
17
  export interface LightboxImage {
17
18
  src: string;
18
19
  alt: string;
20
+ /** What the viewer calls the image (a picture choice: "B · Quiet hover menu"). Absent: the file name its src names. */
21
+ name?: string;
19
22
  /** The heading the image sits under ("Options"), for "2 of 4 · in “Options”" and the peek's rows. */
20
23
  section?: string | null;
21
24
  /**
@@ -70,10 +73,12 @@ function isTextField(el: EventTarget | null): boolean {
70
73
  }
71
74
 
72
75
  /**
73
- * The file name an image's src names: the `path` a host route carries
74
- * (`/api/image?path=…`), else the URL's last segment, else the alt text.
76
+ * What the viewer calls an image: its `name` when the host gave one, else the
77
+ * file name its src names (the `path` a host route carries,
78
+ * `/api/image?path=…`, else the URL's last segment), else the alt text.
75
79
  */
76
- export function lightboxImageName(image: Pick<LightboxImage, 'src' | 'alt'>): string {
80
+ export function lightboxImageName(image: Pick<LightboxImage, 'src' | 'alt' | 'name'>): string {
81
+ if (image.name) return image.name;
77
82
  try {
78
83
  const base = typeof window !== 'undefined' && /^https?:/.test(window.location.href) ? window.location.href : 'http://localhost/';
79
84
  const url = new URL(image.src, base);
@@ -162,7 +167,15 @@ export const ImageLightbox: React.FC<{
162
167
  images?: readonly LightboxImage[];
163
168
  /** Comments on part of an image (#2100). Additive: absent, no region tools. */
164
169
  regions?: LightboxRegionComments;
165
- }> = ({ src, alt, onClose, images, regions }) => {
170
+ /**
171
+ * A touch screen's swipe through `images` (`useSwipeStep`, the Inbox
172
+ * phone viewer's, approval "inbox-image-swipe"): the image follows the
173
+ * finger and steps past a third of the width or on a flick, a zoomed image
174
+ * pans to its edge first, and a drag down at Fit closes. Additive: absent,
175
+ * no swipe.
176
+ */
177
+ swipe?: boolean;
178
+ }> = ({ src, alt, onClose, images, regions, swipe }) => {
166
179
  const overlayRef = useRef<HTMLDivElement>(null);
167
180
  const onCloseRef = useRef(onClose);
168
181
  onCloseRef.current = onClose;
@@ -389,6 +402,47 @@ export const ImageLightbox: React.FC<{
389
402
  }, [index]);
390
403
  const stepRef = useRef(step);
391
404
  stepRef.current = step;
405
+ // A touch screen's swipe (`swipe`): heard on the image area, which stays put (so a finger that comes back while the
406
+ // last step is still sliding is heard), and only the picture follows the finger (`swipeTrackRef`), not the region
407
+ // strip over it. A box being drawn, Region, or the peek keep the touch. One image alone: a drag down still closes.
408
+ const swipeAreaRef = useRef<HTMLDivElement>(null);
409
+ const swipeTrackRef = useRef<HTMLDivElement>(null);
410
+ /** The zoom stage, inside the track that follows the finger with `swipe`; without it, as before (no wrapper). */
411
+ const tracked = (stage: React.ReactNode) =>
412
+ swipe ? (
413
+ <div ref={swipeTrackRef} className="absolute inset-0" data-lightbox-track="">
414
+ {stage}
415
+ </div>
416
+ ) : (
417
+ stage
418
+ );
419
+ const dimsRef = useRef(dims);
420
+ dimsRef.current = dims;
421
+ const zoomModeRef = useRef(zoomMode);
422
+ zoomModeRef.current = zoomMode;
423
+ useSwipeStep(framed ? swipeAreaRef : overlayRef, {
424
+ enabled: !!swipe && !(canDraw && tool === 'region') && !peekOpen && !drawerOpen,
425
+ canStep: (dir) => stepping && index + dir >= 0 && index + dir < (list?.length ?? 0),
426
+ move: () => [swipeTrackRef.current],
427
+ onStep: (dir) => stepRef.current(dir),
428
+ wouldAsk: () => pending !== null,
429
+ keeps: (dir) => !stepping || canPanToward(stageRef.current?.visibleBox() ?? null, dimsRef.current?.width ?? 0, dir),
430
+ dismiss: {
431
+ can: () => {
432
+ if (zoomModeRef.current !== 'fit') return false;
433
+ const box = stageRef.current?.visibleBox() ?? null;
434
+ return box === null || box.y <= 1;
435
+ },
436
+ onDismiss: () => onCloseRef.current(),
437
+ onProgress: (share) => {
438
+ const overlay = overlayRef.current;
439
+ if (!overlay) return;
440
+ // The framed viewer's page colour, or the one-image overlay's dark veil, fades with the drag.
441
+ const veil = framed ? `color-mix(in oklch, var(--background) ${Math.round((1 - share) * 100)}%, transparent)` : `rgb(0 0 0 / ${(0.8 * (1 - share)).toFixed(3)})`;
442
+ overlay.style.backgroundColor = share > 0 ? veil : '';
443
+ },
444
+ },
445
+ });
392
446
  const steppingRef = useRef(stepping);
393
447
  steppingRef.current = stepping;
394
448
  const peekOpenRef = useRef(peekOpen);
@@ -531,22 +585,24 @@ export const ImageLightbox: React.FC<{
531
585
  data-image-lightbox=""
532
586
  className="fixed inset-0 z-[200] bg-black/80 text-white backdrop-blur-sm outline-none"
533
587
  >
534
- <ImageZoomStage
535
- ref={stageRef}
536
- key={src}
537
- src={src}
538
- alt={alt}
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}
549
- />
588
+ {tracked(
589
+ <ImageZoomStage
590
+ ref={stageRef}
591
+ key={src}
592
+ src={src}
593
+ alt={alt}
594
+ label={alt ? `Image: ${alt}` : 'Image'}
595
+ mode={zoomMode}
596
+ onModeChange={setZoomMode}
597
+ inset={{ x: 0.9, y: 0.85 }}
598
+ onBackgroundTap={onClose}
599
+ className="absolute inset-0"
600
+ imageClassName="shadow-2xl"
601
+ radius={8}
602
+ stageAttributes={{ 'data-lightbox-stage': zoomMode }}
603
+ wheelHint={wheelHint}
604
+ />
605
+ )}
550
606
  {alt && (
551
607
  <div className="pointer-events-none absolute inset-x-0 bottom-[2.5vh] mx-auto max-w-[90vw] truncate text-center text-sm text-white/70">{alt}</div>
552
608
  )}
@@ -711,48 +767,50 @@ export const ImageLightbox: React.FC<{
711
767
  </svg>
712
768
  </button>
713
769
  )}
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>
770
+ <div ref={swipeAreaRef} className="relative min-h-0 min-w-0 flex-1 self-stretch" data-lightbox-swipe={swipe ? '' : undefined}>
771
+ {tracked(
772
+ <ImageZoomStage
773
+ ref={stageRef}
774
+ key={`${index}:${current.src}`}
775
+ src={current.src}
776
+ alt={current.alt}
777
+ label={`Image: ${name}`}
778
+ description={drawing ? 'Region: drag to draw a box and comment on it. Or frame the spot with Actual size and the scroll keys, then press Enter to comment on what is on screen.' : undefined}
779
+ mode={zoomMode}
780
+ onModeChange={setZoomMode}
781
+ onScaleChange={setScale}
782
+ onImageLoad={setDims}
783
+ // A single image keeps #2067's tap beside the image to close
784
+ // (its click swallowed so the page beneath never gets it), in
785
+ // Move only: in Region a press there may start a box.
786
+ onBackgroundTap={!stepping && !drawing ? onClose : undefined}
787
+ onViewChange={regionsOn ? (v, stage, eased) => setView({ view: v, stage, eased }) : undefined}
788
+ drawing={drawing}
789
+ onDraw={drawing ? onDraw : undefined}
790
+ className="absolute inset-0"
791
+ imageClassName="shadow-xl"
792
+ stageAttributes={{ 'data-lightbox-stage': zoomMode, ...(regionsOn ? { 'data-region-tool-active': tool } : {}) }}
793
+ wheelHint={wheelHint}
794
+ >
795
+ {regionsOn && (
796
+ <RegionBoxLayer
797
+ view={view?.view ?? null}
798
+ stage={view?.stage ?? null}
799
+ eased={view?.eased ?? false}
800
+ regions={boxes}
801
+ selectedId={selectedId}
802
+ draft={draft}
803
+ pending={pending ? { box: pending, n: pendingNumber } : null}
804
+ interactive={!drawing}
805
+ onPick={(id) => {
806
+ setDrawerOpen(true);
807
+ setSelectedId(id);
808
+ }}
809
+ pendingRef={setPendingEl}
810
+ />
811
+ )}
812
+ </ImageZoomStage>
813
+ )}
756
814
  {strip && (
757
815
  <div className={`pointer-events-none absolute inset-x-0 z-[3] flex justify-center ${phone ? 'bottom-2.5' : 'top-2.5'}`}>{strip}</div>
758
816
  )}
@@ -1,9 +1,8 @@
1
- import React, { useState, useRef, useCallback, useEffect, useMemo } from "react";
2
- import { createPortal } from "react-dom";
1
+ import React, { useState, useMemo } from "react";
3
2
  import { isCodeFilePath, isCodeFilePathStrict, CODE_PATH_BARE_REGEX, parseCodePath } from "@plannotator/core/code-file";
4
3
  import { findCrossRepoRef, forgeRefLinks, matchCrossRepoRef, type CrossRepoRef } from "@plannotator/core/forge-refs";
5
- import { ensureHighlight, highlightToHtml } from "../utils/codeHighlight";
6
- import { useFenceTheme } from "../hooks/useFenceTheme";
4
+ import { CodeSnippetPreview } from "./CodeSnippetPreview";
5
+ import { useHoverPreview } from "../hooks/useHoverPreview";
7
6
  import { transformPlainText } from "../utils/inlineTransforms";
8
7
  import { hasLinkedDocExtension } from "../utils/markdownExtensions";
9
8
  import { getImageSrc } from "./ImageThumbnail";
@@ -77,101 +76,6 @@ function gateCodePath(
77
76
  }
78
77
  }
79
78
 
80
- function extToLanguage(filepath: string): string | undefined {
81
- const ext = filepath.split('.').pop()?.toLowerCase();
82
- const map: Record<string, string> = {
83
- ts: 'typescript', tsx: 'typescript', js: 'javascript', jsx: 'javascript',
84
- py: 'python', rb: 'ruby', rs: 'rust', go: 'go', java: 'java',
85
- css: 'css', scss: 'scss', json: 'json', yml: 'yaml', yaml: 'yaml',
86
- sql: 'sql', sh: 'bash', bash: 'bash', zsh: 'bash', md: 'markdown',
87
- html: 'html', xml: 'xml', toml: 'toml', swift: 'swift', kt: 'kotlin',
88
- };
89
- return ext ? map[ext] : undefined;
90
- }
91
-
92
- const CodeSnippetPreview: React.FC<{
93
- anchorEl: HTMLElement | null;
94
- contents: string;
95
- filepath: string;
96
- line: number;
97
- lineEnd?: number;
98
- onMouseEnter?: () => void;
99
- onMouseLeave?: () => void;
100
- }> = ({ anchorEl, contents, filepath, line, lineEnd, onMouseEnter, onMouseLeave }) => {
101
- const allLines = contents.split('\n');
102
- const start = Math.max(0, line - 1);
103
- const end = Math.min(allLines.length, (lineEnd ?? line));
104
- const snippet = allLines.slice(start, end).join('\n');
105
-
106
- const fenceTheme = useFenceTheme();
107
- // Bumped once the grammar is attached, to re-run the memo below with the
108
- // highlighter warm. Until then the snippet renders as plain text — no
109
- // auto-detection, and an unknown extension simply stays plain.
110
- const [highlighterGeneration, setHighlighterGeneration] = useState(0);
111
- const lang = extToLanguage(filepath);
112
-
113
- useEffect(() => {
114
- if (!lang) return;
115
- let cancelled = false;
116
- void ensureHighlight(lang, fenceTheme).then((ok) => {
117
- if (ok && !cancelled) setHighlighterGeneration((n) => n + 1);
118
- });
119
- return () => { cancelled = true; };
120
- }, [lang, fenceTheme]);
121
-
122
- const highlightedLines = useMemo(() => {
123
- // Highlight the snippet as one unit so multi-line constructs (block
124
- // comments, template literals) tokenise correctly, then split back into
125
- // rows: the highlighter joins lines with "\n" and never emits one inside a
126
- // span, so the split is exact.
127
- const html = lang ? highlightToHtml(snippet, lang, fenceTheme) : null;
128
- if (html !== null) return html.split('\n');
129
- return snippet.split('\n').map(line =>
130
- line.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'));
131
- // highlighterGeneration is the "grammar just became available" signal.
132
- // eslint-disable-next-line react-hooks/exhaustive-deps
133
- }, [snippet, lang, fenceTheme, highlighterGeneration]);
134
-
135
- if (!anchorEl) return null;
136
-
137
- const rect = anchorEl.getBoundingClientRect();
138
- const spaceBelow = window.innerHeight - rect.bottom;
139
- const showAbove = spaceBelow < 200 && rect.top > spaceBelow;
140
- const top = showAbove ? undefined : rect.bottom + 4;
141
- const bottom = showAbove ? window.innerHeight - rect.top + 4 : undefined;
142
- const left = Math.max(8, Math.min(rect.left, window.innerWidth - 520));
143
-
144
- return createPortal(
145
- <div
146
- className="fixed z-[9999] rounded-lg border border-border bg-card shadow-xl flex flex-col"
147
- style={{ top, bottom, left, maxWidth: 'min(600px, 90vw)', maxHeight: '300px' }}
148
- onMouseEnter={onMouseEnter}
149
- onMouseLeave={onMouseLeave}
150
- >
151
- <div className="px-3 py-1.5 border-b border-border/50 text-[10px] text-muted-foreground font-mono flex items-center justify-between gap-4 flex-shrink-0">
152
- <span>{filepath.split('/').pop()}</span>
153
- <span className="opacity-60">{lineEnd && lineEnd !== line ? `lines ${line}–${lineEnd}` : `line ${line}`}</span>
154
- </div>
155
- <div className="code-snippet-preview overflow-auto text-[12px] leading-5 min-h-0" style={{ padding: 0, background: 'var(--code-bg, #1e293b)' }}>
156
- <table className="border-collapse w-full">
157
- <tbody>
158
- {snippet.split('\n').map((_, i) => (
159
- <tr key={start + i} className="hover:bg-white/5">
160
- <td className="select-none text-muted-foreground/40 text-right pr-3 pl-3 py-0 align-top font-mono w-8 whitespace-nowrap" style={{ userSelect: 'none' }}>{start + i + 1}</td>
161
- <td
162
- className="font-mono pr-3 py-0 whitespace-pre"
163
- dangerouslySetInnerHTML={{ __html: highlightedLines[i] ?? '' }}
164
- />
165
- </tr>
166
- ))}
167
- </tbody>
168
- </table>
169
- </div>
170
- </div>,
171
- document.body,
172
- );
173
- };
174
-
175
79
  const CodeFileLink: React.FC<{
176
80
  candidate: string;
177
81
  display: string;
@@ -181,57 +85,16 @@ const CodeFileLink: React.FC<{
181
85
  const validation = useCodePathValidation();
182
86
  const gate = gateCodePath(candidate, validation);
183
87
  const [pickerOpen, setPickerOpen] = useState(false);
184
- const [hoverPreview, setHoverPreview] = useState<{ contents: string; filepath: string } | null>(null);
185
- const hoverPreviewRef = useRef(hoverPreview);
186
- hoverPreviewRef.current = hoverPreview;
187
- const anchorRef = useRef<HTMLElement | null>(null);
188
- const showTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
189
- const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
190
88
  const parsed = parseCodePath(candidate);
191
89
  const hasLineRef = parsed.line != null;
192
-
193
- const cancelHide = useCallback(() => {
194
- if (hideTimerRef.current) { clearTimeout(hideTimerRef.current); hideTimerRef.current = null; }
195
- }, []);
196
-
197
- const scheduleHide = useCallback(() => {
198
- cancelHide();
199
- hideTimerRef.current = setTimeout(() => {
200
- setHoverPreview(null);
201
- }, 200);
202
- }, [cancelHide]);
203
-
204
- const handleMouseEnter = useCallback(() => {
205
- if (!hasLineRef || gate.render === 'plain') return;
206
- cancelHide();
207
- if (hoverPreviewRef.current) return;
208
- showTimerRef.current = setTimeout(async () => {
209
- try {
210
- const data = await docPreviewFetcher(candidate, baseDir);
211
- if (data?.contents) setHoverPreview({ contents: data.contents, filepath: data.filepath ?? candidate });
212
- } catch {}
213
- }, 150);
214
- }, [candidate, hasLineRef, gate.render, cancelHide, baseDir]);
215
-
216
- const handleMouseLeave = useCallback(() => {
217
- if (showTimerRef.current) { clearTimeout(showTimerRef.current); showTimerRef.current = null; }
218
- scheduleHide();
219
- }, [scheduleHide]);
220
-
221
- const handlePreviewEnter = useCallback(() => {
222
- cancelHide();
223
- }, [cancelHide]);
224
-
225
- const handlePreviewLeave = useCallback(() => {
226
- scheduleHide();
227
- }, [scheduleHide]);
228
-
229
- useEffect(() => {
230
- return () => {
231
- if (showTimerRef.current) clearTimeout(showTimerRef.current);
232
- if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
233
- };
234
- }, []);
90
+ const hover = useHoverPreview<{ contents: string; filepath: string }>(async () => {
91
+ const data = await docPreviewFetcher(candidate, baseDir);
92
+ return data?.contents ? { contents: data.contents, filepath: data.filepath ?? candidate } : null;
93
+ }, hasLineRef && gate.render !== 'plain');
94
+ const hoverPreview = hover.preview;
95
+ const anchorRef = hover.anchorRef;
96
+ const handleMouseEnter = hover.onEnter;
97
+ const handleMouseLeave = hover.onLeave;
235
98
 
236
99
  if (gate.render === 'plain') {
237
100
  return (
@@ -280,8 +143,7 @@ const CodeFileLink: React.FC<{
280
143
  filepath={hoverPreview.filepath}
281
144
  line={parsed.line!}
282
145
  lineEnd={parsed.lineEnd}
283
- onMouseEnter={handlePreviewEnter}
284
- onMouseLeave={handlePreviewLeave}
146
+ {...hover.previewHandlers}
285
147
  />
286
148
  )}
287
149
  {pickerOpen && isAmbiguous && (
@@ -350,6 +212,9 @@ function canRenderSpacedDollarMath(tex: string): boolean {
350
212
  const trimmed = normalizeMathTex(tex);
351
213
  if (!trimmed) return false;
352
214
  if (!/^\s|\s$/.test(tex)) return true;
215
+ // Padding must be symmetric (`$ y $`). In `$write-better on the copy, and $`
216
+ // the closing `$` is really the opener of the next `$skill` token.
217
+ if (/^\s/.test(tex) !== /\s$/.test(tex)) return false;
353
218
  return (
354
219
  /\\[a-zA-Z]+/.test(trimmed) ||
355
220
  /[=^_{}+\-*/<>|]/.test(trimmed) ||