@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 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). A reply whose parent is absent renders
44
- * as a top-level card. Without any `inReplyTo` the input order is returned
45
- * unchanged, so annotations without replies render exactly as before.
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 ids = new Set(sorted.map((a) => a.id));
53
+ const parents = resolveReplyParents(sorted);
50
54
  const byParent = new Map<string, Annotation[]>();
51
55
  for (const a of sorted) {
52
- if (a.inReplyTo && ids.has(a.inReplyTo) && a.inReplyTo !== a.id) {
53
- const list = byParent.get(a.inReplyTo) ?? [];
54
- list.push(a);
55
- byParent.set(a.inReplyTo, list);
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 emitted = new Set<string>();
60
- const emit = (a: Annotation, isReply: boolean) => {
61
- if (emitted.has(a.id)) return;
62
- emitted.add(a.id);
63
- out.push({ annotation: a, isReply });
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 (a.inReplyTo && ids.has(a.inReplyTo) && a.inReplyTo !== a.id) continue;
68
- emit(a, false);
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
- const ta = a.kind === 'plan' ? threadTs(a.annotation, sortedAnnotations) : a.ts;
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
- interface AnnotationToolstripProps {
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
- <ToolstripButton
146
- active={mode === 'quickLabel'}
147
- onClick={() => onModeChange('quickLabel')}
148
- label="Label"
149
- color="warning"
150
- mounted={mounted}
151
- compact={compact}
152
- iconOnly={iconOnly}
153
- icon={
154
- <svg className="w-3.5 h-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
155
- <path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
156
- </svg>
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 (and the refresh beside it) render only when provided. */
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
- {/* Show/hide tools: removes ALL floating chrome (sidebar tongue tabs +
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. Sits left of the pen; this button is the only way back,
88
- so it never hides itself. Eye = tools visible, eye-off = hidden. */}
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
- {canRefresh && onRefresh && (
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, AnnotatorState } from './types';
6
- import { DEFAULT_STATE } from './types';
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<AnnotatorState>(DEFAULT_STATE);
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(DEFAULT_STATE);
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(s => ({
120
- ...s,
121
- strokes: s.strokes.slice(0, -1),
122
- }));
83
+ setState(undoStroke);
84
+ }, []);
85
+
86
+ const handleRedo = useCallback(() => {
87
+ setState(redoStroke);
123
88
  }, []);
124
89
 
125
90
  const handleClear = useCallback(() => {
126
- setState(s => ({
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;