@plannotator/ui 0.36.0 → 0.38.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 +6 -4
- package/README.md +57 -1
- package/components/ActionMenu.tsx +22 -24
- package/components/AnnotationToolbar.tsx +3 -1
- package/components/ApproveDropdown.tsx +15 -18
- package/components/CommentPopover.tsx +9 -4
- package/components/DecisionControl.tsx +626 -0
- package/components/DocBadges.tsx +9 -6
- package/components/FloatingQuickLabelPicker.tsx +4 -1
- package/components/KeyboardShortcuts.tsx +1 -0
- package/components/PlanHeaderMenu.tsx +1 -1
- package/components/Settings.tsx +67 -6
- package/components/StickyHeaderLane.tsx +16 -40
- package/components/ToolbarButtons.tsx +31 -9
- package/components/Viewer.tsx +277 -95
- package/components/VimTargetReticle.tsx +1 -1
- package/components/blocks/AlertBlock.tsx +51 -4
- package/components/compactHeaderLayout.ts +52 -0
- package/config/reviewView.ts +1 -0
- package/config/settings.ts +48 -3
- package/configure.ts +9 -0
- package/hooks/useAnnotationHighlighter.ts +31 -19
- package/hooks/useDismissablePopover.ts +65 -0
- package/hooks/useVimDocumentFocus.ts +6 -0
- package/package.json +22 -22
- package/shortcuts/decisionControl.shortcuts.ts +31 -0
- package/shortcuts/index.ts +1 -0
- package/shortcuts/plan-review/documentView.shortcuts.ts +13 -0
- package/styles.css +1 -1
- package/theme.css +11 -0
- package/utils/alertTitle.ts +69 -0
- package/utils/decisionSpec.ts +430 -0
- package/utils/platform.ts +16 -0
- package/utils/vimScroll.ts +1 -1
- package/sprite_package_additional/index.html +0 -34
- package/sprite_package_new/index.html +0 -34
- package/sprite_package_pulluphang/index.html +0 -34
package/components/Viewer.tsx
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { generateId } from '../utils/generateId';
|
|
1
2
|
import React, { useRef, useState, useEffect, useMemo, forwardRef, useImperativeHandle, useCallback } from 'react';
|
|
2
3
|
import { createPortal } from 'react-dom';
|
|
3
4
|
import { AnnotationType, type Block, type Annotation, type EditorMode, type InputMethod, type ImageAttachment, type ActionsLabelMode } from '../types';
|
|
@@ -44,7 +45,7 @@ import { MermaidBlock } from './MermaidBlock';
|
|
|
44
45
|
import { isGraphvizLanguage, isMermaidLanguage } from './diagramLanguages';
|
|
45
46
|
import { getIdentity } from '../utils/identity';
|
|
46
47
|
import { type QuickLabel } from '../utils/quickLabels';
|
|
47
|
-
import { DocBadges, type LinkedDocBadgeInfo } from './DocBadges';
|
|
48
|
+
import { DocBadges, type DocBadgesProps, type LinkedDocBadgeInfo } from './DocBadges';
|
|
48
49
|
import { PinpointOverlay } from './PinpointOverlay';
|
|
49
50
|
import { usePinpoint } from '../hooks/usePinpoint';
|
|
50
51
|
import { useAnnotationHighlighter } from '../hooks/useAnnotationHighlighter';
|
|
@@ -58,8 +59,24 @@ import {
|
|
|
58
59
|
} from '../hooks/useScrollViewport';
|
|
59
60
|
import { decodeAnchorHash } from '../utils/anchors';
|
|
60
61
|
import { VimModeOverlay } from './VimModeOverlay';
|
|
62
|
+
import { AnnotationToolstrip } from './AnnotationToolstrip';
|
|
63
|
+
import {
|
|
64
|
+
resolveCompactHeaderGeometry,
|
|
65
|
+
snapCompactHeaderWidth,
|
|
66
|
+
} from './compactHeaderLayout';
|
|
67
|
+
|
|
68
|
+
/** Typed controls required for Viewer's opt-in, in-flow annotation header. */
|
|
69
|
+
export interface ViewerAnnotationHeaderConfig {
|
|
70
|
+
/** Persist and apply a Select or Pinpoint input-method change. */
|
|
71
|
+
readonly onInputMethodChange: (method: InputMethod) => void;
|
|
72
|
+
/** Persist and apply an annotation-mode change. */
|
|
73
|
+
readonly onModeChange: (mode: EditorMode) => void;
|
|
74
|
+
/** Omit Quick Label without changing or coercing the current mode. */
|
|
75
|
+
readonly hideQuickLabel?: boolean;
|
|
76
|
+
}
|
|
61
77
|
|
|
62
|
-
|
|
78
|
+
/** Public properties for the Markdown document Viewer. */
|
|
79
|
+
export interface ViewerProps {
|
|
63
80
|
blocks: Block[];
|
|
64
81
|
markdown: string;
|
|
65
82
|
frontmatter?: Frontmatter | null;
|
|
@@ -75,6 +92,14 @@ interface ViewerProps {
|
|
|
75
92
|
onRemoveGlobalAttachment?: (path: string) => void;
|
|
76
93
|
repoInfo?: { display: string; branch?: string; host?: string } | null;
|
|
77
94
|
stickyActions?: boolean;
|
|
95
|
+
/**
|
|
96
|
+
* Render compact annotation controls and the existing document actions in
|
|
97
|
+
* one measured, in-flow header owned by Viewer. The header reserves its
|
|
98
|
+
* responsive height before document content and follows `stickyActions`.
|
|
99
|
+
* Omit this prop to retain the legacy floating action bar exactly. Read-only
|
|
100
|
+
* viewers suppress the annotation header rather than expose dead controls.
|
|
101
|
+
*/
|
|
102
|
+
annotationHeader?: ViewerAnnotationHeaderConfig;
|
|
78
103
|
/** Render the plan as a floating card on a grid background (shadow/border/padding). Default false. */
|
|
79
104
|
gridEnabled?: boolean;
|
|
80
105
|
onOpenLinkedDoc?: (path: string) => void;
|
|
@@ -189,6 +214,118 @@ const FrontmatterCard: React.FC<{ frontmatter: Frontmatter }> = ({ frontmatter }
|
|
|
189
214
|
);
|
|
190
215
|
};
|
|
191
216
|
|
|
217
|
+
interface ViewerDocumentHeaderProps {
|
|
218
|
+
readonly config: ViewerAnnotationHeaderConfig;
|
|
219
|
+
readonly inputMethod: InputMethod;
|
|
220
|
+
readonly mode: EditorMode;
|
|
221
|
+
readonly taterMode: boolean;
|
|
222
|
+
readonly sticky: boolean;
|
|
223
|
+
readonly stuck: boolean;
|
|
224
|
+
readonly sentinelRef: React.RefObject<HTMLDivElement | null>;
|
|
225
|
+
readonly badges: Omit<DocBadgesProps, 'layout'>;
|
|
226
|
+
readonly actions: React.ReactNode;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Viewer-owned header that keeps leading controls and trailing actions in one flow. */
|
|
230
|
+
const ViewerDocumentHeader: React.FC<ViewerDocumentHeaderProps> = ({
|
|
231
|
+
config,
|
|
232
|
+
inputMethod,
|
|
233
|
+
mode,
|
|
234
|
+
taterMode,
|
|
235
|
+
sticky,
|
|
236
|
+
stuck,
|
|
237
|
+
sentinelRef,
|
|
238
|
+
badges,
|
|
239
|
+
actions,
|
|
240
|
+
}) => {
|
|
241
|
+
const headerRef = useRef<HTMLDivElement>(null);
|
|
242
|
+
const actionsRef = useRef<HTMLDivElement>(null);
|
|
243
|
+
const [headerWidth, setHeaderWidth] = useState(0);
|
|
244
|
+
const [actionsWidth, setActionsWidth] = useState(0);
|
|
245
|
+
|
|
246
|
+
useEffect(() => {
|
|
247
|
+
const header = headerRef.current;
|
|
248
|
+
if (!header) return;
|
|
249
|
+
const observer = new ResizeObserver(([entry]) => {
|
|
250
|
+
const next = snapCompactHeaderWidth(entry.contentRect.width);
|
|
251
|
+
setHeaderWidth((current) => current === next ? current : next);
|
|
252
|
+
});
|
|
253
|
+
observer.observe(header);
|
|
254
|
+
return () => observer.disconnect();
|
|
255
|
+
}, []);
|
|
256
|
+
|
|
257
|
+
useEffect(() => {
|
|
258
|
+
const actionCluster = actionsRef.current;
|
|
259
|
+
if (!actionCluster) return;
|
|
260
|
+
const observer = new ResizeObserver(([entry]) => {
|
|
261
|
+
const next = snapCompactHeaderWidth(entry.contentRect.width);
|
|
262
|
+
setActionsWidth((current) => current === next ? current : next);
|
|
263
|
+
});
|
|
264
|
+
observer.observe(actionCluster);
|
|
265
|
+
return () => observer.disconnect();
|
|
266
|
+
}, []);
|
|
267
|
+
|
|
268
|
+
const geometry = resolveCompactHeaderGeometry({
|
|
269
|
+
containerWidth: headerWidth,
|
|
270
|
+
trailingWidth: actionsWidth,
|
|
271
|
+
leadingInset: 0,
|
|
272
|
+
});
|
|
273
|
+
const narrow = geometry.layout === 'narrow';
|
|
274
|
+
const iconOnly = geometry.layout !== 'wide';
|
|
275
|
+
|
|
276
|
+
return (
|
|
277
|
+
<>
|
|
278
|
+
{sticky && <div ref={sentinelRef} className="h-0 w-0" aria-hidden="true" />}
|
|
279
|
+
<div
|
|
280
|
+
ref={headerRef}
|
|
281
|
+
data-annotation-exclude
|
|
282
|
+
data-print-hide
|
|
283
|
+
data-viewer-document-header
|
|
284
|
+
data-header-layout={geometry.layout}
|
|
285
|
+
className={`annotation-exclude ${sticky ? 'sticky top-3' : 'relative'} z-40 mb-3 md:mb-4 rounded-lg transition-colors duration-150 ${
|
|
286
|
+
stuck ? 'bg-card/95 backdrop-blur-sm shadow-sm' : ''
|
|
287
|
+
}`}
|
|
288
|
+
>
|
|
289
|
+
<div className={`flex min-w-0 ${
|
|
290
|
+
narrow
|
|
291
|
+
? 'flex-col items-stretch gap-2'
|
|
292
|
+
: 'flex-row items-start justify-between gap-4'
|
|
293
|
+
}`}>
|
|
294
|
+
<div
|
|
295
|
+
data-viewer-annotation-controls
|
|
296
|
+
className={`inline-flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 py-1 md:py-2 ${
|
|
297
|
+
narrow ? 'w-full' : 'flex-1'
|
|
298
|
+
}`}
|
|
299
|
+
>
|
|
300
|
+
<div className="flex-shrink-0">
|
|
301
|
+
<AnnotationToolstrip
|
|
302
|
+
inputMethod={inputMethod}
|
|
303
|
+
onInputMethodChange={config.onInputMethodChange}
|
|
304
|
+
mode={mode}
|
|
305
|
+
onModeChange={config.onModeChange}
|
|
306
|
+
taterMode={taterMode}
|
|
307
|
+
hideQuickLabel={config.hideQuickLabel}
|
|
308
|
+
compact
|
|
309
|
+
iconOnly={iconOnly}
|
|
310
|
+
/>
|
|
311
|
+
</div>
|
|
312
|
+
<DocBadges layout="header" {...badges} />
|
|
313
|
+
</div>
|
|
314
|
+
<div
|
|
315
|
+
ref={actionsRef}
|
|
316
|
+
data-sticky-actions
|
|
317
|
+
className={`flex flex-shrink-0 items-start gap-1 rounded-lg p-1 md:gap-2 md:p-2 ${
|
|
318
|
+
narrow ? 'self-end' : ''
|
|
319
|
+
}`}
|
|
320
|
+
>
|
|
321
|
+
{actions}
|
|
322
|
+
</div>
|
|
323
|
+
</div>
|
|
324
|
+
</div>
|
|
325
|
+
</>
|
|
326
|
+
);
|
|
327
|
+
};
|
|
328
|
+
|
|
192
329
|
/**
|
|
193
330
|
* Render and annotate a parsed Markdown document.
|
|
194
331
|
*
|
|
@@ -211,6 +348,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
211
348
|
onRemoveGlobalAttachment,
|
|
212
349
|
repoInfo,
|
|
213
350
|
stickyActions = true,
|
|
351
|
+
annotationHeader,
|
|
214
352
|
gridEnabled = false,
|
|
215
353
|
planDiffStats,
|
|
216
354
|
isPlanDiffActive,
|
|
@@ -242,6 +380,8 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
242
380
|
vimHudKeyPanelEnabled = true,
|
|
243
381
|
onVimHudKeyPanelChange,
|
|
244
382
|
}, ref) => {
|
|
383
|
+
const viewerAnnotationHeader = readOnly ? undefined : annotationHeader;
|
|
384
|
+
const hasViewerAnnotationHeader = viewerAnnotationHeader !== undefined;
|
|
245
385
|
const [copied, setCopied] = useState(false);
|
|
246
386
|
const [lightbox, setLightbox] = useState<{ src: string; alt: string } | null>(null);
|
|
247
387
|
const [locationHash, setLocationHash] = useState(() => window.location.hash);
|
|
@@ -271,6 +411,10 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
271
411
|
const docBadgesRef = useRef<HTMLDivElement | null>(null);
|
|
272
412
|
const [badgeClearance, setBadgeClearance] = useState(0);
|
|
273
413
|
useEffect(() => {
|
|
414
|
+
if (hasViewerAnnotationHeader) {
|
|
415
|
+
setBadgeClearance(0);
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
274
418
|
const el = docBadgesRef.current;
|
|
275
419
|
const article = containerRef.current;
|
|
276
420
|
if (!el || !article) { setBadgeClearance(0); return; }
|
|
@@ -284,7 +428,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
284
428
|
ro.observe(el);
|
|
285
429
|
window.addEventListener('resize', measure);
|
|
286
430
|
return () => { ro.disconnect(); window.removeEventListener('resize', measure); };
|
|
287
|
-
}, [repoInfo, hasPreviousVersion, showDemoBadge, linkedDocInfo, archiveInfo, sourceInfo, planDiffStats, openInAppPath]);
|
|
431
|
+
}, [hasViewerAnnotationHeader, repoInfo, hasPreviousVersion, showDemoBadge, linkedDocInfo, archiveInfo, sourceInfo, planDiffStats, openInAppPath]);
|
|
288
432
|
|
|
289
433
|
// Per-doc heading slug map with dedup — computed once per blocks array so
|
|
290
434
|
// anchor ids stay stable across re-renders and duplicate heading texts get
|
|
@@ -605,14 +749,18 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
605
749
|
// The IntersectionObserver root must be the actual scroll element — the
|
|
606
750
|
// OverlayScrollArea viewport — not the <main> host, which doesn't scroll.
|
|
607
751
|
useEffect(() => {
|
|
608
|
-
if (!stickyActions
|
|
752
|
+
if (!stickyActions) {
|
|
753
|
+
setIsStuck(false);
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
if (!stickySentinelRef.current || !scrollViewport) return;
|
|
609
757
|
const observer = new IntersectionObserver(
|
|
610
758
|
([entry]) => setIsStuck(!entry.isIntersecting),
|
|
611
759
|
{ root: getScrollViewportIntersectionRoot(scrollViewport), threshold: 0 }
|
|
612
760
|
);
|
|
613
761
|
observer.observe(stickySentinelRef.current);
|
|
614
762
|
return () => observer.disconnect();
|
|
615
|
-
}, [stickyActions, scrollViewport]);
|
|
763
|
+
}, [hasViewerAnnotationHeader, stickyActions, scrollViewport]);
|
|
616
764
|
|
|
617
765
|
useEffect(() => {
|
|
618
766
|
const handleHashChange = () => {
|
|
@@ -634,12 +782,15 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
634
782
|
const target = document.getElementById(anchor);
|
|
635
783
|
if (!target || !container.contains(target)) return false;
|
|
636
784
|
|
|
637
|
-
const
|
|
638
|
-
|
|
639
|
-
|
|
785
|
+
const documentHeader = stickyActions
|
|
786
|
+
? container.querySelector<HTMLElement>('[data-viewer-document-header]')
|
|
787
|
+
?? container.querySelector<HTMLElement>('[data-sticky-actions]')
|
|
788
|
+
: null;
|
|
789
|
+
const stickyTop = documentHeader
|
|
790
|
+
? Number.parseFloat(window.getComputedStyle(documentHeader).top || '0') || 0
|
|
640
791
|
: 0;
|
|
641
|
-
const headerOffset =
|
|
642
|
-
?
|
|
792
|
+
const headerOffset = documentHeader
|
|
793
|
+
? documentHeader.getBoundingClientRect().height + stickyTop
|
|
643
794
|
: 0;
|
|
644
795
|
const containerRect = getScrollViewportRect(scrollViewport);
|
|
645
796
|
const targetRect = target.getBoundingClientRect();
|
|
@@ -651,7 +802,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
651
802
|
behavior: 'smooth',
|
|
652
803
|
});
|
|
653
804
|
return true;
|
|
654
|
-
}, [scrollViewport]);
|
|
805
|
+
}, [scrollViewport, stickyActions]);
|
|
655
806
|
|
|
656
807
|
useEffect(() => {
|
|
657
808
|
if (!scrollViewport || !locationHash || lastAutoScrolledHashRef.current === locationHash) return;
|
|
@@ -758,7 +909,10 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
758
909
|
|
|
759
910
|
if (viewerCommentPopover.isGlobal) {
|
|
760
911
|
const newAnnotation: Annotation = {
|
|
761
|
-
|
|
912
|
+
// randomUUID, not Date.now(): two comments minted in the same
|
|
913
|
+
// millisecond (paste + submit, or a concurrent external write)
|
|
914
|
+
// would collide on a timestamp id.
|
|
915
|
+
id: generateId('global'),
|
|
762
916
|
blockId: '',
|
|
763
917
|
startOffset: 0,
|
|
764
918
|
endOffset: 0,
|
|
@@ -788,6 +942,82 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
788
942
|
|
|
789
943
|
const codePathValidation = useValidatedCodePaths(markdown, codePathBaseDir, disableCodePathValidation);
|
|
790
944
|
|
|
945
|
+
const documentActions = (
|
|
946
|
+
<>
|
|
947
|
+
{messagePickerInfo && (
|
|
948
|
+
<button
|
|
949
|
+
onClick={messagePickerInfo.onOpen}
|
|
950
|
+
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"
|
|
951
|
+
title="Pick a different message to annotate"
|
|
952
|
+
>
|
|
953
|
+
<MessagesIcon />
|
|
954
|
+
{actionsLabelMode === 'full' && (
|
|
955
|
+
<span>Message {messagePickerInfo.current} of {messagePickerInfo.total}</span>
|
|
956
|
+
)}
|
|
957
|
+
{actionsLabelMode === 'short' && (
|
|
958
|
+
<span>{messagePickerInfo.current}/{messagePickerInfo.total}</span>
|
|
959
|
+
)}
|
|
960
|
+
</button>
|
|
961
|
+
)}
|
|
962
|
+
|
|
963
|
+
{!readOnly && onAddGlobalAttachment && onRemoveGlobalAttachment && (
|
|
964
|
+
<AttachmentsButton
|
|
965
|
+
images={globalAttachments}
|
|
966
|
+
onAdd={onAddGlobalAttachment}
|
|
967
|
+
onRemove={onRemoveGlobalAttachment}
|
|
968
|
+
variant="toolbar"
|
|
969
|
+
hideLabel={actionsLabelMode === 'icon'}
|
|
970
|
+
/>
|
|
971
|
+
)}
|
|
972
|
+
|
|
973
|
+
{!readOnly && (
|
|
974
|
+
<button
|
|
975
|
+
ref={globalCommentButtonRef}
|
|
976
|
+
onClick={() => {
|
|
977
|
+
const anchorEl = globalCommentButtonRef.current;
|
|
978
|
+
if (!anchorEl) return;
|
|
979
|
+
setViewerCommentPopover({
|
|
980
|
+
anchorEl,
|
|
981
|
+
contextText: '',
|
|
982
|
+
isGlobal: true,
|
|
983
|
+
});
|
|
984
|
+
}}
|
|
985
|
+
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"
|
|
986
|
+
title="Add global comment"
|
|
987
|
+
>
|
|
988
|
+
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
989
|
+
<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" />
|
|
990
|
+
</svg>
|
|
991
|
+
{actionsLabelMode === 'full' && <span>Global comment</span>}
|
|
992
|
+
{actionsLabelMode === 'short' && <span>Comment</span>}
|
|
993
|
+
</button>
|
|
994
|
+
)}
|
|
995
|
+
|
|
996
|
+
<button
|
|
997
|
+
onClick={handleCopyPlan}
|
|
998
|
+
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"
|
|
999
|
+
title={copied ? 'Copied!' : copyLabel || (linkedDocInfo ? 'Copy file' : 'Copy plan')}
|
|
1000
|
+
>
|
|
1001
|
+
{copied ? (
|
|
1002
|
+
<>
|
|
1003
|
+
<svg className="w-3.5 h-3.5 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
1004
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
|
1005
|
+
</svg>
|
|
1006
|
+
Copied!
|
|
1007
|
+
</>
|
|
1008
|
+
) : (
|
|
1009
|
+
<>
|
|
1010
|
+
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
1011
|
+
<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" />
|
|
1012
|
+
</svg>
|
|
1013
|
+
{actionsLabelMode === 'full' && <span>{copyLabel || (linkedDocInfo ? 'Copy file' : 'Copy plan')}</span>}
|
|
1014
|
+
{actionsLabelMode === 'short' && <span>Copy</span>}
|
|
1015
|
+
</>
|
|
1016
|
+
)}
|
|
1017
|
+
</button>
|
|
1018
|
+
</>
|
|
1019
|
+
);
|
|
1020
|
+
|
|
791
1021
|
return (
|
|
792
1022
|
<CodePathValidationContext.Provider value={codePathValidation}>
|
|
793
1023
|
<div className="relative z-50 w-full" style={maxWidth === null ? undefined : { maxWidth: maxWidth ?? 832 }}>
|
|
@@ -810,8 +1040,8 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
810
1040
|
...(vimModeActive ? { outline: 'none' } : {}),
|
|
811
1041
|
} as React.CSSProperties}
|
|
812
1042
|
>
|
|
813
|
-
{/*
|
|
814
|
-
{(repoInfo || hasPreviousVersion || showDemoBadge || linkedDocInfo || archiveInfo || sourceInfo || openInAppPath) && (
|
|
1043
|
+
{/* Legacy badge placement remains byte-for-byte opt-out behavior. */}
|
|
1044
|
+
{!viewerAnnotationHeader && (repoInfo || hasPreviousVersion || showDemoBadge || linkedDocInfo || archiveInfo || sourceInfo || openInAppPath) && (
|
|
815
1045
|
<div ref={docBadgesRef} data-print-hide className={`absolute top-3 md:top-4 ${gridEnabled ? 'left-3 md:left-5' : 'left-0'}`}>
|
|
816
1046
|
<DocBadges
|
|
817
1047
|
layout="column"
|
|
@@ -831,88 +1061,40 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
831
1061
|
</div>
|
|
832
1062
|
)}
|
|
833
1063
|
|
|
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
|
-
});
|
|
1064
|
+
{viewerAnnotationHeader ? (
|
|
1065
|
+
<ViewerDocumentHeader
|
|
1066
|
+
config={viewerAnnotationHeader}
|
|
1067
|
+
inputMethod={inputMethod}
|
|
1068
|
+
mode={mode}
|
|
1069
|
+
taterMode={taterMode}
|
|
1070
|
+
sticky={stickyActions}
|
|
1071
|
+
stuck={isStuck}
|
|
1072
|
+
sentinelRef={stickySentinelRef}
|
|
1073
|
+
badges={{
|
|
1074
|
+
repoInfo,
|
|
1075
|
+
planDiffStats,
|
|
1076
|
+
isPlanDiffActive,
|
|
1077
|
+
hasPreviousVersion,
|
|
1078
|
+
onPlanDiffToggle,
|
|
1079
|
+
planDiffBaselineLabel,
|
|
1080
|
+
planDiffBaselineTooltip,
|
|
1081
|
+
showDemoBadge,
|
|
1082
|
+
archiveInfo,
|
|
1083
|
+
linkedDocInfo,
|
|
1084
|
+
sourceInfo,
|
|
1085
|
+
openInAppPath,
|
|
880
1086
|
}}
|
|
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>
|
|
1087
|
+
actions={documentActions}
|
|
1088
|
+
/>
|
|
1089
|
+
) : (
|
|
1090
|
+
<>
|
|
1091
|
+
{badgeClearance > 0 && <div data-print-hide style={{ height: badgeClearance }} aria-hidden="true" />}
|
|
1092
|
+
{stickyActions && <div ref={stickySentinelRef} className="h-0 w-0 float-right" aria-hidden="true" />}
|
|
1093
|
+
<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`}>
|
|
1094
|
+
{documentActions}
|
|
1095
|
+
</div>
|
|
1096
|
+
</>
|
|
1097
|
+
)}
|
|
916
1098
|
{frontmatter && <><div className="clear-right md:hidden" /><FrontmatterCard frontmatter={frontmatter} /></>}
|
|
917
1099
|
{!frontmatter && blocks.length > 0 && blocks[0].type !== 'heading' && <div className="mt-4" />}
|
|
918
1100
|
{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(
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import type { AlertKind } from '../../types';
|
|
3
3
|
import { renderProseBody } from './proseBody';
|
|
4
|
+
import { InlineMarkdown } from '../InlineMarkdown';
|
|
5
|
+
import { parseAlertTitleLine } from '../../utils/alertTitle';
|
|
4
6
|
|
|
5
7
|
interface AlertBlockProps {
|
|
6
8
|
blockId: string;
|
|
@@ -22,6 +24,25 @@ const TITLE: Record<AlertKind, string> = {
|
|
|
22
24
|
important: 'Important',
|
|
23
25
|
};
|
|
24
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Host seam: resolve a named icon from a title line's `<!-- icon: name -->`
|
|
29
|
+
* comment to a React node. Default: null for every name, so the alert keeps
|
|
30
|
+
* the type's own icon exactly as today. A host that ships an icon set (the
|
|
31
|
+
* package deliberately bundles none) registers a renderer once at startup.
|
|
32
|
+
* Returning null for an unknown name falls back to the type's icon.
|
|
33
|
+
*/
|
|
34
|
+
export type AlertIconRenderer = (name: string) => React.ReactNode | null;
|
|
35
|
+
|
|
36
|
+
let alertIconRenderer: AlertIconRenderer | null = null;
|
|
37
|
+
|
|
38
|
+
export function setAlertIconRenderer(renderer: AlertIconRenderer | null): void {
|
|
39
|
+
alertIconRenderer = renderer;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function resetAlertIconRenderer(): void {
|
|
43
|
+
alertIconRenderer = null;
|
|
44
|
+
}
|
|
45
|
+
|
|
25
46
|
const Icon: React.FC<{ kind: AlertKind }> = ({ kind }) => {
|
|
26
47
|
const common = { viewBox: '0 0 16 16', width: '16', height: '16', fill: 'currentColor', 'aria-hidden': true as const };
|
|
27
48
|
switch (kind) {
|
|
@@ -41,6 +62,19 @@ const Icon: React.FC<{ kind: AlertKind }> = ({ kind }) => {
|
|
|
41
62
|
export const AlertBlock: React.FC<AlertBlockProps> = ({
|
|
42
63
|
blockId, kind, body, onOpenLinkedDoc, onOpenCodeFile, imageBaseDir, onImageClick, githubRepo, onNavigateAnchor,
|
|
43
64
|
}) => {
|
|
65
|
+
const proseProps = { imageBaseDir, onImageClick, onOpenLinkedDoc, onOpenCodeFile, onNavigateAnchor, githubRepo };
|
|
66
|
+
|
|
67
|
+
// A bold-only first line (optionally led by one emoji, optionally trailed by
|
|
68
|
+
// an `<!-- icon: name -->` comment) is the alert's title and rides the icon
|
|
69
|
+
// row in place of the type word; the emoji, or a host-resolved named icon,
|
|
70
|
+
// takes the icon slot. See utils/alertTitle for the exact grammar. A body
|
|
71
|
+
// with no such line renders exactly as before.
|
|
72
|
+
const titleLine = parseAlertTitleLine(body);
|
|
73
|
+
const hostIcon = titleLine?.icon && alertIconRenderer ? alertIconRenderer(titleLine.icon) : null;
|
|
74
|
+
const icon = titleLine?.emoji
|
|
75
|
+
? <span aria-hidden="true" className="alert-emoji text-base leading-none">{titleLine.emoji}</span>
|
|
76
|
+
: hostIcon ?? <Icon kind={kind} />;
|
|
77
|
+
|
|
44
78
|
return (
|
|
45
79
|
<div
|
|
46
80
|
className={`alert alert-${kind} my-4 pl-4 pr-3 py-2 border-l-[3px]`}
|
|
@@ -48,11 +82,24 @@ export const AlertBlock: React.FC<AlertBlockProps> = ({
|
|
|
48
82
|
data-block-type="alert"
|
|
49
83
|
data-alert-kind={kind}
|
|
50
84
|
>
|
|
51
|
-
<div
|
|
52
|
-
|
|
53
|
-
|
|
85
|
+
<div
|
|
86
|
+
className="alert-title flex items-center gap-2 font-semibold mb-1"
|
|
87
|
+
aria-label={titleLine?.title ? `${TITLE[kind]}: ${titleLine.title}` : undefined}
|
|
88
|
+
>
|
|
89
|
+
{icon}
|
|
90
|
+
<span>
|
|
91
|
+
{titleLine?.title
|
|
92
|
+
? <InlineMarkdown text={titleLine.title} {...proseProps} />
|
|
93
|
+
: TITLE[kind]}
|
|
94
|
+
</span>
|
|
54
95
|
</div>
|
|
55
|
-
{
|
|
96
|
+
{titleLine
|
|
97
|
+
? (titleLine.rest.trim()
|
|
98
|
+
// Indent the body by the icon's width plus the row gap so it starts
|
|
99
|
+
// at the title's left edge (16px icon + 8px gap = pl-6).
|
|
100
|
+
? <div className="alert-body pl-6">{renderProseBody({ body: titleLine.rest, ...proseProps })}</div>
|
|
101
|
+
: null)
|
|
102
|
+
: renderProseBody({ body, ...proseProps })}
|
|
56
103
|
</div>
|
|
57
104
|
);
|
|
58
105
|
};
|
|
@@ -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
|
+
}
|