@plannotator/ui 0.35.2 → 0.37.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 +4 -3
- package/README.md +73 -1
- package/components/DocBadges.tsx +9 -6
- package/components/StickyHeaderLane.tsx +70 -58
- package/components/Viewer.tsx +272 -94
- package/components/VimTargetReticle.tsx +1 -1
- package/components/compactHeaderLayout.ts +52 -0
- package/hooks/useAnnotationHighlighter.ts +31 -19
- package/package.json +1 -1
- package/styles.css +1 -1
- package/utils/vimScroll.ts +1 -1
package/components/Viewer.tsx
CHANGED
|
@@ -44,7 +44,7 @@ import { MermaidBlock } from './MermaidBlock';
|
|
|
44
44
|
import { isGraphvizLanguage, isMermaidLanguage } from './diagramLanguages';
|
|
45
45
|
import { getIdentity } from '../utils/identity';
|
|
46
46
|
import { type QuickLabel } from '../utils/quickLabels';
|
|
47
|
-
import { DocBadges, type LinkedDocBadgeInfo } from './DocBadges';
|
|
47
|
+
import { DocBadges, type DocBadgesProps, type LinkedDocBadgeInfo } from './DocBadges';
|
|
48
48
|
import { PinpointOverlay } from './PinpointOverlay';
|
|
49
49
|
import { usePinpoint } from '../hooks/usePinpoint';
|
|
50
50
|
import { useAnnotationHighlighter } from '../hooks/useAnnotationHighlighter';
|
|
@@ -58,8 +58,24 @@ import {
|
|
|
58
58
|
} from '../hooks/useScrollViewport';
|
|
59
59
|
import { decodeAnchorHash } from '../utils/anchors';
|
|
60
60
|
import { VimModeOverlay } from './VimModeOverlay';
|
|
61
|
+
import { AnnotationToolstrip } from './AnnotationToolstrip';
|
|
62
|
+
import {
|
|
63
|
+
resolveCompactHeaderGeometry,
|
|
64
|
+
snapCompactHeaderWidth,
|
|
65
|
+
} from './compactHeaderLayout';
|
|
66
|
+
|
|
67
|
+
/** Typed controls required for Viewer's opt-in, in-flow annotation header. */
|
|
68
|
+
export interface ViewerAnnotationHeaderConfig {
|
|
69
|
+
/** Persist and apply a Select or Pinpoint input-method change. */
|
|
70
|
+
readonly onInputMethodChange: (method: InputMethod) => void;
|
|
71
|
+
/** Persist and apply an annotation-mode change. */
|
|
72
|
+
readonly onModeChange: (mode: EditorMode) => void;
|
|
73
|
+
/** Omit Quick Label without changing or coercing the current mode. */
|
|
74
|
+
readonly hideQuickLabel?: boolean;
|
|
75
|
+
}
|
|
61
76
|
|
|
62
|
-
|
|
77
|
+
/** Public properties for the Markdown document Viewer. */
|
|
78
|
+
export interface ViewerProps {
|
|
63
79
|
blocks: Block[];
|
|
64
80
|
markdown: string;
|
|
65
81
|
frontmatter?: Frontmatter | null;
|
|
@@ -75,6 +91,14 @@ interface ViewerProps {
|
|
|
75
91
|
onRemoveGlobalAttachment?: (path: string) => void;
|
|
76
92
|
repoInfo?: { display: string; branch?: string; host?: string } | null;
|
|
77
93
|
stickyActions?: boolean;
|
|
94
|
+
/**
|
|
95
|
+
* Render compact annotation controls and the existing document actions in
|
|
96
|
+
* one measured, in-flow header owned by Viewer. The header reserves its
|
|
97
|
+
* responsive height before document content and follows `stickyActions`.
|
|
98
|
+
* Omit this prop to retain the legacy floating action bar exactly. Read-only
|
|
99
|
+
* viewers suppress the annotation header rather than expose dead controls.
|
|
100
|
+
*/
|
|
101
|
+
annotationHeader?: ViewerAnnotationHeaderConfig;
|
|
78
102
|
/** Render the plan as a floating card on a grid background (shadow/border/padding). Default false. */
|
|
79
103
|
gridEnabled?: boolean;
|
|
80
104
|
onOpenLinkedDoc?: (path: string) => void;
|
|
@@ -189,6 +213,118 @@ const FrontmatterCard: React.FC<{ frontmatter: Frontmatter }> = ({ frontmatter }
|
|
|
189
213
|
);
|
|
190
214
|
};
|
|
191
215
|
|
|
216
|
+
interface ViewerDocumentHeaderProps {
|
|
217
|
+
readonly config: ViewerAnnotationHeaderConfig;
|
|
218
|
+
readonly inputMethod: InputMethod;
|
|
219
|
+
readonly mode: EditorMode;
|
|
220
|
+
readonly taterMode: boolean;
|
|
221
|
+
readonly sticky: boolean;
|
|
222
|
+
readonly stuck: boolean;
|
|
223
|
+
readonly sentinelRef: React.RefObject<HTMLDivElement | null>;
|
|
224
|
+
readonly badges: Omit<DocBadgesProps, 'layout'>;
|
|
225
|
+
readonly actions: React.ReactNode;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Viewer-owned header that keeps leading controls and trailing actions in one flow. */
|
|
229
|
+
const ViewerDocumentHeader: React.FC<ViewerDocumentHeaderProps> = ({
|
|
230
|
+
config,
|
|
231
|
+
inputMethod,
|
|
232
|
+
mode,
|
|
233
|
+
taterMode,
|
|
234
|
+
sticky,
|
|
235
|
+
stuck,
|
|
236
|
+
sentinelRef,
|
|
237
|
+
badges,
|
|
238
|
+
actions,
|
|
239
|
+
}) => {
|
|
240
|
+
const headerRef = useRef<HTMLDivElement>(null);
|
|
241
|
+
const actionsRef = useRef<HTMLDivElement>(null);
|
|
242
|
+
const [headerWidth, setHeaderWidth] = useState(0);
|
|
243
|
+
const [actionsWidth, setActionsWidth] = useState(0);
|
|
244
|
+
|
|
245
|
+
useEffect(() => {
|
|
246
|
+
const header = headerRef.current;
|
|
247
|
+
if (!header) return;
|
|
248
|
+
const observer = new ResizeObserver(([entry]) => {
|
|
249
|
+
const next = snapCompactHeaderWidth(entry.contentRect.width);
|
|
250
|
+
setHeaderWidth((current) => current === next ? current : next);
|
|
251
|
+
});
|
|
252
|
+
observer.observe(header);
|
|
253
|
+
return () => observer.disconnect();
|
|
254
|
+
}, []);
|
|
255
|
+
|
|
256
|
+
useEffect(() => {
|
|
257
|
+
const actionCluster = actionsRef.current;
|
|
258
|
+
if (!actionCluster) return;
|
|
259
|
+
const observer = new ResizeObserver(([entry]) => {
|
|
260
|
+
const next = snapCompactHeaderWidth(entry.contentRect.width);
|
|
261
|
+
setActionsWidth((current) => current === next ? current : next);
|
|
262
|
+
});
|
|
263
|
+
observer.observe(actionCluster);
|
|
264
|
+
return () => observer.disconnect();
|
|
265
|
+
}, []);
|
|
266
|
+
|
|
267
|
+
const geometry = resolveCompactHeaderGeometry({
|
|
268
|
+
containerWidth: headerWidth,
|
|
269
|
+
trailingWidth: actionsWidth,
|
|
270
|
+
leadingInset: 0,
|
|
271
|
+
});
|
|
272
|
+
const narrow = geometry.layout === 'narrow';
|
|
273
|
+
const iconOnly = geometry.layout !== 'wide';
|
|
274
|
+
|
|
275
|
+
return (
|
|
276
|
+
<>
|
|
277
|
+
{sticky && <div ref={sentinelRef} className="h-0 w-0" aria-hidden="true" />}
|
|
278
|
+
<div
|
|
279
|
+
ref={headerRef}
|
|
280
|
+
data-annotation-exclude
|
|
281
|
+
data-print-hide
|
|
282
|
+
data-viewer-document-header
|
|
283
|
+
data-header-layout={geometry.layout}
|
|
284
|
+
className={`annotation-exclude ${sticky ? 'sticky top-3' : 'relative'} z-40 mb-3 md:mb-4 rounded-lg transition-colors duration-150 ${
|
|
285
|
+
stuck ? 'bg-card/95 backdrop-blur-sm shadow-sm' : ''
|
|
286
|
+
}`}
|
|
287
|
+
>
|
|
288
|
+
<div className={`flex min-w-0 ${
|
|
289
|
+
narrow
|
|
290
|
+
? 'flex-col items-stretch gap-2'
|
|
291
|
+
: 'flex-row items-start justify-between gap-4'
|
|
292
|
+
}`}>
|
|
293
|
+
<div
|
|
294
|
+
data-viewer-annotation-controls
|
|
295
|
+
className={`inline-flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 py-1 md:py-2 ${
|
|
296
|
+
narrow ? 'w-full' : 'flex-1'
|
|
297
|
+
}`}
|
|
298
|
+
>
|
|
299
|
+
<div className="flex-shrink-0">
|
|
300
|
+
<AnnotationToolstrip
|
|
301
|
+
inputMethod={inputMethod}
|
|
302
|
+
onInputMethodChange={config.onInputMethodChange}
|
|
303
|
+
mode={mode}
|
|
304
|
+
onModeChange={config.onModeChange}
|
|
305
|
+
taterMode={taterMode}
|
|
306
|
+
hideQuickLabel={config.hideQuickLabel}
|
|
307
|
+
compact
|
|
308
|
+
iconOnly={iconOnly}
|
|
309
|
+
/>
|
|
310
|
+
</div>
|
|
311
|
+
<DocBadges layout="header" {...badges} />
|
|
312
|
+
</div>
|
|
313
|
+
<div
|
|
314
|
+
ref={actionsRef}
|
|
315
|
+
data-sticky-actions
|
|
316
|
+
className={`flex flex-shrink-0 items-start gap-1 rounded-lg p-1 md:gap-2 md:p-2 ${
|
|
317
|
+
narrow ? 'self-end' : ''
|
|
318
|
+
}`}
|
|
319
|
+
>
|
|
320
|
+
{actions}
|
|
321
|
+
</div>
|
|
322
|
+
</div>
|
|
323
|
+
</div>
|
|
324
|
+
</>
|
|
325
|
+
);
|
|
326
|
+
};
|
|
327
|
+
|
|
192
328
|
/**
|
|
193
329
|
* Render and annotate a parsed Markdown document.
|
|
194
330
|
*
|
|
@@ -211,6 +347,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
211
347
|
onRemoveGlobalAttachment,
|
|
212
348
|
repoInfo,
|
|
213
349
|
stickyActions = true,
|
|
350
|
+
annotationHeader,
|
|
214
351
|
gridEnabled = false,
|
|
215
352
|
planDiffStats,
|
|
216
353
|
isPlanDiffActive,
|
|
@@ -242,6 +379,8 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
242
379
|
vimHudKeyPanelEnabled = true,
|
|
243
380
|
onVimHudKeyPanelChange,
|
|
244
381
|
}, ref) => {
|
|
382
|
+
const viewerAnnotationHeader = readOnly ? undefined : annotationHeader;
|
|
383
|
+
const hasViewerAnnotationHeader = viewerAnnotationHeader !== undefined;
|
|
245
384
|
const [copied, setCopied] = useState(false);
|
|
246
385
|
const [lightbox, setLightbox] = useState<{ src: string; alt: string } | null>(null);
|
|
247
386
|
const [locationHash, setLocationHash] = useState(() => window.location.hash);
|
|
@@ -271,6 +410,10 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
271
410
|
const docBadgesRef = useRef<HTMLDivElement | null>(null);
|
|
272
411
|
const [badgeClearance, setBadgeClearance] = useState(0);
|
|
273
412
|
useEffect(() => {
|
|
413
|
+
if (hasViewerAnnotationHeader) {
|
|
414
|
+
setBadgeClearance(0);
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
274
417
|
const el = docBadgesRef.current;
|
|
275
418
|
const article = containerRef.current;
|
|
276
419
|
if (!el || !article) { setBadgeClearance(0); return; }
|
|
@@ -284,7 +427,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
284
427
|
ro.observe(el);
|
|
285
428
|
window.addEventListener('resize', measure);
|
|
286
429
|
return () => { ro.disconnect(); window.removeEventListener('resize', measure); };
|
|
287
|
-
}, [repoInfo, hasPreviousVersion, showDemoBadge, linkedDocInfo, archiveInfo, sourceInfo, planDiffStats, openInAppPath]);
|
|
430
|
+
}, [hasViewerAnnotationHeader, repoInfo, hasPreviousVersion, showDemoBadge, linkedDocInfo, archiveInfo, sourceInfo, planDiffStats, openInAppPath]);
|
|
288
431
|
|
|
289
432
|
// Per-doc heading slug map with dedup — computed once per blocks array so
|
|
290
433
|
// anchor ids stay stable across re-renders and duplicate heading texts get
|
|
@@ -605,14 +748,18 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
605
748
|
// The IntersectionObserver root must be the actual scroll element — the
|
|
606
749
|
// OverlayScrollArea viewport — not the <main> host, which doesn't scroll.
|
|
607
750
|
useEffect(() => {
|
|
608
|
-
if (!stickyActions
|
|
751
|
+
if (!stickyActions) {
|
|
752
|
+
setIsStuck(false);
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
if (!stickySentinelRef.current || !scrollViewport) return;
|
|
609
756
|
const observer = new IntersectionObserver(
|
|
610
757
|
([entry]) => setIsStuck(!entry.isIntersecting),
|
|
611
758
|
{ root: getScrollViewportIntersectionRoot(scrollViewport), threshold: 0 }
|
|
612
759
|
);
|
|
613
760
|
observer.observe(stickySentinelRef.current);
|
|
614
761
|
return () => observer.disconnect();
|
|
615
|
-
}, [stickyActions, scrollViewport]);
|
|
762
|
+
}, [hasViewerAnnotationHeader, stickyActions, scrollViewport]);
|
|
616
763
|
|
|
617
764
|
useEffect(() => {
|
|
618
765
|
const handleHashChange = () => {
|
|
@@ -634,12 +781,15 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
634
781
|
const target = document.getElementById(anchor);
|
|
635
782
|
if (!target || !container.contains(target)) return false;
|
|
636
783
|
|
|
637
|
-
const
|
|
638
|
-
|
|
639
|
-
|
|
784
|
+
const documentHeader = stickyActions
|
|
785
|
+
? container.querySelector<HTMLElement>('[data-viewer-document-header]')
|
|
786
|
+
?? container.querySelector<HTMLElement>('[data-sticky-actions]')
|
|
787
|
+
: null;
|
|
788
|
+
const stickyTop = documentHeader
|
|
789
|
+
? Number.parseFloat(window.getComputedStyle(documentHeader).top || '0') || 0
|
|
640
790
|
: 0;
|
|
641
|
-
const headerOffset =
|
|
642
|
-
?
|
|
791
|
+
const headerOffset = documentHeader
|
|
792
|
+
? documentHeader.getBoundingClientRect().height + stickyTop
|
|
643
793
|
: 0;
|
|
644
794
|
const containerRect = getScrollViewportRect(scrollViewport);
|
|
645
795
|
const targetRect = target.getBoundingClientRect();
|
|
@@ -651,7 +801,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
651
801
|
behavior: 'smooth',
|
|
652
802
|
});
|
|
653
803
|
return true;
|
|
654
|
-
}, [scrollViewport]);
|
|
804
|
+
}, [scrollViewport, stickyActions]);
|
|
655
805
|
|
|
656
806
|
useEffect(() => {
|
|
657
807
|
if (!scrollViewport || !locationHash || lastAutoScrolledHashRef.current === locationHash) return;
|
|
@@ -788,6 +938,82 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
788
938
|
|
|
789
939
|
const codePathValidation = useValidatedCodePaths(markdown, codePathBaseDir, disableCodePathValidation);
|
|
790
940
|
|
|
941
|
+
const documentActions = (
|
|
942
|
+
<>
|
|
943
|
+
{messagePickerInfo && (
|
|
944
|
+
<button
|
|
945
|
+
onClick={messagePickerInfo.onOpen}
|
|
946
|
+
className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground bg-muted/50 hover:bg-muted rounded-md transition-colors"
|
|
947
|
+
title="Pick a different message to annotate"
|
|
948
|
+
>
|
|
949
|
+
<MessagesIcon />
|
|
950
|
+
{actionsLabelMode === 'full' && (
|
|
951
|
+
<span>Message {messagePickerInfo.current} of {messagePickerInfo.total}</span>
|
|
952
|
+
)}
|
|
953
|
+
{actionsLabelMode === 'short' && (
|
|
954
|
+
<span>{messagePickerInfo.current}/{messagePickerInfo.total}</span>
|
|
955
|
+
)}
|
|
956
|
+
</button>
|
|
957
|
+
)}
|
|
958
|
+
|
|
959
|
+
{!readOnly && onAddGlobalAttachment && onRemoveGlobalAttachment && (
|
|
960
|
+
<AttachmentsButton
|
|
961
|
+
images={globalAttachments}
|
|
962
|
+
onAdd={onAddGlobalAttachment}
|
|
963
|
+
onRemove={onRemoveGlobalAttachment}
|
|
964
|
+
variant="toolbar"
|
|
965
|
+
hideLabel={actionsLabelMode === 'icon'}
|
|
966
|
+
/>
|
|
967
|
+
)}
|
|
968
|
+
|
|
969
|
+
{!readOnly && (
|
|
970
|
+
<button
|
|
971
|
+
ref={globalCommentButtonRef}
|
|
972
|
+
onClick={() => {
|
|
973
|
+
const anchorEl = globalCommentButtonRef.current;
|
|
974
|
+
if (!anchorEl) return;
|
|
975
|
+
setViewerCommentPopover({
|
|
976
|
+
anchorEl,
|
|
977
|
+
contextText: '',
|
|
978
|
+
isGlobal: true,
|
|
979
|
+
});
|
|
980
|
+
}}
|
|
981
|
+
className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground bg-muted/50 hover:bg-muted rounded-md transition-colors"
|
|
982
|
+
title="Add global comment"
|
|
983
|
+
>
|
|
984
|
+
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
985
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418" />
|
|
986
|
+
</svg>
|
|
987
|
+
{actionsLabelMode === 'full' && <span>Global comment</span>}
|
|
988
|
+
{actionsLabelMode === 'short' && <span>Comment</span>}
|
|
989
|
+
</button>
|
|
990
|
+
)}
|
|
991
|
+
|
|
992
|
+
<button
|
|
993
|
+
onClick={handleCopyPlan}
|
|
994
|
+
className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground bg-muted/50 hover:bg-muted rounded-md transition-colors"
|
|
995
|
+
title={copied ? 'Copied!' : copyLabel || (linkedDocInfo ? 'Copy file' : 'Copy plan')}
|
|
996
|
+
>
|
|
997
|
+
{copied ? (
|
|
998
|
+
<>
|
|
999
|
+
<svg className="w-3.5 h-3.5 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
1000
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
|
1001
|
+
</svg>
|
|
1002
|
+
Copied!
|
|
1003
|
+
</>
|
|
1004
|
+
) : (
|
|
1005
|
+
<>
|
|
1006
|
+
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
1007
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
|
1008
|
+
</svg>
|
|
1009
|
+
{actionsLabelMode === 'full' && <span>{copyLabel || (linkedDocInfo ? 'Copy file' : 'Copy plan')}</span>}
|
|
1010
|
+
{actionsLabelMode === 'short' && <span>Copy</span>}
|
|
1011
|
+
</>
|
|
1012
|
+
)}
|
|
1013
|
+
</button>
|
|
1014
|
+
</>
|
|
1015
|
+
);
|
|
1016
|
+
|
|
791
1017
|
return (
|
|
792
1018
|
<CodePathValidationContext.Provider value={codePathValidation}>
|
|
793
1019
|
<div className="relative z-50 w-full" style={maxWidth === null ? undefined : { maxWidth: maxWidth ?? 832 }}>
|
|
@@ -810,8 +1036,8 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
810
1036
|
...(vimModeActive ? { outline: 'none' } : {}),
|
|
811
1037
|
} as React.CSSProperties}
|
|
812
1038
|
>
|
|
813
|
-
{/*
|
|
814
|
-
{(repoInfo || hasPreviousVersion || showDemoBadge || linkedDocInfo || archiveInfo || sourceInfo || openInAppPath) && (
|
|
1039
|
+
{/* Legacy badge placement remains byte-for-byte opt-out behavior. */}
|
|
1040
|
+
{!viewerAnnotationHeader && (repoInfo || hasPreviousVersion || showDemoBadge || linkedDocInfo || archiveInfo || sourceInfo || openInAppPath) && (
|
|
815
1041
|
<div ref={docBadgesRef} data-print-hide className={`absolute top-3 md:top-4 ${gridEnabled ? 'left-3 md:left-5' : 'left-0'}`}>
|
|
816
1042
|
<DocBadges
|
|
817
1043
|
layout="column"
|
|
@@ -831,88 +1057,40 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
831
1057
|
</div>
|
|
832
1058
|
)}
|
|
833
1059
|
|
|
834
|
-
{
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
</button>
|
|
857
|
-
)}
|
|
858
|
-
|
|
859
|
-
{/* Attachments button */}
|
|
860
|
-
{!readOnly && onAddGlobalAttachment && onRemoveGlobalAttachment && (
|
|
861
|
-
<AttachmentsButton
|
|
862
|
-
images={globalAttachments}
|
|
863
|
-
onAdd={onAddGlobalAttachment}
|
|
864
|
-
onRemove={onRemoveGlobalAttachment}
|
|
865
|
-
variant="toolbar"
|
|
866
|
-
hideLabel={actionsLabelMode === 'icon'}
|
|
867
|
-
/>
|
|
868
|
-
)}
|
|
869
|
-
|
|
870
|
-
{/* <span className="md:hidden">Comment</span><span className="hidden md:inline">Global comment</span> button */}
|
|
871
|
-
{!readOnly && (
|
|
872
|
-
<button
|
|
873
|
-
ref={globalCommentButtonRef}
|
|
874
|
-
onClick={() => {
|
|
875
|
-
setViewerCommentPopover({
|
|
876
|
-
anchorEl: globalCommentButtonRef.current!,
|
|
877
|
-
contextText: '',
|
|
878
|
-
isGlobal: true,
|
|
879
|
-
});
|
|
1060
|
+
{viewerAnnotationHeader ? (
|
|
1061
|
+
<ViewerDocumentHeader
|
|
1062
|
+
config={viewerAnnotationHeader}
|
|
1063
|
+
inputMethod={inputMethod}
|
|
1064
|
+
mode={mode}
|
|
1065
|
+
taterMode={taterMode}
|
|
1066
|
+
sticky={stickyActions}
|
|
1067
|
+
stuck={isStuck}
|
|
1068
|
+
sentinelRef={stickySentinelRef}
|
|
1069
|
+
badges={{
|
|
1070
|
+
repoInfo,
|
|
1071
|
+
planDiffStats,
|
|
1072
|
+
isPlanDiffActive,
|
|
1073
|
+
hasPreviousVersion,
|
|
1074
|
+
onPlanDiffToggle,
|
|
1075
|
+
planDiffBaselineLabel,
|
|
1076
|
+
planDiffBaselineTooltip,
|
|
1077
|
+
showDemoBadge,
|
|
1078
|
+
archiveInfo,
|
|
1079
|
+
linkedDocInfo,
|
|
1080
|
+
sourceInfo,
|
|
1081
|
+
openInAppPath,
|
|
880
1082
|
}}
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
{
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
{/* Copy plan/file button */}
|
|
893
|
-
<button
|
|
894
|
-
onClick={handleCopyPlan}
|
|
895
|
-
className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground bg-muted/50 hover:bg-muted rounded-md transition-colors"
|
|
896
|
-
title={copied ? 'Copied!' : copyLabel || (linkedDocInfo ? 'Copy file' : 'Copy plan')}
|
|
897
|
-
>
|
|
898
|
-
{copied ? (
|
|
899
|
-
<>
|
|
900
|
-
<svg className="w-3.5 h-3.5 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
901
|
-
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
|
902
|
-
</svg>
|
|
903
|
-
Copied!
|
|
904
|
-
</>
|
|
905
|
-
) : (
|
|
906
|
-
<>
|
|
907
|
-
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
908
|
-
<path strokeLinecap="round" strokeLinejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
|
909
|
-
</svg>
|
|
910
|
-
{actionsLabelMode === 'full' && <span>{copyLabel || (linkedDocInfo ? 'Copy file' : 'Copy plan')}</span>}
|
|
911
|
-
{actionsLabelMode === 'short' && <span>Copy</span>}
|
|
912
|
-
</>
|
|
913
|
-
)}
|
|
914
|
-
</button>
|
|
915
|
-
</div>
|
|
1083
|
+
actions={documentActions}
|
|
1084
|
+
/>
|
|
1085
|
+
) : (
|
|
1086
|
+
<>
|
|
1087
|
+
{badgeClearance > 0 && <div data-print-hide style={{ height: badgeClearance }} aria-hidden="true" />}
|
|
1088
|
+
{stickyActions && <div ref={stickySentinelRef} className="h-0 w-0 float-right" aria-hidden="true" />}
|
|
1089
|
+
<div data-print-hide data-sticky-actions className={`${stickyActions ? 'sticky top-3' : ''} z-30 float-right flex items-start gap-1 md:gap-2 rounded-lg p-1 md:p-2 transition-colors duration-150 ${isStuck ? 'bg-card/95 backdrop-blur-sm shadow-sm' : ''} ${gridEnabled ? '-mr-3 md:-mr-5 lg:-mr-7 xl:-mr-9' : '-mr-1 md:-mr-2'} mt-6 md:-mt-5 lg:-mt-7 xl:-mt-9`}>
|
|
1090
|
+
{documentActions}
|
|
1091
|
+
</div>
|
|
1092
|
+
</>
|
|
1093
|
+
)}
|
|
916
1094
|
{frontmatter && <><div className="clear-right md:hidden" /><FrontmatterCard frontmatter={frontmatter} /></>}
|
|
917
1095
|
{!frontmatter && blocks.length > 0 && blocks[0].type !== 'heading' && <div className="mt-4" />}
|
|
918
1096
|
{groupBlocks(blocks).map(group =>
|
|
@@ -194,7 +194,7 @@ export function VimTargetReticle({
|
|
|
194
194
|
? getScrollViewportRect(scrollViewport).top
|
|
195
195
|
: containerRect.top;
|
|
196
196
|
const stickyBottom = container
|
|
197
|
-
.querySelector<HTMLElement>('[data-sticky-actions]')
|
|
197
|
+
.querySelector<HTMLElement>('[data-viewer-document-header], [data-sticky-actions]')
|
|
198
198
|
?.getBoundingClientRect()
|
|
199
199
|
.bottom;
|
|
200
200
|
setPosition(relativePosition(
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/** Responsive presentation chosen for a measured compact document header. */
|
|
2
|
+
export type CompactHeaderLayout = 'wide' | 'tight' | 'narrow';
|
|
3
|
+
|
|
4
|
+
/** Measurements used to place a compact leading lane beside trailing actions. */
|
|
5
|
+
export interface CompactHeaderMeasurements {
|
|
6
|
+
/** Measured width of the full header container. */
|
|
7
|
+
readonly containerWidth: number;
|
|
8
|
+
/** Measured width occupied by the trailing action cluster. */
|
|
9
|
+
readonly trailingWidth: number;
|
|
10
|
+
/** Leading inset that is unavailable to the compact controls. */
|
|
11
|
+
readonly leadingInset: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Resolved responsive geometry for a compact document header. */
|
|
15
|
+
export interface CompactHeaderGeometry {
|
|
16
|
+
/** Pixels available to the leading controls after actions and spacing. */
|
|
17
|
+
readonly availableForLeading: number;
|
|
18
|
+
/** Whether controls keep labels, become icon-only, or stack. */
|
|
19
|
+
readonly layout: CompactHeaderLayout;
|
|
20
|
+
/** Whether both required measurements have arrived. */
|
|
21
|
+
readonly measured: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const GRID_SIZE = 16;
|
|
25
|
+
const CLUSTER_GAP = 16;
|
|
26
|
+
const WIDE_LEADING_WIDTH = 460;
|
|
27
|
+
const MIN_LEADING_WIDTH = 300;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Quantize a ResizeObserver width to avoid rendering for every dragged pixel.
|
|
31
|
+
* Floors toward the more conservative responsive layout.
|
|
32
|
+
*/
|
|
33
|
+
export function snapCompactHeaderWidth(width: number): number {
|
|
34
|
+
return Math.floor(width / GRID_SIZE) * GRID_SIZE;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Resolve the shared wide, tight, and narrow compact-header states. */
|
|
38
|
+
export function resolveCompactHeaderGeometry({
|
|
39
|
+
containerWidth,
|
|
40
|
+
trailingWidth,
|
|
41
|
+
leadingInset,
|
|
42
|
+
}: CompactHeaderMeasurements): CompactHeaderGeometry {
|
|
43
|
+
const measured = containerWidth > 0 && trailingWidth > 0;
|
|
44
|
+
const availableForLeading = containerWidth - leadingInset - trailingWidth - CLUSTER_GAP;
|
|
45
|
+
const layout = !measured || availableForLeading >= WIDE_LEADING_WIDTH
|
|
46
|
+
? 'wide'
|
|
47
|
+
: availableForLeading >= MIN_LEADING_WIDTH
|
|
48
|
+
? 'tight'
|
|
49
|
+
: 'narrow';
|
|
50
|
+
|
|
51
|
+
return { availableForLeading, layout, measured };
|
|
52
|
+
}
|
|
@@ -196,6 +196,12 @@ const escapeAttrValue = (value: string): string => {
|
|
|
196
196
|
const normalizeForRestoreCompare = (value: string): string =>
|
|
197
197
|
value.replace(/\s+/g, ' ').trim();
|
|
198
198
|
|
|
199
|
+
// web-highlighter 0.8.x accepts only class, ID, and tag exclusions.
|
|
200
|
+
const ANNOTATION_EXCLUDED_SELECTOR = '.annotation-exclude';
|
|
201
|
+
|
|
202
|
+
const isAnnotationExcludedTextNode = (node: Node): boolean =>
|
|
203
|
+
Boolean(node.parentElement?.closest(ANNOTATION_EXCLUDED_SELECTOR));
|
|
204
|
+
|
|
199
205
|
const applyMathAnnotationClass = (
|
|
200
206
|
element: HTMLElement,
|
|
201
207
|
id: string,
|
|
@@ -361,21 +367,28 @@ export function useAnnotationHighlighter({
|
|
|
361
367
|
const searchOnce = (needle: string): Range | null => {
|
|
362
368
|
if (!needle || !containerRef.current) return null;
|
|
363
369
|
|
|
364
|
-
const
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
+
const walker = document.createTreeWalker(
|
|
371
|
+
containerRef.current,
|
|
372
|
+
NodeFilter.SHOW_TEXT,
|
|
373
|
+
{
|
|
374
|
+
acceptNode: (node) => isAnnotationExcludedTextNode(node)
|
|
375
|
+
? NodeFilter.FILTER_REJECT
|
|
376
|
+
: NodeFilter.FILTER_ACCEPT,
|
|
377
|
+
},
|
|
378
|
+
);
|
|
379
|
+
const textNodes: Text[] = [];
|
|
380
|
+
let currentNode: Text | null;
|
|
381
|
+
while ((currentNode = walker.nextNode() as Text | null)) {
|
|
382
|
+
textNodes.push(currentNode);
|
|
383
|
+
}
|
|
370
384
|
|
|
385
|
+
const rangeFromTextOffsets = (startIndex: number, endIndex: number): Range | null => {
|
|
371
386
|
let charCount = 0;
|
|
372
387
|
let startNode: Text | null = null;
|
|
373
388
|
let startOffset = 0;
|
|
374
389
|
let endNode: Text | null = null;
|
|
375
390
|
let endOffset = 0;
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
while ((node = walker.nextNode() as Text | null)) {
|
|
391
|
+
for (const node of textNodes) {
|
|
379
392
|
const nodeLength = node.textContent?.length || 0;
|
|
380
393
|
|
|
381
394
|
if (!startNode && charCount + nodeLength > startIndex) {
|
|
@@ -433,14 +446,7 @@ export function useAnnotationHighlighter({
|
|
|
433
446
|
};
|
|
434
447
|
};
|
|
435
448
|
|
|
436
|
-
const
|
|
437
|
-
containerRef.current,
|
|
438
|
-
NodeFilter.SHOW_TEXT,
|
|
439
|
-
null
|
|
440
|
-
);
|
|
441
|
-
|
|
442
|
-
let node: Text | null;
|
|
443
|
-
while ((node = walker.nextNode() as Text | null)) {
|
|
449
|
+
for (const node of textNodes) {
|
|
444
450
|
const text = node.textContent || '';
|
|
445
451
|
const index = text.indexOf(needle);
|
|
446
452
|
if (index !== -1) {
|
|
@@ -451,7 +457,7 @@ export function useAnnotationHighlighter({
|
|
|
451
457
|
}
|
|
452
458
|
}
|
|
453
459
|
|
|
454
|
-
const fullText =
|
|
460
|
+
const fullText = textNodes.map((node) => node.textContent ?? '').join('');
|
|
455
461
|
const searchIndex = fullText.indexOf(needle);
|
|
456
462
|
if (searchIndex !== -1) {
|
|
457
463
|
return rangeFromTextOffsets(searchIndex, searchIndex + needle.length);
|
|
@@ -840,7 +846,13 @@ export function useAnnotationHighlighter({
|
|
|
840
846
|
|
|
841
847
|
const highlighter = new Highlighter({
|
|
842
848
|
$root: containerRef.current,
|
|
843
|
-
exceptSelectors: [
|
|
849
|
+
exceptSelectors: [
|
|
850
|
+
'.annotation-toolbar',
|
|
851
|
+
'button',
|
|
852
|
+
'.math-annotatable',
|
|
853
|
+
'.katex',
|
|
854
|
+
ANNOTATION_EXCLUDED_SELECTOR,
|
|
855
|
+
],
|
|
844
856
|
wrapTag: 'mark',
|
|
845
857
|
style: { className: 'annotation-highlight' },
|
|
846
858
|
});
|