@plannotator/ui 0.33.0 → 0.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/HANDOFF.md +42 -8
- package/README.md +11 -3
- package/components/AnnotationPanel.tsx +37 -36
- package/components/AnnotationToolstrip.tsx +25 -15
- package/components/GraphvizBlock.tsx +15 -4
- package/components/HtmlSurfaceControls.tsx +16 -7
- package/components/ImageAnnotator/Toolbar.tsx +27 -0
- package/components/ImageAnnotator/index.tsx +51 -59
- package/components/ImageAnnotator/strokeHistory.ts +58 -0
- package/components/MermaidBlock.tsx +31 -4
- package/components/PlanHeaderMenu.tsx +1 -1
- package/components/Settings.tsx +16 -1
- package/components/StickyHeaderLane.tsx +4 -0
- package/components/html-viewer/HtmlViewer.tsx +17 -4
- package/components/html-viewer/useHtmlAnnotation.ts +20 -11
- package/config/index.ts +3 -0
- package/config/reviewView.ts +37 -0
- package/config/settings.ts +15 -0
- package/hooks/useCodeAnnotationDraft.ts +19 -2
- package/hooks/useHtmlRefresh.ts +19 -10
- package/hooks/useSharing.ts +57 -8
- package/hooks/useUndoHistory.ts +83 -0
- package/package.json +2 -2
- package/shortcuts/history.shortcuts.ts +25 -0
- package/shortcuts/index.ts +1 -0
- package/shortcuts/plan-review/imageAnnotator.shortcuts.ts +10 -1
- package/utils/math.ts +30 -6
- package/utils/mermaid-math-slot.ts +56 -0
- package/utils/parser.ts +41 -14
- package/utils/runtimeRetry.ts +31 -0
- package/utils/undoHistory.ts +167 -0
- package/webmcp/changes.ts +24 -0
- package/webmcp/nudges.ts +22 -7
|
@@ -2,8 +2,16 @@ 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
|
|
6
|
-
import {
|
|
5
|
+
import type { Point } from './types';
|
|
6
|
+
import { useImageAnnotatorShortcuts } from '../../shortcuts';
|
|
7
|
+
import {
|
|
8
|
+
DEFAULT_STROKE_HISTORY_STATE,
|
|
9
|
+
clearStrokeHistory,
|
|
10
|
+
recordStroke,
|
|
11
|
+
redoStroke,
|
|
12
|
+
undoStroke,
|
|
13
|
+
type StrokeHistoryState,
|
|
14
|
+
} from './strokeHistory';
|
|
7
15
|
|
|
8
16
|
interface ImageAnnotatorProps {
|
|
9
17
|
imageSrc: string;
|
|
@@ -21,7 +29,7 @@ export const ImageAnnotator: React.FC<ImageAnnotatorProps> = ({
|
|
|
21
29
|
onClose,
|
|
22
30
|
initialName = '',
|
|
23
31
|
}) => {
|
|
24
|
-
const [state, setState] = useState<
|
|
32
|
+
const [state, setState] = useState<StrokeHistoryState>(DEFAULT_STROKE_HISTORY_STATE);
|
|
25
33
|
const [saving, setSaving] = useState(false);
|
|
26
34
|
const [name, setName] = useState(initialName);
|
|
27
35
|
const imageRef = useRef<HTMLImageElement | null>(null);
|
|
@@ -30,51 +38,11 @@ export const ImageAnnotator: React.FC<ImageAnnotatorProps> = ({
|
|
|
30
38
|
// Reset state when dialog opens
|
|
31
39
|
useEffect(() => {
|
|
32
40
|
if (isOpen) {
|
|
33
|
-
setState(
|
|
41
|
+
setState(DEFAULT_STROKE_HISTORY_STATE);
|
|
34
42
|
setName(initialName);
|
|
35
43
|
}
|
|
36
44
|
}, [isOpen, initialName]);
|
|
37
45
|
|
|
38
|
-
// Keyboard shortcuts
|
|
39
|
-
useEffect(() => {
|
|
40
|
-
if (!isOpen) return;
|
|
41
|
-
|
|
42
|
-
const handleKeyDown = (e: KeyboardEvent) => {
|
|
43
|
-
// Don't intercept when typing in the name input
|
|
44
|
-
const target = e.target as HTMLElement;
|
|
45
|
-
if (target.tagName === 'INPUT') {
|
|
46
|
-
if (e.key === 'Escape') {
|
|
47
|
-
// Blur and let the next Escape close
|
|
48
|
-
target.blur();
|
|
49
|
-
e.preventDefault();
|
|
50
|
-
}
|
|
51
|
-
return;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// Escape or Enter to accept
|
|
55
|
-
if (e.key === 'Escape' || e.key === 'Enter') {
|
|
56
|
-
e.preventDefault();
|
|
57
|
-
handleAccept();
|
|
58
|
-
return;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// Cmd+Z to undo
|
|
62
|
-
if ((e.metaKey || e.ctrlKey) && e.key === 'z') {
|
|
63
|
-
e.preventDefault();
|
|
64
|
-
handleUndo();
|
|
65
|
-
return;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// 1/2/3 to switch tools
|
|
69
|
-
if (e.key === '1') setState(s => ({ ...s, tool: 'pen' }));
|
|
70
|
-
if (e.key === '2') setState(s => ({ ...s, tool: 'arrow' }));
|
|
71
|
-
if (e.key === '3') setState(s => ({ ...s, tool: 'circle' }));
|
|
72
|
-
};
|
|
73
|
-
|
|
74
|
-
window.addEventListener('keydown', handleKeyDown);
|
|
75
|
-
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
76
|
-
}, [isOpen, state.strokes]);
|
|
77
|
-
|
|
78
46
|
const handleStrokeStart = useCallback((point: Point) => {
|
|
79
47
|
const id = crypto.randomUUID();
|
|
80
48
|
setState(s => ({
|
|
@@ -107,27 +75,20 @@ export const ImageAnnotator: React.FC<ImageAnnotatorProps> = ({
|
|
|
107
75
|
if (!s.currentStroke || s.currentStroke.points.length < 2) {
|
|
108
76
|
return { ...s, currentStroke: null };
|
|
109
77
|
}
|
|
110
|
-
return
|
|
111
|
-
...s,
|
|
112
|
-
strokes: [...s.strokes, s.currentStroke],
|
|
113
|
-
currentStroke: null,
|
|
114
|
-
};
|
|
78
|
+
return recordStroke(s, s.currentStroke);
|
|
115
79
|
});
|
|
116
80
|
}, []);
|
|
117
81
|
|
|
118
82
|
const handleUndo = useCallback(() => {
|
|
119
|
-
setState(
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
83
|
+
setState(undoStroke);
|
|
84
|
+
}, []);
|
|
85
|
+
|
|
86
|
+
const handleRedo = useCallback(() => {
|
|
87
|
+
setState(redoStroke);
|
|
123
88
|
}, []);
|
|
124
89
|
|
|
125
90
|
const handleClear = useCallback(() => {
|
|
126
|
-
setState(
|
|
127
|
-
...s,
|
|
128
|
-
strokes: [],
|
|
129
|
-
currentStroke: null,
|
|
130
|
-
}));
|
|
91
|
+
setState(clearStrokeHistory);
|
|
131
92
|
}, []);
|
|
132
93
|
|
|
133
94
|
const handleImageLoad = useCallback((img: HTMLImageElement) => {
|
|
@@ -159,7 +120,8 @@ export const ImageAnnotator: React.FC<ImageAnnotatorProps> = ({
|
|
|
159
120
|
|
|
160
121
|
// Composite image + drawings
|
|
161
122
|
const canvas = document.createElement('canvas');
|
|
162
|
-
const ctx = canvas.getContext('2d')
|
|
123
|
+
const ctx = canvas.getContext('2d');
|
|
124
|
+
if (!ctx) throw new Error('Canvas 2D context is unavailable');
|
|
163
125
|
|
|
164
126
|
canvas.width = img.naturalWidth;
|
|
165
127
|
canvas.height = img.naturalHeight;
|
|
@@ -190,6 +152,29 @@ export const ImageAnnotator: React.FC<ImageAnnotatorProps> = ({
|
|
|
190
152
|
}
|
|
191
153
|
};
|
|
192
154
|
|
|
155
|
+
const keyboardTargetIsInput = (event: KeyboardEvent): boolean =>
|
|
156
|
+
event.composedPath()[0] instanceof HTMLInputElement;
|
|
157
|
+
|
|
158
|
+
useImageAnnotatorShortcuts({
|
|
159
|
+
handlers: {
|
|
160
|
+
penTool: { when: (event) => isOpen && !saving && !keyboardTargetIsInput(event), handle: () => setState((current) => ({ ...current, tool: 'pen' })) },
|
|
161
|
+
arrowTool: { when: (event) => isOpen && !saving && !keyboardTargetIsInput(event), handle: () => setState((current) => ({ ...current, tool: 'arrow' })) },
|
|
162
|
+
circleTool: { when: (event) => isOpen && !saving && !keyboardTargetIsInput(event), handle: () => setState((current) => ({ ...current, tool: 'circle' })) },
|
|
163
|
+
undo: {
|
|
164
|
+
when: (event) => isOpen && !saving && !keyboardTargetIsInput(event) && state.strokes.length > 0,
|
|
165
|
+
handle: handleUndo,
|
|
166
|
+
},
|
|
167
|
+
redo: {
|
|
168
|
+
when: (event) => isOpen && !saving && !keyboardTargetIsInput(event) && state.futureStrokes.length > 0,
|
|
169
|
+
handle: handleRedo,
|
|
170
|
+
},
|
|
171
|
+
save: {
|
|
172
|
+
when: (event) => isOpen && !saving && !keyboardTargetIsInput(event),
|
|
173
|
+
handle: () => { void handleAccept(); },
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
|
|
193
178
|
const handleBackdropClick = (e: React.MouseEvent) => {
|
|
194
179
|
if (e.target === e.currentTarget) {
|
|
195
180
|
handleAccept();
|
|
@@ -212,10 +197,12 @@ export const ImageAnnotator: React.FC<ImageAnnotatorProps> = ({
|
|
|
212
197
|
color={state.color}
|
|
213
198
|
strokeSize={state.strokeSize}
|
|
214
199
|
canUndo={state.strokes.length > 0}
|
|
200
|
+
canRedo={state.futureStrokes.length > 0}
|
|
215
201
|
onToolChange={(tool) => setState(s => ({ ...s, tool }))}
|
|
216
202
|
onColorChange={(color) => setState(s => ({ ...s, color }))}
|
|
217
203
|
onStrokeSizeChange={(strokeSize) => setState(s => ({ ...s, strokeSize }))}
|
|
218
204
|
onUndo={handleUndo}
|
|
205
|
+
onRedo={handleRedo}
|
|
219
206
|
onClear={handleClear}
|
|
220
207
|
onSave={handleAccept}
|
|
221
208
|
/>
|
|
@@ -242,6 +229,11 @@ export const ImageAnnotator: React.FC<ImageAnnotatorProps> = ({
|
|
|
242
229
|
value={name}
|
|
243
230
|
onChange={(e) => setName(e.target.value)}
|
|
244
231
|
onKeyDown={(e) => {
|
|
232
|
+
if (e.key === 'Escape') {
|
|
233
|
+
e.preventDefault();
|
|
234
|
+
e.currentTarget.blur();
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
245
237
|
if (e.key === 'Enter' && !e.nativeEvent.isComposing) {
|
|
246
238
|
e.preventDefault();
|
|
247
239
|
handleAccept();
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { DEFAULT_STATE, type AnnotatorState, type Stroke } from './types';
|
|
2
|
+
|
|
3
|
+
/** Internal image-annotator state that adds redo without widening the published state contract. */
|
|
4
|
+
export interface StrokeHistoryState extends AnnotatorState {
|
|
5
|
+
/** Strokes removed by undo, newest redo candidate last. */
|
|
6
|
+
futureStrokes: Stroke[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Initial state for the image annotator's internal stroke history. */
|
|
10
|
+
export const DEFAULT_STROKE_HISTORY_STATE: StrokeHistoryState = {
|
|
11
|
+
...DEFAULT_STATE,
|
|
12
|
+
futureStrokes: [],
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** Commit a completed stroke and invalidate the abandoned redo branch. */
|
|
16
|
+
export function recordStroke(state: StrokeHistoryState, stroke: Stroke): StrokeHistoryState {
|
|
17
|
+
return {
|
|
18
|
+
...state,
|
|
19
|
+
strokes: [...state.strokes, stroke],
|
|
20
|
+
futureStrokes: [],
|
|
21
|
+
currentStroke: null,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Move the latest visible stroke to the redo stack. */
|
|
26
|
+
export function undoStroke(state: StrokeHistoryState): StrokeHistoryState {
|
|
27
|
+
const stroke = state.strokes.at(-1);
|
|
28
|
+
if (!stroke) return state;
|
|
29
|
+
return {
|
|
30
|
+
...state,
|
|
31
|
+
strokes: state.strokes.slice(0, -1),
|
|
32
|
+
futureStrokes: [...state.futureStrokes, stroke],
|
|
33
|
+
currentStroke: null,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Restore the latest stroke removed by undo. */
|
|
38
|
+
export function redoStroke(state: StrokeHistoryState): StrokeHistoryState {
|
|
39
|
+
const stroke = state.futureStrokes.at(-1);
|
|
40
|
+
if (!stroke) return state;
|
|
41
|
+
return {
|
|
42
|
+
...state,
|
|
43
|
+
strokes: [...state.strokes, stroke],
|
|
44
|
+
futureStrokes: state.futureStrokes.slice(0, -1),
|
|
45
|
+
currentStroke: null,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Clear the canvas and invalidate both stroke branches. */
|
|
50
|
+
export function clearStrokeHistory(state: StrokeHistoryState): StrokeHistoryState {
|
|
51
|
+
if (state.strokes.length === 0 && state.futureStrokes.length === 0 && state.currentStroke === null) return state;
|
|
52
|
+
return {
|
|
53
|
+
...state,
|
|
54
|
+
strokes: [],
|
|
55
|
+
futureStrokes: [],
|
|
56
|
+
currentStroke: null,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -9,6 +9,12 @@ import {
|
|
|
9
9
|
loadMermaidRuntime,
|
|
10
10
|
__setMermaidRuntimeLoaderForTests,
|
|
11
11
|
} from '../utils/mermaid';
|
|
12
|
+
import { loadMathRenderer } from '../utils/math';
|
|
13
|
+
import { hasMermaidMath } from '../utils/mermaid-math-slot';
|
|
14
|
+
import { createRuntimeRetryEpoch } from '../utils/runtimeRetry';
|
|
15
|
+
|
|
16
|
+
/** One Retry re-attempts every block whose runtime import failed (see utils/runtimeRetry). */
|
|
17
|
+
const mermaidRetryEpoch = createRuntimeRetryEpoch();
|
|
12
18
|
|
|
13
19
|
// Re-exported: the config pin test and the lazy-retry test import them from here.
|
|
14
20
|
export { MERMAID_CONFIG, __setMermaidRuntimeLoaderForTests };
|
|
@@ -133,6 +139,16 @@ const MermaidBlockImpl: React.FC<{ block: Block }> = ({ block }) => {
|
|
|
133
139
|
const [retryToken, setRetryToken] = useState(0);
|
|
134
140
|
const [showSource, setShowSource] = useState(false);
|
|
135
141
|
const [isExpanded, setIsExpanded] = useState(false);
|
|
142
|
+
// A sibling's Retry re-attempts this block too, but only while its own
|
|
143
|
+
// failure was the shared runtime import; a healthy block or a diagram
|
|
144
|
+
// syntax error is left alone.
|
|
145
|
+
const runtimeUnavailableRef = useRef(runtimeUnavailable);
|
|
146
|
+
runtimeUnavailableRef.current = runtimeUnavailable;
|
|
147
|
+
useEffect(() => mermaidRetryEpoch.subscribe(() => {
|
|
148
|
+
if (!runtimeUnavailableRef.current) return;
|
|
149
|
+
setError(null);
|
|
150
|
+
setRetryToken((token) => token + 1);
|
|
151
|
+
}), []);
|
|
136
152
|
|
|
137
153
|
// All zoom/pan state as refs to avoid re-renders
|
|
138
154
|
const zoomLevelRef = useRef(1);
|
|
@@ -208,6 +224,20 @@ const MermaidBlockImpl: React.FC<{ block: Block }> = ({ block }) => {
|
|
|
208
224
|
return;
|
|
209
225
|
}
|
|
210
226
|
try {
|
|
227
|
+
// A `$$` label makes Mermaid render KaTeX. On a host that redirects
|
|
228
|
+
// Mermaid's `katex` import to `utils/mermaid-math-slot` the label is
|
|
229
|
+
// typeset through the math slot, which must be filled by then: warm
|
|
230
|
+
// it with the registered loader first. A filled slot (Plannotator's
|
|
231
|
+
// eager entry) resolves at once; a load failure is left to the
|
|
232
|
+
// render, whose error panel names it with the source.
|
|
233
|
+
if (hasMermaidMath(block.content)) {
|
|
234
|
+
try {
|
|
235
|
+
await loadMathRenderer();
|
|
236
|
+
} catch {
|
|
237
|
+
// Reported by the render below.
|
|
238
|
+
}
|
|
239
|
+
if (cancelled) return;
|
|
240
|
+
}
|
|
211
241
|
const id = `mermaid-${block.id}`;
|
|
212
242
|
const { svg: renderedSvg } = await mermaid.render(id, block.content);
|
|
213
243
|
if (!cancelled) {
|
|
@@ -443,10 +473,7 @@ const MermaidBlockImpl: React.FC<{ block: Block }> = ({ block }) => {
|
|
|
443
473
|
{runtimeUnavailable && (
|
|
444
474
|
<button
|
|
445
475
|
type="button"
|
|
446
|
-
onClick={() =>
|
|
447
|
-
setError(null);
|
|
448
|
-
setRetryToken((token) => token + 1);
|
|
449
|
-
}}
|
|
476
|
+
onClick={() => mermaidRetryEpoch.bump()}
|
|
450
477
|
className="ml-auto rounded-md border border-destructive/30 px-2 py-0.5 text-xs text-destructive hover:bg-destructive/10"
|
|
451
478
|
title="Retry loading the diagram renderer"
|
|
452
479
|
>
|
|
@@ -40,7 +40,7 @@ interface PlanHeaderMenuProps {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
export interface CompactPlanAction {
|
|
43
|
-
id: 'exit' | 'feedback' | 'approve' | 'copy' | 'done' | 'edit' | 'tools' | 'annotate' | 'annotations' | 'ai' | 'review';
|
|
43
|
+
id: 'exit' | 'feedback' | 'approve' | 'copy' | 'done' | 'edit' | 'tools' | 'annotate' | 'refresh' | 'annotations' | 'ai' | 'review';
|
|
44
44
|
label: string;
|
|
45
45
|
subtitle?: string;
|
|
46
46
|
onSelect: () => void;
|
package/components/Settings.tsx
CHANGED
|
@@ -3,7 +3,7 @@ import { createPortal } from 'react-dom';
|
|
|
3
3
|
import type { AnnotateAgentTerminalSide } from '@plannotator/core/agent-terminal';
|
|
4
4
|
import type { Origin } from '@plannotator/core/agents';
|
|
5
5
|
import type { DiffLineBgIntensity } from '@plannotator/core/config-types';
|
|
6
|
-
import { configStore, useConfigValue, setReviewPanelView, setReviewDefaultDiffType } from '../config';
|
|
6
|
+
import { configStore, useConfigValue, setReviewPanelView, setReviewDefaultDiffType, setReviewAutoViewed } from '../config';
|
|
7
7
|
import { setWebMcpToolsEnabled, useWebMcpToolsEnabled } from '../webmcp/preference';
|
|
8
8
|
import { loadDiffFont } from '../utils/diffFonts';
|
|
9
9
|
import { TaterSpritePullup } from './TaterSpritePullup';
|
|
@@ -375,8 +375,23 @@ function ReviewAnalysisTab() {
|
|
|
375
375
|
const GitTab: React.FC<{ sinceBaseUnavailable?: boolean }> = ({ sinceBaseUnavailable }) => {
|
|
376
376
|
const defaultDiffType = useConfigValue('defaultDiffType');
|
|
377
377
|
const reviewPanelView = useConfigValue('reviewPanelView');
|
|
378
|
+
const reviewAutoViewed = useConfigValue('reviewAutoViewed');
|
|
378
379
|
return (
|
|
379
380
|
<div className="space-y-5">
|
|
381
|
+
<div className="space-y-2">
|
|
382
|
+
<div className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
|
383
|
+
Viewed files
|
|
384
|
+
</div>
|
|
385
|
+
{/* Never write `reviewAutoViewed` directly — setReviewAutoViewed also
|
|
386
|
+
consumes the first-time notice, since an explicit toggle is proof
|
|
387
|
+
the reviewer already found the switch. */}
|
|
388
|
+
<ToggleSwitch
|
|
389
|
+
checked={reviewAutoViewed}
|
|
390
|
+
onChange={(v) => setReviewAutoViewed(v)}
|
|
391
|
+
label="Auto-mark viewed"
|
|
392
|
+
description="Mark a file viewed when you scroll past it or move on to another file. Files you un-view stay un-viewed, and files that change on refresh become un-viewed."
|
|
393
|
+
/>
|
|
394
|
+
</div>
|
|
380
395
|
<div className="space-y-2">
|
|
381
396
|
<div>
|
|
382
397
|
<div className="text-sm font-medium">Default review view</div>
|
|
@@ -65,6 +65,8 @@ interface StickyHeaderLaneProps {
|
|
|
65
65
|
mode: EditorMode;
|
|
66
66
|
onModeChange: (mode: EditorMode) => void;
|
|
67
67
|
taterMode?: boolean;
|
|
68
|
+
/** Omit the Quick Label tool in the compact toolstrip (mirrors AnnotationToolstripProps.hideQuickLabel). */
|
|
69
|
+
hideQuickLabel?: boolean;
|
|
68
70
|
|
|
69
71
|
// Badge state
|
|
70
72
|
repoInfo?: { display: string; branch?: string } | null;
|
|
@@ -95,6 +97,7 @@ export const StickyHeaderLane: React.FC<StickyHeaderLaneProps> = ({
|
|
|
95
97
|
mode,
|
|
96
98
|
onModeChange,
|
|
97
99
|
taterMode,
|
|
100
|
+
hideQuickLabel,
|
|
98
101
|
repoInfo,
|
|
99
102
|
planDiffStats,
|
|
100
103
|
isPlanDiffActive,
|
|
@@ -252,6 +255,7 @@ export const StickyHeaderLane: React.FC<StickyHeaderLaneProps> = ({
|
|
|
252
255
|
mode={mode}
|
|
253
256
|
onModeChange={onModeChange}
|
|
254
257
|
taterMode={taterMode}
|
|
258
|
+
hideQuickLabel={hideQuickLabel}
|
|
255
259
|
compact
|
|
256
260
|
iconOnly={isNarrow || isToolstripIconOnly}
|
|
257
261
|
/>
|
|
@@ -281,9 +281,19 @@ export interface HtmlViewerProps {
|
|
|
281
281
|
bridgeReadyTimeoutMs?: number;
|
|
282
282
|
/** The bridge could not be established on the `bridgeScriptUrl` path (no
|
|
283
283
|
* ready within the timeout, or a protocol version mismatch). The surface
|
|
284
|
-
* shows its own banner as well
|
|
285
|
-
* retry affordance). Never called
|
|
284
|
+
* shows its own banner as well unless `bridgeErrorDisplay` is `'none'`;
|
|
285
|
+
* this lets the host react (telemetry, a retry affordance). Never called
|
|
286
|
+
* on the inline path. */
|
|
286
287
|
onBridgeUnavailable?: (info: BridgeUnavailableInfo) => void;
|
|
288
|
+
/**
|
|
289
|
+
* Who renders the bridge-failure strip on the `bridgeScriptUrl` path.
|
|
290
|
+
* `'banner'` (default): the package renders its `[data-bridge-error]`
|
|
291
|
+
* strip over the frame, as in 0.33.0. `'none'`: no strip is rendered and
|
|
292
|
+
* the host owns the display through `onBridgeUnavailable`, which fires
|
|
293
|
+
* exactly as before (and a version mismatch still logs its one console
|
|
294
|
+
* warning). Meaningless on the inline path, which never shows a strip.
|
|
295
|
+
*/
|
|
296
|
+
bridgeErrorDisplay?: "banner" | "none";
|
|
287
297
|
}
|
|
288
298
|
|
|
289
299
|
/**
|
|
@@ -329,6 +339,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
329
339
|
bridgeScriptUrl,
|
|
330
340
|
bridgeReadyTimeoutMs = DEFAULT_BRIDGE_READY_TIMEOUT_MS,
|
|
331
341
|
onBridgeUnavailable,
|
|
342
|
+
bridgeErrorDisplay = "banner",
|
|
332
343
|
},
|
|
333
344
|
ref,
|
|
334
345
|
) => {
|
|
@@ -1061,8 +1072,10 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
1061
1072
|
{/* bridgeScriptUrl path only: the bridge did not come up (no
|
|
1062
1073
|
ready within the timeout, or a stale asset's version). Floated
|
|
1063
1074
|
over the top of the iframe so it never changes the layout the
|
|
1064
|
-
page renders in; the page itself stays visible.
|
|
1065
|
-
|
|
1075
|
+
page renders in; the page itself stays visible. A host that
|
|
1076
|
+
renders its own notice from onBridgeUnavailable passes
|
|
1077
|
+
bridgeErrorDisplay="none" and no strip is rendered at all. */}
|
|
1078
|
+
{bridgeError && !bridgeErrorDismissed && bridgeErrorDisplay !== "none" && (
|
|
1066
1079
|
<div
|
|
1067
1080
|
role="alert"
|
|
1068
1081
|
data-print-hide
|
|
@@ -55,12 +55,12 @@ function nextHtmlAnnId(): string {
|
|
|
55
55
|
return `html-ann-${Date.now().toString(36)}-${(htmlAnnSeq++).toString(36)}`;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
58
|
+
// Ids minted for locally created annotations (create-mark) live per hook
|
|
59
|
+
// instance (mintedIdsRef below), not per module: the unanchored union only
|
|
60
|
+
// consults them against the bridge of the instance that minted them (a
|
|
61
|
+
// remounted viewer restores from the host's list and never reports an id it
|
|
62
|
+
// was not asked for), and a module-wide set leaked every id ever minted into
|
|
63
|
+
// unrelated later instances of a long-lived host page.
|
|
64
64
|
|
|
65
65
|
function htmlCommentDraftKey(
|
|
66
66
|
text: string,
|
|
@@ -463,6 +463,15 @@ export function useHtmlAnnotation({
|
|
|
463
463
|
enabledRef.current = enabled;
|
|
464
464
|
const modeRef = useRef(mode);
|
|
465
465
|
modeRef.current = mode;
|
|
466
|
+
// Ids this instance minted (see the module comment above nextHtmlAnnId);
|
|
467
|
+
// released on unmount so nothing outlives the viewer that created it.
|
|
468
|
+
const mintedIdsRef = useRef<Set<string>>(new Set());
|
|
469
|
+
useEffect(() => {
|
|
470
|
+
const minted = mintedIdsRef.current;
|
|
471
|
+
return () => {
|
|
472
|
+
minted.clear();
|
|
473
|
+
};
|
|
474
|
+
}, []);
|
|
466
475
|
// Mirror toolbar visibility into a ref so the (stable) message handler can gate
|
|
467
476
|
// type-to-comment on "the markup toolbar is showing", like AnnotationToolbar does.
|
|
468
477
|
const toolbarStateRef = useRef(toolbarState);
|
|
@@ -805,7 +814,7 @@ export function useHtmlAnnotation({
|
|
|
805
814
|
if (!text || type !== AnnotationType.DELETION) return;
|
|
806
815
|
|
|
807
816
|
const id = nextHtmlAnnId();
|
|
808
|
-
|
|
817
|
+
mintedIdsRef.current.add(id);
|
|
809
818
|
post({ type: `${PREFIX}create-mark`, id, annotationType: "deletion" });
|
|
810
819
|
onAddRef.current?.({
|
|
811
820
|
id,
|
|
@@ -864,7 +873,7 @@ export function useHtmlAnnotation({
|
|
|
864
873
|
: undefined;
|
|
865
874
|
|
|
866
875
|
const id = nextHtmlAnnId();
|
|
867
|
-
|
|
876
|
+
mintedIdsRef.current.add(id);
|
|
868
877
|
post({ type: `${PREFIX}create-mark`, id, annotationType: "comment" });
|
|
869
878
|
onAddRef.current?.({
|
|
870
879
|
id,
|
|
@@ -910,7 +919,7 @@ export function useHtmlAnnotation({
|
|
|
910
919
|
: undefined;
|
|
911
920
|
|
|
912
921
|
const id = nextHtmlAnnId();
|
|
913
|
-
|
|
922
|
+
mintedIdsRef.current.add(id);
|
|
914
923
|
post({ type: `${PREFIX}create-mark`, id, annotationType: "comment" });
|
|
915
924
|
onAddRef.current?.({
|
|
916
925
|
id,
|
|
@@ -970,7 +979,7 @@ export function useHtmlAnnotation({
|
|
|
970
979
|
const text = pendingTextRef.current;
|
|
971
980
|
if (!text) return;
|
|
972
981
|
const id = nextHtmlAnnId();
|
|
973
|
-
|
|
982
|
+
mintedIdsRef.current.add(id);
|
|
974
983
|
post({ type: `${PREFIX}create-mark`, id, annotationType: "comment" });
|
|
975
984
|
onAddRef.current?.({
|
|
976
985
|
id,
|
|
@@ -1067,6 +1076,6 @@ export function useHtmlAnnotation({
|
|
|
1067
1076
|
removeDraftTarget,
|
|
1068
1077
|
flashDraftTarget,
|
|
1069
1078
|
composerFocusToken,
|
|
1070
|
-
createdAnnotationIds:
|
|
1079
|
+
createdAnnotationIds: mintedIdsRef.current,
|
|
1071
1080
|
};
|
|
1072
1081
|
}
|
package/config/index.ts
CHANGED
package/config/reviewView.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { configStore } from './configStore';
|
|
2
2
|
import { SETTINGS } from './settings';
|
|
3
|
+
import { storage } from '../utils/storage';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* The ONLY writers for the coupled setting pair (reviewPanelView,
|
|
@@ -73,3 +74,39 @@ export function setReviewDefaultDiffType(
|
|
|
73
74
|
store.set('reviewPanelViewLastUsed', 'tree');
|
|
74
75
|
}
|
|
75
76
|
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* One-time gate for the auto-mark-viewed notice — the toast that fires the
|
|
81
|
+
* FIRST time auto-view actually marks a file, i.e. the moment the feature
|
|
82
|
+
* demonstrates itself. Cookie-based, mirroring the other review first-run
|
|
83
|
+
* gates, so it survives the random port each session runs on. Versioned so a
|
|
84
|
+
* meaningful revision can re-show it.
|
|
85
|
+
*
|
|
86
|
+
* It lives beside the setting rather than in the review app because BOTH
|
|
87
|
+
* writers of the setting (Settings > Git here, and the file-list gear in the
|
|
88
|
+
* review app) must consume the gate: someone who found the switch has
|
|
89
|
+
* demonstrably discovered the feature and must never be told about it.
|
|
90
|
+
*/
|
|
91
|
+
const AUTO_VIEWED_NOTICE_SEEN_KEY = 'plannotator-auto-viewed-notice-seen';
|
|
92
|
+
const AUTO_VIEWED_NOTICE_VERSION = '1';
|
|
93
|
+
|
|
94
|
+
export function needsAutoViewedNotice(): boolean {
|
|
95
|
+
return storage.getItem(AUTO_VIEWED_NOTICE_SEEN_KEY) !== AUTO_VIEWED_NOTICE_VERSION;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function markAutoViewedNoticeSeen(): void {
|
|
99
|
+
storage.setItem(AUTO_VIEWED_NOTICE_SEEN_KEY, AUTO_VIEWED_NOTICE_VERSION);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The only writer of `reviewAutoViewed` outside the notice's own "Turn off"
|
|
104
|
+
* action. Stamps the notice gate: an explicit toggle is proof of discovery.
|
|
105
|
+
*/
|
|
106
|
+
export function setReviewAutoViewed(
|
|
107
|
+
value: boolean,
|
|
108
|
+
store: PanelViewConfigStore = configStore,
|
|
109
|
+
): void {
|
|
110
|
+
markAutoViewedNoticeSeen();
|
|
111
|
+
store.set('reviewAutoViewed', value);
|
|
112
|
+
}
|
package/config/settings.ts
CHANGED
|
@@ -261,6 +261,21 @@ export const SETTINGS = {
|
|
|
261
261
|
serverKey: undefined, fromServer: undefined, toServer: undefined,
|
|
262
262
|
},
|
|
263
263
|
|
|
264
|
+
// Mark a file viewed when the reviewer scrolls past it or moves on to
|
|
265
|
+
// another file. Cookie-only like the other review-chrome preferences: it
|
|
266
|
+
// shapes how the local file list checks itself off and changes no review
|
|
267
|
+
// semantics (viewed gates nothing on submit).
|
|
268
|
+
reviewAutoViewed: {
|
|
269
|
+
defaultValue: true as boolean,
|
|
270
|
+
fromCookie: () => {
|
|
271
|
+
const value = storage.getItem('plannotator-review-auto-viewed');
|
|
272
|
+
return value === 'true' ? true : value === 'false' ? false : undefined;
|
|
273
|
+
},
|
|
274
|
+
toCookie: (value: boolean) =>
|
|
275
|
+
storage.setItem('plannotator-review-auto-viewed', String(value)),
|
|
276
|
+
serverKey: undefined, fromServer: undefined, toServer: undefined,
|
|
277
|
+
},
|
|
278
|
+
|
|
264
279
|
reviewShowStageControls: {
|
|
265
280
|
defaultValue: true as boolean,
|
|
266
281
|
fromCookie: () => {
|
|
@@ -16,6 +16,17 @@ interface DraftData {
|
|
|
16
16
|
descriptionAnnotations?: Annotation[];
|
|
17
17
|
commentAnnotations?: CommentAnnotation[];
|
|
18
18
|
viewedFiles?: string[];
|
|
19
|
+
/**
|
|
20
|
+
* Files the reviewer manually un-viewed, which auto-mark-viewed must never
|
|
21
|
+
* re-check (the "come back to this" contract). Additive and optional: a
|
|
22
|
+
* draft written before this field restores fine, and a draft carrying it is
|
|
23
|
+
* ignored gracefully by an older build.
|
|
24
|
+
*
|
|
25
|
+
* Deliberately absent from `isEmpty` and from the engagement signal — a
|
|
26
|
+
* session whose only state is suppression is still an empty draft and is
|
|
27
|
+
* still cleared, keeping #948's clear-everything semantics untouched.
|
|
28
|
+
*/
|
|
29
|
+
autoViewSuppressed?: string[];
|
|
19
30
|
draftGeneration?: number;
|
|
20
31
|
ts: number;
|
|
21
32
|
}
|
|
@@ -40,13 +51,14 @@ interface UseCodeAnnotationDraftOptions {
|
|
|
40
51
|
descriptionAnnotations?: Annotation[];
|
|
41
52
|
commentAnnotations?: CommentAnnotation[];
|
|
42
53
|
viewedFiles: Set<string>;
|
|
54
|
+
autoViewSuppressed?: Set<string>;
|
|
43
55
|
isApiMode: boolean;
|
|
44
56
|
submitted: boolean;
|
|
45
57
|
}
|
|
46
58
|
|
|
47
59
|
interface UseCodeAnnotationDraftResult {
|
|
48
60
|
draftBanner: { count: number; viewedCount: number; timeAgo: string } | null;
|
|
49
|
-
restoreDraft: () => { annotations: CodeAnnotation[]; descriptionAnnotations: Annotation[]; commentAnnotations: CommentAnnotation[]; viewedFiles: string[] };
|
|
61
|
+
restoreDraft: () => { annotations: CodeAnnotation[]; descriptionAnnotations: Annotation[]; commentAnnotations: CommentAnnotation[]; viewedFiles: string[]; autoViewSuppressed: string[] };
|
|
50
62
|
getDraftGeneration: () => number;
|
|
51
63
|
dismissDraft: () => void;
|
|
52
64
|
}
|
|
@@ -56,6 +68,7 @@ export function useCodeAnnotationDraft({
|
|
|
56
68
|
descriptionAnnotations = [],
|
|
57
69
|
commentAnnotations = [],
|
|
58
70
|
viewedFiles,
|
|
71
|
+
autoViewSuppressed,
|
|
59
72
|
isApiMode,
|
|
60
73
|
submitted,
|
|
61
74
|
}: UseCodeAnnotationDraftOptions): UseCodeAnnotationDraftResult {
|
|
@@ -145,6 +158,9 @@ export function useCodeAnnotationDraft({
|
|
|
145
158
|
descriptionAnnotations,
|
|
146
159
|
commentAnnotations,
|
|
147
160
|
viewedFiles: [...viewedFiles],
|
|
161
|
+
...(autoViewSuppressed && autoViewSuppressed.size > 0
|
|
162
|
+
? { autoViewSuppressed: [...autoViewSuppressed] }
|
|
163
|
+
: {}),
|
|
148
164
|
draftGeneration,
|
|
149
165
|
ts: Date.now(),
|
|
150
166
|
};
|
|
@@ -155,7 +171,7 @@ export function useCodeAnnotationDraft({
|
|
|
155
171
|
return () => {
|
|
156
172
|
if (timerRef.current) clearTimeout(timerRef.current);
|
|
157
173
|
};
|
|
158
|
-
}, [annotations, descriptionAnnotations, commentAnnotations, viewedFiles, isApiMode, submitted]);
|
|
174
|
+
}, [annotations, descriptionAnnotations, commentAnnotations, viewedFiles, autoViewSuppressed, isApiMode, submitted]);
|
|
159
175
|
|
|
160
176
|
const restoreDraft = useCallback(() => {
|
|
161
177
|
// Cancel any pending autosave so it can't fire with pre-restore state and
|
|
@@ -169,6 +185,7 @@ export function useCodeAnnotationDraft({
|
|
|
169
185
|
descriptionAnnotations: data?.descriptionAnnotations ?? [],
|
|
170
186
|
commentAnnotations: data?.commentAnnotations ?? [],
|
|
171
187
|
viewedFiles: data?.viewedFiles ?? [],
|
|
188
|
+
autoViewSuppressed: data?.autoViewSuppressed ?? [],
|
|
172
189
|
};
|
|
173
190
|
}, []);
|
|
174
191
|
|