pmx-canvas 0.7.1 → 0.7.3

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 (76) hide show
  1. package/.github/extensions/pmx-canvas/extension.mjs +79 -61
  2. package/CHANGELOG.md +29 -1
  3. package/Readme.md +48 -9
  4. package/dist/canvas/global.css +144 -45
  5. package/dist/canvas/index.js +90 -90
  6. package/dist/json-render/index.js +55 -55
  7. package/dist/types/client/canvas/ChoiceMenu.d.ts +13 -0
  8. package/dist/types/client/canvas/ExternalWriters.d.ts +3 -3
  9. package/dist/types/client/icons.d.ts +15 -0
  10. package/dist/types/client/nodes/ExtAppFrame.d.ts +1 -1
  11. package/dist/types/client/nodes/FileNode.d.ts +0 -12
  12. package/dist/types/client/nodes/McpAppNode.d.ts +2 -1
  13. package/dist/types/client/nodes/viewer-runtime-state.d.ts +1 -0
  14. package/dist/types/client/state/canvas-store.d.ts +2 -0
  15. package/dist/types/client/state/intent-bridge.d.ts +1 -1
  16. package/dist/types/json-render/renderer/index.d.ts +2 -1
  17. package/dist/types/json-render/server.d.ts +2 -0
  18. package/dist/types/shared/ax-surface-protocol.d.ts +2 -0
  19. package/dist/types/shared/delimited-text.d.ts +14 -0
  20. package/docs/ax-host-adapter-contract.md +13 -2
  21. package/docs/cli.md +9 -2
  22. package/docs/http-api.md +12 -8
  23. package/docs/mcp.md +8 -1
  24. package/docs/sdk.md +2 -2
  25. package/package.json +1 -1
  26. package/skills/pmx-canvas/SKILL.md +41 -16
  27. package/skills/pmx-canvas/evals/evals.json +112 -2
  28. package/skills/pmx-canvas/references/full-reference.md +9 -4
  29. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +17 -3
  30. package/skills/pmx-canvas/references/installing-pmx-canvas.md +76 -4
  31. package/skills/pmx-canvas-testing/SKILL.md +49 -0
  32. package/src/cli/commands/backup.ts +15 -4
  33. package/src/cli/commands/boards.ts +16 -3
  34. package/src/client/canvas/AttentionHistory.tsx +2 -1
  35. package/src/client/canvas/BoardSwitcher.tsx +3 -3
  36. package/src/client/canvas/CanvasNode.tsx +211 -197
  37. package/src/client/canvas/CanvasViewport.tsx +6 -0
  38. package/src/client/canvas/ChoiceMenu.tsx +122 -0
  39. package/src/client/canvas/CommandBar.tsx +27 -25
  40. package/src/client/canvas/ContextPinBar.tsx +3 -1
  41. package/src/client/canvas/ExpandedNodeOverlay.tsx +3 -3
  42. package/src/client/canvas/ExternalWriters.tsx +4 -3
  43. package/src/client/canvas/FeedbackDialog.tsx +12 -9
  44. package/src/client/canvas/IntentLayer.tsx +2 -2
  45. package/src/client/canvas/SelectionBar.tsx +2 -2
  46. package/src/client/canvas/SessionPanel.tsx +6 -4
  47. package/src/client/canvas/SessionReceipt.tsx +2 -1
  48. package/src/client/canvas/SnapshotPanel.tsx +3 -2
  49. package/src/client/canvas/ToolRail.tsx +48 -100
  50. package/src/client/canvas/TopBar.tsx +2 -2
  51. package/src/client/icons.tsx +118 -1
  52. package/src/client/nodes/BoardNode.tsx +2 -1
  53. package/src/client/nodes/ExtAppFrame.tsx +3 -1
  54. package/src/client/nodes/FileNode.tsx +4 -78
  55. package/src/client/nodes/ImageNode.tsx +2 -1
  56. package/src/client/nodes/MarkdownNode.tsx +2 -1
  57. package/src/client/nodes/McpAppNode.tsx +16 -0
  58. package/src/client/nodes/PromptNode.tsx +3 -1
  59. package/src/client/nodes/use-ax-surface-bridge.ts +4 -2
  60. package/src/client/nodes/viewer-runtime-state.ts +78 -0
  61. package/src/client/state/canvas-store.ts +3 -0
  62. package/src/client/state/intent-bridge.ts +14 -2
  63. package/src/client/theme/global.css +144 -45
  64. package/src/json-render/renderer/index.tsx +58 -38
  65. package/src/json-render/server.ts +8 -1
  66. package/src/mcp/server.ts +2 -2
  67. package/src/server/agent-presence.ts +17 -11
  68. package/src/server/board-export.ts +97 -32
  69. package/src/server/index.ts +9 -7
  70. package/src/server/operations/composites.ts +3 -2
  71. package/src/server/operations/ops/ax-presence.ts +7 -3
  72. package/src/server/operations/ops/boards.ts +12 -5
  73. package/src/server/operations/registry.ts +4 -13
  74. package/src/server/server.ts +13 -4
  75. package/src/shared/ax-surface-protocol.ts +3 -0
  76. package/src/shared/delimited-text.ts +75 -0
@@ -38,7 +38,16 @@ import { KIND_COLOR } from './kind-colors';
38
38
  import { reportHumanGrab, takeOverNode, yieldedNodes } from '../state/human-store';
39
39
  import { AxStepControls } from '../nodes/AxStepControls';
40
40
  import { canOpenAsSite, openNodeAsSite } from '../nodes/surface-url';
41
- import { getNodeIcon } from '../icons';
41
+ import {
42
+ getNodeIcon,
43
+ IconChevronRight,
44
+ IconChevronDown,
45
+ IconClose,
46
+ IconMore,
47
+ IconExternalLink,
48
+ IconExpand,
49
+ IconSparkle,
50
+ } from '../icons';
42
51
  import { EXPANDABLE_TYPES, TYPE_LABELS } from '../types';
43
52
  import type { CanvasNodeState } from '../types';
44
53
  import { AUTO_FIT_TITLEBAR_HEIGHT, computeAutoFitHeight, shouldAutoFitNode } from './auto-fit';
@@ -405,6 +414,7 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
405
414
  height: node.collapsed ? 'auto' : `${node.size.height}px`,
406
415
  zIndex: node.zIndex,
407
416
  '--node-chrome-scale': chromeScale.toFixed(3),
417
+ '--node-port-scale': (1 / viewportScale).toFixed(3),
408
418
  ...(groupColor ? { '--group-color': groupColor } : {}),
409
419
  };
410
420
  // Shimmer while an attached agent is mutating this node (rail-chrome-v2
@@ -472,7 +482,7 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
472
482
  toggleCollapsed(node.id);
473
483
  }}
474
484
  >
475
- ▸
485
+ <IconChevronRight size={14} />
476
486
  </button>
477
487
  <div class="group-chip-text">
478
488
  <div class="group-chip-name">{title}</div>
@@ -506,13 +516,156 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
506
516
  }}
507
517
  onDblClick={handleDblClick}
508
518
  >
509
- {isGroup && (
510
- <div class="node-titlebar group-edge-row" onPointerDown={handleTitlePointerDown}>
511
- <span class="group-name-pill">
519
+ <div class="node-content">
520
+ {isGroup && (
521
+ <div class="node-titlebar group-edge-row" onPointerDown={handleTitlePointerDown}>
522
+ <span class="group-name-pill">
523
+ {renaming ? (
524
+ <input
525
+ ref={renameRef}
526
+ class="node-title-input group-title-input"
527
+ value={title}
528
+ onBlur={(e) => commitRename((e.target as HTMLInputElement).value)}
529
+ onKeyDown={(e) => {
530
+ if (e.key === 'Enter') commitRename((e.target as HTMLInputElement).value);
531
+ if (e.key === 'Escape') setRenaming(false);
532
+ }}
533
+ onClick={(e) => e.stopPropagation()}
534
+ />
535
+ ) : (
536
+ <span class="group-name" title={title}>
537
+ {title}
538
+ </span>
539
+ )}
540
+ <span class="group-count">{groupChildren.length}</span>
541
+ </span>
542
+ <span class="group-edge-spacer" />
543
+ <span class="group-actions" role="toolbar" aria-label={`${title} actions`}>
544
+ <button
545
+ type="button"
546
+ class="group-action"
547
+ title="Auto-arrange children"
548
+ aria-label="Auto-arrange children"
549
+ disabled={groupChildren.length === 0}
550
+ onClick={(e) => {
551
+ e.stopPropagation();
552
+ void addToGroupFromClient(node.id, groupChildren, 'grid');
553
+ }}
554
+ >
555
+ <svg
556
+ width="11"
557
+ height="11"
558
+ viewBox="0 0 16 16"
559
+ fill="none"
560
+ stroke="currentColor"
561
+ stroke-width="1.5"
562
+ aria-hidden="true"
563
+ >
564
+ <rect x="1.5" y="1.5" width="5" height="5" rx="1" />
565
+ <rect x="9.5" y="1.5" width="5" height="5" rx="1" />
566
+ <rect x="1.5" y="9.5" width="5" height="5" rx="1" />
567
+ <rect x="9.5" y="9.5" width="5" height="5" rx="1" />
568
+ </svg>
569
+ </button>
570
+ <button
571
+ type="button"
572
+ class="group-action"
573
+ title="Collapse group"
574
+ aria-label="Collapse group"
575
+ onClick={(e) => {
576
+ e.stopPropagation();
577
+ toggleCollapsed(node.id);
578
+ }}
579
+ >
580
+ <svg
581
+ width="11"
582
+ height="11"
583
+ viewBox="0 0 16 16"
584
+ fill="none"
585
+ stroke="currentColor"
586
+ stroke-width="1.8"
587
+ stroke-linecap="round"
588
+ stroke-linejoin="round"
589
+ aria-hidden="true"
590
+ >
591
+ <path d="M3 10 L8 5 L13 10" />
592
+ </svg>
593
+ </button>
594
+ <button
595
+ type="button"
596
+ class="group-action"
597
+ title="Rename · Ungroup · Pin all to context"
598
+ aria-label="Group menu"
599
+ aria-expanded={groupMenuOpen}
600
+ onClick={(e) => {
601
+ e.stopPropagation();
602
+ setGroupMenuOpen((open) => !open);
603
+ }}
604
+ >
605
+ <IconMore size={14} />
606
+ </button>
607
+ {groupMenuOpen && (
608
+ <div class="group-menu" role="menu" onPointerDown={(e) => e.stopPropagation()}>
609
+ <button
610
+ type="button"
611
+ role="menuitem"
612
+ onClick={(e) => {
613
+ e.stopPropagation();
614
+ setGroupMenuOpen(false);
615
+ setRenaming(true);
616
+ requestAnimationFrame(() => renameRef.current?.focus());
617
+ }}
618
+ >
619
+ Rename
620
+ </button>
621
+ <button
622
+ type="button"
623
+ role="menuitem"
624
+ onClick={(e) => {
625
+ e.stopPropagation();
626
+ setGroupMenuOpen(false);
627
+ // Dissolve — the same op an agent's ungroup runs.
628
+ removeNode(node.id);
629
+ void ungroupFromClient(node.id);
630
+ }}
631
+ >
632
+ Ungroup
633
+ </button>
634
+ <button
635
+ type="button"
636
+ role="menuitem"
637
+ disabled={groupChildren.length === 0}
638
+ onClick={(e) => {
639
+ e.stopPropagation();
640
+ setGroupMenuOpen(false);
641
+ addContextPins(groupChildren);
642
+ }}
643
+ >
644
+ Pin all to context
645
+ </button>
646
+ </div>
647
+ )}
648
+ </span>
649
+ </div>
650
+ )}
651
+ {!isGroup && (
652
+ <div class="node-titlebar" onPointerDown={handleTitlePointerDown}>
653
+ <span class="node-type-icon" aria-hidden="true">
654
+ {(() => {
655
+ const NodeIcon = getNodeIcon(node.type);
656
+ return <NodeIcon size={Math.round(14 * chromeScale)} />;
657
+ })()}
658
+ </span>
659
+ {/* The kind-colored icon says what type this is (rail-chrome-v2 card
660
+ shell: icon · title · controls, no type badge). Only the AX status
661
+ chip joins it, and only when there is a status to show. */}
662
+ {typeof node.data.axWorkStatus === 'string' && (
663
+ <span class={`node-ax-status node-ax-status-${node.data.axWorkStatus}`}>{node.data.axWorkStatus}</span>
664
+ )}
512
665
  {renaming ? (
513
666
  <input
514
667
  ref={renameRef}
515
- class="node-title-input group-title-input"
668
+ class="node-title-input"
516
669
  value={title}
517
670
  onBlur={(e) => commitRename((e.target as HTMLInputElement).value)}
518
671
  onKeyDown={(e) => {
@@ -522,234 +675,93 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
522
675
  onClick={(e) => e.stopPropagation()}
523
676
  />
524
677
  ) : (
525
- <span class="group-name" title={title}>
678
+ <span class="node-title" title={title}>
526
679
  {title}
527
680
  </span>
528
681
  )}
529
- <span class="group-count">{groupChildren.length}</span>
530
- </span>
531
- <span class="group-edge-spacer" />
532
- <span class="group-actions" role="toolbar" aria-label={`${title} actions`}>
533
- <button
534
- type="button"
535
- class="group-action"
536
- title="Auto-arrange children"
537
- aria-label="Auto-arrange children"
538
- disabled={groupChildren.length === 0}
539
- onClick={(e) => {
540
- e.stopPropagation();
541
- void addToGroupFromClient(node.id, groupChildren, 'grid');
542
- }}
543
- >
544
- <svg
545
- width="11"
546
- height="11"
547
- viewBox="0 0 16 16"
548
- fill="none"
549
- stroke="currentColor"
550
- stroke-width="1.5"
551
- aria-hidden="true"
552
- >
553
- <rect x="1.5" y="1.5" width="5" height="5" rx="1" />
554
- <rect x="9.5" y="1.5" width="5" height="5" rx="1" />
555
- <rect x="1.5" y="9.5" width="5" height="5" rx="1" />
556
- <rect x="9.5" y="9.5" width="5" height="5" rx="1" />
557
- </svg>
558
- </button>
559
- <button
560
- type="button"
561
- class="group-action"
562
- title="Collapse group"
563
- aria-label="Collapse group"
564
- onClick={(e) => {
565
- e.stopPropagation();
566
- toggleCollapsed(node.id);
567
- }}
568
- >
569
- <svg
570
- width="11"
571
- height="11"
572
- viewBox="0 0 16 16"
573
- fill="none"
574
- stroke="currentColor"
575
- stroke-width="1.8"
576
- stroke-linecap="round"
577
- stroke-linejoin="round"
578
- aria-hidden="true"
682
+ <div class="node-controls">
683
+ {isPinned && (
684
+ <span class="pin-indicator" title="Pinned">
685
+ ⊙
686
+ </span>
687
+ )}
688
+ <button
689
+ type="button"
690
+ class={`ctx-pin-btn${isContextPinned ? ' ctx-pin-active' : ''}`}
691
+ onClick={(e) => {
692
+ e.stopPropagation();
693
+ toggleContextPin(node.id);
694
+ }}
695
+ title={isContextPinned ? 'Remove from context' : 'Add to context'}
579
696
  >
580
- <path d="M3 10 L8 5 L13 10" />
581
- </svg>
582
- </button>
583
- <button
584
- type="button"
585
- class="group-action"
586
- title="Rename · Ungroup · Pin all to context"
587
- aria-label="Group menu"
588
- aria-expanded={groupMenuOpen}
589
- onClick={(e) => {
590
- e.stopPropagation();
591
- setGroupMenuOpen((open) => !open);
592
- }}
593
- >
594
- ⋯
595
- </button>
596
- {groupMenuOpen && (
597
- <div class="group-menu" role="menu" onPointerDown={(e) => e.stopPropagation()}>
598
- <button
599
- type="button"
600
- role="menuitem"
601
- onClick={(e) => {
602
- e.stopPropagation();
603
- setGroupMenuOpen(false);
604
- setRenaming(true);
605
- requestAnimationFrame(() => renameRef.current?.focus());
606
- }}
607
- >
608
- Rename
609
- </button>
697
+ <IconSparkle size={14} />
698
+ </button>
699
+ {/* Open as site — full-page standalone view of this node's surface,
700
+ served from /api/canvas/surface/:id (same document as the canvas
701
+ iframe). Opens via the system browser so embedded hosts do not trap
702
+ it in their own webview. */}
703
+ {canOpenAsSite(node) && (
610
704
  <button
611
705
  type="button"
612
- role="menuitem"
613
706
  onClick={(e) => {
614
707
  e.stopPropagation();
615
- setGroupMenuOpen(false);
616
- // Dissolve — the same op an agent's ungroup runs.
617
- removeNode(node.id);
618
- void ungroupFromClient(node.id);
708
+ void openNodeAsSite(node);
619
709
  }}
710
+ title="Open as site"
620
711
  >
621
- Ungroup
712
+ <IconExternalLink size={14} />
622
713
  </button>
714
+ )}
715
+ {/* Expand — opens node as full-viewport overlay for focused work */}
716
+ {EXPANDABLE_TYPES.has(node.type) && (
623
717
  <button
624
718
  type="button"
625
- role="menuitem"
626
- disabled={groupChildren.length === 0}
627
719
  onClick={(e) => {
628
720
  e.stopPropagation();
629
- setGroupMenuOpen(false);
630
- addContextPins(groupChildren);
721
+ expandNode(node.id);
631
722
  }}
723
+ title="Expand (focus mode)"
632
724
  >
633
- Pin all to context
725
+ <IconExpand size={14} />
634
726
  </button>
635
- </div>
636
- )}
637
- </span>
638
- </div>
639
- )}
640
- {!isGroup && (
641
- <div class="node-titlebar" onPointerDown={handleTitlePointerDown}>
642
- <span class="node-type-icon" aria-hidden="true">
643
- {(() => {
644
- const NodeIcon = getNodeIcon(node.type);
645
- return <NodeIcon size={Math.round(14 * chromeScale)} />;
646
- })()}
647
- </span>
648
- {/* The kind-colored icon says what type this is (rail-chrome-v2 card
649
- shell: icon · title · controls, no type badge). Only the AX status
650
- chip joins it, and only when there is a status to show. */}
651
- {typeof node.data.axWorkStatus === 'string' && (
652
- <span class={`node-ax-status node-ax-status-${node.data.axWorkStatus}`}>{node.data.axWorkStatus}</span>
653
- )}
654
- {renaming ? (
655
- <input
656
- ref={renameRef}
657
- class="node-title-input"
658
- value={title}
659
- onBlur={(e) => commitRename((e.target as HTMLInputElement).value)}
660
- onKeyDown={(e) => {
661
- if (e.key === 'Enter') commitRename((e.target as HTMLInputElement).value);
662
- if (e.key === 'Escape') setRenaming(false);
663
- }}
664
- onClick={(e) => e.stopPropagation()}
665
- />
666
- ) : (
667
- <span class="node-title" title={title}>
668
- {title}
669
- </span>
670
- )}
671
- <div class="node-controls">
672
- {isPinned && (
673
- <span class="pin-indicator" title="Pinned">
674
- ⊙
675
- </span>
676
- )}
677
- <button
678
- type="button"
679
- class={`ctx-pin-btn${isContextPinned ? ' ctx-pin-active' : ''}`}
680
- onClick={(e) => {
681
- e.stopPropagation();
682
- toggleContextPin(node.id);
683
- }}
684
- title={isContextPinned ? 'Remove from context' : 'Add to context'}
685
- >
686
- {'\u2726'}
687
- </button>
688
- {/* Open as site — full-page standalone view of this node's surface,
689
- served from /api/canvas/surface/:id (same document as the canvas
690
- iframe). Opens via the system browser so embedded hosts do not trap
691
- it in their own webview. */}
692
- {canOpenAsSite(node) && (
727
+ )}
693
728
  <button
694
729
  type="button"
695
730
  onClick={(e) => {
696
731
  e.stopPropagation();
697
- void openNodeAsSite(node);
732
+ toggleCollapsed(node.id);
698
733
  }}
699
- title="Open as site"
734
+ title={node.collapsed ? 'Expand' : 'Collapse'}
700
735
  >
701
- ↗
736
+ {node.collapsed ? <IconChevronRight size={14} /> : <IconChevronDown size={14} />}
702
737
  </button>
703
- )}
704
- {/* Expand — opens node as full-viewport overlay for focused work */}
705
- {EXPANDABLE_TYPES.has(node.type) && (
738
+ {/* Report #64: status nodes get the same remove control as every other
739
+ node type (backend removal + undo/history handle status uniformly). */}
706
740
  <button
707
741
  type="button"
708
742
  onClick={(e) => {
709
743
  e.stopPropagation();
710
- expandNode(node.id);
744
+ void removeNodeFromClient(node.id);
711
745
  }}
712
- title="Expand (focus mode)"
746
+ title="Close"
713
747
  >
714
- ⤢
748
+ <IconClose size={14} />
715
749
  </button>
716
- )}
717
- <button
718
- type="button"
719
- onClick={(e) => {
720
- e.stopPropagation();
721
- toggleCollapsed(node.id);
722
- }}
723
- title={node.collapsed ? 'Expand' : 'Collapse'}
724
- >
725
- {node.collapsed ? '▸' : '▾'}
726
- </button>
727
- {/* Report #64: status nodes get the same remove control as every other
728
- node type (backend removal + undo/history handle status uniformly). */}
729
- <button
730
- type="button"
731
- onClick={(e) => {
732
- e.stopPropagation();
733
- void removeNodeFromClient(node.id);
734
- }}
735
- title="Close"
736
- >
737
- ×
738
- </button>
750
+ </div>
739
751
  </div>
740
- </div>
741
- )}
742
- {!node.collapsed && (
743
- <div ref={bodyRef} class="node-body">
744
- {children}
745
- {/* AX flow step controls — rendered here rather than in a node renderer
752
+ )}
753
+ {!node.collapsed && (
754
+ <div ref={bodyRef} class="node-body">
755
+ {children}
756
+ {/* AX flow step controls — rendered here rather than in a node renderer
746
757
  because a materialized step can be ANY node type, and because the
747
758
  body is what auto-fit measures (a footer outside it would be clipped
748
759
  on a content-fitted node). Renders nothing unless the node carries a
749
760
  `data.axStep` stamp. */}
750
- <AxStepControls node={node} />
751
- </div>
752
- )}
761
+ <AxStepControls node={node} />
762
+ </div>
763
+ )}
764
+ </div>
753
765
  {!node.collapsed && (
754
766
  <div class="node-resize-handle" onPointerDown={(e) => startResize(e, node.size.width, node.size.height)} />
755
767
  )}
@@ -772,8 +784,10 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
772
784
  key={side}
773
785
  class={`node-port node-port-${side}`}
774
786
  onPointerDown={(e) => {
787
+ if (e.button !== 0) return;
775
788
  e.stopPropagation();
776
789
  e.preventDefault();
790
+ e.currentTarget.setPointerCapture(e.pointerId);
777
791
  const cx = node.position.x + node.size.width / 2;
778
792
  const cy = node.position.y + node.size.height / 2;
779
793
  const hw = node.size.width / 2;
@@ -652,12 +652,18 @@ export function CanvasViewport({
652
652
  }
653
653
  }
654
654
 
655
+ function handleCancel() {
656
+ draggingEdge.value = null;
657
+ }
658
+
655
659
  document.addEventListener('pointermove', handleMove);
656
660
  document.addEventListener('pointerup', handleUp);
661
+ document.addEventListener('pointercancel', handleCancel);
657
662
  document.addEventListener('keydown', handleKey);
658
663
  return () => {
659
664
  document.removeEventListener('pointermove', handleMove);
660
665
  document.removeEventListener('pointerup', handleUp);
666
+ document.removeEventListener('pointercancel', handleCancel);
661
667
  document.removeEventListener('keydown', handleKey);
662
668
  };
663
669
  }, [containerRef]);
@@ -0,0 +1,122 @@
1
+ import { useLayoutEffect, useRef, useState } from 'preact/hooks';
2
+ import { IconChevronDown } from '../icons';
3
+
4
+ /** Themed single-choice menu shared by the feedback form and steering composer. */
5
+ export function ChoiceMenu({
6
+ label,
7
+ value,
8
+ options,
9
+ onChange,
10
+ className = '',
11
+ above = false,
12
+ }: {
13
+ label: string;
14
+ value: string;
15
+ options: Array<{ value: string; label: string; disabled?: boolean }>;
16
+ onChange: (value: string) => void;
17
+ className?: string;
18
+ above?: boolean;
19
+ }) {
20
+ const [open, setOpen] = useState(false);
21
+ const root = useRef<HTMLDivElement>(null);
22
+ const trigger = useRef<HTMLButtonElement>(null);
23
+ const menu = useRef<HTMLDivElement>(null);
24
+ const close = () => {
25
+ setOpen(false);
26
+ trigger.current?.focus();
27
+ };
28
+
29
+ useLayoutEffect(() => {
30
+ if (!open) return;
31
+ const selected = menu.current?.querySelector<HTMLButtonElement>('[aria-checked="true"]:not(:disabled)');
32
+ (selected ?? menu.current?.querySelector<HTMLButtonElement>('button:not(:disabled)'))?.focus();
33
+ const outside = (event: PointerEvent) => {
34
+ if (event.target instanceof Node && !root.current?.contains(event.target)) setOpen(false);
35
+ };
36
+ document.addEventListener('pointerdown', outside);
37
+ return () => document.removeEventListener('pointerdown', outside);
38
+ }, [open]);
39
+
40
+ return (
41
+ <div
42
+ ref={root}
43
+ class={`choice-menu ${className}`}
44
+ onBlur={(e) => {
45
+ if (e.relatedTarget instanceof Node && !root.current?.contains(e.relatedTarget)) setOpen(false);
46
+ }}
47
+ onKeyDown={(e) => {
48
+ // Menu typing/navigation must not trigger canvas tool shortcuts.
49
+ // Tab still reaches modal focus traps; closed Escape reaches the dialog.
50
+ if (e.key !== 'Tab' && (e.key !== 'Escape' || open)) e.stopPropagation();
51
+ if (e.key === 'Escape' && open) {
52
+ e.preventDefault();
53
+ e.stopPropagation();
54
+ close();
55
+ }
56
+ if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(e.key)) {
57
+ e.preventDefault();
58
+ e.stopPropagation();
59
+ if (!open) {
60
+ setOpen(true);
61
+ return;
62
+ }
63
+ const items = [...(menu.current?.querySelectorAll<HTMLButtonElement>('button:not(:disabled)') ?? [])];
64
+ if (!items.length) return;
65
+ const index = items.findIndex((item) => item === document.activeElement);
66
+ const next =
67
+ e.key === 'Home'
68
+ ? 0
69
+ : e.key === 'End'
70
+ ? items.length - 1
71
+ : (index + (e.key === 'ArrowUp' ? -1 : 1) + items.length) % items.length;
72
+ items[next]?.focus();
73
+ }
74
+ }}
75
+ >
76
+ <button
77
+ ref={trigger}
78
+ type="button"
79
+ class="choice-menu-trigger"
80
+ aria-label={label}
81
+ aria-haspopup="menu"
82
+ aria-expanded={open}
83
+ onClick={() => setOpen(!open)}
84
+ >
85
+ <span>{options.find((option) => option.value === value)?.label ?? label}</span>
86
+ <IconChevronDown size={14} />
87
+ </button>
88
+ {open && (
89
+ <div
90
+ ref={menu}
91
+ role="menu"
92
+ aria-label={label}
93
+ class={`toolbar-menu choice-menu-options${above ? ' opens-above' : ''}`}
94
+ >
95
+ {options.map((option) => (
96
+ <button
97
+ key={option.value}
98
+ type="button"
99
+ role="menuitemradio"
100
+ class={`toolbar-menu-item${option.value === value ? ' active' : ''}`}
101
+ aria-checked={option.value === value}
102
+ disabled={option.disabled}
103
+ tabIndex={-1}
104
+ data-value={option.value}
105
+ onClick={() => {
106
+ onChange(option.value);
107
+ close();
108
+ }}
109
+ >
110
+ <span>{option.label}</span>
111
+ {option.value === value && (
112
+ <span class="toolbar-menu-check" aria-hidden="true">
113
+ ✓
114
+ </span>
115
+ )}
116
+ </button>
117
+ ))}
118
+ </div>
119
+ )}
120
+ </div>
121
+ );
122
+ }