@plannotator/ui 0.34.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 +1 -0
- package/README.md +8 -0
- 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 +15 -4
- package/components/PlanHeaderMenu.tsx +1 -1
- package/components/Settings.tsx +16 -1
- package/components/StickyHeaderLane.tsx +4 -0
- 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/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
package/HANDOFF.md
CHANGED
|
@@ -195,6 +195,7 @@ We deliberately did **not** restructure the exports map in this PR (move-don't-r
|
|
|
195
195
|
| `components/MarkdownDiff` | Theme-bridging wrapper over `@plannotator/markdown-editor`'s frozen two-revision diff. Same shim pattern as `components/MarkdownEditor` (ThemeProvider bridge, `extensions` passthrough, grid card chrome); never editable. See "Frozen markdown diff (0.28.0)". |
|
|
196
196
|
| `components/CommentPopover` | Anchor capture + comment entry. Ask-AI UI renders only if you pass `onAskAI`. |
|
|
197
197
|
| `components/AnnotationPanel` | Renders from your annotation state; no fetches of its own. |
|
|
198
|
+
| `components/AnnotationToolstrip` | The annotation mode toolstrip (Select / Pinpoint / Markup / Comment / Redline / Label). **Pass `showHelpLink={false}` in a host** — the default help modal embeds Plannotator's own YouTube walkthroughs. `hideQuickLabel` omits only the Label button (`StickyHeaderLane` forwards it, so the pinned scroll header matches); it hides the control, it does **not** clamp the mode — keep host mode state out of `'quickLabel'` (including preferences restored through `utils/editorMode`, which accepts it from storage) or text selection silently opens the quick-label picker with no visible cause. `hideInputMethodSwitch` likewise omits the pinpoint/drag switch. *(Blessed in 0.35.0.)* |
|
|
198
199
|
| `components/ThemeProvider` | Color-mode context. |
|
|
199
200
|
| `theme-modes` (`THEME_MODES`, `Mode`) | The supported Light/Dark/System catalog and mode type. `Mode` also remains exported from `components/ThemeProvider` for compatibility with existing consumers. |
|
|
200
201
|
| `components/ImageThumbnail` / `getImageSrc` | Routes through `imageSrcResolver`. |
|
package/README.md
CHANGED
|
@@ -129,6 +129,14 @@ The srcdoc then carries one classic `<script src>` in the exact place the inline
|
|
|
129
129
|
- **`@plannotator/ui/shortcuts`**: the declarative keyboard-shortcut engine (`defineShortcutScope`, `useShortcutScope`) and the per-surface scopes, including `useHtmlAnnotateShortcuts` for the Mod+Shift+A Annotate/Interact chord on HTML surfaces. Pure React plus `utils/platform`; no backend.
|
|
130
130
|
- **`@plannotator/ui/utils/inputMethod`**: `getInputMethod(surface)` / `saveInputMethod(method, surface)` / `refreshInputMethodStamp(method)`, the per-surface pinpoint-or-drag preference with its TTL, persisted through the `storageBackend` seam.
|
|
131
131
|
|
|
132
|
+
#### Toolstrip host props (0.35.0)
|
|
133
|
+
|
|
134
|
+
`components/AnnotationToolstrip` is supported host surface: the annotation mode toolstrip with per-tool opt-outs, all defaulting to today's rendering.
|
|
135
|
+
|
|
136
|
+
- **`hideQuickLabel`** omits the Quick Label tool. `StickyHeaderLane` forwards it, so the pinned scroll header stays consistent. It hides the button only — it does not clamp the mode, so keep host mode state out of `'quickLabel'` (including preferences restored through `utils/editorMode`).
|
|
137
|
+
- **`showHelpLink={false}`** for hosts: the default help modal embeds Plannotator's own video walkthroughs.
|
|
138
|
+
- **`hideInputMethodSwitch`** omits the pinpoint/drag input-method switch.
|
|
139
|
+
|
|
132
140
|
### WebMCP provider (`@plannotator/ui/webmcp`; 0.32.0)
|
|
133
141
|
|
|
134
142
|
The engine that lets a browser-integrated agent (Chrome/Edge WebMCP, `document.modelContext`) call in-page tools on a document surface. Feature-detected once; a browser without the API sees no registration, no DOM, no network, no timers. Seam: `configurePlannotatorUI({ webmcp: { enabled, namePrefix } })`, default enabled with the `plannotator.` prefix; pass `enabled: false` to keep a host page tool-free, or your own prefix to namespace the tools beside your own. There is deliberately no confirmation seam: the catalog is read-and-comment only (no approve / submit / close tools), and the agent may only edit or remove comments stamped `source: "browser-agent"`.
|
|
@@ -7,6 +7,7 @@ import { useIsMobile } from '../hooks/useIsMobile';
|
|
|
7
7
|
import { OverlayScrollArea } from './OverlayScrollArea';
|
|
8
8
|
import { Button } from './ui/button';
|
|
9
9
|
import { cn } from '../lib/utils';
|
|
10
|
+
import { resolveReplyParents, resolveThreadRootTimestamps } from '@plannotator/core/annotation-threads';
|
|
10
11
|
|
|
11
12
|
// Card type-word colors. Deletion uses `destructive` (reliably red on every
|
|
12
13
|
// theme, matching the in-document .deletion highlight). Comment uses the
|
|
@@ -40,50 +41,47 @@ const TrashCardIcon = () => (
|
|
|
40
41
|
|
|
41
42
|
/**
|
|
42
43
|
* Order annotations so every reply follows its parent (replies among
|
|
43
|
-
* themselves stay in creation order).
|
|
44
|
-
*
|
|
45
|
-
*
|
|
44
|
+
* themselves stay in creation order). The threading rule is the shared one
|
|
45
|
+
* (resolveReplyParents, also what the export applies): a reply whose parent
|
|
46
|
+
* is absent, a self-reference, and every member of an `inReplyTo` cycle
|
|
47
|
+
* render as top-level cards in input order, so nothing is ever dropped.
|
|
48
|
+
* Without any `inReplyTo` the input order is returned unchanged, so
|
|
49
|
+
* annotations without replies render exactly as before.
|
|
46
50
|
*/
|
|
47
51
|
export function threadReplies(sorted: Annotation[]): Array<{ annotation: Annotation; isReply: boolean }> {
|
|
48
52
|
if (!sorted.some((a) => a.inReplyTo)) return sorted.map((annotation) => ({ annotation, isReply: false }));
|
|
49
|
-
const
|
|
53
|
+
const parents = resolveReplyParents(sorted);
|
|
50
54
|
const byParent = new Map<string, Annotation[]>();
|
|
51
55
|
for (const a of sorted) {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
56
|
+
const parent = parents.get(a.id);
|
|
57
|
+
if (!parent) continue;
|
|
58
|
+
const list = byParent.get(parent) ?? [];
|
|
59
|
+
list.push(a);
|
|
60
|
+
byParent.set(parent, list);
|
|
57
61
|
}
|
|
62
|
+
// Depth-first, iteratively: a 5,000-deep chain must not recurse 5,000
|
|
63
|
+
// frames deep. The stack holds each node's replies in reverse so they pop
|
|
64
|
+
// in creation order.
|
|
58
65
|
const out: Array<{ annotation: Annotation; isReply: boolean }> = [];
|
|
59
|
-
const
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
for (const reply of byParent.get(a.id) ?? []) emit(reply, true);
|
|
66
|
+
const stack: Array<{ annotation: Annotation; isReply: boolean }> = [];
|
|
67
|
+
const pushReplies = (a: Annotation) => {
|
|
68
|
+
const replies = byParent.get(a.id);
|
|
69
|
+
if (!replies) return;
|
|
70
|
+
for (let i = replies.length - 1; i >= 0; i--) stack.push({ annotation: replies[i], isReply: true });
|
|
65
71
|
};
|
|
66
72
|
for (const a of sorted) {
|
|
67
|
-
if (
|
|
68
|
-
|
|
73
|
+
if (parents.get(a.id)) continue;
|
|
74
|
+
out.push({ annotation: a, isReply: false });
|
|
75
|
+
pushReplies(a);
|
|
76
|
+
while (stack.length > 0) {
|
|
77
|
+
const next = stack.pop()!;
|
|
78
|
+
out.push(next);
|
|
79
|
+
pushReplies(next.annotation);
|
|
80
|
+
}
|
|
69
81
|
}
|
|
70
|
-
for (const a of sorted) emit(a, false);
|
|
71
82
|
return out;
|
|
72
83
|
}
|
|
73
84
|
|
|
74
|
-
/** Timeline position of an annotation: its own time, or its thread root's for replies. */
|
|
75
|
-
function threadTs(annotation: Annotation, all: Annotation[]): number {
|
|
76
|
-
let current = annotation;
|
|
77
|
-
const seen = new Set<string>();
|
|
78
|
-
while (current.inReplyTo && !seen.has(current.id)) {
|
|
79
|
-
seen.add(current.id);
|
|
80
|
-
const parent = all.find((a) => a.id === current.inReplyTo);
|
|
81
|
-
if (!parent) break;
|
|
82
|
-
current = parent;
|
|
83
|
-
}
|
|
84
|
-
return current.createdA;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
85
|
interface DirectEditsPanelItem {
|
|
88
86
|
id: string;
|
|
89
87
|
title?: string;
|
|
@@ -178,13 +176,16 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
|
|
|
178
176
|
// sit right after its parent (and the parent's earlier replies) at the
|
|
179
177
|
// parent's timeline position. With no replies the order is untouched.
|
|
180
178
|
const threadedAnnotations = threadReplies(sortedAnnotations);
|
|
179
|
+
// Thread timestamps are resolved once per render, linearly (shared helper),
|
|
180
|
+
// and the comparator only reads the map: resolving each chain inside the
|
|
181
|
+
// comparator with a linear parent lookup was O(n^2 log n) and froze the
|
|
182
|
+
// tab on a few thousand threaded comments.
|
|
183
|
+
const threadRootTs = resolveThreadRootTimestamps(sortedAnnotations);
|
|
181
184
|
const timelineEntries = [
|
|
182
|
-
...threadedAnnotations.map(({ annotation, isReply }) => ({ kind: 'plan' as const, ts: annotation.createdA, annotation, isReply })),
|
|
183
|
-
...sortedCodeAnnotations.map(annotation => ({ kind: 'code' as const, ts: annotation.createdAt, annotation, isReply: false })),
|
|
185
|
+
...threadedAnnotations.map(({ annotation, isReply }) => ({ kind: 'plan' as const, ts: annotation.createdA, threadTs: threadRootTs.get(annotation.id) ?? annotation.createdA, annotation, isReply })),
|
|
186
|
+
...sortedCodeAnnotations.map(annotation => ({ kind: 'code' as const, ts: annotation.createdAt, threadTs: annotation.createdAt, annotation, isReply: false })),
|
|
184
187
|
].sort((a, b) => {
|
|
185
|
-
|
|
186
|
-
const tb = b.kind === 'plan' ? threadTs(b.annotation, sortedAnnotations) : b.ts;
|
|
187
|
-
if (ta !== tb) return ta - tb;
|
|
188
|
+
if (a.threadTs !== b.threadTs) return a.threadTs - b.threadTs;
|
|
188
189
|
return a.ts - b.ts;
|
|
189
190
|
});
|
|
190
191
|
const totalCount = annotations.length + codeAnnotations.length + (editorAnnotations?.length ?? 0);
|
|
@@ -2,7 +2,8 @@ import React, { useState, useRef, useLayoutEffect, useEffect } from 'react';
|
|
|
2
2
|
import type { EditorMode, InputMethod } from '../types';
|
|
3
3
|
import { TaterSpritePullup } from './TaterSpritePullup';
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
/** Props for the shared annotation input and action mode toolstrip. */
|
|
6
|
+
export interface AnnotationToolstripProps {
|
|
6
7
|
inputMethod: InputMethod;
|
|
7
8
|
onInputMethodChange: (method: InputMethod) => void;
|
|
8
9
|
mode: EditorMode;
|
|
@@ -30,8 +31,14 @@ interface AnnotationToolstripProps {
|
|
|
30
31
|
* pinpoint-only, so the switch would be a dead control there.
|
|
31
32
|
*/
|
|
32
33
|
hideInputMethodSwitch?: boolean;
|
|
34
|
+
/**
|
|
35
|
+
* Omit only the Quick Label action. Defaults to false so existing consumers
|
|
36
|
+
* retain the complete action-mode group.
|
|
37
|
+
*/
|
|
38
|
+
hideQuickLabel?: boolean;
|
|
33
39
|
}
|
|
34
40
|
|
|
41
|
+
/** Render the shared input-method and annotation-mode controls. */
|
|
35
42
|
export const AnnotationToolstrip: React.FC<AnnotationToolstripProps> = ({
|
|
36
43
|
inputMethod,
|
|
37
44
|
onInputMethodChange,
|
|
@@ -42,6 +49,7 @@ export const AnnotationToolstrip: React.FC<AnnotationToolstripProps> = ({
|
|
|
42
49
|
showHelpLink = true,
|
|
43
50
|
iconOnly = false,
|
|
44
51
|
hideInputMethodSwitch = false,
|
|
52
|
+
hideQuickLabel = false,
|
|
45
53
|
}) => {
|
|
46
54
|
const [showHelp, setShowHelp] = useState(false);
|
|
47
55
|
const [helpTab, setHelpTab] = useState<'selection' | 'plannotator'>('selection');
|
|
@@ -142,20 +150,22 @@ export const AnnotationToolstrip: React.FC<AnnotationToolstripProps> = ({
|
|
|
142
150
|
</svg>
|
|
143
151
|
}
|
|
144
152
|
/>
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
<
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
153
|
+
{!hideQuickLabel && (
|
|
154
|
+
<ToolstripButton
|
|
155
|
+
active={mode === 'quickLabel'}
|
|
156
|
+
onClick={() => onModeChange('quickLabel')}
|
|
157
|
+
label="Label"
|
|
158
|
+
color="warning"
|
|
159
|
+
mounted={mounted}
|
|
160
|
+
compact={compact}
|
|
161
|
+
iconOnly={iconOnly}
|
|
162
|
+
icon={
|
|
163
|
+
<svg className="w-3.5 h-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
164
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
|
165
|
+
</svg>
|
|
166
|
+
}
|
|
167
|
+
/>
|
|
168
|
+
)}
|
|
159
169
|
</div>
|
|
160
170
|
|
|
161
171
|
{/* Help */}
|
|
@@ -2,6 +2,7 @@ import React, { useRef, useState, useEffect, useCallback } from 'react';
|
|
|
2
2
|
import { createPortal } from 'react-dom';
|
|
3
3
|
import type { Viz } from '@viz-js/viz';
|
|
4
4
|
import type { Block } from '../types';
|
|
5
|
+
import { createRuntimeRetryEpoch } from '../utils/runtimeRetry';
|
|
5
6
|
|
|
6
7
|
interface ViewBox {
|
|
7
8
|
x: number;
|
|
@@ -60,6 +61,9 @@ export function __setVizLoaderForTests(
|
|
|
60
61
|
|
|
61
62
|
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
|
62
63
|
|
|
64
|
+
/** One Retry re-attempts every block whose engine import failed (see utils/runtimeRetry). */
|
|
65
|
+
const vizRetryEpoch = createRuntimeRetryEpoch();
|
|
66
|
+
|
|
63
67
|
function parseViewBox(svgEl: SVGSVGElement): ViewBox | null {
|
|
64
68
|
const raw = svgEl.getAttribute('viewBox');
|
|
65
69
|
if (!raw) return null;
|
|
@@ -153,6 +157,16 @@ export const GraphvizBlock: React.FC<{ block: Block }> = ({ block }) => {
|
|
|
153
157
|
const [retryToken, setRetryToken] = useState(0);
|
|
154
158
|
const [showSource, setShowSource] = useState(false);
|
|
155
159
|
const [isExpanded, setIsExpanded] = useState(false);
|
|
160
|
+
// A sibling's Retry re-attempts this block too, but only while its own
|
|
161
|
+
// failure was the shared engine import; a healthy block or a dot syntax
|
|
162
|
+
// error is left alone.
|
|
163
|
+
const runtimeUnavailableRef = useRef(runtimeUnavailable);
|
|
164
|
+
runtimeUnavailableRef.current = runtimeUnavailable;
|
|
165
|
+
useEffect(() => vizRetryEpoch.subscribe(() => {
|
|
166
|
+
if (!runtimeUnavailableRef.current) return;
|
|
167
|
+
setError(null);
|
|
168
|
+
setRetryToken((token) => token + 1);
|
|
169
|
+
}), []);
|
|
156
170
|
|
|
157
171
|
const zoomLevelRef = useRef(1);
|
|
158
172
|
const isDraggingRef = useRef(false);
|
|
@@ -431,10 +445,7 @@ export const GraphvizBlock: React.FC<{ block: Block }> = ({ block }) => {
|
|
|
431
445
|
{runtimeUnavailable && (
|
|
432
446
|
<button
|
|
433
447
|
type="button"
|
|
434
|
-
onClick={() =>
|
|
435
|
-
setError(null);
|
|
436
|
-
setRetryToken((token) => token + 1);
|
|
437
|
-
}}
|
|
448
|
+
onClick={() => vizRetryEpoch.bump()}
|
|
438
449
|
className="ml-auto rounded-md border border-destructive/30 px-2 py-0.5 text-xs text-destructive hover:bg-destructive/10"
|
|
439
450
|
title="Retry loading the diagram renderer"
|
|
440
451
|
>
|
|
@@ -55,9 +55,10 @@ export interface HtmlSurfaceControlsProps {
|
|
|
55
55
|
onToggleArmed?: () => void;
|
|
56
56
|
/** Whether the floating tools over the page are hidden (eye-off). */
|
|
57
57
|
toolsHidden?: boolean;
|
|
58
|
-
/** Flip the tools. The eye
|
|
58
|
+
/** Flip the tools. The eye renders only when provided. */
|
|
59
59
|
onToggleTools?: () => void;
|
|
60
|
-
/** Whether a refresh is offered for this document.
|
|
60
|
+
/** Whether a refresh is offered for this document. The refresh renders
|
|
61
|
+
* whenever this is true and `onRefresh` is passed, with or without the eye. */
|
|
61
62
|
canRefresh?: boolean;
|
|
62
63
|
onRefresh?: () => void;
|
|
63
64
|
isRefreshing?: boolean;
|
|
@@ -80,15 +81,21 @@ export function HtmlSurfaceControls({
|
|
|
80
81
|
if (compact) return null;
|
|
81
82
|
const text = { ...DEFAULT_HTML_SURFACE_CONTROL_LABELS, ...labels };
|
|
82
83
|
const penLabel = armed ? labels?.annotateLabel : labels?.interactLabel;
|
|
84
|
+
const showRefresh = canRefresh && !!onRefresh;
|
|
83
85
|
return (
|
|
84
86
|
<>
|
|
85
|
-
{/*
|
|
87
|
+
{/* The refresh and the eye share one group, left of the pen. Each
|
|
88
|
+
renders on its own terms: the refresh whenever it is offered
|
|
89
|
+
(canRefresh + onRefresh), the eye whenever onToggleTools is passed,
|
|
90
|
+
so a host without the tools toggle still gets its refresh.
|
|
91
|
+
|
|
92
|
+
Show/hide tools: removes ALL floating chrome (sidebar tongue tabs +
|
|
86
93
|
the comment/attachments cluster) from the DOM, leaving nothing over
|
|
87
|
-
the page.
|
|
88
|
-
|
|
89
|
-
{onToggleTools && (
|
|
94
|
+
the page. This button is the only way back, so it never hides
|
|
95
|
+
itself. Eye = tools visible, eye-off = hidden. */}
|
|
96
|
+
{(showRefresh || onToggleTools) && (
|
|
90
97
|
<div className="ml-1 flex items-center gap-0.5">
|
|
91
|
-
{
|
|
98
|
+
{showRefresh && (
|
|
92
99
|
<button
|
|
93
100
|
type="button"
|
|
94
101
|
data-html-refresh
|
|
@@ -117,6 +124,7 @@ export function HtmlSurfaceControls({
|
|
|
117
124
|
<span className="hidden sm:inline">{isRefreshing ? text.refreshing : text.refresh}</span>
|
|
118
125
|
</button>
|
|
119
126
|
)}
|
|
127
|
+
{onToggleTools && (
|
|
120
128
|
<button
|
|
121
129
|
type="button"
|
|
122
130
|
data-html-tools-toggle
|
|
@@ -137,6 +145,7 @@ export function HtmlSurfaceControls({
|
|
|
137
145
|
)}
|
|
138
146
|
<span className="sr-only">{toolsHidden ? text.showTools : text.hideTools}</span>
|
|
139
147
|
</button>
|
|
148
|
+
)}
|
|
140
149
|
</div>
|
|
141
150
|
)}
|
|
142
151
|
|
|
@@ -7,10 +7,12 @@ interface ToolbarProps {
|
|
|
7
7
|
color: string;
|
|
8
8
|
strokeSize: number;
|
|
9
9
|
canUndo: boolean;
|
|
10
|
+
canRedo?: boolean;
|
|
10
11
|
onToolChange: (tool: Tool) => void;
|
|
11
12
|
onColorChange: (color: string) => void;
|
|
12
13
|
onStrokeSizeChange: (size: number) => void;
|
|
13
14
|
onUndo: () => void;
|
|
15
|
+
onRedo?: () => void;
|
|
14
16
|
onClear: () => void;
|
|
15
17
|
onSave: () => void;
|
|
16
18
|
}
|
|
@@ -44,6 +46,13 @@ const UndoIcon = () => (
|
|
|
44
46
|
</svg>
|
|
45
47
|
);
|
|
46
48
|
|
|
49
|
+
const RedoIcon = () => (
|
|
50
|
+
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
|
51
|
+
<path d="M21 7v6h-6" />
|
|
52
|
+
<path d="M3 17a9 9 0 019-9 9 9 0 016 2.3L21 13" />
|
|
53
|
+
</svg>
|
|
54
|
+
);
|
|
55
|
+
|
|
47
56
|
const ClearIcon = () => (
|
|
48
57
|
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
|
49
58
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
@@ -81,10 +90,12 @@ export const Toolbar: React.FC<ToolbarProps> = ({
|
|
|
81
90
|
color,
|
|
82
91
|
strokeSize,
|
|
83
92
|
canUndo,
|
|
93
|
+
canRedo = false,
|
|
84
94
|
onToolChange,
|
|
85
95
|
onColorChange,
|
|
86
96
|
onStrokeSizeChange,
|
|
87
97
|
onUndo,
|
|
98
|
+
onRedo,
|
|
88
99
|
onClear,
|
|
89
100
|
onSave,
|
|
90
101
|
}) => {
|
|
@@ -192,6 +203,22 @@ export const Toolbar: React.FC<ToolbarProps> = ({
|
|
|
192
203
|
<UndoIcon />
|
|
193
204
|
</button>
|
|
194
205
|
|
|
206
|
+
{onRedo && (
|
|
207
|
+
<button
|
|
208
|
+
type="button"
|
|
209
|
+
onClick={onRedo}
|
|
210
|
+
disabled={!canRedo}
|
|
211
|
+
title="Redo (Cmd+Shift+Z)"
|
|
212
|
+
className={`p-1.5 rounded transition-colors ${
|
|
213
|
+
canRedo
|
|
214
|
+
? 'hover:bg-muted text-muted-foreground hover:text-foreground'
|
|
215
|
+
: 'text-muted-foreground/30 cursor-not-allowed'
|
|
216
|
+
}`}
|
|
217
|
+
>
|
|
218
|
+
<RedoIcon />
|
|
219
|
+
</button>
|
|
220
|
+
)}
|
|
221
|
+
|
|
195
222
|
{/* Clear all */}
|
|
196
223
|
<button
|
|
197
224
|
type="button"
|
|
@@ -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
|
+
}
|
|
@@ -11,6 +11,10 @@ import {
|
|
|
11
11
|
} from '../utils/mermaid';
|
|
12
12
|
import { loadMathRenderer } from '../utils/math';
|
|
13
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();
|
|
14
18
|
|
|
15
19
|
// Re-exported: the config pin test and the lazy-retry test import them from here.
|
|
16
20
|
export { MERMAID_CONFIG, __setMermaidRuntimeLoaderForTests };
|
|
@@ -135,6 +139,16 @@ const MermaidBlockImpl: React.FC<{ block: Block }> = ({ block }) => {
|
|
|
135
139
|
const [retryToken, setRetryToken] = useState(0);
|
|
136
140
|
const [showSource, setShowSource] = useState(false);
|
|
137
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
|
+
}), []);
|
|
138
152
|
|
|
139
153
|
// All zoom/pan state as refs to avoid re-renders
|
|
140
154
|
const zoomLevelRef = useRef(1);
|
|
@@ -459,10 +473,7 @@ const MermaidBlockImpl: React.FC<{ block: Block }> = ({ block }) => {
|
|
|
459
473
|
{runtimeUnavailable && (
|
|
460
474
|
<button
|
|
461
475
|
type="button"
|
|
462
|
-
onClick={() =>
|
|
463
|
-
setError(null);
|
|
464
|
-
setRetryToken((token) => token + 1);
|
|
465
|
-
}}
|
|
476
|
+
onClick={() => mermaidRetryEpoch.bump()}
|
|
466
477
|
className="ml-auto rounded-md border border-destructive/30 px-2 py-0.5 text-xs text-destructive hover:bg-destructive/10"
|
|
467
478
|
title="Retry loading the diagram renderer"
|
|
468
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;
|