@plannotator/ui 0.54.0 → 0.56.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 +102 -6
- package/README.md +128 -3
- package/components/AnnotationPanel.tsx +2 -1
- package/components/AttachmentsButton.tsx +1 -1
- package/components/BlockRenderer.tsx +4 -1
- package/components/CodeFilePopout.tsx +6 -4
- package/components/CodeSnippetPreview.tsx +174 -0
- package/components/ComposerTokenTextarea.tsx +43 -2
- package/components/DeferredMarkdownContentsRail.tsx +38 -0
- package/components/DiffFileTree.tsx +7 -6
- package/components/DocBadges.tsx +47 -35
- package/components/DocumentContents.tsx +74 -0
- package/components/ImageLightbox.tsx +121 -63
- package/components/ImageZoomStage.tsx +32 -4
- package/components/InlineMarkdown.tsx +15 -150
- package/components/MarkdownContentsRail.tsx +777 -0
- package/components/Settings.tsx +2 -1
- package/components/TableOfContents.tsx +22 -7
- package/components/Viewer.tsx +51 -19
- package/components/blocks/QuestionBlock.tsx +14 -4
- package/components/chat/ChatComposer.tsx +192 -0
- package/components/chat/ChatFrame.tsx +524 -0
- package/components/chat/ChatTranscript.tsx +422 -0
- package/components/chat/chatMarkdown.ts +436 -0
- package/components/chat/chatRecipes.tsx +39 -0
- package/components/chat/chatWindowStore.ts +196 -0
- package/components/chat/index.ts +72 -0
- package/components/diagram/DiagramCanvas.tsx +7 -0
- package/components/diagram/DiagramComposer.tsx +15 -3
- package/components/diagram/DiagramViewer.tsx +1 -0
- package/components/docBadgesWidth.ts +29 -0
- package/components/html-viewer/HtmlViewer.tsx +36 -1
- package/components/imageZoom.ts +21 -0
- package/components/inbox-thread/FileName.tsx +118 -0
- package/components/inbox-thread/GuideCard.tsx +39 -0
- package/components/inbox-thread/ReaskNote.tsx +25 -0
- package/components/inbox-thread/SkillsLine.tsx +40 -0
- package/components/inbox-thread/Tip.tsx +105 -0
- package/components/inbox-thread/icons.tsx +262 -0
- package/components/inbox-thread/reask.ts +222 -0
- package/contents-rail.css +337 -0
- package/hooks/useActiveSection.ts +46 -63
- package/hooks/useAnnotationHighlighter.ts +40 -1
- package/hooks/useFileBrowser.ts +4 -3
- package/hooks/useHeadingScrollSpy.ts +77 -0
- package/hooks/useHoverPreview.ts +94 -0
- package/hooks/usePrefersReducedMotion.ts +23 -0
- package/hooks/useSwipeStep.ts +597 -0
- package/inbox-thread.css +4542 -0
- package/package.json +13 -4
- package/styles.css +1 -1
- package/theme.css +1 -0
- package/themes/tokyo-night-moon.css +34 -0
- package/utils/aiChatFormat.ts +32 -11
- package/utils/annotationHelpers.ts +32 -4
- package/utils/blockParse.ts +45 -0
- package/utils/headingScrollSpy.ts +105 -0
- package/utils/syntaxTheme.ts +1 -0
- package/utils/themeRegistry.ts +11 -0
|
@@ -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
|
|
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
|
-
*
|
|
74
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
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
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
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
|
)}
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
zoomLimits,
|
|
24
24
|
type FitAxis,
|
|
25
25
|
type NaturalBox,
|
|
26
|
+
type ZoomClear,
|
|
26
27
|
type ZoomInset,
|
|
27
28
|
type ZoomSize,
|
|
28
29
|
type ZoomViewport,
|
|
@@ -165,7 +166,11 @@ function prefersReducedMotion(): boolean {
|
|
|
165
166
|
* (Fit fills the width or height, the smallest scale stays the whole
|
|
166
167
|
* image), `padding` (Fit leaves that many pixels on every side), `overlay`
|
|
167
168
|
* (drawn over the image in the same transform, e.g. a marked-up copy, so it
|
|
168
|
-
* stays aligned at any zoom),
|
|
169
|
+
* stays aligned at any zoom), `clear` (rows the host floats things over: Fit
|
|
170
|
+
* and the pan's limits work between them, the image still slides under them
|
|
171
|
+
* when zoomed, and those rows still take the fingers), and `onImageLoad` /
|
|
172
|
+
* `onImageError`. `clear` and Region mode do not go together: `children` and
|
|
173
|
+
* `onViewChange` speak of the stage without it.
|
|
169
174
|
*
|
|
170
175
|
* Region comments (#2100) add three more, also optional: `drawing` (a
|
|
171
176
|
* primary press and drag draws a box, reported through `onDraw` in the
|
|
@@ -188,6 +193,8 @@ export const ImageZoomStage = forwardRef<ImageZoomStageHandle, {
|
|
|
188
193
|
padding?: number;
|
|
189
194
|
/** Which way Fit fits (default: the whole image). */
|
|
190
195
|
fitAxis?: FitAxis;
|
|
196
|
+
/** Rows at the stage's top and bottom that the host floats things over (default: none). */
|
|
197
|
+
clear?: ZoomClear;
|
|
191
198
|
/** Drawn over the image at its own size, in the image's transform. */
|
|
192
199
|
overlay?: React.ReactNode;
|
|
193
200
|
/** The image's own size, once it has loaded. */
|
|
@@ -216,7 +223,7 @@ export const ImageZoomStage = forwardRef<ImageZoomStageHandle, {
|
|
|
216
223
|
children?: React.ReactNode;
|
|
217
224
|
/** The "⌘ + scroll to zoom" hint's gate, shared across the viewer's stages; `false` turns the hint off. Absent: once per stage. */
|
|
218
225
|
wheelHint?: WheelHintGate | false;
|
|
219
|
-
}>(function ImageZoomStage({ src, alt, mode, onModeChange, onScaleChange, inset, padding, fitAxis, overlay, onImageLoad, onImageError, onBackgroundTap, className, imageClassName, radius = 6, stageAttributes, label, description, drawing = false, onDraw, onViewChange, children, wheelHint }, ref) {
|
|
226
|
+
}>(function ImageZoomStage({ src, alt, mode, onModeChange, onScaleChange, inset, padding, fitAxis, clear, overlay, onImageLoad, onImageError, onBackgroundTap, className, imageClassName, radius = 6, stageAttributes, label, description, drawing = false, onDraw, onViewChange, children, wheelHint }, ref) {
|
|
220
227
|
const stageRef = useRef<HTMLDivElement>(null);
|
|
221
228
|
const imgRef = useRef<HTMLImageElement>(null);
|
|
222
229
|
const [dims, setDims] = useState<ZoomSize | null>(null);
|
|
@@ -230,6 +237,10 @@ export const ImageZoomStage = forwardRef<ImageZoomStageHandle, {
|
|
|
230
237
|
paddingRef.current = padding;
|
|
231
238
|
const axisRef = useRef<FitAxis>(fitAxis ?? 'contain');
|
|
232
239
|
axisRef.current = fitAxis ?? 'contain';
|
|
240
|
+
const clearTop = clear?.top ?? 0;
|
|
241
|
+
const clearBottom = clear?.bottom ?? 0;
|
|
242
|
+
const clearRef = useRef({ top: clearTop, bottom: clearBottom });
|
|
243
|
+
clearRef.current = { top: clearTop, bottom: clearBottom };
|
|
233
244
|
const overlayRef = useRef<HTMLDivElement | null>(null);
|
|
234
245
|
const dimsRef = useRef<ZoomSize | null>(null);
|
|
235
246
|
const boxRef = useRef<StageBox | null>(null);
|
|
@@ -291,7 +302,9 @@ export const ImageZoomStage = forwardRef<ImageZoomStageHandle, {
|
|
|
291
302
|
const stage = stageRef.current;
|
|
292
303
|
if (!stage) return null;
|
|
293
304
|
const r = stage.getBoundingClientRect();
|
|
294
|
-
|
|
305
|
+
// The box every viewport is worked out in: the stage between the clear rows, its corner where the viewport's 0, 0 is drawn (`paint`).
|
|
306
|
+
const { top, bottom } = clearRef.current;
|
|
307
|
+
boxRef.current = { width: r.width, height: r.height - top - bottom, left: r.left, top: r.top + top };
|
|
295
308
|
return boxRef.current;
|
|
296
309
|
}, []);
|
|
297
310
|
|
|
@@ -304,7 +317,7 @@ export const ImageZoomStage = forwardRef<ImageZoomStageHandle, {
|
|
|
304
317
|
const paint = useCallback(
|
|
305
318
|
(el: HTMLElement, view: ZoomViewport, animate: boolean) => {
|
|
306
319
|
el.style.transition = animate && !prefersReducedMotion() ? 'transform 180ms cubic-bezier(0.2, 0, 0, 1)' : 'none';
|
|
307
|
-
el.style.transform = `translate(${view.x}px, ${view.y}px) scale(${view.scale})`;
|
|
320
|
+
el.style.transform = `translate(${view.x}px, ${view.y + clearRef.current.top}px) scale(${view.scale})`;
|
|
308
321
|
el.style.borderRadius = `${radius / view.scale}px`;
|
|
309
322
|
},
|
|
310
323
|
[radius],
|
|
@@ -376,6 +389,21 @@ export const ImageZoomStage = forwardRef<ImageZoomStageHandle, {
|
|
|
376
389
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
377
390
|
}, [padding, fitAxis, measure, applyMode, apply]);
|
|
378
391
|
|
|
392
|
+
// The host's clear rows changed (what floats came or went): Fit eases to the new space, anything else stays where it is on screen, within it.
|
|
393
|
+
const clearShape = useRef({ top: clearTop, bottom: clearBottom });
|
|
394
|
+
useLayoutEffect(() => {
|
|
395
|
+
const movedBy = clearShape.current.top - clearTop;
|
|
396
|
+
if (movedBy === 0 && clearShape.current.bottom === clearBottom) return;
|
|
397
|
+
clearShape.current = { top: clearTop, bottom: clearBottom };
|
|
398
|
+
measure();
|
|
399
|
+
if (!ready() || !viewRef.current) return;
|
|
400
|
+
if (modeRef.current === 'fit') return applyMode('fit', true);
|
|
401
|
+
const kept = clampViewport({ ...viewRef.current, y: viewRef.current.y + movedBy }, dimsRef.current!, boxRef.current!, limits());
|
|
402
|
+
if (modeRef.current === 'zoom') gesture(kept);
|
|
403
|
+
else apply(kept);
|
|
404
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
405
|
+
}, [clearTop, clearBottom, measure, applyMode, apply]);
|
|
406
|
+
|
|
379
407
|
// The image's own size, once it has loaded (or at once when it was cached).
|
|
380
408
|
useEffect(() => {
|
|
381
409
|
const img = imgRef.current;
|
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
import React, { useState,
|
|
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 {
|
|
6
|
-
import {
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>'));
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
},
|
|
196
|
-
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
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) ||
|