@1agh/maude 0.24.0 → 0.26.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/cli/commands/design.mjs +5 -0
- package/cli/lib/design-link.mjs +13 -6
- package/cli/lib/gitignore-block.mjs +1 -0
- package/cli/lib/gitignore-block.test.mjs +1 -0
- package/package.json +8 -8
- package/plugins/design/dependencies.json +30 -2
- package/plugins/design/dev-server/annotations-context-toolbar.tsx +481 -78
- package/plugins/design/dev-server/annotations-layer.tsx +817 -170
- package/plugins/design/dev-server/api.ts +15 -1
- 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-optimize.mjs +35 -0
- package/plugins/design/dev-server/bin/_svg-playwright.mjs +125 -19
- package/plugins/design/dev-server/bin/draw-build.sh +48 -0
- package/plugins/design/dev-server/bin/draw-proof.sh +129 -0
- package/plugins/design/dev-server/bin/smoke.sh +114 -12
- package/plugins/design/dev-server/bin/svg-optimize.sh +24 -0
- 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 +130 -82
- package/plugins/design/dev-server/canvas-icons.tsx +169 -0
- package/plugins/design/dev-server/canvas-lib.tsx +110 -0
- package/plugins/design/dev-server/canvas-shell.tsx +113 -89
- package/plugins/design/dev-server/client/app.jsx +1084 -417
- package/plugins/design/dev-server/config.schema.json +10 -0
- package/plugins/design/dev-server/context.ts +9 -0
- package/plugins/design/dev-server/dist/client.bundle.js +242 -20
- package/plugins/design/dev-server/dist/comment-mount.js +40 -62
- package/plugins/design/dev-server/draw/brush.ts +639 -0
- package/plugins/design/dev-server/draw/composition.ts +229 -0
- package/plugins/design/dev-server/draw/geometry.ts +578 -0
- package/plugins/design/dev-server/draw/index.ts +28 -0
- package/plugins/design/dev-server/draw/layout.ts +260 -0
- package/plugins/design/dev-server/draw/optimize.ts +65 -0
- package/plugins/design/dev-server/draw/palette.ts +417 -0
- package/plugins/design/dev-server/draw/primitives.ts +643 -0
- package/plugins/design/dev-server/draw/serialize.ts +458 -0
- package/plugins/design/dev-server/draw/test/brush.test.ts +213 -0
- package/plugins/design/dev-server/draw/test/composition.test.ts +141 -0
- package/plugins/design/dev-server/draw/test/geometry.test.ts +199 -0
- package/plugins/design/dev-server/draw/test/gradient.test.ts +167 -0
- package/plugins/design/dev-server/draw/test/layout.test.ts +120 -0
- package/plugins/design/dev-server/draw/test/optimize.test.ts +40 -0
- package/plugins/design/dev-server/draw/test/palette.test.ts +123 -0
- package/plugins/design/dev-server/draw/test/primitives.test.ts +129 -0
- package/plugins/design/dev-server/draw/test/serialize.test.ts +105 -0
- package/plugins/design/dev-server/export-dialog.tsx +189 -1
- 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/http.ts +40 -9
- package/plugins/design/dev-server/input-router.tsx +9 -2
- package/plugins/design/dev-server/sync/index.ts +73 -17
- package/plugins/design/dev-server/test/annotations-draw-modifiers.test.ts +206 -0
- package/plugins/design/dev-server/test/annotations-layer.test.ts +83 -3
- package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +243 -0
- package/plugins/design/dev-server/test/canvas-cursors.test.ts +95 -6
- package/plugins/design/dev-server/test/canvas-route.test.ts +4 -1
- 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-21-annotations.svg +1 -0
- package/plugins/design/dev-server/test/input-router.test.ts +11 -4
- package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +32 -0
- package/plugins/design/dev-server/test/sync-runtime.test.ts +52 -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 +9 -11
- package/plugins/design/dev-server/tool-palette.tsx +140 -11
- package/plugins/design/dev-server/tsconfig.json +8 -1
- package/plugins/design/dev-server/use-annotation-resize.tsx +208 -61
- package/plugins/design/dev-server/use-tool-mode.tsx +55 -9
- package/plugins/design/templates/_shell.html +36 -9
- package/plugins/design/templates/design-system-inspiration/_MAPPING.md +15 -0
- package/plugins/design/templates/design-system-inspiration/core/config.json.tpl +1 -0
|
@@ -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,6 +35,13 @@ 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';
|
|
37
45
|
import { IconLineThick, IconLineThin } from './canvas-icons.tsx';
|
|
38
46
|
import { useViewportControllerContext, useWorldRefContext } from './canvas-lib.tsx';
|
|
39
47
|
import { buildAnnotationStrokesRecord } from './commands/annotation-strokes-command.ts';
|
|
@@ -43,7 +51,7 @@ import { useAnnotationSelectionOptional } from './use-annotation-selection.tsx';
|
|
|
43
51
|
import { useAnnotationsVisibility } from './use-annotations-visibility.tsx';
|
|
44
52
|
import { useCollab } from './use-collab.tsx';
|
|
45
53
|
import { useSelectionSetOptional } from './use-selection-set.tsx';
|
|
46
|
-
import { useToolMode } from './use-tool-mode.tsx';
|
|
54
|
+
import { type ShapeKind, useToolMode } from './use-tool-mode.tsx';
|
|
47
55
|
import { useUndoSinks, useUndoStackOptional } from './use-undo-stack.tsx';
|
|
48
56
|
|
|
49
57
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -51,6 +59,21 @@ import { useUndoSinks, useUndoStackOptional } from './use-undo-stack.tsx';
|
|
|
51
59
|
|
|
52
60
|
type WorldPoint = readonly [number, number];
|
|
53
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
|
+
|
|
54
77
|
export interface PenStroke {
|
|
55
78
|
id: string;
|
|
56
79
|
tool: 'pen';
|
|
@@ -82,6 +105,27 @@ export interface EllipseStroke {
|
|
|
82
105
|
ry: number;
|
|
83
106
|
fill?: string | null;
|
|
84
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
|
+
}
|
|
85
129
|
export interface ArrowStroke {
|
|
86
130
|
id: string;
|
|
87
131
|
tool: 'arrow';
|
|
@@ -91,12 +135,14 @@ export interface ArrowStroke {
|
|
|
91
135
|
y1: number;
|
|
92
136
|
x2: number;
|
|
93
137
|
y2: number;
|
|
94
|
-
/**
|
|
95
|
-
startHead?:
|
|
96
|
-
/**
|
|
97
|
-
endHead?:
|
|
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;
|
|
98
142
|
/** Phase 21 — dashed shaft (stroke-dasharray). Absent / false = solid. */
|
|
99
143
|
dashed?: boolean;
|
|
144
|
+
/** Phase 24 — shaft routing. Absent = 'straight' (back-compat). */
|
|
145
|
+
lineType?: ArrowLineType;
|
|
100
146
|
}
|
|
101
147
|
export interface TextStroke {
|
|
102
148
|
id: string;
|
|
@@ -113,6 +159,15 @@ export interface TextStroke {
|
|
|
113
159
|
/** Phase 21 — world coords for standalone (unanchored) text. */
|
|
114
160
|
x?: number;
|
|
115
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;
|
|
116
171
|
}
|
|
117
172
|
/** Phase 21 — sticky note: a paper-tone card with its own word-wrapped text. */
|
|
118
173
|
export interface StickyStroke {
|
|
@@ -127,11 +182,18 @@ export interface StickyStroke {
|
|
|
127
182
|
fontSize: number;
|
|
128
183
|
/** Corner radius; defaults to STICKY_CORNER_RADIUS (8 = soft). */
|
|
129
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;
|
|
130
191
|
}
|
|
131
192
|
export type Stroke =
|
|
132
193
|
| PenStroke
|
|
133
194
|
| RectStroke
|
|
134
195
|
| EllipseStroke
|
|
196
|
+
| PolygonStroke
|
|
135
197
|
| ArrowStroke
|
|
136
198
|
| TextStroke
|
|
137
199
|
| StickyStroke;
|
|
@@ -167,7 +229,12 @@ export const STROKE_PALETTE = [
|
|
|
167
229
|
'#1f1f1f', // ink
|
|
168
230
|
] as const;
|
|
169
231
|
type PaletteColor = (typeof STROKE_PALETTE)[number];
|
|
170
|
-
|
|
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];
|
|
171
238
|
|
|
172
239
|
// Light tints, index-paired to STROKE_PALETTE — picking "blue fill" gives a
|
|
173
240
|
// pale blue wash under a saturated stroke, exactly like FigJam shapes.
|
|
@@ -190,22 +257,33 @@ type Thickness = typeof STROKE_WIDTH_THIN | typeof STROKE_WIDTH_THICK;
|
|
|
190
257
|
const FONT_SIZE_MEDIUM = 14;
|
|
191
258
|
const DEFAULT_FONT_SIZE = FONT_SIZE_MEDIUM;
|
|
192
259
|
|
|
193
|
-
// Phase
|
|
194
|
-
//
|
|
195
|
-
//
|
|
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.
|
|
196
266
|
export const STICKY_PALETTE = [
|
|
197
|
-
'#
|
|
198
|
-
'#
|
|
199
|
-
'#
|
|
200
|
-
'#
|
|
201
|
-
'#
|
|
202
|
-
'#
|
|
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
|
|
203
277
|
] as const;
|
|
204
278
|
const DEFAULT_STICKY_COLOR = STICKY_PALETTE[0];
|
|
205
279
|
const STICKY_CORNER_RADIUS = 8;
|
|
280
|
+
// Phase 24 — stickies are 1:1; the default tap size is a square.
|
|
206
281
|
const STICKY_DEFAULT_W = 200;
|
|
207
|
-
const STICKY_DEFAULT_H =
|
|
282
|
+
const STICKY_DEFAULT_H = 200;
|
|
208
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;
|
|
209
287
|
|
|
210
288
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
211
289
|
// Pure helpers — exported for unit tests.
|
|
@@ -227,21 +305,92 @@ export function penPathD(points: readonly WorldPoint[]): string {
|
|
|
227
305
|
return d;
|
|
228
306
|
}
|
|
229
307
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
|
236
356
|
): string {
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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 };
|
|
245
394
|
}
|
|
246
395
|
|
|
247
396
|
function strokeToSvgEl(s: Stroke): string {
|
|
@@ -249,18 +398,34 @@ function strokeToSvgEl(s: Stroke): string {
|
|
|
249
398
|
// Phase 21 — anchored text keeps the byte-identical Phase 5.1 form;
|
|
250
399
|
// standalone text (no anchorId) writes its own world x/y and omits
|
|
251
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"' : '';
|
|
252
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}"` : '';
|
|
253
409
|
return `<text data-id="${esc(s.id)}" data-tool="text" data-anchor-id="${esc(
|
|
254
410
|
s.anchorId
|
|
255
411
|
)}" data-font-size="${s.fontSize}" fill="${esc(
|
|
256
412
|
s.color
|
|
257
|
-
)}" text-anchor="
|
|
413
|
+
)}"${weight}${deco} text-anchor="${anchor}" dominant-baseline="middle"${alignAttr}>${esc(
|
|
414
|
+
s.text
|
|
415
|
+
)}</text>`;
|
|
258
416
|
}
|
|
259
417
|
const tx = s.x ?? 0;
|
|
260
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}"` : '';
|
|
261
422
|
return `<text data-id="${esc(s.id)}" data-tool="text" x="${tx}" y="${ty}" data-font-size="${
|
|
262
423
|
s.fontSize
|
|
263
|
-
}" fill="${esc(
|
|
424
|
+
}" fill="${esc(
|
|
425
|
+
s.color
|
|
426
|
+
)}"${weight}${deco} text-anchor="${anchor}" dominant-baseline="hanging"${alignAttr}>${esc(
|
|
427
|
+
s.text
|
|
428
|
+
)}</text>`;
|
|
264
429
|
}
|
|
265
430
|
if (s.tool === 'sticky') {
|
|
266
431
|
// Phase 21 — sticky body lives in an allowlisted <text> child so it
|
|
@@ -270,9 +435,17 @@ function strokeToSvgEl(s: Stroke): string {
|
|
|
270
435
|
const r = s.cornerRadius ?? STICKY_CORNER_RADIUS;
|
|
271
436
|
const w = Math.max(0, s.w);
|
|
272
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}"` : '');
|
|
273
446
|
return `<g data-id="${esc(s.id)}" data-tool="sticky" data-r="${r}" data-fs="${
|
|
274
447
|
s.fontSize
|
|
275
|
-
}" fill="${esc(s.color)}"><rect x="${s.x}" y="${
|
|
448
|
+
}" fill="${esc(s.color)}"${styleAttrs}><rect x="${s.x}" y="${
|
|
276
449
|
s.y
|
|
277
450
|
}" width="${w}" height="${h}" rx="${r}" ry="${r}"/><text data-sticky-body="1" x="${
|
|
278
451
|
s.x + 12
|
|
@@ -302,30 +475,61 @@ function strokeToSvgEl(s: Stroke): string {
|
|
|
302
475
|
s.rx
|
|
303
476
|
)}" ry="${Math.max(0, s.ry)}"/>`;
|
|
304
477
|
}
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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.
|
|
310
501
|
const startHead = s.startHead ?? 'none';
|
|
502
|
+
const endHead = s.endHead ?? 'triangle';
|
|
503
|
+
const lineType = s.lineType ?? 'straight';
|
|
311
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.
|
|
312
508
|
const dataAttrs =
|
|
313
|
-
(startHead !== 'none' ? ` data-start-head="${startHead}"` : '') +
|
|
314
|
-
(endHead !== 'triangle' ? ` data-end-head="${endHead}"` : '') +
|
|
509
|
+
(startHead !== 'none' ? ` data-start-head="${esc(startHead)}"` : '') +
|
|
510
|
+
(endHead !== 'triangle' ? ` data-end-head="${esc(endHead)}"` : '') +
|
|
511
|
+
(lineType !== 'straight' ? ` data-line-type="${esc(lineType)}"` : '') +
|
|
315
512
|
(dashed ? ' data-dash="1"' : '');
|
|
316
|
-
const
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
+
}
|
|
329
533
|
}
|
|
330
534
|
|
|
331
535
|
export function strokesToSvg(strokes: readonly Stroke[]): string {
|
|
@@ -354,6 +558,38 @@ function parseFill(raw: string | null): string | null {
|
|
|
354
558
|
return raw;
|
|
355
559
|
}
|
|
356
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
|
+
|
|
357
593
|
export function svgToStrokes(svgText: string): Stroke[] {
|
|
358
594
|
const text = (svgText ?? '').trim();
|
|
359
595
|
if (!text) return [];
|
|
@@ -388,7 +624,7 @@ export function svgToStrokes(svgText: string): Stroke[] {
|
|
|
388
624
|
DEFAULT_FONT_SIZE;
|
|
389
625
|
const stickyColor = el.getAttribute('fill') || DEFAULT_STICKY_COLOR;
|
|
390
626
|
const body = el.querySelector('text');
|
|
391
|
-
|
|
627
|
+
const sticky: StickyStroke = {
|
|
392
628
|
id,
|
|
393
629
|
tool: 'sticky',
|
|
394
630
|
color: stickyColor,
|
|
@@ -399,7 +635,15 @@ export function svgToStrokes(svgText: string): Stroke[] {
|
|
|
399
635
|
text: body?.textContent ?? '',
|
|
400
636
|
fontSize,
|
|
401
637
|
cornerRadius,
|
|
402
|
-
}
|
|
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);
|
|
403
647
|
continue;
|
|
404
648
|
}
|
|
405
649
|
if (tool === 'rect') {
|
|
@@ -422,25 +666,62 @@ export function svgToStrokes(svgText: string): Stroke[] {
|
|
|
422
666
|
out.push({ id, tool: 'ellipse', color, width, cx, cy, rx, ry, fill });
|
|
423
667
|
continue;
|
|
424
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
|
+
}
|
|
425
695
|
if (tool === 'arrow') {
|
|
426
|
-
|
|
427
|
-
|
|
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) {
|
|
428
700
|
const arrow: ArrowStroke = {
|
|
429
701
|
id,
|
|
430
702
|
tool: 'arrow',
|
|
431
703
|
color,
|
|
432
704
|
width,
|
|
433
|
-
x1:
|
|
434
|
-
y1:
|
|
435
|
-
x2:
|
|
436
|
-
y2:
|
|
705
|
+
x1: ends.x1,
|
|
706
|
+
y1: ends.y1,
|
|
707
|
+
x2: ends.x2,
|
|
708
|
+
y2: ends.y2,
|
|
437
709
|
};
|
|
438
|
-
//
|
|
439
|
-
// for a NON-default value, so a legacy arrow carries none of
|
|
440
|
-
// and stays { startHead/endHead/dashed: undefined } →
|
|
441
|
-
// re-serialize (byte-identical,
|
|
442
|
-
|
|
443
|
-
|
|
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;
|
|
444
725
|
const dashRaw = el.getAttribute('data-dash');
|
|
445
726
|
if (dashRaw === '1' || dashRaw === 'true') arrow.dashed = true;
|
|
446
727
|
out.push(arrow);
|
|
@@ -454,10 +735,18 @@ export function svgToStrokes(svgText: string): Stroke[] {
|
|
|
454
735
|
DEFAULT_FONT_SIZE;
|
|
455
736
|
const inkColor = el.getAttribute('fill') || color;
|
|
456
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;
|
|
457
746
|
// Phase 21 — standalone text (no data-anchor-id) carries world x/y
|
|
458
747
|
// instead of a host id.
|
|
459
748
|
if (!rawAnchor) {
|
|
460
|
-
|
|
749
|
+
const t: TextStroke = {
|
|
461
750
|
id,
|
|
462
751
|
tool: 'text',
|
|
463
752
|
color: inkColor,
|
|
@@ -465,17 +754,25 @@ export function svgToStrokes(svgText: string): Stroke[] {
|
|
|
465
754
|
text: body,
|
|
466
755
|
x: Number.parseFloat(el.getAttribute('x') || '0'),
|
|
467
756
|
y: Number.parseFloat(el.getAttribute('y') || '0'),
|
|
468
|
-
}
|
|
757
|
+
};
|
|
758
|
+
if (isBold) t.bold = true;
|
|
759
|
+
if (isStrike) t.strike = true;
|
|
760
|
+
if (align) t.align = align;
|
|
761
|
+
out.push(t);
|
|
469
762
|
continue;
|
|
470
763
|
}
|
|
471
|
-
|
|
764
|
+
const t: TextStroke = {
|
|
472
765
|
id,
|
|
473
766
|
tool: 'text',
|
|
474
767
|
color: inkColor,
|
|
475
768
|
fontSize,
|
|
476
769
|
text: body,
|
|
477
770
|
anchorId: rawAnchor,
|
|
478
|
-
}
|
|
771
|
+
};
|
|
772
|
+
if (isBold) t.bold = true;
|
|
773
|
+
if (isStrike) t.strike = true;
|
|
774
|
+
if (align) t.align = align;
|
|
775
|
+
out.push(t);
|
|
479
776
|
}
|
|
480
777
|
}
|
|
481
778
|
return out;
|
|
@@ -549,6 +846,20 @@ export function strokeHitTest(s: Stroke, wx: number, wy: number, tol: number): b
|
|
|
549
846
|
const dist = Math.abs(Math.sqrt(d) - 1);
|
|
550
847
|
return dist <= band;
|
|
551
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
|
+
}
|
|
552
863
|
// rect — inside when filled, edge-only otherwise.
|
|
553
864
|
const x = s.x;
|
|
554
865
|
const y = s.y;
|
|
@@ -582,6 +893,97 @@ function normalizeBox<T extends { x: number; y: number; w: number; h: number }>(
|
|
|
582
893
|
};
|
|
583
894
|
}
|
|
584
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
|
+
|
|
585
987
|
function normalizeRect(r: RectStroke): RectStroke {
|
|
586
988
|
return normalizeBox(r);
|
|
587
989
|
}
|
|
@@ -594,6 +996,7 @@ function normalizeSticky(s: StickyStroke): StickyStroke {
|
|
|
594
996
|
function isStrokeMeaningful(s: Stroke): boolean {
|
|
595
997
|
if (s.tool === 'pen') return s.points.length >= 2;
|
|
596
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;
|
|
597
1000
|
if (s.tool === 'ellipse') return s.rx >= 2 && s.ry >= 2;
|
|
598
1001
|
if (s.tool === 'text') return s.text.trim().length > 0;
|
|
599
1002
|
// Sticky below a readable floor is discarded like a 2×2 rect.
|
|
@@ -620,7 +1023,7 @@ export function strokeBBox(
|
|
|
620
1023
|
}
|
|
621
1024
|
return { x: xMin, y: yMin, w: xMax - xMin, h: yMax - yMin };
|
|
622
1025
|
}
|
|
623
|
-
if (s.tool === 'rect') {
|
|
1026
|
+
if (s.tool === 'rect' || s.tool === 'polygon') {
|
|
624
1027
|
return {
|
|
625
1028
|
x: Math.min(s.x, s.x + s.w),
|
|
626
1029
|
y: Math.min(s.y, s.y + s.h),
|
|
@@ -834,18 +1237,19 @@ const ANNOT_CSS = `
|
|
|
834
1237
|
stroke-width: 1;
|
|
835
1238
|
stroke-dasharray: 4 3;
|
|
836
1239
|
}
|
|
837
|
-
/* Phase
|
|
838
|
-
foreignObject.
|
|
839
|
-
metrics so the read
|
|
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. */
|
|
840
1244
|
.dc-sticky-body {
|
|
841
1245
|
width: 100%;
|
|
842
1246
|
height: 100%;
|
|
843
1247
|
box-sizing: border-box;
|
|
844
1248
|
padding: 14px 16px;
|
|
845
1249
|
display: flex;
|
|
846
|
-
align-items:
|
|
847
|
-
justify-content:
|
|
848
|
-
text-align:
|
|
1250
|
+
align-items: flex-start;
|
|
1251
|
+
justify-content: flex-start;
|
|
1252
|
+
text-align: left;
|
|
849
1253
|
color: #2a2a28;
|
|
850
1254
|
font-family: var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
|
851
1255
|
line-height: 1.35;
|
|
@@ -853,6 +1257,15 @@ const ANNOT_CSS = `
|
|
|
853
1257
|
overflow-wrap: anywhere;
|
|
854
1258
|
overflow: hidden;
|
|
855
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; }
|
|
856
1269
|
`.trim();
|
|
857
1270
|
|
|
858
1271
|
function ensureAnnotStyles(): void {
|
|
@@ -886,7 +1299,7 @@ function translateOne(s: Stroke, dx: number, dy: number): Stroke {
|
|
|
886
1299
|
if (s.tool === 'pen') {
|
|
887
1300
|
return { ...s, points: s.points.map(([x, y]) => [x + dx, y + dy] as WorldPoint) };
|
|
888
1301
|
}
|
|
889
|
-
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 };
|
|
890
1303
|
if (s.tool === 'ellipse') return { ...s, cx: s.cx + dx, cy: s.cy + dy };
|
|
891
1304
|
if (s.tool === 'arrow')
|
|
892
1305
|
return { ...s, x1: s.x1 + dx, y1: s.y1 + dy, x2: s.x2 + dx, y2: s.y2 + dy };
|
|
@@ -924,7 +1337,7 @@ export { useAnnotationsVisibility } from './use-annotations-visibility.tsx';
|
|
|
924
1337
|
|
|
925
1338
|
export function AnnotationsLayer() {
|
|
926
1339
|
ensureAnnotStyles();
|
|
927
|
-
const { tool, setTool, sticky, tools } = useToolMode();
|
|
1340
|
+
const { tool, setTool, sticky, tools, shapeKind } = useToolMode();
|
|
928
1341
|
const controller = useViewportControllerContext();
|
|
929
1342
|
const vp = controller?.viewport ?? null;
|
|
930
1343
|
const worldRef = useWorldRefContext();
|
|
@@ -943,6 +1356,10 @@ export function AnnotationsLayer() {
|
|
|
943
1356
|
// stroke exists yet (mirrors anchored text: the stroke is born on commit,
|
|
944
1357
|
// so an abandoned empty caret leaves nothing behind / no undo record).
|
|
945
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);
|
|
946
1363
|
const visibilityCtx = useAnnotationsVisibility();
|
|
947
1364
|
const visible = visibilityCtx?.visible ?? true;
|
|
948
1365
|
const setVisible = useCallback(
|
|
@@ -962,6 +1379,11 @@ export function AnnotationsLayer() {
|
|
|
962
1379
|
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
963
1380
|
const drawingRef = useRef<Stroke | null>(null);
|
|
964
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);
|
|
965
1387
|
/**
|
|
966
1388
|
* Phase 20 — latest strokes mirror so command builders can read the
|
|
967
1389
|
* pre-mutation snapshot synchronously (React state isn't refreshed
|
|
@@ -971,20 +1393,22 @@ export function AnnotationsLayer() {
|
|
|
971
1393
|
strokesRef.current = strokes;
|
|
972
1394
|
|
|
973
1395
|
const isDraw =
|
|
974
|
-
tool === 'pen' ||
|
|
975
|
-
tool === 'rect' ||
|
|
976
|
-
tool === 'arrow' ||
|
|
977
|
-
tool === 'ellipse' ||
|
|
978
|
-
tool === 'sticky' ||
|
|
979
|
-
tool === 'text';
|
|
1396
|
+
tool === 'pen' || tool === 'shape' || tool === 'arrow' || tool === 'sticky' || tool === 'text';
|
|
980
1397
|
const isErase = tool === 'eraser';
|
|
981
1398
|
const isActive = isDraw || isErase;
|
|
982
|
-
// T20 —
|
|
983
|
-
//
|
|
984
|
-
// whether to render the Thin / Thick chips.
|
|
985
|
-
const supportsThickness =
|
|
986
|
-
|
|
987
|
-
|
|
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]);
|
|
988
1412
|
|
|
989
1413
|
// Load existing annotations on mount.
|
|
990
1414
|
// Phase 8 Task 5 — seed lastAppliedSvgRef so the first Y.Map observe (when
|
|
@@ -1228,6 +1652,7 @@ export function AnnotationsLayer() {
|
|
|
1228
1652
|
// We do NOT stopPropagation — viewport-controller listens on the host
|
|
1229
1653
|
// ancestor and never claims a bare-left/no-space pointerdown anyway.
|
|
1230
1654
|
e.preventDefault();
|
|
1655
|
+
setGhost(null); // a draw is starting — the ghost placeholder is done
|
|
1231
1656
|
try {
|
|
1232
1657
|
(e.target as Element & { setPointerCapture?: (id: number) => void }).setPointerCapture?.(
|
|
1233
1658
|
e.pointerId
|
|
@@ -1236,6 +1661,8 @@ export function AnnotationsLayer() {
|
|
|
1236
1661
|
/* some browsers reject capture on synthetic events */
|
|
1237
1662
|
}
|
|
1238
1663
|
const [wx, wy] = screenToWorld(e.clientX, e.clientY);
|
|
1664
|
+
drawAnchorRef.current = { x: wx, y: wy };
|
|
1665
|
+
lastDrawPointRef.current = { x: wx, y: wy };
|
|
1239
1666
|
if (isErase) {
|
|
1240
1667
|
eraseAt(wx, wy);
|
|
1241
1668
|
return true;
|
|
@@ -1245,30 +1672,49 @@ export function AnnotationsLayer() {
|
|
|
1245
1672
|
const activeFill = supportsFill ? fill : null;
|
|
1246
1673
|
if (tool === 'pen') {
|
|
1247
1674
|
setDrawing({ id, tool: 'pen', color, width, points: [[wx, wy]] });
|
|
1248
|
-
} else if (tool === '
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
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
|
+
}
|
|
1272
1718
|
} else if (tool === 'arrow') {
|
|
1273
1719
|
setDrawing({
|
|
1274
1720
|
id,
|
|
@@ -1309,6 +1755,7 @@ export function AnnotationsLayer() {
|
|
|
1309
1755
|
},
|
|
1310
1756
|
[
|
|
1311
1757
|
tool,
|
|
1758
|
+
shapeKind,
|
|
1312
1759
|
color,
|
|
1313
1760
|
fill,
|
|
1314
1761
|
thickness,
|
|
@@ -1335,6 +1782,16 @@ export function AnnotationsLayer() {
|
|
|
1335
1782
|
eraseAt(wx, wy);
|
|
1336
1783
|
return;
|
|
1337
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 };
|
|
1338
1795
|
setDrawing((cur) => {
|
|
1339
1796
|
if (!cur) return cur;
|
|
1340
1797
|
if (cur.tool === 'pen') {
|
|
@@ -1342,24 +1799,14 @@ export function AnnotationsLayer() {
|
|
|
1342
1799
|
if (last && Math.hypot(wx - last[0], wy - last[1]) < 1) return cur;
|
|
1343
1800
|
return { ...cur, points: [...cur.points, [wx, wy] as WorldPoint] };
|
|
1344
1801
|
}
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
if (
|
|
1349
|
-
|
|
1350
|
-
const ry = Math.abs(wy - cur.cy);
|
|
1351
|
-
return { ...cur, rx, ry };
|
|
1352
|
-
}
|
|
1353
|
-
if (cur.tool === 'arrow') {
|
|
1354
|
-
return { ...cur, x2: wx, y2: wy };
|
|
1355
|
-
}
|
|
1356
|
-
if (cur.tool === 'sticky') {
|
|
1357
|
-
return { ...cur, w: wx - cur.x, h: wy - cur.y };
|
|
1358
|
-
}
|
|
1359
|
-
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);
|
|
1360
1807
|
});
|
|
1361
1808
|
},
|
|
1362
|
-
[isActive, isErase, visible, screenToWorld, eraseAt]
|
|
1809
|
+
[isActive, isErase, visible, screenToWorld, eraseAt, ghostCapable]
|
|
1363
1810
|
);
|
|
1364
1811
|
|
|
1365
1812
|
const endStroke = useCallback(() => {
|
|
@@ -1368,8 +1815,27 @@ export function AnnotationsLayer() {
|
|
|
1368
1815
|
const cur = drawingRef.current;
|
|
1369
1816
|
if (!cur) return;
|
|
1370
1817
|
let final: Stroke | null = cur;
|
|
1371
|
-
|
|
1372
|
-
|
|
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') {
|
|
1373
1839
|
const norm = normalizeSticky(cur);
|
|
1374
1840
|
// A bare tap (or a drag too small to be a usable card) drops a
|
|
1375
1841
|
// default-sized note at the tap point — FigJam parity.
|
|
@@ -1401,6 +1867,8 @@ export function AnnotationsLayer() {
|
|
|
1401
1867
|
const stickyOnThis = sticky.locked && sticky.tool === toolJustUsed;
|
|
1402
1868
|
if (!stickyOnThis) setTool('move');
|
|
1403
1869
|
}
|
|
1870
|
+
drawAnchorRef.current = null;
|
|
1871
|
+
lastDrawPointRef.current = null;
|
|
1404
1872
|
setDrawing(null);
|
|
1405
1873
|
}, [isActive, isErase, visible, commitStrokes, annotSel, setTool, sticky]);
|
|
1406
1874
|
|
|
@@ -1410,6 +1878,8 @@ export function AnnotationsLayer() {
|
|
|
1410
1878
|
// a no-op.
|
|
1411
1879
|
const cancelStroke = useCallback(() => {
|
|
1412
1880
|
if (!drawingRef.current) return;
|
|
1881
|
+
drawAnchorRef.current = null;
|
|
1882
|
+
lastDrawPointRef.current = null;
|
|
1413
1883
|
setDrawing(null);
|
|
1414
1884
|
}, []);
|
|
1415
1885
|
|
|
@@ -1420,11 +1890,44 @@ export function AnnotationsLayer() {
|
|
|
1420
1890
|
return () => document.removeEventListener('maude:cancel-stroke', onCancel);
|
|
1421
1891
|
}, [cancelStroke]);
|
|
1422
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
|
+
|
|
1423
1917
|
const renderStrokes = useMemo(
|
|
1424
1918
|
() => (drawing ? [...strokes, drawing] : strokes),
|
|
1425
1919
|
[strokes, drawing]
|
|
1426
1920
|
);
|
|
1427
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
|
+
|
|
1428
1931
|
const anchorsById = useMemo(() => {
|
|
1429
1932
|
const map = new Map<string, RectStroke | EllipseStroke>();
|
|
1430
1933
|
for (const s of strokes) {
|
|
@@ -1487,6 +1990,7 @@ export function AnnotationsLayer() {
|
|
|
1487
1990
|
(t === 'pen' ||
|
|
1488
1991
|
t === 'rect' ||
|
|
1489
1992
|
t === 'ellipse' ||
|
|
1993
|
+
t === 'polygon' ||
|
|
1490
1994
|
t === 'arrow' ||
|
|
1491
1995
|
t === 'text' ||
|
|
1492
1996
|
t === 'sticky')
|
|
@@ -1641,7 +2145,10 @@ export function AnnotationsLayer() {
|
|
|
1641
2145
|
|
|
1642
2146
|
document.addEventListener('pointerdown', onDown, true);
|
|
1643
2147
|
return () => document.removeEventListener('pointerdown', onDown, true);
|
|
1644
|
-
|
|
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]);
|
|
1645
2152
|
|
|
1646
2153
|
// Latest marquee + strokes refs for the doc-level pointerup callback
|
|
1647
2154
|
// (avoids re-binding the listener on every state tick).
|
|
@@ -1869,6 +2376,7 @@ export function AnnotationsLayer() {
|
|
|
1869
2376
|
beginStroke={beginStroke}
|
|
1870
2377
|
moveStroke={moveStroke}
|
|
1871
2378
|
endStroke={endStroke}
|
|
2379
|
+
onLeave={() => setGhost(null)}
|
|
1872
2380
|
/>
|
|
1873
2381
|
{visible ? (
|
|
1874
2382
|
<AnnotationsSvg
|
|
@@ -1878,6 +2386,7 @@ export function AnnotationsLayer() {
|
|
|
1878
2386
|
selectMode={tool === 'move'}
|
|
1879
2387
|
selectedStrokes={selectedStrokes}
|
|
1880
2388
|
marquee={marquee}
|
|
2389
|
+
ghost={ghostPreview}
|
|
1881
2390
|
editingTarget={editingTarget}
|
|
1882
2391
|
onCommitEdit={commitEditing}
|
|
1883
2392
|
onCancelEdit={cancelEditing}
|
|
@@ -1919,6 +2428,7 @@ function AnnotationsInput({
|
|
|
1919
2428
|
beginStroke,
|
|
1920
2429
|
moveStroke,
|
|
1921
2430
|
endStroke,
|
|
2431
|
+
onLeave,
|
|
1922
2432
|
}: {
|
|
1923
2433
|
isActive: boolean;
|
|
1924
2434
|
visible: boolean;
|
|
@@ -1928,6 +2438,8 @@ function AnnotationsInput({
|
|
|
1928
2438
|
beginStroke: (e: ReactPointerEvent<HTMLDivElement>, spaceHeld: boolean) => boolean;
|
|
1929
2439
|
moveStroke: (e: ReactPointerEvent<HTMLDivElement>) => void;
|
|
1930
2440
|
endStroke: () => void;
|
|
2441
|
+
/** Phase 24 — clear the ghost placeholder when the pointer leaves the canvas. */
|
|
2442
|
+
onLeave: () => void;
|
|
1931
2443
|
}) {
|
|
1932
2444
|
const worldRef = useWorldRefContext();
|
|
1933
2445
|
const host = worldRef?.current?.parentElement ?? null;
|
|
@@ -1972,6 +2484,7 @@ function AnnotationsInput({
|
|
|
1972
2484
|
onPointerMove={moveStroke}
|
|
1973
2485
|
onPointerUp={endStroke}
|
|
1974
2486
|
onPointerCancel={endStroke}
|
|
2487
|
+
onPointerLeave={onLeave}
|
|
1975
2488
|
/>,
|
|
1976
2489
|
host
|
|
1977
2490
|
);
|
|
@@ -1990,6 +2503,7 @@ function AnnotationsSvg({
|
|
|
1990
2503
|
selectMode,
|
|
1991
2504
|
selectedStrokes,
|
|
1992
2505
|
marquee,
|
|
2506
|
+
ghost,
|
|
1993
2507
|
editingTarget,
|
|
1994
2508
|
onCommitEdit,
|
|
1995
2509
|
onCancelEdit,
|
|
@@ -2000,6 +2514,7 @@ function AnnotationsSvg({
|
|
|
2000
2514
|
selectMode: boolean;
|
|
2001
2515
|
selectedStrokes: readonly Stroke[];
|
|
2002
2516
|
marquee: { ax: number; ay: number; bx: number; by: number } | null;
|
|
2517
|
+
ghost: GhostDescriptor | null;
|
|
2003
2518
|
editingTarget: EditingTarget;
|
|
2004
2519
|
onCommitEdit: (text: string) => void;
|
|
2005
2520
|
onCancelEdit: () => void;
|
|
@@ -2057,6 +2572,7 @@ function AnnotationsSvg({
|
|
|
2057
2572
|
vectorEffect="non-scaling-stroke"
|
|
2058
2573
|
/>
|
|
2059
2574
|
) : null}
|
|
2575
|
+
{ghost ? <GhostPreview ghost={ghost} /> : null}
|
|
2060
2576
|
{editingTarget?.kind === 'anchored' ? (
|
|
2061
2577
|
<TextEditor
|
|
2062
2578
|
anchorId={editingTarget.anchorId}
|
|
@@ -2080,6 +2596,9 @@ function AnnotationsSvg({
|
|
|
2080
2596
|
fontSize={editingTarget.text.fontSize}
|
|
2081
2597
|
color={editingTarget.text.color}
|
|
2082
2598
|
initialText={editingTarget.text.text}
|
|
2599
|
+
bold={editingTarget.text.bold}
|
|
2600
|
+
strike={editingTarget.text.strike}
|
|
2601
|
+
align={editingTarget.text.align ?? 'left'}
|
|
2083
2602
|
onCommit={onCommitEdit}
|
|
2084
2603
|
onCancel={onCancelEdit}
|
|
2085
2604
|
/>
|
|
@@ -2153,6 +2672,9 @@ function TextEditor({
|
|
|
2153
2672
|
const bbox = strokeBBox(host);
|
|
2154
2673
|
if (!bbox) return null;
|
|
2155
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';
|
|
2156
2678
|
return (
|
|
2157
2679
|
<foreignObject x={bbox.x} y={bbox.y} width={Math.max(20, bbox.w)} height={Math.max(20, bbox.h)}>
|
|
2158
2680
|
<div
|
|
@@ -2166,13 +2688,16 @@ function TextEditor({
|
|
|
2166
2688
|
height: '100%',
|
|
2167
2689
|
display: 'flex',
|
|
2168
2690
|
alignItems: 'center',
|
|
2169
|
-
justifyContent:
|
|
2691
|
+
justifyContent:
|
|
2692
|
+
align === 'left' ? 'flex-start' : align === 'right' ? 'flex-end' : 'center',
|
|
2170
2693
|
padding: '0 8px',
|
|
2171
2694
|
boxSizing: 'border-box',
|
|
2172
|
-
textAlign:
|
|
2695
|
+
textAlign: align,
|
|
2173
2696
|
color: existing?.color ?? '#1a1a1a',
|
|
2174
2697
|
fontSize: `${fontSize}px`,
|
|
2175
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,
|
|
2176
2701
|
lineHeight: 1.25,
|
|
2177
2702
|
outline: 'none',
|
|
2178
2703
|
background: 'transparent',
|
|
@@ -2242,7 +2767,7 @@ function StickyEditor({
|
|
|
2242
2767
|
contentEditable
|
|
2243
2768
|
suppressContentEditableWarning
|
|
2244
2769
|
aria-label="Edit sticky note text"
|
|
2245
|
-
style={{
|
|
2770
|
+
style={{ ...stickyBodyStyle(sticky), outline: 'none', cursor: 'text' }}
|
|
2246
2771
|
onBlur={() => onCommit(ref.current?.innerText ?? '')}
|
|
2247
2772
|
onKeyDown={(e) => {
|
|
2248
2773
|
if (e.key === 'Escape') {
|
|
@@ -2265,6 +2790,9 @@ function StandaloneTextEditor({
|
|
|
2265
2790
|
fontSize,
|
|
2266
2791
|
color,
|
|
2267
2792
|
initialText,
|
|
2793
|
+
bold,
|
|
2794
|
+
strike,
|
|
2795
|
+
align,
|
|
2268
2796
|
onCommit,
|
|
2269
2797
|
onCancel,
|
|
2270
2798
|
}: {
|
|
@@ -2273,6 +2801,9 @@ function StandaloneTextEditor({
|
|
|
2273
2801
|
fontSize: number;
|
|
2274
2802
|
color: string;
|
|
2275
2803
|
initialText: string;
|
|
2804
|
+
bold?: boolean;
|
|
2805
|
+
strike?: boolean;
|
|
2806
|
+
align?: TextAlign;
|
|
2276
2807
|
onCommit: (text: string) => void;
|
|
2277
2808
|
onCancel: () => void;
|
|
2278
2809
|
}) {
|
|
@@ -2322,6 +2853,9 @@ function StandaloneTextEditor({
|
|
|
2322
2853
|
color,
|
|
2323
2854
|
fontSize: `${fontSize}px`,
|
|
2324
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',
|
|
2325
2859
|
lineHeight: 1.2,
|
|
2326
2860
|
outline: 'none',
|
|
2327
2861
|
background: 'transparent',
|
|
@@ -2458,6 +2992,86 @@ function AnnotGroupBbox({
|
|
|
2458
2992
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
2459
2993
|
// Stroke renderer
|
|
2460
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
|
+
|
|
2461
3075
|
function StrokeNode({
|
|
2462
3076
|
stroke,
|
|
2463
3077
|
anchorsById,
|
|
@@ -2478,32 +3092,46 @@ function StrokeNode({
|
|
|
2478
3092
|
if (stroke.tool === 'text') {
|
|
2479
3093
|
// Anchored text renders centered in its host; standalone (Phase 21) renders
|
|
2480
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;
|
|
2481
3101
|
if (stroke.anchorId != null && stroke.anchorId !== '') {
|
|
2482
3102
|
const host = anchorsById.get(stroke.anchorId);
|
|
2483
3103
|
const bbox = host ? strokeBBox(host) : null;
|
|
2484
3104
|
if (!bbox) return null;
|
|
2485
|
-
const cx = bbox.x + bbox.w / 2;
|
|
2486
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;
|
|
2487
3115
|
return (
|
|
2488
3116
|
<text
|
|
2489
3117
|
data-id={stroke.id}
|
|
2490
3118
|
data-tool="text"
|
|
2491
3119
|
data-anchor-id={stroke.anchorId}
|
|
2492
3120
|
data-font-size={stroke.fontSize}
|
|
2493
|
-
x={
|
|
3121
|
+
x={tx}
|
|
2494
3122
|
y={cy}
|
|
2495
3123
|
fill={stroke.color}
|
|
2496
3124
|
fontSize={stroke.fontSize}
|
|
2497
|
-
textAnchor=
|
|
3125
|
+
textAnchor={anchor}
|
|
2498
3126
|
dominantBaseline="middle"
|
|
2499
|
-
style={
|
|
2500
|
-
fontFamily: 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
|
|
2501
|
-
}}
|
|
3127
|
+
style={textStyle}
|
|
2502
3128
|
>
|
|
2503
3129
|
{stroke.text}
|
|
2504
3130
|
</text>
|
|
2505
3131
|
);
|
|
2506
3132
|
}
|
|
3133
|
+
const align = stroke.align ?? 'left';
|
|
3134
|
+
const anchor = align === 'left' ? 'start' : align === 'right' ? 'end' : 'middle';
|
|
2507
3135
|
return (
|
|
2508
3136
|
<text
|
|
2509
3137
|
data-id={stroke.id}
|
|
@@ -2513,10 +3141,10 @@ function StrokeNode({
|
|
|
2513
3141
|
y={stroke.y ?? 0}
|
|
2514
3142
|
fill={stroke.color}
|
|
2515
3143
|
fontSize={stroke.fontSize}
|
|
2516
|
-
textAnchor=
|
|
3144
|
+
textAnchor={anchor}
|
|
2517
3145
|
dominantBaseline="hanging"
|
|
2518
3146
|
pointerEvents={interactive ? 'visiblePainted' : 'none'}
|
|
2519
|
-
style={
|
|
3147
|
+
style={textStyle}
|
|
2520
3148
|
>
|
|
2521
3149
|
{stroke.text}
|
|
2522
3150
|
</text>
|
|
@@ -2550,7 +3178,7 @@ function StrokeNode({
|
|
|
2550
3178
|
<div
|
|
2551
3179
|
xmlns="http://www.w3.org/1999/xhtml"
|
|
2552
3180
|
className="dc-sticky-body"
|
|
2553
|
-
style={
|
|
3181
|
+
style={stickyBodyStyle(stroke)}
|
|
2554
3182
|
>
|
|
2555
3183
|
{stroke.text}
|
|
2556
3184
|
</div>
|
|
@@ -2602,35 +3230,54 @@ function StrokeNode({
|
|
|
2602
3230
|
/>
|
|
2603
3231
|
);
|
|
2604
3232
|
}
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
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.
|
|
2609
3249
|
return (
|
|
2610
3250
|
<g {...common} fill="none" pointerEvents={hitMode}>
|
|
2611
|
-
|
|
2612
|
-
x1={stroke.x1}
|
|
2613
|
-
y1={stroke.y1}
|
|
2614
|
-
x2={stroke.x2}
|
|
2615
|
-
y2={stroke.y2}
|
|
2616
|
-
strokeDasharray={dashed ? '6 4' : undefined}
|
|
2617
|
-
/>
|
|
2618
|
-
{startHead === 'triangle' ? (
|
|
2619
|
-
<polyline
|
|
2620
|
-
points={arrowHeadPoints(stroke.x2, stroke.y2, stroke.x1, stroke.y1, stroke.width)}
|
|
2621
|
-
fill={stroke.color}
|
|
2622
|
-
/>
|
|
2623
|
-
) : null}
|
|
2624
|
-
{endHead === 'triangle' ? (
|
|
2625
|
-
<polyline
|
|
2626
|
-
points={arrowHeadPoints(stroke.x1, stroke.y1, stroke.x2, stroke.y2, stroke.width)}
|
|
2627
|
-
fill={stroke.color}
|
|
2628
|
-
/>
|
|
2629
|
-
) : null}
|
|
3251
|
+
{arrowPrimitives(stroke).map((p, i) => renderArrowPrimitive(p, i))}
|
|
2630
3252
|
</g>
|
|
2631
3253
|
);
|
|
2632
3254
|
}
|
|
2633
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
|
+
|
|
2634
3281
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
2635
3282
|
// Chrome — color swatches + (optional fill picker) + (optional thickness chip)
|
|
2636
3283
|
// + presentation toggle + help button.
|