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.
- package/.github/extensions/pmx-canvas/extension.mjs +79 -61
- package/CHANGELOG.md +29 -1
- package/Readme.md +48 -9
- package/dist/canvas/global.css +144 -45
- package/dist/canvas/index.js +90 -90
- package/dist/json-render/index.js +55 -55
- package/dist/types/client/canvas/ChoiceMenu.d.ts +13 -0
- package/dist/types/client/canvas/ExternalWriters.d.ts +3 -3
- package/dist/types/client/icons.d.ts +15 -0
- package/dist/types/client/nodes/ExtAppFrame.d.ts +1 -1
- package/dist/types/client/nodes/FileNode.d.ts +0 -12
- package/dist/types/client/nodes/McpAppNode.d.ts +2 -1
- package/dist/types/client/nodes/viewer-runtime-state.d.ts +1 -0
- package/dist/types/client/state/canvas-store.d.ts +2 -0
- package/dist/types/client/state/intent-bridge.d.ts +1 -1
- package/dist/types/json-render/renderer/index.d.ts +2 -1
- package/dist/types/json-render/server.d.ts +2 -0
- package/dist/types/shared/ax-surface-protocol.d.ts +2 -0
- package/dist/types/shared/delimited-text.d.ts +14 -0
- package/docs/ax-host-adapter-contract.md +13 -2
- package/docs/cli.md +9 -2
- package/docs/http-api.md +12 -8
- package/docs/mcp.md +8 -1
- package/docs/sdk.md +2 -2
- package/package.json +1 -1
- package/skills/pmx-canvas/SKILL.md +41 -16
- package/skills/pmx-canvas/evals/evals.json +112 -2
- package/skills/pmx-canvas/references/full-reference.md +9 -4
- package/skills/pmx-canvas/references/github-copilot-app-adapter.md +17 -3
- package/skills/pmx-canvas/references/installing-pmx-canvas.md +76 -4
- package/skills/pmx-canvas-testing/SKILL.md +49 -0
- package/src/cli/commands/backup.ts +15 -4
- package/src/cli/commands/boards.ts +16 -3
- package/src/client/canvas/AttentionHistory.tsx +2 -1
- package/src/client/canvas/BoardSwitcher.tsx +3 -3
- package/src/client/canvas/CanvasNode.tsx +211 -197
- package/src/client/canvas/CanvasViewport.tsx +6 -0
- package/src/client/canvas/ChoiceMenu.tsx +122 -0
- package/src/client/canvas/CommandBar.tsx +27 -25
- package/src/client/canvas/ContextPinBar.tsx +3 -1
- package/src/client/canvas/ExpandedNodeOverlay.tsx +3 -3
- package/src/client/canvas/ExternalWriters.tsx +4 -3
- package/src/client/canvas/FeedbackDialog.tsx +12 -9
- package/src/client/canvas/IntentLayer.tsx +2 -2
- package/src/client/canvas/SelectionBar.tsx +2 -2
- package/src/client/canvas/SessionPanel.tsx +6 -4
- package/src/client/canvas/SessionReceipt.tsx +2 -1
- package/src/client/canvas/SnapshotPanel.tsx +3 -2
- package/src/client/canvas/ToolRail.tsx +48 -100
- package/src/client/canvas/TopBar.tsx +2 -2
- package/src/client/icons.tsx +118 -1
- package/src/client/nodes/BoardNode.tsx +2 -1
- package/src/client/nodes/ExtAppFrame.tsx +3 -1
- package/src/client/nodes/FileNode.tsx +4 -78
- package/src/client/nodes/ImageNode.tsx +2 -1
- package/src/client/nodes/MarkdownNode.tsx +2 -1
- package/src/client/nodes/McpAppNode.tsx +16 -0
- package/src/client/nodes/PromptNode.tsx +3 -1
- package/src/client/nodes/use-ax-surface-bridge.ts +4 -2
- package/src/client/nodes/viewer-runtime-state.ts +78 -0
- package/src/client/state/canvas-store.ts +3 -0
- package/src/client/state/intent-bridge.ts +14 -2
- package/src/client/theme/global.css +144 -45
- package/src/json-render/renderer/index.tsx +58 -38
- package/src/json-render/server.ts +8 -1
- package/src/mcp/server.ts +2 -2
- package/src/server/agent-presence.ts +17 -11
- package/src/server/board-export.ts +97 -32
- package/src/server/index.ts +9 -7
- package/src/server/operations/composites.ts +3 -2
- package/src/server/operations/ops/ax-presence.ts +7 -3
- package/src/server/operations/ops/boards.ts +12 -5
- package/src/server/operations/registry.ts +4 -13
- package/src/server/server.ts +13 -4
- package/src/shared/ax-surface-protocol.ts +3 -0
- 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 {
|
|
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
|
-
|
|
510
|
-
|
|
511
|
-
<
|
|
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
|
|
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="
|
|
678
|
+
<span class="node-title" title={title}>
|
|
526
679
|
{title}
|
|
527
680
|
</span>
|
|
528
681
|
)}
|
|
529
|
-
<
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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
|
-
<
|
|
581
|
-
</
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
630
|
-
addContextPins(groupChildren);
|
|
721
|
+
expandNode(node.id);
|
|
631
722
|
}}
|
|
723
|
+
title="Expand (focus mode)"
|
|
632
724
|
>
|
|
633
|
-
|
|
725
|
+
<IconExpand size={14} />
|
|
634
726
|
</button>
|
|
635
|
-
|
|
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
|
-
|
|
732
|
+
toggleCollapsed(node.id);
|
|
698
733
|
}}
|
|
699
|
-
title=
|
|
734
|
+
title={node.collapsed ? 'Expand' : 'Collapse'}
|
|
700
735
|
>
|
|
701
|
-
|
|
736
|
+
{node.collapsed ? <IconChevronRight size={14} /> : <IconChevronDown size={14} />}
|
|
702
737
|
</button>
|
|
703
|
-
|
|
704
|
-
|
|
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
|
-
|
|
744
|
+
void removeNodeFromClient(node.id);
|
|
711
745
|
}}
|
|
712
|
-
title="
|
|
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
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
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
|
-
|
|
751
|
-
|
|
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
|
+
}
|