@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.
- package/components/AnnotationPanel.tsx +68 -37
- package/components/AnnotationToolbar.tsx +1 -1
- package/components/BlockRenderer.tsx +1 -1
- package/components/EditorAnnotationCard.tsx +13 -10
- package/components/ImageAnnotator/index.tsx +1 -1
- package/components/Viewer.tsx +23 -9
- package/components/blocks/CodeBlock.tsx +2 -2
- package/components/blocks/HtmlBlock.tsx +1 -1
- package/components/blocks/TableBlock.tsx +1 -1
- package/components/blocks/TablePopout.tsx +1 -1
- package/components/html-viewer/HtmlViewer.tsx +26 -52
- package/components/html-viewer/bridge-script.ts +26 -13
- package/components/html-viewer/srcdoc.ts +130 -0
- package/hooks/useAnnotationHighlighter.ts +44 -6
- package/hooks/useCodeAnnotationDraft.ts +0 -5
- package/hooks/usePinpoint.ts +2 -2
- package/package.json +1 -1
- package/utils/parser.ts +1 -1
- package/utils/sharing.ts +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React, { useState, useRef, useEffect } from 'react';
|
|
2
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
521
|
-
|
|
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={
|
|
525
|
-
className="relative rounded-md p-1.5 text-muted-foreground transition-colors before:absolute before:-inset-1.5 before:content-[''] hover:text-
|
|
526
|
-
title="
|
|
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
|
-
<
|
|
551
|
+
<TrashCardIcon />
|
|
529
552
|
</button>
|
|
530
|
-
|
|
531
|
-
|
|
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
|
-
|
|
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
|
-
|
|
654
|
-
|
|
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();
|
|
658
|
-
className="relative rounded-md p-1.5 text-muted-foreground transition-colors before:absolute before:-inset-1.5 before:content-[''] hover:text-
|
|
659
|
-
title="
|
|
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
|
-
<
|
|
700
|
+
<TrashCardIcon />
|
|
662
701
|
</button>
|
|
663
|
-
|
|
664
|
-
|
|
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
|
|
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";
|
|
@@ -14,7 +14,8 @@ type EditorAnnotationVariant = 'plan' | 'code-review';
|
|
|
14
14
|
|
|
15
15
|
interface EditorAnnotationCardProps {
|
|
16
16
|
annotation: EditorAnnotation;
|
|
17
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
<
|
|
48
|
-
|
|
49
|
-
|
|
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,
|
|
5
|
+
import type { Point, AnnotatorState } from './types';
|
|
6
6
|
import { DEFAULT_STATE } from './types';
|
|
7
7
|
|
|
8
8
|
interface ImageAnnotatorProps {
|
package/components/Viewer.tsx
CHANGED
|
@@ -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,
|
|
5
|
-
import {
|
|
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
|
|
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
|
|
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
|
|
|
@@ -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 {
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
{
|
|
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))
|
|
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
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
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
|
}
|
package/hooks/usePinpoint.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { useEffect, useState,
|
|
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
|
|
4
|
+
import { resolvePinpointTarget } from '../utils/blockTargeting';
|
|
5
5
|
|
|
6
6
|
export interface UsePinpointOptions {
|
|
7
7
|
containerRef: RefObject<HTMLElement | null>;
|
package/package.json
CHANGED
package/utils/parser.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Block,
|
|
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 {
|
|
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
|
|