@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.
@@ -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
- interface ViewerProps {
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 || !stickySentinelRef.current || !scrollViewport) return;
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 stickyActionsEl = container.querySelector<HTMLElement>('[data-sticky-actions]');
638
- const stickyTop = stickyActionsEl
639
- ? Number.parseFloat(window.getComputedStyle(stickyActionsEl).top || '0') || 0
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 = stickyActionsEl
642
- ? stickyActionsEl.getBoundingClientRect().height + stickyTop
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
- {/* Repo info + plan diff badge + demo badge + linked doc badge + archive badge - top left */}
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
- {/* Clearance so document content starts below the (absolute) badge cluster
835
- when it outgrows the card's top padding — see the measuring effect above. */}
836
- {badgeClearance > 0 && <div data-print-hide style={{ height: badgeClearance }} aria-hidden="true" />}
837
-
838
- {/* Sentinel for sticky detection */}
839
- {stickyActions && <div ref={stickySentinelRef} className="h-0 w-0 float-right" aria-hidden="true" />}
840
-
841
- {/* Header buttons - top right */}
842
- <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`}>
843
- {messagePickerInfo && (
844
- <button
845
- onClick={messagePickerInfo.onOpen}
846
- 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"
847
- title="Pick a different message to annotate"
848
- >
849
- <MessagesIcon />
850
- {actionsLabelMode === 'full' && (
851
- <span>Message {messagePickerInfo.current} of {messagePickerInfo.total}</span>
852
- )}
853
- {actionsLabelMode === 'short' && (
854
- <span>{messagePickerInfo.current}/{messagePickerInfo.total}</span>
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
- 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"
882
- title="Add global comment"
883
- >
884
- <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
885
- <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" />
886
- </svg>
887
- {actionsLabelMode === 'full' && <span>Global comment</span>}
888
- {actionsLabelMode === 'short' && <span>Comment</span>}
889
- </button>
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 rangeFromTextOffsets = (startIndex: number, endIndex: number): Range | null => {
365
- const walker = document.createTreeWalker(
366
- containerRef.current!,
367
- NodeFilter.SHOW_TEXT,
368
- null
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
- let node: Text | null;
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 walker = document.createTreeWalker(
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 = containerRef.current.textContent || '';
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: ['.annotation-toolbar', 'button', '.math-annotatable', '.katex'],
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
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plannotator/ui",
3
- "version": "0.35.2",
3
+ "version": "0.37.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./components/*": "./components/*.tsx",