@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.
Files changed (211) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +166 -0
  3. package/cli/bin/maude.exe +15 -0
  4. package/cli/bin/maude.mjs +45 -0
  5. package/cli/bin/mdcc.exe +10 -0
  6. package/cli/bin/mdcc.mjs +7 -0
  7. package/cli/cli-wrapper.cjs +67 -0
  8. package/cli/commands/config.mjs +94 -0
  9. package/cli/commands/design.mjs +386 -0
  10. package/cli/commands/help.mjs +57 -0
  11. package/cli/commands/init.mjs +178 -0
  12. package/cli/commands/version.mjs +7 -0
  13. package/cli/install.cjs +113 -0
  14. package/cli/lib/argv.mjs +37 -0
  15. package/cli/lib/argv.test.mjs +46 -0
  16. package/cli/lib/copy-tree.mjs +78 -0
  17. package/package.json +94 -0
  18. package/plugins/design/dev-server/annotations-context-toolbar.tsx +397 -0
  19. package/plugins/design/dev-server/annotations-layer.tsx +1717 -0
  20. package/plugins/design/dev-server/api.ts +674 -0
  21. package/plugins/design/dev-server/bin/_screenshot-playwright.mjs +50 -0
  22. package/plugins/design/dev-server/bin/bootstrap-check.sh +83 -0
  23. package/plugins/design/dev-server/bin/canvas-edit.sh +48 -0
  24. package/plugins/design/dev-server/bin/handoff.sh +27 -0
  25. package/plugins/design/dev-server/bin/screenshot.sh +232 -0
  26. package/plugins/design/dev-server/bin/server-up.sh +135 -0
  27. package/plugins/design/dev-server/bin/slug.sh +22 -0
  28. package/plugins/design/dev-server/bin/smoke.sh +272 -0
  29. package/plugins/design/dev-server/build.ts +267 -0
  30. package/plugins/design/dev-server/canvas-build.ts +219 -0
  31. package/plugins/design/dev-server/canvas-edit.ts +388 -0
  32. package/plugins/design/dev-server/canvas-header.ts +165 -0
  33. package/plugins/design/dev-server/canvas-icons.tsx +131 -0
  34. package/plugins/design/dev-server/canvas-lib-inline.ts +260 -0
  35. package/plugins/design/dev-server/canvas-lib-resolver.ts +85 -0
  36. package/plugins/design/dev-server/canvas-lib.tsx +1995 -0
  37. package/plugins/design/dev-server/canvas-meta.schema.json +181 -0
  38. package/plugins/design/dev-server/canvas-pipeline.ts +270 -0
  39. package/plugins/design/dev-server/canvas-shell.tsx +813 -0
  40. package/plugins/design/dev-server/client/app.jsx +2027 -0
  41. package/plugins/design/dev-server/client/hmr.mjs +85 -0
  42. package/plugins/design/dev-server/client/iframe-lazy.mjs +121 -0
  43. package/plugins/design/dev-server/client/index.html +15 -0
  44. package/plugins/design/dev-server/client/styles/0-reset.css +18 -0
  45. package/plugins/design/dev-server/client/styles/1-tokens.css +297 -0
  46. package/plugins/design/dev-server/client/styles/2-layout.css +35 -0
  47. package/plugins/design/dev-server/client/styles/3-shell.css +906 -0
  48. package/plugins/design/dev-server/client/styles/4-components.css +1268 -0
  49. package/plugins/design/dev-server/client/styles/5-utilities.css +4 -0
  50. package/plugins/design/dev-server/client/styles/_index.css +24 -0
  51. package/plugins/design/dev-server/client/styles.css +1419 -0
  52. package/plugins/design/dev-server/config.schema.json +147 -0
  53. package/plugins/design/dev-server/context-menu.tsx +343 -0
  54. package/plugins/design/dev-server/context.ts +173 -0
  55. package/plugins/design/dev-server/dist/client.bundle.js +20323 -0
  56. package/plugins/design/dev-server/dist/styles.css +2875 -0
  57. package/plugins/design/dev-server/examples/README.md +9 -0
  58. package/plugins/design/dev-server/examples/perf-100-artboards.tsx +113 -0
  59. package/plugins/design/dev-server/fs-watch.ts +63 -0
  60. package/plugins/design/dev-server/handoff.ts +721 -0
  61. package/plugins/design/dev-server/history.ts +125 -0
  62. package/plugins/design/dev-server/hmr-broadcast.ts +114 -0
  63. package/plugins/design/dev-server/http.ts +413 -0
  64. package/plugins/design/dev-server/input-router.tsx +485 -0
  65. package/plugins/design/dev-server/inspect.ts +365 -0
  66. package/plugins/design/dev-server/locator.ts +159 -0
  67. package/plugins/design/dev-server/mem.ts +97 -0
  68. package/plugins/design/dev-server/runtime-bundle.ts +235 -0
  69. package/plugins/design/dev-server/server.mjs +1246 -0
  70. package/plugins/design/dev-server/server.ts +131 -0
  71. package/plugins/design/dev-server/test/_helpers.ts +81 -0
  72. package/plugins/design/dev-server/test/active-state.test.ts +145 -0
  73. package/plugins/design/dev-server/test/annotations-api.test.ts +146 -0
  74. package/plugins/design/dev-server/test/annotations-layer.test.ts +419 -0
  75. package/plugins/design/dev-server/test/binary-smoke.test.ts +47 -0
  76. package/plugins/design/dev-server/test/bundle-smoke.test.ts +29 -0
  77. package/plugins/design/dev-server/test/canvas-build.test.ts +78 -0
  78. package/plugins/design/dev-server/test/canvas-edit.test.ts +139 -0
  79. package/plugins/design/dev-server/test/canvas-header.test.ts +127 -0
  80. package/plugins/design/dev-server/test/canvas-lib-inline.test.ts +146 -0
  81. package/plugins/design/dev-server/test/canvas-lib-resolver.test.ts +112 -0
  82. package/plugins/design/dev-server/test/canvas-meta-api.test.ts +236 -0
  83. package/plugins/design/dev-server/test/canvas-pipeline.test.ts +180 -0
  84. package/plugins/design/dev-server/test/canvas-route.test.ts +176 -0
  85. package/plugins/design/dev-server/test/fs-watch.test.ts +41 -0
  86. package/plugins/design/dev-server/test/handoff-static-frames.test.ts +215 -0
  87. package/plugins/design/dev-server/test/handoff.test.ts +281 -0
  88. package/plugins/design/dev-server/test/history-rollback.test.ts +62 -0
  89. package/plugins/design/dev-server/test/hmr-broadcast.test.ts +108 -0
  90. package/plugins/design/dev-server/test/input-router.test.ts +316 -0
  91. package/plugins/design/dev-server/test/locator.test.ts +214 -0
  92. package/plugins/design/dev-server/test/perf-harness.ts +193 -0
  93. package/plugins/design/dev-server/test/phase-3.6-smoke.test.ts +77 -0
  94. package/plugins/design/dev-server/test/runtime-bundle.test.ts +69 -0
  95. package/plugins/design/dev-server/test/server-lifecycle.test.ts +28 -0
  96. package/plugins/design/dev-server/test/tool-palette.test.tsx +55 -0
  97. package/plugins/design/dev-server/test/use-annotation-selection.test.tsx +77 -0
  98. package/plugins/design/dev-server/test/use-artboard-drag.test.ts +325 -0
  99. package/plugins/design/dev-server/test/use-selection-set.test.tsx +166 -0
  100. package/plugins/design/dev-server/test/use-snap-guides.test.ts +190 -0
  101. package/plugins/design/dev-server/test/use-tool-mode.test.tsx +93 -0
  102. package/plugins/design/dev-server/test/ws-handshake.test.ts +33 -0
  103. package/plugins/design/dev-server/tool-palette.tsx +278 -0
  104. package/plugins/design/dev-server/tsconfig.json +26 -0
  105. package/plugins/design/dev-server/use-annotation-selection.tsx +92 -0
  106. package/plugins/design/dev-server/use-annotations-visibility.tsx +43 -0
  107. package/plugins/design/dev-server/use-artboard-drag.tsx +445 -0
  108. package/plugins/design/dev-server/use-selection-set.tsx +224 -0
  109. package/plugins/design/dev-server/use-snap-guides.tsx +215 -0
  110. package/plugins/design/dev-server/use-tool-mode.tsx +114 -0
  111. package/plugins/design/dev-server/ws.ts +90 -0
  112. package/plugins/design/templates/_shell.html +177 -0
  113. package/plugins/design/templates/canvas.tsx.template +54 -0
  114. package/plugins/design/templates/design-system-inspiration/_MAPPING.md +277 -0
  115. package/plugins/design/templates/design-system-inspiration/_README.md +71 -0
  116. package/plugins/design/templates/design-system-inspiration/audience-consumer/components-banner.html +68 -0
  117. package/plugins/design/templates/design-system-inspiration/audience-consumer/components-empty-state-generous.html +39 -0
  118. package/plugins/design/templates/design-system-inspiration/audience-consumer/components-feature-grid.html +62 -0
  119. package/plugins/design/templates/design-system-inspiration/audience-consumer/components-marketing-card.html +63 -0
  120. package/plugins/design/templates/design-system-inspiration/audience-consumer/components-testimonial.html +80 -0
  121. package/plugins/design/templates/design-system-inspiration/audience-developer/components-code-block.html +71 -0
  122. package/plugins/design/templates/design-system-inspiration/audience-developer/components-diff-view.html +65 -0
  123. package/plugins/design/templates/design-system-inspiration/audience-developer/components-log-stream.html +62 -0
  124. package/plugins/design/templates/design-system-inspiration/audience-developer/components-monospace-table.html +57 -0
  125. package/plugins/design/templates/design-system-inspiration/audience-developer/components-terminal-pane.html +67 -0
  126. package/plugins/design/templates/design-system-inspiration/audience-developer/type-mono.html +57 -0
  127. package/plugins/design/templates/design-system-inspiration/audience-pro/colors-presence.html +74 -0
  128. package/plugins/design/templates/design-system-inspiration/audience-pro/components-command-palette.html +90 -0
  129. package/plugins/design/templates/design-system-inspiration/audience-pro/components-keyboard.html +51 -0
  130. package/plugins/design/templates/design-system-inspiration/audience-pro/components-list.html +89 -0
  131. package/plugins/design/templates/design-system-inspiration/audience-pro/components-shortcuts-overlay.html +85 -0
  132. package/plugins/design/templates/design-system-inspiration/audience-pro/components-toast-menu.html +80 -0
  133. package/plugins/design/templates/design-system-inspiration/core/INDEX.md.tpl +25 -0
  134. package/plugins/design/templates/design-system-inspiration/core/README.orchestration.md.tpl +71 -0
  135. package/plugins/design/templates/design-system-inspiration/core/README.philosophy.md.tpl +77 -0
  136. package/plugins/design/templates/design-system-inspiration/core/SKILL.md.tpl +50 -0
  137. package/plugins/design/templates/design-system-inspiration/core/colors_and_type.css.tpl +111 -0
  138. package/plugins/design/templates/design-system-inspiration/core/config.json.tpl +28 -0
  139. package/plugins/design/templates/design-system-inspiration/core/preview/_layout.css +113 -0
  140. package/plugins/design/templates/design-system-inspiration/core/preview/colors-accent.html +48 -0
  141. package/plugins/design/templates/design-system-inspiration/core/preview/colors-surfaces.html +49 -0
  142. package/plugins/design/templates/design-system-inspiration/core/preview/colors-text.html +52 -0
  143. package/plugins/design/templates/design-system-inspiration/core/preview/components-buttons.html +83 -0
  144. package/plugins/design/templates/design-system-inspiration/core/preview/components-cards.html +79 -0
  145. package/plugins/design/templates/design-system-inspiration/core/preview/components-inputs.html +82 -0
  146. package/plugins/design/templates/design-system-inspiration/core/preview/motion.html +66 -0
  147. package/plugins/design/templates/design-system-inspiration/core/preview/spacing-scale.html +53 -0
  148. package/plugins/design/templates/design-system-inspiration/core/preview/type-scale.html +62 -0
  149. package/plugins/design/templates/design-system-inspiration/foundations/borders.html +39 -0
  150. package/plugins/design/templates/design-system-inspiration/foundations/elevation.html +46 -0
  151. package/plugins/design/templates/design-system-inspiration/foundations/focus.html +48 -0
  152. package/plugins/design/templates/design-system-inspiration/foundations/grid.html +51 -0
  153. package/plugins/design/templates/design-system-inspiration/foundations/iconography.html +73 -0
  154. package/plugins/design/templates/design-system-inspiration/foundations/opacity.html +45 -0
  155. package/plugins/design/templates/design-system-inspiration/foundations/radii.html +40 -0
  156. package/plugins/design/templates/design-system-inspiration/foundations/selection.html +45 -0
  157. package/plugins/design/templates/design-system-inspiration/meta/accessibility.html +62 -0
  158. package/plugins/design/templates/design-system-inspiration/meta/i18n.html +73 -0
  159. package/plugins/design/templates/design-system-inspiration/meta/presence-multiplayer.html +71 -0
  160. package/plugins/design/templates/design-system-inspiration/meta/tokens-index.html +80 -0
  161. package/plugins/design/templates/design-system-inspiration/patterns/patterns-auth.html +78 -0
  162. package/plugins/design/templates/design-system-inspiration/patterns/patterns-data-density.html +61 -0
  163. package/plugins/design/templates/design-system-inspiration/patterns/patterns-error-pages.html +70 -0
  164. package/plugins/design/templates/design-system-inspiration/patterns/patterns-form-layouts.html +70 -0
  165. package/plugins/design/templates/design-system-inspiration/patterns/patterns-onboarding.html +71 -0
  166. package/plugins/design/templates/design-system-inspiration/patterns/patterns-pricing.html +83 -0
  167. package/plugins/design/templates/design-system-inspiration/platform-desktop/components-resize-panels.html +63 -0
  168. package/plugins/design/templates/design-system-inspiration/platform-desktop/ui_kits-desktop-index.html +55 -0
  169. package/plugins/design/templates/design-system-inspiration/platform-desktop/ui_kits-desktop-showcase.html +302 -0
  170. package/plugins/design/templates/design-system-inspiration/platform-mobile/components-bottom-sheet.html +63 -0
  171. package/plugins/design/templates/design-system-inspiration/platform-mobile/components-pull-to-refresh.html +74 -0
  172. package/plugins/design/templates/design-system-inspiration/platform-mobile/components-segmented-control.html +51 -0
  173. package/plugins/design/templates/design-system-inspiration/platform-mobile/components-tab-bar.html +57 -0
  174. package/plugins/design/templates/design-system-inspiration/platform-mobile/ui_kits-mobile-index.html +58 -0
  175. package/plugins/design/templates/design-system-inspiration/platform-mobile/ui_kits-mobile-showcase.html +237 -0
  176. package/plugins/design/templates/design-system-inspiration/status/colors-status.html +49 -0
  177. package/plugins/design/templates/design-system-inspiration/status/components-status.html +63 -0
  178. package/plugins/design/templates/design-system-inspiration/status/skeletons.html +74 -0
  179. package/plugins/design/templates/design-system-inspiration/theme-both/colors-themes-side-by-side.html +59 -0
  180. package/plugins/design/templates/design-system-inspiration/universal/components-callout.html +74 -0
  181. package/plugins/design/templates/design-system-inspiration/universal/components-dialogs.html +81 -0
  182. package/plugins/design/templates/design-system-inspiration/universal/components-tables.html +101 -0
  183. package/plugins/design/templates/design-system-inspiration/universal/components-toggles.html +74 -0
  184. package/plugins/design/templates/design-system-inspiration/universal/components-tooltips.html +74 -0
  185. package/plugins/design/templates/design-system-inspiration/universal/empty-state.html.tpl +34 -0
  186. package/plugins/design/templates/design-system-inspiration/universal/logo.html +42 -0
  187. package/plugins/design/templates/ds-specimen.tsx.template +59 -0
  188. package/plugins/flow/.claude-plugin/config.schema.json +398 -0
  189. package/plugins/flow/README.md +45 -0
  190. package/plugins/flow/templates/ai-skeleton/INDEX.md +50 -0
  191. package/plugins/flow/templates/ai-skeleton/README.md +46 -0
  192. package/plugins/flow/templates/ai-skeleton/browser/har/.gitkeep +0 -0
  193. package/plugins/flow/templates/ai-skeleton/browser/snapshots/.gitkeep +0 -0
  194. package/plugins/flow/templates/ai-skeleton/business/README.md +12 -0
  195. package/plugins/flow/templates/ai-skeleton/context/README.md +12 -0
  196. package/plugins/flow/templates/ai-skeleton/decisions/README.md +20 -0
  197. package/plugins/flow/templates/ai-skeleton/design-import/.gitkeep +0 -0
  198. package/plugins/flow/templates/ai-skeleton/dev-logs/.gitkeep +0 -0
  199. package/plugins/flow/templates/ai-skeleton/device/.gitkeep +0 -0
  200. package/plugins/flow/templates/ai-skeleton/docs/README.md +5 -0
  201. package/plugins/flow/templates/ai-skeleton/logs/.gitkeep +0 -0
  202. package/plugins/flow/templates/ai-skeleton/logs/README.md +13 -0
  203. package/plugins/flow/templates/ai-skeleton/plans/README.md +16 -0
  204. package/plugins/flow/templates/ai-skeleton/plans/archive/.gitkeep +0 -0
  205. package/plugins/flow/templates/ai-skeleton/release-guide.md +35 -0
  206. package/plugins/flow/templates/ai-skeleton/reviews/README.md +15 -0
  207. package/plugins/flow/templates/ai-skeleton/scenarios/README.md +26 -0
  208. package/plugins/flow/templates/ai-skeleton/scenarios/_lib/.gitkeep +0 -0
  209. package/plugins/flow/templates/ai-skeleton/state/STATE.md +25 -0
  210. package/plugins/flow/templates/ai-skeleton/templates/HANDOFF.md +32 -0
  211. 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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
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
+ }