@plannotator/ui 0.23.0 → 0.25.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.
@@ -1,5 +1,5 @@
1
1
  import React, { useState, useRef, useEffect } from 'react';
2
- import { Annotation, AnnotationType, Block, type CodeAnnotation, type EditorAnnotation } from '../types';
2
+ import { AnnotationType, type Annotation, type Block, type CodeAnnotation, type EditorAnnotation } from '../types';
3
3
  import { isCurrentUser } from '../utils/identity';
4
4
  import { ImageThumbnail } from './ImageThumbnail';
5
5
  import { EditorAnnotationCard } from './EditorAnnotationCard';
@@ -73,12 +73,18 @@ interface PanelProps {
73
73
  /** Committed direct edits to one or more documents. Rendered as pinned cards
74
74
  * above the annotation timeline with expandable unified diffs. */
75
75
  directEdits?: DirectEditsPanelItem[] | null;
76
+ /** Host slot rendered at the foot of each plan-annotation card (e.g. reply/
77
+ * resolve UI). The panel stays presentation-only; clicks inside the slot
78
+ * do not select the card. Default: nothing rendered. */
79
+ renderCardFooter?: (annotation: Annotation) => React.ReactNode;
80
+ /** Hide every mutation affordance (delete/edit buttons on all card kinds).
81
+ * Selection and scrolling still work. Default false — today's behavior. */
82
+ readOnly?: boolean;
76
83
  }
77
84
 
78
85
  export const AnnotationPanel: React.FC<PanelProps> = ({
79
86
  isOpen,
80
87
  annotations,
81
- blocks,
82
88
  onSelect,
83
89
  onDelete,
84
90
  onEdit,
@@ -97,6 +103,8 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
97
103
  otherFileAnnotations,
98
104
  onOtherFileAnnotationsClick,
99
105
  directEdits = null,
106
+ renderCardFooter,
107
+ readOnly = false,
100
108
  }) => {
101
109
  const isMobile = useIsMobile();
102
110
  const [copiedText, setCopiedText] = useState(false);
@@ -195,6 +203,8 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
195
203
  onSelect={() => onSelect(entry.annotation.id)}
196
204
  onDelete={() => onDelete(entry.annotation.id)}
197
205
  onEdit={onEdit ? (updates: Partial<Annotation>) => onEdit(entry.annotation.id, updates) : undefined}
206
+ readOnly={readOnly}
207
+ footer={renderCardFooter?.(entry.annotation)}
198
208
  />
199
209
  ) : (
200
210
  <CodeAnnotationCard
@@ -205,6 +215,7 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
205
215
  onSelect={() => onSelectCodeAnnotation?.(entry.annotation.id)}
206
216
  onDelete={() => onDeleteCodeAnnotation?.(entry.annotation.id)}
207
217
  onEdit={onEditCodeAnnotation ? (updates: Partial<CodeAnnotation>) => onEditCodeAnnotation(entry.annotation.id, updates) : undefined}
218
+ readOnly={readOnly}
208
219
  />
209
220
  )
210
221
  ))}
@@ -221,7 +232,7 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
221
232
  <EditorAnnotationCard
222
233
  key={ann.id}
223
234
  annotation={ann}
224
- onDelete={() => onDeleteEditorAnnotation?.(ann.id)}
235
+ onDelete={readOnly ? undefined : () => onDeleteEditorAnnotation?.(ann.id)}
225
236
  />
226
237
  ))}
227
238
  </>
@@ -418,7 +429,9 @@ const AnnotationCard: React.FC<{
418
429
  onSelect: () => void;
419
430
  onDelete: () => void;
420
431
  onEdit?: (updates: Partial<Annotation>) => void;
421
- }> = ({ annotation, isSelected, isMe, onSelect, onDelete, onEdit }) => {
432
+ readOnly?: boolean;
433
+ footer?: React.ReactNode;
434
+ }> = ({ annotation, isSelected, isMe, onSelect, onDelete, onEdit, readOnly = false, footer }) => {
422
435
  const [isEditing, setIsEditing] = useState(false);
423
436
  const [editText, setEditText] = useState(annotation.text || '');
424
437
  const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -517,26 +530,28 @@ const AnnotationCard: React.FC<{
517
530
  <span className="text-[10px] text-muted-foreground/50 truncate">
518
531
  {annotation.author ? `${annotation.author}${isMe ? ' (me)' : ''} · ` : ''}{formatTimestamp(annotation.createdA)}
519
532
  </span>
520
- <div className="ml-auto flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 [@media(hover:none)]:opacity-100">
521
- {onEdit && annotation.type !== AnnotationType.DELETION && !isEditing && (
533
+ {!readOnly && (
534
+ <div className="ml-auto flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 [@media(hover:none)]:opacity-100">
535
+ {onEdit && annotation.type !== AnnotationType.DELETION && !isEditing && (
536
+ <button
537
+ type="button"
538
+ onClick={handleStartEdit}
539
+ className="relative rounded-md p-1.5 text-muted-foreground transition-colors before:absolute before:-inset-1.5 before:content-[''] hover:text-foreground"
540
+ title="Edit annotation"
541
+ >
542
+ <PencilIcon />
543
+ </button>
544
+ )}
522
545
  <button
523
546
  type="button"
524
- onClick={handleStartEdit}
525
- className="relative rounded-md p-1.5 text-muted-foreground transition-colors before:absolute before:-inset-1.5 before:content-[''] hover:text-foreground"
526
- title="Edit annotation"
547
+ onClick={(e: React.MouseEvent<HTMLButtonElement>) => { e.stopPropagation(); onDelete(); }}
548
+ className="relative rounded-md p-1.5 text-muted-foreground transition-colors before:absolute before:-inset-1.5 before:content-[''] hover:text-destructive"
549
+ title="Delete annotation"
527
550
  >
528
- <PencilIcon />
551
+ <TrashCardIcon />
529
552
  </button>
530
- )}
531
- <button
532
- type="button"
533
- onClick={(e: React.MouseEvent<HTMLButtonElement>) => { e.stopPropagation(); onDelete(); }}
534
- className="relative rounded-md p-1.5 text-muted-foreground transition-colors before:absolute before:-inset-1.5 before:content-[''] hover:text-destructive"
535
- title="Delete annotation"
536
- >
537
- <TrashCardIcon />
538
- </button>
539
- </div>
553
+ </div>
554
+ )}
540
555
  </div>
541
556
 
542
557
  {/* Global Comment - show text directly */}
@@ -585,6 +600,19 @@ const AnnotationCard: React.FC<{
585
600
  ))}
586
601
  </div>
587
602
  )}
603
+
604
+ {/* Host footer slot (reply/resolve UI etc.) — interactions inside it
605
+ must not toggle card selection. */}
606
+ {footer != null && footer !== false && (
607
+ <div
608
+ data-annotation-card-footer="true"
609
+ className="mt-2"
610
+ onClick={(e: React.MouseEvent) => e.stopPropagation()}
611
+ onKeyDown={(e: React.KeyboardEvent) => e.stopPropagation()}
612
+ >
613
+ {footer}
614
+ </div>
615
+ )}
588
616
  </div>
589
617
  );
590
618
  };
@@ -596,7 +624,8 @@ const CodeAnnotationCard: React.FC<{
596
624
  onSelect: () => void;
597
625
  onDelete: () => void;
598
626
  onEdit?: (updates: Partial<CodeAnnotation>) => void;
599
- }> = ({ annotation, isSelected, isMe, onSelect, onDelete, onEdit }) => {
627
+ readOnly?: boolean;
628
+ }> = ({ annotation, isSelected, isMe, onSelect, onDelete, onEdit, readOnly = false }) => {
600
629
  const [isEditing, setIsEditing] = useState(false);
601
630
  const [editText, setEditText] = useState(annotation.text || '');
602
631
  const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -650,26 +679,28 @@ const CodeAnnotationCard: React.FC<{
650
679
  <span className="text-[10px] text-muted-foreground/50 truncate">
651
680
  {annotation.author ? `${annotation.author}${isMe ? ' (me)' : ''} · ` : ''}{formatTimestamp(annotation.createdAt)}
652
681
  </span>
653
- <div className="ml-auto flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 [@media(hover:none)]:opacity-100">
654
- {onEdit && !isEditing && (
682
+ {!readOnly && (
683
+ <div className="ml-auto flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 [@media(hover:none)]:opacity-100">
684
+ {onEdit && !isEditing && (
685
+ <button
686
+ type="button"
687
+ onClick={(e) => { e.stopPropagation(); setIsEditing(true); }}
688
+ className="relative rounded-md p-1.5 text-muted-foreground transition-colors before:absolute before:-inset-1.5 before:content-[''] hover:text-foreground"
689
+ title="Edit annotation"
690
+ >
691
+ <PencilIcon />
692
+ </button>
693
+ )}
655
694
  <button
656
695
  type="button"
657
- onClick={(e) => { e.stopPropagation(); setIsEditing(true); }}
658
- className="relative rounded-md p-1.5 text-muted-foreground transition-colors before:absolute before:-inset-1.5 before:content-[''] hover:text-foreground"
659
- title="Edit annotation"
696
+ onClick={(e) => { e.stopPropagation(); onDelete(); }}
697
+ className="relative rounded-md p-1.5 text-muted-foreground transition-colors before:absolute before:-inset-1.5 before:content-[''] hover:text-destructive"
698
+ title="Delete annotation"
660
699
  >
661
- <PencilIcon />
700
+ <TrashCardIcon />
662
701
  </button>
663
- )}
664
- <button
665
- type="button"
666
- onClick={(e) => { e.stopPropagation(); onDelete(); }}
667
- className="relative rounded-md p-1.5 text-muted-foreground transition-colors before:absolute before:-inset-1.5 before:content-[''] hover:text-destructive"
668
- title="Delete annotation"
669
- >
670
- <TrashCardIcon />
671
- </button>
672
- </div>
702
+ </div>
703
+ )}
673
704
  </div>
674
705
 
675
706
  {/* File / line meta */}
@@ -1,4 +1,4 @@
1
- import React, { useState, useEffect, useRef, useMemo, useCallback } from "react";
1
+ import React, { useState, useEffect, useRef, useMemo } from "react";
2
2
  import { AnnotationType } from "../types";
3
3
  import { createPortal } from "react-dom";
4
4
  import { useDismissOnOutsideAndEscape } from "../hooks/useDismissOnOutsideAndEscape";
@@ -1,5 +1,5 @@
1
1
  import React from "react";
2
- import { Block } from "../types";
2
+ import type { Block } from "../types";
3
3
  import { InlineMarkdown } from "./InlineMarkdown";
4
4
  import { ListItemBody } from "./ListItemBody";
5
5
  import { CodeBlock } from "./blocks/CodeBlock";
@@ -14,7 +14,8 @@ type EditorAnnotationVariant = 'plan' | 'code-review';
14
14
 
15
15
  interface EditorAnnotationCardProps {
16
16
  annotation: EditorAnnotation;
17
- onDelete: () => void;
17
+ /** Omit to render the card read-only (no delete affordance). */
18
+ onDelete?: () => void;
18
19
  variant?: EditorAnnotationVariant;
19
20
  }
20
21
 
@@ -38,15 +39,17 @@ export const EditorAnnotationCard: React.FC<EditorAnnotationCardProps> = ({ anno
38
39
  <span className="text-[10px] font-mono text-muted-foreground/50 truncate" title={annotation.filePath}>
39
40
  {annotation.filePath}:{lineRange}
40
41
  </span>
41
- <button
42
- onClick={(e) => { e.stopPropagation(); onDelete(); }}
43
- className="ml-auto relative rounded-md p-1.5 text-muted-foreground transition-colors before:absolute before:-inset-1.5 before:content-[''] opacity-0 group-hover:opacity-100 [@media(hover:none)]:opacity-100 hover:text-destructive"
44
- title="Delete annotation"
45
- >
46
- <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
47
- <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
48
- </svg>
49
- </button>
42
+ {onDelete && (
43
+ <button
44
+ onClick={(e) => { e.stopPropagation(); onDelete(); }}
45
+ className="ml-auto relative rounded-md p-1.5 text-muted-foreground transition-colors before:absolute before:-inset-1.5 before:content-[''] opacity-0 group-hover:opacity-100 [@media(hover:none)]:opacity-100 hover:text-destructive"
46
+ title="Delete annotation"
47
+ >
48
+ <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
49
+ <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
50
+ </svg>
51
+ </button>
52
+ )}
50
53
  </div>
51
54
 
52
55
  {/* Selected text */}
@@ -2,7 +2,7 @@ import React, { useState, useCallback, useEffect, useRef } from 'react';
2
2
  import { Canvas } from './Canvas';
3
3
  import { Toolbar } from './Toolbar';
4
4
  import { renderStroke } from './utils';
5
- import type { Point, Stroke, Tool, AnnotatorState } from './types';
5
+ import type { Point, AnnotatorState } from './types';
6
6
  import { DEFAULT_STATE } from './types';
7
7
 
8
8
  interface ImageAnnotatorProps {
@@ -1,8 +1,8 @@
1
1
  import React, { useRef, useState, useEffect, useMemo, forwardRef, useImperativeHandle, useCallback } from 'react';
2
2
  import { createPortal } from 'react-dom';
3
3
  import hljs from 'highlight.js';
4
- import { Block, Annotation, AnnotationType, EditorMode, type InputMethod, type ImageAttachment, type ActionsLabelMode } from '../types';
5
- import { Frontmatter, computeListIndices, groupBlocks } from '../utils/parser';
4
+ import { AnnotationType, type Block, type Annotation, type EditorMode, type InputMethod, type ImageAttachment, type ActionsLabelMode } from '../types';
5
+ import { computeListIndices, groupBlocks, type Frontmatter } from '../utils/parser';
6
6
  import { buildHeadingSlugMap } from '../utils/slugify';
7
7
  import { BlockRenderer } from './BlockRenderer';
8
8
  import { CodeBlock } from './blocks/CodeBlock';
@@ -11,7 +11,6 @@ import { TableToolbar } from './blocks/TableToolbar';
11
11
  import { TablePopout } from './blocks/TablePopout';
12
12
  import { CodePathValidationContext } from './CodePathValidationContext';
13
13
  import { useValidatedCodePaths } from '../hooks/useValidatedCodePaths';
14
- import { ListMarker } from './ListMarker';
15
14
  import { AnnotationToolbar } from './AnnotationToolbar';
16
15
  import { FloatingQuickLabelPicker } from './FloatingQuickLabelPicker';
17
16
 
@@ -33,13 +32,12 @@ class ToolbarErrorBoundary extends React.Component<
33
32
  }
34
33
  }
35
34
 
36
- import { CommentPopover, type CommentAskAIContext, type CommentAskAIHandler } from './CommentPopover';
35
+ import { CommentPopover, type CommentAskAIHandler } from './CommentPopover';
37
36
  import { TaterSpriteSitting } from './TaterSpriteSitting';
38
37
  import { AttachmentsButton } from './AttachmentsButton';
39
38
  import { MessagesIcon } from './icons/MessagesIcon';
40
39
  import { GraphvizBlock } from './GraphvizBlock';
41
40
  import { MermaidBlock } from './MermaidBlock';
42
- import { getImageSrc } from './ImageThumbnail';
43
41
  import { isGraphvizLanguage, isMermaidLanguage } from './diagramLanguages';
44
42
  import { getIdentity } from '../utils/identity';
45
43
  import { type QuickLabel } from '../utils/quickLabels';
@@ -112,6 +110,15 @@ interface ViewerProps {
112
110
  onToggleCheckbox?: (blockId: string, checked: boolean) => void;
113
111
  checkboxOverrides?: Map<string, boolean>;
114
112
  onAskAI?: CommentAskAIHandler;
113
+ /** Whether comment popovers offer image attachments. Hosts without an
114
+ * uploadTransport pass false so the attach affordance never dead-ends.
115
+ * Default true — today's behavior. */
116
+ allowImages?: boolean;
117
+ /** View-only mode: suppresses every annotation-creation entry point
118
+ * (selection toolbar, comment popovers, quick labels, pinpoint, global
119
+ * comment, attachments, checkbox toggles). Existing annotations still
120
+ * render and remain selectable. Default false — today's behavior. */
121
+ readOnly?: boolean;
115
122
  }
116
123
 
117
124
  export interface ViewerHandle {
@@ -191,6 +198,8 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
191
198
  onToggleCheckbox,
192
199
  checkboxOverrides,
193
200
  onAskAI,
201
+ allowImages = true,
202
+ readOnly = false,
194
203
  }, ref) => {
195
204
  const [copied, setCopied] = useState(false);
196
205
  const [lightbox, setLightbox] = useState<{ src: string; alt: string } | null>(null);
@@ -283,6 +292,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
283
292
  onSelectAnnotation,
284
293
  selectedAnnotationId,
285
294
  mode,
295
+ enabled: !readOnly,
286
296
  });
287
297
 
288
298
  // Refs for code block annotation path
@@ -319,7 +329,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
319
329
  containerRef,
320
330
  highlighterRef,
321
331
  inputMethod,
322
- enabled: !toolbarState && !hookCommentPopover && !viewerCommentPopover && !hookQuickLabelPicker && !codeBlockQuickLabelPicker && !(isPlanDiffActive ?? false),
332
+ enabled: !readOnly && !toolbarState && !hookCommentPopover && !viewerCommentPopover && !hookQuickLabelPicker && !codeBlockQuickLabelPicker && !(isPlanDiffActive ?? false),
323
333
  onCodeBlockClick: handlePinpointCodeBlockClick,
324
334
  });
325
335
 
@@ -612,7 +622,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
612
622
  )}
613
623
 
614
624
  {/* Attachments button */}
615
- {onAddGlobalAttachment && onRemoveGlobalAttachment && (
625
+ {!readOnly && onAddGlobalAttachment && onRemoveGlobalAttachment && (
616
626
  <AttachmentsButton
617
627
  images={globalAttachments}
618
628
  onAdd={onAddGlobalAttachment}
@@ -623,6 +633,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
623
633
  )}
624
634
 
625
635
  {/* <span className="md:hidden">Comment</span><span className="hidden md:inline">Global comment</span> button */}
636
+ {!readOnly && (
626
637
  <button
627
638
  ref={globalCommentButtonRef}
628
639
  onClick={() => {
@@ -641,6 +652,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
641
652
  {actionsLabelMode === 'full' && <span>Global comment</span>}
642
653
  {actionsLabelMode === 'short' && <span>Comment</span>}
643
654
  </button>
655
+ )}
644
656
 
645
657
  {/* Copy plan/file button */}
646
658
  <button
@@ -683,7 +695,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
683
695
  orderedIndex={indices[i]}
684
696
  onOpenLinkedDoc={onOpenLinkedDoc}
685
697
  onOpenCodeFile={onOpenCodeFile}
686
- onToggleCheckbox={onToggleCheckbox}
698
+ onToggleCheckbox={readOnly ? undefined : onToggleCheckbox}
687
699
  checkboxOverrides={checkboxOverrides}
688
700
  githubRepo={repoInfo?.display}
689
701
  headingAnchorId={headingSlugMap.get(block.id)}
@@ -758,7 +770,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
758
770
  isHovered={inputMethod !== 'pinpoint' && hoveredCodeBlock?.block.id === group.block.id}
759
771
  />
760
772
  ) : (
761
- <BlockRenderer imageBaseDir={imageBaseDir} onImageClick={(src, alt) => setLightbox({ src, alt })} key={group.block.id} block={group.block} onOpenLinkedDoc={onOpenLinkedDoc} onOpenCodeFile={onOpenCodeFile} onNavigateAnchor={scrollToAnchor} onToggleCheckbox={onToggleCheckbox} checkboxOverrides={checkboxOverrides} githubRepo={repoInfo?.display} headingAnchorId={headingSlugMap.get(group.block.id)} />
773
+ <BlockRenderer imageBaseDir={imageBaseDir} onImageClick={(src, alt) => setLightbox({ src, alt })} key={group.block.id} block={group.block} onOpenLinkedDoc={onOpenLinkedDoc} onOpenCodeFile={onOpenCodeFile} onNavigateAnchor={scrollToAnchor} onToggleCheckbox={readOnly ? undefined : onToggleCheckbox} checkboxOverrides={checkboxOverrides} githubRepo={repoInfo?.display} headingAnchorId={headingSlugMap.get(group.block.id)} />
762
774
  )
763
775
  )}
764
776
 
@@ -875,6 +887,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
875
887
  initialText={hookCommentPopover.initialText}
876
888
  onSubmit={hookCommentSubmit}
877
889
  onClose={hookCommentClose}
890
+ allowImages={allowImages}
878
891
  onAskAI={onAskAI}
879
892
  askAIContext={{
880
893
  kind: 'selection',
@@ -892,6 +905,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
892
905
  initialText={viewerCommentPopover.initialText}
893
906
  onSubmit={handleViewerCommentSubmit}
894
907
  onClose={handleViewerCommentClose}
908
+ allowImages={allowImages}
895
909
  onAskAI={onAskAI}
896
910
  askAIContext={{
897
911
  kind: viewerCommentPopover.isGlobal ? 'general' : 'selection',
@@ -1,7 +1,7 @@
1
1
  import React, { useState, useRef, useEffect, useCallback } from 'react';
2
2
  import hljs from 'highlight.js';
3
3
  import 'highlight.js/styles/github-dark.css';
4
- import { Block } from '../../types';
4
+ import type { Block } from '../../types';
5
5
 
6
6
  interface CodeBlockProps {
7
7
  block: Block;
@@ -10,7 +10,7 @@ interface CodeBlockProps {
10
10
  isHovered: boolean;
11
11
  }
12
12
 
13
- export const CodeBlock: React.FC<CodeBlockProps> = ({ block, onHover, onLeave, isHovered }) => {
13
+ export const CodeBlock: React.FC<CodeBlockProps> = ({ block, onHover, onLeave }) => {
14
14
  const [copied, setCopied] = useState(false);
15
15
  const containerRef = useRef<HTMLDivElement>(null);
16
16
  const codeRef = useRef<HTMLElement>(null);
@@ -1,6 +1,6 @@
1
1
  import React, { useRef, useEffect } from "react";
2
2
  import { isCodeFilePath } from "@plannotator/core/code-file";
3
- import { Block } from "../../types";
3
+ import type { Block } from "../../types";
4
4
  import { sanitizeBlockHtml } from "../../utils/sanitizeHtml";
5
5
  import { getImageSrc } from "../ImageThumbnail";
6
6
 
@@ -1,5 +1,5 @@
1
1
  import React, { useRef } from 'react';
2
- import { Block } from '../../types';
2
+ import type { Block } from '../../types';
3
3
  import { InlineMarkdown } from '../InlineMarkdown';
4
4
 
5
5
  interface TableBlockProps {
@@ -9,7 +9,7 @@ import {
9
9
  type ColumnDef,
10
10
  type SortingState,
11
11
  } from '@tanstack/react-table';
12
- import { Block } from '../../types';
12
+ import type { Block } from '../../types';
13
13
  import { InlineMarkdown } from '../InlineMarkdown';
14
14
  import { PopoutDialog } from '../PopoutDialog';
15
15
  import { parseTableContent, buildCsvFromRows, buildMarkdownTable } from './TableBlock';
@@ -17,39 +17,16 @@ import { CommentPopover, type CommentAskAIHandler } from "../CommentPopover";
17
17
  import { FloatingQuickLabelPicker } from "../FloatingQuickLabelPicker";
18
18
  import type { ViewerHandle } from "../Viewer";
19
19
  import { useHtmlAnnotation } from "./useHtmlAnnotation";
20
- import { ANNOTATION_HIGHLIGHT_CSS, BRIDGE_SCRIPT } from "./bridge-script";
20
+ import {
21
+ THEME_TOKENS,
22
+ buildSrcdocInjection,
23
+ buildThemeTokenPayload,
24
+ hasHostThemeOptIn,
25
+ injectIntoHead,
26
+ } from "./srcdoc";
21
27
 
22
28
  const PREFIX = "plannotator-bridge-";
23
29
 
24
- const THEME_TOKENS = [
25
- "--background",
26
- "--foreground",
27
- "--card",
28
- "--card-foreground",
29
- "--primary",
30
- "--primary-foreground",
31
- "--secondary",
32
- "--secondary-foreground",
33
- "--muted",
34
- "--muted-foreground",
35
- "--accent",
36
- "--accent-foreground",
37
- "--destructive",
38
- "--destructive-foreground",
39
- "--success",
40
- "--success-foreground",
41
- "--warning",
42
- "--warning-foreground",
43
- "--border",
44
- "--input",
45
- "--ring",
46
- "--code-bg",
47
- "--focus-highlight",
48
- "--font-sans",
49
- "--font-mono",
50
- "--radius",
51
- ] as const;
52
-
53
30
  function readThemeTokens(): Record<string, string> {
54
31
  const style = getComputedStyle(document.documentElement);
55
32
  const tokens: Record<string, string> = {};
@@ -124,26 +101,19 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
124
101
  contextText: string;
125
102
  } | null>(null);
126
103
 
127
- const srcdoc = useMemo(() => {
128
- const tokens = readThemeTokens();
129
- let themeCSS = ":root {\n";
130
- for (const [key, val] of Object.entries(tokens)) {
131
- themeCSS += ` ${key}: ${val};\n`;
132
- }
133
- themeCSS += "}\n";
134
- if (isLightTheme()) themeCSS += ":root { color-scheme: light; }\n:root.light, :root { }\n";
104
+ // Host theming is opt-in per document (Plannotator-generated artifacts tag
105
+ // themselves); arbitrary HTML renders untouched, like a standalone tab.
106
+ const hostTheme = useMemo(() => hasHostThemeOptIn(rawHtml), [rawHtml]);
135
107
 
136
- // Version-diff highlights: htmlDiff wraps changed text in <ins>/<del>.
137
- const diffCSS =
138
- "ins{background:#e6ffec;color:#0a7d33;text-decoration:none;border-radius:2px;box-shadow:0 0 0 1px #abf2bc inset}" +
139
- "del{background:#ffebe9;color:#b31d28;text-decoration:line-through;border-radius:2px;box-shadow:0 0 0 1px #ffc1bc inset}";
140
- const injection = `<style>${themeCSS}${ANNOTATION_HIGHLIGHT_CSS}${diffCSS}</style><script>${BRIDGE_SCRIPT}</script>`;
141
- const headClose = rawHtml.indexOf("</head>");
142
- if (headClose !== -1) {
143
- return rawHtml.slice(0, headClose) + injection + rawHtml.slice(headClose);
144
- }
145
- return injection + rawHtml;
146
- }, [rawHtml]);
108
+ const srcdoc = useMemo(() => {
109
+ const injection = buildSrcdocInjection({
110
+ tokens: readThemeTokens(),
111
+ isLight: isLightTheme(),
112
+ hostTheme,
113
+ diffActive: !!diffActive,
114
+ });
115
+ return injectIntoHead(rawHtml, injection);
116
+ }, [rawHtml, hostTheme, diffActive]);
147
117
 
148
118
  const handleResize = useCallback((height: number) => {
149
119
  setIframeHeight(height);
@@ -189,9 +159,13 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
189
159
  useEffect(() => {
190
160
  if (!iframeReady) return;
191
161
  function sendTheme() {
192
- const tokens = readThemeTokens();
193
162
  iframeRef.current?.contentWindow?.postMessage(
194
- { type: `${PREFIX}theme`, tokens, isLight: isLightTheme() },
163
+ {
164
+ type: `${PREFIX}theme`,
165
+ tokens: buildThemeTokenPayload(readThemeTokens(), hostTheme),
166
+ isLight: isLightTheme(),
167
+ hostTheme,
168
+ },
195
169
  "*",
196
170
  );
197
171
  }
@@ -202,7 +176,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
202
176
  attributeFilter: ["class", "style"],
203
177
  });
204
178
  return () => observer.disconnect();
205
- }, [iframeReady]);
179
+ }, [iframeReady, hostTheme]);
206
180
 
207
181
  useImperativeHandle(ref, () => ({
208
182
  removeHighlight: hook.removeHighlight,
@@ -9,6 +9,11 @@
9
9
  * No external dependencies.
10
10
  */
11
11
 
12
+ /**
13
+ * Reads only viewer-namespaced \`--pn-*\` variables (with fallbacks): arbitrary
14
+ * documents may define bare token names like \`--accent\` for themselves, and the
15
+ * viewer must never depend on — or collide with — the author's namespace.
16
+ */
12
17
  export const ANNOTATION_HIGHLIGHT_CSS = `
13
18
  .annotation-highlight {
14
19
  border-radius: 2px;
@@ -17,33 +22,33 @@ export const ANNOTATION_HIGHLIGHT_CSS = `
17
22
  cursor: pointer;
18
23
  }
19
24
  .annotation-highlight.deletion {
20
- background: oklch(from var(--destructive, #c0392b) l c h / 0.35);
25
+ background: oklch(from var(--pn-destructive, #c0392b) l c h / 0.35);
21
26
  text-decoration: line-through;
22
- text-decoration-color: var(--destructive, #c0392b);
27
+ text-decoration-color: var(--pn-destructive, #c0392b);
23
28
  text-decoration-thickness: 2px;
24
29
  }
25
30
  .annotation-highlight.comment {
26
31
  background: oklch(0.70 0.18 60 / 0.3);
27
- border-bottom: 2px solid var(--accent, #d97757);
32
+ border-bottom: 2px solid var(--pn-accent, #d97757);
28
33
  }
29
34
  .annotation-highlight.focused {
30
- background: oklch(from var(--focus-highlight, #4493f8) l c h / 0.45) !important;
31
- box-shadow: 0 0 8px oklch(from var(--focus-highlight, #4493f8) l c h / 0.4);
32
- border-bottom: 2px solid var(--focus-highlight, #4493f8);
35
+ background: oklch(from var(--pn-focus-highlight, #4493f8) l c h / 0.45) !important;
36
+ box-shadow: 0 0 8px oklch(from var(--pn-focus-highlight, #4493f8) l c h / 0.4);
37
+ border-bottom: 2px solid var(--pn-focus-highlight, #4493f8);
33
38
  filter: none;
34
39
  }
35
40
  .annotation-highlight:hover {
36
41
  filter: brightness(1.2);
37
42
  }
38
43
  .plannotator-pinpoint-hover {
39
- outline: 2px solid var(--focus-highlight, #4493f8) !important;
44
+ outline: 2px solid var(--pn-focus-highlight, #4493f8) !important;
40
45
  outline-offset: 1px;
41
46
  cursor: crosshair !important;
42
47
  }
43
48
  /* SVG nodes can't take a CSS outline — stroke their shapes instead. */
44
49
  .plannotator-pinpoint-hover rect, .plannotator-pinpoint-hover path,
45
50
  .plannotator-pinpoint-hover circle, .plannotator-pinpoint-hover ellipse, .plannotator-pinpoint-hover polygon {
46
- stroke: var(--focus-highlight, #4493f8) !important; stroke-width: 2.5px !important;
51
+ stroke: var(--pn-focus-highlight, #4493f8) !important; stroke-width: 2.5px !important;
47
52
  }
48
53
  `;
49
54
 
@@ -51,15 +56,23 @@ export const BRIDGE_SCRIPT = `(function() {
51
56
  var PREFIX = 'plannotator-bridge-';
52
57
 
53
58
  // --- Theme ---
59
+ // The author owns this document. Unless it opted in to host theming
60
+ // (hostTheme), only viewer-namespaced --pn-* properties may be written to its
61
+ // root, and its class list is never touched.
54
62
  window.addEventListener('message', function(e) {
55
63
  if (!e.data || e.data.type !== PREFIX + 'theme') return;
56
64
  var root = document.documentElement;
57
- var tokens = e.data.tokens;
65
+ var tokens = e.data.tokens || {};
66
+ var hostTheme = !!e.data.hostTheme;
58
67
  for (var key in tokens) {
59
- if (tokens.hasOwnProperty(key)) root.style.setProperty(key, tokens[key]);
68
+ if (!tokens.hasOwnProperty(key)) continue;
69
+ if (!hostTheme && key.indexOf('--pn-') !== 0) continue;
70
+ root.style.setProperty(key, tokens[key]);
71
+ }
72
+ if (hostTheme) {
73
+ root.classList.remove('light');
74
+ if (e.data.isLight) root.classList.add('light');
60
75
  }
61
- root.classList.remove('light');
62
- if (e.data.isLight) root.classList.add('light');
63
76
  });
64
77
 
65
78
  // --- Resize ---
@@ -244,7 +257,7 @@ export const BRIDGE_SCRIPT = `(function() {
244
257
  if (!pinpointLabelEl) {
245
258
  pinpointLabelEl = document.createElement('div');
246
259
  pinpointLabelEl.setAttribute('data-plannotator-pinpoint-label', '');
247
- pinpointLabelEl.style.cssText = 'position:fixed;z-index:2147483647;pointer-events:none;display:none;font:600 11px/1.3 system-ui,-apple-system,sans-serif;padding:2px 7px;border-radius:5px;background:var(--focus-highlight,#4493f8);color:#fff;white-space:nowrap;box-shadow:0 1px 5px rgba(0,0,0,.35);';
260
+ pinpointLabelEl.style.cssText = 'position:fixed;z-index:2147483647;pointer-events:none;display:none;font:600 11px/1.3 system-ui,-apple-system,sans-serif;padding:2px 7px;border-radius:5px;background:var(--pn-focus-highlight,#4493f8);color:#fff;white-space:nowrap;box-shadow:0 1px 5px rgba(0,0,0,.35);';
248
261
  document.body.appendChild(pinpointLabelEl);
249
262
  }
250
263
  return pinpointLabelEl;
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Srcdoc injection builder for the HTML viewer.
3
+ *
4
+ * Product rule: arbitrary HTML must render exactly as it would in a plain
5
+ * browser tab. The viewer never writes into the document's namespace — no bare
6
+ * CSS custom properties, no classes on the author's root, no `color-scheme`,
7
+ * no styling of author elements. Host theme tokens are pushed under the
8
+ * viewer-owned `--pn-*` prefix, which the annotation CSS reads.
9
+ *
10
+ * Documents that WANT to follow the host theme (e.g. Plannotator-generated
11
+ * artifacts) opt in with `<meta name="plannotator-theme" content="host">`,
12
+ * which re-enables the bare-token push, the `light` class on their root, and
13
+ * `color-scheme` sync — for that document only.
14
+ *
15
+ * Pure string logic (no DOM) so the rendering-neutrality contract is unit-testable.
16
+ */
17
+ import { ANNOTATION_HIGHLIGHT_CSS, BRIDGE_SCRIPT } from "./bridge-script";
18
+
19
+ export const THEME_TOKENS = [
20
+ "--background",
21
+ "--foreground",
22
+ "--card",
23
+ "--card-foreground",
24
+ "--primary",
25
+ "--primary-foreground",
26
+ "--secondary",
27
+ "--secondary-foreground",
28
+ "--muted",
29
+ "--muted-foreground",
30
+ "--accent",
31
+ "--accent-foreground",
32
+ "--destructive",
33
+ "--destructive-foreground",
34
+ "--success",
35
+ "--success-foreground",
36
+ "--warning",
37
+ "--warning-foreground",
38
+ "--border",
39
+ "--input",
40
+ "--ring",
41
+ "--code-bg",
42
+ "--focus-highlight",
43
+ "--font-sans",
44
+ "--font-mono",
45
+ "--radius",
46
+ ] as const;
47
+
48
+ /** Viewer-owned namespace for properties injected into the document. */
49
+ export const PN_TOKEN_PREFIX = "--pn-";
50
+
51
+ /**
52
+ * Version-diff highlights. htmlDiff tags the <ins>/<del> it generates with
53
+ * this class so author-written <ins>/<del> markup is never restyled.
54
+ */
55
+ export const DIFF_HIGHLIGHT_CSS =
56
+ "ins.plannotator-diff{background:#e6ffec;color:#0a7d33;text-decoration:none;border-radius:2px;box-shadow:0 0 0 1px #abf2bc inset}" +
57
+ "del.plannotator-diff{background:#ffebe9;color:#b31d28;text-decoration:line-through;border-radius:2px;box-shadow:0 0 0 1px #ffc1bc inset}";
58
+
59
+ /**
60
+ * True when the document opts in to following the host theme via
61
+ * `<meta name="plannotator-theme" content="host">` (attribute order/quoting agnostic).
62
+ */
63
+ export function hasHostThemeOptIn(rawHtml: string): boolean {
64
+ const metas = rawHtml.match(/<meta\b[^>]*>/gi);
65
+ if (!metas) return false;
66
+ return metas.some(
67
+ (tag) =>
68
+ /\bname\s*=\s*["']?plannotator-theme["']?/i.test(tag) &&
69
+ /\bcontent\s*=\s*["']?host["']?/i.test(tag),
70
+ );
71
+ }
72
+
73
+ /**
74
+ * Build the theme properties to write into the document. Bare host token names
75
+ * (`--muted`, `--background`, …) collide with author variables, so they are
76
+ * remapped to `--pn-*`; the originals ride along only for host-theme documents.
77
+ */
78
+ export function buildThemeTokenPayload(
79
+ tokens: Record<string, string>,
80
+ hostTheme: boolean,
81
+ ): Record<string, string> {
82
+ const payload: Record<string, string> = {};
83
+ for (const [key, val] of Object.entries(tokens)) {
84
+ payload[PN_TOKEN_PREFIX + key.slice(2)] = val;
85
+ if (hostTheme) payload[key] = val;
86
+ }
87
+ return payload;
88
+ }
89
+
90
+ export interface SrcdocInjectionOptions {
91
+ /** Host theme tokens, keyed by bare name (as read from the host root). */
92
+ tokens: Record<string, string>;
93
+ /** Whether the host is currently in its light theme. */
94
+ isLight: boolean;
95
+ /** Document opted in to host theming (see {@link hasHostThemeOptIn}). */
96
+ hostTheme: boolean;
97
+ /** The version-diff view is showing (rawHtml is htmlDiff output). */
98
+ diffActive: boolean;
99
+ }
100
+
101
+ /** The `<style>` + `<script>` block spliced into the document's head. */
102
+ export function buildSrcdocInjection({
103
+ tokens,
104
+ isLight,
105
+ hostTheme,
106
+ diffActive,
107
+ }: SrcdocInjectionOptions): string {
108
+ const payload = buildThemeTokenPayload(tokens, hostTheme);
109
+ let themeCSS = ":root {\n";
110
+ for (const [key, val] of Object.entries(payload)) {
111
+ themeCSS += ` ${key}: ${val};\n`;
112
+ }
113
+ themeCSS += "}\n";
114
+ // Host-theme documents mirror the host's light/dark; arbitrary documents keep
115
+ // their own color-scheme resolution (document + OS), like a standalone tab.
116
+ if (hostTheme) {
117
+ themeCSS += `:root { color-scheme: ${isLight ? "light" : "dark"}; }\n`;
118
+ }
119
+ const diffCSS = diffActive ? DIFF_HIGHLIGHT_CSS : "";
120
+ return `<style>${themeCSS}${ANNOTATION_HIGHLIGHT_CSS}${diffCSS}</style><script>${BRIDGE_SCRIPT}</script>`;
121
+ }
122
+
123
+ /** Splice the injection just before `</head>`, or prepend when there is none. */
124
+ export function injectIntoHead(rawHtml: string, injection: string): string {
125
+ const headClose = rawHtml.indexOf("</head>");
126
+ if (headClose !== -1) {
127
+ return rawHtml.slice(0, headClose) + injection + rawHtml.slice(headClose);
128
+ }
129
+ return injection + rawHtml;
130
+ }
@@ -174,6 +174,12 @@ const escapeAttrValue = (value: string): string => {
174
174
  return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
175
175
  };
176
176
 
177
+ /** Whitespace-insensitive comparison for restore verification: a highlight
178
+ * spanning element boundaries legitimately differs from `originalText` in
179
+ * whitespace, so only content differences count as a mismatch. */
180
+ const normalizeForRestoreCompare = (value: string): string =>
181
+ value.replace(/\s+/g, ' ').trim();
182
+
177
183
  const applyMathAnnotationClass = (
178
184
  element: HTMLElement,
179
185
  id: string,
@@ -227,6 +233,15 @@ export interface UseAnnotationHighlighterOptions {
227
233
  selectedAnnotationId: string | null;
228
234
  mode: EditorMode;
229
235
  enabled?: boolean;
236
+ /** Opt-in: after a meta-based restore (`fromStore`), verify the painted text
237
+ * matches the annotation's `originalText` (whitespace-normalized). On
238
+ * mismatch the wrong highlight is removed and the text-search fallback
239
+ * runs instead; if that also fails, `onRestoreMismatch` fires and nothing
240
+ * is painted. Default false — today's behavior (positions are trusted). */
241
+ verifyRestoredContent?: boolean;
242
+ /** Fires when a restore was rejected (content mismatch) and the text-search
243
+ * fallback could not re-anchor the annotation either. */
244
+ onRestoreMismatch?: (annotation: Annotation, restoredText: string) => void;
230
245
  }
231
246
 
232
247
  export interface UseAnnotationHighlighterReturn {
@@ -258,6 +273,8 @@ export function useAnnotationHighlighter({
258
273
  selectedAnnotationId,
259
274
  mode,
260
275
  enabled = true,
276
+ verifyRestoredContent = false,
277
+ onRestoreMismatch,
261
278
  }: UseAnnotationHighlighterOptions): UseAnnotationHighlighterReturn {
262
279
  const highlighterRef = useRef<Highlighter | null>(null);
263
280
  const modeRef = useRef<EditorMode>(mode);
@@ -278,6 +295,8 @@ export function useAnnotationHighlighter({
278
295
  useEffect(() => { modeRef.current = mode; }, [mode]);
279
296
  useEffect(() => { onAddAnnotationRef.current = onAddAnnotation; }, [onAddAnnotation]);
280
297
  useEffect(() => { onSelectAnnotationRef.current = onSelectAnnotation; }, [onSelectAnnotation]);
298
+ const onRestoreMismatchRef = useRef(onRestoreMismatch);
299
+ useEffect(() => { onRestoreMismatchRef.current = onRestoreMismatch; }, [onRestoreMismatch]);
281
300
 
282
301
  const clearPendingSelection = useCallback(() => {
283
302
  pendingSourceRef.current = null;
@@ -625,23 +644,42 @@ export function useAnnotationHighlighter({
625
644
  }
626
645
  }
627
646
 
647
+ // When a meta-based restore paints text that no longer matches the
648
+ // anchor's originalText (document drift), remember what it painted so
649
+ // the mismatch can be reported if the text fallback also fails.
650
+ let rejectedRestoreText: string | null = null;
651
+
628
652
  if (ann.startMeta && ann.endMeta) {
629
653
  try {
630
654
  highlighter.fromStore(ann.startMeta, ann.endMeta, ann.originalText, ann.id);
631
655
  const restoredDoms = highlighter.getDoms(ann.id);
632
656
  if (restoredDoms && restoredDoms.length > 0) {
633
- if (ann.type === AnnotationType.DELETION) {
634
- highlighter.addClass('deletion', ann.id);
635
- } else if (ann.type === AnnotationType.COMMENT) {
636
- highlighter.addClass('comment', ann.id);
657
+ const restoredText = restoredDoms.map(dom => dom.textContent ?? '').join('');
658
+ if (
659
+ verifyRestoredContent &&
660
+ normalizeForRestoreCompare(restoredText) !== normalizeForRestoreCompare(ann.originalText)
661
+ ) {
662
+ // Positions resolved, but onto the WRONG text — remove the bad
663
+ // highlight and fall through to the text-search fallback.
664
+ try { highlighter.remove(ann.id); } catch {}
665
+ rejectedRestoreText = restoredText;
666
+ } else {
667
+ if (ann.type === AnnotationType.DELETION) {
668
+ highlighter.addClass('deletion', ann.id);
669
+ } else if (ann.type === AnnotationType.COMMENT) {
670
+ highlighter.addClass('comment', ann.id);
671
+ }
672
+ return;
637
673
  }
638
- return;
639
674
  }
640
675
  } catch {}
641
676
  }
642
677
 
643
678
  const range = findTextInDOM(ann.originalText);
644
679
  if (!range) {
680
+ if (rejectedRestoreText !== null) {
681
+ onRestoreMismatchRef.current?.(ann, rejectedRestoreText);
682
+ }
645
683
  console.warn(`Could not find text for annotation ${ann.id}: "${ann.originalText.slice(0, 50)}..."`);
646
684
  return;
647
685
  }
@@ -720,7 +758,7 @@ export function useAnnotationHighlighter({
720
758
  console.warn(`Failed to apply highlight for annotation ${ann.id}:`, e);
721
759
  }
722
760
  });
723
- }, [findMathElementsForAnnotation, findTextInDOM]);
761
+ }, [findMathElementsForAnnotation, findTextInDOM, verifyRestoredContent]);
724
762
 
725
763
  const removeHighlight = useCallback((id: string) => {
726
764
  highlighterRef.current?.remove(id);
@@ -20,11 +20,6 @@ interface DraftData {
20
20
  ts: number;
21
21
  }
22
22
 
23
- interface MissingDraftData {
24
- found?: false;
25
- draftGeneration?: number;
26
- }
27
-
28
23
  function readDraftGeneration(value: unknown): number | null {
29
24
  return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : null;
30
25
  }
@@ -1,7 +1,7 @@
1
- import { useEffect, useState, useCallback, type RefObject } from 'react';
1
+ import { useEffect, useState, type RefObject } from 'react';
2
2
  import type Highlighter from '@plannotator/web-highlighter';
3
3
  import type { InputMethod } from '../types';
4
- import { resolvePinpointTarget, type PinpointTarget } from '../utils/blockTargeting';
4
+ import { resolvePinpointTarget } from '../utils/blockTargeting';
5
5
 
6
6
  export interface UsePinpointOptions {
7
7
  containerRef: RefObject<HTMLElement | null>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plannotator/ui",
3
- "version": "0.23.0",
3
+ "version": "0.25.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./components/*": "./components/*.tsx",
package/utils/parser.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Block, type Annotation, type CodeAnnotation, type EditorAnnotation, type ImageAttachment } from '../types';
1
+ import type { Block, Annotation, CodeAnnotation, EditorAnnotation, ImageAttachment } from '../types';
2
2
  import { planDenyFeedback } from '@plannotator/core/feedback-templates';
3
3
 
4
4
  /**
package/utils/sharing.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  * Inspired by textarea.my's approach.
9
9
  */
10
10
 
11
- import { Annotation, AnnotationType, type ImageAttachment } from '../types';
11
+ import { AnnotationType, type Annotation, type ImageAttachment } from '../types';
12
12
  import { compress, decompress } from '@plannotator/core/compress';
13
13
  import { encrypt, decrypt } from '@plannotator/core/crypto';
14
14