@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.
- package/HANDOFF.md +59 -5
- package/README.md +50 -2
- package/components/BlockRenderer.tsx +4 -1
- package/components/CodeSnippetPreview.tsx +174 -0
- package/components/DeferredMarkdownContentsRail.tsx +38 -0
- package/components/DiffFileTree.tsx +7 -6
- package/components/DocumentContents.tsx +74 -0
- package/components/ImageLightbox.tsx +121 -63
- package/components/InlineMarkdown.tsx +15 -150
- package/components/MarkdownContentsRail.tsx +777 -0
- package/components/TableOfContents.tsx +20 -6
- package/components/Viewer.tsx +8 -2
- package/components/blocks/QuestionBlock.tsx +14 -4
- package/components/html-viewer/HtmlViewer.tsx +36 -1
- package/components/imageZoom.ts +11 -0
- package/contents-rail.css +337 -0
- 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/package.json +5 -3
- package/styles.css +1 -1
- package/utils/annotationHelpers.ts +32 -4
- package/utils/blockParse.ts +45 -0
- package/utils/headingScrollSpy.ts +92 -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
|
)}
|
|
@@ -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) ||
|