@1agh/maude 0.15.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/LICENSE +21 -0
- package/README.md +166 -0
- package/cli/bin/maude.exe +15 -0
- package/cli/bin/maude.mjs +45 -0
- package/cli/bin/mdcc.exe +10 -0
- package/cli/bin/mdcc.mjs +7 -0
- package/cli/cli-wrapper.cjs +67 -0
- package/cli/commands/config.mjs +94 -0
- package/cli/commands/design.mjs +386 -0
- package/cli/commands/help.mjs +57 -0
- package/cli/commands/init.mjs +178 -0
- package/cli/commands/version.mjs +7 -0
- package/cli/install.cjs +113 -0
- package/cli/lib/argv.mjs +37 -0
- package/cli/lib/argv.test.mjs +46 -0
- package/cli/lib/copy-tree.mjs +78 -0
- package/package.json +94 -0
- package/plugins/design/dev-server/annotations-context-toolbar.tsx +397 -0
- package/plugins/design/dev-server/annotations-layer.tsx +1717 -0
- package/plugins/design/dev-server/api.ts +674 -0
- package/plugins/design/dev-server/bin/_screenshot-playwright.mjs +50 -0
- package/plugins/design/dev-server/bin/bootstrap-check.sh +83 -0
- package/plugins/design/dev-server/bin/canvas-edit.sh +48 -0
- package/plugins/design/dev-server/bin/handoff.sh +27 -0
- package/plugins/design/dev-server/bin/screenshot.sh +232 -0
- package/plugins/design/dev-server/bin/server-up.sh +135 -0
- package/plugins/design/dev-server/bin/slug.sh +22 -0
- package/plugins/design/dev-server/bin/smoke.sh +272 -0
- package/plugins/design/dev-server/build.ts +267 -0
- package/plugins/design/dev-server/canvas-build.ts +219 -0
- package/plugins/design/dev-server/canvas-edit.ts +388 -0
- package/plugins/design/dev-server/canvas-header.ts +165 -0
- package/plugins/design/dev-server/canvas-icons.tsx +131 -0
- package/plugins/design/dev-server/canvas-lib-inline.ts +260 -0
- package/plugins/design/dev-server/canvas-lib-resolver.ts +85 -0
- package/plugins/design/dev-server/canvas-lib.tsx +1995 -0
- package/plugins/design/dev-server/canvas-meta.schema.json +181 -0
- package/plugins/design/dev-server/canvas-pipeline.ts +270 -0
- package/plugins/design/dev-server/canvas-shell.tsx +813 -0
- package/plugins/design/dev-server/client/app.jsx +2027 -0
- package/plugins/design/dev-server/client/hmr.mjs +85 -0
- package/plugins/design/dev-server/client/iframe-lazy.mjs +121 -0
- package/plugins/design/dev-server/client/index.html +15 -0
- package/plugins/design/dev-server/client/styles/0-reset.css +18 -0
- package/plugins/design/dev-server/client/styles/1-tokens.css +297 -0
- package/plugins/design/dev-server/client/styles/2-layout.css +35 -0
- package/plugins/design/dev-server/client/styles/3-shell.css +906 -0
- package/plugins/design/dev-server/client/styles/4-components.css +1268 -0
- package/plugins/design/dev-server/client/styles/5-utilities.css +4 -0
- package/plugins/design/dev-server/client/styles/_index.css +24 -0
- package/plugins/design/dev-server/client/styles.css +1419 -0
- package/plugins/design/dev-server/config.schema.json +147 -0
- package/plugins/design/dev-server/context-menu.tsx +343 -0
- package/plugins/design/dev-server/context.ts +173 -0
- package/plugins/design/dev-server/dist/client.bundle.js +20323 -0
- package/plugins/design/dev-server/dist/styles.css +2875 -0
- package/plugins/design/dev-server/examples/README.md +9 -0
- package/plugins/design/dev-server/examples/perf-100-artboards.tsx +113 -0
- package/plugins/design/dev-server/fs-watch.ts +63 -0
- package/plugins/design/dev-server/handoff.ts +721 -0
- package/plugins/design/dev-server/history.ts +125 -0
- package/plugins/design/dev-server/hmr-broadcast.ts +114 -0
- package/plugins/design/dev-server/http.ts +413 -0
- package/plugins/design/dev-server/input-router.tsx +485 -0
- package/plugins/design/dev-server/inspect.ts +365 -0
- package/plugins/design/dev-server/locator.ts +159 -0
- package/plugins/design/dev-server/mem.ts +97 -0
- package/plugins/design/dev-server/runtime-bundle.ts +235 -0
- package/plugins/design/dev-server/server.mjs +1246 -0
- package/plugins/design/dev-server/server.ts +131 -0
- package/plugins/design/dev-server/test/_helpers.ts +81 -0
- package/plugins/design/dev-server/test/active-state.test.ts +145 -0
- package/plugins/design/dev-server/test/annotations-api.test.ts +146 -0
- package/plugins/design/dev-server/test/annotations-layer.test.ts +419 -0
- package/plugins/design/dev-server/test/binary-smoke.test.ts +47 -0
- package/plugins/design/dev-server/test/bundle-smoke.test.ts +29 -0
- package/plugins/design/dev-server/test/canvas-build.test.ts +78 -0
- package/plugins/design/dev-server/test/canvas-edit.test.ts +139 -0
- package/plugins/design/dev-server/test/canvas-header.test.ts +127 -0
- package/plugins/design/dev-server/test/canvas-lib-inline.test.ts +146 -0
- package/plugins/design/dev-server/test/canvas-lib-resolver.test.ts +112 -0
- package/plugins/design/dev-server/test/canvas-meta-api.test.ts +236 -0
- package/plugins/design/dev-server/test/canvas-pipeline.test.ts +180 -0
- package/plugins/design/dev-server/test/canvas-route.test.ts +176 -0
- package/plugins/design/dev-server/test/fs-watch.test.ts +41 -0
- package/plugins/design/dev-server/test/handoff-static-frames.test.ts +215 -0
- package/plugins/design/dev-server/test/handoff.test.ts +281 -0
- package/plugins/design/dev-server/test/history-rollback.test.ts +62 -0
- package/plugins/design/dev-server/test/hmr-broadcast.test.ts +108 -0
- package/plugins/design/dev-server/test/input-router.test.ts +316 -0
- package/plugins/design/dev-server/test/locator.test.ts +214 -0
- package/plugins/design/dev-server/test/perf-harness.ts +193 -0
- package/plugins/design/dev-server/test/phase-3.6-smoke.test.ts +77 -0
- package/plugins/design/dev-server/test/runtime-bundle.test.ts +69 -0
- package/plugins/design/dev-server/test/server-lifecycle.test.ts +28 -0
- package/plugins/design/dev-server/test/tool-palette.test.tsx +55 -0
- package/plugins/design/dev-server/test/use-annotation-selection.test.tsx +77 -0
- package/plugins/design/dev-server/test/use-artboard-drag.test.ts +325 -0
- package/plugins/design/dev-server/test/use-selection-set.test.tsx +166 -0
- package/plugins/design/dev-server/test/use-snap-guides.test.ts +190 -0
- package/plugins/design/dev-server/test/use-tool-mode.test.tsx +93 -0
- package/plugins/design/dev-server/test/ws-handshake.test.ts +33 -0
- package/plugins/design/dev-server/tool-palette.tsx +278 -0
- package/plugins/design/dev-server/tsconfig.json +26 -0
- package/plugins/design/dev-server/use-annotation-selection.tsx +92 -0
- package/plugins/design/dev-server/use-annotations-visibility.tsx +43 -0
- package/plugins/design/dev-server/use-artboard-drag.tsx +445 -0
- package/plugins/design/dev-server/use-selection-set.tsx +224 -0
- package/plugins/design/dev-server/use-snap-guides.tsx +215 -0
- package/plugins/design/dev-server/use-tool-mode.tsx +114 -0
- package/plugins/design/dev-server/ws.ts +90 -0
- package/plugins/design/templates/_shell.html +177 -0
- package/plugins/design/templates/canvas.tsx.template +54 -0
- package/plugins/design/templates/design-system-inspiration/_MAPPING.md +277 -0
- package/plugins/design/templates/design-system-inspiration/_README.md +71 -0
- package/plugins/design/templates/design-system-inspiration/audience-consumer/components-banner.html +68 -0
- package/plugins/design/templates/design-system-inspiration/audience-consumer/components-empty-state-generous.html +39 -0
- package/plugins/design/templates/design-system-inspiration/audience-consumer/components-feature-grid.html +62 -0
- package/plugins/design/templates/design-system-inspiration/audience-consumer/components-marketing-card.html +63 -0
- package/plugins/design/templates/design-system-inspiration/audience-consumer/components-testimonial.html +80 -0
- package/plugins/design/templates/design-system-inspiration/audience-developer/components-code-block.html +71 -0
- package/plugins/design/templates/design-system-inspiration/audience-developer/components-diff-view.html +65 -0
- package/plugins/design/templates/design-system-inspiration/audience-developer/components-log-stream.html +62 -0
- package/plugins/design/templates/design-system-inspiration/audience-developer/components-monospace-table.html +57 -0
- package/plugins/design/templates/design-system-inspiration/audience-developer/components-terminal-pane.html +67 -0
- package/plugins/design/templates/design-system-inspiration/audience-developer/type-mono.html +57 -0
- package/plugins/design/templates/design-system-inspiration/audience-pro/colors-presence.html +74 -0
- package/plugins/design/templates/design-system-inspiration/audience-pro/components-command-palette.html +90 -0
- package/plugins/design/templates/design-system-inspiration/audience-pro/components-keyboard.html +51 -0
- package/plugins/design/templates/design-system-inspiration/audience-pro/components-list.html +89 -0
- package/plugins/design/templates/design-system-inspiration/audience-pro/components-shortcuts-overlay.html +85 -0
- package/plugins/design/templates/design-system-inspiration/audience-pro/components-toast-menu.html +80 -0
- package/plugins/design/templates/design-system-inspiration/core/INDEX.md.tpl +25 -0
- package/plugins/design/templates/design-system-inspiration/core/README.orchestration.md.tpl +71 -0
- package/plugins/design/templates/design-system-inspiration/core/README.philosophy.md.tpl +77 -0
- package/plugins/design/templates/design-system-inspiration/core/SKILL.md.tpl +50 -0
- package/plugins/design/templates/design-system-inspiration/core/colors_and_type.css.tpl +111 -0
- package/plugins/design/templates/design-system-inspiration/core/config.json.tpl +28 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/_layout.css +113 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/colors-accent.html +48 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/colors-surfaces.html +49 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/colors-text.html +52 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/components-buttons.html +83 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/components-cards.html +79 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/components-inputs.html +82 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/motion.html +66 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/spacing-scale.html +53 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/type-scale.html +62 -0
- package/plugins/design/templates/design-system-inspiration/foundations/borders.html +39 -0
- package/plugins/design/templates/design-system-inspiration/foundations/elevation.html +46 -0
- package/plugins/design/templates/design-system-inspiration/foundations/focus.html +48 -0
- package/plugins/design/templates/design-system-inspiration/foundations/grid.html +51 -0
- package/plugins/design/templates/design-system-inspiration/foundations/iconography.html +73 -0
- package/plugins/design/templates/design-system-inspiration/foundations/opacity.html +45 -0
- package/plugins/design/templates/design-system-inspiration/foundations/radii.html +40 -0
- package/plugins/design/templates/design-system-inspiration/foundations/selection.html +45 -0
- package/plugins/design/templates/design-system-inspiration/meta/accessibility.html +62 -0
- package/plugins/design/templates/design-system-inspiration/meta/i18n.html +73 -0
- package/plugins/design/templates/design-system-inspiration/meta/presence-multiplayer.html +71 -0
- package/plugins/design/templates/design-system-inspiration/meta/tokens-index.html +80 -0
- package/plugins/design/templates/design-system-inspiration/patterns/patterns-auth.html +78 -0
- package/plugins/design/templates/design-system-inspiration/patterns/patterns-data-density.html +61 -0
- package/plugins/design/templates/design-system-inspiration/patterns/patterns-error-pages.html +70 -0
- package/plugins/design/templates/design-system-inspiration/patterns/patterns-form-layouts.html +70 -0
- package/plugins/design/templates/design-system-inspiration/patterns/patterns-onboarding.html +71 -0
- package/plugins/design/templates/design-system-inspiration/patterns/patterns-pricing.html +83 -0
- package/plugins/design/templates/design-system-inspiration/platform-desktop/components-resize-panels.html +63 -0
- package/plugins/design/templates/design-system-inspiration/platform-desktop/ui_kits-desktop-index.html +55 -0
- package/plugins/design/templates/design-system-inspiration/platform-desktop/ui_kits-desktop-showcase.html +302 -0
- package/plugins/design/templates/design-system-inspiration/platform-mobile/components-bottom-sheet.html +63 -0
- package/plugins/design/templates/design-system-inspiration/platform-mobile/components-pull-to-refresh.html +74 -0
- package/plugins/design/templates/design-system-inspiration/platform-mobile/components-segmented-control.html +51 -0
- package/plugins/design/templates/design-system-inspiration/platform-mobile/components-tab-bar.html +57 -0
- package/plugins/design/templates/design-system-inspiration/platform-mobile/ui_kits-mobile-index.html +58 -0
- package/plugins/design/templates/design-system-inspiration/platform-mobile/ui_kits-mobile-showcase.html +237 -0
- package/plugins/design/templates/design-system-inspiration/status/colors-status.html +49 -0
- package/plugins/design/templates/design-system-inspiration/status/components-status.html +63 -0
- package/plugins/design/templates/design-system-inspiration/status/skeletons.html +74 -0
- package/plugins/design/templates/design-system-inspiration/theme-both/colors-themes-side-by-side.html +59 -0
- package/plugins/design/templates/design-system-inspiration/universal/components-callout.html +74 -0
- package/plugins/design/templates/design-system-inspiration/universal/components-dialogs.html +81 -0
- package/plugins/design/templates/design-system-inspiration/universal/components-tables.html +101 -0
- package/plugins/design/templates/design-system-inspiration/universal/components-toggles.html +74 -0
- package/plugins/design/templates/design-system-inspiration/universal/components-tooltips.html +74 -0
- package/plugins/design/templates/design-system-inspiration/universal/empty-state.html.tpl +34 -0
- package/plugins/design/templates/design-system-inspiration/universal/logo.html +42 -0
- package/plugins/design/templates/ds-specimen.tsx.template +59 -0
- package/plugins/flow/.claude-plugin/config.schema.json +398 -0
- package/plugins/flow/README.md +45 -0
- package/plugins/flow/templates/ai-skeleton/INDEX.md +50 -0
- package/plugins/flow/templates/ai-skeleton/README.md +46 -0
- package/plugins/flow/templates/ai-skeleton/browser/har/.gitkeep +0 -0
- package/plugins/flow/templates/ai-skeleton/browser/snapshots/.gitkeep +0 -0
- package/plugins/flow/templates/ai-skeleton/business/README.md +12 -0
- package/plugins/flow/templates/ai-skeleton/context/README.md +12 -0
- package/plugins/flow/templates/ai-skeleton/decisions/README.md +20 -0
- package/plugins/flow/templates/ai-skeleton/design-import/.gitkeep +0 -0
- package/plugins/flow/templates/ai-skeleton/dev-logs/.gitkeep +0 -0
- package/plugins/flow/templates/ai-skeleton/device/.gitkeep +0 -0
- package/plugins/flow/templates/ai-skeleton/docs/README.md +5 -0
- package/plugins/flow/templates/ai-skeleton/logs/.gitkeep +0 -0
- package/plugins/flow/templates/ai-skeleton/logs/README.md +13 -0
- package/plugins/flow/templates/ai-skeleton/plans/README.md +16 -0
- package/plugins/flow/templates/ai-skeleton/plans/archive/.gitkeep +0 -0
- package/plugins/flow/templates/ai-skeleton/release-guide.md +35 -0
- package/plugins/flow/templates/ai-skeleton/reviews/README.md +15 -0
- package/plugins/flow/templates/ai-skeleton/scenarios/README.md +26 -0
- package/plugins/flow/templates/ai-skeleton/scenarios/_lib/.gitkeep +0 -0
- package/plugins/flow/templates/ai-skeleton/state/STATE.md +25 -0
- package/plugins/flow/templates/ai-skeleton/templates/HANDOFF.md +32 -0
- package/plugins/flow/templates/ai-skeleton/workflows.config.json +74 -0
|
@@ -0,0 +1,1717 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file annotations-layer.tsx — FigJam-style annotation overlay
|
|
3
|
+
* @scope plugins/design/dev-server/annotations-layer.tsx
|
|
4
|
+
* @purpose Portal-rendered draw layer. Strokes live in world coords and
|
|
5
|
+
* render INSIDE `.dc-world` via `createPortal`, so CSS `zoom` +
|
|
6
|
+
* `translate` on the world move them in lockstep with artboards
|
|
7
|
+
* with zero frame lag. A separate transparent input overlay
|
|
8
|
+
* (also portal-mounted inside the host) captures pointerdown
|
|
9
|
+
* only for draw / erase tools; viewport gestures (space-pan,
|
|
10
|
+
* middle-mouse, wheel/pinch) bypass us and reach
|
|
11
|
+
* `useViewportController` directly.
|
|
12
|
+
*
|
|
13
|
+
* Schema (back-compatible with Phase 5):
|
|
14
|
+
* - pen → <path data-tool="pen" d="M.. L..">
|
|
15
|
+
* - rect → <rect data-tool="rect" x= y= width= height= [fill=]>
|
|
16
|
+
* - ellipse → <ellipse data-tool="ellipse" cx= cy= rx= ry= [fill=]> NEW
|
|
17
|
+
* - arrow → <g data-tool="arrow"><line/><polyline/></g>
|
|
18
|
+
* - text → <text data-tool="text" data-anchor-id= x= y= fill= …> NEW
|
|
19
|
+
*
|
|
20
|
+
* Persists to `<designRoot>/<slug>.annotations.svg` via PUT /_api/annotations
|
|
21
|
+
* on commit, debounced 200 ms.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
type PointerEvent as ReactPointerEvent,
|
|
26
|
+
createContext,
|
|
27
|
+
useCallback,
|
|
28
|
+
useContext,
|
|
29
|
+
useEffect,
|
|
30
|
+
useMemo,
|
|
31
|
+
useRef,
|
|
32
|
+
useState,
|
|
33
|
+
} from 'react';
|
|
34
|
+
import { createPortal } from 'react-dom';
|
|
35
|
+
|
|
36
|
+
import { AnnotationContextToolbar } from './annotations-context-toolbar.tsx';
|
|
37
|
+
import { useViewportControllerContext, useWorldRefContext } from './canvas-lib.tsx';
|
|
38
|
+
import { useAnnotationSelectionOptional } from './use-annotation-selection.tsx';
|
|
39
|
+
import { useAnnotationsVisibility } from './use-annotations-visibility.tsx';
|
|
40
|
+
import { useSelectionSetOptional } from './use-selection-set.tsx';
|
|
41
|
+
import { useToolMode } from './use-tool-mode.tsx';
|
|
42
|
+
|
|
43
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
44
|
+
// Types
|
|
45
|
+
|
|
46
|
+
type WorldPoint = readonly [number, number];
|
|
47
|
+
|
|
48
|
+
export interface PenStroke {
|
|
49
|
+
id: string;
|
|
50
|
+
tool: 'pen';
|
|
51
|
+
color: string;
|
|
52
|
+
width: number;
|
|
53
|
+
points: WorldPoint[];
|
|
54
|
+
}
|
|
55
|
+
export interface RectStroke {
|
|
56
|
+
id: string;
|
|
57
|
+
tool: 'rect';
|
|
58
|
+
color: string;
|
|
59
|
+
width: number;
|
|
60
|
+
x: number;
|
|
61
|
+
y: number;
|
|
62
|
+
w: number;
|
|
63
|
+
h: number;
|
|
64
|
+
fill?: string | null;
|
|
65
|
+
}
|
|
66
|
+
export interface EllipseStroke {
|
|
67
|
+
id: string;
|
|
68
|
+
tool: 'ellipse';
|
|
69
|
+
color: string;
|
|
70
|
+
width: number;
|
|
71
|
+
cx: number;
|
|
72
|
+
cy: number;
|
|
73
|
+
rx: number;
|
|
74
|
+
ry: number;
|
|
75
|
+
fill?: string | null;
|
|
76
|
+
}
|
|
77
|
+
export interface ArrowStroke {
|
|
78
|
+
id: string;
|
|
79
|
+
tool: 'arrow';
|
|
80
|
+
color: string;
|
|
81
|
+
width: number;
|
|
82
|
+
x1: number;
|
|
83
|
+
y1: number;
|
|
84
|
+
x2: number;
|
|
85
|
+
y2: number;
|
|
86
|
+
}
|
|
87
|
+
export interface TextStroke {
|
|
88
|
+
id: string;
|
|
89
|
+
tool: 'text';
|
|
90
|
+
color: string;
|
|
91
|
+
fontSize: number;
|
|
92
|
+
text: string;
|
|
93
|
+
anchorId: string;
|
|
94
|
+
}
|
|
95
|
+
export type Stroke = PenStroke | RectStroke | EllipseStroke | ArrowStroke | TextStroke;
|
|
96
|
+
|
|
97
|
+
const PALETTE = [
|
|
98
|
+
'#d63b1f', // accent red — first slot mirrors the default DS accent
|
|
99
|
+
'#f5a623', // amber
|
|
100
|
+
'#1a8f3e', // green
|
|
101
|
+
'#1d6cf0', // blue
|
|
102
|
+
'#7a4ad3', // purple
|
|
103
|
+
'#1a1a1a', // ink
|
|
104
|
+
] as const;
|
|
105
|
+
type PaletteColor = (typeof PALETTE)[number];
|
|
106
|
+
const DEFAULT_COLOR: PaletteColor = PALETTE[0];
|
|
107
|
+
|
|
108
|
+
const FILL_PALETTE = [
|
|
109
|
+
'#fff4d6', // amber tint
|
|
110
|
+
'#e6f4ea', // green tint
|
|
111
|
+
'#e3edff', // blue tint
|
|
112
|
+
'#f0e8fb', // purple tint
|
|
113
|
+
'#ffe5e0', // red tint
|
|
114
|
+
'#f4f1ee', // paper
|
|
115
|
+
] as const;
|
|
116
|
+
|
|
117
|
+
const STROKE_WIDTH_THIN = 2;
|
|
118
|
+
const STROKE_WIDTH_THICK = 6;
|
|
119
|
+
type Thickness = typeof STROKE_WIDTH_THIN | typeof STROKE_WIDTH_THICK;
|
|
120
|
+
|
|
121
|
+
const FONT_SIZE_SMALL = 12;
|
|
122
|
+
const FONT_SIZE_MEDIUM = 14;
|
|
123
|
+
const FONT_SIZE_LARGE = 20;
|
|
124
|
+
const DEFAULT_FONT_SIZE = FONT_SIZE_MEDIUM;
|
|
125
|
+
|
|
126
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
127
|
+
// Pure helpers — exported for unit tests.
|
|
128
|
+
|
|
129
|
+
export function rid(): string {
|
|
130
|
+
return `s_${Math.random().toString(36).slice(2, 10)}`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function esc(s: string): string {
|
|
134
|
+
return s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function penPathD(points: readonly WorldPoint[]): string {
|
|
138
|
+
if (points.length === 0) return '';
|
|
139
|
+
const [first, ...rest] = points as readonly WorldPoint[];
|
|
140
|
+
if (!first) return '';
|
|
141
|
+
let d = `M${first[0]} ${first[1]}`;
|
|
142
|
+
for (const p of rest) d += ` L${p[0]} ${p[1]}`;
|
|
143
|
+
return d;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function arrowHeadPoints(
|
|
147
|
+
x1: number,
|
|
148
|
+
y1: number,
|
|
149
|
+
x2: number,
|
|
150
|
+
y2: number,
|
|
151
|
+
width: number
|
|
152
|
+
): string {
|
|
153
|
+
const angle = Math.atan2(y2 - y1, x2 - x1);
|
|
154
|
+
const len = 12 + width * 2;
|
|
155
|
+
const wing = Math.PI / 7;
|
|
156
|
+
const ax = x2 - Math.cos(angle - wing) * len;
|
|
157
|
+
const ay = y2 - Math.sin(angle - wing) * len;
|
|
158
|
+
const bx = x2 - Math.cos(angle + wing) * len;
|
|
159
|
+
const by = y2 - Math.sin(angle + wing) * len;
|
|
160
|
+
return `${ax},${ay} ${x2},${y2} ${bx},${by}`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function strokeToSvgEl(s: Stroke): string {
|
|
164
|
+
if (s.tool === 'text') {
|
|
165
|
+
return `<text data-id="${esc(s.id)}" data-tool="text" data-anchor-id="${esc(
|
|
166
|
+
s.anchorId
|
|
167
|
+
)}" data-font-size="${s.fontSize}" fill="${esc(
|
|
168
|
+
s.color
|
|
169
|
+
)}" text-anchor="middle" dominant-baseline="middle">${esc(s.text)}</text>`;
|
|
170
|
+
}
|
|
171
|
+
const common = `data-id="${esc(s.id)}" data-tool="${s.tool}" stroke="${esc(s.color)}" stroke-width="${s.width}" stroke-linecap="round" stroke-linejoin="round" vector-effect="non-scaling-stroke"`;
|
|
172
|
+
if (s.tool === 'pen') {
|
|
173
|
+
return `<path ${common} fill="none" d="${penPathD(s.points)}" pointer-events="stroke"/>`;
|
|
174
|
+
}
|
|
175
|
+
if (s.tool === 'rect') {
|
|
176
|
+
const fill = s.fill ? esc(s.fill) : 'none';
|
|
177
|
+
return `<rect ${common} fill="${fill}" x="${s.x}" y="${s.y}" width="${Math.max(
|
|
178
|
+
0,
|
|
179
|
+
s.w
|
|
180
|
+
)}" height="${Math.max(0, s.h)}"/>`;
|
|
181
|
+
}
|
|
182
|
+
if (s.tool === 'ellipse') {
|
|
183
|
+
const fill = s.fill ? esc(s.fill) : 'none';
|
|
184
|
+
return `<ellipse ${common} fill="${fill}" cx="${s.cx}" cy="${s.cy}" rx="${Math.max(
|
|
185
|
+
0,
|
|
186
|
+
s.rx
|
|
187
|
+
)}" ry="${Math.max(0, s.ry)}"/>`;
|
|
188
|
+
}
|
|
189
|
+
const head = arrowHeadPoints(s.x1, s.y1, s.x2, s.y2, s.width);
|
|
190
|
+
return `<g ${common} fill="none"><line x1="${s.x1}" y1="${s.y1}" x2="${s.x2}" y2="${s.y2}"/><polyline points="${head}" fill="${esc(s.color)}"/></g>`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function strokesToSvg(strokes: readonly Stroke[]): string {
|
|
194
|
+
const header = '<svg xmlns="http://www.w3.org/2000/svg" data-mdcc-annotations="1">';
|
|
195
|
+
if (strokes.length === 0) return `${header}</svg>`;
|
|
196
|
+
const body = strokes.map(strokeToSvgEl).join('');
|
|
197
|
+
return `${header}${body}</svg>`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function parsePathD(d: string): WorldPoint[] {
|
|
201
|
+
const out: WorldPoint[] = [];
|
|
202
|
+
const re = /[ML]\s*(-?\d+(?:\.\d+)?)\s*[\s,]\s*(-?\d+(?:\.\d+)?)/g;
|
|
203
|
+
let m: RegExpExecArray | null;
|
|
204
|
+
// biome-ignore lint/suspicious/noAssignInExpressions: idiomatic regex loop
|
|
205
|
+
while ((m = re.exec(d)) !== null) {
|
|
206
|
+
const [, x = '0', y = '0'] = m;
|
|
207
|
+
out.push([Number.parseFloat(x), Number.parseFloat(y)]);
|
|
208
|
+
}
|
|
209
|
+
return out;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function parseFill(raw: string | null): string | null {
|
|
213
|
+
if (!raw) return null;
|
|
214
|
+
const v = raw.trim().toLowerCase();
|
|
215
|
+
if (!v || v === 'none' || v === 'transparent') return null;
|
|
216
|
+
return raw;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function svgToStrokes(svgText: string): Stroke[] {
|
|
220
|
+
const text = (svgText ?? '').trim();
|
|
221
|
+
if (!text) return [];
|
|
222
|
+
if (typeof DOMParser === 'undefined') return [];
|
|
223
|
+
try {
|
|
224
|
+
const doc = new DOMParser().parseFromString(text, 'image/svg+xml');
|
|
225
|
+
if (doc.querySelector('parsererror')) return [];
|
|
226
|
+
const out: Stroke[] = [];
|
|
227
|
+
for (const el of Array.from(doc.querySelectorAll('[data-tool]'))) {
|
|
228
|
+
const tool = el.getAttribute('data-tool');
|
|
229
|
+
const id = el.getAttribute('data-id') || rid();
|
|
230
|
+
const color = el.getAttribute('stroke') || el.getAttribute('fill') || DEFAULT_COLOR;
|
|
231
|
+
const width = Number.parseFloat(el.getAttribute('stroke-width') || '2') || 2;
|
|
232
|
+
if (tool === 'pen') {
|
|
233
|
+
const d = el.getAttribute('d') || '';
|
|
234
|
+
const points = parsePathD(d);
|
|
235
|
+
if (points.length) out.push({ id, tool: 'pen', color, width, points });
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
if (tool === 'rect') {
|
|
239
|
+
const x = Number.parseFloat(el.getAttribute('x') || '0');
|
|
240
|
+
const y = Number.parseFloat(el.getAttribute('y') || '0');
|
|
241
|
+
const w = Number.parseFloat(el.getAttribute('width') || '0');
|
|
242
|
+
const h = Number.parseFloat(el.getAttribute('height') || '0');
|
|
243
|
+
const fill = parseFill(el.getAttribute('fill'));
|
|
244
|
+
out.push({ id, tool: 'rect', color, width, x, y, w, h, fill });
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
if (tool === 'ellipse') {
|
|
248
|
+
const cx = Number.parseFloat(el.getAttribute('cx') || '0');
|
|
249
|
+
const cy = Number.parseFloat(el.getAttribute('cy') || '0');
|
|
250
|
+
const rx = Number.parseFloat(el.getAttribute('rx') || '0');
|
|
251
|
+
const ry = Number.parseFloat(el.getAttribute('ry') || '0');
|
|
252
|
+
const fill = parseFill(el.getAttribute('fill'));
|
|
253
|
+
out.push({ id, tool: 'ellipse', color, width, cx, cy, rx, ry, fill });
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
if (tool === 'arrow') {
|
|
257
|
+
const line = el.querySelector('line');
|
|
258
|
+
if (line) {
|
|
259
|
+
out.push({
|
|
260
|
+
id,
|
|
261
|
+
tool: 'arrow',
|
|
262
|
+
color,
|
|
263
|
+
width,
|
|
264
|
+
x1: Number.parseFloat(line.getAttribute('x1') || '0'),
|
|
265
|
+
y1: Number.parseFloat(line.getAttribute('y1') || '0'),
|
|
266
|
+
x2: Number.parseFloat(line.getAttribute('x2') || '0'),
|
|
267
|
+
y2: Number.parseFloat(line.getAttribute('y2') || '0'),
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
if (tool === 'text') {
|
|
273
|
+
const anchorId = el.getAttribute('data-anchor-id') || '';
|
|
274
|
+
const fontSize =
|
|
275
|
+
Number.parseFloat(el.getAttribute('data-font-size') || String(DEFAULT_FONT_SIZE)) ||
|
|
276
|
+
DEFAULT_FONT_SIZE;
|
|
277
|
+
const inkColor = el.getAttribute('fill') || color;
|
|
278
|
+
out.push({
|
|
279
|
+
id,
|
|
280
|
+
tool: 'text',
|
|
281
|
+
color: inkColor,
|
|
282
|
+
fontSize,
|
|
283
|
+
text: (el.textContent || '').trim(),
|
|
284
|
+
anchorId,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return out;
|
|
289
|
+
} catch {
|
|
290
|
+
return [];
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function pointSegmentDist(
|
|
295
|
+
px: number,
|
|
296
|
+
py: number,
|
|
297
|
+
ax: number,
|
|
298
|
+
ay: number,
|
|
299
|
+
bx: number,
|
|
300
|
+
by: number
|
|
301
|
+
): number {
|
|
302
|
+
const dx = bx - ax;
|
|
303
|
+
const dy = by - ay;
|
|
304
|
+
const len2 = dx * dx + dy * dy;
|
|
305
|
+
if (len2 === 0) return Math.hypot(px - ax, py - ay);
|
|
306
|
+
let t = ((px - ax) * dx + (py - ay) * dy) / len2;
|
|
307
|
+
t = Math.max(0, Math.min(1, t));
|
|
308
|
+
return Math.hypot(px - (ax + t * dx), py - (ay + t * dy));
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export function strokeHitTest(s: Stroke, wx: number, wy: number, tol: number): boolean {
|
|
312
|
+
if (s.tool === 'text') return false;
|
|
313
|
+
const t = Math.max(tol, 'width' in s ? s.width : 2);
|
|
314
|
+
if (s.tool === 'pen') {
|
|
315
|
+
if (s.points.length === 1) {
|
|
316
|
+
const p = s.points[0] as WorldPoint;
|
|
317
|
+
return Math.hypot(wx - p[0], wy - p[1]) <= t;
|
|
318
|
+
}
|
|
319
|
+
for (let i = 1; i < s.points.length; i++) {
|
|
320
|
+
const a = s.points[i - 1] as WorldPoint;
|
|
321
|
+
const b = s.points[i] as WorldPoint;
|
|
322
|
+
if (pointSegmentDist(wx, wy, a[0], a[1], b[0], b[1]) <= t) return true;
|
|
323
|
+
}
|
|
324
|
+
return false;
|
|
325
|
+
}
|
|
326
|
+
if (s.tool === 'arrow') {
|
|
327
|
+
return pointSegmentDist(wx, wy, s.x1, s.y1, s.x2, s.y2) <= t;
|
|
328
|
+
}
|
|
329
|
+
if (s.tool === 'ellipse') {
|
|
330
|
+
// Inside-ellipse hit when filled; on the perimeter otherwise.
|
|
331
|
+
if (s.rx <= 0 || s.ry <= 0) return false;
|
|
332
|
+
const nx = (wx - s.cx) / s.rx;
|
|
333
|
+
const ny = (wy - s.cy) / s.ry;
|
|
334
|
+
const d = nx * nx + ny * ny;
|
|
335
|
+
if (s.fill) return d <= 1.0 + t / Math.max(s.rx, s.ry);
|
|
336
|
+
// Stroke-only: hit if normalized distance is within a band around 1.
|
|
337
|
+
const band = t / Math.max(s.rx, s.ry);
|
|
338
|
+
const dist = Math.abs(Math.sqrt(d) - 1);
|
|
339
|
+
return dist <= band;
|
|
340
|
+
}
|
|
341
|
+
// rect — inside when filled, edge-only otherwise.
|
|
342
|
+
const x = s.x;
|
|
343
|
+
const y = s.y;
|
|
344
|
+
const x2 = x + s.w;
|
|
345
|
+
const y2 = y + s.h;
|
|
346
|
+
const xMin = Math.min(x, x2);
|
|
347
|
+
const xMax = Math.max(x, x2);
|
|
348
|
+
const yMin = Math.min(y, y2);
|
|
349
|
+
const yMax = Math.max(y, y2);
|
|
350
|
+
if (s.fill) {
|
|
351
|
+
return wx >= xMin - t && wx <= xMax + t && wy >= yMin - t && wy <= yMax + t;
|
|
352
|
+
}
|
|
353
|
+
if (wx < xMin - t || wx > xMax + t) return false;
|
|
354
|
+
if (wy < yMin - t || wy > yMax + t) return false;
|
|
355
|
+
const onLeft = Math.abs(wx - x) <= t;
|
|
356
|
+
const onRight = Math.abs(wx - x2) <= t;
|
|
357
|
+
const onTop = Math.abs(wy - y) <= t;
|
|
358
|
+
const onBottom = Math.abs(wy - y2) <= t;
|
|
359
|
+
return onLeft || onRight || onTop || onBottom;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function normalizeRect(r: RectStroke): RectStroke {
|
|
363
|
+
if (r.w >= 0 && r.h >= 0) return r;
|
|
364
|
+
return {
|
|
365
|
+
...r,
|
|
366
|
+
x: Math.min(r.x, r.x + r.w),
|
|
367
|
+
y: Math.min(r.y, r.y + r.h),
|
|
368
|
+
w: Math.abs(r.w),
|
|
369
|
+
h: Math.abs(r.h),
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function isStrokeMeaningful(s: Stroke): boolean {
|
|
374
|
+
if (s.tool === 'pen') return s.points.length >= 2;
|
|
375
|
+
if (s.tool === 'rect') return Math.abs(s.w) >= 4 && Math.abs(s.h) >= 4;
|
|
376
|
+
if (s.tool === 'ellipse') return s.rx >= 2 && s.ry >= 2;
|
|
377
|
+
if (s.tool === 'text') return s.text.trim().length > 0;
|
|
378
|
+
return Math.hypot(s.x2 - s.x1, s.y2 - s.y1) >= 4;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export function strokeBBox(
|
|
382
|
+
s: Stroke,
|
|
383
|
+
anchors?: Map<string, RectStroke | EllipseStroke>
|
|
384
|
+
): { x: number; y: number; w: number; h: number } | null {
|
|
385
|
+
if (s.tool === 'pen') {
|
|
386
|
+
if (!s.points.length) return null;
|
|
387
|
+
let xMin = Number.POSITIVE_INFINITY;
|
|
388
|
+
let xMax = Number.NEGATIVE_INFINITY;
|
|
389
|
+
let yMin = Number.POSITIVE_INFINITY;
|
|
390
|
+
let yMax = Number.NEGATIVE_INFINITY;
|
|
391
|
+
for (const [px, py] of s.points) {
|
|
392
|
+
if (px < xMin) xMin = px;
|
|
393
|
+
if (px > xMax) xMax = px;
|
|
394
|
+
if (py < yMin) yMin = py;
|
|
395
|
+
if (py > yMax) yMax = py;
|
|
396
|
+
}
|
|
397
|
+
return { x: xMin, y: yMin, w: xMax - xMin, h: yMax - yMin };
|
|
398
|
+
}
|
|
399
|
+
if (s.tool === 'rect') {
|
|
400
|
+
return {
|
|
401
|
+
x: Math.min(s.x, s.x + s.w),
|
|
402
|
+
y: Math.min(s.y, s.y + s.h),
|
|
403
|
+
w: Math.abs(s.w),
|
|
404
|
+
h: Math.abs(s.h),
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
if (s.tool === 'ellipse') {
|
|
408
|
+
return { x: s.cx - s.rx, y: s.cy - s.ry, w: s.rx * 2, h: s.ry * 2 };
|
|
409
|
+
}
|
|
410
|
+
if (s.tool === 'arrow') {
|
|
411
|
+
return {
|
|
412
|
+
x: Math.min(s.x1, s.x2),
|
|
413
|
+
y: Math.min(s.y1, s.y2),
|
|
414
|
+
w: Math.abs(s.x2 - s.x1),
|
|
415
|
+
h: Math.abs(s.y2 - s.y1),
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
// text → inherit from anchor
|
|
419
|
+
const host = anchors?.get(s.anchorId);
|
|
420
|
+
return host ? strokeBBox(host) : null;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function isEditable(t: EventTarget | null): boolean {
|
|
424
|
+
if (!t || !(t as HTMLElement).tagName) return false;
|
|
425
|
+
const el = t as HTMLElement;
|
|
426
|
+
const tag = el.tagName;
|
|
427
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
|
|
428
|
+
if (el.isContentEditable) return true;
|
|
429
|
+
return false;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function deriveFile(): string | undefined {
|
|
433
|
+
if (typeof window === 'undefined') return undefined;
|
|
434
|
+
try {
|
|
435
|
+
const p = window.location.pathname;
|
|
436
|
+
if (p === '/_canvas-shell.html' || p === '/_canvas-shell') {
|
|
437
|
+
const qs = new URLSearchParams(window.location.search);
|
|
438
|
+
const canvas = qs.get('canvas') ?? '';
|
|
439
|
+
const designRel = (qs.get('designRel') ?? '.design').replace(/^\/+|\/+$/g, '');
|
|
440
|
+
return `${designRel}/${canvas}`;
|
|
441
|
+
}
|
|
442
|
+
return decodeURIComponent(p).replace(/^\//, '');
|
|
443
|
+
} catch {
|
|
444
|
+
return undefined;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
449
|
+
// Styles
|
|
450
|
+
|
|
451
|
+
const ANNOT_CSS = `
|
|
452
|
+
.dc-annot-chrome {
|
|
453
|
+
/* Stacks directly above the centered tool toolbar (which is bottom:16px,
|
|
454
|
+
32px tall → top edge ~ bottom:48px). 8 px gap → chrome at bottom:60px. */
|
|
455
|
+
position: absolute;
|
|
456
|
+
left: 50%;
|
|
457
|
+
bottom: 60px;
|
|
458
|
+
transform: translateX(-50%);
|
|
459
|
+
display: flex;
|
|
460
|
+
align-items: center;
|
|
461
|
+
gap: 8px;
|
|
462
|
+
background: var(--bg-1, rgba(255,255,255,0.98));
|
|
463
|
+
border: 1px solid var(--u-border-2, rgba(0,0,0,0.08));
|
|
464
|
+
border-radius: 8px;
|
|
465
|
+
padding: 6px 10px;
|
|
466
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
467
|
+
font-size: 11px;
|
|
468
|
+
color: rgba(40,30,20,0.85);
|
|
469
|
+
z-index: 6;
|
|
470
|
+
box-shadow: 0 6px 24px rgba(0,0,0,0.08);
|
|
471
|
+
user-select: none;
|
|
472
|
+
}
|
|
473
|
+
.dc-annot-chrome .dc-annot-swatches { display: flex; gap: 4px; }
|
|
474
|
+
.dc-annot-chrome .dc-annot-sw {
|
|
475
|
+
width: 18px;
|
|
476
|
+
height: 18px;
|
|
477
|
+
border-radius: 3px;
|
|
478
|
+
border: 1px solid rgba(0,0,0,0.12);
|
|
479
|
+
cursor: pointer;
|
|
480
|
+
padding: 0;
|
|
481
|
+
appearance: none;
|
|
482
|
+
}
|
|
483
|
+
.dc-annot-chrome .dc-annot-sw[aria-pressed="true"] {
|
|
484
|
+
box-shadow: 0 0 0 2px var(--accent, #d63b1f);
|
|
485
|
+
border-color: transparent;
|
|
486
|
+
}
|
|
487
|
+
.dc-annot-chrome .dc-annot-sw:focus-visible {
|
|
488
|
+
outline: 2px solid var(--accent, #d63b1f);
|
|
489
|
+
outline-offset: 2px;
|
|
490
|
+
}
|
|
491
|
+
.dc-annot-chrome .dc-annot-sep {
|
|
492
|
+
width: 1px;
|
|
493
|
+
align-self: stretch;
|
|
494
|
+
background: rgba(0,0,0,0.08);
|
|
495
|
+
margin: 0 2px;
|
|
496
|
+
}
|
|
497
|
+
.dc-annot-chrome .dc-annot-fill {
|
|
498
|
+
width: 18px;
|
|
499
|
+
height: 18px;
|
|
500
|
+
border-radius: 3px;
|
|
501
|
+
border: 1px solid rgba(0,0,0,0.18);
|
|
502
|
+
cursor: pointer;
|
|
503
|
+
padding: 0;
|
|
504
|
+
appearance: none;
|
|
505
|
+
position: relative;
|
|
506
|
+
}
|
|
507
|
+
.dc-annot-chrome .dc-annot-fill--none {
|
|
508
|
+
background: #fff;
|
|
509
|
+
}
|
|
510
|
+
.dc-annot-chrome .dc-annot-fill--none::after {
|
|
511
|
+
content: "";
|
|
512
|
+
position: absolute; inset: 2px;
|
|
513
|
+
background:
|
|
514
|
+
linear-gradient(135deg, transparent 47%, #d63b1f 47%, #d63b1f 53%, transparent 53%);
|
|
515
|
+
}
|
|
516
|
+
.dc-annot-chrome .dc-annot-fill[aria-pressed="true"] {
|
|
517
|
+
box-shadow: 0 0 0 2px var(--accent, #d63b1f);
|
|
518
|
+
border-color: transparent;
|
|
519
|
+
}
|
|
520
|
+
.dc-annot-chrome .dc-annot-btn {
|
|
521
|
+
appearance: none;
|
|
522
|
+
background: transparent;
|
|
523
|
+
border: 1px solid rgba(0,0,0,0.12);
|
|
524
|
+
border-radius: 3px;
|
|
525
|
+
padding: 4px 8px;
|
|
526
|
+
font: inherit;
|
|
527
|
+
color: inherit;
|
|
528
|
+
cursor: pointer;
|
|
529
|
+
letter-spacing: 0.05em;
|
|
530
|
+
text-transform: uppercase;
|
|
531
|
+
}
|
|
532
|
+
.dc-annot-chrome .dc-annot-btn[aria-pressed="true"] {
|
|
533
|
+
background: var(--accent, #d63b1f);
|
|
534
|
+
color: var(--accent-fg, #fff);
|
|
535
|
+
border-color: transparent;
|
|
536
|
+
}
|
|
537
|
+
.dc-annot-chrome .dc-annot-btn:hover { background: rgba(0,0,0,0.04); }
|
|
538
|
+
.dc-annot-chrome .dc-annot-btn:focus-visible {
|
|
539
|
+
outline: 2px solid var(--accent, #d63b1f);
|
|
540
|
+
outline-offset: 2px;
|
|
541
|
+
}
|
|
542
|
+
.dc-annot-input {
|
|
543
|
+
position: absolute;
|
|
544
|
+
inset: 0;
|
|
545
|
+
z-index: 4;
|
|
546
|
+
}
|
|
547
|
+
.dc-annot-svg {
|
|
548
|
+
position: absolute;
|
|
549
|
+
left: 0;
|
|
550
|
+
top: 0;
|
|
551
|
+
/*
|
|
552
|
+
* .dc-world has no intrinsic dimensions — its children render via absolute
|
|
553
|
+
* positioning. An SVG inside with width:100%/height:100% resolves to 0 px
|
|
554
|
+
* and Chrome clips children even under overflow:visible. We hardcode a
|
|
555
|
+
* very large width/height instead so the SVG viewport easily covers any
|
|
556
|
+
* world-coord stroke. vector-effect="non-scaling-stroke" on every stroke
|
|
557
|
+
* keeps thickness px-constant under CSS zoom; overflow:visible covers the
|
|
558
|
+
* rare edge case of a stroke straying outside this 200k box.
|
|
559
|
+
*/
|
|
560
|
+
width: 200000px;
|
|
561
|
+
height: 200000px;
|
|
562
|
+
overflow: visible;
|
|
563
|
+
pointer-events: none;
|
|
564
|
+
}
|
|
565
|
+
/* Drag-select marquee — rendered while user is dragging to select strokes. */
|
|
566
|
+
.dc-annot-marquee {
|
|
567
|
+
pointer-events: none;
|
|
568
|
+
fill: color-mix(in oklab, var(--accent, #d63b1f) 8%, transparent);
|
|
569
|
+
stroke: var(--accent, #d63b1f);
|
|
570
|
+
stroke-width: 1;
|
|
571
|
+
stroke-dasharray: 4 3;
|
|
572
|
+
}
|
|
573
|
+
`.trim();
|
|
574
|
+
|
|
575
|
+
function ensureAnnotStyles(): void {
|
|
576
|
+
if (typeof document === 'undefined') return;
|
|
577
|
+
if (document.getElementById('dc-annot-css')) return;
|
|
578
|
+
const s = document.createElement('style');
|
|
579
|
+
s.id = 'dc-annot-css';
|
|
580
|
+
s.textContent = ANNOT_CSS;
|
|
581
|
+
document.head.appendChild(s);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
585
|
+
// Strokes store — lifted out of the layer so the contextual toolbar (Phase 5.1
|
|
586
|
+
// Task 8) can mutate strokes without prop-drilling.
|
|
587
|
+
|
|
588
|
+
export interface StrokesStoreValue {
|
|
589
|
+
strokes: Stroke[];
|
|
590
|
+
setStrokes: (next: Stroke[]) => void;
|
|
591
|
+
updateStroke: (id: string, patch: Partial<Stroke>) => void;
|
|
592
|
+
deleteStrokes: (ids: string[]) => void;
|
|
593
|
+
translateStrokes: (ids: string[], dx: number, dy: number) => void;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const StrokesStoreContext = createContext<StrokesStoreValue | null>(null);
|
|
597
|
+
|
|
598
|
+
export function useStrokesStore(): StrokesStoreValue | null {
|
|
599
|
+
return useContext(StrokesStoreContext);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function translateOne(s: Stroke, dx: number, dy: number): Stroke {
|
|
603
|
+
if (s.tool === 'pen') {
|
|
604
|
+
return { ...s, points: s.points.map(([x, y]) => [x + dx, y + dy] as WorldPoint) };
|
|
605
|
+
}
|
|
606
|
+
if (s.tool === 'rect') return { ...s, x: s.x + dx, y: s.y + dy };
|
|
607
|
+
if (s.tool === 'ellipse') return { ...s, cx: s.cx + dx, cy: s.cy + dy };
|
|
608
|
+
if (s.tool === 'arrow')
|
|
609
|
+
return { ...s, x1: s.x1 + dx, y1: s.y1 + dy, x2: s.x2 + dx, y2: s.y2 + dy };
|
|
610
|
+
return s; // text inherits its host's bbox
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// Annotations visibility now lives in use-annotations-visibility.tsx so the
|
|
614
|
+
// ToolPalette (a sibling under CanvasRouter, not a descendant of this layer)
|
|
615
|
+
// can read the same state. Re-exported here for back-compat.
|
|
616
|
+
export { useAnnotationsVisibility } from './use-annotations-visibility.tsx';
|
|
617
|
+
|
|
618
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
619
|
+
// Component
|
|
620
|
+
|
|
621
|
+
export function AnnotationsLayer() {
|
|
622
|
+
ensureAnnotStyles();
|
|
623
|
+
const { tool } = useToolMode();
|
|
624
|
+
const controller = useViewportControllerContext();
|
|
625
|
+
const vp = controller?.viewport ?? null;
|
|
626
|
+
const worldRef = useWorldRefContext();
|
|
627
|
+
const annotSel = useAnnotationSelectionOptional();
|
|
628
|
+
const elementSel = useSelectionSetOptional();
|
|
629
|
+
|
|
630
|
+
const [strokes, setStrokesState] = useState<Stroke[]>([]);
|
|
631
|
+
const [drawing, setDrawing] = useState<Stroke | null>(null);
|
|
632
|
+
const [color, setColor] = useState<string>(DEFAULT_COLOR);
|
|
633
|
+
const [fill, setFill] = useState<string | null>(null);
|
|
634
|
+
const [thickness, setThickness] = useState<Thickness>(STROKE_WIDTH_THIN);
|
|
635
|
+
const visibilityCtx = useAnnotationsVisibility();
|
|
636
|
+
const visible = visibilityCtx?.visible ?? true;
|
|
637
|
+
const setVisible = useCallback(
|
|
638
|
+
(next: boolean | ((cur: boolean) => boolean)) => {
|
|
639
|
+
if (!visibilityCtx) return;
|
|
640
|
+
const v =
|
|
641
|
+
typeof next === 'function'
|
|
642
|
+
? (next as (cur: boolean) => boolean)(visibilityCtx.visible)
|
|
643
|
+
: next;
|
|
644
|
+
visibilityCtx.setVisible(v);
|
|
645
|
+
},
|
|
646
|
+
[visibilityCtx]
|
|
647
|
+
);
|
|
648
|
+
const [editingId, setEditingId] = useState<string | null>(null);
|
|
649
|
+
|
|
650
|
+
const fileRef = useRef<string | undefined>(undefined);
|
|
651
|
+
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
652
|
+
const drawingRef = useRef<Stroke | null>(null);
|
|
653
|
+
drawingRef.current = drawing;
|
|
654
|
+
|
|
655
|
+
const isDraw = tool === 'pen' || tool === 'rect' || tool === 'arrow' || tool === 'ellipse';
|
|
656
|
+
const isErase = tool === 'eraser';
|
|
657
|
+
const isActive = isDraw || isErase;
|
|
658
|
+
const supportsThickness = tool === 'pen' || tool === 'arrow';
|
|
659
|
+
const supportsFill = tool === 'rect' || tool === 'ellipse';
|
|
660
|
+
|
|
661
|
+
// Load existing annotations on mount.
|
|
662
|
+
useEffect(() => {
|
|
663
|
+
const file = deriveFile();
|
|
664
|
+
fileRef.current = file;
|
|
665
|
+
if (!file) return;
|
|
666
|
+
let cancelled = false;
|
|
667
|
+
void fetch(`/_api/annotations?file=${encodeURIComponent(file)}`, {
|
|
668
|
+
headers: { Accept: 'image/svg+xml' },
|
|
669
|
+
})
|
|
670
|
+
.then((r) => (r.ok ? r.text() : ''))
|
|
671
|
+
.then((text) => {
|
|
672
|
+
if (cancelled) return;
|
|
673
|
+
const loaded = svgToStrokes(text);
|
|
674
|
+
if (loaded.length) setStrokesState(loaded);
|
|
675
|
+
})
|
|
676
|
+
.catch(() => {
|
|
677
|
+
/* network blip — start with an empty annotation set */
|
|
678
|
+
});
|
|
679
|
+
return () => {
|
|
680
|
+
cancelled = true;
|
|
681
|
+
};
|
|
682
|
+
}, []);
|
|
683
|
+
|
|
684
|
+
const scheduleSave = useCallback((next: readonly Stroke[]) => {
|
|
685
|
+
const file = fileRef.current;
|
|
686
|
+
if (!file) return;
|
|
687
|
+
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
|
688
|
+
saveTimerRef.current = setTimeout(() => {
|
|
689
|
+
saveTimerRef.current = null;
|
|
690
|
+
const svg = strokesToSvg(next);
|
|
691
|
+
void fetch('/_api/annotations', {
|
|
692
|
+
method: 'PUT',
|
|
693
|
+
headers: { 'Content-Type': 'application/json' },
|
|
694
|
+
body: JSON.stringify({ file, svg }),
|
|
695
|
+
}).catch(() => {
|
|
696
|
+
/* swallow — the user will see uncommitted state until the next stroke */
|
|
697
|
+
});
|
|
698
|
+
}, 200);
|
|
699
|
+
}, []);
|
|
700
|
+
|
|
701
|
+
const setStrokes = useCallback(
|
|
702
|
+
(next: Stroke[]) => {
|
|
703
|
+
setStrokesState(next);
|
|
704
|
+
scheduleSave(next);
|
|
705
|
+
},
|
|
706
|
+
[scheduleSave]
|
|
707
|
+
);
|
|
708
|
+
|
|
709
|
+
const strokesStore = useMemo<StrokesStoreValue>(() => {
|
|
710
|
+
const updateStroke = (id: string, patch: Partial<Stroke>): void => {
|
|
711
|
+
setStrokesState((prev) => {
|
|
712
|
+
const next = prev.map((s) => (s.id === id ? ({ ...s, ...patch } as Stroke) : s));
|
|
713
|
+
scheduleSave(next);
|
|
714
|
+
return next;
|
|
715
|
+
});
|
|
716
|
+
};
|
|
717
|
+
const deleteStrokes = (ids: string[]): void => {
|
|
718
|
+
const set = new Set(ids);
|
|
719
|
+
setStrokesState((prev) => {
|
|
720
|
+
const next = prev.filter(
|
|
721
|
+
(s) => !set.has(s.id) && !(s.tool === 'text' && set.has(s.anchorId))
|
|
722
|
+
);
|
|
723
|
+
scheduleSave(next);
|
|
724
|
+
return next;
|
|
725
|
+
});
|
|
726
|
+
};
|
|
727
|
+
const translateStrokes = (ids: string[], dx: number, dy: number): void => {
|
|
728
|
+
const set = new Set(ids);
|
|
729
|
+
setStrokesState((prev) => {
|
|
730
|
+
const next = prev.map((s) => (set.has(s.id) ? translateOne(s, dx, dy) : s));
|
|
731
|
+
scheduleSave(next);
|
|
732
|
+
return next;
|
|
733
|
+
});
|
|
734
|
+
};
|
|
735
|
+
return {
|
|
736
|
+
strokes,
|
|
737
|
+
setStrokes,
|
|
738
|
+
updateStroke,
|
|
739
|
+
deleteStrokes,
|
|
740
|
+
translateStrokes,
|
|
741
|
+
};
|
|
742
|
+
}, [strokes, setStrokes, scheduleSave]);
|
|
743
|
+
|
|
744
|
+
// Menubar bridge (Phase 5.1 Task 10) — listen for postMessages from the
|
|
745
|
+
// dev-server shell. `selection-clear` + `tool-set` live in canvas-shell
|
|
746
|
+
// (those providers are above us); we own visibility + annotation-select-all
|
|
747
|
+
// because they read this layer's local state.
|
|
748
|
+
useEffect(() => {
|
|
749
|
+
if (typeof window === 'undefined') return;
|
|
750
|
+
const onMessage = (e: MessageEvent) => {
|
|
751
|
+
const m = e.data as { dgn?: string; visible?: boolean } | null;
|
|
752
|
+
if (!m || typeof m !== 'object' || !m.dgn) return;
|
|
753
|
+
if (m.dgn === 'view-annotations') {
|
|
754
|
+
if (typeof m.visible === 'boolean') setVisible(m.visible);
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
if (m.dgn === 'annotation-select-all') {
|
|
758
|
+
if (annotSel) annotSel.replace(strokes.map((s) => s.id));
|
|
759
|
+
}
|
|
760
|
+
};
|
|
761
|
+
window.addEventListener('message', onMessage);
|
|
762
|
+
return () => window.removeEventListener('message', onMessage);
|
|
763
|
+
}, [annotSel, strokes, setVisible]);
|
|
764
|
+
|
|
765
|
+
// Document-level toggle: Shift+P (presentation). Annotation-shortcut help is
|
|
766
|
+
// owned by the dev-server menubar (Help button); we no longer ship an
|
|
767
|
+
// in-canvas help dialog from this layer.
|
|
768
|
+
useEffect(() => {
|
|
769
|
+
if (typeof document === 'undefined') return;
|
|
770
|
+
const onKey = (e: KeyboardEvent) => {
|
|
771
|
+
if (isEditable(e.target)) return;
|
|
772
|
+
if (e.key === 'P' && e.shiftKey && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
|
773
|
+
e.preventDefault();
|
|
774
|
+
setVisible((v) => !v);
|
|
775
|
+
}
|
|
776
|
+
};
|
|
777
|
+
document.addEventListener('keydown', onKey, true);
|
|
778
|
+
return () => document.removeEventListener('keydown', onKey, true);
|
|
779
|
+
}, [setVisible]);
|
|
780
|
+
|
|
781
|
+
const screenToWorld = useCallback(
|
|
782
|
+
(cx: number, cy: number): [number, number] => {
|
|
783
|
+
const v = vp ?? { x: 0, y: 0, zoom: 1 };
|
|
784
|
+
const z = v.zoom || 1;
|
|
785
|
+
return [(cx - v.x) / z, (cy - v.y) / z];
|
|
786
|
+
},
|
|
787
|
+
[vp]
|
|
788
|
+
);
|
|
789
|
+
|
|
790
|
+
const eraseAt = useCallback(
|
|
791
|
+
(wx: number, wy: number) => {
|
|
792
|
+
const zoom = vp?.zoom || 1;
|
|
793
|
+
const tol = 8 / zoom;
|
|
794
|
+
setStrokesState((prev) => {
|
|
795
|
+
for (let i = prev.length - 1; i >= 0; i--) {
|
|
796
|
+
const candidate = prev[i];
|
|
797
|
+
if (candidate && strokeHitTest(candidate, wx, wy, tol)) {
|
|
798
|
+
const removedId = candidate.id;
|
|
799
|
+
const next = prev
|
|
800
|
+
.slice(0, i)
|
|
801
|
+
.concat(prev.slice(i + 1))
|
|
802
|
+
.filter((s) => !(s.tool === 'text' && s.anchorId === removedId));
|
|
803
|
+
scheduleSave(next);
|
|
804
|
+
return next;
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
return prev;
|
|
808
|
+
});
|
|
809
|
+
},
|
|
810
|
+
[vp, scheduleSave]
|
|
811
|
+
);
|
|
812
|
+
|
|
813
|
+
const beginStroke = useCallback(
|
|
814
|
+
(e: ReactPointerEvent<HTMLDivElement>, spaceHeld: boolean) => {
|
|
815
|
+
if (!isActive || !visible) return false;
|
|
816
|
+
if (e.button !== 0) return false;
|
|
817
|
+
if (spaceHeld) return false;
|
|
818
|
+
if (e.metaKey || e.ctrlKey) return false;
|
|
819
|
+
// We do NOT stopPropagation — viewport-controller listens on the host
|
|
820
|
+
// ancestor and never claims a bare-left/no-space pointerdown anyway.
|
|
821
|
+
e.preventDefault();
|
|
822
|
+
try {
|
|
823
|
+
(e.target as Element & { setPointerCapture?: (id: number) => void }).setPointerCapture?.(
|
|
824
|
+
e.pointerId
|
|
825
|
+
);
|
|
826
|
+
} catch {
|
|
827
|
+
/* some browsers reject capture on synthetic events */
|
|
828
|
+
}
|
|
829
|
+
const [wx, wy] = screenToWorld(e.clientX, e.clientY);
|
|
830
|
+
if (isErase) {
|
|
831
|
+
eraseAt(wx, wy);
|
|
832
|
+
return true;
|
|
833
|
+
}
|
|
834
|
+
const id = rid();
|
|
835
|
+
const width: number = supportsThickness ? thickness : STROKE_WIDTH_THIN;
|
|
836
|
+
const activeFill = supportsFill ? fill : null;
|
|
837
|
+
if (tool === 'pen') {
|
|
838
|
+
setDrawing({ id, tool: 'pen', color, width, points: [[wx, wy]] });
|
|
839
|
+
} else if (tool === 'rect') {
|
|
840
|
+
setDrawing({
|
|
841
|
+
id,
|
|
842
|
+
tool: 'rect',
|
|
843
|
+
color,
|
|
844
|
+
width: STROKE_WIDTH_THIN,
|
|
845
|
+
x: wx,
|
|
846
|
+
y: wy,
|
|
847
|
+
w: 0,
|
|
848
|
+
h: 0,
|
|
849
|
+
fill: activeFill,
|
|
850
|
+
});
|
|
851
|
+
} else if (tool === 'ellipse') {
|
|
852
|
+
setDrawing({
|
|
853
|
+
id,
|
|
854
|
+
tool: 'ellipse',
|
|
855
|
+
color,
|
|
856
|
+
width: STROKE_WIDTH_THIN,
|
|
857
|
+
cx: wx,
|
|
858
|
+
cy: wy,
|
|
859
|
+
rx: 0,
|
|
860
|
+
ry: 0,
|
|
861
|
+
fill: activeFill,
|
|
862
|
+
});
|
|
863
|
+
} else if (tool === 'arrow') {
|
|
864
|
+
setDrawing({
|
|
865
|
+
id,
|
|
866
|
+
tool: 'arrow',
|
|
867
|
+
color,
|
|
868
|
+
width,
|
|
869
|
+
x1: wx,
|
|
870
|
+
y1: wy,
|
|
871
|
+
x2: wx,
|
|
872
|
+
y2: wy,
|
|
873
|
+
});
|
|
874
|
+
}
|
|
875
|
+
return true;
|
|
876
|
+
},
|
|
877
|
+
[
|
|
878
|
+
tool,
|
|
879
|
+
color,
|
|
880
|
+
fill,
|
|
881
|
+
thickness,
|
|
882
|
+
supportsThickness,
|
|
883
|
+
supportsFill,
|
|
884
|
+
isActive,
|
|
885
|
+
isErase,
|
|
886
|
+
visible,
|
|
887
|
+
screenToWorld,
|
|
888
|
+
eraseAt,
|
|
889
|
+
]
|
|
890
|
+
);
|
|
891
|
+
|
|
892
|
+
const moveStroke = useCallback(
|
|
893
|
+
(e: ReactPointerEvent<HTMLDivElement>) => {
|
|
894
|
+
if (!isActive || !visible) return;
|
|
895
|
+
const [wx, wy] = screenToWorld(e.clientX, e.clientY);
|
|
896
|
+
if (isErase) {
|
|
897
|
+
if ((e.buttons & 1) === 0) return;
|
|
898
|
+
eraseAt(wx, wy);
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
setDrawing((cur) => {
|
|
902
|
+
if (!cur) return cur;
|
|
903
|
+
if (cur.tool === 'pen') {
|
|
904
|
+
const last = cur.points[cur.points.length - 1] as WorldPoint | undefined;
|
|
905
|
+
if (last && Math.hypot(wx - last[0], wy - last[1]) < 1) return cur;
|
|
906
|
+
return { ...cur, points: [...cur.points, [wx, wy] as WorldPoint] };
|
|
907
|
+
}
|
|
908
|
+
if (cur.tool === 'rect') {
|
|
909
|
+
return { ...cur, w: wx - cur.x, h: wy - cur.y };
|
|
910
|
+
}
|
|
911
|
+
if (cur.tool === 'ellipse') {
|
|
912
|
+
const rx = Math.abs(wx - cur.cx);
|
|
913
|
+
const ry = Math.abs(wy - cur.cy);
|
|
914
|
+
return { ...cur, rx, ry };
|
|
915
|
+
}
|
|
916
|
+
if (cur.tool === 'arrow') {
|
|
917
|
+
return { ...cur, x2: wx, y2: wy };
|
|
918
|
+
}
|
|
919
|
+
return cur;
|
|
920
|
+
});
|
|
921
|
+
},
|
|
922
|
+
[isActive, isErase, visible, screenToWorld, eraseAt]
|
|
923
|
+
);
|
|
924
|
+
|
|
925
|
+
const endStroke = useCallback(() => {
|
|
926
|
+
if (!isActive || !visible) return;
|
|
927
|
+
if (isErase) return;
|
|
928
|
+
const cur = drawingRef.current;
|
|
929
|
+
if (!cur) return;
|
|
930
|
+
let final: Stroke | null = cur;
|
|
931
|
+
if (cur.tool === 'rect') final = normalizeRect(cur);
|
|
932
|
+
if (final && !isStrokeMeaningful(final)) final = null;
|
|
933
|
+
if (final) {
|
|
934
|
+
const committed = final;
|
|
935
|
+
setStrokesState((prev) => {
|
|
936
|
+
const next = [...prev, committed];
|
|
937
|
+
scheduleSave(next);
|
|
938
|
+
return next;
|
|
939
|
+
});
|
|
940
|
+
}
|
|
941
|
+
setDrawing(null);
|
|
942
|
+
}, [isActive, isErase, visible, scheduleSave]);
|
|
943
|
+
|
|
944
|
+
const renderStrokes = useMemo(
|
|
945
|
+
() => (drawing ? [...strokes, drawing] : strokes),
|
|
946
|
+
[strokes, drawing]
|
|
947
|
+
);
|
|
948
|
+
|
|
949
|
+
const anchorsById = useMemo(() => {
|
|
950
|
+
const map = new Map<string, RectStroke | EllipseStroke>();
|
|
951
|
+
for (const s of strokes) {
|
|
952
|
+
if (s.tool === 'rect' || s.tool === 'ellipse') map.set(s.id, s);
|
|
953
|
+
}
|
|
954
|
+
return map;
|
|
955
|
+
}, [strokes]);
|
|
956
|
+
|
|
957
|
+
const strokesById = useMemo(() => {
|
|
958
|
+
const map = new Map<string, Stroke>();
|
|
959
|
+
for (const s of strokes) map.set(s.id, s);
|
|
960
|
+
return map;
|
|
961
|
+
}, [strokes]);
|
|
962
|
+
|
|
963
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
964
|
+
// Move-tool selection + drag (Phase 5.1 Tasks 6 + 7). Single doc-level
|
|
965
|
+
// capture pointerdown listener:
|
|
966
|
+
// - target is a stroke → select (replace, or add with Shift)
|
|
967
|
+
// - bare click on empty world → clear annotation selection
|
|
968
|
+
// - Cmd / Cmd+Shift falls through to element-selection (we bail).
|
|
969
|
+
// Once a stroke is selected, clicking inside its bbox starts a drag.
|
|
970
|
+
|
|
971
|
+
const dragStateRef = useRef<{
|
|
972
|
+
pointerId: number;
|
|
973
|
+
startWX: number;
|
|
974
|
+
startWY: number;
|
|
975
|
+
movedIds: string[];
|
|
976
|
+
} | null>(null);
|
|
977
|
+
|
|
978
|
+
// Drag-select marquee state. World-coord rectangle (anchor + cursor); the
|
|
979
|
+
// cursor end animates with pointermove. `null` = no marquee active.
|
|
980
|
+
const [marquee, setMarquee] = useState<{
|
|
981
|
+
ax: number;
|
|
982
|
+
ay: number;
|
|
983
|
+
bx: number;
|
|
984
|
+
by: number;
|
|
985
|
+
} | null>(null);
|
|
986
|
+
|
|
987
|
+
useEffect(() => {
|
|
988
|
+
if (typeof document === 'undefined') return;
|
|
989
|
+
if (tool !== 'move') return;
|
|
990
|
+
if (!annotSel) return;
|
|
991
|
+
|
|
992
|
+
const findStrokeId = (el: Element | null): string | null => {
|
|
993
|
+
const node = el?.closest?.('[data-id][data-tool]') ?? null;
|
|
994
|
+
const id = node?.getAttribute('data-id') ?? null;
|
|
995
|
+
const t = node?.getAttribute('data-tool') ?? null;
|
|
996
|
+
if (
|
|
997
|
+
id &&
|
|
998
|
+
t &&
|
|
999
|
+
(t === 'pen' || t === 'rect' || t === 'ellipse' || t === 'arrow' || t === 'text')
|
|
1000
|
+
) {
|
|
1001
|
+
return id;
|
|
1002
|
+
}
|
|
1003
|
+
return null;
|
|
1004
|
+
};
|
|
1005
|
+
|
|
1006
|
+
// Chrome elements never deselect. Includes the per-shape context toolbar,
|
|
1007
|
+
// the main tool palette, the in-canvas draw chrome, the minimap, and the
|
|
1008
|
+
// right-click menu. Clicks on these route to their own handlers.
|
|
1009
|
+
const CHROME_SELECTOR =
|
|
1010
|
+
'.dc-annot-ctx, .dc-tool-palette, .dc-annot-chrome, .dc-mm, .dc-context-menu, .dc-tp-popover';
|
|
1011
|
+
|
|
1012
|
+
const onDown = (e: PointerEvent) => {
|
|
1013
|
+
if (e.button !== 0) return;
|
|
1014
|
+
if (e.metaKey || e.ctrlKey) return; // escape hatch into element-selection
|
|
1015
|
+
const target = e.target as Element | null;
|
|
1016
|
+
if (target?.closest?.(CHROME_SELECTOR)) return; // chrome owns its clicks
|
|
1017
|
+
const strokeId = findStrokeId(target);
|
|
1018
|
+
const [wx, wy] = screenToWorld(e.clientX, e.clientY);
|
|
1019
|
+
const startClientX = e.clientX;
|
|
1020
|
+
const startClientY = e.clientY;
|
|
1021
|
+
|
|
1022
|
+
// Stroke hit — select + start drag-translate of the group ─────────
|
|
1023
|
+
if (strokeId) {
|
|
1024
|
+
e.preventDefault();
|
|
1025
|
+
e.stopImmediatePropagation();
|
|
1026
|
+
elementSel?.clear();
|
|
1027
|
+
let ids: string[];
|
|
1028
|
+
if (e.shiftKey) {
|
|
1029
|
+
annotSel.add(strokeId);
|
|
1030
|
+
ids = annotSel.contains(strokeId)
|
|
1031
|
+
? annotSel.selectedIds
|
|
1032
|
+
: [...annotSel.selectedIds, strokeId];
|
|
1033
|
+
} else if (annotSel.contains(strokeId)) {
|
|
1034
|
+
ids = annotSel.selectedIds;
|
|
1035
|
+
} else {
|
|
1036
|
+
annotSel.replace(strokeId);
|
|
1037
|
+
ids = [strokeId];
|
|
1038
|
+
}
|
|
1039
|
+
dragStateRef.current = {
|
|
1040
|
+
pointerId: e.pointerId,
|
|
1041
|
+
startWX: wx,
|
|
1042
|
+
startWY: wy,
|
|
1043
|
+
movedIds: ids,
|
|
1044
|
+
};
|
|
1045
|
+
const onMove = (mv: PointerEvent) => {
|
|
1046
|
+
const st = dragStateRef.current;
|
|
1047
|
+
if (!st || mv.pointerId !== st.pointerId) return;
|
|
1048
|
+
const [cwx, cwy] = screenToWorld(mv.clientX, mv.clientY);
|
|
1049
|
+
const dx = cwx - st.startWX;
|
|
1050
|
+
const dy = cwy - st.startWY;
|
|
1051
|
+
if (dx === 0 && dy === 0) return;
|
|
1052
|
+
strokesStore.translateStrokes(st.movedIds, dx, dy);
|
|
1053
|
+
st.startWX = cwx;
|
|
1054
|
+
st.startWY = cwy;
|
|
1055
|
+
};
|
|
1056
|
+
const onUp = (up: PointerEvent) => {
|
|
1057
|
+
if (up.pointerId !== dragStateRef.current?.pointerId) return;
|
|
1058
|
+
dragStateRef.current = null;
|
|
1059
|
+
document.removeEventListener('pointermove', onMove, true);
|
|
1060
|
+
document.removeEventListener('pointerup', onUp, true);
|
|
1061
|
+
document.removeEventListener('pointercancel', onUp, true);
|
|
1062
|
+
};
|
|
1063
|
+
document.addEventListener('pointermove', onMove, true);
|
|
1064
|
+
document.addEventListener('pointerup', onUp, true);
|
|
1065
|
+
document.addEventListener('pointercancel', onUp, true);
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
// Empty world — start a drag-select gesture. A small movement falls
|
|
1070
|
+
// back to a "click on empty world → clear selection" (Figma-style).
|
|
1071
|
+
const addToSelection = e.shiftKey;
|
|
1072
|
+
let moved = false;
|
|
1073
|
+
const onMove = (mv: PointerEvent) => {
|
|
1074
|
+
const distSq = (mv.clientX - startClientX) ** 2 + (mv.clientY - startClientY) ** 2;
|
|
1075
|
+
if (!moved && distSq < 16) return; // 4 px threshold
|
|
1076
|
+
moved = true;
|
|
1077
|
+
const [cwx, cwy] = screenToWorld(mv.clientX, mv.clientY);
|
|
1078
|
+
setMarquee({ ax: wx, ay: wy, bx: cwx, by: cwy });
|
|
1079
|
+
};
|
|
1080
|
+
const onUp = (_up: PointerEvent) => {
|
|
1081
|
+
document.removeEventListener('pointermove', onMove, true);
|
|
1082
|
+
document.removeEventListener('pointerup', onUp, true);
|
|
1083
|
+
document.removeEventListener('pointercancel', onUp, true);
|
|
1084
|
+
if (!moved) {
|
|
1085
|
+
// True click on empty world → clear (unless modifier add-mode).
|
|
1086
|
+
if (!addToSelection && annotSel.selectedIds.length > 0) annotSel.clear();
|
|
1087
|
+
return;
|
|
1088
|
+
}
|
|
1089
|
+
const final = marqueeRef.current;
|
|
1090
|
+
setMarquee(null);
|
|
1091
|
+
if (!final) return;
|
|
1092
|
+
const xMin = Math.min(final.ax, final.bx);
|
|
1093
|
+
const xMax = Math.max(final.ax, final.bx);
|
|
1094
|
+
const yMin = Math.min(final.ay, final.by);
|
|
1095
|
+
const yMax = Math.max(final.ay, final.by);
|
|
1096
|
+
const hits: string[] = [];
|
|
1097
|
+
for (const s of strokesStoreRef.current.strokes) {
|
|
1098
|
+
if (s.tool === 'text') continue; // text inherits its host's bbox
|
|
1099
|
+
const bb = strokeBBox(s);
|
|
1100
|
+
if (!bb) continue;
|
|
1101
|
+
if (bb.x + bb.w >= xMin && bb.x <= xMax && bb.y + bb.h >= yMin && bb.y <= yMax) {
|
|
1102
|
+
hits.push(s.id);
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
if (addToSelection) {
|
|
1106
|
+
annotSel.add(hits);
|
|
1107
|
+
} else if (hits.length === 0) {
|
|
1108
|
+
annotSel.clear();
|
|
1109
|
+
} else {
|
|
1110
|
+
annotSel.replace(hits);
|
|
1111
|
+
}
|
|
1112
|
+
};
|
|
1113
|
+
document.addEventListener('pointermove', onMove, true);
|
|
1114
|
+
document.addEventListener('pointerup', onUp, true);
|
|
1115
|
+
document.addEventListener('pointercancel', onUp, true);
|
|
1116
|
+
};
|
|
1117
|
+
|
|
1118
|
+
document.addEventListener('pointerdown', onDown, true);
|
|
1119
|
+
return () => document.removeEventListener('pointerdown', onDown, true);
|
|
1120
|
+
}, [tool, annotSel, elementSel, screenToWorld, strokesStore]);
|
|
1121
|
+
|
|
1122
|
+
// Latest marquee + strokes refs for the doc-level pointerup callback
|
|
1123
|
+
// (avoids re-binding the listener on every state tick).
|
|
1124
|
+
const marqueeRef = useRef(marquee);
|
|
1125
|
+
marqueeRef.current = marquee;
|
|
1126
|
+
const strokesStoreRef = useRef(strokesStore);
|
|
1127
|
+
strokesStoreRef.current = strokesStore;
|
|
1128
|
+
|
|
1129
|
+
// Double-click on a selected rect/ellipse enters text-edit mode.
|
|
1130
|
+
useEffect(() => {
|
|
1131
|
+
if (typeof document === 'undefined') return;
|
|
1132
|
+
if (tool !== 'move') return;
|
|
1133
|
+
const onDbl = (e: MouseEvent) => {
|
|
1134
|
+
const target = e.target as Element | null;
|
|
1135
|
+
const node = target?.closest?.('[data-id][data-tool]');
|
|
1136
|
+
if (!node) return;
|
|
1137
|
+
const id = node.getAttribute('data-id');
|
|
1138
|
+
const t = node.getAttribute('data-tool');
|
|
1139
|
+
if (id && (t === 'rect' || t === 'ellipse')) {
|
|
1140
|
+
e.preventDefault();
|
|
1141
|
+
setEditingId(id);
|
|
1142
|
+
}
|
|
1143
|
+
};
|
|
1144
|
+
document.addEventListener('dblclick', onDbl, true);
|
|
1145
|
+
return () => document.removeEventListener('dblclick', onDbl, true);
|
|
1146
|
+
}, [tool]);
|
|
1147
|
+
|
|
1148
|
+
const commitText = useCallback(
|
|
1149
|
+
(anchorId: string, text: string) => {
|
|
1150
|
+
const trimmed = text.trim();
|
|
1151
|
+
setStrokesState((prev) => {
|
|
1152
|
+
const existing = prev.find((s) => s.tool === 'text' && s.anchorId === anchorId) as
|
|
1153
|
+
| TextStroke
|
|
1154
|
+
| undefined;
|
|
1155
|
+
let next: Stroke[];
|
|
1156
|
+
if (trimmed.length === 0) {
|
|
1157
|
+
next = existing ? prev.filter((s) => s.id !== existing.id) : prev;
|
|
1158
|
+
} else if (existing) {
|
|
1159
|
+
next = prev.map((s) => (s.id === existing.id ? { ...existing, text: trimmed } : s));
|
|
1160
|
+
} else {
|
|
1161
|
+
next = [
|
|
1162
|
+
...prev,
|
|
1163
|
+
{
|
|
1164
|
+
id: rid(),
|
|
1165
|
+
tool: 'text',
|
|
1166
|
+
color: '#1a1a1a',
|
|
1167
|
+
fontSize: DEFAULT_FONT_SIZE,
|
|
1168
|
+
text: trimmed,
|
|
1169
|
+
anchorId,
|
|
1170
|
+
} as TextStroke,
|
|
1171
|
+
];
|
|
1172
|
+
}
|
|
1173
|
+
scheduleSave(next);
|
|
1174
|
+
return next;
|
|
1175
|
+
});
|
|
1176
|
+
},
|
|
1177
|
+
[scheduleSave]
|
|
1178
|
+
);
|
|
1179
|
+
|
|
1180
|
+
// Keyboard: arrow nudge + Backspace/Delete remove selected strokes.
|
|
1181
|
+
useEffect(() => {
|
|
1182
|
+
if (typeof document === 'undefined') return;
|
|
1183
|
+
if (!annotSel) return;
|
|
1184
|
+
const onKey = (e: KeyboardEvent) => {
|
|
1185
|
+
if (isEditable(e.target)) return;
|
|
1186
|
+
if (annotSel.selectedIds.length === 0) return;
|
|
1187
|
+
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
|
1188
|
+
const step = e.shiftKey ? 10 : 1;
|
|
1189
|
+
if (e.key === 'ArrowLeft') {
|
|
1190
|
+
e.preventDefault();
|
|
1191
|
+
strokesStore.translateStrokes(annotSel.selectedIds, -step, 0);
|
|
1192
|
+
return;
|
|
1193
|
+
}
|
|
1194
|
+
if (e.key === 'ArrowRight') {
|
|
1195
|
+
e.preventDefault();
|
|
1196
|
+
strokesStore.translateStrokes(annotSel.selectedIds, step, 0);
|
|
1197
|
+
return;
|
|
1198
|
+
}
|
|
1199
|
+
if (e.key === 'ArrowUp') {
|
|
1200
|
+
e.preventDefault();
|
|
1201
|
+
strokesStore.translateStrokes(annotSel.selectedIds, 0, -step);
|
|
1202
|
+
return;
|
|
1203
|
+
}
|
|
1204
|
+
if (e.key === 'ArrowDown') {
|
|
1205
|
+
e.preventDefault();
|
|
1206
|
+
strokesStore.translateStrokes(annotSel.selectedIds, 0, step);
|
|
1207
|
+
return;
|
|
1208
|
+
}
|
|
1209
|
+
if (e.key === 'Backspace' || e.key === 'Delete') {
|
|
1210
|
+
e.preventDefault();
|
|
1211
|
+
strokesStore.deleteStrokes(annotSel.selectedIds);
|
|
1212
|
+
annotSel.clear();
|
|
1213
|
+
}
|
|
1214
|
+
};
|
|
1215
|
+
document.addEventListener('keydown', onKey, true);
|
|
1216
|
+
return () => document.removeEventListener('keydown', onKey, true);
|
|
1217
|
+
}, [annotSel, strokesStore]);
|
|
1218
|
+
|
|
1219
|
+
// Selected stroke halos — bboxes in world coords, vector-effect non-scaling-stroke.
|
|
1220
|
+
const selectedStrokes = useMemo(() => {
|
|
1221
|
+
if (!annotSel || annotSel.selectedIds.length === 0) return [] as Stroke[];
|
|
1222
|
+
const out: Stroke[] = [];
|
|
1223
|
+
for (const id of annotSel.selectedIds) {
|
|
1224
|
+
const s = strokesById.get(id);
|
|
1225
|
+
if (s) out.push(s);
|
|
1226
|
+
}
|
|
1227
|
+
return out;
|
|
1228
|
+
}, [annotSel, strokesById]);
|
|
1229
|
+
|
|
1230
|
+
return (
|
|
1231
|
+
<StrokesStoreContext.Provider value={strokesStore}>
|
|
1232
|
+
<>
|
|
1233
|
+
<AnnotationsInput
|
|
1234
|
+
isActive={isActive}
|
|
1235
|
+
visible={visible}
|
|
1236
|
+
beginStroke={beginStroke}
|
|
1237
|
+
moveStroke={moveStroke}
|
|
1238
|
+
endStroke={endStroke}
|
|
1239
|
+
/>
|
|
1240
|
+
{visible ? (
|
|
1241
|
+
<AnnotationsSvg
|
|
1242
|
+
worldRef={worldRef}
|
|
1243
|
+
strokes={renderStrokes}
|
|
1244
|
+
anchorsById={anchorsById}
|
|
1245
|
+
selectMode={tool === 'move'}
|
|
1246
|
+
selectedStrokes={selectedStrokes}
|
|
1247
|
+
marquee={marquee}
|
|
1248
|
+
editingId={editingId}
|
|
1249
|
+
existingTextFor={(anchorId) =>
|
|
1250
|
+
strokes.find((s) => s.tool === 'text' && s.anchorId === anchorId) as
|
|
1251
|
+
| TextStroke
|
|
1252
|
+
| undefined
|
|
1253
|
+
}
|
|
1254
|
+
onCommitText={(anchorId, text) => {
|
|
1255
|
+
commitText(anchorId, text);
|
|
1256
|
+
setEditingId(null);
|
|
1257
|
+
}}
|
|
1258
|
+
onCancelEdit={() => setEditingId(null)}
|
|
1259
|
+
/>
|
|
1260
|
+
) : null}
|
|
1261
|
+
<AnnotationContextToolbar />
|
|
1262
|
+
{isActive ? (
|
|
1263
|
+
<AnnotationsChrome
|
|
1264
|
+
color={color}
|
|
1265
|
+
setColor={setColor}
|
|
1266
|
+
supportsFill={supportsFill}
|
|
1267
|
+
fill={fill}
|
|
1268
|
+
setFill={setFill}
|
|
1269
|
+
supportsThickness={supportsThickness}
|
|
1270
|
+
thickness={thickness}
|
|
1271
|
+
setThickness={setThickness}
|
|
1272
|
+
/>
|
|
1273
|
+
) : null}
|
|
1274
|
+
</>
|
|
1275
|
+
</StrokesStoreContext.Provider>
|
|
1276
|
+
);
|
|
1277
|
+
}
|
|
1278
|
+
AnnotationsLayer.displayName = 'AnnotationsLayer';
|
|
1279
|
+
|
|
1280
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1281
|
+
// Input — transparent overlay portaled into the host (.dc-canvas). Receives
|
|
1282
|
+
// pointer events for draw / erase ONLY; viewport gestures (middle-mouse,
|
|
1283
|
+
// space-pan, wheel) reach `useViewportController` because we never call
|
|
1284
|
+
// stopPropagation and the controller listens at the host level alongside us.
|
|
1285
|
+
|
|
1286
|
+
function AnnotationsInput({
|
|
1287
|
+
isActive,
|
|
1288
|
+
visible,
|
|
1289
|
+
beginStroke,
|
|
1290
|
+
moveStroke,
|
|
1291
|
+
endStroke,
|
|
1292
|
+
}: {
|
|
1293
|
+
isActive: boolean;
|
|
1294
|
+
visible: boolean;
|
|
1295
|
+
beginStroke: (e: ReactPointerEvent<HTMLDivElement>, spaceHeld: boolean) => boolean;
|
|
1296
|
+
moveStroke: (e: ReactPointerEvent<HTMLDivElement>) => void;
|
|
1297
|
+
endStroke: () => void;
|
|
1298
|
+
}) {
|
|
1299
|
+
const worldRef = useWorldRefContext();
|
|
1300
|
+
const host = worldRef?.current?.parentElement ?? null;
|
|
1301
|
+
const [, force] = useState({});
|
|
1302
|
+
// Host may not be attached on first commit; nudge a re-render once it is.
|
|
1303
|
+
useEffect(() => {
|
|
1304
|
+
if (host) return;
|
|
1305
|
+
const id = setTimeout(() => force({}), 0);
|
|
1306
|
+
return () => clearTimeout(id);
|
|
1307
|
+
}, [host]);
|
|
1308
|
+
|
|
1309
|
+
const spaceHeldRef = useRef(false);
|
|
1310
|
+
useEffect(() => {
|
|
1311
|
+
if (typeof document === 'undefined') return;
|
|
1312
|
+
const down = (e: KeyboardEvent) => {
|
|
1313
|
+
if (e.code === 'Space' && !isEditable(e.target)) spaceHeldRef.current = true;
|
|
1314
|
+
};
|
|
1315
|
+
const up = (e: KeyboardEvent) => {
|
|
1316
|
+
if (e.code === 'Space') spaceHeldRef.current = false;
|
|
1317
|
+
};
|
|
1318
|
+
document.addEventListener('keydown', down, true);
|
|
1319
|
+
document.addEventListener('keyup', up, true);
|
|
1320
|
+
return () => {
|
|
1321
|
+
document.removeEventListener('keydown', down, true);
|
|
1322
|
+
document.removeEventListener('keyup', up, true);
|
|
1323
|
+
};
|
|
1324
|
+
}, []);
|
|
1325
|
+
|
|
1326
|
+
if (!host) return null;
|
|
1327
|
+
const interactive = isActive && visible;
|
|
1328
|
+
return createPortal(
|
|
1329
|
+
<div
|
|
1330
|
+
className="dc-annot-input"
|
|
1331
|
+
aria-hidden="true"
|
|
1332
|
+
style={{ pointerEvents: interactive ? 'auto' : 'none' }}
|
|
1333
|
+
onPointerDown={(e) => {
|
|
1334
|
+
beginStroke(e, spaceHeldRef.current);
|
|
1335
|
+
}}
|
|
1336
|
+
onPointerMove={moveStroke}
|
|
1337
|
+
onPointerUp={endStroke}
|
|
1338
|
+
onPointerCancel={endStroke}
|
|
1339
|
+
/>,
|
|
1340
|
+
host
|
|
1341
|
+
);
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1345
|
+
// SVG — portaled INTO `.dc-world` so the world's CSS zoom + translate apply
|
|
1346
|
+
// natively. `vector-effect="non-scaling-stroke"` keeps stroke px-thick at any
|
|
1347
|
+
// zoom level. `pointer-events: none` on the container — strokes are decorative
|
|
1348
|
+
// for now (Phase 5.1 Task 6 will reintroduce hit-test via the selection store).
|
|
1349
|
+
|
|
1350
|
+
function AnnotationsSvg({
|
|
1351
|
+
worldRef,
|
|
1352
|
+
strokes,
|
|
1353
|
+
anchorsById,
|
|
1354
|
+
selectMode,
|
|
1355
|
+
selectedStrokes,
|
|
1356
|
+
marquee,
|
|
1357
|
+
editingId,
|
|
1358
|
+
existingTextFor,
|
|
1359
|
+
onCommitText,
|
|
1360
|
+
onCancelEdit,
|
|
1361
|
+
}: {
|
|
1362
|
+
worldRef: ReturnType<typeof useWorldRefContext>;
|
|
1363
|
+
strokes: readonly Stroke[];
|
|
1364
|
+
anchorsById: Map<string, RectStroke | EllipseStroke>;
|
|
1365
|
+
selectMode: boolean;
|
|
1366
|
+
selectedStrokes: readonly Stroke[];
|
|
1367
|
+
marquee: { ax: number; ay: number; bx: number; by: number } | null;
|
|
1368
|
+
editingId: string | null;
|
|
1369
|
+
existingTextFor: (anchorId: string) => TextStroke | undefined;
|
|
1370
|
+
onCommitText: (anchorId: string, text: string) => void;
|
|
1371
|
+
onCancelEdit: () => void;
|
|
1372
|
+
}) {
|
|
1373
|
+
const [, force] = useState({});
|
|
1374
|
+
useEffect(() => {
|
|
1375
|
+
if (worldRef?.current) return;
|
|
1376
|
+
const id = setTimeout(() => force({}), 0);
|
|
1377
|
+
return () => clearTimeout(id);
|
|
1378
|
+
}, [worldRef]);
|
|
1379
|
+
const target = worldRef?.current ?? null;
|
|
1380
|
+
if (!target) return null;
|
|
1381
|
+
return createPortal(
|
|
1382
|
+
<svg className="dc-annot-svg" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
|
|
1383
|
+
{strokes.map((s) => (
|
|
1384
|
+
<StrokeNode key={s.id} stroke={s} anchorsById={anchorsById} interactive={selectMode} />
|
|
1385
|
+
))}
|
|
1386
|
+
{selectedStrokes.map((s) => (
|
|
1387
|
+
<SelectionHalo key={`halo-${s.id}`} stroke={s} anchorsById={anchorsById} />
|
|
1388
|
+
))}
|
|
1389
|
+
{marquee ? (
|
|
1390
|
+
<rect
|
|
1391
|
+
className="dc-annot-marquee"
|
|
1392
|
+
x={Math.min(marquee.ax, marquee.bx)}
|
|
1393
|
+
y={Math.min(marquee.ay, marquee.by)}
|
|
1394
|
+
width={Math.abs(marquee.bx - marquee.ax)}
|
|
1395
|
+
height={Math.abs(marquee.by - marquee.ay)}
|
|
1396
|
+
vectorEffect="non-scaling-stroke"
|
|
1397
|
+
/>
|
|
1398
|
+
) : null}
|
|
1399
|
+
{editingId ? (
|
|
1400
|
+
<TextEditor
|
|
1401
|
+
anchorId={editingId}
|
|
1402
|
+
host={anchorsById.get(editingId) ?? null}
|
|
1403
|
+
existing={existingTextFor(editingId)}
|
|
1404
|
+
onCommit={onCommitText}
|
|
1405
|
+
onCancel={onCancelEdit}
|
|
1406
|
+
/>
|
|
1407
|
+
) : null}
|
|
1408
|
+
</svg>,
|
|
1409
|
+
target
|
|
1410
|
+
);
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
function TextEditor({
|
|
1414
|
+
anchorId,
|
|
1415
|
+
host,
|
|
1416
|
+
existing,
|
|
1417
|
+
onCommit,
|
|
1418
|
+
onCancel,
|
|
1419
|
+
}: {
|
|
1420
|
+
anchorId: string;
|
|
1421
|
+
host: RectStroke | EllipseStroke | null;
|
|
1422
|
+
existing: TextStroke | undefined;
|
|
1423
|
+
onCommit: (anchorId: string, text: string) => void;
|
|
1424
|
+
onCancel: () => void;
|
|
1425
|
+
}) {
|
|
1426
|
+
const ref = useRef<HTMLDivElement | null>(null);
|
|
1427
|
+
const initial = existing?.text ?? '';
|
|
1428
|
+
const initialRef = useRef(initial);
|
|
1429
|
+
initialRef.current = initial;
|
|
1430
|
+
|
|
1431
|
+
useEffect(() => {
|
|
1432
|
+
const el = ref.current;
|
|
1433
|
+
if (!el) return;
|
|
1434
|
+
el.focus();
|
|
1435
|
+
// Select all so a re-edit replaces existing text easily.
|
|
1436
|
+
try {
|
|
1437
|
+
const r = document.createRange();
|
|
1438
|
+
r.selectNodeContents(el);
|
|
1439
|
+
const sel = window.getSelection();
|
|
1440
|
+
if (sel) {
|
|
1441
|
+
sel.removeAllRanges();
|
|
1442
|
+
sel.addRange(r);
|
|
1443
|
+
}
|
|
1444
|
+
} catch {
|
|
1445
|
+
/* selection API blocked */
|
|
1446
|
+
}
|
|
1447
|
+
}, []);
|
|
1448
|
+
|
|
1449
|
+
// Commit on outside click; cancel-on-Esc handled in onKeyDown below.
|
|
1450
|
+
useEffect(() => {
|
|
1451
|
+
if (typeof document === 'undefined') return;
|
|
1452
|
+
const onDown = (e: PointerEvent) => {
|
|
1453
|
+
const el = ref.current;
|
|
1454
|
+
if (!el) return;
|
|
1455
|
+
if (el.contains(e.target as Node)) return;
|
|
1456
|
+
onCommit(anchorId, el.innerText || '');
|
|
1457
|
+
};
|
|
1458
|
+
document.addEventListener('pointerdown', onDown, true);
|
|
1459
|
+
return () => document.removeEventListener('pointerdown', onDown, true);
|
|
1460
|
+
}, [anchorId, onCommit]);
|
|
1461
|
+
|
|
1462
|
+
if (!host) return null;
|
|
1463
|
+
const bbox = strokeBBox(host);
|
|
1464
|
+
if (!bbox) return null;
|
|
1465
|
+
const fontSize = existing?.fontSize ?? DEFAULT_FONT_SIZE;
|
|
1466
|
+
return (
|
|
1467
|
+
<foreignObject x={bbox.x} y={bbox.y} width={Math.max(20, bbox.w)} height={Math.max(20, bbox.h)}>
|
|
1468
|
+
<div
|
|
1469
|
+
ref={ref}
|
|
1470
|
+
contentEditable
|
|
1471
|
+
suppressContentEditableWarning
|
|
1472
|
+
aria-label="Edit annotation text"
|
|
1473
|
+
style={{
|
|
1474
|
+
width: '100%',
|
|
1475
|
+
height: '100%',
|
|
1476
|
+
display: 'flex',
|
|
1477
|
+
alignItems: 'center',
|
|
1478
|
+
justifyContent: 'center',
|
|
1479
|
+
padding: '0 8px',
|
|
1480
|
+
boxSizing: 'border-box',
|
|
1481
|
+
textAlign: 'center',
|
|
1482
|
+
color: existing?.color ?? '#1a1a1a',
|
|
1483
|
+
fontSize: `${fontSize}px`,
|
|
1484
|
+
fontFamily: 'ui-sans-serif, system-ui, sans-serif',
|
|
1485
|
+
lineHeight: 1.25,
|
|
1486
|
+
outline: 'none',
|
|
1487
|
+
background: 'transparent',
|
|
1488
|
+
cursor: 'text',
|
|
1489
|
+
}}
|
|
1490
|
+
onKeyDown={(e) => {
|
|
1491
|
+
if (e.key === 'Escape') {
|
|
1492
|
+
e.preventDefault();
|
|
1493
|
+
onCancel();
|
|
1494
|
+
return;
|
|
1495
|
+
}
|
|
1496
|
+
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
|
1497
|
+
e.preventDefault();
|
|
1498
|
+
const el = ref.current;
|
|
1499
|
+
onCommit(anchorId, el?.innerText || '');
|
|
1500
|
+
}
|
|
1501
|
+
}}
|
|
1502
|
+
>
|
|
1503
|
+
{initial}
|
|
1504
|
+
</div>
|
|
1505
|
+
</foreignObject>
|
|
1506
|
+
);
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
function SelectionHalo({
|
|
1510
|
+
stroke,
|
|
1511
|
+
anchorsById,
|
|
1512
|
+
}: {
|
|
1513
|
+
stroke: Stroke;
|
|
1514
|
+
anchorsById: Map<string, RectStroke | EllipseStroke>;
|
|
1515
|
+
}) {
|
|
1516
|
+
const bbox = strokeBBox(stroke, anchorsById);
|
|
1517
|
+
if (!bbox) return null;
|
|
1518
|
+
const pad = 4;
|
|
1519
|
+
return (
|
|
1520
|
+
<rect
|
|
1521
|
+
x={bbox.x - pad}
|
|
1522
|
+
y={bbox.y - pad}
|
|
1523
|
+
width={bbox.w + pad * 2}
|
|
1524
|
+
height={bbox.h + pad * 2}
|
|
1525
|
+
fill="none"
|
|
1526
|
+
stroke="var(--accent, #d63b1f)"
|
|
1527
|
+
strokeWidth={1.5}
|
|
1528
|
+
strokeDasharray="4 3"
|
|
1529
|
+
vectorEffect="non-scaling-stroke"
|
|
1530
|
+
pointerEvents="none"
|
|
1531
|
+
rx={2}
|
|
1532
|
+
/>
|
|
1533
|
+
);
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1537
|
+
// Stroke renderer
|
|
1538
|
+
|
|
1539
|
+
function StrokeNode({
|
|
1540
|
+
stroke,
|
|
1541
|
+
anchorsById,
|
|
1542
|
+
interactive,
|
|
1543
|
+
}: {
|
|
1544
|
+
stroke: Stroke;
|
|
1545
|
+
anchorsById: Map<string, RectStroke | EllipseStroke>;
|
|
1546
|
+
interactive: boolean;
|
|
1547
|
+
}) {
|
|
1548
|
+
// In Move mode, individual stroke nodes claim pointer events so we can
|
|
1549
|
+
// hit-test them from the doc-level capture listener. In draw mode the
|
|
1550
|
+
// overlay above handles input, so the strokes themselves stay inert.
|
|
1551
|
+
const hitMode = interactive ? 'visiblePainted' : ('none' as const);
|
|
1552
|
+
const strokeHit = interactive ? 'stroke' : ('none' as const);
|
|
1553
|
+
if (stroke.tool === 'text') {
|
|
1554
|
+
const host = anchorsById.get(stroke.anchorId);
|
|
1555
|
+
const bbox = host ? strokeBBox(host) : null;
|
|
1556
|
+
if (!bbox) return null;
|
|
1557
|
+
const cx = bbox.x + bbox.w / 2;
|
|
1558
|
+
const cy = bbox.y + bbox.h / 2;
|
|
1559
|
+
return (
|
|
1560
|
+
<text
|
|
1561
|
+
data-id={stroke.id}
|
|
1562
|
+
data-tool="text"
|
|
1563
|
+
data-anchor-id={stroke.anchorId}
|
|
1564
|
+
data-font-size={stroke.fontSize}
|
|
1565
|
+
x={cx}
|
|
1566
|
+
y={cy}
|
|
1567
|
+
fill={stroke.color}
|
|
1568
|
+
fontSize={stroke.fontSize}
|
|
1569
|
+
textAnchor="middle"
|
|
1570
|
+
dominantBaseline="middle"
|
|
1571
|
+
style={{ fontFamily: 'ui-sans-serif, system-ui, sans-serif' }}
|
|
1572
|
+
>
|
|
1573
|
+
{stroke.text}
|
|
1574
|
+
</text>
|
|
1575
|
+
);
|
|
1576
|
+
}
|
|
1577
|
+
const common = {
|
|
1578
|
+
'data-id': stroke.id,
|
|
1579
|
+
'data-tool': stroke.tool,
|
|
1580
|
+
stroke: stroke.color,
|
|
1581
|
+
strokeWidth: stroke.width,
|
|
1582
|
+
strokeLinecap: 'round' as const,
|
|
1583
|
+
strokeLinejoin: 'round' as const,
|
|
1584
|
+
vectorEffect: 'non-scaling-stroke' as const,
|
|
1585
|
+
};
|
|
1586
|
+
if (stroke.tool === 'pen') {
|
|
1587
|
+
return <path {...common} fill="none" d={penPathD(stroke.points)} pointerEvents={strokeHit} />;
|
|
1588
|
+
}
|
|
1589
|
+
if (stroke.tool === 'rect') {
|
|
1590
|
+
const x = Math.min(stroke.x, stroke.x + stroke.w);
|
|
1591
|
+
const y = Math.min(stroke.y, stroke.y + stroke.h);
|
|
1592
|
+
return (
|
|
1593
|
+
<rect
|
|
1594
|
+
{...common}
|
|
1595
|
+
fill={stroke.fill ?? 'none'}
|
|
1596
|
+
x={x}
|
|
1597
|
+
y={y}
|
|
1598
|
+
width={Math.abs(stroke.w)}
|
|
1599
|
+
height={Math.abs(stroke.h)}
|
|
1600
|
+
pointerEvents={hitMode}
|
|
1601
|
+
/>
|
|
1602
|
+
);
|
|
1603
|
+
}
|
|
1604
|
+
if (stroke.tool === 'ellipse') {
|
|
1605
|
+
return (
|
|
1606
|
+
<ellipse
|
|
1607
|
+
{...common}
|
|
1608
|
+
fill={stroke.fill ?? 'none'}
|
|
1609
|
+
cx={stroke.cx}
|
|
1610
|
+
cy={stroke.cy}
|
|
1611
|
+
rx={Math.max(0, stroke.rx)}
|
|
1612
|
+
ry={Math.max(0, stroke.ry)}
|
|
1613
|
+
pointerEvents={hitMode}
|
|
1614
|
+
/>
|
|
1615
|
+
);
|
|
1616
|
+
}
|
|
1617
|
+
const head = arrowHeadPoints(stroke.x1, stroke.y1, stroke.x2, stroke.y2, stroke.width);
|
|
1618
|
+
return (
|
|
1619
|
+
<g {...common} fill="none" pointerEvents={hitMode}>
|
|
1620
|
+
<line x1={stroke.x1} y1={stroke.y1} x2={stroke.x2} y2={stroke.y2} />
|
|
1621
|
+
<polyline points={head} fill={stroke.color} />
|
|
1622
|
+
</g>
|
|
1623
|
+
);
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1627
|
+
// Chrome — color swatches + (optional fill picker) + (optional thickness chip)
|
|
1628
|
+
// + presentation toggle + help button.
|
|
1629
|
+
|
|
1630
|
+
function AnnotationsChrome({
|
|
1631
|
+
color,
|
|
1632
|
+
setColor,
|
|
1633
|
+
supportsFill,
|
|
1634
|
+
fill,
|
|
1635
|
+
setFill,
|
|
1636
|
+
supportsThickness,
|
|
1637
|
+
thickness,
|
|
1638
|
+
setThickness,
|
|
1639
|
+
}: {
|
|
1640
|
+
color: string;
|
|
1641
|
+
setColor: (c: string) => void;
|
|
1642
|
+
supportsFill: boolean;
|
|
1643
|
+
fill: string | null;
|
|
1644
|
+
setFill: (f: string | null) => void;
|
|
1645
|
+
supportsThickness: boolean;
|
|
1646
|
+
thickness: Thickness;
|
|
1647
|
+
setThickness: (t: Thickness) => void;
|
|
1648
|
+
}) {
|
|
1649
|
+
return (
|
|
1650
|
+
<div className="dc-annot-chrome" role="toolbar" aria-label="Annotation tools">
|
|
1651
|
+
<div className="dc-annot-swatches" role="radiogroup" aria-label="Stroke color">
|
|
1652
|
+
{PALETTE.map((c) => (
|
|
1653
|
+
<button
|
|
1654
|
+
key={c}
|
|
1655
|
+
type="button"
|
|
1656
|
+
className="dc-annot-sw"
|
|
1657
|
+
aria-pressed={c === color}
|
|
1658
|
+
aria-label={`Color ${c}`}
|
|
1659
|
+
title={`Color ${c}`}
|
|
1660
|
+
style={{ background: c }}
|
|
1661
|
+
onClick={() => setColor(c)}
|
|
1662
|
+
/>
|
|
1663
|
+
))}
|
|
1664
|
+
</div>
|
|
1665
|
+
{supportsFill ? (
|
|
1666
|
+
<>
|
|
1667
|
+
<div className="dc-annot-sep" />
|
|
1668
|
+
<div className="dc-annot-swatches" role="radiogroup" aria-label="Fill color">
|
|
1669
|
+
<button
|
|
1670
|
+
type="button"
|
|
1671
|
+
className="dc-annot-fill dc-annot-fill--none"
|
|
1672
|
+
aria-pressed={fill == null}
|
|
1673
|
+
aria-label="No fill"
|
|
1674
|
+
title="No fill"
|
|
1675
|
+
onClick={() => setFill(null)}
|
|
1676
|
+
/>
|
|
1677
|
+
{FILL_PALETTE.map((c) => (
|
|
1678
|
+
<button
|
|
1679
|
+
key={c}
|
|
1680
|
+
type="button"
|
|
1681
|
+
className="dc-annot-fill"
|
|
1682
|
+
aria-pressed={c === fill}
|
|
1683
|
+
aria-label={`Fill ${c}`}
|
|
1684
|
+
title={`Fill ${c}`}
|
|
1685
|
+
style={{ background: c }}
|
|
1686
|
+
onClick={() => setFill(c)}
|
|
1687
|
+
/>
|
|
1688
|
+
))}
|
|
1689
|
+
</div>
|
|
1690
|
+
</>
|
|
1691
|
+
) : null}
|
|
1692
|
+
{supportsThickness ? (
|
|
1693
|
+
<>
|
|
1694
|
+
<div className="dc-annot-sep" />
|
|
1695
|
+
<button
|
|
1696
|
+
type="button"
|
|
1697
|
+
className="dc-annot-btn"
|
|
1698
|
+
aria-pressed={thickness === STROKE_WIDTH_THIN}
|
|
1699
|
+
title="Thin (2px)"
|
|
1700
|
+
onClick={() => setThickness(STROKE_WIDTH_THIN)}
|
|
1701
|
+
>
|
|
1702
|
+
Thin
|
|
1703
|
+
</button>
|
|
1704
|
+
<button
|
|
1705
|
+
type="button"
|
|
1706
|
+
className="dc-annot-btn"
|
|
1707
|
+
aria-pressed={thickness === STROKE_WIDTH_THICK}
|
|
1708
|
+
title="Thick (6px)"
|
|
1709
|
+
onClick={() => setThickness(STROKE_WIDTH_THICK)}
|
|
1710
|
+
>
|
|
1711
|
+
Thick
|
|
1712
|
+
</button>
|
|
1713
|
+
</>
|
|
1714
|
+
) : null}
|
|
1715
|
+
</div>
|
|
1716
|
+
);
|
|
1717
|
+
}
|