@1agh/maude 0.24.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.
Files changed (50) hide show
  1. package/package.json +8 -8
  2. package/plugins/design/dependencies.json +30 -2
  3. package/plugins/design/dev-server/annotations-context-toolbar.tsx +481 -78
  4. package/plugins/design/dev-server/annotations-layer.tsx +817 -170
  5. package/plugins/design/dev-server/api.ts +15 -1
  6. package/plugins/design/dev-server/bin/_enumerate-artboards-playwright.mjs +2 -2
  7. package/plugins/design/dev-server/bin/_html-playwright.mjs +2 -2
  8. package/plugins/design/dev-server/bin/_pdf-playwright.mjs +25 -8
  9. package/plugins/design/dev-server/bin/_png-playwright.mjs +20 -20
  10. package/plugins/design/dev-server/bin/_pptx-playwright.mjs +2 -2
  11. package/plugins/design/dev-server/bin/_pw-launch.mjs +61 -0
  12. package/plugins/design/dev-server/bin/_screenshot-playwright.mjs +2 -2
  13. package/plugins/design/dev-server/bin/_svg-playwright.mjs +125 -19
  14. package/plugins/design/dev-server/bin/smoke.sh +114 -12
  15. package/plugins/design/dev-server/canvas-arrowheads.ts +220 -0
  16. package/plugins/design/dev-server/canvas-comment-mount.tsx +8 -24
  17. package/plugins/design/dev-server/canvas-cursors.ts +130 -82
  18. package/plugins/design/dev-server/canvas-icons.tsx +169 -0
  19. package/plugins/design/dev-server/canvas-shell.tsx +113 -89
  20. package/plugins/design/dev-server/client/app.jsx +1084 -417
  21. package/plugins/design/dev-server/dist/client.bundle.js +242 -20
  22. package/plugins/design/dev-server/dist/comment-mount.js +40 -62
  23. package/plugins/design/dev-server/export-dialog.tsx +189 -1
  24. package/plugins/design/dev-server/exporters/html.ts +4 -1
  25. package/plugins/design/dev-server/exporters/index.ts +4 -1
  26. package/plugins/design/dev-server/exporters/pdf.ts +7 -1
  27. package/plugins/design/dev-server/exporters/png.ts +21 -2
  28. package/plugins/design/dev-server/exporters/pptx.ts +330 -201
  29. package/plugins/design/dev-server/exporters/scope.ts +107 -11
  30. package/plugins/design/dev-server/exporters/svg.ts +4 -1
  31. package/plugins/design/dev-server/http.ts +40 -9
  32. package/plugins/design/dev-server/input-router.tsx +9 -2
  33. package/plugins/design/dev-server/test/annotations-draw-modifiers.test.ts +206 -0
  34. package/plugins/design/dev-server/test/annotations-layer.test.ts +83 -3
  35. package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +243 -0
  36. package/plugins/design/dev-server/test/canvas-cursors.test.ts +95 -6
  37. package/plugins/design/dev-server/test/canvas-route.test.ts +4 -1
  38. package/plugins/design/dev-server/test/exporters/png.test.ts +24 -1
  39. package/plugins/design/dev-server/test/exporters/pptx-deck.test.ts +112 -0
  40. package/plugins/design/dev-server/test/exporters/pw-launch.test.ts +49 -0
  41. package/plugins/design/dev-server/test/exporters/scope.test.ts +0 -0
  42. package/plugins/design/dev-server/test/fixtures/phase-21-annotations.svg +1 -0
  43. package/plugins/design/dev-server/test/input-router.test.ts +11 -4
  44. package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +32 -0
  45. package/plugins/design/dev-server/test/use-annotation-resize.test.ts +200 -0
  46. package/plugins/design/dev-server/test/use-tool-mode.test.tsx +9 -11
  47. package/plugins/design/dev-server/tool-palette.tsx +140 -11
  48. package/plugins/design/dev-server/use-annotation-resize.tsx +208 -61
  49. package/plugins/design/dev-server/use-tool-mode.tsx +55 -9
  50. package/plugins/design/templates/_shell.html +36 -9
@@ -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
- /** Phase 21 — head on the (x1,y1) start. Absent = 'none' (back-compat). */
95
- startHead?: 'none' | 'triangle';
96
- /** Phase 21 — head on the (x2,y2) end. Absent = 'triangle' (back-compat). */
97
- endHead?: 'none' | 'triangle';
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
- const DEFAULT_COLOR: PaletteColor = STROKE_PALETTE[0];
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 21 — sticky-note paper tints. FigJam-style desaturated tints, wholly
194
- // separate from the stroke ink PALETTE and the translucent FILL_PALETTE so
195
- // stickies read as "paper", not "ink". Slot 0 (yellow) is the default.
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
- '#ffe27a', // yellow (default — warm FigJam paper)
198
- '#ffc1d4', // pink
199
- '#a9d6ff', // blue
200
- '#b3e6b8', // green
201
- '#d8c2fb', // purple
202
- '#f2ece1', // paper-white
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 = 160;
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
- export function arrowHeadPoints(
231
- x1: number,
232
- y1: number,
233
- x2: number,
234
- y2: number,
235
- width: number
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
- const angle = Math.atan2(y2 - y1, x2 - x1);
238
- const len = 12 + width * 2;
239
- const wing = Math.PI / 7;
240
- const ax = x2 - Math.cos(angle - wing) * len;
241
- const ay = y2 - Math.sin(angle - wing) * len;
242
- const bx = x2 - Math.cos(angle + wing) * len;
243
- const by = y2 - Math.sin(angle + wing) * len;
244
- return `${ax},${ay} ${x2},${y2} ${bx},${by}`;
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="middle" dominant-baseline="middle">${esc(s.text)}</text>`;
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(s.color)}" text-anchor="start" dominant-baseline="hanging">${esc(s.text)}</text>`;
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
- // arrow Phase 21 adds optional start/end heads + dash. Defaults
306
- // (startHead 'none', endHead 'triangle', solid) emit the byte-identical
307
- // Phase 5.1 form: data-* attrs + extra polyline + dasharray appear only for
308
- // non-default values.
309
- const endHead = s.endHead ?? 'triangle';
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 dash = dashed ? ' stroke-dasharray="6 4"' : '';
317
- const heads =
318
- (startHead === 'triangle'
319
- ? `<polyline points="${arrowHeadPoints(s.x2, s.y2, s.x1, s.y1, s.width)}" fill="${esc(
320
- s.color
321
- )}"/>`
322
- : '') +
323
- (endHead === 'triangle'
324
- ? `<polyline points="${arrowHeadPoints(s.x1, s.y1, s.x2, s.y2, s.width)}" fill="${esc(
325
- s.color
326
- )}"/>`
327
- : '');
328
- return `<g ${common} fill="none"${dataAttrs}><line x1="${s.x1}" y1="${s.y1}" x2="${s.x2}" y2="${s.y2}"${dash}/>${heads}</g>`;
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
- out.push({
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
- const line = el.querySelector('line');
427
- if (line) {
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: Number.parseFloat(line.getAttribute('x1') || '0'),
434
- y1: Number.parseFloat(line.getAttribute('y1') || '0'),
435
- x2: Number.parseFloat(line.getAttribute('x2') || '0'),
436
- y2: Number.parseFloat(line.getAttribute('y2') || '0'),
705
+ x1: ends.x1,
706
+ y1: ends.y1,
707
+ x2: ends.x2,
708
+ y2: ends.y2,
437
709
  };
438
- // Phase 21 heads + dash. Serializer only writes a data-* attribute
439
- // for a NON-default value, so a legacy arrow carries none of these
440
- // and stays { startHead/endHead/dashed: undefined } → defaults on
441
- // re-serialize (byte-identical, Task 10 canary).
442
- if (el.getAttribute('data-start-head') === 'triangle') arrow.startHead = 'triangle';
443
- if (el.getAttribute('data-end-head') === 'none') arrow.endHead = 'none';
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
- out.push({
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
- out.push({
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 21 — sticky-note body. Word-wrapped multi-line text inside the card's
838
- foreignObject. The editor textarea (.dc-sticky-editor) mirrors the same box
839
- metrics so the readedit swap doesn't shift the text. */
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: center;
847
- justify-content: center;
848
- text-align: center;
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 — rect + ellipse expose stroke weight too (FigJam ships thickness on
983
- // every shape). The annotation toolbar reads supportsThickness to decide
984
- // whether to render the Thin / Thick chips.
985
- const supportsThickness =
986
- tool === 'pen' || tool === 'arrow' || tool === 'rect' || tool === 'ellipse';
987
- const supportsFill = tool === 'rect' || tool === 'ellipse';
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 === 'rect') {
1249
- setDrawing({
1250
- id,
1251
- tool: 'rect',
1252
- color,
1253
- width,
1254
- x: wx,
1255
- y: wy,
1256
- w: 0,
1257
- h: 0,
1258
- fill: activeFill,
1259
- });
1260
- } else if (tool === 'ellipse') {
1261
- setDrawing({
1262
- id,
1263
- tool: 'ellipse',
1264
- color,
1265
- width,
1266
- cx: wx,
1267
- cy: wy,
1268
- rx: 0,
1269
- ry: 0,
1270
- fill: activeFill,
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
- if (cur.tool === 'rect') {
1346
- return { ...cur, w: wx - cur.x, h: wy - cur.y };
1347
- }
1348
- if (cur.tool === 'ellipse') {
1349
- const rx = Math.abs(wx - cur.cx);
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
- if (cur.tool === 'rect') final = normalizeRect(cur);
1372
- else if (cur.tool === 'sticky') {
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
- }, [tool, annotSel, elementSel, screenToWorld, strokesStore]);
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: 'center',
2691
+ justifyContent:
2692
+ align === 'left' ? 'flex-start' : align === 'right' ? 'flex-end' : 'center',
2170
2693
  padding: '0 8px',
2171
2694
  boxSizing: 'border-box',
2172
- textAlign: 'center',
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={{ fontSize: `${sticky.fontSize}px`, outline: 'none', cursor: 'text' }}
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={cx}
3121
+ x={tx}
2494
3122
  y={cy}
2495
3123
  fill={stroke.color}
2496
3124
  fontSize={stroke.fontSize}
2497
- textAnchor="middle"
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="start"
3144
+ textAnchor={anchor}
2517
3145
  dominantBaseline="hanging"
2518
3146
  pointerEvents={interactive ? 'visiblePainted' : 'none'}
2519
- style={{ fontFamily: 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)' }}
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={{ fontSize: `${stroke.fontSize}px` }}
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
- // arrow Phase 21 renders per-side heads + optional dash.
2606
- const endHead = stroke.endHead ?? 'triangle';
2607
- const startHead = stroke.startHead ?? 'none';
2608
- const dashed = stroke.dashed ?? false;
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
- <line
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.