@plannotator/ui 0.34.0 → 0.35.1

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`. |
@@ -674,11 +675,11 @@ Additive only, but required: `@plannotator/ui` 0.32.0 imports the new `@plannota
674
675
 
675
676
  ## Publishing & versioning
676
677
 
677
- - The current pair is `@plannotator/ui` `0.34.0` on `@plannotator/core` `0.25.0`. **No lockstep again**: nothing under `packages/core` changed since the 0.32.0 pair, so core is not republished and ui 0.34.0 pins the already published core 0.25.0 (only the ui tarball is built and published). 0.34.0 carries the 0.33.0 adoption feedback: the Mermaid KaTeX redirect target `utils/mermaid-math-slot`, `HtmlViewer` `bridgeErrorDisplay`, the anchored bridge-script alias, and `resetMathRenderer` keeping the registered loader (with `setMathRendererLoader(null)` and `getMathRendererLoader`). 0.33.0 carried the bridge-script asset (`bridge-script.asset.js`, `bridge-script.lite`, both generated by `prepack`), the `utils/math-default-loader` split, and `HANDOFF.md` inside the tarball so the README's section references resolve for a consumer.
678
- - Recent pairs, for the consumer's install matrix: ui 0.31.0 on core 0.24.0 (lockstep), ui 0.32.0 on core 0.25.0 (lockstep, `html-anchor`), ui 0.33.0 and ui 0.34.0 on core 0.25.0 (ui only, core unchanged).
679
- - When a pair IS lockstep, **publish `core` first**: ui 0.32.0 imports the new `@plannotator/core/html-anchor` subpath, which no earlier published core (0.24.0 and before) has, just as ui 0.29.0 needed core 0.23.0 for `@plannotator/core/annotatable`. The ui→core dependency resolves exactly at pack time, from the lockfile: after a version bump, run `bun install` so `bun.lock` carries the new workspace versions, or `bun pm pack` will still stamp the previous core version into ui's tarball (the 0.31.0 lesson).
678
+ - The current pair is `@plannotator/ui` `0.35.1` on `@plannotator/core` `0.25.0`. **No lockstep again**: nothing under `packages/core` changed, so core is not republished. UI 0.35.1 is the packaging correction for 0.35.0: the source and packed UI manifests now pin core 0.25.0 exactly, so external pnpm consumers do not receive the unpublished `workspace:*` protocol. It otherwise carries the same `hideQuickLabel` toolstrip seam as 0.35.0.
679
+ - Recent pairs, for the consumer's install matrix: ui 0.31.0 on core 0.24.0 (lockstep), ui 0.32.0 on core 0.25.0 (lockstep, `html-anchor`), and ui 0.33.0 through ui 0.35.1 on core 0.25.0 (ui only, core unchanged). Do not consume ui 0.35.0 externally; its published manifest contains `workspace:*`.
680
+ - When both packages change, **publish `core` first**: ui 0.32.0 imports the `@plannotator/core/html-anchor` subpath, which no earlier published core (0.24.0 and before) has, just as ui 0.29.0 needed core 0.23.0 for `@plannotator/core/annotatable`. Bump core, update UI's exact core dependency to the same new version, and run `bun install` so `bun.lock` records the new workspace versions before packing either package.
680
681
  - The HTML annotation seams also changed the guides.show viewer **stylesheet** (five utility rules from `HtmlSurfaceControls`; the viewer JS is unchanged), so `packages/core/guide-viewer-manifest.ts` now pins a CSS hash that exists on guides.show only after the deploy workflow has published this build's `/v1/` assets. A guide exported from this build before that deploy would pin a stylesheet the host does not serve yet: **deploy guides.show before any release that ships this manifest.**
681
- - They depend on each other via `workspace:*`. At publish time that must resolve to the **exact** version in the tarball, so publish with a tool that does that resolution (the repo's existing flow uses `bun pm pack` to build the tarball, then `npm publish *.tgz --access public`). Publish **`core` first, then `ui`**.
682
+ - UI declares the already published core version exactly in its source manifest. Do not replace it with `workspace:*`: direct publication can preserve that protocol and make the package impossible to install outside this repository. Bun links the local core workspace whenever its version matches the exact dependency. Before publishing, run `bun run --cwd packages/ui smoke:package`; it checks the source and packed manifests, required tarball subpaths, local Bun linking, and a real pnpm install in an external temporary consumer. When both packages change, publish **`core` first, then `ui`**.
682
683
  - **`--provenance` only works from a supported CI environment (GitHub Actions OIDC)** — a local publish fails with `Automatic provenance generation not supported for provider: null`. Until a CI publish job exists for these two packages, local publishes drop the flag. Publishing under `--tag next` first lets the consumer preflight before `npm dist-tag add <pkg>@<version> latest` promotes it.
683
684
  - `styles.css` is built by the `prepack` script (`bun run build:css`) so the published tarball always carries fresh precompiled CSS; since 0.33.0 `prepack` also runs `build:bridge-assets`, which generates the gitignored `bridge-script.asset.js` and `bridge-script.lite.ts` beside their source. Both are in `files`, so a tarball built without `prepack` (a hand-rolled `npm pack --ignore-scripts`) would ship export subpaths that resolve to nothing; always build with `bun pm pack`.
684
685
  - There is **no CI publish job for these two packages yet** — first publish is manual from `main` after merge. (Wiring a CI publish job is a follow-up.)
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"`.
@@ -162,9 +170,9 @@ npm install @plannotator/ui @plannotator/core
162
170
  ## Packages & publishing
163
171
 
164
172
  - `@plannotator/core` — pure utils + types, zero deps, browser-safe (CI enforces no `node:` imports). Published.
165
- - `@plannotator/ui` — React components/hooks + theme + `configure()`. Depends on `@plannotator/core` (exact-version lockstep). Published.
173
+ - `@plannotator/ui` — React components/hooks + theme + `configure()`. Depends on an exact published `@plannotator/core` version. Published.
166
174
  - `@plannotator/shared`, `@plannotator/ai` — stay private to the monorepo; `shared` re-exports `core`'s modules via shims so Plannotator's internals are untouched.
167
- - Versioned together (currently `@plannotator/ui` 0.34.0 on `@plannotator/core` 0.25.0). `core` is bumped only when something under `packages/core` changed, so `ui` can advance alone: 0.33.0 and 0.34.0 are such releases, published on the already available core 0.25.0. When both change, publish `core` then `ui`: build each tarball with **`bun pm pack`** (resolves `workspace:*` to the exact version at pack time, from `bun.lock`, so run `bun install` after a bump), then **`npm publish *.tgz --provenance --access public`**, the repo's existing flow (`--provenance` needs CI OIDC; local publishes drop it, see HANDOFF.md "Publishing & versioning").
175
+ - Currently `@plannotator/ui` 0.35.1 depends exactly on `@plannotator/core` 0.25.0. `core` is bumped only when something under `packages/core` changes, so `ui` can advance alone. Keep the published core version exact in `packages/ui/package.json`; do not use a `workspace:` protocol there, because a directly published manifest must remain installable outside this monorepo. Bun still links the matching local workspace during development. When both packages change, publish `core` first, then build and publish the UI tarball. See HANDOFF.md "Publishing & versioning" for the verification command.
168
176
 
169
177
  ## The one rule
170
178
 
@@ -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
+ }