@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 +5 -4
- package/README.md +10 -2
- 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 -1
- 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`. |
|
|
@@ -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.
|
|
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
|
|
679
|
-
- When
|
|
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
|
-
-
|
|
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`
|
|
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
|
-
-
|
|
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).
|
|
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
|
+
}
|