@1agh/maude 0.29.0 → 0.31.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 (162) hide show
  1. package/README.md +6 -6
  2. package/apps/studio/acp/bridge.ts +285 -0
  3. package/apps/studio/acp/env.ts +48 -0
  4. package/apps/studio/acp/index.ts +132 -0
  5. package/apps/studio/acp/probe.ts +112 -0
  6. package/apps/studio/acp/transcript.ts +149 -0
  7. package/apps/studio/ai-banner.tsx +0 -1
  8. package/apps/studio/annotations-align.ts +149 -0
  9. package/apps/studio/annotations-bindings.ts +197 -0
  10. package/apps/studio/annotations-context-toolbar.tsx +436 -186
  11. package/apps/studio/annotations-groups.ts +270 -0
  12. package/apps/studio/annotations-layer.tsx +1714 -1661
  13. package/apps/studio/annotations-model.ts +2077 -0
  14. package/apps/studio/annotations-snap.ts +125 -0
  15. package/apps/studio/api.ts +433 -182
  16. package/apps/studio/bin/_png-playwright.mjs +1 -1
  17. package/apps/studio/bin/annotate.mjs +732 -0
  18. package/apps/studio/bin/annotate.sh +17 -0
  19. package/apps/studio/bin/chat-open.sh +44 -0
  20. package/apps/studio/bin/read-annotations.mjs +152 -17
  21. package/apps/studio/build.ts +1 -1
  22. package/apps/studio/canvas-arrowheads.ts +78 -9
  23. package/apps/studio/canvas-cursors.ts +2 -0
  24. package/apps/studio/canvas-edit.ts +257 -7
  25. package/apps/studio/canvas-icons.tsx +105 -0
  26. package/apps/studio/canvas-lib.tsx +112 -19
  27. package/apps/studio/canvas-list-watch.ts +177 -0
  28. package/apps/studio/canvas-shell.tsx +326 -9
  29. package/apps/studio/client/app.jsx +3579 -250
  30. package/apps/studio/client/canvas-url.js +5 -0
  31. package/apps/studio/client/github.js +99 -0
  32. package/apps/studio/client/index.html +1 -1
  33. package/apps/studio/client/panels/ChatPanel.jsx +770 -0
  34. package/apps/studio/client/panels/CollabModelInfographic.jsx +71 -0
  35. package/apps/studio/client/panels/CreateProject.jsx +334 -0
  36. package/apps/studio/client/panels/DiffView.jsx +590 -0
  37. package/apps/studio/client/panels/GitPanel.jsx +767 -0
  38. package/apps/studio/client/panels/IdentityBar.jsx +294 -0
  39. package/apps/studio/client/panels/OnboardingWizard.jsx +563 -0
  40. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +349 -0
  41. package/apps/studio/client/panels/acp-runtime.js +286 -0
  42. package/apps/studio/client/panels/chat-markdown.jsx +138 -0
  43. package/apps/studio/client/panels/git-grouping.js +86 -0
  44. package/apps/studio/client/styles/0-reset.css +4 -0
  45. package/apps/studio/client/styles/3-shell-maude.css +1326 -14
  46. package/apps/studio/client/styles/4-components.css +39 -0
  47. package/apps/studio/client/styles/5-maude-overrides.css +41 -2
  48. package/apps/studio/client/styles/6-acp-chat.css +771 -0
  49. package/apps/studio/client/styles/_index.css +2 -0
  50. package/apps/studio/client/tour/collab-tour.js +61 -0
  51. package/apps/studio/client/tour/overlay.jsx +116 -18
  52. package/apps/studio/client/tour/usage-tour.js +23 -10
  53. package/apps/studio/client/whats-new.jsx +25 -10
  54. package/apps/studio/collab/registry.ts +13 -0
  55. package/apps/studio/collab/room.ts +36 -0
  56. package/apps/studio/commands/annotation-strokes-command.ts +1 -1
  57. package/apps/studio/commands/move-artboards-command.ts +1 -1
  58. package/apps/studio/comments-overlay.tsx +7 -5
  59. package/apps/studio/context-menu.tsx +4 -3
  60. package/apps/studio/contextual-toolbar.tsx +14 -0
  61. package/apps/studio/cursors-overlay.tsx +37 -21
  62. package/apps/studio/dist/client.bundle.js +31795 -1829
  63. package/apps/studio/dist/comment-mount.js +97 -9
  64. package/apps/studio/dist/styles.css +7938 -1599
  65. package/apps/studio/dom-selection.ts +115 -0
  66. package/apps/studio/export-dialog.tsx +3 -3
  67. package/apps/studio/git/endpoints.ts +338 -0
  68. package/apps/studio/git/service.ts +1334 -0
  69. package/apps/studio/git/watch.ts +97 -0
  70. package/apps/studio/github/endpoints.ts +358 -0
  71. package/apps/studio/github/service.ts +231 -0
  72. package/apps/studio/github/token.ts +53 -0
  73. package/apps/studio/history.ts +20 -3
  74. package/apps/studio/hmr-broadcast.ts +9 -2
  75. package/apps/studio/http.ts +508 -6
  76. package/apps/studio/input-router.tsx +7 -0
  77. package/apps/studio/inspect.ts +37 -8
  78. package/apps/studio/participants-chrome.tsx +70 -16
  79. package/apps/studio/paths.ts +12 -0
  80. package/apps/studio/scaffold-design.ts +57 -0
  81. package/apps/studio/server.ts +70 -4
  82. package/apps/studio/sync/agent.ts +311 -57
  83. package/apps/studio/sync/codec.ts +69 -0
  84. package/apps/studio/sync/cold-start.ts +198 -0
  85. package/apps/studio/sync/connection-state.ts +58 -2
  86. package/apps/studio/sync/hub-link.ts +137 -0
  87. package/apps/studio/sync/index.ts +563 -221
  88. package/apps/studio/sync/journal.ts +190 -0
  89. package/apps/studio/sync/migrate-seed.ts +202 -44
  90. package/apps/studio/sync/projection.ts +22 -3
  91. package/apps/studio/sync/status.ts +15 -4
  92. package/apps/studio/test/acp-bridge.test.ts +127 -0
  93. package/apps/studio/test/acp-env.test.ts +65 -0
  94. package/apps/studio/test/acp-origin-gate.test.ts +78 -0
  95. package/apps/studio/test/acp-transcript.test.ts +112 -0
  96. package/apps/studio/test/activity.test.ts +1 -7
  97. package/apps/studio/test/annotate-write.test.ts +184 -0
  98. package/apps/studio/test/annotations-align.test.ts +88 -0
  99. package/apps/studio/test/annotations-bindings.test.ts +124 -0
  100. package/apps/studio/test/annotations-groups.test.ts +231 -0
  101. package/apps/studio/test/annotations-snap.test.ts +79 -0
  102. package/apps/studio/test/canvas-create-api.test.ts +72 -0
  103. package/apps/studio/test/canvas-edit.test.ts +181 -1
  104. package/apps/studio/test/canvas-list-watch.test.ts +322 -0
  105. package/apps/studio/test/canvas-meta-api.test.ts +161 -27
  106. package/apps/studio/test/canvas-origin-gate.test.ts +43 -0
  107. package/apps/studio/test/chat-markdown.test.tsx +58 -0
  108. package/apps/studio/test/collab-bridge.test.ts +0 -2
  109. package/apps/studio/test/collab-room.test.ts +2 -7
  110. package/apps/studio/test/collab-session-survival.test.tsx +176 -0
  111. package/apps/studio/test/csp-canvas-shell.test.ts +5 -0
  112. package/apps/studio/test/csrf-write-guard.test.ts +78 -0
  113. package/apps/studio/test/editing-presence.test.ts +103 -0
  114. package/apps/studio/test/figjam-v3-model.test.ts +342 -0
  115. package/apps/studio/test/fixtures/figjam-v3-groups-bindings.svg +1 -0
  116. package/apps/studio/test/fixtures/mock-acp-agent.mjs +45 -0
  117. package/apps/studio/test/git-api.test.ts +0 -0
  118. package/apps/studio/test/git-branches.test.ts +106 -0
  119. package/apps/studio/test/git-grouping.test.ts +106 -0
  120. package/apps/studio/test/git-watch.test.ts +97 -0
  121. package/apps/studio/test/github-api.test.ts +465 -0
  122. package/apps/studio/test/hub-link.test.ts +69 -0
  123. package/apps/studio/test/participants-chrome.test.ts +36 -1
  124. package/apps/studio/test/shared-doc-migrate.test.ts +224 -14
  125. package/apps/studio/test/sync-agent.test.ts +272 -2
  126. package/apps/studio/test/sync-codec.test.ts +65 -0
  127. package/apps/studio/test/sync-cold-seed-dedup.test.ts +187 -0
  128. package/apps/studio/test/sync-cold-start.test.ts +304 -0
  129. package/apps/studio/test/sync-connection-state.test.ts +68 -0
  130. package/apps/studio/test/sync-hardening.test.ts +0 -8
  131. package/apps/studio/test/sync-hubs-config.test.ts +0 -1
  132. package/apps/studio/test/sync-incident-replay.test.ts +211 -0
  133. package/apps/studio/test/sync-journal.test.ts +176 -0
  134. package/apps/studio/test/sync-runtime.test.ts +360 -5
  135. package/apps/studio/test/sync-status.test.ts +66 -0
  136. package/apps/studio/test/tour-overlay.test.tsx +18 -0
  137. package/apps/studio/test/use-annotation-resize.test.ts +159 -0
  138. package/apps/studio/test/use-artboard-drag.test.ts +0 -1
  139. package/apps/studio/test/use-collab.test.ts +0 -0
  140. package/apps/studio/test/use-tool-mode.test.tsx +5 -2
  141. package/apps/studio/tool-palette.tsx +30 -11
  142. package/apps/studio/undo-hud.tsx +0 -1
  143. package/apps/studio/use-agent-presence.tsx +4 -2
  144. package/apps/studio/use-annotation-resize.tsx +484 -54
  145. package/apps/studio/use-annotation-selection.tsx +16 -4
  146. package/apps/studio/use-canvas-activity.tsx +2 -2
  147. package/apps/studio/use-chrome-visibility.tsx +66 -0
  148. package/apps/studio/use-collab.tsx +434 -202
  149. package/apps/studio/use-selection-set.tsx +9 -0
  150. package/apps/studio/use-tool-mode.tsx +5 -1
  151. package/apps/studio/whats-new.json +208 -10
  152. package/apps/studio/whats-new.schema.json +18 -2
  153. package/apps/studio/ws.ts +44 -1
  154. package/cli/commands/design-link.test.mjs +84 -0
  155. package/cli/commands/design.mjs +3 -1
  156. package/cli/commands/design.test.mjs +5 -1
  157. package/cli/lib/design-link.mjs +51 -1
  158. package/cli/lib/gitignore-block.mjs +16 -3
  159. package/cli/lib/gitignore-block.test.mjs +13 -1
  160. package/package.json +11 -9
  161. package/plugins/design/dependencies.json +17 -0
  162. package/plugins/design/templates/_shell.html +58 -12
@@ -21,15 +21,18 @@
21
21
  */
22
22
 
23
23
  import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
24
-
24
+ import type { AlignEdge } from './annotations-align.ts';
25
25
  import {
26
26
  type ArrowHead,
27
27
  type ArrowLineType,
28
+ type ConvertibleShapeKind,
29
+ convertShapeKind,
28
30
  FILL_PALETTE,
29
31
  type ListType,
30
32
  STICKY_PALETTE,
31
33
  STROKE_PALETTE,
32
34
  type Stroke,
35
+ shapeKindOf,
33
36
  type TextAlign,
34
37
  useStrokesStore,
35
38
  } from './annotations-layer.tsx';
@@ -45,10 +48,10 @@ import {
45
48
  IconArrowNone,
46
49
  IconBold,
47
50
  IconChevronDown,
48
- IconCornerPill,
49
- IconCornerSoft,
50
- IconCornerSquare,
51
51
  IconDash,
52
+ IconDistributeH,
53
+ IconDistributeV,
54
+ IconGroup,
52
55
  IconItalic,
53
56
  IconLineCurved,
54
57
  IconLineElbow,
@@ -58,9 +61,17 @@ import {
58
61
  IconLink,
59
62
  IconListBullet,
60
63
  IconListOrdered,
64
+ IconObjAlignBottom,
65
+ IconObjAlignHCenter,
66
+ IconObjAlignLeft,
67
+ IconObjAlignRight,
68
+ IconObjAlignTop,
69
+ IconObjAlignVCenter,
61
70
  IconStrike,
62
71
  IconTrash,
63
72
  IconUnderline,
73
+ IconUngroup,
74
+ SHAPE_KIND_ICONS,
64
75
  } from './canvas-icons.tsx';
65
76
  import { useAnnotationSelectionOptional } from './use-annotation-selection.tsx';
66
77
  import { isHttpUrl, linkDomain } from './use-canvas-media-drop.tsx';
@@ -104,6 +115,28 @@ const ALIGN_OPTIONS: ReadonlyArray<{ value: TextAlign; label: string }> = [
104
115
  { value: 'center', label: 'Align center' },
105
116
  { value: 'right', label: 'Align right' },
106
117
  ];
118
+ // FigJam v3 — object align + distribute actions for the multi-select cluster.
119
+ type AlignAction = AlignEdge | 'dist-h' | 'dist-v';
120
+ const ALIGN_ACTION_OPTIONS: ReadonlyArray<{ value: AlignAction; label: string }> = [
121
+ { value: 'left', label: 'Align left' },
122
+ { value: 'h-center', label: 'Align horizontal centers' },
123
+ { value: 'right', label: 'Align right' },
124
+ { value: 'top', label: 'Align top' },
125
+ { value: 'v-center', label: 'Align vertical centers' },
126
+ { value: 'bottom', label: 'Align bottom' },
127
+ { value: 'dist-h', label: 'Distribute horizontal spacing' },
128
+ { value: 'dist-v', label: 'Distribute vertical spacing' },
129
+ ];
130
+ const ALIGN_ACTION_ICON: Record<string, (p: { size?: number }) => ReactNode> = {
131
+ left: IconObjAlignLeft,
132
+ 'h-center': IconObjAlignHCenter,
133
+ right: IconObjAlignRight,
134
+ top: IconObjAlignTop,
135
+ 'v-center': IconObjAlignVCenter,
136
+ bottom: IconObjAlignBottom,
137
+ 'dist-h': IconDistributeH,
138
+ 'dist-v': IconDistributeV,
139
+ };
107
140
  const FONT_SIZE_PRESETS: ReadonlyArray<{ px: number; label: string }> = [
108
141
  { px: 12, label: 'Small' },
109
142
  { px: 16, label: 'Medium' },
@@ -400,13 +433,43 @@ function unionRect(rects: DOMRect[]): { x: number; y: number; w: number; h: numb
400
433
 
401
434
  type SwatchMode = 'stroke' | 'fill';
402
435
 
403
- export function AnnotationContextToolbar() {
436
+ export function AnnotationContextToolbar({
437
+ editingId = null,
438
+ }: {
439
+ /**
440
+ * FigJam v3 — while an inline text editor is open, the toolbar flips into
441
+ * TEXT mode: size / B / I / S / U / align controls that drive the EDITOR
442
+ * through `maude:editor-format` events (mutating the stroke mid-edit would
443
+ * re-render the contentEditable and clobber typed text). The editor echoes
444
+ * its live state back via `maude:editor-format-state`.
445
+ */
446
+ editingId?: string | null;
447
+ } = {}) {
404
448
  ensureToolbarStyles();
405
449
  const annotSel = useAnnotationSelectionOptional();
406
450
  const store = useStrokesStore();
407
451
  const ref = useRef<HTMLDivElement | null>(null);
408
452
  const rafRef = useRef<number | null>(null);
409
453
  const [, force] = useState({});
454
+ // Edit-mode mirror of the editor's live formatting state.
455
+ const [editorFmt, setEditorFmt] = useState<{
456
+ bold?: boolean;
457
+ italic?: boolean;
458
+ underline?: boolean;
459
+ strike?: boolean;
460
+ fontSize?: number;
461
+ align?: string;
462
+ }>({});
463
+ useEffect(() => {
464
+ if (!editingId || typeof document === 'undefined') return;
465
+ const onState = (e: Event) => {
466
+ setEditorFmt((e as CustomEvent<Record<string, unknown>>).detail ?? {});
467
+ };
468
+ document.addEventListener('maude:editor-format-state', onState);
469
+ // Ask the already-mounted editor for its current state (mount-order race).
470
+ document.dispatchEvent(new CustomEvent('maude:editor-format-request'));
471
+ return () => document.removeEventListener('maude:editor-format-state', onState);
472
+ }, [editingId]);
410
473
  // T30 / G_S1 — collapsed Stroke|Fill toggle state. Defaults to 'stroke';
411
474
  // auto-reverts to 'stroke' whenever caps.fill is false so the toggle
412
475
  // can't get stranded on a hidden mode after the selection changes type.
@@ -431,25 +494,34 @@ export function AnnotationContextToolbar() {
431
494
  fill: false,
432
495
  thickness: false,
433
496
  fontSize: false,
434
- cornerRadius: false,
435
497
  arrowDir: false,
436
498
  dash: false,
499
+ shapeKind: false,
437
500
  };
438
501
  }
439
- const allFillable = selectedStrokes.every((s) => s.tool === 'rect' || s.tool === 'ellipse');
502
+ // Wave H — polygons (diamond / triangle) are full shapes: fill + stroke
503
+ // weight, identical toolbar to rect/ellipse. The same "every selected
504
+ // stroke is a closed shape" test gates fill AND the shape-kind switcher.
505
+ const allClosedShapes = selectedStrokes.every(
506
+ (s) => s.tool === 'rect' || s.tool === 'ellipse' || s.tool === 'polygon'
507
+ );
440
508
  // T20 — rect + ellipse now carry stroke weight too.
441
509
  const allThickness = selectedStrokes.every(
442
- (s) => s.tool === 'pen' || s.tool === 'arrow' || s.tool === 'rect' || s.tool === 'ellipse'
510
+ (s) =>
511
+ s.tool === 'pen' ||
512
+ s.tool === 'arrow' ||
513
+ s.tool === 'rect' ||
514
+ s.tool === 'ellipse' ||
515
+ s.tool === 'polygon'
443
516
  );
444
- // Phase 21 — fontSize applies to text + sticky; cornerRadius to rect +
445
- // sticky; arrow direction + dash to arrows.
517
+ // Phase 21 — fontSize applies to text + sticky; arrow direction + dash to
518
+ // arrows. (The Phase-21 rect corner-radius cluster was RETIRED in Wave H —
519
+ // the shape-kind switcher's square/rounded covers it and showing both read
520
+ // as "shapes twice". Pill rects keep rendering; `cornerRadius` stays in
521
+ // the model + serialization, there's just no dedicated toolbar control.)
446
522
  const fontSizeApplicable = selectedStrokes.some(
447
523
  (s) => s.tool === 'text' || s.tool === 'sticky'
448
524
  );
449
- // Phase 24 — the corner-radius control is rect-only now. Stickies have a
450
- // fixed soft radius (no switch); the sticky-color swatch row is handled
451
- // separately below.
452
- const allRect = selectedStrokes.every((s) => s.tool === 'rect');
453
525
  const allArrow = selectedStrokes.every((s) => s.tool === 'arrow');
454
526
  // Dash now applies to arrows AND every outlined shape (rect / ellipse /
455
527
  // polygon) — item 7. Heads + line-type (arrowDir) stay arrow-only.
@@ -458,25 +530,30 @@ export function AnnotationContextToolbar() {
458
530
  );
459
531
  return {
460
532
  color: true,
461
- fill: allFillable,
533
+ fill: allClosedShapes,
462
534
  thickness: allThickness,
463
535
  fontSize: fontSizeApplicable,
464
- cornerRadius: allRect,
465
536
  arrowDir: allArrow,
466
537
  dash: allDashable,
538
+ // Wave H — the shape-kind switcher (square/rounded/circle/diamond/
539
+ // triangle conversion) shows when EVERY selected stroke is a closed
540
+ // shape. Conversions keep ids, so anchored text + binds follow.
541
+ shapeKind: allClosedShapes,
467
542
  };
468
543
  }, [selectedStrokes]);
469
544
 
470
545
  // Position tracker — uses rAF to follow pan/zoom while the toolbar is up.
546
+ // Edit mode anchors over the EDITED stroke (the selection may be elsewhere).
471
547
  useEffect(() => {
472
- if (selectedStrokes.length === 0) return;
548
+ if (selectedStrokes.length === 0 && !editingId) return;
549
+ const targetIds = editingId ? [editingId] : selectedStrokes.map((s) => s.id);
473
550
  const tick = () => {
474
551
  rafRef.current = null;
475
552
  const el = ref.current;
476
553
  if (!el) return;
477
554
  const rects: DOMRect[] = [];
478
- for (const s of selectedStrokes) {
479
- const node = document.querySelector(`[data-id="${cssEscape(s.id)}"]`);
555
+ for (const id of targetIds) {
556
+ const node = document.querySelector(`[data-id="${cssEscape(id)}"]`);
480
557
  if (node) rects.push(node.getBoundingClientRect());
481
558
  }
482
559
  const u = unionRect(rects);
@@ -489,7 +566,10 @@ export function AnnotationContextToolbar() {
489
566
  // Lay out off-screen first so the size is real, then position.
490
567
  const tbW = el.offsetWidth || 280;
491
568
  const tbH = el.offsetHeight || 36;
492
- const margin = 8;
569
+ // Wave G — clear the selection chrome that floats around the stroke
570
+ // (halo pad + connection dots at ~16 px + the corner rotate zones)
571
+ // instead of sitting glued to the bbox and covering the top dot.
572
+ const margin = 28;
493
573
  let top = u.y - tbH - margin;
494
574
  if (top < 8) top = u.y + u.h + margin;
495
575
  let left = u.x + (u.w - tbW) / 2;
@@ -504,7 +584,7 @@ export function AnnotationContextToolbar() {
504
584
  return () => {
505
585
  if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
506
586
  };
507
- }, [selectedStrokes]);
587
+ }, [selectedStrokes, editingId]);
508
588
 
509
589
  // Force a re-render when the strokes themselves mutate (the position rAF
510
590
  // already follows the bbox; this ensures the active-state of the swatches
@@ -514,173 +594,168 @@ export function AnnotationContextToolbar() {
514
594
  force({});
515
595
  }, [selectedStrokes]);
516
596
 
597
+ // FigJam v3 — every bulk style mutation routes through `applyToStrokes` so
598
+ // an N-stroke selection edit is ONE undo record (the pre-v3 per-stroke
599
+ // `updateStroke` loop pushed N records — ⌘Z had to be pressed N times).
600
+ const selectedIds = useMemo(() => selectedStrokes.map((s) => s.id), [selectedStrokes]);
517
601
  const setColor = useCallback(
518
602
  (c: string) => {
519
- if (!store) return;
520
- for (const s of selectedStrokes) store.updateStroke(s.id, { color: c });
603
+ store?.applyToStrokes(selectedIds, () => ({ color: c }), 'set color');
521
604
  },
522
- [store, selectedStrokes]
605
+ [store, selectedIds]
523
606
  );
524
607
  const setFill = useCallback(
525
608
  (f: string | null) => {
526
- if (!store) return;
527
- for (const s of selectedStrokes) {
528
- if (s.tool === 'rect' || s.tool === 'ellipse') {
529
- store.updateStroke(s.id, { fill: f } as Partial<Stroke>);
530
- }
531
- }
609
+ store?.applyToStrokes(
610
+ selectedIds,
611
+ (s) =>
612
+ s.tool === 'rect' || s.tool === 'ellipse' || s.tool === 'polygon'
613
+ ? ({ fill: f } as Partial<Stroke>)
614
+ : null,
615
+ 'set fill'
616
+ );
532
617
  },
533
- [store, selectedStrokes]
618
+ [store, selectedIds]
534
619
  );
535
620
  const setThickness = useCallback(
536
621
  (w: number) => {
537
- if (!store) return;
538
- for (const s of selectedStrokes) {
539
- // T20 — rect + ellipse now carry stroke weight.
540
- if (s.tool === 'pen' || s.tool === 'arrow' || s.tool === 'rect' || s.tool === 'ellipse') {
541
- store.updateStroke(s.id, { width: w } as Partial<Stroke>);
542
- }
543
- }
622
+ // T20 — rect + ellipse carry stroke weight too; Wave H adds polygon.
623
+ store?.applyToStrokes(
624
+ selectedIds,
625
+ (s) =>
626
+ s.tool === 'pen' ||
627
+ s.tool === 'arrow' ||
628
+ s.tool === 'rect' ||
629
+ s.tool === 'ellipse' ||
630
+ s.tool === 'polygon'
631
+ ? ({ width: w } as Partial<Stroke>)
632
+ : null,
633
+ 'set thickness'
634
+ );
544
635
  },
545
- [store, selectedStrokes]
636
+ [store, selectedIds]
546
637
  );
547
638
  const setFontSize = useCallback(
548
639
  (sz: number) => {
549
- if (!store) return;
550
- for (const s of selectedStrokes) {
551
- // Phase 21 — sticky carries fontSize too.
552
- if (s.tool === 'text' || s.tool === 'sticky') {
553
- store.updateStroke(s.id, { fontSize: sz } as Partial<Stroke>);
554
- }
555
- }
640
+ // Phase 21 — sticky carries fontSize too.
641
+ store?.applyToStrokes(
642
+ selectedIds,
643
+ (s) =>
644
+ s.tool === 'text' || s.tool === 'sticky' ? ({ fontSize: sz } as Partial<Stroke>) : null,
645
+ 'set font size'
646
+ );
556
647
  },
557
- [store, selectedStrokes]
648
+ [store, selectedIds]
558
649
  );
559
- // Phase 24 — bold / strike / align on text + sticky bodies.
560
- const setBold = useCallback(
561
- (bold: boolean) => {
562
- if (!store) return;
563
- for (const s of selectedStrokes) {
564
- if (s.tool === 'text' || s.tool === 'sticky') {
565
- store.updateStroke(s.id, { bold } as Partial<Stroke>);
566
- }
567
- }
650
+ // Phase 24 — bold / strike / italic / underline / list / align on text +
651
+ // sticky bodies, all single-record.
652
+ const applyTextPatch = useCallback(
653
+ (patch: Partial<Stroke>, label: string) => {
654
+ store?.applyToStrokes(
655
+ selectedIds,
656
+ (s) => (s.tool === 'text' || s.tool === 'sticky' ? patch : null),
657
+ label
658
+ );
568
659
  },
569
- [store, selectedStrokes]
660
+ [store, selectedIds]
661
+ );
662
+ const setBold = useCallback(
663
+ (bold: boolean) => applyTextPatch({ bold } as Partial<Stroke>, 'set bold'),
664
+ [applyTextPatch]
570
665
  );
571
666
  const setStrike = useCallback(
572
- (strike: boolean) => {
573
- if (!store) return;
574
- for (const s of selectedStrokes) {
575
- if (s.tool === 'text' || s.tool === 'sticky') {
576
- store.updateStroke(s.id, { strike } as Partial<Stroke>);
577
- }
578
- }
579
- },
580
- [store, selectedStrokes]
667
+ (strike: boolean) => applyTextPatch({ strike } as Partial<Stroke>, 'set strikethrough'),
668
+ [applyTextPatch]
581
669
  );
582
- // Italic / underline / list (items 4b + 4c) — same text + sticky fan-out.
583
670
  const setItalic = useCallback(
584
- (italic: boolean) => {
585
- if (!store) return;
586
- for (const s of selectedStrokes) {
587
- if (s.tool === 'text' || s.tool === 'sticky') {
588
- store.updateStroke(s.id, { italic } as Partial<Stroke>);
589
- }
590
- }
591
- },
592
- [store, selectedStrokes]
671
+ (italic: boolean) => applyTextPatch({ italic } as Partial<Stroke>, 'set italic'),
672
+ [applyTextPatch]
593
673
  );
594
674
  const setUnderline = useCallback(
595
- (underline: boolean) => {
596
- if (!store) return;
597
- for (const s of selectedStrokes) {
598
- if (s.tool === 'text' || s.tool === 'sticky') {
599
- store.updateStroke(s.id, { underline } as Partial<Stroke>);
600
- }
601
- }
602
- },
603
- [store, selectedStrokes]
675
+ (underline: boolean) => applyTextPatch({ underline } as Partial<Stroke>, 'set underline'),
676
+ [applyTextPatch]
604
677
  );
605
678
  const setListType = useCallback(
606
- (listType: ListType | undefined) => {
607
- if (!store) return;
608
- for (const s of selectedStrokes) {
609
- if (s.tool === 'text' || s.tool === 'sticky') {
610
- store.updateStroke(s.id, { listType } as Partial<Stroke>);
611
- }
612
- }
613
- },
614
- [store, selectedStrokes]
679
+ (listType: ListType | undefined) =>
680
+ applyTextPatch({ listType } as Partial<Stroke>, 'set list style'),
681
+ [applyTextPatch]
615
682
  );
616
683
  const setAlign = useCallback(
617
- (align: TextAlign) => {
618
- if (!store) return;
619
- for (const s of selectedStrokes) {
620
- if (s.tool === 'text' || s.tool === 'sticky') {
621
- store.updateStroke(s.id, { align } as Partial<Stroke>);
622
- }
623
- }
624
- },
625
- [store, selectedStrokes]
684
+ (align: TextAlign) => applyTextPatch({ align } as Partial<Stroke>, 'set alignment'),
685
+ [applyTextPatch]
626
686
  );
627
- // Phase 21corner radius (rect + sticky).
628
- const setCornerRadius = useCallback(
629
- (r: number) => {
630
- if (!store) return;
631
- for (const s of selectedStrokes) {
632
- if (s.tool === 'rect' || s.tool === 'sticky') {
633
- store.updateStroke(s.id, { cornerRadius: r } as Partial<Stroke>);
634
- }
635
- }
687
+ // Wave Hconvert the selection to another shape kind (one undo record).
688
+ const setSelectionShapeKind = useCallback(
689
+ (kind: ConvertibleShapeKind) => {
690
+ store?.applyToStrokes(selectedIds, (s) => convertShapeKind(s, kind), 'change shape');
636
691
  },
637
- [store, selectedStrokes]
692
+ [store, selectedIds]
638
693
  );
639
- // Phase 24 — per-end arrowhead + line-type (replaces the 4 fixed direction
640
- // presets with two head dropdowns + a routing dropdown).
694
+ // Phase 24 — per-end arrowhead + line-type.
641
695
  const setStartHead = useCallback(
642
696
  (startHead: ArrowHead) => {
643
- if (!store) return;
644
- for (const s of selectedStrokes) {
645
- if (s.tool === 'arrow') store.updateStroke(s.id, { startHead } as Partial<Stroke>);
646
- }
697
+ store?.applyToStrokes(
698
+ selectedIds,
699
+ (s) => (s.tool === 'arrow' ? ({ startHead } as Partial<Stroke>) : null),
700
+ 'set arrowhead'
701
+ );
647
702
  },
648
- [store, selectedStrokes]
703
+ [store, selectedIds]
649
704
  );
650
705
  const setEndHead = useCallback(
651
706
  (endHead: ArrowHead) => {
652
- if (!store) return;
653
- for (const s of selectedStrokes) {
654
- if (s.tool === 'arrow') store.updateStroke(s.id, { endHead } as Partial<Stroke>);
655
- }
707
+ store?.applyToStrokes(
708
+ selectedIds,
709
+ (s) => (s.tool === 'arrow' ? ({ endHead } as Partial<Stroke>) : null),
710
+ 'set arrowhead'
711
+ );
656
712
  },
657
- [store, selectedStrokes]
713
+ [store, selectedIds]
658
714
  );
659
715
  const setLineType = useCallback(
660
716
  (lineType: ArrowLineType) => {
661
- if (!store) return;
662
- for (const s of selectedStrokes) {
663
- if (s.tool === 'arrow') store.updateStroke(s.id, { lineType } as Partial<Stroke>);
664
- }
717
+ store?.applyToStrokes(
718
+ selectedIds,
719
+ (s) => (s.tool === 'arrow' ? ({ lineType } as Partial<Stroke>) : null),
720
+ 'set line type'
721
+ );
665
722
  },
666
- [store, selectedStrokes]
723
+ [store, selectedIds]
667
724
  );
668
725
  // Dash toggle — arrow + rect / ellipse / polygon (item 7).
669
726
  const setDashed = useCallback(
670
727
  (dashed: boolean) => {
671
- if (!store) return;
672
- for (const s of selectedStrokes) {
673
- if (
674
- s.tool === 'arrow' ||
675
- s.tool === 'rect' ||
676
- s.tool === 'ellipse' ||
677
- s.tool === 'polygon'
678
- ) {
679
- store.updateStroke(s.id, { dashed } as Partial<Stroke>);
680
- }
728
+ store?.applyToStrokes(
729
+ selectedIds,
730
+ (s) =>
731
+ s.tool === 'arrow' || s.tool === 'rect' || s.tool === 'ellipse' || s.tool === 'polygon'
732
+ ? ({ dashed } as Partial<Stroke>)
733
+ : null,
734
+ 'set dash'
735
+ );
736
+ },
737
+ [store, selectedIds]
738
+ );
739
+ // FigJam v3 — group / ungroup / align / distribute on the selection.
740
+ const groupSel = useCallback(() => {
741
+ if (!annotSel || !store) return;
742
+ const members = store.groupSelection(annotSel.selectedIds);
743
+ if (members) annotSel.replace(members);
744
+ }, [annotSel, store]);
745
+ const ungroupSel = useCallback(() => {
746
+ if (!annotSel || !store) return;
747
+ store.ungroupSelection(annotSel.selectedIds);
748
+ }, [annotSel, store]);
749
+ const alignSel = useCallback(
750
+ (op: AlignAction) => {
751
+ if (!annotSel || !store) return;
752
+ if (op === 'dist-h' || op === 'dist-v') {
753
+ store.distributeSelection(annotSel.selectedIds, op === 'dist-h' ? 'h' : 'v');
754
+ } else {
755
+ store.alignSelection(annotSel.selectedIds, op);
681
756
  }
682
757
  },
683
- [store, selectedStrokes]
758
+ [annotSel, store]
684
759
  );
685
760
  const remove = useCallback(() => {
686
761
  if (!annotSel || !store) return;
@@ -688,6 +763,60 @@ export function AnnotationContextToolbar() {
688
763
  annotSel.clear();
689
764
  }, [annotSel, store]);
690
765
 
766
+ // FigJam v3 — TEXT mode while an inline editor is open: a focused strip of
767
+ // text controls dispatching to the editor (Image-6 FigJam parity).
768
+ if (editingId) {
769
+ const dispatchFmt = (key: string, value?: unknown) => {
770
+ document.dispatchEvent(new CustomEvent('maude:editor-format', { detail: { key, value } }));
771
+ };
772
+ const fmtBtn = (
773
+ key: 'bold' | 'italic' | 'strike' | 'underline',
774
+ label: string,
775
+ icon: ReactNode
776
+ ) => (
777
+ <button
778
+ type="button"
779
+ className="dc-annot-ctx-ibtn"
780
+ aria-label={label}
781
+ aria-pressed={!!editorFmt[key]}
782
+ title={label}
783
+ // The editor commits on blur/outside-pointerdown — suppress the
784
+ // mousedown so clicking the toolbar doesn't end the edit session.
785
+ onMouseDown={(e) => e.preventDefault()}
786
+ onPointerDown={(e) => e.stopPropagation()}
787
+ onClick={() => dispatchFmt(key)}
788
+ >
789
+ {icon}
790
+ </button>
791
+ );
792
+ return (
793
+ <div
794
+ ref={ref}
795
+ className="dc-annot-ctx"
796
+ role="toolbar"
797
+ aria-label="Text formatting"
798
+ style={{ display: 'flex', top: -9999, left: -9999 }}
799
+ onPointerDown={(e) => e.stopPropagation()}
800
+ >
801
+ <FontSizeDropdown value={editorFmt.fontSize} onPick={(n) => dispatchFmt('fontSize', n)} />
802
+ {fmtBtn('bold', 'Bold', <IconBold />)}
803
+ {fmtBtn('italic', 'Italic', <IconItalic />)}
804
+ {fmtBtn('strike', 'Strikethrough', <IconStrike />)}
805
+ {fmtBtn('underline', 'Underline', <IconUnderline />)}
806
+ <IconDropdown
807
+ ariaLabel="Text alignment"
808
+ value={editorFmt.align ?? 'left'}
809
+ options={ALIGN_OPTIONS}
810
+ renderIcon={(v) => {
811
+ const I = ALIGN_ICON[v as TextAlign];
812
+ return I ? <I size={16} /> : null;
813
+ }}
814
+ onPick={(v) => dispatchFmt('align', v)}
815
+ />
816
+ </div>
817
+ );
818
+ }
819
+
691
820
  if (!annotSel || !store || selectedStrokes.length === 0) return null;
692
821
 
693
822
  // ── Phase 23 — dedicated panel for a single image / link selection. ─────────
@@ -829,19 +958,31 @@ export function AnnotationContextToolbar() {
829
958
  );
830
959
  }
831
960
 
961
+ // FigJam v3 — group / align availability. Selection expansion guarantees a
962
+ // group is always selected whole, so "any member grouped" ⇒ ungroupable.
963
+ const canGroup = selectedStrokes.length >= 2;
964
+ const canUngroup = selectedStrokes.some((s) => (s.groupIds?.length ?? 0) > 0);
965
+ const canAlign = selectedStrokes.length >= 2;
966
+
832
967
  // Active values for swatch highlighting — when uniform across selection.
833
968
  const uniqColor = uniformValue(selectedStrokes.map((s) => s.color));
834
969
  const uniqFill = caps.fill
835
970
  ? uniformValue(
836
971
  selectedStrokes.map((s) =>
837
- s.tool === 'rect' || s.tool === 'ellipse' ? (s.fill ?? null) : undefined
972
+ s.tool === 'rect' || s.tool === 'ellipse' || s.tool === 'polygon'
973
+ ? (s.fill ?? null)
974
+ : undefined
838
975
  )
839
976
  )
840
977
  : undefined;
841
978
  const uniqThickness = caps.thickness
842
979
  ? uniformValue(
843
980
  selectedStrokes.map((s) =>
844
- s.tool === 'pen' || s.tool === 'arrow' || s.tool === 'rect' || s.tool === 'ellipse'
981
+ s.tool === 'pen' ||
982
+ s.tool === 'arrow' ||
983
+ s.tool === 'rect' ||
984
+ s.tool === 'ellipse' ||
985
+ s.tool === 'polygon'
845
986
  ? s.width
846
987
  : undefined
847
988
  )
@@ -903,18 +1044,9 @@ export function AnnotationContextToolbar() {
903
1044
  })
904
1045
  )
905
1046
  : undefined;
906
- // Phase 21 — uniform corner radius across rect/sticky (default per type: rect
907
- // sharp = 0, sticky soft = 8).
908
- const uniqRadius = caps.cornerRadius
909
- ? uniformValue(
910
- selectedStrokes.map((s) =>
911
- s.tool === 'rect'
912
- ? (s.cornerRadius ?? 0)
913
- : s.tool === 'sticky'
914
- ? (s.cornerRadius ?? 8)
915
- : undefined
916
- )
917
- )
1047
+ // Wave H — uniform shape kind across the selection (undefined = mixed).
1048
+ const uniqShapeKind = caps.shapeKind
1049
+ ? uniformValue(selectedStrokes.map((s) => shapeKindOf(s) ?? undefined))
918
1050
  : undefined;
919
1051
  // Phase 21 — uniform arrow head pair (default start none / end triangle).
920
1052
  const uniqStartHead = caps.arrowDir
@@ -1119,39 +1251,34 @@ export function AnnotationContextToolbar() {
1119
1251
  </button>
1120
1252
  </>
1121
1253
  ) : null}
1122
- {caps.cornerRadius ? (
1254
+ {caps.shapeKind ? (
1123
1255
  <>
1124
1256
  <div className="dc-annot-ctx-sep" />
1125
- <button
1126
- type="button"
1127
- className="dc-annot-ctx-ibtn"
1128
- aria-label="Square corners"
1129
- aria-pressed={uniqRadius === 0}
1130
- title="Square corners"
1131
- onClick={() => setCornerRadius(0)}
1132
- >
1133
- <IconCornerSquare />
1134
- </button>
1135
- <button
1136
- type="button"
1137
- className="dc-annot-ctx-ibtn"
1138
- aria-label="Soft corners"
1139
- aria-pressed={uniqRadius === 8}
1140
- title="Soft corners"
1141
- onClick={() => setCornerRadius(8)}
1142
- >
1143
- <IconCornerSoft />
1144
- </button>
1145
- <button
1146
- type="button"
1147
- className="dc-annot-ctx-ibtn"
1148
- aria-label="Pill corners"
1149
- aria-pressed={uniqRadius === 999}
1150
- title="Pill corners"
1151
- onClick={() => setCornerRadius(999)}
1152
- >
1153
- <IconCornerPill />
1154
- </button>
1257
+ {(
1258
+ [
1259
+ ['square', 'Square'],
1260
+ ['rounded', 'Rounded square'],
1261
+ ['circle', 'Circle'],
1262
+ ['diamond', 'Diamond'],
1263
+ ['triangle', 'Triangle'],
1264
+ ['triangle-down', 'Triangle down'],
1265
+ ] as Array<[ConvertibleShapeKind, string]>
1266
+ ).map(([kind, label]) => {
1267
+ const KindIcon = SHAPE_KIND_ICONS[kind];
1268
+ return (
1269
+ <button
1270
+ key={kind}
1271
+ type="button"
1272
+ className="dc-annot-ctx-ibtn"
1273
+ aria-label={label}
1274
+ aria-pressed={uniqShapeKind === kind}
1275
+ title={label}
1276
+ onClick={() => setSelectionShapeKind(kind)}
1277
+ >
1278
+ {KindIcon ? <KindIcon size={16} /> : null}
1279
+ </button>
1280
+ );
1281
+ })}
1155
1282
  </>
1156
1283
  ) : null}
1157
1284
  {caps.arrowDir ? (
@@ -1204,6 +1331,46 @@ export function AnnotationContextToolbar() {
1204
1331
  </button>
1205
1332
  </>
1206
1333
  ) : null}
1334
+ {canGroup || canUngroup || canAlign ? (
1335
+ <>
1336
+ <div className="dc-annot-ctx-sep" />
1337
+ {canGroup ? (
1338
+ <button
1339
+ type="button"
1340
+ className="dc-annot-ctx-ibtn"
1341
+ aria-label="Group selection"
1342
+ title="Group (⌘G)"
1343
+ onClick={groupSel}
1344
+ >
1345
+ <IconGroup />
1346
+ </button>
1347
+ ) : null}
1348
+ {canUngroup ? (
1349
+ <button
1350
+ type="button"
1351
+ className="dc-annot-ctx-ibtn"
1352
+ aria-label="Ungroup selection"
1353
+ title="Ungroup (⌘⇧G)"
1354
+ onClick={ungroupSel}
1355
+ >
1356
+ <IconUngroup />
1357
+ </button>
1358
+ ) : null}
1359
+ {canAlign ? (
1360
+ <ActionDropdown
1361
+ ariaLabel="Align and distribute"
1362
+ triggerIcon={<IconObjAlignLeft size={16} />}
1363
+ options={ALIGN_ACTION_OPTIONS}
1364
+ renderIcon={(v) => {
1365
+ const I = ALIGN_ACTION_ICON[v];
1366
+ return I ? <I size={16} /> : null;
1367
+ }}
1368
+ isDisabled={(v) => (v === 'dist-h' || v === 'dist-v') && selectedStrokes.length < 3}
1369
+ onPick={(v) => alignSel(v as AlignAction)}
1370
+ />
1371
+ ) : null}
1372
+ </>
1373
+ ) : null}
1207
1374
  <div className="dc-annot-ctx-sep" />
1208
1375
  <button
1209
1376
  type="button"
@@ -1297,6 +1464,89 @@ function IconDropdown({
1297
1464
  );
1298
1465
  }
1299
1466
 
1467
+ /**
1468
+ * FigJam v3 — an ACTION dropdown (vs IconDropdown's radio semantics): each
1469
+ * item fires an operation instead of reflecting a current value. Used by the
1470
+ * align/distribute cluster; distribute items disable below three strokes.
1471
+ */
1472
+ function ActionDropdown({
1473
+ ariaLabel,
1474
+ triggerIcon,
1475
+ options,
1476
+ renderIcon,
1477
+ isDisabled,
1478
+ onPick,
1479
+ }: {
1480
+ ariaLabel: string;
1481
+ triggerIcon: ReactNode;
1482
+ options: ReadonlyArray<{ value: string; label: string }>;
1483
+ renderIcon: (v: string) => ReactNode;
1484
+ isDisabled?: (v: string) => boolean;
1485
+ onPick: (v: string) => void;
1486
+ }) {
1487
+ const [open, setOpen] = useState(false);
1488
+ const ref = useRef<HTMLDivElement | null>(null);
1489
+ useEffect(() => {
1490
+ if (!open) return;
1491
+ const onDown = (e: PointerEvent) => {
1492
+ if (!ref.current?.contains(e.target as Node)) setOpen(false);
1493
+ };
1494
+ const onKey = (e: KeyboardEvent) => {
1495
+ if (e.key === 'Escape') setOpen(false);
1496
+ };
1497
+ document.addEventListener('pointerdown', onDown, true);
1498
+ document.addEventListener('keydown', onKey, true);
1499
+ return () => {
1500
+ document.removeEventListener('pointerdown', onDown, true);
1501
+ document.removeEventListener('keydown', onKey, true);
1502
+ };
1503
+ }, [open]);
1504
+ return (
1505
+ <div ref={ref} className="dc-annot-ctx-dd">
1506
+ <button
1507
+ type="button"
1508
+ className="dc-annot-ctx-ibtn dc-annot-ctx-dd-trigger"
1509
+ aria-haspopup="menu"
1510
+ aria-expanded={open}
1511
+ aria-label={ariaLabel}
1512
+ title={ariaLabel}
1513
+ onClick={() => setOpen((o) => !o)}
1514
+ >
1515
+ {triggerIcon}
1516
+ <span className="dc-annot-ctx-dd-caret" aria-hidden="true">
1517
+ <IconChevronDown size={8} />
1518
+ </span>
1519
+ </button>
1520
+ {open ? (
1521
+ <div className="dc-annot-ctx-menu" role="menu" aria-label={ariaLabel}>
1522
+ {options.map((o) => {
1523
+ const disabled = isDisabled?.(o.value) ?? false;
1524
+ return (
1525
+ <button
1526
+ key={o.value}
1527
+ type="button"
1528
+ role="menuitem"
1529
+ aria-label={o.label}
1530
+ title={o.label}
1531
+ className="dc-annot-ctx-ibtn"
1532
+ disabled={disabled}
1533
+ style={disabled ? { opacity: 0.35, cursor: 'default' } : undefined}
1534
+ onClick={() => {
1535
+ if (disabled) return;
1536
+ onPick(o.value);
1537
+ setOpen(false);
1538
+ }}
1539
+ >
1540
+ {renderIcon(o.value)}
1541
+ </button>
1542
+ );
1543
+ })}
1544
+ </div>
1545
+ ) : null}
1546
+ </div>
1547
+ );
1548
+ }
1549
+
1300
1550
  /**
1301
1551
  * Phase 24 — font-size control: named presets (Small → Huge) + a numeric input
1302
1552
  * for an arbitrary px value (clamped 8–200, applied on Enter / blur). The