@1agh/maude 0.23.0 → 0.25.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/README.md +34 -1
- package/cli/bin/maude.mjs +3 -0
- package/cli/commands/cache.mjs +181 -0
- package/cli/commands/cache.test.mjs +166 -0
- package/cli/commands/design-link.test.mjs +32 -2
- package/cli/commands/design.mjs +95 -4
- package/cli/commands/design.test.mjs +56 -0
- package/cli/commands/help.mjs +34 -0
- package/cli/commands/hub.mjs +255 -30
- package/cli/commands/hub.test.mjs +126 -2
- package/cli/commands/init.mjs +3 -0
- package/cli/commands/preflight.mjs +11 -0
- package/cli/commands/preflight.test.mjs +46 -0
- package/cli/commands/scenario-report.mjs +45 -0
- package/cli/lib/cache.mjs +407 -0
- package/cli/lib/cache.test.mjs +303 -0
- package/cli/lib/design-link.mjs +74 -10
- package/cli/lib/flow-design-integration.test.mjs +165 -0
- package/cli/lib/gitignore-block.mjs +90 -0
- package/cli/lib/gitignore-block.test.mjs +123 -0
- package/cli/lib/plugin-cli-reachability.test.mjs +56 -0
- package/cli/lib/preflight.mjs +41 -10
- package/package.json +8 -8
- package/plugins/design/dependencies.json +30 -2
- package/plugins/design/dev-server/ai-banner.tsx +2 -2
- package/plugins/design/dev-server/annotations-context-toolbar.tsx +763 -123
- package/plugins/design/dev-server/annotations-layer.tsx +1624 -208
- package/plugins/design/dev-server/api.ts +123 -4
- package/plugins/design/dev-server/bin/_enumerate-artboards-playwright.mjs +2 -2
- package/plugins/design/dev-server/bin/_html-playwright.mjs +2 -2
- package/plugins/design/dev-server/bin/_pdf-playwright.mjs +25 -8
- package/plugins/design/dev-server/bin/_png-playwright.mjs +20 -20
- package/plugins/design/dev-server/bin/_pptx-playwright.mjs +2 -2
- package/plugins/design/dev-server/bin/_pw-launch.mjs +61 -0
- package/plugins/design/dev-server/bin/_screenshot-playwright.mjs +2 -2
- package/plugins/design/dev-server/bin/_svg-playwright.mjs +125 -19
- package/plugins/design/dev-server/bin/preflight.sh +21 -10
- package/plugins/design/dev-server/bin/prep.sh +211 -0
- package/plugins/design/dev-server/bin/scenario-report.mjs +209 -0
- package/plugins/design/dev-server/bin/screenshot.sh +18 -1
- package/plugins/design/dev-server/bin/smoke.sh +208 -23
- package/plugins/design/dev-server/canvas-arrowheads.ts +220 -0
- package/plugins/design/dev-server/canvas-comment-mount.tsx +8 -24
- package/plugins/design/dev-server/canvas-cursors.ts +173 -0
- package/plugins/design/dev-server/canvas-icons.tsx +299 -0
- package/plugins/design/dev-server/canvas-lib.tsx +25 -21
- package/plugins/design/dev-server/canvas-meta.schema.json +20 -0
- package/plugins/design/dev-server/canvas-shell.tsx +404 -66
- package/plugins/design/dev-server/client/app.jsx +1231 -416
- package/plugins/design/dev-server/client/styles/3-shell.css +10 -0
- package/plugins/design/dev-server/collab/index.ts +87 -9
- package/plugins/design/dev-server/collab/persistence.ts +34 -3
- package/plugins/design/dev-server/collab/registry.ts +80 -2
- package/plugins/design/dev-server/collab/room.ts +21 -8
- package/plugins/design/dev-server/context-menu.tsx +167 -23
- package/plugins/design/dev-server/context.ts +24 -0
- package/plugins/design/dev-server/contextual-toolbar.tsx +7 -7
- package/plugins/design/dev-server/dist/client.bundle.js +364 -29
- package/plugins/design/dev-server/dist/comment-mount.js +78 -33
- package/plugins/design/dev-server/dist/styles.css +16 -0
- package/plugins/design/dev-server/equal-spacing-handles.tsx +1 -1
- package/plugins/design/dev-server/export-dialog.tsx +208 -18
- package/plugins/design/dev-server/exporters/html.ts +4 -1
- package/plugins/design/dev-server/exporters/index.ts +4 -1
- package/plugins/design/dev-server/exporters/pdf.ts +7 -1
- package/plugins/design/dev-server/exporters/png.ts +21 -2
- package/plugins/design/dev-server/exporters/pptx.ts +330 -201
- package/plugins/design/dev-server/exporters/scope.ts +107 -11
- package/plugins/design/dev-server/exporters/svg.ts +4 -1
- package/plugins/design/dev-server/fs-watch.ts +1 -0
- package/plugins/design/dev-server/http.ts +299 -28
- package/plugins/design/dev-server/input-router.tsx +35 -5
- package/plugins/design/dev-server/participants-chrome.tsx +10 -10
- package/plugins/design/dev-server/server.ts +123 -1
- package/plugins/design/dev-server/sync/agent.ts +95 -0
- package/plugins/design/dev-server/sync/codec.ts +155 -0
- package/plugins/design/dev-server/sync/connection-state.ts +203 -0
- package/plugins/design/dev-server/sync/index.ts +479 -35
- package/plugins/design/dev-server/sync/materialize.ts +62 -0
- package/plugins/design/dev-server/sync/migrate-seed.ts +163 -0
- package/plugins/design/dev-server/sync/origins.ts +57 -0
- package/plugins/design/dev-server/sync/projection.ts +368 -0
- package/plugins/design/dev-server/sync/status.ts +115 -0
- package/plugins/design/dev-server/sync/untrusted.ts +153 -0
- package/plugins/design/dev-server/test/_helpers.ts +6 -2
- package/plugins/design/dev-server/test/annotations-draw-modifiers.test.ts +206 -0
- package/plugins/design/dev-server/test/annotations-layer.test.ts +311 -0
- package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +519 -0
- package/plugins/design/dev-server/test/canvas-cursors.test.ts +162 -0
- package/plugins/design/dev-server/test/canvas-origin-gate.test.ts +76 -0
- package/plugins/design/dev-server/test/canvas-route.test.ts +4 -1
- package/plugins/design/dev-server/test/collab-reseed-guard.test.ts +78 -0
- package/plugins/design/dev-server/test/collab-room.test.ts +21 -10
- package/plugins/design/dev-server/test/csp-canvas-shell.test.ts +46 -0
- package/plugins/design/dev-server/test/exporters/png.test.ts +24 -1
- package/plugins/design/dev-server/test/exporters/pptx-deck.test.ts +112 -0
- package/plugins/design/dev-server/test/exporters/pw-launch.test.ts +49 -0
- package/plugins/design/dev-server/test/exporters/scope.test.ts +0 -0
- package/plugins/design/dev-server/test/fixtures/phase-20-annotations.svg +1 -0
- package/plugins/design/dev-server/test/fixtures/phase-21-annotations.svg +1 -0
- package/plugins/design/dev-server/test/input-router.test.ts +32 -4
- package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +132 -0
- package/plugins/design/dev-server/test/shared-doc-convergence.test.ts +265 -0
- package/plugins/design/dev-server/test/shared-doc-foundation.test.ts +63 -0
- package/plugins/design/dev-server/test/shared-doc-migrate.test.ts +160 -0
- package/plugins/design/dev-server/test/shared-doc-projection.test.ts +238 -0
- package/plugins/design/dev-server/test/sync-connection-state.test.ts +146 -0
- package/plugins/design/dev-server/test/sync-meta-codec.test.ts +123 -0
- package/plugins/design/dev-server/test/sync-runtime.test.ts +531 -4
- package/plugins/design/dev-server/test/sync-status.test.ts +80 -0
- package/plugins/design/dev-server/test/sync-untrusted.test.ts +104 -0
- package/plugins/design/dev-server/test/use-annotation-resize.test.ts +200 -0
- package/plugins/design/dev-server/test/use-tool-mode.test.tsx +39 -13
- package/plugins/design/dev-server/tool-palette.tsx +155 -24
- package/plugins/design/dev-server/undo-hud.tsx +4 -4
- package/plugins/design/dev-server/undo-stack.ts +20 -4
- package/plugins/design/dev-server/use-annotation-resize.tsx +219 -61
- package/plugins/design/dev-server/use-tool-mode.tsx +68 -19
- package/plugins/design/dev-server/ws.ts +40 -1
- package/plugins/design/templates/_shell.html +85 -10
- package/plugins/design/templates/canvas.tsx.template +13 -0
- package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +14 -7
- package/plugins/flow/.claude-plugin/config.schema.json +5 -0
- package/plugins/flow/templates/ai-skeleton/README.md +1 -1
- package/plugins/flow/templates/ai-skeleton/gitignore +10 -0
- package/plugins/flow/templates/ai-skeleton/scenarios/README.md +3 -1
- package/plugins/flow/templates/ai-skeleton/workflows.config.json +2 -1
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
import {
|
|
25
|
+
type CSSProperties,
|
|
25
26
|
type PointerEvent as ReactPointerEvent,
|
|
26
27
|
createContext,
|
|
27
28
|
useCallback,
|
|
@@ -34,15 +35,23 @@ import {
|
|
|
34
35
|
import { createPortal } from 'react-dom';
|
|
35
36
|
|
|
36
37
|
import { AnnotationContextToolbar } from './annotations-context-toolbar.tsx';
|
|
38
|
+
import {
|
|
39
|
+
ARROW_HEADS,
|
|
40
|
+
type ArrowHead,
|
|
41
|
+
type ArrowLineType,
|
|
42
|
+
type SvgPrimitive,
|
|
43
|
+
arrowPrimitives,
|
|
44
|
+
} from './canvas-arrowheads.ts';
|
|
45
|
+
import { IconLineThick, IconLineThin } from './canvas-icons.tsx';
|
|
37
46
|
import { useViewportControllerContext, useWorldRefContext } from './canvas-lib.tsx';
|
|
38
47
|
import { buildAnnotationStrokesRecord } from './commands/annotation-strokes-command.ts';
|
|
39
|
-
import { crossedDragThreshold } from './input-router.tsx';
|
|
48
|
+
import { type Tool, crossedDragThreshold } from './input-router.tsx';
|
|
40
49
|
import { AnnotationResizeOverlay } from './use-annotation-resize.tsx';
|
|
41
50
|
import { useAnnotationSelectionOptional } from './use-annotation-selection.tsx';
|
|
42
51
|
import { useAnnotationsVisibility } from './use-annotations-visibility.tsx';
|
|
43
52
|
import { useCollab } from './use-collab.tsx';
|
|
44
53
|
import { useSelectionSetOptional } from './use-selection-set.tsx';
|
|
45
|
-
import { useToolMode } from './use-tool-mode.tsx';
|
|
54
|
+
import { type ShapeKind, useToolMode } from './use-tool-mode.tsx';
|
|
46
55
|
import { useUndoSinks, useUndoStackOptional } from './use-undo-stack.tsx';
|
|
47
56
|
|
|
48
57
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -50,6 +59,21 @@ import { useUndoSinks, useUndoStackOptional } from './use-undo-stack.tsx';
|
|
|
50
59
|
|
|
51
60
|
type WorldPoint = readonly [number, number];
|
|
52
61
|
|
|
62
|
+
// Phase 24 — arrow style enums are OWNED by canvas-arrowheads.ts (so that
|
|
63
|
+
// module imports nothing back from here — no cycle, see DDR-067) and re-exported
|
|
64
|
+
// here for back-compat (context-toolbar etc. import them from this module).
|
|
65
|
+
export type { ArrowHead, ArrowLineType } from './canvas-arrowheads.ts';
|
|
66
|
+
/** Phase 24 — polygon shape primitives (diamond + the two triangle pointings). */
|
|
67
|
+
export type PolygonShape = 'diamond' | 'triangle' | 'triangle-down';
|
|
68
|
+
/** Phase 24 — horizontal alignment for text + sticky bodies. */
|
|
69
|
+
export type TextAlign = 'left' | 'center' | 'right';
|
|
70
|
+
|
|
71
|
+
/** Phase 24 — cursor-following ghost placeholder descriptor (pure chrome). */
|
|
72
|
+
type GhostDescriptor =
|
|
73
|
+
| { kind: 'text'; x: number; y: number; color: string }
|
|
74
|
+
| { kind: 'sticky'; x: number; y: number; color: string }
|
|
75
|
+
| { kind: 'shape'; x: number; y: number; shapeKind: ShapeKind; color: string };
|
|
76
|
+
|
|
53
77
|
export interface PenStroke {
|
|
54
78
|
id: string;
|
|
55
79
|
tool: 'pen';
|
|
@@ -67,6 +91,8 @@ export interface RectStroke {
|
|
|
67
91
|
w: number;
|
|
68
92
|
h: number;
|
|
69
93
|
fill?: string | null;
|
|
94
|
+
/** Phase 21 — corner radius (rx/ry). Absent / 0 = sharp 90° corners (back-compat). */
|
|
95
|
+
cornerRadius?: number;
|
|
70
96
|
}
|
|
71
97
|
export interface EllipseStroke {
|
|
72
98
|
id: string;
|
|
@@ -79,6 +105,27 @@ export interface EllipseStroke {
|
|
|
79
105
|
ry: number;
|
|
80
106
|
fill?: string | null;
|
|
81
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* Phase 24 — diamond / triangle / triangle-down primitives. Stored as a bbox
|
|
110
|
+
* (x/y/w/h, exactly like a rect) + a `shape` discriminant; the actual SVG
|
|
111
|
+
* points are derived from the bbox at serialize + render time. Brand-new on
|
|
112
|
+
* disk (`<polygon data-tool="polygon" data-shape="…">`), so no back-compat
|
|
113
|
+
* constraint — only idempotent round-trip.
|
|
114
|
+
*/
|
|
115
|
+
export interface PolygonStroke {
|
|
116
|
+
id: string;
|
|
117
|
+
tool: 'polygon';
|
|
118
|
+
shape: PolygonShape;
|
|
119
|
+
color: string;
|
|
120
|
+
width: number;
|
|
121
|
+
x: number;
|
|
122
|
+
y: number;
|
|
123
|
+
w: number;
|
|
124
|
+
h: number;
|
|
125
|
+
fill?: string | null;
|
|
126
|
+
/** Dashed outline (stroke-dasharray). Absent / false = solid. */
|
|
127
|
+
dashed?: boolean;
|
|
128
|
+
}
|
|
82
129
|
export interface ArrowStroke {
|
|
83
130
|
id: string;
|
|
84
131
|
tool: 'arrow';
|
|
@@ -88,6 +135,14 @@ export interface ArrowStroke {
|
|
|
88
135
|
y1: number;
|
|
89
136
|
x2: number;
|
|
90
137
|
y2: number;
|
|
138
|
+
/** Head on the (x1,y1) start. Absent = 'none' (back-compat). Phase 24 widened the enum. */
|
|
139
|
+
startHead?: ArrowHead;
|
|
140
|
+
/** Head on the (x2,y2) end. Absent = 'triangle' (back-compat). Phase 24 widened the enum. */
|
|
141
|
+
endHead?: ArrowHead;
|
|
142
|
+
/** Phase 21 — dashed shaft (stroke-dasharray). Absent / false = solid. */
|
|
143
|
+
dashed?: boolean;
|
|
144
|
+
/** Phase 24 — shaft routing. Absent = 'straight' (back-compat). */
|
|
145
|
+
lineType?: ArrowLineType;
|
|
91
146
|
}
|
|
92
147
|
export interface TextStroke {
|
|
93
148
|
id: string;
|
|
@@ -95,28 +150,104 @@ export interface TextStroke {
|
|
|
95
150
|
color: string;
|
|
96
151
|
fontSize: number;
|
|
97
152
|
text: string;
|
|
98
|
-
|
|
153
|
+
/**
|
|
154
|
+
* Host shape id for anchored text (double-click a rect/ellipse). Phase 21
|
|
155
|
+
* relaxed this to optional: standalone text (the `text` tool) carries no
|
|
156
|
+
* anchor and renders at its own world `(x, y)` instead.
|
|
157
|
+
*/
|
|
158
|
+
anchorId?: string;
|
|
159
|
+
/** Phase 21 — world coords for standalone (unanchored) text. */
|
|
160
|
+
x?: number;
|
|
161
|
+
y?: number;
|
|
162
|
+
/** Phase 24 — bold weight. Absent / false = normal (back-compat). */
|
|
163
|
+
bold?: boolean;
|
|
164
|
+
/** Phase 24 — strikethrough. Absent / false = none (back-compat). */
|
|
165
|
+
strike?: boolean;
|
|
166
|
+
/**
|
|
167
|
+
* Phase 24 — horizontal alignment. Absent default differs by kind: anchored
|
|
168
|
+
* text = 'center' (legacy, byte-identical), standalone = 'left'.
|
|
169
|
+
*/
|
|
170
|
+
align?: TextAlign;
|
|
99
171
|
}
|
|
100
|
-
|
|
172
|
+
/** Phase 21 — sticky note: a paper-tone card with its own word-wrapped text. */
|
|
173
|
+
export interface StickyStroke {
|
|
174
|
+
id: string;
|
|
175
|
+
tool: 'sticky';
|
|
176
|
+
color: string;
|
|
177
|
+
x: number;
|
|
178
|
+
y: number;
|
|
179
|
+
w: number;
|
|
180
|
+
h: number;
|
|
181
|
+
text: string;
|
|
182
|
+
fontSize: number;
|
|
183
|
+
/** Corner radius; defaults to STICKY_CORNER_RADIUS (8 = soft). */
|
|
184
|
+
cornerRadius?: number;
|
|
185
|
+
/** Phase 24 — bold body weight. Absent / false = normal. */
|
|
186
|
+
bold?: boolean;
|
|
187
|
+
/** Phase 24 — strikethrough body. Absent / false = none. */
|
|
188
|
+
strike?: boolean;
|
|
189
|
+
/** Phase 24 — body alignment. Absent = 'left' (FigJam sticky default). */
|
|
190
|
+
align?: TextAlign;
|
|
191
|
+
}
|
|
192
|
+
export type Stroke =
|
|
193
|
+
| PenStroke
|
|
194
|
+
| RectStroke
|
|
195
|
+
| EllipseStroke
|
|
196
|
+
| PolygonStroke
|
|
197
|
+
| ArrowStroke
|
|
198
|
+
| TextStroke
|
|
199
|
+
| StickyStroke;
|
|
101
200
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
201
|
+
/**
|
|
202
|
+
* Phase 21 — what the inline editor is currently bound to. `anchored` edits
|
|
203
|
+
* the text hosted by a rect/ellipse; `sticky` edits a card body; `standalone`
|
|
204
|
+
* re-edits a free text node; `pending` is a not-yet-born text caret (no stroke
|
|
205
|
+
* exists until real text is committed).
|
|
206
|
+
*/
|
|
207
|
+
type EditingTarget =
|
|
208
|
+
| { kind: 'anchored'; anchorId: string; host: RectStroke | EllipseStroke }
|
|
209
|
+
| { kind: 'sticky'; sticky: StickyStroke }
|
|
210
|
+
| { kind: 'standalone'; text: TextStroke }
|
|
211
|
+
| { kind: 'pending'; x: number; y: number }
|
|
212
|
+
| null;
|
|
213
|
+
|
|
214
|
+
// Phase 21 colour system — a single coherent hue family used everywhere.
|
|
215
|
+
// FigJam model: stroke (saturated ink) is INDEPENDENT of fill, and fills are
|
|
216
|
+
// light TINTS of the same hue (index-paired with STROKE_PALETTE). Stickies use
|
|
217
|
+
// their own lightened paper set (STICKY_PALETTE). Exported so the draw-time
|
|
218
|
+
// chrome AND the per-selection context toolbar share ONE palette instead of
|
|
219
|
+
// drifting apart.
|
|
220
|
+
export const STROKE_PALETTE = [
|
|
221
|
+
'#e5484d', // red (default — markup ink)
|
|
222
|
+
'#f2762a', // orange
|
|
223
|
+
'#e0a500', // amber
|
|
224
|
+
'#30a46c', // green
|
|
225
|
+
'#3b82f6', // blue
|
|
226
|
+
'#8b5cf6', // purple
|
|
227
|
+
'#e93d82', // pink
|
|
228
|
+
'#7c7c7c', // gray
|
|
229
|
+
'#1f1f1f', // ink
|
|
109
230
|
] as const;
|
|
110
|
-
type PaletteColor = (typeof
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
231
|
+
type PaletteColor = (typeof STROKE_PALETTE)[number];
|
|
232
|
+
// Phase 24 — default markup ink is BLACK (the `#1f1f1f` ink swatch, slot 8) for
|
|
233
|
+
// EVERY ink tool (pen / shape / arrow / text). It's a palette member so the
|
|
234
|
+
// draw chrome + per-selection toolbar highlight it as the active swatch; the
|
|
235
|
+
// other hues stay one click away. (Stickies keep their warm-paper default —
|
|
236
|
+
// DEFAULT_STICKY_COLOR — they're paper, not ink.)
|
|
237
|
+
const DEFAULT_COLOR: PaletteColor = STROKE_PALETTE[8];
|
|
238
|
+
|
|
239
|
+
// Light tints, index-paired to STROKE_PALETTE — picking "blue fill" gives a
|
|
240
|
+
// pale blue wash under a saturated stroke, exactly like FigJam shapes.
|
|
241
|
+
export const FILL_PALETTE = [
|
|
242
|
+
'#fbe0e1', // red tint
|
|
243
|
+
'#fce6d6', // orange tint
|
|
244
|
+
'#fbeec2', // amber tint
|
|
245
|
+
'#d9f1e2', // green tint
|
|
246
|
+
'#e0ebfd', // blue tint
|
|
247
|
+
'#ebe3fc', // purple tint
|
|
248
|
+
'#fbdfeb', // pink tint
|
|
249
|
+
'#ededed', // gray tint
|
|
250
|
+
'#e7e7e7', // ink tint
|
|
120
251
|
] as const;
|
|
121
252
|
|
|
122
253
|
const STROKE_WIDTH_THIN = 3;
|
|
@@ -126,6 +257,34 @@ type Thickness = typeof STROKE_WIDTH_THIN | typeof STROKE_WIDTH_THICK;
|
|
|
126
257
|
const FONT_SIZE_MEDIUM = 14;
|
|
127
258
|
const DEFAULT_FONT_SIZE = FONT_SIZE_MEDIUM;
|
|
128
259
|
|
|
260
|
+
// Phase 24 — sticky-note paper tints. A muted/desaturated FigJam-style set
|
|
261
|
+
// (Image #2): a warm paper yellow default, then white/grey + soft pastels.
|
|
262
|
+
// Wholly separate from the stroke ink PALETTE and the translucent FILL_PALETTE
|
|
263
|
+
// so stickies read as "paper", not "ink". Slot 0 (yellow) is the default.
|
|
264
|
+
// Existing stickies keep their stored hex; only NEW stickies pick up the new
|
|
265
|
+
// default tint.
|
|
266
|
+
export const STICKY_PALETTE = [
|
|
267
|
+
'#fce8a6', // muted yellow (default — warm paper)
|
|
268
|
+
'#ffffff', // white
|
|
269
|
+
'#e6e4e0', // light grey
|
|
270
|
+
'#f7c5c0', // salmon
|
|
271
|
+
'#f8d2a6', // peach
|
|
272
|
+
'#bfe3c0', // mint
|
|
273
|
+
'#a9dbdb', // aqua
|
|
274
|
+
'#bcd2f0', // light blue
|
|
275
|
+
'#cfc4ec', // lavender
|
|
276
|
+
'#f3c4dd', // light pink
|
|
277
|
+
] as const;
|
|
278
|
+
const DEFAULT_STICKY_COLOR = STICKY_PALETTE[0];
|
|
279
|
+
const STICKY_CORNER_RADIUS = 8;
|
|
280
|
+
// Phase 24 — stickies are 1:1; the default tap size is a square.
|
|
281
|
+
const STICKY_DEFAULT_W = 200;
|
|
282
|
+
const STICKY_DEFAULT_H = 200;
|
|
283
|
+
const STICKY_MIN_SIZE = 40;
|
|
284
|
+
// Phase 24 — a bare tap with the Shape tool drops a default-sized shape at the
|
|
285
|
+
// tap point (FigJam parity: click commits, drag sizes). Square aspect.
|
|
286
|
+
const SHAPE_DEFAULT_SIZE = 120;
|
|
287
|
+
|
|
129
288
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
130
289
|
// Pure helpers — exported for unit tests.
|
|
131
290
|
|
|
@@ -146,30 +305,153 @@ export function penPathD(points: readonly WorldPoint[]): string {
|
|
|
146
305
|
return d;
|
|
147
306
|
}
|
|
148
307
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
308
|
+
// Phase 24 — moved to canvas-arrowheads.ts (single source for shaft + heads).
|
|
309
|
+
// Re-exported so the existing test import (`from '../annotations-layer.tsx'`)
|
|
310
|
+
// and the byte-identical canary keep working.
|
|
311
|
+
export { arrowHeadPoints } from './canvas-arrowheads.ts';
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Phase 24 — polygon vertices derived from the bbox. `diamond` = the four
|
|
315
|
+
* edge-midpoints; `triangle` = apex-up; `triangle-down` = apex-down. Every
|
|
316
|
+
* shape's vertices span the FULL bbox, so a parse-back via the points' min/max
|
|
317
|
+
* recovers x/y/w/h exactly (idempotent round-trip).
|
|
318
|
+
*/
|
|
319
|
+
export function polygonVertices(
|
|
320
|
+
shape: PolygonShape,
|
|
321
|
+
x: number,
|
|
322
|
+
y: number,
|
|
323
|
+
w: number,
|
|
324
|
+
h: number
|
|
325
|
+
): Array<[number, number]> {
|
|
326
|
+
if (shape === 'diamond') {
|
|
327
|
+
return [
|
|
328
|
+
[x + w / 2, y],
|
|
329
|
+
[x + w, y + h / 2],
|
|
330
|
+
[x + w / 2, y + h],
|
|
331
|
+
[x, y + h / 2],
|
|
332
|
+
];
|
|
333
|
+
}
|
|
334
|
+
if (shape === 'triangle') {
|
|
335
|
+
return [
|
|
336
|
+
[x + w / 2, y],
|
|
337
|
+
[x + w, y + h],
|
|
338
|
+
[x, y + h],
|
|
339
|
+
];
|
|
340
|
+
}
|
|
341
|
+
// triangle-down — apex at the bottom.
|
|
342
|
+
return [
|
|
343
|
+
[x, y],
|
|
344
|
+
[x + w, y],
|
|
345
|
+
[x + w / 2, y + h],
|
|
346
|
+
];
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** Vertices as an SVG `points` string. */
|
|
350
|
+
export function polygonPoints(
|
|
351
|
+
shape: PolygonShape,
|
|
352
|
+
x: number,
|
|
353
|
+
y: number,
|
|
354
|
+
w: number,
|
|
355
|
+
h: number
|
|
155
356
|
): string {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
357
|
+
return polygonVertices(shape, x, y, w, h)
|
|
358
|
+
.map(([px, py]) => `${px},${py}`)
|
|
359
|
+
.join(' ');
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Even-odd ray-cast point-in-polygon test. */
|
|
363
|
+
function pointInPolygon(px: number, py: number, pts: ReadonlyArray<[number, number]>): boolean {
|
|
364
|
+
let inside = false;
|
|
365
|
+
for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {
|
|
366
|
+
const a = pts[i];
|
|
367
|
+
const b = pts[j];
|
|
368
|
+
if (!a || !b) continue;
|
|
369
|
+
const [xi, yi] = a;
|
|
370
|
+
const [xj, yj] = b;
|
|
371
|
+
if (yi > py !== yj > py && px < ((xj - xi) * (py - yi)) / (yj - yi) + xi) {
|
|
372
|
+
inside = !inside;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return inside;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Parse a polygon `points` string back into its bounding box. */
|
|
379
|
+
function polygonBBox(points: string): { x: number; y: number; w: number; h: number } | null {
|
|
380
|
+
let xMin = Number.POSITIVE_INFINITY;
|
|
381
|
+
let yMin = Number.POSITIVE_INFINITY;
|
|
382
|
+
let xMax = Number.NEGATIVE_INFINITY;
|
|
383
|
+
let yMax = Number.NEGATIVE_INFINITY;
|
|
384
|
+
for (const pair of points.trim().split(/\s+/)) {
|
|
385
|
+
const [px, py] = pair.split(',').map((n) => Number.parseFloat(n));
|
|
386
|
+
if (px == null || py == null || Number.isNaN(px) || Number.isNaN(py)) continue;
|
|
387
|
+
if (px < xMin) xMin = px;
|
|
388
|
+
if (px > xMax) xMax = px;
|
|
389
|
+
if (py < yMin) yMin = py;
|
|
390
|
+
if (py > yMax) yMax = py;
|
|
391
|
+
}
|
|
392
|
+
if (!Number.isFinite(xMin)) return null;
|
|
393
|
+
return { x: xMin, y: yMin, w: xMax - xMin, h: yMax - yMin };
|
|
164
394
|
}
|
|
165
395
|
|
|
166
396
|
function strokeToSvgEl(s: Stroke): string {
|
|
167
397
|
if (s.tool === 'text') {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
398
|
+
// Phase 21 — anchored text keeps the byte-identical Phase 5.1 form;
|
|
399
|
+
// standalone text (no anchorId) writes its own world x/y and omits
|
|
400
|
+
// data-anchor-id (so the parser routes it back to the standalone branch).
|
|
401
|
+
// Phase 24 — bold/strike/align serialize ONLY for non-default values, so a
|
|
402
|
+
// legacy text node stays byte-identical (weight/deco/alignAttr are empty).
|
|
403
|
+
const weight = s.bold ? ' font-weight="700"' : '';
|
|
404
|
+
const deco = s.strike ? ' text-decoration="line-through"' : '';
|
|
405
|
+
if (s.anchorId != null && s.anchorId !== '') {
|
|
406
|
+
const align = s.align ?? 'center'; // anchored default = centre (legacy)
|
|
407
|
+
const anchor = align === 'left' ? 'start' : align === 'right' ? 'end' : 'middle';
|
|
408
|
+
const alignAttr = align !== 'center' ? ` data-align="${align}"` : '';
|
|
409
|
+
return `<text data-id="${esc(s.id)}" data-tool="text" data-anchor-id="${esc(
|
|
410
|
+
s.anchorId
|
|
411
|
+
)}" data-font-size="${s.fontSize}" fill="${esc(
|
|
412
|
+
s.color
|
|
413
|
+
)}"${weight}${deco} text-anchor="${anchor}" dominant-baseline="middle"${alignAttr}>${esc(
|
|
414
|
+
s.text
|
|
415
|
+
)}</text>`;
|
|
416
|
+
}
|
|
417
|
+
const tx = s.x ?? 0;
|
|
418
|
+
const ty = s.y ?? 0;
|
|
419
|
+
const align = s.align ?? 'left'; // standalone default = left
|
|
420
|
+
const anchor = align === 'left' ? 'start' : align === 'right' ? 'end' : 'middle';
|
|
421
|
+
const alignAttr = align !== 'left' ? ` data-align="${align}"` : '';
|
|
422
|
+
return `<text data-id="${esc(s.id)}" data-tool="text" x="${tx}" y="${ty}" data-font-size="${
|
|
423
|
+
s.fontSize
|
|
424
|
+
}" fill="${esc(
|
|
171
425
|
s.color
|
|
172
|
-
)}" text-anchor="
|
|
426
|
+
)}"${weight}${deco} text-anchor="${anchor}" dominant-baseline="hanging"${alignAttr}>${esc(
|
|
427
|
+
s.text
|
|
428
|
+
)}</text>`;
|
|
429
|
+
}
|
|
430
|
+
if (s.tool === 'sticky') {
|
|
431
|
+
// Phase 21 — sticky body lives in an allowlisted <text> child so it
|
|
432
|
+
// survives sanitizeAnnotationSvg (which strips <foreignObject>, DDR-060
|
|
433
|
+
// F1). The live canvas re-renders this stroke with a foreignObject so the
|
|
434
|
+
// text word-wraps; the persisted <text> is the inert, sanitizer-safe form.
|
|
435
|
+
const r = s.cornerRadius ?? STICKY_CORNER_RADIUS;
|
|
436
|
+
const w = Math.max(0, s.w);
|
|
437
|
+
const h = Math.max(0, s.h);
|
|
438
|
+
// Phase 24 — bold/strike/align on the <g> data-attrs, emitted ONLY for
|
|
439
|
+
// non-default values (sticky default align = left) so Phase-21 stickies
|
|
440
|
+
// serialize byte-identically.
|
|
441
|
+
const align = s.align ?? 'left';
|
|
442
|
+
const styleAttrs =
|
|
443
|
+
(s.bold ? ' data-bold="1"' : '') +
|
|
444
|
+
(s.strike ? ' data-strike="1"' : '') +
|
|
445
|
+
(align !== 'left' ? ` data-align="${align}"` : '');
|
|
446
|
+
return `<g data-id="${esc(s.id)}" data-tool="sticky" data-r="${r}" data-fs="${
|
|
447
|
+
s.fontSize
|
|
448
|
+
}" fill="${esc(s.color)}"${styleAttrs}><rect x="${s.x}" y="${
|
|
449
|
+
s.y
|
|
450
|
+
}" width="${w}" height="${h}" rx="${r}" ry="${r}"/><text data-sticky-body="1" x="${
|
|
451
|
+
s.x + 12
|
|
452
|
+
}" y="${s.y + 12}" font-size="${
|
|
453
|
+
s.fontSize
|
|
454
|
+
}" fill="#1a1a1a" dominant-baseline="hanging">${esc(s.text)}</text></g>`;
|
|
173
455
|
}
|
|
174
456
|
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"`;
|
|
175
457
|
if (s.tool === 'pen') {
|
|
@@ -177,10 +459,14 @@ function strokeToSvgEl(s: Stroke): string {
|
|
|
177
459
|
}
|
|
178
460
|
if (s.tool === 'rect') {
|
|
179
461
|
const fill = s.fill ? esc(s.fill) : 'none';
|
|
462
|
+
// Phase 21 — corner radius: append rx/ry/data-r ONLY when > 0 so legacy
|
|
463
|
+
// sharp-corner rects serialize byte-identically (Task 10 canary).
|
|
464
|
+
const r = s.cornerRadius ?? 0;
|
|
465
|
+
const round = r > 0 ? ` rx="${r}" ry="${r}" data-r="${r}"` : '';
|
|
180
466
|
return `<rect ${common} fill="${fill}" x="${s.x}" y="${s.y}" width="${Math.max(
|
|
181
467
|
0,
|
|
182
468
|
s.w
|
|
183
|
-
)}" height="${Math.max(0, s.h)}"/>`;
|
|
469
|
+
)}" height="${Math.max(0, s.h)}"${round}/>`;
|
|
184
470
|
}
|
|
185
471
|
if (s.tool === 'ellipse') {
|
|
186
472
|
const fill = s.fill ? esc(s.fill) : 'none';
|
|
@@ -189,8 +475,61 @@ function strokeToSvgEl(s: Stroke): string {
|
|
|
189
475
|
s.rx
|
|
190
476
|
)}" ry="${Math.max(0, s.ry)}"/>`;
|
|
191
477
|
}
|
|
192
|
-
|
|
193
|
-
|
|
478
|
+
if (s.tool === 'polygon') {
|
|
479
|
+
// Phase 24 — bbox-derived points + data-shape. Normalize the bbox so a
|
|
480
|
+
// negative-extent (mid-flip) stroke serializes idempotently.
|
|
481
|
+
const nx = Math.min(s.x, s.x + s.w);
|
|
482
|
+
const ny = Math.min(s.y, s.y + s.h);
|
|
483
|
+
const nw = Math.abs(s.w);
|
|
484
|
+
const nh = Math.abs(s.h);
|
|
485
|
+
const fill = s.fill ? esc(s.fill) : 'none';
|
|
486
|
+
const dash = s.dashed ? ' stroke-dasharray="6 4"' : '';
|
|
487
|
+
const dashAttr = s.dashed ? ' data-dash="1"' : '';
|
|
488
|
+
return `<polygon ${common} fill="${fill}" data-shape="${s.shape}" points="${polygonPoints(
|
|
489
|
+
s.shape,
|
|
490
|
+
nx,
|
|
491
|
+
ny,
|
|
492
|
+
nw,
|
|
493
|
+
nh
|
|
494
|
+
)}"${dash}${dashAttr}/>`;
|
|
495
|
+
}
|
|
496
|
+
// arrow — Phase 24 reduces to ordered SVG primitives (canvas-arrowheads), the
|
|
497
|
+
// same primitives StrokeNode renders. Defaults (startHead 'none', endHead
|
|
498
|
+
// 'triangle', lineType 'straight', solid) reduce to exactly
|
|
499
|
+
// [<line>, <polyline fill=color>] → the byte-identical Phase 5.1 form. data-*
|
|
500
|
+
// attrs appear only for non-default values.
|
|
501
|
+
const startHead = s.startHead ?? 'none';
|
|
502
|
+
const endHead = s.endHead ?? 'triangle';
|
|
503
|
+
const lineType = s.lineType ?? 'straight';
|
|
504
|
+
const dashed = s.dashed ?? false;
|
|
505
|
+
// esc() every interpolated value (defence-in-depth, Phase 24 security review
|
|
506
|
+
// DDR-067) — heads are clamped on parse, but a value reaching serialize must
|
|
507
|
+
// never be able to break out of the attribute.
|
|
508
|
+
const dataAttrs =
|
|
509
|
+
(startHead !== 'none' ? ` data-start-head="${esc(startHead)}"` : '') +
|
|
510
|
+
(endHead !== 'triangle' ? ` data-end-head="${esc(endHead)}"` : '') +
|
|
511
|
+
(lineType !== 'straight' ? ` data-line-type="${esc(lineType)}"` : '') +
|
|
512
|
+
(dashed ? ' data-dash="1"' : '');
|
|
513
|
+
const body = arrowPrimitives(s).map(svgPrimitiveToString).join('');
|
|
514
|
+
return `<g ${common} fill="none"${dataAttrs}>${body}</g>`;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** Format one arrow SVG primitive for the persisted string (byte-identical to
|
|
518
|
+
* the Phase-5.1 `<line>`/`<polyline>` forms for the legacy default arrow). */
|
|
519
|
+
function svgPrimitiveToString(p: SvgPrimitive): string {
|
|
520
|
+
const dash = 'dash' in p && p.dash ? ' stroke-dasharray="6 4"' : '';
|
|
521
|
+
switch (p.el) {
|
|
522
|
+
case 'line':
|
|
523
|
+
return `<line x1="${p.x1}" y1="${p.y1}" x2="${p.x2}" y2="${p.y2}"${dash}/>`;
|
|
524
|
+
case 'path':
|
|
525
|
+
return `<path d="${p.d}"${dash}/>`;
|
|
526
|
+
case 'polyline':
|
|
527
|
+
return `<polyline points="${p.points}" fill="${esc(p.fill)}"/>`;
|
|
528
|
+
case 'polygon':
|
|
529
|
+
return `<polygon points="${p.points}" fill="${esc(p.fill)}"/>`;
|
|
530
|
+
case 'circle':
|
|
531
|
+
return `<circle cx="${p.cx}" cy="${p.cy}" r="${p.r}" fill="${esc(p.fill)}"/>`;
|
|
532
|
+
}
|
|
194
533
|
}
|
|
195
534
|
|
|
196
535
|
export function strokesToSvg(strokes: readonly Stroke[]): string {
|
|
@@ -219,6 +558,38 @@ function parseFill(raw: string | null): string | null {
|
|
|
219
558
|
return raw;
|
|
220
559
|
}
|
|
221
560
|
|
|
561
|
+
/**
|
|
562
|
+
* Phase 24 — recover an arrow's two endpoints from its shaft. A straight arrow
|
|
563
|
+
* persists a `<line>`; a curved/elbow arrow persists a `<path>` whose first and
|
|
564
|
+
* last coordinate pairs are the endpoints (the bow control / elbow corner sit
|
|
565
|
+
* between them, so first-pair = start, last-pair = end recovers the ends
|
|
566
|
+
* exactly → idempotent re-serialize).
|
|
567
|
+
*/
|
|
568
|
+
function arrowEndpoints(el: Element): { x1: number; y1: number; x2: number; y2: number } | null {
|
|
569
|
+
const line = el.querySelector('line');
|
|
570
|
+
if (line) {
|
|
571
|
+
return {
|
|
572
|
+
x1: Number.parseFloat(line.getAttribute('x1') || '0'),
|
|
573
|
+
y1: Number.parseFloat(line.getAttribute('y1') || '0'),
|
|
574
|
+
x2: Number.parseFloat(line.getAttribute('x2') || '0'),
|
|
575
|
+
y2: Number.parseFloat(line.getAttribute('y2') || '0'),
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
const path = el.querySelector('path');
|
|
579
|
+
if (path) {
|
|
580
|
+
const nums = (path.getAttribute('d') || '').match(/-?\d+(?:\.\d+)?/g);
|
|
581
|
+
if (nums && nums.length >= 4) {
|
|
582
|
+
return {
|
|
583
|
+
x1: Number.parseFloat(nums[0] as string),
|
|
584
|
+
y1: Number.parseFloat(nums[1] as string),
|
|
585
|
+
x2: Number.parseFloat(nums[nums.length - 2] as string),
|
|
586
|
+
y2: Number.parseFloat(nums[nums.length - 1] as string),
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
return null;
|
|
591
|
+
}
|
|
592
|
+
|
|
222
593
|
export function svgToStrokes(svgText: string): Stroke[] {
|
|
223
594
|
const text = (svgText ?? '').trim();
|
|
224
595
|
if (!text) return [];
|
|
@@ -238,13 +609,52 @@ export function svgToStrokes(svgText: string): Stroke[] {
|
|
|
238
609
|
if (points.length) out.push({ id, tool: 'pen', color, width, points });
|
|
239
610
|
continue;
|
|
240
611
|
}
|
|
612
|
+
if (tool === 'sticky') {
|
|
613
|
+
// Phase 21 — sticky reads geometry off its <rect> child, paper tint
|
|
614
|
+
// off the group fill, body text off the inner <text>.
|
|
615
|
+
const rectEl = el.querySelector('rect');
|
|
616
|
+
const x = Number.parseFloat(rectEl?.getAttribute('x') || '0');
|
|
617
|
+
const y = Number.parseFloat(rectEl?.getAttribute('y') || '0');
|
|
618
|
+
const w = Number.parseFloat(rectEl?.getAttribute('width') || '0');
|
|
619
|
+
const h = Number.parseFloat(rectEl?.getAttribute('height') || '0');
|
|
620
|
+
const cornerRadius =
|
|
621
|
+
Number.parseFloat(el.getAttribute('data-r') || String(STICKY_CORNER_RADIUS)) || 0;
|
|
622
|
+
const fontSize =
|
|
623
|
+
Number.parseFloat(el.getAttribute('data-fs') || String(DEFAULT_FONT_SIZE)) ||
|
|
624
|
+
DEFAULT_FONT_SIZE;
|
|
625
|
+
const stickyColor = el.getAttribute('fill') || DEFAULT_STICKY_COLOR;
|
|
626
|
+
const body = el.querySelector('text');
|
|
627
|
+
const sticky: StickyStroke = {
|
|
628
|
+
id,
|
|
629
|
+
tool: 'sticky',
|
|
630
|
+
color: stickyColor,
|
|
631
|
+
x,
|
|
632
|
+
y,
|
|
633
|
+
w,
|
|
634
|
+
h,
|
|
635
|
+
text: body?.textContent ?? '',
|
|
636
|
+
fontSize,
|
|
637
|
+
cornerRadius,
|
|
638
|
+
};
|
|
639
|
+
// Phase 24 — style attrs; absent ⇒ defaults (normal / left), left unset.
|
|
640
|
+
if (el.getAttribute('data-bold') === '1') sticky.bold = true;
|
|
641
|
+
if (el.getAttribute('data-strike') === '1') sticky.strike = true;
|
|
642
|
+
const sticAlign = el.getAttribute('data-align');
|
|
643
|
+
if (sticAlign === 'left' || sticAlign === 'center' || sticAlign === 'right') {
|
|
644
|
+
sticky.align = sticAlign;
|
|
645
|
+
}
|
|
646
|
+
out.push(sticky);
|
|
647
|
+
continue;
|
|
648
|
+
}
|
|
241
649
|
if (tool === 'rect') {
|
|
242
650
|
const x = Number.parseFloat(el.getAttribute('x') || '0');
|
|
243
651
|
const y = Number.parseFloat(el.getAttribute('y') || '0');
|
|
244
652
|
const w = Number.parseFloat(el.getAttribute('width') || '0');
|
|
245
653
|
const h = Number.parseFloat(el.getAttribute('height') || '0');
|
|
246
654
|
const fill = parseFill(el.getAttribute('fill'));
|
|
247
|
-
|
|
655
|
+
// Phase 21 — corner radius; absent ⇒ 0 (sharp, back-compat).
|
|
656
|
+
const cornerRadius = Number.parseFloat(el.getAttribute('data-r') || '0') || 0;
|
|
657
|
+
out.push({ id, tool: 'rect', color, width, x, y, w, h, fill, cornerRadius });
|
|
248
658
|
continue;
|
|
249
659
|
}
|
|
250
660
|
if (tool === 'ellipse') {
|
|
@@ -256,36 +666,113 @@ export function svgToStrokes(svgText: string): Stroke[] {
|
|
|
256
666
|
out.push({ id, tool: 'ellipse', color, width, cx, cy, rx, ry, fill });
|
|
257
667
|
continue;
|
|
258
668
|
}
|
|
669
|
+
if (tool === 'polygon') {
|
|
670
|
+
// Phase 24 — recover the bbox from the points; shape from data-shape.
|
|
671
|
+
const shapeRaw = el.getAttribute('data-shape');
|
|
672
|
+
const shape: PolygonShape =
|
|
673
|
+
shapeRaw === 'triangle' || shapeRaw === 'triangle-down' ? shapeRaw : 'diamond';
|
|
674
|
+
const bb = polygonBBox(el.getAttribute('points') || '');
|
|
675
|
+
if (bb) {
|
|
676
|
+
const fill = parseFill(el.getAttribute('fill'));
|
|
677
|
+
const poly: PolygonStroke = {
|
|
678
|
+
id,
|
|
679
|
+
tool: 'polygon',
|
|
680
|
+
shape,
|
|
681
|
+
color,
|
|
682
|
+
width,
|
|
683
|
+
x: bb.x,
|
|
684
|
+
y: bb.y,
|
|
685
|
+
w: bb.w,
|
|
686
|
+
h: bb.h,
|
|
687
|
+
fill,
|
|
688
|
+
};
|
|
689
|
+
const dashRaw = el.getAttribute('data-dash');
|
|
690
|
+
if (dashRaw === '1' || dashRaw === 'true') poly.dashed = true;
|
|
691
|
+
out.push(poly);
|
|
692
|
+
}
|
|
693
|
+
continue;
|
|
694
|
+
}
|
|
259
695
|
if (tool === 'arrow') {
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
696
|
+
// Phase 24 — shaft is a <line> (straight) OR a <path> (curved/elbow).
|
|
697
|
+
// Recover the two endpoints from whichever is present.
|
|
698
|
+
const ends = arrowEndpoints(el);
|
|
699
|
+
if (ends) {
|
|
700
|
+
const arrow: ArrowStroke = {
|
|
263
701
|
id,
|
|
264
702
|
tool: 'arrow',
|
|
265
703
|
color,
|
|
266
704
|
width,
|
|
267
|
-
x1:
|
|
268
|
-
y1:
|
|
269
|
-
x2:
|
|
270
|
-
y2:
|
|
271
|
-
}
|
|
705
|
+
x1: ends.x1,
|
|
706
|
+
y1: ends.y1,
|
|
707
|
+
x2: ends.x2,
|
|
708
|
+
y2: ends.y2,
|
|
709
|
+
};
|
|
710
|
+
// Heads + dash + line-type. The serializer writes a data-* attribute
|
|
711
|
+
// only for a NON-default value, so a legacy arrow carries none of
|
|
712
|
+
// these and stays { startHead/endHead/dashed/lineType: undefined } →
|
|
713
|
+
// defaults on re-serialize (byte-identical, canary). Phase 24 widened
|
|
714
|
+
// the head enum, so read the literal value rather than match a single
|
|
715
|
+
// string.
|
|
716
|
+
// Clamp to the known head vocabulary — an out-of-vocab / poisoned
|
|
717
|
+
// value (hub-pushed SVG) is rejected, never cast through unchecked
|
|
718
|
+
// (Phase 24 security review, DDR-067).
|
|
719
|
+
const sh = el.getAttribute('data-start-head');
|
|
720
|
+
if (sh && ARROW_HEADS.has(sh)) arrow.startHead = sh as ArrowHead;
|
|
721
|
+
const eh = el.getAttribute('data-end-head');
|
|
722
|
+
if (eh && ARROW_HEADS.has(eh)) arrow.endHead = eh as ArrowHead;
|
|
723
|
+
const lt = el.getAttribute('data-line-type');
|
|
724
|
+
if (lt === 'curved' || lt === 'elbow' || lt === 'straight') arrow.lineType = lt;
|
|
725
|
+
const dashRaw = el.getAttribute('data-dash');
|
|
726
|
+
if (dashRaw === '1' || dashRaw === 'true') arrow.dashed = true;
|
|
727
|
+
out.push(arrow);
|
|
272
728
|
}
|
|
273
729
|
continue;
|
|
274
730
|
}
|
|
275
731
|
if (tool === 'text') {
|
|
276
|
-
const
|
|
732
|
+
const rawAnchor = el.getAttribute('data-anchor-id');
|
|
277
733
|
const fontSize =
|
|
278
734
|
Number.parseFloat(el.getAttribute('data-font-size') || String(DEFAULT_FONT_SIZE)) ||
|
|
279
735
|
DEFAULT_FONT_SIZE;
|
|
280
736
|
const inkColor = el.getAttribute('fill') || color;
|
|
281
|
-
|
|
737
|
+
const body = (el.textContent || '').trim();
|
|
738
|
+
// Phase 24 — bold / strike / align. `data-align` is the round-trip
|
|
739
|
+
// source of truth (text-anchor is derived from it). Absent ⇒ default
|
|
740
|
+
// (normal / per-kind align), left unset so legacy nodes round-trip.
|
|
741
|
+
const isBold = el.getAttribute('font-weight') === '700';
|
|
742
|
+
const isStrike = (el.getAttribute('text-decoration') || '').includes('line-through');
|
|
743
|
+
const da = el.getAttribute('data-align');
|
|
744
|
+
const align: TextAlign | undefined =
|
|
745
|
+
da === 'left' || da === 'center' || da === 'right' ? da : undefined;
|
|
746
|
+
// Phase 21 — standalone text (no data-anchor-id) carries world x/y
|
|
747
|
+
// instead of a host id.
|
|
748
|
+
if (!rawAnchor) {
|
|
749
|
+
const t: TextStroke = {
|
|
750
|
+
id,
|
|
751
|
+
tool: 'text',
|
|
752
|
+
color: inkColor,
|
|
753
|
+
fontSize,
|
|
754
|
+
text: body,
|
|
755
|
+
x: Number.parseFloat(el.getAttribute('x') || '0'),
|
|
756
|
+
y: Number.parseFloat(el.getAttribute('y') || '0'),
|
|
757
|
+
};
|
|
758
|
+
if (isBold) t.bold = true;
|
|
759
|
+
if (isStrike) t.strike = true;
|
|
760
|
+
if (align) t.align = align;
|
|
761
|
+
out.push(t);
|
|
762
|
+
continue;
|
|
763
|
+
}
|
|
764
|
+
const t: TextStroke = {
|
|
282
765
|
id,
|
|
283
766
|
tool: 'text',
|
|
284
767
|
color: inkColor,
|
|
285
768
|
fontSize,
|
|
286
|
-
text:
|
|
287
|
-
anchorId,
|
|
288
|
-
}
|
|
769
|
+
text: body,
|
|
770
|
+
anchorId: rawAnchor,
|
|
771
|
+
};
|
|
772
|
+
if (isBold) t.bold = true;
|
|
773
|
+
if (isStrike) t.strike = true;
|
|
774
|
+
if (align) t.align = align;
|
|
775
|
+
out.push(t);
|
|
289
776
|
}
|
|
290
777
|
}
|
|
291
778
|
return out;
|
|
@@ -312,8 +799,26 @@ function pointSegmentDist(
|
|
|
312
799
|
}
|
|
313
800
|
|
|
314
801
|
export function strokeHitTest(s: Stroke, wx: number, wy: number, tol: number): boolean {
|
|
315
|
-
if (s.tool === 'text')
|
|
802
|
+
if (s.tool === 'text') {
|
|
803
|
+
// Anchored text isn't independently hit-testable (it inherits its host).
|
|
804
|
+
// Standalone text (Phase 21) uses its synthetic bbox so the eraser can
|
|
805
|
+
// reach it.
|
|
806
|
+
if (s.anchorId != null && s.anchorId !== '') return false;
|
|
807
|
+
const bb = strokeBBox(s);
|
|
808
|
+
if (!bb) return false;
|
|
809
|
+
return (
|
|
810
|
+
wx >= bb.x - tol && wx <= bb.x + bb.w + tol && wy >= bb.y - tol && wy <= bb.y + bb.h + tol
|
|
811
|
+
);
|
|
812
|
+
}
|
|
316
813
|
const t = Math.max(tol, 'width' in s ? s.width : 2);
|
|
814
|
+
if (s.tool === 'sticky') {
|
|
815
|
+
// Sticky is a solid paper card — filled-rect hit anywhere inside.
|
|
816
|
+
const xMin = Math.min(s.x, s.x + s.w);
|
|
817
|
+
const xMax = Math.max(s.x, s.x + s.w);
|
|
818
|
+
const yMin = Math.min(s.y, s.y + s.h);
|
|
819
|
+
const yMax = Math.max(s.y, s.y + s.h);
|
|
820
|
+
return wx >= xMin - t && wx <= xMax + t && wy >= yMin - t && wy <= yMax + t;
|
|
821
|
+
}
|
|
317
822
|
if (s.tool === 'pen') {
|
|
318
823
|
if (s.points.length === 1) {
|
|
319
824
|
const p = s.points[0] as WorldPoint;
|
|
@@ -341,6 +846,20 @@ export function strokeHitTest(s: Stroke, wx: number, wy: number, tol: number): b
|
|
|
341
846
|
const dist = Math.abs(Math.sqrt(d) - 1);
|
|
342
847
|
return dist <= band;
|
|
343
848
|
}
|
|
849
|
+
if (s.tool === 'polygon') {
|
|
850
|
+
const nx = Math.min(s.x, s.x + s.w);
|
|
851
|
+
const ny = Math.min(s.y, s.y + s.h);
|
|
852
|
+
const pts = polygonVertices(s.shape, nx, ny, Math.abs(s.w), Math.abs(s.h));
|
|
853
|
+
// Filled → inside-hit; always allow an edge-proximity hit (covers the
|
|
854
|
+
// stroke-only outline + a tolerance band on a filled shape).
|
|
855
|
+
if (s.fill && pointInPolygon(wx, wy, pts)) return true;
|
|
856
|
+
for (let i = 0; i < pts.length; i++) {
|
|
857
|
+
const a = pts[i] as [number, number];
|
|
858
|
+
const b = pts[(i + 1) % pts.length] as [number, number];
|
|
859
|
+
if (pointSegmentDist(wx, wy, a[0], a[1], b[0], b[1]) <= t) return true;
|
|
860
|
+
}
|
|
861
|
+
return false;
|
|
862
|
+
}
|
|
344
863
|
// rect — inside when filled, edge-only otherwise.
|
|
345
864
|
const x = s.x;
|
|
346
865
|
const y = s.y;
|
|
@@ -362,7 +881,8 @@ export function strokeHitTest(s: Stroke, wx: number, wy: number, tol: number): b
|
|
|
362
881
|
return onLeft || onRight || onTop || onBottom;
|
|
363
882
|
}
|
|
364
883
|
|
|
365
|
-
|
|
884
|
+
/** Flip a negative-extent box so x/y is the top-left and w/h are positive. */
|
|
885
|
+
function normalizeBox<T extends { x: number; y: number; w: number; h: number }>(r: T): T {
|
|
366
886
|
if (r.w >= 0 && r.h >= 0) return r;
|
|
367
887
|
return {
|
|
368
888
|
...r,
|
|
@@ -373,11 +893,115 @@ function normalizeRect(r: RectStroke): RectStroke {
|
|
|
373
893
|
};
|
|
374
894
|
}
|
|
375
895
|
|
|
896
|
+
/**
|
|
897
|
+
* Phase 24 — draw-time resize modifiers (FigJam parity, mirror of the
|
|
898
|
+
* `use-annotation-resize.tsx` set so create + resize feel identical):
|
|
899
|
+
* • `shift` — lock to 1:1 (square / circle); the larger drag axis sets the
|
|
900
|
+
* side, each axis keeps its own sign so the drag direction holds.
|
|
901
|
+
* • `alt` — grow from the pointer-down point as CENTER (symmetric).
|
|
902
|
+
* With neither held the box is `{ x: down, w: cursor − down }` — byte-identical
|
|
903
|
+
* to the previous corner-drag math.
|
|
904
|
+
*/
|
|
905
|
+
export interface DrawMods {
|
|
906
|
+
shift: boolean;
|
|
907
|
+
alt: boolean;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/** Constrain a draw drag (`ax,ay` = pointer-down anchor; `wx,wy` = cursor). */
|
|
911
|
+
export function constrainDrawBox(
|
|
912
|
+
ax: number,
|
|
913
|
+
ay: number,
|
|
914
|
+
wx: number,
|
|
915
|
+
wy: number,
|
|
916
|
+
mods: DrawMods
|
|
917
|
+
): { x: number; y: number; w: number; h: number } {
|
|
918
|
+
let dx = wx - ax;
|
|
919
|
+
let dy = wy - ay;
|
|
920
|
+
if (mods.shift) {
|
|
921
|
+
const side = Math.max(Math.abs(dx), Math.abs(dy));
|
|
922
|
+
dx = (dx < 0 ? -1 : 1) * side;
|
|
923
|
+
dy = (dy < 0 ? -1 : 1) * side;
|
|
924
|
+
}
|
|
925
|
+
if (mods.alt) {
|
|
926
|
+
// Anchor is the center → span ±|d| on each axis around it.
|
|
927
|
+
return { x: ax - dx, y: ay - dy, w: 2 * dx, h: 2 * dy };
|
|
928
|
+
}
|
|
929
|
+
return { x: ax, y: ay, w: dx, h: dy };
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
/**
|
|
933
|
+
* Apply the draw-time modifiers to the in-progress stroke. Shared by the
|
|
934
|
+
* pointer-move handler and the live keydown/keyup re-apply, so holding Shift /
|
|
935
|
+
* Alt updates the draft even without moving the cursor. `anchor` is the
|
|
936
|
+
* pointer-down point; pen / text carry no box so they pass through unchanged.
|
|
937
|
+
*/
|
|
938
|
+
export function applyDrawModifiers(
|
|
939
|
+
cur: Stroke,
|
|
940
|
+
anchor: { x: number; y: number },
|
|
941
|
+
wx: number,
|
|
942
|
+
wy: number,
|
|
943
|
+
mods: DrawMods
|
|
944
|
+
): Stroke {
|
|
945
|
+
if (cur.tool === 'rect' || cur.tool === 'polygon') {
|
|
946
|
+
const b = constrainDrawBox(anchor.x, anchor.y, wx, wy, mods);
|
|
947
|
+
return { ...cur, x: b.x, y: b.y, w: b.w, h: b.h };
|
|
948
|
+
}
|
|
949
|
+
if (cur.tool === 'ellipse') {
|
|
950
|
+
const b = constrainDrawBox(anchor.x, anchor.y, wx, wy, mods);
|
|
951
|
+
return {
|
|
952
|
+
...cur,
|
|
953
|
+
cx: b.x + b.w / 2,
|
|
954
|
+
cy: b.y + b.h / 2,
|
|
955
|
+
rx: Math.abs(b.w) / 2,
|
|
956
|
+
ry: Math.abs(b.h) / 2,
|
|
957
|
+
};
|
|
958
|
+
}
|
|
959
|
+
if (cur.tool === 'sticky') {
|
|
960
|
+
// Stickies are always 1:1 — force the square constraint; Alt still centers.
|
|
961
|
+
const b = constrainDrawBox(anchor.x, anchor.y, wx, wy, { shift: true, alt: mods.alt });
|
|
962
|
+
return { ...cur, x: b.x, y: b.y, w: b.w, h: b.h };
|
|
963
|
+
}
|
|
964
|
+
if (cur.tool === 'arrow') {
|
|
965
|
+
let x2 = wx;
|
|
966
|
+
let y2 = wy;
|
|
967
|
+
if (mods.shift) {
|
|
968
|
+
// Snap the shaft to the nearest 45° around the anchor (its midpoint
|
|
969
|
+
// under Alt), keeping the cursor's distance.
|
|
970
|
+
const dx = wx - anchor.x;
|
|
971
|
+
const dy = wy - anchor.y;
|
|
972
|
+
const dist = Math.hypot(dx, dy);
|
|
973
|
+
const step = Math.PI / 4;
|
|
974
|
+
const ang = Math.round(Math.atan2(dy, dx) / step) * step;
|
|
975
|
+
x2 = anchor.x + Math.cos(ang) * dist;
|
|
976
|
+
y2 = anchor.y + Math.sin(ang) * dist;
|
|
977
|
+
}
|
|
978
|
+
if (mods.alt) {
|
|
979
|
+
// Anchor is the midpoint → the start end mirrors the dragged end.
|
|
980
|
+
return { ...cur, x1: 2 * anchor.x - x2, y1: 2 * anchor.y - y2, x2, y2 };
|
|
981
|
+
}
|
|
982
|
+
return { ...cur, x1: anchor.x, y1: anchor.y, x2, y2 };
|
|
983
|
+
}
|
|
984
|
+
return cur;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
function normalizeRect(r: RectStroke): RectStroke {
|
|
988
|
+
return normalizeBox(r);
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
// Phase 21 — sticky shares rect's drag-to-create flip (x = min, w = abs(w)).
|
|
992
|
+
function normalizeSticky(s: StickyStroke): StickyStroke {
|
|
993
|
+
return normalizeBox(s);
|
|
994
|
+
}
|
|
995
|
+
|
|
376
996
|
function isStrokeMeaningful(s: Stroke): boolean {
|
|
377
997
|
if (s.tool === 'pen') return s.points.length >= 2;
|
|
378
998
|
if (s.tool === 'rect') return Math.abs(s.w) >= 4 && Math.abs(s.h) >= 4;
|
|
999
|
+
if (s.tool === 'polygon') return Math.abs(s.w) >= 4 && Math.abs(s.h) >= 4;
|
|
379
1000
|
if (s.tool === 'ellipse') return s.rx >= 2 && s.ry >= 2;
|
|
380
1001
|
if (s.tool === 'text') return s.text.trim().length > 0;
|
|
1002
|
+
// Sticky below a readable floor is discarded like a 2×2 rect.
|
|
1003
|
+
if (s.tool === 'sticky')
|
|
1004
|
+
return Math.abs(s.w) >= STICKY_MIN_SIZE && Math.abs(s.h) >= STICKY_MIN_SIZE;
|
|
381
1005
|
return Math.hypot(s.x2 - s.x1, s.y2 - s.y1) >= 4;
|
|
382
1006
|
}
|
|
383
1007
|
|
|
@@ -399,7 +1023,7 @@ export function strokeBBox(
|
|
|
399
1023
|
}
|
|
400
1024
|
return { x: xMin, y: yMin, w: xMax - xMin, h: yMax - yMin };
|
|
401
1025
|
}
|
|
402
|
-
if (s.tool === 'rect') {
|
|
1026
|
+
if (s.tool === 'rect' || s.tool === 'polygon') {
|
|
403
1027
|
return {
|
|
404
1028
|
x: Math.min(s.x, s.x + s.w),
|
|
405
1029
|
y: Math.min(s.y, s.y + s.h),
|
|
@@ -418,9 +1042,29 @@ export function strokeBBox(
|
|
|
418
1042
|
h: Math.abs(s.y2 - s.y1),
|
|
419
1043
|
};
|
|
420
1044
|
}
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
1045
|
+
if (s.tool === 'sticky') {
|
|
1046
|
+
return {
|
|
1047
|
+
x: Math.min(s.x, s.x + s.w),
|
|
1048
|
+
y: Math.min(s.y, s.y + s.h),
|
|
1049
|
+
w: Math.abs(s.w),
|
|
1050
|
+
h: Math.abs(s.h),
|
|
1051
|
+
};
|
|
1052
|
+
}
|
|
1053
|
+
// text — anchored inherits its host's bbox; standalone (Phase 21) gets a
|
|
1054
|
+
// synthetic bbox from its world (x, y) so it's selectable and the context
|
|
1055
|
+
// toolbar can position against it.
|
|
1056
|
+
if (s.anchorId != null && s.anchorId !== '') {
|
|
1057
|
+
const host = anchors?.get(s.anchorId);
|
|
1058
|
+
return host ? strokeBBox(host) : null;
|
|
1059
|
+
}
|
|
1060
|
+
const tx = s.x ?? 0;
|
|
1061
|
+
const ty = s.y ?? 0;
|
|
1062
|
+
return {
|
|
1063
|
+
x: tx,
|
|
1064
|
+
y: ty,
|
|
1065
|
+
w: Math.max(8, s.text.length * s.fontSize * 0.55),
|
|
1066
|
+
h: s.fontSize * 1.2,
|
|
1067
|
+
};
|
|
424
1068
|
}
|
|
425
1069
|
|
|
426
1070
|
function isEditable(t: EventTarget | null): boolean {
|
|
@@ -454,93 +1098,113 @@ function deriveFile(): string | undefined {
|
|
|
454
1098
|
const ANNOT_CSS = `
|
|
455
1099
|
.dc-annot-chrome {
|
|
456
1100
|
/* Stacks directly above the centered tool toolbar (which is bottom:16px,
|
|
457
|
-
32px tall → top edge ~ bottom:48px). 8 px gap → chrome at bottom:60px.
|
|
1101
|
+
32px tall → top edge ~ bottom:48px). 8 px gap → chrome at bottom:60px.
|
|
1102
|
+
Phase 21 — dark "marker tray" matching the FigJam selection bar. */
|
|
458
1103
|
position: absolute;
|
|
459
1104
|
left: 50%;
|
|
460
|
-
bottom:
|
|
1105
|
+
bottom: 64px;
|
|
461
1106
|
transform: translateX(-50%);
|
|
462
1107
|
display: flex;
|
|
463
1108
|
align-items: center;
|
|
464
|
-
gap:
|
|
465
|
-
background:
|
|
466
|
-
border: 1px solid
|
|
467
|
-
border-radius:
|
|
468
|
-
padding:
|
|
469
|
-
font-family: var(--
|
|
1109
|
+
gap: 2px;
|
|
1110
|
+
background: #26262b;
|
|
1111
|
+
border: 1px solid rgba(255,255,255,0.08);
|
|
1112
|
+
border-radius: 12px;
|
|
1113
|
+
padding: 5px 8px;
|
|
1114
|
+
font-family: var(--maude-chrome-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
|
470
1115
|
font-size: 11px;
|
|
471
|
-
color:
|
|
1116
|
+
color: rgba(255,255,255,0.85);
|
|
472
1117
|
z-index: 6;
|
|
473
|
-
box-shadow: 0
|
|
1118
|
+
box-shadow: 0 8px 28px rgba(0,0,0,0.34), 0 2px 6px rgba(0,0,0,0.22);
|
|
474
1119
|
user-select: none;
|
|
475
1120
|
}
|
|
476
|
-
.dc-annot-chrome .dc-annot-swatches { display: flex; gap:
|
|
1121
|
+
.dc-annot-chrome .dc-annot-swatches { display: flex; align-items: center; gap: 1px; }
|
|
477
1122
|
.dc-annot-chrome .dc-annot-sw {
|
|
478
|
-
width:
|
|
479
|
-
height:
|
|
480
|
-
border-radius:
|
|
481
|
-
border: 1px solid rgba(
|
|
1123
|
+
width: 20px;
|
|
1124
|
+
height: 20px;
|
|
1125
|
+
border-radius: 50%;
|
|
1126
|
+
border: 1px solid rgba(255,255,255,0.16);
|
|
482
1127
|
cursor: pointer;
|
|
483
1128
|
padding: 0;
|
|
484
1129
|
appearance: none;
|
|
1130
|
+
transition: transform 80ms ease;
|
|
485
1131
|
}
|
|
1132
|
+
.dc-annot-chrome .dc-annot-sw:hover { transform: scale(1.1); }
|
|
486
1133
|
.dc-annot-chrome .dc-annot-sw[aria-pressed="true"] {
|
|
487
|
-
box-shadow: 0 0 0 2px
|
|
1134
|
+
box-shadow: 0 0 0 2px #26262b, 0 0 0 3px rgba(255,255,255,0.92);
|
|
488
1135
|
border-color: transparent;
|
|
489
1136
|
}
|
|
490
1137
|
.dc-annot-chrome .dc-annot-sw:focus-visible {
|
|
491
|
-
outline: 2px solid
|
|
492
|
-
outline-offset:
|
|
1138
|
+
outline: 2px solid #ffffff;
|
|
1139
|
+
outline-offset: 1px;
|
|
493
1140
|
}
|
|
494
1141
|
.dc-annot-chrome .dc-annot-sep {
|
|
495
1142
|
width: 1px;
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
1143
|
+
height: 16px;
|
|
1144
|
+
align-self: center;
|
|
1145
|
+
background: rgba(255,255,255,0.09);
|
|
1146
|
+
margin: 0 4px;
|
|
499
1147
|
}
|
|
500
1148
|
.dc-annot-chrome .dc-annot-fill {
|
|
501
|
-
width:
|
|
502
|
-
height:
|
|
503
|
-
border-radius:
|
|
504
|
-
border: 1px solid rgba(
|
|
1149
|
+
width: 20px;
|
|
1150
|
+
height: 20px;
|
|
1151
|
+
border-radius: 50%;
|
|
1152
|
+
border: 1px solid rgba(255,255,255,0.16);
|
|
505
1153
|
cursor: pointer;
|
|
506
1154
|
padding: 0;
|
|
507
1155
|
appearance: none;
|
|
508
1156
|
position: relative;
|
|
1157
|
+
transition: transform 80ms ease;
|
|
1158
|
+
}
|
|
1159
|
+
.dc-annot-chrome .dc-annot-fill:hover { transform: scale(1.1); }
|
|
1160
|
+
.dc-annot-chrome .dc-annot-fill:focus-visible {
|
|
1161
|
+
outline: 2px solid #ffffff;
|
|
1162
|
+
outline-offset: 1px;
|
|
509
1163
|
}
|
|
510
1164
|
.dc-annot-chrome .dc-annot-fill--none {
|
|
511
|
-
background: #
|
|
1165
|
+
background: #3a3a40;
|
|
512
1166
|
}
|
|
513
1167
|
.dc-annot-chrome .dc-annot-fill--none::after {
|
|
514
1168
|
content: "";
|
|
515
|
-
position: absolute; inset:
|
|
1169
|
+
position: absolute; inset: 4px;
|
|
1170
|
+
border-radius: 50%;
|
|
516
1171
|
background:
|
|
517
|
-
linear-gradient(135deg, transparent
|
|
1172
|
+
linear-gradient(135deg, transparent 44%, rgba(255,255,255,0.55) 44%, rgba(255,255,255,0.55) 56%, transparent 56%);
|
|
518
1173
|
}
|
|
519
1174
|
.dc-annot-chrome .dc-annot-fill[aria-pressed="true"] {
|
|
520
|
-
box-shadow: 0 0 0 2px
|
|
1175
|
+
box-shadow: 0 0 0 2px #26262b, 0 0 0 3px rgba(255,255,255,0.92);
|
|
521
1176
|
border-color: transparent;
|
|
522
1177
|
}
|
|
523
|
-
|
|
1178
|
+
/* Phase 21 — icon buttons (light glyph on dark, white-tint active). */
|
|
1179
|
+
.dc-annot-chrome .dc-annot-ibtn {
|
|
524
1180
|
appearance: none;
|
|
525
1181
|
background: transparent;
|
|
526
|
-
border:
|
|
527
|
-
border-radius:
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
1182
|
+
border: 0;
|
|
1183
|
+
border-radius: 7px;
|
|
1184
|
+
width: 26px;
|
|
1185
|
+
height: 26px;
|
|
1186
|
+
display: inline-flex;
|
|
1187
|
+
align-items: center;
|
|
1188
|
+
justify-content: center;
|
|
1189
|
+
color: rgba(255,255,255,0.78);
|
|
531
1190
|
cursor: pointer;
|
|
532
|
-
|
|
533
|
-
|
|
1191
|
+
padding: 0;
|
|
1192
|
+
transition: background-color 80ms linear, color 80ms linear;
|
|
534
1193
|
}
|
|
535
|
-
.dc-annot-chrome .dc-annot-
|
|
536
|
-
background:
|
|
537
|
-
color:
|
|
538
|
-
border-color: transparent;
|
|
1194
|
+
.dc-annot-chrome .dc-annot-ibtn:hover {
|
|
1195
|
+
background: rgba(255,255,255,0.1);
|
|
1196
|
+
color: #ffffff;
|
|
539
1197
|
}
|
|
540
|
-
.dc-annot-chrome .dc-annot-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
1198
|
+
.dc-annot-chrome .dc-annot-ibtn[aria-pressed="true"] {
|
|
1199
|
+
background: rgba(255,255,255,0.18);
|
|
1200
|
+
color: #ffffff;
|
|
1201
|
+
}
|
|
1202
|
+
.dc-annot-chrome .dc-annot-ibtn:focus-visible {
|
|
1203
|
+
outline: 2px solid #ffffff;
|
|
1204
|
+
outline-offset: -2px;
|
|
1205
|
+
}
|
|
1206
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1207
|
+
.dc-annot-chrome .dc-annot-ibtn, .dc-annot-chrome .dc-annot-sw, .dc-annot-chrome .dc-annot-fill { transition: none; }
|
|
544
1208
|
}
|
|
545
1209
|
.dc-annot-input {
|
|
546
1210
|
position: absolute;
|
|
@@ -573,6 +1237,35 @@ const ANNOT_CSS = `
|
|
|
573
1237
|
stroke-width: 1;
|
|
574
1238
|
stroke-dasharray: 4 3;
|
|
575
1239
|
}
|
|
1240
|
+
/* Phase 24 — sticky-note body. Word-wrapped multi-line text inside the card's
|
|
1241
|
+
foreignObject. Text sits TOP-LEFT (FigJam parity); the editor contentEditable
|
|
1242
|
+
mirrors the same box metrics so the read-edit swap doesn't shift the text.
|
|
1243
|
+
text-align is overridden inline per-sticky when align is not left. */
|
|
1244
|
+
.dc-sticky-body {
|
|
1245
|
+
width: 100%;
|
|
1246
|
+
height: 100%;
|
|
1247
|
+
box-sizing: border-box;
|
|
1248
|
+
padding: 14px 16px;
|
|
1249
|
+
display: flex;
|
|
1250
|
+
align-items: flex-start;
|
|
1251
|
+
justify-content: flex-start;
|
|
1252
|
+
text-align: left;
|
|
1253
|
+
color: #2a2a28;
|
|
1254
|
+
font-family: var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
|
1255
|
+
line-height: 1.35;
|
|
1256
|
+
white-space: pre-wrap;
|
|
1257
|
+
overflow-wrap: anywhere;
|
|
1258
|
+
overflow: hidden;
|
|
1259
|
+
}
|
|
1260
|
+
/* Phase 24 — while editing an annotation's text (a text label OR a sticky body,
|
|
1261
|
+
both carry the dc-annot-editor class), force the I-beam. The important flag
|
|
1262
|
+
plus the class selector beat use-tool-mode's blanket star-cursor rule (you
|
|
1263
|
+
usually open the editor from MOVE mode, whose move glyph would otherwise sit
|
|
1264
|
+
over the text you're typing into). The element's inline text cursor can't win
|
|
1265
|
+
that fight on its own — a non-important inline style loses to !important.
|
|
1266
|
+
See DDR-067. (No backticks in this comment: the whole block is a JS template
|
|
1267
|
+
literal, so a backtick here would terminate the string.) */
|
|
1268
|
+
.dc-annot-editor, .dc-annot-editor * { cursor: text !important; }
|
|
576
1269
|
`.trim();
|
|
577
1270
|
|
|
578
1271
|
function ensureAnnotStyles(): void {
|
|
@@ -606,11 +1299,15 @@ function translateOne(s: Stroke, dx: number, dy: number): Stroke {
|
|
|
606
1299
|
if (s.tool === 'pen') {
|
|
607
1300
|
return { ...s, points: s.points.map(([x, y]) => [x + dx, y + dy] as WorldPoint) };
|
|
608
1301
|
}
|
|
609
|
-
if (s.tool === 'rect') return { ...s, x: s.x + dx, y: s.y + dy };
|
|
1302
|
+
if (s.tool === 'rect' || s.tool === 'polygon') return { ...s, x: s.x + dx, y: s.y + dy };
|
|
610
1303
|
if (s.tool === 'ellipse') return { ...s, cx: s.cx + dx, cy: s.cy + dy };
|
|
611
1304
|
if (s.tool === 'arrow')
|
|
612
1305
|
return { ...s, x1: s.x1 + dx, y1: s.y1 + dy, x2: s.x2 + dx, y2: s.y2 + dy };
|
|
613
|
-
return s
|
|
1306
|
+
if (s.tool === 'sticky') return { ...s, x: s.x + dx, y: s.y + dy };
|
|
1307
|
+
// text — anchored inherits its host's bbox (moves with the host); standalone
|
|
1308
|
+
// (Phase 21) carries its own world (x, y) and translates directly.
|
|
1309
|
+
if (s.anchorId != null && s.anchorId !== '') return s;
|
|
1310
|
+
return { ...s, x: (s.x ?? 0) + dx, y: (s.y ?? 0) + dy };
|
|
614
1311
|
}
|
|
615
1312
|
|
|
616
1313
|
/**
|
|
@@ -640,7 +1337,7 @@ export { useAnnotationsVisibility } from './use-annotations-visibility.tsx';
|
|
|
640
1337
|
|
|
641
1338
|
export function AnnotationsLayer() {
|
|
642
1339
|
ensureAnnotStyles();
|
|
643
|
-
const { tool, setTool, sticky } = useToolMode();
|
|
1340
|
+
const { tool, setTool, sticky, tools, shapeKind } = useToolMode();
|
|
644
1341
|
const controller = useViewportControllerContext();
|
|
645
1342
|
const vp = controller?.viewport ?? null;
|
|
646
1343
|
const worldRef = useWorldRefContext();
|
|
@@ -652,6 +1349,17 @@ export function AnnotationsLayer() {
|
|
|
652
1349
|
const [color, setColor] = useState<string>(DEFAULT_COLOR);
|
|
653
1350
|
const [fill, setFill] = useState<string | null>(null);
|
|
654
1351
|
const [thickness, setThickness] = useState<Thickness>(STROKE_WIDTH_THIN);
|
|
1352
|
+
// Phase 21 — draw-time paper tint for the sticky tool (recolor-after via the
|
|
1353
|
+
// context toolbar). Separate from `color` (ink for pen/rect/text/arrow).
|
|
1354
|
+
const [stickyColor, setStickyColor] = useState<string>(DEFAULT_STICKY_COLOR);
|
|
1355
|
+
// Phase 21 — a standalone-text caret waiting for its first keystroke. No
|
|
1356
|
+
// stroke exists yet (mirrors anchored text: the stroke is born on commit,
|
|
1357
|
+
// so an abandoned empty caret leaves nothing behind / no undo record).
|
|
1358
|
+
const [pendingText, setPendingText] = useState<{ x: number; y: number } | null>(null);
|
|
1359
|
+
// Phase 24 — ghost placeholder: world coords the cursor is hovering while a
|
|
1360
|
+
// shape/sticky/text tool is armed and nothing is being drawn yet. Pure chrome
|
|
1361
|
+
// (low-opacity, pointer-events:none) — never selectable, hit-tested, or saved.
|
|
1362
|
+
const [ghost, setGhost] = useState<{ x: number; y: number } | null>(null);
|
|
655
1363
|
const visibilityCtx = useAnnotationsVisibility();
|
|
656
1364
|
const visible = visibilityCtx?.visible ?? true;
|
|
657
1365
|
const setVisible = useCallback(
|
|
@@ -671,6 +1379,11 @@ export function AnnotationsLayer() {
|
|
|
671
1379
|
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
672
1380
|
const drawingRef = useRef<Stroke | null>(null);
|
|
673
1381
|
drawingRef.current = drawing;
|
|
1382
|
+
// Phase 24 — pointer-down anchor + last cursor (world coords) for the active
|
|
1383
|
+
// draw, so the resize modifiers (Shift 1:1 / Alt from-center) can re-constrain
|
|
1384
|
+
// the draft on a bare keydown/keyup, not just on pointer-move.
|
|
1385
|
+
const drawAnchorRef = useRef<{ x: number; y: number } | null>(null);
|
|
1386
|
+
const lastDrawPointRef = useRef<{ x: number; y: number } | null>(null);
|
|
674
1387
|
/**
|
|
675
1388
|
* Phase 20 — latest strokes mirror so command builders can read the
|
|
676
1389
|
* pre-mutation snapshot synchronously (React state isn't refreshed
|
|
@@ -679,15 +1392,23 @@ export function AnnotationsLayer() {
|
|
|
679
1392
|
const strokesRef = useRef<Stroke[]>(strokes);
|
|
680
1393
|
strokesRef.current = strokes;
|
|
681
1394
|
|
|
682
|
-
const isDraw =
|
|
1395
|
+
const isDraw =
|
|
1396
|
+
tool === 'pen' || tool === 'shape' || tool === 'arrow' || tool === 'sticky' || tool === 'text';
|
|
683
1397
|
const isErase = tool === 'eraser';
|
|
684
1398
|
const isActive = isDraw || isErase;
|
|
685
|
-
// T20 —
|
|
686
|
-
//
|
|
687
|
-
// whether to render the Thin / Thick chips.
|
|
688
|
-
const supportsThickness =
|
|
689
|
-
|
|
690
|
-
|
|
1399
|
+
// T20 / Phase 24 — every shape primitive carries stroke weight (FigJam ships
|
|
1400
|
+
// thickness on all of them). The annotation toolbar reads supportsThickness
|
|
1401
|
+
// to decide whether to render the Thin / Thick chips.
|
|
1402
|
+
const supportsThickness = tool === 'pen' || tool === 'arrow' || tool === 'shape';
|
|
1403
|
+
const supportsFill = tool === 'shape';
|
|
1404
|
+
// Phase 24 — tools that show a cursor-following ghost placeholder.
|
|
1405
|
+
const ghostCapable = tool === 'shape' || tool === 'sticky' || tool === 'text';
|
|
1406
|
+
|
|
1407
|
+
// Clear the ghost when the active tool stops being ghost-capable (or
|
|
1408
|
+
// visibility toggles) so a stale ghost never lingers after a tool change.
|
|
1409
|
+
useEffect(() => {
|
|
1410
|
+
if (!ghostCapable || !visible) setGhost(null);
|
|
1411
|
+
}, [ghostCapable, visible]);
|
|
691
1412
|
|
|
692
1413
|
// Load existing annotations on mount.
|
|
693
1414
|
// Phase 8 Task 5 — seed lastAppliedSvgRef so the first Y.Map observe (when
|
|
@@ -835,7 +1556,7 @@ export function AnnotationsLayer() {
|
|
|
835
1556
|
const set = new Set(ids);
|
|
836
1557
|
const prev = strokesRef.current;
|
|
837
1558
|
const next = prev.filter(
|
|
838
|
-
(s) => !set.has(s.id) && !(s.tool === 'text' && set.has(s.anchorId))
|
|
1559
|
+
(s) => !set.has(s.id) && !(s.tool === 'text' && s.anchorId != null && set.has(s.anchorId))
|
|
839
1560
|
);
|
|
840
1561
|
if (next.length === prev.length) return;
|
|
841
1562
|
commitStrokes(prev, next);
|
|
@@ -931,6 +1652,7 @@ export function AnnotationsLayer() {
|
|
|
931
1652
|
// We do NOT stopPropagation — viewport-controller listens on the host
|
|
932
1653
|
// ancestor and never claims a bare-left/no-space pointerdown anyway.
|
|
933
1654
|
e.preventDefault();
|
|
1655
|
+
setGhost(null); // a draw is starting — the ghost placeholder is done
|
|
934
1656
|
try {
|
|
935
1657
|
(e.target as Element & { setPointerCapture?: (id: number) => void }).setPointerCapture?.(
|
|
936
1658
|
e.pointerId
|
|
@@ -939,6 +1661,8 @@ export function AnnotationsLayer() {
|
|
|
939
1661
|
/* some browsers reject capture on synthetic events */
|
|
940
1662
|
}
|
|
941
1663
|
const [wx, wy] = screenToWorld(e.clientX, e.clientY);
|
|
1664
|
+
drawAnchorRef.current = { x: wx, y: wy };
|
|
1665
|
+
lastDrawPointRef.current = { x: wx, y: wy };
|
|
942
1666
|
if (isErase) {
|
|
943
1667
|
eraseAt(wx, wy);
|
|
944
1668
|
return true;
|
|
@@ -948,30 +1672,49 @@ export function AnnotationsLayer() {
|
|
|
948
1672
|
const activeFill = supportsFill ? fill : null;
|
|
949
1673
|
if (tool === 'pen') {
|
|
950
1674
|
setDrawing({ id, tool: 'pen', color, width, points: [[wx, wy]] });
|
|
951
|
-
} else if (tool === '
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
1675
|
+
} else if (tool === 'shape') {
|
|
1676
|
+
// Phase 24 — the single Shape tool maps its kind onto a stroke type:
|
|
1677
|
+
// circle → ellipse; square/rounded → rect (cornerRadius 0 / 8);
|
|
1678
|
+
// diamond/triangle/triangle-down → polygon.
|
|
1679
|
+
if (shapeKind === 'circle') {
|
|
1680
|
+
setDrawing({
|
|
1681
|
+
id,
|
|
1682
|
+
tool: 'ellipse',
|
|
1683
|
+
color,
|
|
1684
|
+
width,
|
|
1685
|
+
cx: wx,
|
|
1686
|
+
cy: wy,
|
|
1687
|
+
rx: 0,
|
|
1688
|
+
ry: 0,
|
|
1689
|
+
fill: activeFill,
|
|
1690
|
+
});
|
|
1691
|
+
} else if (shapeKind === 'square' || shapeKind === 'rounded') {
|
|
1692
|
+
setDrawing({
|
|
1693
|
+
id,
|
|
1694
|
+
tool: 'rect',
|
|
1695
|
+
color,
|
|
1696
|
+
width,
|
|
1697
|
+
x: wx,
|
|
1698
|
+
y: wy,
|
|
1699
|
+
w: 0,
|
|
1700
|
+
h: 0,
|
|
1701
|
+
fill: activeFill,
|
|
1702
|
+
cornerRadius: shapeKind === 'rounded' ? 8 : 0,
|
|
1703
|
+
});
|
|
1704
|
+
} else {
|
|
1705
|
+
setDrawing({
|
|
1706
|
+
id,
|
|
1707
|
+
tool: 'polygon',
|
|
1708
|
+
shape: shapeKind,
|
|
1709
|
+
color,
|
|
1710
|
+
width,
|
|
1711
|
+
x: wx,
|
|
1712
|
+
y: wy,
|
|
1713
|
+
w: 0,
|
|
1714
|
+
h: 0,
|
|
1715
|
+
fill: activeFill,
|
|
1716
|
+
});
|
|
1717
|
+
}
|
|
975
1718
|
} else if (tool === 'arrow') {
|
|
976
1719
|
setDrawing({
|
|
977
1720
|
id,
|
|
@@ -983,14 +1726,40 @@ export function AnnotationsLayer() {
|
|
|
983
1726
|
x2: wx,
|
|
984
1727
|
y2: wy,
|
|
985
1728
|
});
|
|
1729
|
+
} else if (tool === 'sticky') {
|
|
1730
|
+
// Phase 21 — drag-create a paper card. Default size if the user just
|
|
1731
|
+
// taps (no drag) is applied in endStroke.
|
|
1732
|
+
setDrawing({
|
|
1733
|
+
id,
|
|
1734
|
+
tool: 'sticky',
|
|
1735
|
+
color: stickyColor,
|
|
1736
|
+
x: wx,
|
|
1737
|
+
y: wy,
|
|
1738
|
+
w: 0,
|
|
1739
|
+
h: 0,
|
|
1740
|
+
text: '',
|
|
1741
|
+
fontSize: DEFAULT_FONT_SIZE,
|
|
1742
|
+
cornerRadius: STICKY_CORNER_RADIUS,
|
|
1743
|
+
});
|
|
1744
|
+
} else if (tool === 'text') {
|
|
1745
|
+
// Phase 21 — single click drops an editable caret at the click point.
|
|
1746
|
+
// No stroke is created until the user commits real text (mirrors the
|
|
1747
|
+
// anchored double-click flow), so an empty caret leaves nothing behind.
|
|
1748
|
+
setPendingText({ x: wx, y: wy });
|
|
1749
|
+
if (annotSel) annotSel.clear();
|
|
1750
|
+
const stickyOnText = sticky.locked && sticky.tool === 'text';
|
|
1751
|
+
if (!stickyOnText) setTool('move');
|
|
1752
|
+
return true;
|
|
986
1753
|
}
|
|
987
1754
|
return true;
|
|
988
1755
|
},
|
|
989
1756
|
[
|
|
990
1757
|
tool,
|
|
1758
|
+
shapeKind,
|
|
991
1759
|
color,
|
|
992
1760
|
fill,
|
|
993
1761
|
thickness,
|
|
1762
|
+
stickyColor,
|
|
994
1763
|
supportsThickness,
|
|
995
1764
|
supportsFill,
|
|
996
1765
|
isActive,
|
|
@@ -998,6 +1767,9 @@ export function AnnotationsLayer() {
|
|
|
998
1767
|
visible,
|
|
999
1768
|
screenToWorld,
|
|
1000
1769
|
eraseAt,
|
|
1770
|
+
annotSel,
|
|
1771
|
+
sticky,
|
|
1772
|
+
setTool,
|
|
1001
1773
|
]
|
|
1002
1774
|
);
|
|
1003
1775
|
|
|
@@ -1010,6 +1782,16 @@ export function AnnotationsLayer() {
|
|
|
1010
1782
|
eraseAt(wx, wy);
|
|
1011
1783
|
return;
|
|
1012
1784
|
}
|
|
1785
|
+
// Phase 24 — ghost placeholder. While nothing is being drawn, track the
|
|
1786
|
+
// cursor so a translucent preview can follow it; an active draw hides it.
|
|
1787
|
+
if (drawingRef.current == null) {
|
|
1788
|
+
if (ghostCapable) setGhost({ x: wx, y: wy });
|
|
1789
|
+
return;
|
|
1790
|
+
}
|
|
1791
|
+
setGhost(null);
|
|
1792
|
+
lastDrawPointRef.current = { x: wx, y: wy };
|
|
1793
|
+
const anchor = drawAnchorRef.current;
|
|
1794
|
+
const mods: DrawMods = { shift: e.shiftKey, alt: e.altKey };
|
|
1013
1795
|
setDrawing((cur) => {
|
|
1014
1796
|
if (!cur) return cur;
|
|
1015
1797
|
if (cur.tool === 'pen') {
|
|
@@ -1017,21 +1799,14 @@ export function AnnotationsLayer() {
|
|
|
1017
1799
|
if (last && Math.hypot(wx - last[0], wy - last[1]) < 1) return cur;
|
|
1018
1800
|
return { ...cur, points: [...cur.points, [wx, wy] as WorldPoint] };
|
|
1019
1801
|
}
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
if (
|
|
1024
|
-
|
|
1025
|
-
const ry = Math.abs(wy - cur.cy);
|
|
1026
|
-
return { ...cur, rx, ry };
|
|
1027
|
-
}
|
|
1028
|
-
if (cur.tool === 'arrow') {
|
|
1029
|
-
return { ...cur, x2: wx, y2: wy };
|
|
1030
|
-
}
|
|
1031
|
-
return cur;
|
|
1802
|
+
// Phase 24 — Shift (1:1) / Alt (from-center) apply to every box + arrow
|
|
1803
|
+
// shape, mirroring the resize handles. The anchor is the pointer-down
|
|
1804
|
+
// point; without it (shouldn't happen mid-draw) fall back to no change.
|
|
1805
|
+
if (!anchor) return cur;
|
|
1806
|
+
return applyDrawModifiers(cur, anchor, wx, wy, mods);
|
|
1032
1807
|
});
|
|
1033
1808
|
},
|
|
1034
|
-
[isActive, isErase, visible, screenToWorld, eraseAt]
|
|
1809
|
+
[isActive, isErase, visible, screenToWorld, eraseAt, ghostCapable]
|
|
1035
1810
|
);
|
|
1036
1811
|
|
|
1037
1812
|
const endStroke = useCallback(() => {
|
|
@@ -1040,7 +1815,35 @@ export function AnnotationsLayer() {
|
|
|
1040
1815
|
const cur = drawingRef.current;
|
|
1041
1816
|
if (!cur) return;
|
|
1042
1817
|
let final: Stroke | null = cur;
|
|
1043
|
-
|
|
1818
|
+
// Phase 24 — a bare tap (both axes below the drag threshold) drops a
|
|
1819
|
+
// default-sized shape at the tap point so "click to place" works like
|
|
1820
|
+
// FigJam; a real drag sizes it. A thin/degenerate drag still gets discarded
|
|
1821
|
+
// by isStrokeMeaningful below.
|
|
1822
|
+
const isTap = (w: number, h: number) => Math.abs(w) < 4 && Math.abs(h) < 4;
|
|
1823
|
+
if (cur.tool === 'rect') {
|
|
1824
|
+
const norm = normalizeRect(cur);
|
|
1825
|
+
final = isTap(norm.w, norm.h)
|
|
1826
|
+
? { ...norm, x: cur.x, y: cur.y, w: SHAPE_DEFAULT_SIZE, h: SHAPE_DEFAULT_SIZE }
|
|
1827
|
+
: norm;
|
|
1828
|
+
} else if (cur.tool === 'polygon') {
|
|
1829
|
+
const norm = normalizeBox(cur);
|
|
1830
|
+
final = isTap(norm.w, norm.h)
|
|
1831
|
+
? { ...norm, x: cur.x, y: cur.y, w: SHAPE_DEFAULT_SIZE, h: SHAPE_DEFAULT_SIZE }
|
|
1832
|
+
: norm;
|
|
1833
|
+
} else if (cur.tool === 'ellipse') {
|
|
1834
|
+
final =
|
|
1835
|
+
cur.rx < 2 && cur.ry < 2
|
|
1836
|
+
? { ...cur, rx: SHAPE_DEFAULT_SIZE / 2, ry: SHAPE_DEFAULT_SIZE / 2 }
|
|
1837
|
+
: cur;
|
|
1838
|
+
} else if (cur.tool === 'sticky') {
|
|
1839
|
+
const norm = normalizeSticky(cur);
|
|
1840
|
+
// A bare tap (or a drag too small to be a usable card) drops a
|
|
1841
|
+
// default-sized note at the tap point — FigJam parity.
|
|
1842
|
+
final =
|
|
1843
|
+
Math.abs(norm.w) < STICKY_MIN_SIZE || Math.abs(norm.h) < STICKY_MIN_SIZE
|
|
1844
|
+
? { ...norm, w: STICKY_DEFAULT_W, h: STICKY_DEFAULT_H }
|
|
1845
|
+
: norm;
|
|
1846
|
+
}
|
|
1044
1847
|
if (final && !isStrokeMeaningful(final)) final = null;
|
|
1045
1848
|
if (final) {
|
|
1046
1849
|
const committed = final;
|
|
@@ -1051,6 +1854,9 @@ export function AnnotationsLayer() {
|
|
|
1051
1854
|
// see + adjust it. annotSel is optional (some test harnesses mount
|
|
1052
1855
|
// AnnotationsLayer without the provider), so guard the call.
|
|
1053
1856
|
if (annotSel) annotSel.replace(committed.id);
|
|
1857
|
+
// Phase 21 — a fresh sticky opens in edit mode (FigJam parity: drop a
|
|
1858
|
+
// note, type immediately). Only meaningful deviation from rect/ellipse.
|
|
1859
|
+
if (committed.tool === 'sticky') setEditingId(committed.id);
|
|
1054
1860
|
}
|
|
1055
1861
|
// T18 / T19 — flip the tool back to Move after every commit UNLESS sticky
|
|
1056
1862
|
// mode is locked on this tool. Sticky lets the user draw many shapes in a
|
|
@@ -1061,6 +1867,8 @@ export function AnnotationsLayer() {
|
|
|
1061
1867
|
const stickyOnThis = sticky.locked && sticky.tool === toolJustUsed;
|
|
1062
1868
|
if (!stickyOnThis) setTool('move');
|
|
1063
1869
|
}
|
|
1870
|
+
drawAnchorRef.current = null;
|
|
1871
|
+
lastDrawPointRef.current = null;
|
|
1064
1872
|
setDrawing(null);
|
|
1065
1873
|
}, [isActive, isErase, visible, commitStrokes, annotSel, setTool, sticky]);
|
|
1066
1874
|
|
|
@@ -1070,6 +1878,8 @@ export function AnnotationsLayer() {
|
|
|
1070
1878
|
// a no-op.
|
|
1071
1879
|
const cancelStroke = useCallback(() => {
|
|
1072
1880
|
if (!drawingRef.current) return;
|
|
1881
|
+
drawAnchorRef.current = null;
|
|
1882
|
+
lastDrawPointRef.current = null;
|
|
1073
1883
|
setDrawing(null);
|
|
1074
1884
|
}, []);
|
|
1075
1885
|
|
|
@@ -1080,11 +1890,44 @@ export function AnnotationsLayer() {
|
|
|
1080
1890
|
return () => document.removeEventListener('maude:cancel-stroke', onCancel);
|
|
1081
1891
|
}, [cancelStroke]);
|
|
1082
1892
|
|
|
1893
|
+
// Phase 24 — holding/releasing Shift or Alt mid-draw re-constrains the draft
|
|
1894
|
+
// at the last cursor position (FigJam: the modifier engages while held, no
|
|
1895
|
+
// pointer-move needed). Pen / text carry no box, so they're skipped.
|
|
1896
|
+
useEffect(() => {
|
|
1897
|
+
if (typeof document === 'undefined') return;
|
|
1898
|
+
const onKey = (e: KeyboardEvent) => {
|
|
1899
|
+
const cur = drawingRef.current;
|
|
1900
|
+
const anchor = drawAnchorRef.current;
|
|
1901
|
+
const p = lastDrawPointRef.current;
|
|
1902
|
+
if (!cur || !anchor || !p) return;
|
|
1903
|
+
if (e.key !== 'Shift' && e.key !== 'Alt') return;
|
|
1904
|
+
if (cur.tool === 'pen' || cur.tool === 'text') return;
|
|
1905
|
+
e.preventDefault();
|
|
1906
|
+
const mods: DrawMods = { shift: e.shiftKey, alt: e.altKey };
|
|
1907
|
+
setDrawing((c) => (c ? applyDrawModifiers(c, anchor, p.x, p.y, mods) : c));
|
|
1908
|
+
};
|
|
1909
|
+
document.addEventListener('keydown', onKey, true);
|
|
1910
|
+
document.addEventListener('keyup', onKey, true);
|
|
1911
|
+
return () => {
|
|
1912
|
+
document.removeEventListener('keydown', onKey, true);
|
|
1913
|
+
document.removeEventListener('keyup', onKey, true);
|
|
1914
|
+
};
|
|
1915
|
+
}, []);
|
|
1916
|
+
|
|
1083
1917
|
const renderStrokes = useMemo(
|
|
1084
1918
|
() => (drawing ? [...strokes, drawing] : strokes),
|
|
1085
1919
|
[strokes, drawing]
|
|
1086
1920
|
);
|
|
1087
1921
|
|
|
1922
|
+
// Phase 24 — the ghost descriptor handed to the SVG layer. Suppressed while a
|
|
1923
|
+
// draw is in progress (the real preview takes over) so the two never overlap.
|
|
1924
|
+
const ghostPreview = useMemo<GhostDescriptor | null>(() => {
|
|
1925
|
+
if (!ghost || !ghostCapable || drawing) return null;
|
|
1926
|
+
if (tool === 'text') return { kind: 'text', x: ghost.x, y: ghost.y, color };
|
|
1927
|
+
if (tool === 'sticky') return { kind: 'sticky', x: ghost.x, y: ghost.y, color: stickyColor };
|
|
1928
|
+
return { kind: 'shape', x: ghost.x, y: ghost.y, shapeKind, color };
|
|
1929
|
+
}, [ghost, ghostCapable, drawing, tool, shapeKind, color, stickyColor]);
|
|
1930
|
+
|
|
1088
1931
|
const anchorsById = useMemo(() => {
|
|
1089
1932
|
const map = new Map<string, RectStroke | EllipseStroke>();
|
|
1090
1933
|
for (const s of strokes) {
|
|
@@ -1144,7 +1987,13 @@ export function AnnotationsLayer() {
|
|
|
1144
1987
|
if (
|
|
1145
1988
|
id &&
|
|
1146
1989
|
t &&
|
|
1147
|
-
(t === 'pen' ||
|
|
1990
|
+
(t === 'pen' ||
|
|
1991
|
+
t === 'rect' ||
|
|
1992
|
+
t === 'ellipse' ||
|
|
1993
|
+
t === 'polygon' ||
|
|
1994
|
+
t === 'arrow' ||
|
|
1995
|
+
t === 'text' ||
|
|
1996
|
+
t === 'sticky')
|
|
1148
1997
|
) {
|
|
1149
1998
|
return id;
|
|
1150
1999
|
}
|
|
@@ -1155,7 +2004,7 @@ export function AnnotationsLayer() {
|
|
|
1155
2004
|
// the main tool palette, the in-canvas draw chrome, the minimap, and the
|
|
1156
2005
|
// right-click menu. Clicks on these route to their own handlers.
|
|
1157
2006
|
const CHROME_SELECTOR =
|
|
1158
|
-
'.dc-annot-ctx, .dc-tool-palette, .dc-annot-chrome, .dc-mm, .dc-context-menu, .dc-tp-popover, .dc-multi-artboard-tb, .dc-elem-ctx-tb, .dc-cv-eq-spacing-layer, .cm-composer, .cm-thread, .cm-mention-popup, .cm-pin, .dc-annot-resize-handle';
|
|
2007
|
+
'.dc-annot-ctx, .dc-tool-palette, .dc-annot-chrome, .dc-mm, .dc-context-menu, .dc-tp-popover, .dc-multi-artboard-tb, .dc-elem-ctx-tb, .dc-cv-eq-spacing-layer, .cm-composer, .cm-thread, .cm-mention-popup, .cm-pin, .dc-annot-resize-handle, .dc-annot-editor';
|
|
1159
2008
|
|
|
1160
2009
|
const onDown = (e: PointerEvent) => {
|
|
1161
2010
|
if (e.button !== 0) return;
|
|
@@ -1274,7 +2123,10 @@ export function AnnotationsLayer() {
|
|
|
1274
2123
|
const yMax = Math.max(final.ay, final.by);
|
|
1275
2124
|
const hits: string[] = [];
|
|
1276
2125
|
for (const s of strokesStoreRef.current.strokes) {
|
|
1277
|
-
|
|
2126
|
+
// Anchored text inherits its host's bbox (selected with the host);
|
|
2127
|
+
// standalone text (Phase 21) has its own synthetic bbox and IS
|
|
2128
|
+
// marquee-selectable.
|
|
2129
|
+
if (s.tool === 'text' && s.anchorId != null && s.anchorId !== '') continue;
|
|
1278
2130
|
const bb = strokeBBox(s);
|
|
1279
2131
|
if (!bb) continue;
|
|
1280
2132
|
if (bb.x + bb.w >= xMin && bb.x <= xMax && bb.y + bb.h >= yMin && bb.y <= yMax) {
|
|
@@ -1293,7 +2145,10 @@ export function AnnotationsLayer() {
|
|
|
1293
2145
|
|
|
1294
2146
|
document.addEventListener('pointerdown', onDown, true);
|
|
1295
2147
|
return () => document.removeEventListener('pointerdown', onDown, true);
|
|
1296
|
-
|
|
2148
|
+
// commitStrokes is included defensively (it is a stable useCallback([]) ref,
|
|
2149
|
+
// so this never re-binds the listener) to remove the latent stale-closure
|
|
2150
|
+
// trap flagged in the Phase 24 frontend review.
|
|
2151
|
+
}, [tool, annotSel, elementSel, screenToWorld, strokesStore, commitStrokes]);
|
|
1297
2152
|
|
|
1298
2153
|
// Latest marquee + strokes refs for the doc-level pointerup callback
|
|
1299
2154
|
// (avoids re-binding the listener on every state tick).
|
|
@@ -1302,7 +2157,9 @@ export function AnnotationsLayer() {
|
|
|
1302
2157
|
const strokesStoreRef = useRef(strokesStore);
|
|
1303
2158
|
strokesStoreRef.current = strokesStore;
|
|
1304
2159
|
|
|
1305
|
-
// Double-click
|
|
2160
|
+
// Double-click enters text-edit mode: rect/ellipse (anchored text), sticky
|
|
2161
|
+
// (its own body), or a standalone text node (re-edit in place). Anchored text
|
|
2162
|
+
// nodes are edited via their host, so a data-anchor-id text node is skipped.
|
|
1306
2163
|
useEffect(() => {
|
|
1307
2164
|
if (typeof document === 'undefined') return;
|
|
1308
2165
|
if (tool !== 'move') return;
|
|
@@ -1312,7 +2169,13 @@ export function AnnotationsLayer() {
|
|
|
1312
2169
|
if (!node) return;
|
|
1313
2170
|
const id = node.getAttribute('data-id');
|
|
1314
2171
|
const t = node.getAttribute('data-tool');
|
|
1315
|
-
if (id
|
|
2172
|
+
if (!id) return;
|
|
2173
|
+
if (t === 'rect' || t === 'ellipse' || t === 'sticky') {
|
|
2174
|
+
e.preventDefault();
|
|
2175
|
+
setEditingId(id);
|
|
2176
|
+
return;
|
|
2177
|
+
}
|
|
2178
|
+
if (t === 'text' && !node.getAttribute('data-anchor-id')) {
|
|
1316
2179
|
e.preventDefault();
|
|
1317
2180
|
setEditingId(id);
|
|
1318
2181
|
}
|
|
@@ -1356,6 +2219,103 @@ export function AnnotationsLayer() {
|
|
|
1356
2219
|
[commitStrokes]
|
|
1357
2220
|
);
|
|
1358
2221
|
|
|
2222
|
+
// Phase 21 — sticky body edit. Sticky text is freeform (newlines preserved,
|
|
2223
|
+
// no trim) and the card persists even when blank, so this only updates text.
|
|
2224
|
+
const commitStickyText = useCallback(
|
|
2225
|
+
(id: string, text: string) => {
|
|
2226
|
+
const prev = strokesRef.current;
|
|
2227
|
+
const existing = prev.find((s) => s.id === id && s.tool === 'sticky') as
|
|
2228
|
+
| StickyStroke
|
|
2229
|
+
| undefined;
|
|
2230
|
+
if (!existing || existing.text === text) return;
|
|
2231
|
+
const next = prev.map((s) => (s.id === id ? { ...existing, text } : s));
|
|
2232
|
+
commitStrokes(prev, next, 'edit sticky');
|
|
2233
|
+
},
|
|
2234
|
+
[commitStrokes]
|
|
2235
|
+
);
|
|
2236
|
+
|
|
2237
|
+
// Phase 21 — re-edit an EXISTING standalone text node. Empty text deletes it
|
|
2238
|
+
// (same rule as anchored text).
|
|
2239
|
+
const commitStandaloneText = useCallback(
|
|
2240
|
+
(id: string, text: string) => {
|
|
2241
|
+
const trimmed = text.trim();
|
|
2242
|
+
const prev = strokesRef.current;
|
|
2243
|
+
const existing = prev.find((s) => s.id === id && s.tool === 'text') as TextStroke | undefined;
|
|
2244
|
+
if (!existing) return;
|
|
2245
|
+
if (trimmed.length === 0) {
|
|
2246
|
+
commitStrokes(
|
|
2247
|
+
prev,
|
|
2248
|
+
prev.filter((s) => s.id !== id),
|
|
2249
|
+
'delete text'
|
|
2250
|
+
);
|
|
2251
|
+
return;
|
|
2252
|
+
}
|
|
2253
|
+
if (existing.text === trimmed) return;
|
|
2254
|
+
commitStrokes(
|
|
2255
|
+
prev,
|
|
2256
|
+
prev.map((s) => (s.id === id ? { ...existing, text: trimmed } : s)),
|
|
2257
|
+
'edit text'
|
|
2258
|
+
);
|
|
2259
|
+
},
|
|
2260
|
+
[commitStrokes]
|
|
2261
|
+
);
|
|
2262
|
+
|
|
2263
|
+
// Phase 21 — born-on-commit standalone text (from the text-tool caret). An
|
|
2264
|
+
// empty caret persists nothing — ONE undo record only when real text lands.
|
|
2265
|
+
const createStandaloneText = useCallback(
|
|
2266
|
+
(x: number, y: number, text: string) => {
|
|
2267
|
+
const trimmed = text.trim();
|
|
2268
|
+
if (trimmed.length === 0) return;
|
|
2269
|
+
const prev = strokesRef.current;
|
|
2270
|
+
const id = rid();
|
|
2271
|
+
const next: Stroke[] = [
|
|
2272
|
+
...prev,
|
|
2273
|
+
{ id, tool: 'text', color, fontSize: DEFAULT_FONT_SIZE, text: trimmed, x, y },
|
|
2274
|
+
];
|
|
2275
|
+
commitStrokes(prev, next, 'add text');
|
|
2276
|
+
if (annotSel) annotSel.replace(id);
|
|
2277
|
+
},
|
|
2278
|
+
[commitStrokes, color, annotSel]
|
|
2279
|
+
);
|
|
2280
|
+
|
|
2281
|
+
// Phase 21 — resolve what (if anything) is being edited, and route a single
|
|
2282
|
+
// commit call to the right writer. `editingId` doubles as the host id
|
|
2283
|
+
// (anchored) OR the sticky/standalone stroke id; `pendingText` is the
|
|
2284
|
+
// not-yet-born text caret.
|
|
2285
|
+
const editingTarget = useMemo<EditingTarget>(() => {
|
|
2286
|
+
if (pendingText) return { kind: 'pending', x: pendingText.x, y: pendingText.y };
|
|
2287
|
+
if (!editingId) return null;
|
|
2288
|
+
const host = anchorsById.get(editingId);
|
|
2289
|
+
if (host) return { kind: 'anchored', anchorId: editingId, host };
|
|
2290
|
+
const s = strokesById.get(editingId);
|
|
2291
|
+
if (s?.tool === 'sticky') return { kind: 'sticky', sticky: s };
|
|
2292
|
+
if (s?.tool === 'text' && (s.anchorId == null || s.anchorId === ''))
|
|
2293
|
+
return { kind: 'standalone', text: s };
|
|
2294
|
+
return null;
|
|
2295
|
+
}, [pendingText, editingId, anchorsById, strokesById]);
|
|
2296
|
+
|
|
2297
|
+
const editingTargetRef = useRef(editingTarget);
|
|
2298
|
+
editingTargetRef.current = editingTarget;
|
|
2299
|
+
|
|
2300
|
+
const commitEditing = useCallback(
|
|
2301
|
+
(text: string) => {
|
|
2302
|
+
const target = editingTargetRef.current;
|
|
2303
|
+
setEditingId(null);
|
|
2304
|
+
setPendingText(null);
|
|
2305
|
+
if (!target) return;
|
|
2306
|
+
if (target.kind === 'anchored') commitText(target.anchorId, text);
|
|
2307
|
+
else if (target.kind === 'sticky') commitStickyText(target.sticky.id, text);
|
|
2308
|
+
else if (target.kind === 'standalone') commitStandaloneText(target.text.id, text);
|
|
2309
|
+
else if (target.kind === 'pending') createStandaloneText(target.x, target.y, text);
|
|
2310
|
+
},
|
|
2311
|
+
[commitText, commitStickyText, commitStandaloneText, createStandaloneText]
|
|
2312
|
+
);
|
|
2313
|
+
|
|
2314
|
+
const cancelEditing = useCallback(() => {
|
|
2315
|
+
setEditingId(null);
|
|
2316
|
+
setPendingText(null);
|
|
2317
|
+
}, []);
|
|
2318
|
+
|
|
1359
2319
|
// Keyboard: arrow nudge + Backspace/Delete remove selected strokes.
|
|
1360
2320
|
useEffect(() => {
|
|
1361
2321
|
if (typeof document === 'undefined') return;
|
|
@@ -1412,9 +2372,11 @@ export function AnnotationsLayer() {
|
|
|
1412
2372
|
<AnnotationsInput
|
|
1413
2373
|
isActive={isActive}
|
|
1414
2374
|
visible={visible}
|
|
2375
|
+
cursor={tools.find((t) => t.id === tool)?.cursor ?? 'crosshair'}
|
|
1415
2376
|
beginStroke={beginStroke}
|
|
1416
2377
|
moveStroke={moveStroke}
|
|
1417
2378
|
endStroke={endStroke}
|
|
2379
|
+
onLeave={() => setGhost(null)}
|
|
1418
2380
|
/>
|
|
1419
2381
|
{visible ? (
|
|
1420
2382
|
<AnnotationsSvg
|
|
@@ -1424,25 +2386,21 @@ export function AnnotationsLayer() {
|
|
|
1424
2386
|
selectMode={tool === 'move'}
|
|
1425
2387
|
selectedStrokes={selectedStrokes}
|
|
1426
2388
|
marquee={marquee}
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
| undefined
|
|
1432
|
-
}
|
|
1433
|
-
onCommitText={(anchorId, text) => {
|
|
1434
|
-
commitText(anchorId, text);
|
|
1435
|
-
setEditingId(null);
|
|
1436
|
-
}}
|
|
1437
|
-
onCancelEdit={() => setEditingId(null)}
|
|
2389
|
+
ghost={ghostPreview}
|
|
2390
|
+
editingTarget={editingTarget}
|
|
2391
|
+
onCommitEdit={commitEditing}
|
|
2392
|
+
onCancelEdit={cancelEditing}
|
|
1438
2393
|
/>
|
|
1439
2394
|
) : null}
|
|
1440
2395
|
<AnnotationContextToolbar />
|
|
1441
2396
|
{visible && tool === 'move' ? <AnnotationResizeOverlay store={strokesStore} /> : null}
|
|
1442
2397
|
{isActive ? (
|
|
1443
2398
|
<AnnotationsChrome
|
|
2399
|
+
tool={tool}
|
|
1444
2400
|
color={color}
|
|
1445
2401
|
setColor={setColor}
|
|
2402
|
+
stickyColor={stickyColor}
|
|
2403
|
+
setStickyColor={setStickyColor}
|
|
1446
2404
|
supportsFill={supportsFill}
|
|
1447
2405
|
fill={fill}
|
|
1448
2406
|
setFill={setFill}
|
|
@@ -1466,15 +2424,22 @@ AnnotationsLayer.displayName = 'AnnotationsLayer';
|
|
|
1466
2424
|
function AnnotationsInput({
|
|
1467
2425
|
isActive,
|
|
1468
2426
|
visible,
|
|
2427
|
+
cursor,
|
|
1469
2428
|
beginStroke,
|
|
1470
2429
|
moveStroke,
|
|
1471
2430
|
endStroke,
|
|
2431
|
+
onLeave,
|
|
1472
2432
|
}: {
|
|
1473
2433
|
isActive: boolean;
|
|
1474
2434
|
visible: boolean;
|
|
2435
|
+
/** Active-tool cursor (crosshair / text / cell) — applied to the capture
|
|
2436
|
+
* overlay so the affordance shows over the whole canvas while drawing. */
|
|
2437
|
+
cursor: string;
|
|
1475
2438
|
beginStroke: (e: ReactPointerEvent<HTMLDivElement>, spaceHeld: boolean) => boolean;
|
|
1476
2439
|
moveStroke: (e: ReactPointerEvent<HTMLDivElement>) => void;
|
|
1477
2440
|
endStroke: () => void;
|
|
2441
|
+
/** Phase 24 — clear the ghost placeholder when the pointer leaves the canvas. */
|
|
2442
|
+
onLeave: () => void;
|
|
1478
2443
|
}) {
|
|
1479
2444
|
const worldRef = useWorldRefContext();
|
|
1480
2445
|
const host = worldRef?.current?.parentElement ?? null;
|
|
@@ -1509,13 +2474,17 @@ function AnnotationsInput({
|
|
|
1509
2474
|
<div
|
|
1510
2475
|
className="dc-annot-input"
|
|
1511
2476
|
aria-hidden="true"
|
|
1512
|
-
style={{
|
|
2477
|
+
style={{
|
|
2478
|
+
pointerEvents: interactive ? 'auto' : 'none',
|
|
2479
|
+
cursor: interactive ? cursor : 'default',
|
|
2480
|
+
}}
|
|
1513
2481
|
onPointerDown={(e) => {
|
|
1514
2482
|
beginStroke(e, spaceHeldRef.current);
|
|
1515
2483
|
}}
|
|
1516
2484
|
onPointerMove={moveStroke}
|
|
1517
2485
|
onPointerUp={endStroke}
|
|
1518
2486
|
onPointerCancel={endStroke}
|
|
2487
|
+
onPointerLeave={onLeave}
|
|
1519
2488
|
/>,
|
|
1520
2489
|
host
|
|
1521
2490
|
);
|
|
@@ -1534,9 +2503,9 @@ function AnnotationsSvg({
|
|
|
1534
2503
|
selectMode,
|
|
1535
2504
|
selectedStrokes,
|
|
1536
2505
|
marquee,
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
2506
|
+
ghost,
|
|
2507
|
+
editingTarget,
|
|
2508
|
+
onCommitEdit,
|
|
1540
2509
|
onCancelEdit,
|
|
1541
2510
|
}: {
|
|
1542
2511
|
worldRef: ReturnType<typeof useWorldRefContext>;
|
|
@@ -1545,9 +2514,9 @@ function AnnotationsSvg({
|
|
|
1545
2514
|
selectMode: boolean;
|
|
1546
2515
|
selectedStrokes: readonly Stroke[];
|
|
1547
2516
|
marquee: { ax: number; ay: number; bx: number; by: number } | null;
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
2517
|
+
ghost: GhostDescriptor | null;
|
|
2518
|
+
editingTarget: EditingTarget;
|
|
2519
|
+
onCommitEdit: (text: string) => void;
|
|
1551
2520
|
onCancelEdit: () => void;
|
|
1552
2521
|
}) {
|
|
1553
2522
|
const [, force] = useState({});
|
|
@@ -1558,10 +2527,31 @@ function AnnotationsSvg({
|
|
|
1558
2527
|
}, [worldRef]);
|
|
1559
2528
|
const target = worldRef?.current ?? null;
|
|
1560
2529
|
if (!target) return null;
|
|
2530
|
+
// A sticky whose body is being edited hides its read-only text so the
|
|
2531
|
+
// editor textarea (rendered below at the same bbox) isn't double-painted.
|
|
2532
|
+
const editingStickyId = editingTarget?.kind === 'sticky' ? editingTarget.sticky.id : null;
|
|
2533
|
+
const anchoredExisting =
|
|
2534
|
+
editingTarget?.kind === 'anchored'
|
|
2535
|
+
? (strokes.find((s) => s.tool === 'text' && s.anchorId === editingTarget.anchorId) as
|
|
2536
|
+
| TextStroke
|
|
2537
|
+
| undefined)
|
|
2538
|
+
: undefined;
|
|
1561
2539
|
return createPortal(
|
|
1562
2540
|
<svg className="dc-annot-svg" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
|
|
2541
|
+
<defs>
|
|
2542
|
+
{/* Phase 21 — soft "lifted paper" drop shadow for sticky notes. */}
|
|
2543
|
+
<filter id="dc-sticky-shadow" x="-25%" y="-25%" width="150%" height="170%">
|
|
2544
|
+
<feDropShadow dx="0" dy="4" stdDeviation="8" floodColor="#000000" floodOpacity="0.28" />
|
|
2545
|
+
</filter>
|
|
2546
|
+
</defs>
|
|
1563
2547
|
{strokes.map((s) => (
|
|
1564
|
-
<StrokeNode
|
|
2548
|
+
<StrokeNode
|
|
2549
|
+
key={s.id}
|
|
2550
|
+
stroke={s}
|
|
2551
|
+
anchorsById={anchorsById}
|
|
2552
|
+
interactive={selectMode}
|
|
2553
|
+
editing={s.id === editingStickyId}
|
|
2554
|
+
/>
|
|
1565
2555
|
))}
|
|
1566
2556
|
{selectedStrokes.map((s) => (
|
|
1567
2557
|
<SelectionHalo
|
|
@@ -1582,12 +2572,45 @@ function AnnotationsSvg({
|
|
|
1582
2572
|
vectorEffect="non-scaling-stroke"
|
|
1583
2573
|
/>
|
|
1584
2574
|
) : null}
|
|
1585
|
-
{
|
|
2575
|
+
{ghost ? <GhostPreview ghost={ghost} /> : null}
|
|
2576
|
+
{editingTarget?.kind === 'anchored' ? (
|
|
1586
2577
|
<TextEditor
|
|
1587
|
-
anchorId={
|
|
1588
|
-
host={
|
|
1589
|
-
existing={
|
|
1590
|
-
onCommit={
|
|
2578
|
+
anchorId={editingTarget.anchorId}
|
|
2579
|
+
host={editingTarget.host}
|
|
2580
|
+
existing={anchoredExisting}
|
|
2581
|
+
onCommit={(_anchorId, text) => onCommitEdit(text)}
|
|
2582
|
+
onCancel={onCancelEdit}
|
|
2583
|
+
/>
|
|
2584
|
+
) : null}
|
|
2585
|
+
{editingTarget?.kind === 'sticky' ? (
|
|
2586
|
+
<StickyEditor
|
|
2587
|
+
sticky={editingTarget.sticky}
|
|
2588
|
+
onCommit={onCommitEdit}
|
|
2589
|
+
onCancel={onCancelEdit}
|
|
2590
|
+
/>
|
|
2591
|
+
) : null}
|
|
2592
|
+
{editingTarget?.kind === 'standalone' ? (
|
|
2593
|
+
<StandaloneTextEditor
|
|
2594
|
+
x={editingTarget.text.x ?? 0}
|
|
2595
|
+
y={editingTarget.text.y ?? 0}
|
|
2596
|
+
fontSize={editingTarget.text.fontSize}
|
|
2597
|
+
color={editingTarget.text.color}
|
|
2598
|
+
initialText={editingTarget.text.text}
|
|
2599
|
+
bold={editingTarget.text.bold}
|
|
2600
|
+
strike={editingTarget.text.strike}
|
|
2601
|
+
align={editingTarget.text.align ?? 'left'}
|
|
2602
|
+
onCommit={onCommitEdit}
|
|
2603
|
+
onCancel={onCancelEdit}
|
|
2604
|
+
/>
|
|
2605
|
+
) : null}
|
|
2606
|
+
{editingTarget?.kind === 'pending' ? (
|
|
2607
|
+
<StandaloneTextEditor
|
|
2608
|
+
x={editingTarget.x}
|
|
2609
|
+
y={editingTarget.y}
|
|
2610
|
+
fontSize={DEFAULT_FONT_SIZE}
|
|
2611
|
+
color={DEFAULT_COLOR}
|
|
2612
|
+
initialText=""
|
|
2613
|
+
onCommit={onCommitEdit}
|
|
1591
2614
|
onCancel={onCancelEdit}
|
|
1592
2615
|
/>
|
|
1593
2616
|
) : null}
|
|
@@ -1649,10 +2672,14 @@ function TextEditor({
|
|
|
1649
2672
|
const bbox = strokeBBox(host);
|
|
1650
2673
|
if (!bbox) return null;
|
|
1651
2674
|
const fontSize = existing?.fontSize ?? DEFAULT_FONT_SIZE;
|
|
2675
|
+
// Phase 24 — match the committed render's bold / strike / align (anchored
|
|
2676
|
+
// default align = centre).
|
|
2677
|
+
const align = existing?.align ?? 'center';
|
|
1652
2678
|
return (
|
|
1653
2679
|
<foreignObject x={bbox.x} y={bbox.y} width={Math.max(20, bbox.w)} height={Math.max(20, bbox.h)}>
|
|
1654
2680
|
<div
|
|
1655
2681
|
ref={ref}
|
|
2682
|
+
className="dc-annot-editor"
|
|
1656
2683
|
contentEditable
|
|
1657
2684
|
suppressContentEditableWarning
|
|
1658
2685
|
aria-label="Edit annotation text"
|
|
@@ -1661,13 +2688,16 @@ function TextEditor({
|
|
|
1661
2688
|
height: '100%',
|
|
1662
2689
|
display: 'flex',
|
|
1663
2690
|
alignItems: 'center',
|
|
1664
|
-
justifyContent:
|
|
2691
|
+
justifyContent:
|
|
2692
|
+
align === 'left' ? 'flex-start' : align === 'right' ? 'flex-end' : 'center',
|
|
1665
2693
|
padding: '0 8px',
|
|
1666
2694
|
boxSizing: 'border-box',
|
|
1667
|
-
textAlign:
|
|
2695
|
+
textAlign: align,
|
|
1668
2696
|
color: existing?.color ?? '#1a1a1a',
|
|
1669
2697
|
fontSize: `${fontSize}px`,
|
|
1670
2698
|
fontFamily: 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
|
|
2699
|
+
fontWeight: existing?.bold ? 700 : undefined,
|
|
2700
|
+
textDecoration: existing?.strike ? 'line-through' : undefined,
|
|
1671
2701
|
lineHeight: 1.25,
|
|
1672
2702
|
outline: 'none',
|
|
1673
2703
|
background: 'transparent',
|
|
@@ -1692,6 +2722,163 @@ function TextEditor({
|
|
|
1692
2722
|
);
|
|
1693
2723
|
}
|
|
1694
2724
|
|
|
2725
|
+
// Phase 21 — sticky body editor. A textarea hosted in a foreignObject at the
|
|
2726
|
+
// card's bbox, so it word-wraps + moves with CSS zoom natively. Commit on blur,
|
|
2727
|
+
// cancel on Esc; Enter inserts a newline (sticky is multi-line).
|
|
2728
|
+
function StickyEditor({
|
|
2729
|
+
sticky,
|
|
2730
|
+
onCommit,
|
|
2731
|
+
onCancel,
|
|
2732
|
+
}: {
|
|
2733
|
+
sticky: StickyStroke;
|
|
2734
|
+
onCommit: (text: string) => void;
|
|
2735
|
+
onCancel: () => void;
|
|
2736
|
+
}) {
|
|
2737
|
+
// A flex-centered contentEditable (NOT a textarea) so the edit view matches
|
|
2738
|
+
// the committed `.dc-sticky-body` exactly — text stays centered, no jump on
|
|
2739
|
+
// commit. Multi-line: Enter inserts a line break; Esc cancels; blur commits.
|
|
2740
|
+
const ref = useRef<HTMLDivElement | null>(null);
|
|
2741
|
+
useEffect(() => {
|
|
2742
|
+
const el = ref.current;
|
|
2743
|
+
if (!el) return;
|
|
2744
|
+
el.focus();
|
|
2745
|
+
try {
|
|
2746
|
+
const r = document.createRange();
|
|
2747
|
+
r.selectNodeContents(el);
|
|
2748
|
+
const sel = window.getSelection();
|
|
2749
|
+
if (sel) {
|
|
2750
|
+
sel.removeAllRanges();
|
|
2751
|
+
sel.addRange(r);
|
|
2752
|
+
}
|
|
2753
|
+
} catch {
|
|
2754
|
+
/* selection API blocked */
|
|
2755
|
+
}
|
|
2756
|
+
}, []);
|
|
2757
|
+
const x = Math.min(sticky.x, sticky.x + sticky.w);
|
|
2758
|
+
const y = Math.min(sticky.y, sticky.y + sticky.h);
|
|
2759
|
+
const w = Math.abs(sticky.w);
|
|
2760
|
+
const h = Math.abs(sticky.h);
|
|
2761
|
+
return (
|
|
2762
|
+
<foreignObject x={x} y={y} width={w} height={h}>
|
|
2763
|
+
<div
|
|
2764
|
+
xmlns="http://www.w3.org/1999/xhtml"
|
|
2765
|
+
ref={ref}
|
|
2766
|
+
className="dc-annot-editor dc-sticky-body"
|
|
2767
|
+
contentEditable
|
|
2768
|
+
suppressContentEditableWarning
|
|
2769
|
+
aria-label="Edit sticky note text"
|
|
2770
|
+
style={{ ...stickyBodyStyle(sticky), outline: 'none', cursor: 'text' }}
|
|
2771
|
+
onBlur={() => onCommit(ref.current?.innerText ?? '')}
|
|
2772
|
+
onKeyDown={(e) => {
|
|
2773
|
+
if (e.key === 'Escape') {
|
|
2774
|
+
e.preventDefault();
|
|
2775
|
+
onCancel();
|
|
2776
|
+
}
|
|
2777
|
+
}}
|
|
2778
|
+
>
|
|
2779
|
+
{sticky.text}
|
|
2780
|
+
</div>
|
|
2781
|
+
</foreignObject>
|
|
2782
|
+
);
|
|
2783
|
+
}
|
|
2784
|
+
|
|
2785
|
+
// Phase 21 — standalone text editor. A single-line contentEditable box anchored
|
|
2786
|
+
// at the world (x, y). Enter / blur / outside-click commit; Esc cancels.
|
|
2787
|
+
function StandaloneTextEditor({
|
|
2788
|
+
x,
|
|
2789
|
+
y,
|
|
2790
|
+
fontSize,
|
|
2791
|
+
color,
|
|
2792
|
+
initialText,
|
|
2793
|
+
bold,
|
|
2794
|
+
strike,
|
|
2795
|
+
align,
|
|
2796
|
+
onCommit,
|
|
2797
|
+
onCancel,
|
|
2798
|
+
}: {
|
|
2799
|
+
x: number;
|
|
2800
|
+
y: number;
|
|
2801
|
+
fontSize: number;
|
|
2802
|
+
color: string;
|
|
2803
|
+
initialText: string;
|
|
2804
|
+
bold?: boolean;
|
|
2805
|
+
strike?: boolean;
|
|
2806
|
+
align?: TextAlign;
|
|
2807
|
+
onCommit: (text: string) => void;
|
|
2808
|
+
onCancel: () => void;
|
|
2809
|
+
}) {
|
|
2810
|
+
const ref = useRef<HTMLDivElement | null>(null);
|
|
2811
|
+
useEffect(() => {
|
|
2812
|
+
const el = ref.current;
|
|
2813
|
+
if (!el) return;
|
|
2814
|
+
el.focus();
|
|
2815
|
+
try {
|
|
2816
|
+
const r = document.createRange();
|
|
2817
|
+
r.selectNodeContents(el);
|
|
2818
|
+
const sel = window.getSelection();
|
|
2819
|
+
if (sel) {
|
|
2820
|
+
sel.removeAllRanges();
|
|
2821
|
+
sel.addRange(r);
|
|
2822
|
+
}
|
|
2823
|
+
} catch {
|
|
2824
|
+
/* selection API blocked */
|
|
2825
|
+
}
|
|
2826
|
+
}, []);
|
|
2827
|
+
// Commit on outside click.
|
|
2828
|
+
useEffect(() => {
|
|
2829
|
+
if (typeof document === 'undefined') return;
|
|
2830
|
+
const onDown = (e: PointerEvent) => {
|
|
2831
|
+
const el = ref.current;
|
|
2832
|
+
if (!el) return;
|
|
2833
|
+
if (el.contains(e.target as Node)) return;
|
|
2834
|
+
onCommit(el.innerText || '');
|
|
2835
|
+
};
|
|
2836
|
+
document.addEventListener('pointerdown', onDown, true);
|
|
2837
|
+
return () => document.removeEventListener('pointerdown', onDown, true);
|
|
2838
|
+
}, [onCommit]);
|
|
2839
|
+
return (
|
|
2840
|
+
<foreignObject x={x} y={y} width={600} height={Math.max(24, fontSize * 1.6)}>
|
|
2841
|
+
<div
|
|
2842
|
+
xmlns="http://www.w3.org/1999/xhtml"
|
|
2843
|
+
ref={ref}
|
|
2844
|
+
className="dc-annot-editor"
|
|
2845
|
+
contentEditable
|
|
2846
|
+
suppressContentEditableWarning
|
|
2847
|
+
aria-label="Edit text"
|
|
2848
|
+
style={{
|
|
2849
|
+
display: 'inline-block',
|
|
2850
|
+
minWidth: '8px',
|
|
2851
|
+
whiteSpace: 'pre',
|
|
2852
|
+
padding: '0 2px',
|
|
2853
|
+
color,
|
|
2854
|
+
fontSize: `${fontSize}px`,
|
|
2855
|
+
fontFamily: 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
|
|
2856
|
+
fontWeight: bold ? 700 : undefined,
|
|
2857
|
+
textDecoration: strike ? 'line-through' : undefined,
|
|
2858
|
+
textAlign: align ?? 'left',
|
|
2859
|
+
lineHeight: 1.2,
|
|
2860
|
+
outline: 'none',
|
|
2861
|
+
background: 'transparent',
|
|
2862
|
+
cursor: 'text',
|
|
2863
|
+
}}
|
|
2864
|
+
onKeyDown={(e) => {
|
|
2865
|
+
if (e.key === 'Escape') {
|
|
2866
|
+
e.preventDefault();
|
|
2867
|
+
onCancel();
|
|
2868
|
+
return;
|
|
2869
|
+
}
|
|
2870
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
2871
|
+
e.preventDefault();
|
|
2872
|
+
onCommit(ref.current?.innerText || '');
|
|
2873
|
+
}
|
|
2874
|
+
}}
|
|
2875
|
+
>
|
|
2876
|
+
{initialText}
|
|
2877
|
+
</div>
|
|
2878
|
+
</foreignObject>
|
|
2879
|
+
);
|
|
2880
|
+
}
|
|
2881
|
+
|
|
1695
2882
|
function SelectionHalo({
|
|
1696
2883
|
stroke,
|
|
1697
2884
|
anchorsById,
|
|
@@ -1792,7 +2979,7 @@ function AnnotGroupBbox({
|
|
|
1792
2979
|
width={handle}
|
|
1793
2980
|
height={handle}
|
|
1794
2981
|
fill="var(--maude-hud-accent, #d63b1f)"
|
|
1795
|
-
stroke="var(--bg-0, #ffffff)"
|
|
2982
|
+
stroke="var(--maude-chrome-bg-0, #ffffff)"
|
|
1796
2983
|
strokeWidth={1}
|
|
1797
2984
|
vectorEffect="non-scaling-stroke"
|
|
1798
2985
|
rx={1}
|
|
@@ -1805,14 +2992,97 @@ function AnnotGroupBbox({
|
|
|
1805
2992
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
1806
2993
|
// Stroke renderer
|
|
1807
2994
|
|
|
2995
|
+
/**
|
|
2996
|
+
* Phase 24 — inline style for a sticky body (read view + editor share it so the
|
|
2997
|
+
* read↔edit swap doesn't shift). Applies bold / strike / align atop the
|
|
2998
|
+
* `.dc-sticky-body` defaults (top-left).
|
|
2999
|
+
*/
|
|
3000
|
+
function stickyBodyStyle(s: StickyStroke): CSSProperties {
|
|
3001
|
+
const align = s.align ?? 'left';
|
|
3002
|
+
return {
|
|
3003
|
+
fontSize: `${s.fontSize}px`,
|
|
3004
|
+
fontWeight: s.bold ? 700 : undefined,
|
|
3005
|
+
textDecoration: s.strike ? 'line-through' : undefined,
|
|
3006
|
+
textAlign: align,
|
|
3007
|
+
justifyContent: align === 'left' ? 'flex-start' : align === 'right' ? 'flex-end' : 'center',
|
|
3008
|
+
};
|
|
3009
|
+
}
|
|
3010
|
+
|
|
3011
|
+
/**
|
|
3012
|
+
* Phase 24 — the translucent cursor-following ghost placeholder. Pure chrome:
|
|
3013
|
+
* `pointer-events:none`, never added to `strokes`, so it can't be selected,
|
|
3014
|
+
* hit-tested, erased, or persisted. Static (no animation) — reduced-motion safe.
|
|
3015
|
+
* Geometry mirrors what a click/tap would create at the cursor (shape +
|
|
3016
|
+
* SHAPE_DEFAULT_SIZE top-left at cursor; sticky default square; text I-beam).
|
|
3017
|
+
*/
|
|
3018
|
+
function GhostPreview({ ghost }: { ghost: GhostDescriptor }) {
|
|
3019
|
+
const { x, y } = ghost;
|
|
3020
|
+
if (ghost.kind === 'text') {
|
|
3021
|
+
const h = 22;
|
|
3022
|
+
return (
|
|
3023
|
+
<path
|
|
3024
|
+
d={`M${x - 4} ${y}H${x + 4}M${x} ${y}V${y + h}M${x - 4} ${y + h}H${x + 4}`}
|
|
3025
|
+
stroke={ghost.color}
|
|
3026
|
+
strokeWidth={1.5}
|
|
3027
|
+
strokeOpacity={0.5}
|
|
3028
|
+
fill="none"
|
|
3029
|
+
vectorEffect="non-scaling-stroke"
|
|
3030
|
+
pointerEvents="none"
|
|
3031
|
+
/>
|
|
3032
|
+
);
|
|
3033
|
+
}
|
|
3034
|
+
if (ghost.kind === 'sticky') {
|
|
3035
|
+
const s = STICKY_DEFAULT_W;
|
|
3036
|
+
return (
|
|
3037
|
+
<rect
|
|
3038
|
+
x={x}
|
|
3039
|
+
y={y}
|
|
3040
|
+
width={s}
|
|
3041
|
+
height={s}
|
|
3042
|
+
rx={STICKY_CORNER_RADIUS}
|
|
3043
|
+
ry={STICKY_CORNER_RADIUS}
|
|
3044
|
+
fill={ghost.color}
|
|
3045
|
+
fillOpacity={0.32}
|
|
3046
|
+
stroke={ghost.color}
|
|
3047
|
+
strokeOpacity={0.55}
|
|
3048
|
+
strokeWidth={1.5}
|
|
3049
|
+
vectorEffect="non-scaling-stroke"
|
|
3050
|
+
pointerEvents="none"
|
|
3051
|
+
/>
|
|
3052
|
+
);
|
|
3053
|
+
}
|
|
3054
|
+
// shape — dashed outline of the default-sized primitive at the cursor.
|
|
3055
|
+
const sz = SHAPE_DEFAULT_SIZE;
|
|
3056
|
+
const common = {
|
|
3057
|
+
fill: 'none',
|
|
3058
|
+
stroke: ghost.color,
|
|
3059
|
+
strokeWidth: 2,
|
|
3060
|
+
strokeOpacity: 0.5,
|
|
3061
|
+
strokeDasharray: '6 5',
|
|
3062
|
+
vectorEffect: 'non-scaling-stroke' as const,
|
|
3063
|
+
pointerEvents: 'none' as const,
|
|
3064
|
+
};
|
|
3065
|
+
if (ghost.shapeKind === 'circle') {
|
|
3066
|
+
return <ellipse cx={x + sz / 2} cy={y + sz / 2} rx={sz / 2} ry={sz / 2} {...common} />;
|
|
3067
|
+
}
|
|
3068
|
+
if (ghost.shapeKind === 'square' || ghost.shapeKind === 'rounded') {
|
|
3069
|
+
const r = ghost.shapeKind === 'rounded' ? 8 : 0;
|
|
3070
|
+
return <rect x={x} y={y} width={sz} height={sz} rx={r} ry={r} {...common} />;
|
|
3071
|
+
}
|
|
3072
|
+
return <polygon points={polygonPoints(ghost.shapeKind, x, y, sz, sz)} {...common} />;
|
|
3073
|
+
}
|
|
3074
|
+
|
|
1808
3075
|
function StrokeNode({
|
|
1809
3076
|
stroke,
|
|
1810
3077
|
anchorsById,
|
|
1811
3078
|
interactive,
|
|
3079
|
+
editing = false,
|
|
1812
3080
|
}: {
|
|
1813
3081
|
stroke: Stroke;
|
|
1814
3082
|
anchorsById: Map<string, RectStroke | EllipseStroke>;
|
|
1815
3083
|
interactive: boolean;
|
|
3084
|
+
/** Phase 21 — sticky-only: hide the read-only body while its editor is up. */
|
|
3085
|
+
editing?: boolean;
|
|
1816
3086
|
}) {
|
|
1817
3087
|
// In Move mode, individual stroke nodes claim pointer events so we can
|
|
1818
3088
|
// hit-test them from the doc-level capture listener. In draw mode the
|
|
@@ -1820,29 +3090,103 @@ function StrokeNode({
|
|
|
1820
3090
|
const hitMode = interactive ? 'visiblePainted' : ('none' as const);
|
|
1821
3091
|
const strokeHit = interactive ? 'stroke' : ('none' as const);
|
|
1822
3092
|
if (stroke.tool === 'text') {
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
const
|
|
1827
|
-
|
|
3093
|
+
// Anchored text renders centered in its host; standalone (Phase 21) renders
|
|
3094
|
+
// top-left-anchored at its own world (x, y).
|
|
3095
|
+
// Phase 24 — bold / strike / align applied to the rendered <text>.
|
|
3096
|
+
const textStyle = {
|
|
3097
|
+
fontFamily: 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
|
|
3098
|
+
fontWeight: stroke.bold ? 700 : undefined,
|
|
3099
|
+
textDecoration: stroke.strike ? 'line-through' : undefined,
|
|
3100
|
+
} as const;
|
|
3101
|
+
if (stroke.anchorId != null && stroke.anchorId !== '') {
|
|
3102
|
+
const host = anchorsById.get(stroke.anchorId);
|
|
3103
|
+
const bbox = host ? strokeBBox(host) : null;
|
|
3104
|
+
if (!bbox) return null;
|
|
3105
|
+
const cy = bbox.y + bbox.h / 2;
|
|
3106
|
+
const align = stroke.align ?? 'center';
|
|
3107
|
+
const pad = 8;
|
|
3108
|
+
const anchor = align === 'left' ? 'start' : align === 'right' ? 'end' : 'middle';
|
|
3109
|
+
const tx =
|
|
3110
|
+
align === 'left'
|
|
3111
|
+
? bbox.x + pad
|
|
3112
|
+
: align === 'right'
|
|
3113
|
+
? bbox.x + bbox.w - pad
|
|
3114
|
+
: bbox.x + bbox.w / 2;
|
|
3115
|
+
return (
|
|
3116
|
+
<text
|
|
3117
|
+
data-id={stroke.id}
|
|
3118
|
+
data-tool="text"
|
|
3119
|
+
data-anchor-id={stroke.anchorId}
|
|
3120
|
+
data-font-size={stroke.fontSize}
|
|
3121
|
+
x={tx}
|
|
3122
|
+
y={cy}
|
|
3123
|
+
fill={stroke.color}
|
|
3124
|
+
fontSize={stroke.fontSize}
|
|
3125
|
+
textAnchor={anchor}
|
|
3126
|
+
dominantBaseline="middle"
|
|
3127
|
+
style={textStyle}
|
|
3128
|
+
>
|
|
3129
|
+
{stroke.text}
|
|
3130
|
+
</text>
|
|
3131
|
+
);
|
|
3132
|
+
}
|
|
3133
|
+
const align = stroke.align ?? 'left';
|
|
3134
|
+
const anchor = align === 'left' ? 'start' : align === 'right' ? 'end' : 'middle';
|
|
1828
3135
|
return (
|
|
1829
3136
|
<text
|
|
1830
3137
|
data-id={stroke.id}
|
|
1831
3138
|
data-tool="text"
|
|
1832
|
-
data-anchor-id={stroke.anchorId}
|
|
1833
3139
|
data-font-size={stroke.fontSize}
|
|
1834
|
-
x={
|
|
1835
|
-
y={
|
|
3140
|
+
x={stroke.x ?? 0}
|
|
3141
|
+
y={stroke.y ?? 0}
|
|
1836
3142
|
fill={stroke.color}
|
|
1837
3143
|
fontSize={stroke.fontSize}
|
|
1838
|
-
textAnchor=
|
|
1839
|
-
dominantBaseline="
|
|
1840
|
-
|
|
3144
|
+
textAnchor={anchor}
|
|
3145
|
+
dominantBaseline="hanging"
|
|
3146
|
+
pointerEvents={interactive ? 'visiblePainted' : 'none'}
|
|
3147
|
+
style={textStyle}
|
|
1841
3148
|
>
|
|
1842
3149
|
{stroke.text}
|
|
1843
3150
|
</text>
|
|
1844
3151
|
);
|
|
1845
3152
|
}
|
|
3153
|
+
if (stroke.tool === 'sticky') {
|
|
3154
|
+
const x = Math.min(stroke.x, stroke.x + stroke.w);
|
|
3155
|
+
const y = Math.min(stroke.y, stroke.y + stroke.h);
|
|
3156
|
+
const w = Math.abs(stroke.w);
|
|
3157
|
+
const h = Math.abs(stroke.h);
|
|
3158
|
+
const r = stroke.cornerRadius ?? STICKY_CORNER_RADIUS;
|
|
3159
|
+
return (
|
|
3160
|
+
<g data-id={stroke.id} data-tool="sticky" pointerEvents={hitMode}>
|
|
3161
|
+
{/* Paper card: soft drop shadow + hairline edge so it reads as a
|
|
3162
|
+
lifted sticky, not a flat colored box (FigJam-style). */}
|
|
3163
|
+
<rect
|
|
3164
|
+
x={x}
|
|
3165
|
+
y={y}
|
|
3166
|
+
width={w}
|
|
3167
|
+
height={h}
|
|
3168
|
+
rx={r}
|
|
3169
|
+
ry={r}
|
|
3170
|
+
fill={stroke.color}
|
|
3171
|
+
stroke="rgba(0,0,0,0.05)"
|
|
3172
|
+
strokeWidth={1}
|
|
3173
|
+
vectorEffect="non-scaling-stroke"
|
|
3174
|
+
filter="url(#dc-sticky-shadow)"
|
|
3175
|
+
/>
|
|
3176
|
+
{editing ? null : (
|
|
3177
|
+
<foreignObject x={x} y={y} width={w} height={h} pointerEvents="none">
|
|
3178
|
+
<div
|
|
3179
|
+
xmlns="http://www.w3.org/1999/xhtml"
|
|
3180
|
+
className="dc-sticky-body"
|
|
3181
|
+
style={stickyBodyStyle(stroke)}
|
|
3182
|
+
>
|
|
3183
|
+
{stroke.text}
|
|
3184
|
+
</div>
|
|
3185
|
+
</foreignObject>
|
|
3186
|
+
)}
|
|
3187
|
+
</g>
|
|
3188
|
+
);
|
|
3189
|
+
}
|
|
1846
3190
|
const common = {
|
|
1847
3191
|
'data-id': stroke.id,
|
|
1848
3192
|
'data-tool': stroke.tool,
|
|
@@ -1858,6 +3202,7 @@ function StrokeNode({
|
|
|
1858
3202
|
if (stroke.tool === 'rect') {
|
|
1859
3203
|
const x = Math.min(stroke.x, stroke.x + stroke.w);
|
|
1860
3204
|
const y = Math.min(stroke.y, stroke.y + stroke.h);
|
|
3205
|
+
const r = stroke.cornerRadius ?? 0;
|
|
1861
3206
|
return (
|
|
1862
3207
|
<rect
|
|
1863
3208
|
{...common}
|
|
@@ -1866,6 +3211,8 @@ function StrokeNode({
|
|
|
1866
3211
|
y={y}
|
|
1867
3212
|
width={Math.abs(stroke.w)}
|
|
1868
3213
|
height={Math.abs(stroke.h)}
|
|
3214
|
+
rx={r}
|
|
3215
|
+
ry={r}
|
|
1869
3216
|
pointerEvents={hitMode}
|
|
1870
3217
|
/>
|
|
1871
3218
|
);
|
|
@@ -1883,22 +3230,64 @@ function StrokeNode({
|
|
|
1883
3230
|
/>
|
|
1884
3231
|
);
|
|
1885
3232
|
}
|
|
1886
|
-
|
|
3233
|
+
if (stroke.tool === 'polygon') {
|
|
3234
|
+
const nx = Math.min(stroke.x, stroke.x + stroke.w);
|
|
3235
|
+
const ny = Math.min(stroke.y, stroke.y + stroke.h);
|
|
3236
|
+
return (
|
|
3237
|
+
<polygon
|
|
3238
|
+
{...common}
|
|
3239
|
+
data-shape={stroke.shape}
|
|
3240
|
+
fill={stroke.fill ?? 'none'}
|
|
3241
|
+
points={polygonPoints(stroke.shape, nx, ny, Math.abs(stroke.w), Math.abs(stroke.h))}
|
|
3242
|
+
strokeDasharray={stroke.dashed ? '6 4' : undefined}
|
|
3243
|
+
pointerEvents={hitMode}
|
|
3244
|
+
/>
|
|
3245
|
+
);
|
|
3246
|
+
}
|
|
3247
|
+
// arrow — Phase 24 renders the SAME ordered primitives the serializer emits
|
|
3248
|
+
// (canvas-arrowheads), so the on-canvas and persisted forms can never drift.
|
|
1887
3249
|
return (
|
|
1888
3250
|
<g {...common} fill="none" pointerEvents={hitMode}>
|
|
1889
|
-
|
|
1890
|
-
<polyline points={head} fill={stroke.color} />
|
|
3251
|
+
{arrowPrimitives(stroke).map((p, i) => renderArrowPrimitive(p, i))}
|
|
1891
3252
|
</g>
|
|
1892
3253
|
);
|
|
1893
3254
|
}
|
|
1894
3255
|
|
|
3256
|
+
/** Map one arrow primitive to JSX (heads inherit stroke from the parent <g>). */
|
|
3257
|
+
function renderArrowPrimitive(p: SvgPrimitive, key: number): JSX.Element {
|
|
3258
|
+
switch (p.el) {
|
|
3259
|
+
case 'line':
|
|
3260
|
+
return (
|
|
3261
|
+
<line
|
|
3262
|
+
key={key}
|
|
3263
|
+
x1={p.x1}
|
|
3264
|
+
y1={p.y1}
|
|
3265
|
+
x2={p.x2}
|
|
3266
|
+
y2={p.y2}
|
|
3267
|
+
strokeDasharray={p.dash ? '6 4' : undefined}
|
|
3268
|
+
/>
|
|
3269
|
+
);
|
|
3270
|
+
case 'path':
|
|
3271
|
+
return <path key={key} d={p.d} strokeDasharray={p.dash ? '6 4' : undefined} />;
|
|
3272
|
+
case 'polyline':
|
|
3273
|
+
return <polyline key={key} points={p.points} fill={p.fill} />;
|
|
3274
|
+
case 'polygon':
|
|
3275
|
+
return <polygon key={key} points={p.points} fill={p.fill} />;
|
|
3276
|
+
case 'circle':
|
|
3277
|
+
return <circle key={key} cx={p.cx} cy={p.cy} r={p.r} fill={p.fill} />;
|
|
3278
|
+
}
|
|
3279
|
+
}
|
|
3280
|
+
|
|
1895
3281
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
1896
3282
|
// Chrome — color swatches + (optional fill picker) + (optional thickness chip)
|
|
1897
3283
|
// + presentation toggle + help button.
|
|
1898
3284
|
|
|
1899
3285
|
function AnnotationsChrome({
|
|
3286
|
+
tool,
|
|
1900
3287
|
color,
|
|
1901
3288
|
setColor,
|
|
3289
|
+
stickyColor,
|
|
3290
|
+
setStickyColor,
|
|
1902
3291
|
supportsFill,
|
|
1903
3292
|
fill,
|
|
1904
3293
|
setFill,
|
|
@@ -1906,8 +3295,11 @@ function AnnotationsChrome({
|
|
|
1906
3295
|
thickness,
|
|
1907
3296
|
setThickness,
|
|
1908
3297
|
}: {
|
|
3298
|
+
tool: Tool;
|
|
1909
3299
|
color: string;
|
|
1910
3300
|
setColor: (c: string) => void;
|
|
3301
|
+
stickyColor: string;
|
|
3302
|
+
setStickyColor: (c: string) => void;
|
|
1911
3303
|
supportsFill: boolean;
|
|
1912
3304
|
fill: string | null;
|
|
1913
3305
|
setFill: (f: string | null) => void;
|
|
@@ -1915,10 +3307,32 @@ function AnnotationsChrome({
|
|
|
1915
3307
|
thickness: Thickness;
|
|
1916
3308
|
setThickness: (t: Thickness) => void;
|
|
1917
3309
|
}) {
|
|
3310
|
+
// Sticky tool picks a paper tint (its own palette); every other draw tool
|
|
3311
|
+
// picks ink from the stroke PALETTE.
|
|
3312
|
+
if (tool === 'sticky') {
|
|
3313
|
+
return (
|
|
3314
|
+
<div className="dc-annot-chrome" role="toolbar" aria-label="Sticky note tools">
|
|
3315
|
+
<div className="dc-annot-swatches" role="radiogroup" aria-label="Sticky color">
|
|
3316
|
+
{STICKY_PALETTE.map((c) => (
|
|
3317
|
+
<button
|
|
3318
|
+
key={c}
|
|
3319
|
+
type="button"
|
|
3320
|
+
className="dc-annot-sw"
|
|
3321
|
+
aria-pressed={c === stickyColor}
|
|
3322
|
+
aria-label={`Sticky color ${c}`}
|
|
3323
|
+
title={`Sticky color ${c}`}
|
|
3324
|
+
style={{ background: c }}
|
|
3325
|
+
onClick={() => setStickyColor(c)}
|
|
3326
|
+
/>
|
|
3327
|
+
))}
|
|
3328
|
+
</div>
|
|
3329
|
+
</div>
|
|
3330
|
+
);
|
|
3331
|
+
}
|
|
1918
3332
|
return (
|
|
1919
3333
|
<div className="dc-annot-chrome" role="toolbar" aria-label="Annotation tools">
|
|
1920
3334
|
<div className="dc-annot-swatches" role="radiogroup" aria-label="Stroke color">
|
|
1921
|
-
{
|
|
3335
|
+
{STROKE_PALETTE.map((c) => (
|
|
1922
3336
|
<button
|
|
1923
3337
|
key={c}
|
|
1924
3338
|
type="button"
|
|
@@ -1963,21 +3377,23 @@ function AnnotationsChrome({
|
|
|
1963
3377
|
<div className="dc-annot-sep" />
|
|
1964
3378
|
<button
|
|
1965
3379
|
type="button"
|
|
1966
|
-
className="dc-annot-
|
|
3380
|
+
className="dc-annot-ibtn"
|
|
3381
|
+
aria-label="Thin stroke"
|
|
1967
3382
|
aria-pressed={thickness === STROKE_WIDTH_THIN}
|
|
1968
3383
|
title="Thin (3px)"
|
|
1969
3384
|
onClick={() => setThickness(STROKE_WIDTH_THIN)}
|
|
1970
3385
|
>
|
|
1971
|
-
|
|
3386
|
+
<IconLineThin />
|
|
1972
3387
|
</button>
|
|
1973
3388
|
<button
|
|
1974
3389
|
type="button"
|
|
1975
|
-
className="dc-annot-
|
|
3390
|
+
className="dc-annot-ibtn"
|
|
3391
|
+
aria-label="Thick stroke"
|
|
1976
3392
|
aria-pressed={thickness === STROKE_WIDTH_THICK}
|
|
1977
3393
|
title="Thick (6px)"
|
|
1978
3394
|
onClick={() => setThickness(STROKE_WIDTH_THICK)}
|
|
1979
3395
|
>
|
|
1980
|
-
|
|
3396
|
+
<IconLineThick />
|
|
1981
3397
|
</button>
|
|
1982
3398
|
</>
|
|
1983
3399
|
) : null}
|