@1agh/maude 0.24.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/cli/commands/design.mjs +5 -0
  2. package/cli/lib/design-link.mjs +13 -6
  3. package/cli/lib/gitignore-block.mjs +1 -0
  4. package/cli/lib/gitignore-block.test.mjs +1 -0
  5. package/package.json +8 -8
  6. package/plugins/design/dependencies.json +30 -2
  7. package/plugins/design/dev-server/annotations-context-toolbar.tsx +481 -78
  8. package/plugins/design/dev-server/annotations-layer.tsx +817 -170
  9. package/plugins/design/dev-server/api.ts +15 -1
  10. package/plugins/design/dev-server/bin/_enumerate-artboards-playwright.mjs +2 -2
  11. package/plugins/design/dev-server/bin/_html-playwright.mjs +2 -2
  12. package/plugins/design/dev-server/bin/_pdf-playwright.mjs +25 -8
  13. package/plugins/design/dev-server/bin/_png-playwright.mjs +20 -20
  14. package/plugins/design/dev-server/bin/_pptx-playwright.mjs +2 -2
  15. package/plugins/design/dev-server/bin/_pw-launch.mjs +61 -0
  16. package/plugins/design/dev-server/bin/_screenshot-playwright.mjs +2 -2
  17. package/plugins/design/dev-server/bin/_svg-optimize.mjs +35 -0
  18. package/plugins/design/dev-server/bin/_svg-playwright.mjs +125 -19
  19. package/plugins/design/dev-server/bin/draw-build.sh +48 -0
  20. package/plugins/design/dev-server/bin/draw-proof.sh +129 -0
  21. package/plugins/design/dev-server/bin/smoke.sh +114 -12
  22. package/plugins/design/dev-server/bin/svg-optimize.sh +24 -0
  23. package/plugins/design/dev-server/canvas-arrowheads.ts +220 -0
  24. package/plugins/design/dev-server/canvas-comment-mount.tsx +8 -24
  25. package/plugins/design/dev-server/canvas-cursors.ts +130 -82
  26. package/plugins/design/dev-server/canvas-icons.tsx +169 -0
  27. package/plugins/design/dev-server/canvas-lib.tsx +110 -0
  28. package/plugins/design/dev-server/canvas-shell.tsx +113 -89
  29. package/plugins/design/dev-server/client/app.jsx +1084 -417
  30. package/plugins/design/dev-server/config.schema.json +10 -0
  31. package/plugins/design/dev-server/context.ts +9 -0
  32. package/plugins/design/dev-server/dist/client.bundle.js +242 -20
  33. package/plugins/design/dev-server/dist/comment-mount.js +40 -62
  34. package/plugins/design/dev-server/draw/brush.ts +639 -0
  35. package/plugins/design/dev-server/draw/composition.ts +229 -0
  36. package/plugins/design/dev-server/draw/geometry.ts +578 -0
  37. package/plugins/design/dev-server/draw/index.ts +28 -0
  38. package/plugins/design/dev-server/draw/layout.ts +260 -0
  39. package/plugins/design/dev-server/draw/optimize.ts +65 -0
  40. package/plugins/design/dev-server/draw/palette.ts +417 -0
  41. package/plugins/design/dev-server/draw/primitives.ts +643 -0
  42. package/plugins/design/dev-server/draw/serialize.ts +458 -0
  43. package/plugins/design/dev-server/draw/test/brush.test.ts +213 -0
  44. package/plugins/design/dev-server/draw/test/composition.test.ts +141 -0
  45. package/plugins/design/dev-server/draw/test/geometry.test.ts +199 -0
  46. package/plugins/design/dev-server/draw/test/gradient.test.ts +167 -0
  47. package/plugins/design/dev-server/draw/test/layout.test.ts +120 -0
  48. package/plugins/design/dev-server/draw/test/optimize.test.ts +40 -0
  49. package/plugins/design/dev-server/draw/test/palette.test.ts +123 -0
  50. package/plugins/design/dev-server/draw/test/primitives.test.ts +129 -0
  51. package/plugins/design/dev-server/draw/test/serialize.test.ts +105 -0
  52. package/plugins/design/dev-server/export-dialog.tsx +189 -1
  53. package/plugins/design/dev-server/exporters/html.ts +4 -1
  54. package/plugins/design/dev-server/exporters/index.ts +4 -1
  55. package/plugins/design/dev-server/exporters/pdf.ts +7 -1
  56. package/plugins/design/dev-server/exporters/png.ts +21 -2
  57. package/plugins/design/dev-server/exporters/pptx.ts +330 -201
  58. package/plugins/design/dev-server/exporters/scope.ts +107 -11
  59. package/plugins/design/dev-server/exporters/svg.ts +4 -1
  60. package/plugins/design/dev-server/http.ts +40 -9
  61. package/plugins/design/dev-server/input-router.tsx +9 -2
  62. package/plugins/design/dev-server/sync/index.ts +73 -17
  63. package/plugins/design/dev-server/test/annotations-draw-modifiers.test.ts +206 -0
  64. package/plugins/design/dev-server/test/annotations-layer.test.ts +83 -3
  65. package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +243 -0
  66. package/plugins/design/dev-server/test/canvas-cursors.test.ts +95 -6
  67. package/plugins/design/dev-server/test/canvas-route.test.ts +4 -1
  68. package/plugins/design/dev-server/test/exporters/png.test.ts +24 -1
  69. package/plugins/design/dev-server/test/exporters/pptx-deck.test.ts +112 -0
  70. package/plugins/design/dev-server/test/exporters/pw-launch.test.ts +49 -0
  71. package/plugins/design/dev-server/test/exporters/scope.test.ts +0 -0
  72. package/plugins/design/dev-server/test/fixtures/phase-21-annotations.svg +1 -0
  73. package/plugins/design/dev-server/test/input-router.test.ts +11 -4
  74. package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +32 -0
  75. package/plugins/design/dev-server/test/sync-runtime.test.ts +52 -0
  76. package/plugins/design/dev-server/test/use-annotation-resize.test.ts +200 -0
  77. package/plugins/design/dev-server/test/use-tool-mode.test.tsx +9 -11
  78. package/plugins/design/dev-server/tool-palette.tsx +140 -11
  79. package/plugins/design/dev-server/tsconfig.json +8 -1
  80. package/plugins/design/dev-server/use-annotation-resize.tsx +208 -61
  81. package/plugins/design/dev-server/use-tool-mode.tsx +55 -9
  82. package/plugins/design/templates/_shell.html +36 -9
  83. package/plugins/design/templates/design-system-inspiration/_MAPPING.md +15 -0
  84. package/plugins/design/templates/design-system-inspiration/core/config.json.tpl +1 -0
@@ -20,30 +20,93 @@
20
20
  * - font-size: only when at least one stroke is text (and the rest are its host)
21
21
  */
22
22
 
23
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
23
+ import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
24
24
 
25
25
  import {
26
+ type ArrowHead,
27
+ type ArrowLineType,
26
28
  FILL_PALETTE,
29
+ STICKY_PALETTE,
27
30
  STROKE_PALETTE,
28
31
  type Stroke,
32
+ type TextAlign,
29
33
  useStrokesStore,
30
34
  } from './annotations-layer.tsx';
31
35
  import {
32
- IconArrowBothHeads,
33
- IconArrowEndHead,
36
+ IconAlignCenter,
37
+ IconAlignLeft,
38
+ IconAlignRight,
34
39
  IconArrowNone,
35
- IconArrowStartHead,
40
+ IconArrowheadCircle,
41
+ IconArrowheadDiamond,
42
+ IconArrowheadLine,
43
+ IconArrowheadTriangle,
44
+ IconArrowheadTriangleOutline,
45
+ IconBold,
46
+ IconChevronDown,
36
47
  IconCornerPill,
37
48
  IconCornerSoft,
38
49
  IconCornerSquare,
39
50
  IconDash,
40
- IconLetterA,
51
+ IconLineCurved,
52
+ IconLineElbow,
53
+ IconLineStraight,
41
54
  IconLineThick,
42
55
  IconLineThin,
56
+ IconStrike,
43
57
  IconTrash,
44
58
  } from './canvas-icons.tsx';
45
59
  import { useAnnotationSelectionOptional } from './use-annotation-selection.tsx';
46
60
 
61
+ // Phase 24 — arrowhead + line-type option metadata (icon + label per value),
62
+ // driving the per-end head dropdowns + the line-type dropdown.
63
+ const HEAD_ICON: Record<ArrowHead, (p: { size?: number }) => ReactNode> = {
64
+ none: IconArrowNone,
65
+ line: IconArrowheadLine,
66
+ triangle: IconArrowheadTriangle,
67
+ 'triangle-outline': IconArrowheadTriangleOutline,
68
+ circle: IconArrowheadCircle,
69
+ diamond: IconArrowheadDiamond,
70
+ };
71
+ const HEAD_OPTIONS: ReadonlyArray<{ value: ArrowHead; label: string }> = [
72
+ { value: 'none', label: 'None' },
73
+ { value: 'line', label: 'Line' },
74
+ { value: 'triangle', label: 'Triangle' },
75
+ { value: 'triangle-outline', label: 'Triangle (outline)' },
76
+ { value: 'circle', label: 'Circle' },
77
+ { value: 'diamond', label: 'Diamond' },
78
+ ];
79
+ const LINETYPE_ICON: Record<ArrowLineType, (p: { size?: number }) => ReactNode> = {
80
+ straight: IconLineStraight,
81
+ curved: IconLineCurved,
82
+ elbow: IconLineElbow,
83
+ };
84
+ const LINETYPE_OPTIONS: ReadonlyArray<{ value: ArrowLineType; label: string }> = [
85
+ { value: 'straight', label: 'Straight' },
86
+ { value: 'curved', label: 'Curved' },
87
+ { value: 'elbow', label: 'Elbow' },
88
+ ];
89
+ // Phase 24 — text alignment (text + sticky) + named font-size presets.
90
+ const ALIGN_ICON: Record<TextAlign, (p: { size?: number }) => ReactNode> = {
91
+ left: IconAlignLeft,
92
+ center: IconAlignCenter,
93
+ right: IconAlignRight,
94
+ };
95
+ const ALIGN_OPTIONS: ReadonlyArray<{ value: TextAlign; label: string }> = [
96
+ { value: 'left', label: 'Align left' },
97
+ { value: 'center', label: 'Align center' },
98
+ { value: 'right', label: 'Align right' },
99
+ ];
100
+ const FONT_SIZE_PRESETS: ReadonlyArray<{ px: number; label: string }> = [
101
+ { px: 12, label: 'Small' },
102
+ { px: 16, label: 'Medium' },
103
+ { px: 24, label: 'Large' },
104
+ { px: 36, label: 'Extra large' },
105
+ { px: 64, label: 'Huge' },
106
+ ];
107
+ const FONT_SIZE_MIN = 8;
108
+ const FONT_SIZE_MAX = 200;
109
+
47
110
  // Phase 21 — the swatch palettes come from annotations-layer so the draw-time
48
111
  // chrome and this per-selection toolbar share ONE hue family. STROKE mode shows
49
112
  // saturated inks; FILL mode shows the index-paired light tints (FigJam: a
@@ -190,6 +253,91 @@ const TOOLBAR_CSS = `
190
253
  background: color-mix(in oklab, #ff5a4d 26%, transparent);
191
254
  color: #ffffff;
192
255
  }
256
+ /* Phase 24 — icon dropdown (arrowhead / line-type / text-align). The wrapper
257
+ anchors the menu directly above the trigger. */
258
+ .dc-annot-ctx-dd { position: relative; display: inline-flex; }
259
+ .dc-annot-ctx-dd-trigger { padding-right: 9px; }
260
+ .dc-annot-ctx-dd-caret {
261
+ position: absolute;
262
+ right: 1px;
263
+ bottom: 2px;
264
+ display: inline-flex;
265
+ opacity: 0.5;
266
+ pointer-events: none;
267
+ }
268
+ .dc-annot-ctx-menu {
269
+ position: absolute;
270
+ bottom: calc(100% + 6px);
271
+ left: 50%;
272
+ transform: translateX(-50%);
273
+ display: flex;
274
+ gap: 1px;
275
+ padding: 4px;
276
+ background: ${CTX_SURFACE};
277
+ border: 1px solid rgba(255,255,255,0.1);
278
+ border-radius: 9px;
279
+ box-shadow: 0 8px 28px rgba(0,0,0,0.4), 0 2px 6px rgba(0,0,0,0.24);
280
+ z-index: 9;
281
+ }
282
+ .dc-annot-ctx-menu button[aria-checked="true"] {
283
+ background: rgba(255,255,255,0.18);
284
+ color: #ffffff;
285
+ }
286
+ /* Font-size dropdown — wider trigger showing the current px + a vertical menu
287
+ of named presets and a numeric input. */
288
+ .dc-annot-ctx-fs-trigger {
289
+ width: auto;
290
+ min-width: 30px;
291
+ padding: 0 14px 0 7px;
292
+ font-variant-numeric: tabular-nums;
293
+ }
294
+ .dc-annot-ctx-fs-val { font-size: 12px; }
295
+ .dc-annot-ctx-fs-menu {
296
+ position: absolute;
297
+ bottom: calc(100% + 6px);
298
+ left: 50%;
299
+ transform: translateX(-50%);
300
+ display: flex;
301
+ flex-direction: column;
302
+ gap: 1px;
303
+ padding: 4px;
304
+ min-width: 132px;
305
+ background: ${CTX_SURFACE};
306
+ border: 1px solid rgba(255,255,255,0.1);
307
+ border-radius: 9px;
308
+ box-shadow: 0 8px 28px rgba(0,0,0,0.4), 0 2px 6px rgba(0,0,0,0.24);
309
+ z-index: 9;
310
+ }
311
+ .dc-annot-ctx-fs-item {
312
+ appearance: none;
313
+ background: transparent;
314
+ border: 0;
315
+ border-radius: 6px;
316
+ padding: 5px 8px;
317
+ display: flex;
318
+ justify-content: space-between;
319
+ gap: 16px;
320
+ font: inherit;
321
+ font-size: 12px;
322
+ color: rgba(255,255,255,0.85);
323
+ cursor: pointer;
324
+ }
325
+ .dc-annot-ctx-fs-item:hover { background: rgba(255,255,255,0.1); }
326
+ .dc-annot-ctx-fs-item[aria-checked="true"] { background: rgba(255,255,255,0.18); color: #fff; }
327
+ .dc-annot-ctx-fs-px { opacity: 0.5; font-variant-numeric: tabular-nums; }
328
+ .dc-annot-ctx-fs-input {
329
+ margin-top: 3px;
330
+ width: 100%;
331
+ box-sizing: border-box;
332
+ background: rgba(255,255,255,0.08);
333
+ border: 1px solid rgba(255,255,255,0.14);
334
+ border-radius: 6px;
335
+ padding: 4px 8px;
336
+ color: #fff;
337
+ font: inherit;
338
+ font-size: 12px;
339
+ }
340
+ .dc-annot-ctx-fs-input:focus-visible { outline: 2px solid #fff; outline-offset: -1px; }
193
341
  @media (prefers-reduced-motion: reduce) {
194
342
  .dc-annot-ctx-ibtn, .dc-annot-ctx-sw { transition: none; }
195
343
  }
@@ -269,14 +417,17 @@ export function AnnotationContextToolbar() {
269
417
  const fontSizeApplicable = selectedStrokes.some(
270
418
  (s) => s.tool === 'text' || s.tool === 'sticky'
271
419
  );
272
- const allRectOrSticky = selectedStrokes.every((s) => s.tool === 'rect' || s.tool === 'sticky');
420
+ // Phase 24 the corner-radius control is rect-only now. Stickies have a
421
+ // fixed soft radius (no switch); the sticky-color swatch row is handled
422
+ // separately below.
423
+ const allRect = selectedStrokes.every((s) => s.tool === 'rect');
273
424
  const allArrow = selectedStrokes.every((s) => s.tool === 'arrow');
274
425
  return {
275
426
  color: true,
276
427
  fill: allFillable,
277
428
  thickness: allThickness,
278
429
  fontSize: fontSizeApplicable,
279
- cornerRadius: allRectOrSticky,
430
+ cornerRadius: allRect,
280
431
  arrowDir: allArrow,
281
432
  dash: allArrow,
282
433
  };
@@ -371,6 +522,40 @@ export function AnnotationContextToolbar() {
371
522
  },
372
523
  [store, selectedStrokes]
373
524
  );
525
+ // Phase 24 — bold / strike / align on text + sticky bodies.
526
+ const setBold = useCallback(
527
+ (bold: boolean) => {
528
+ if (!store) return;
529
+ for (const s of selectedStrokes) {
530
+ if (s.tool === 'text' || s.tool === 'sticky') {
531
+ store.updateStroke(s.id, { bold } as Partial<Stroke>);
532
+ }
533
+ }
534
+ },
535
+ [store, selectedStrokes]
536
+ );
537
+ const setStrike = useCallback(
538
+ (strike: boolean) => {
539
+ if (!store) return;
540
+ for (const s of selectedStrokes) {
541
+ if (s.tool === 'text' || s.tool === 'sticky') {
542
+ store.updateStroke(s.id, { strike } as Partial<Stroke>);
543
+ }
544
+ }
545
+ },
546
+ [store, selectedStrokes]
547
+ );
548
+ const setAlign = useCallback(
549
+ (align: TextAlign) => {
550
+ if (!store) return;
551
+ for (const s of selectedStrokes) {
552
+ if (s.tool === 'text' || s.tool === 'sticky') {
553
+ store.updateStroke(s.id, { align } as Partial<Stroke>);
554
+ }
555
+ }
556
+ },
557
+ [store, selectedStrokes]
558
+ );
374
559
  // Phase 21 — corner radius (rect + sticky).
375
560
  const setCornerRadius = useCallback(
376
561
  (r: number) => {
@@ -383,14 +568,31 @@ export function AnnotationContextToolbar() {
383
568
  },
384
569
  [store, selectedStrokes]
385
570
  );
386
- // Phase 21arrow head direction (None / Start / End / Both).
387
- const setArrowDir = useCallback(
388
- (startHead: 'none' | 'triangle', endHead: 'none' | 'triangle') => {
571
+ // Phase 24per-end arrowhead + line-type (replaces the 4 fixed direction
572
+ // presets with two head dropdowns + a routing dropdown).
573
+ const setStartHead = useCallback(
574
+ (startHead: ArrowHead) => {
389
575
  if (!store) return;
390
576
  for (const s of selectedStrokes) {
391
- if (s.tool === 'arrow') {
392
- store.updateStroke(s.id, { startHead, endHead } as Partial<Stroke>);
393
- }
577
+ if (s.tool === 'arrow') store.updateStroke(s.id, { startHead } as Partial<Stroke>);
578
+ }
579
+ },
580
+ [store, selectedStrokes]
581
+ );
582
+ const setEndHead = useCallback(
583
+ (endHead: ArrowHead) => {
584
+ if (!store) return;
585
+ for (const s of selectedStrokes) {
586
+ if (s.tool === 'arrow') store.updateStroke(s.id, { endHead } as Partial<Stroke>);
587
+ }
588
+ },
589
+ [store, selectedStrokes]
590
+ );
591
+ const setLineType = useCallback(
592
+ (lineType: ArrowLineType) => {
593
+ if (!store) return;
594
+ for (const s of selectedStrokes) {
595
+ if (s.tool === 'arrow') store.updateStroke(s.id, { lineType } as Partial<Stroke>);
394
596
  }
395
597
  },
396
598
  [store, selectedStrokes]
@@ -440,6 +642,32 @@ export function AnnotationContextToolbar() {
440
642
  )
441
643
  )
442
644
  : undefined;
645
+ // Phase 24 — uniform bold / strike / align across the text-bearing selection.
646
+ // Align default differs per kind: anchored text = centre, standalone + sticky
647
+ // = left (matches the serializer defaults so the active-state reads true).
648
+ const uniqBold = caps.fontSize
649
+ ? uniformValue(
650
+ selectedStrokes.map((s) =>
651
+ s.tool === 'text' || s.tool === 'sticky' ? !!s.bold : undefined
652
+ )
653
+ )
654
+ : undefined;
655
+ const uniqStrike = caps.fontSize
656
+ ? uniformValue(
657
+ selectedStrokes.map((s) =>
658
+ s.tool === 'text' || s.tool === 'sticky' ? !!s.strike : undefined
659
+ )
660
+ )
661
+ : undefined;
662
+ const uniqAlign = caps.fontSize
663
+ ? uniformValue(
664
+ selectedStrokes.map((s) => {
665
+ if (s.tool === 'sticky') return s.align ?? 'left';
666
+ if (s.tool === 'text') return s.align ?? (s.anchorId ? 'center' : 'left');
667
+ return undefined;
668
+ })
669
+ )
670
+ : undefined;
443
671
  // Phase 21 — uniform corner radius across rect/sticky (default per type: rect
444
672
  // sharp = 0, sticky soft = 8).
445
673
  const uniqRadius = caps.cornerRadius
@@ -464,6 +692,11 @@ export function AnnotationContextToolbar() {
464
692
  selectedStrokes.map((s) => (s.tool === 'arrow' ? (s.endHead ?? 'triangle') : undefined))
465
693
  )
466
694
  : undefined;
695
+ const uniqLineType = caps.arrowDir
696
+ ? uniformValue(
697
+ selectedStrokes.map((s) => (s.tool === 'arrow' ? (s.lineType ?? 'straight') : undefined))
698
+ )
699
+ : undefined;
467
700
  const uniqDashed = caps.dash
468
701
  ? uniformValue(
469
702
  selectedStrokes.map((s) => (s.tool === 'arrow' ? (s.dashed ?? false) : undefined))
@@ -474,10 +707,21 @@ export function AnnotationContextToolbar() {
474
707
  // useEffect below could call setSwatchMode('stroke') but reading the
475
708
  // effective mode inline avoids an extra render cycle.
476
709
  const effectiveMode: SwatchMode = caps.fill ? swatchMode : 'stroke';
477
- const showPalette = effectiveMode === 'stroke' ? STROKE_PALETTE : FILL_PALETTE;
710
+ // Phase 24 a sticky selection picks its PAPER TINT, not stroke ink. The
711
+ // pre-Phase-24 toolbar showed STROKE_PALETTE for stickies (a latent bug):
712
+ // sticky has no stroke, so its swatch must paint the muted STICKY_PALETTE and
713
+ // write to `color` (the paper tint). No Stroke|Fill toggle for sticky
714
+ // (caps.fill is already false).
715
+ const allSticky = selectedStrokes.every((s) => s.tool === 'sticky');
716
+ const showPalette = allSticky
717
+ ? STICKY_PALETTE
718
+ : effectiveMode === 'stroke'
719
+ ? STROKE_PALETTE
720
+ : FILL_PALETTE;
478
721
  const onSwatchClick =
479
- effectiveMode === 'stroke' ? (c: string) => setColor(c) : (c: string) => setFill(c);
480
- const activeValue = effectiveMode === 'stroke' ? uniqColor : (uniqFill ?? null);
722
+ !allSticky && effectiveMode === 'fill' ? (c: string) => setFill(c) : (c: string) => setColor(c);
723
+ const activeValue = allSticky || effectiveMode === 'stroke' ? uniqColor : (uniqFill ?? null);
724
+ const swatchKind = allSticky ? 'Sticky color' : effectiveMode === 'stroke' ? 'Color' : 'Fill';
481
725
 
482
726
  return (
483
727
  <div
@@ -526,9 +770,9 @@ export function AnnotationContextToolbar() {
526
770
  key={c}
527
771
  type="button"
528
772
  className="dc-annot-ctx-sw"
529
- aria-label={`${effectiveMode === 'stroke' ? 'Color' : 'Fill'} ${c}`}
773
+ aria-label={`${swatchKind} ${c}`}
530
774
  aria-pressed={activeValue === c}
531
- title={`${effectiveMode === 'stroke' ? 'Color' : 'Fill'} ${c}`}
775
+ title={`${swatchKind} ${c}`}
532
776
  style={{ background: c }}
533
777
  onClick={() => onSwatchClick(c)}
534
778
  />
@@ -562,36 +806,37 @@ export function AnnotationContextToolbar() {
562
806
  {caps.fontSize ? (
563
807
  <>
564
808
  <div className="dc-annot-ctx-sep" />
809
+ <FontSizeDropdown value={uniqFontSize} onPick={setFontSize} />
565
810
  <button
566
811
  type="button"
567
812
  className="dc-annot-ctx-ibtn"
568
- aria-label="Small text"
569
- aria-pressed={uniqFontSize === 12}
570
- title="Small (12px)"
571
- onClick={() => setFontSize(12)}
572
- >
573
- <IconLetterA size={13} />
574
- </button>
575
- <button
576
- type="button"
577
- className="dc-annot-ctx-ibtn"
578
- aria-label="Medium text"
579
- aria-pressed={uniqFontSize === 14}
580
- title="Medium (14px)"
581
- onClick={() => setFontSize(14)}
813
+ aria-label="Bold"
814
+ aria-pressed={uniqBold === true}
815
+ title="Bold"
816
+ onClick={() => setBold(!(uniqBold === true))}
582
817
  >
583
- <IconLetterA size={16} />
818
+ <IconBold />
584
819
  </button>
585
820
  <button
586
821
  type="button"
587
822
  className="dc-annot-ctx-ibtn"
588
- aria-label="Large text"
589
- aria-pressed={uniqFontSize === 20}
590
- title="Large (20px)"
591
- onClick={() => setFontSize(20)}
823
+ aria-label="Strikethrough"
824
+ aria-pressed={uniqStrike === true}
825
+ title="Strikethrough"
826
+ onClick={() => setStrike(!(uniqStrike === true))}
592
827
  >
593
- <IconLetterA size={19} />
828
+ <IconStrike />
594
829
  </button>
830
+ <IconDropdown
831
+ ariaLabel="Text alignment"
832
+ value={uniqAlign ?? 'left'}
833
+ options={ALIGN_OPTIONS}
834
+ renderIcon={(v) => {
835
+ const I = ALIGN_ICON[v as TextAlign];
836
+ return I ? <I size={16} /> : null;
837
+ }}
838
+ onPick={(v) => setAlign(v as TextAlign)}
839
+ />
595
840
  </>
596
841
  ) : null}
597
842
  {caps.cornerRadius ? (
@@ -632,46 +877,36 @@ export function AnnotationContextToolbar() {
632
877
  {caps.arrowDir ? (
633
878
  <>
634
879
  <div className="dc-annot-ctx-sep" />
635
- <button
636
- type="button"
637
- className="dc-annot-ctx-ibtn"
638
- aria-label="Line, no arrowheads"
639
- aria-pressed={uniqStartHead === 'none' && uniqEndHead === 'none'}
640
- title="Line (no heads)"
641
- onClick={() => setArrowDir('none', 'none')}
642
- >
643
- <IconArrowNone />
644
- </button>
645
- <button
646
- type="button"
647
- className="dc-annot-ctx-ibtn"
648
- aria-label="Arrowhead at start"
649
- aria-pressed={uniqStartHead === 'triangle' && uniqEndHead === 'none'}
650
- title="Head at start"
651
- onClick={() => setArrowDir('triangle', 'none')}
652
- >
653
- <IconArrowStartHead />
654
- </button>
655
- <button
656
- type="button"
657
- className="dc-annot-ctx-ibtn"
658
- aria-label="Arrowhead at end"
659
- aria-pressed={uniqStartHead === 'none' && uniqEndHead === 'triangle'}
660
- title="Head at end"
661
- onClick={() => setArrowDir('none', 'triangle')}
662
- >
663
- <IconArrowEndHead />
664
- </button>
665
- <button
666
- type="button"
667
- className="dc-annot-ctx-ibtn"
668
- aria-label="Arrowheads at both ends"
669
- aria-pressed={uniqStartHead === 'triangle' && uniqEndHead === 'triangle'}
670
- title="Heads at both ends"
671
- onClick={() => setArrowDir('triangle', 'triangle')}
672
- >
673
- <IconArrowBothHeads />
674
- </button>
880
+ <IconDropdown
881
+ ariaLabel="Start arrowhead"
882
+ value={uniqStartHead ?? 'none'}
883
+ options={HEAD_OPTIONS}
884
+ renderIcon={(v) => {
885
+ const I = HEAD_ICON[v as ArrowHead];
886
+ return I ? <I size={16} /> : null;
887
+ }}
888
+ onPick={(v) => setStartHead(v as ArrowHead)}
889
+ />
890
+ <IconDropdown
891
+ ariaLabel="End arrowhead"
892
+ value={uniqEndHead ?? 'triangle'}
893
+ options={HEAD_OPTIONS}
894
+ renderIcon={(v) => {
895
+ const I = HEAD_ICON[v as ArrowHead];
896
+ return I ? <I size={16} /> : null;
897
+ }}
898
+ onPick={(v) => setEndHead(v as ArrowHead)}
899
+ />
900
+ <IconDropdown
901
+ ariaLabel="Line type"
902
+ value={uniqLineType ?? 'straight'}
903
+ options={LINETYPE_OPTIONS}
904
+ renderIcon={(v) => {
905
+ const I = LINETYPE_ICON[v as ArrowLineType];
906
+ return I ? <I size={16} /> : null;
907
+ }}
908
+ onPick={(v) => setLineType(v as ArrowLineType)}
909
+ />
675
910
  </>
676
911
  ) : null}
677
912
  {caps.dash ? (
@@ -704,6 +939,174 @@ export function AnnotationContextToolbar() {
704
939
  }
705
940
  AnnotationContextToolbar.displayName = 'AnnotationContextToolbar';
706
941
 
942
+ /**
943
+ * Phase 24 — a compact icon dropdown for the context toolbar (arrowhead per
944
+ * end, line-type). Trigger shows the current value's icon + a caret; the menu
945
+ * is a single row of icon-only radio items. Closes on outside-pointerdown or
946
+ * Escape. Reuses the existing `.dc-annot-ctx-ibtn` button styling.
947
+ */
948
+ function IconDropdown({
949
+ ariaLabel,
950
+ value,
951
+ options,
952
+ renderIcon,
953
+ onPick,
954
+ }: {
955
+ ariaLabel: string;
956
+ value: string;
957
+ options: ReadonlyArray<{ value: string; label: string }>;
958
+ renderIcon: (v: string) => ReactNode;
959
+ onPick: (v: string) => void;
960
+ }) {
961
+ const [open, setOpen] = useState(false);
962
+ const ref = useRef<HTMLDivElement | null>(null);
963
+ useEffect(() => {
964
+ if (!open) return;
965
+ const onDown = (e: PointerEvent) => {
966
+ if (!ref.current?.contains(e.target as Node)) setOpen(false);
967
+ };
968
+ const onKey = (e: KeyboardEvent) => {
969
+ if (e.key === 'Escape') setOpen(false);
970
+ };
971
+ document.addEventListener('pointerdown', onDown, true);
972
+ document.addEventListener('keydown', onKey, true);
973
+ return () => {
974
+ document.removeEventListener('pointerdown', onDown, true);
975
+ document.removeEventListener('keydown', onKey, true);
976
+ };
977
+ }, [open]);
978
+ const current = options.find((o) => o.value === value);
979
+ return (
980
+ <div ref={ref} className="dc-annot-ctx-dd">
981
+ <button
982
+ type="button"
983
+ className="dc-annot-ctx-ibtn dc-annot-ctx-dd-trigger"
984
+ aria-haspopup="menu"
985
+ aria-expanded={open}
986
+ aria-label={`${ariaLabel}: ${current?.label ?? value}`}
987
+ title={ariaLabel}
988
+ onClick={() => setOpen((o) => !o)}
989
+ >
990
+ {renderIcon(value)}
991
+ <span className="dc-annot-ctx-dd-caret" aria-hidden="true">
992
+ <IconChevronDown size={8} />
993
+ </span>
994
+ </button>
995
+ {open ? (
996
+ <div className="dc-annot-ctx-menu" role="menu" aria-label={ariaLabel}>
997
+ {options.map((o) => (
998
+ <button
999
+ key={o.value}
1000
+ type="button"
1001
+ role="menuitemradio"
1002
+ aria-checked={o.value === value}
1003
+ aria-label={o.label}
1004
+ title={o.label}
1005
+ className="dc-annot-ctx-ibtn"
1006
+ onClick={() => {
1007
+ onPick(o.value);
1008
+ setOpen(false);
1009
+ }}
1010
+ >
1011
+ {renderIcon(o.value)}
1012
+ </button>
1013
+ ))}
1014
+ </div>
1015
+ ) : null}
1016
+ </div>
1017
+ );
1018
+ }
1019
+
1020
+ /**
1021
+ * Phase 24 — font-size control: named presets (Small → Huge) + a numeric input
1022
+ * for an arbitrary px value (clamped 8–200, applied on Enter / blur). The
1023
+ * trigger shows the current size, or "—" when the selection is mixed.
1024
+ */
1025
+ function FontSizeDropdown({
1026
+ value,
1027
+ onPick,
1028
+ }: {
1029
+ value: number | undefined;
1030
+ onPick: (n: number) => void;
1031
+ }) {
1032
+ const [open, setOpen] = useState(false);
1033
+ const ref = useRef<HTMLDivElement | null>(null);
1034
+ useEffect(() => {
1035
+ if (!open) return;
1036
+ const onDown = (e: PointerEvent) => {
1037
+ if (!ref.current?.contains(e.target as Node)) setOpen(false);
1038
+ };
1039
+ const onKey = (e: KeyboardEvent) => {
1040
+ if (e.key === 'Escape') setOpen(false);
1041
+ };
1042
+ document.addEventListener('pointerdown', onDown, true);
1043
+ document.addEventListener('keydown', onKey, true);
1044
+ return () => {
1045
+ document.removeEventListener('pointerdown', onDown, true);
1046
+ document.removeEventListener('keydown', onKey, true);
1047
+ };
1048
+ }, [open]);
1049
+ const applyInput = (raw: string) => {
1050
+ const n = Math.round(Number(raw));
1051
+ if (Number.isFinite(n) && n >= FONT_SIZE_MIN && n <= FONT_SIZE_MAX) onPick(n);
1052
+ };
1053
+ return (
1054
+ <div ref={ref} className="dc-annot-ctx-dd">
1055
+ <button
1056
+ type="button"
1057
+ className="dc-annot-ctx-ibtn dc-annot-ctx-fs-trigger"
1058
+ aria-haspopup="menu"
1059
+ aria-expanded={open}
1060
+ aria-label={`Font size: ${value != null ? `${value} pixels` : 'mixed'}`}
1061
+ title="Font size"
1062
+ onClick={() => setOpen((o) => !o)}
1063
+ >
1064
+ <span className="dc-annot-ctx-fs-val">{value != null ? value : '—'}</span>
1065
+ <span className="dc-annot-ctx-dd-caret" aria-hidden="true">
1066
+ <IconChevronDown size={8} />
1067
+ </span>
1068
+ </button>
1069
+ {open ? (
1070
+ <div className="dc-annot-ctx-fs-menu" role="menu" aria-label="Font size">
1071
+ {FONT_SIZE_PRESETS.map((p) => (
1072
+ <button
1073
+ key={p.px}
1074
+ type="button"
1075
+ role="menuitemradio"
1076
+ aria-checked={value === p.px}
1077
+ className="dc-annot-ctx-fs-item"
1078
+ onClick={() => {
1079
+ onPick(p.px);
1080
+ setOpen(false);
1081
+ }}
1082
+ >
1083
+ <span>{p.label}</span>
1084
+ <span className="dc-annot-ctx-fs-px">{p.px}</span>
1085
+ </button>
1086
+ ))}
1087
+ <input
1088
+ key={value ?? 'mixed'}
1089
+ className="dc-annot-ctx-fs-input"
1090
+ type="number"
1091
+ min={FONT_SIZE_MIN}
1092
+ max={FONT_SIZE_MAX}
1093
+ defaultValue={value ?? ''}
1094
+ aria-label="Custom font size in pixels"
1095
+ onKeyDown={(e) => {
1096
+ if (e.key === 'Enter') {
1097
+ e.preventDefault();
1098
+ applyInput((e.target as HTMLInputElement).value);
1099
+ setOpen(false);
1100
+ }
1101
+ }}
1102
+ onBlur={(e) => applyInput(e.target.value)}
1103
+ />
1104
+ </div>
1105
+ ) : null}
1106
+ </div>
1107
+ );
1108
+ }
1109
+
707
1110
  function uniformValue<T>(values: (T | undefined)[]): T | undefined {
708
1111
  const filtered = values.filter((v) => v !== undefined) as T[];
709
1112
  if (filtered.length === 0) return undefined;