pmx-canvas 0.4.8 → 0.5.1
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 +154 -5
- package/.github/extensions/pmx-canvas/steering-delivery.mjs +79 -0
- package/CHANGELOG.md +156 -0
- package/Readme.md +72 -23
- package/dist/canvas/global.css +3071 -796
- package/dist/canvas/index.js +122 -104
- package/dist/json-render/index.css +1 -1
- package/dist/types/cli/agent.d.ts +1 -0
- package/dist/types/cli/commands/pump.d.ts +9 -0
- package/dist/types/client/App.d.ts +1 -1
- package/dist/types/client/canvas/AgentPresenceLayer.d.ts +11 -0
- package/dist/types/client/canvas/BarHint.d.ts +22 -0
- package/dist/types/client/canvas/CanvasNode.d.ts +5 -0
- package/dist/types/client/canvas/CanvasViewport.d.ts +4 -4
- package/dist/types/client/canvas/CommandBar.d.ts +10 -0
- package/dist/types/client/canvas/ConnectionBanner.d.ts +13 -0
- package/dist/types/client/canvas/ContextMenu.d.ts +6 -0
- package/dist/types/client/canvas/EdgeLayer.d.ts +2 -1
- package/dist/types/client/canvas/EmptyState.d.ts +11 -0
- package/dist/types/client/canvas/ExternalWriters.d.ts +5 -0
- package/dist/types/client/canvas/HumanPresenceLayer.d.ts +8 -0
- package/dist/types/client/canvas/Minimap.d.ts +12 -1
- package/dist/types/client/canvas/ScopeFenceLayer.d.ts +8 -0
- package/dist/types/client/canvas/SelectionBar.d.ts +6 -0
- package/dist/types/client/canvas/SessionPanel.d.ts +1 -0
- package/dist/types/client/canvas/SessionReceipt.d.ts +17 -0
- package/dist/types/client/canvas/TextPrompt.d.ts +25 -0
- package/dist/types/client/canvas/ToolRail.d.ts +21 -0
- package/dist/types/client/canvas/TopBar.d.ts +7 -0
- package/dist/types/client/canvas/canvas-area.d.ts +39 -0
- package/dist/types/client/canvas/create-in-view.d.ts +18 -0
- package/dist/types/client/canvas/import-files.d.ts +8 -0
- package/dist/types/client/canvas/kind-colors.d.ts +3 -0
- package/dist/types/client/canvas/use-focus-trap.d.ts +14 -0
- package/dist/types/client/canvas/use-now.d.ts +6 -0
- package/dist/types/client/icons.d.ts +11 -4
- package/dist/types/client/nodes/ExtAppFrame.d.ts +2 -0
- package/dist/types/client/nodes/GroupNode.d.ts +6 -1
- package/dist/types/client/nodes/MarkdownNode.d.ts +2 -0
- package/dist/types/client/nodes/inline-editor-commands.d.ts +3 -4
- package/dist/types/client/state/canvas-store.d.ts +49 -5
- package/dist/types/client/state/human-store.d.ts +26 -0
- package/dist/types/client/state/iframe-mode.d.ts +1 -0
- package/dist/types/client/state/intent-bridge.d.ts +37 -5
- package/dist/types/client/state/node-factory.d.ts +0 -1
- package/dist/types/client/state/presence-store.d.ts +78 -0
- package/dist/types/client/state/session-store.d.ts +176 -0
- package/dist/types/client/types.d.ts +2 -1
- package/dist/types/client/utils/platform.d.ts +2 -0
- package/dist/types/server/agent-presence.d.ts +192 -0
- package/dist/types/server/ax-gate-ttl.d.ts +6 -0
- package/dist/types/server/ax-interaction.d.ts +4 -10
- package/dist/types/server/ax-state-manager.d.ts +21 -2
- package/dist/types/server/ax-state.d.ts +49 -3
- package/dist/types/server/ax-wait.d.ts +13 -0
- package/dist/types/server/canvas-db.d.ts +9 -1
- package/dist/types/server/canvas-operations.d.ts +11 -0
- package/dist/types/server/canvas-state.d.ts +27 -13
- package/dist/types/server/canvas-validation.d.ts +0 -14
- package/dist/types/server/html-surface.d.ts +1 -1
- package/dist/types/server/human-presence.d.ts +40 -0
- package/dist/types/server/index.d.ts +29 -3
- package/dist/types/server/intent-registry.d.ts +7 -0
- package/dist/types/server/mutation-history.d.ts +15 -2
- package/dist/types/server/operations/invoker.d.ts +13 -2
- package/dist/types/server/operations/ops/ax-presence.d.ts +2 -0
- package/dist/types/server/operations/ops/human-presence.d.ts +3 -0
- package/dist/types/server/operations/registry.d.ts +11 -0
- package/dist/types/server/operations/types.d.ts +1 -1
- package/dist/types/server/scope-fence.d.ts +62 -0
- package/dist/types/server/server.d.ts +0 -12
- package/dist/types/shared/agent-presence.d.ts +130 -0
- package/dist/types/shared/approval-gates.d.ts +19 -0
- package/dist/types/shared/auto-arrange.d.ts +0 -1
- package/dist/types/shared/ax-kinds.d.ts +11 -0
- package/dist/types/shared/human-presence.d.ts +31 -0
- package/dist/types/shared/iframe-probe.d.ts +8 -0
- package/dist/types/shared/scope-fence.d.ts +21 -0
- package/docs/RELEASE.md +20 -0
- package/docs/ax-host-adapter-contract.md +30 -14
- package/docs/cli.md +37 -1
- package/docs/environment.md +3 -0
- package/docs/http-api.md +222 -5
- package/docs/mcp.md +6 -5
- package/docs/node-types.md +3 -1
- package/docs/plans/plan-009-tech-debt-backlog.md +13 -0
- package/docs/screenshot.png +0 -0
- package/docs/screenshots/multi-agent-live.png +0 -0
- package/docs/screenshots/workbench-live-dark.png +0 -0
- package/docs/screenshots/workbench-live-light.png +0 -0
- package/docs/sdk.md +1 -1
- package/package.json +4 -4
- package/skills/pmx-canvas/SKILL.md +112 -17
- package/skills/pmx-canvas/references/codex-app-adapter.md +28 -0
- package/skills/pmx-canvas/references/full-reference.md +55 -16
- package/skills/pmx-canvas/references/github-copilot-app-adapter.md +41 -24
- package/skills/pmx-canvas-orchestration/SKILL.md +56 -2
- package/skills/pmx-canvas-testing/SKILL.md +17 -0
- package/src/cli/agent.ts +1 -0
- package/src/cli/commands/copilot.ts +15 -8
- package/src/cli/commands/nodes.ts +1 -19
- package/src/cli/commands/pump.ts +263 -0
- package/src/cli/index.ts +2 -5
- package/src/cli/shared.ts +1 -1
- package/src/client/App.tsx +209 -585
- package/src/client/canvas/AgentPresenceLayer.tsx +74 -0
- package/src/client/canvas/AttentionHistory.tsx +0 -10
- package/src/client/canvas/BarHint.tsx +49 -0
- package/src/client/canvas/CanvasNode.tsx +488 -136
- package/src/client/canvas/CanvasViewport.tsx +190 -94
- package/src/client/canvas/CommandBar.tsx +140 -0
- package/src/client/canvas/CommandPalette.tsx +133 -58
- package/src/client/canvas/ConnectionBanner.tsx +65 -0
- package/src/client/canvas/ContextMenu.tsx +88 -27
- package/src/client/canvas/ContextPinBar.tsx +2 -2
- package/src/client/canvas/EdgeLayer.tsx +121 -38
- package/src/client/canvas/EmptyState.tsx +93 -0
- package/src/client/canvas/ExpandedNodeOverlay.tsx +64 -118
- package/src/client/canvas/ExternalWriters.tsx +308 -0
- package/src/client/canvas/FocusFieldLayer.tsx +12 -1
- package/src/client/canvas/HumanPresenceLayer.tsx +45 -0
- package/src/client/canvas/IntentLayer.tsx +1 -1
- package/src/client/canvas/Minimap.tsx +117 -190
- package/src/client/canvas/ScopeFenceLayer.tsx +38 -0
- package/src/client/canvas/SelectionBar.tsx +175 -23
- package/src/client/canvas/SessionPanel.tsx +481 -0
- package/src/client/canvas/SessionReceipt.tsx +141 -0
- package/src/client/canvas/ShortcutOverlay.tsx +21 -6
- package/src/client/canvas/SnapshotPanel.tsx +166 -80
- package/src/client/canvas/TextPrompt.tsx +128 -0
- package/src/client/canvas/ToolRail.tsx +447 -0
- package/src/client/canvas/TopBar.tsx +308 -0
- package/src/client/canvas/auto-fit.ts +1 -5
- package/src/client/canvas/canvas-area.ts +74 -0
- package/src/client/canvas/create-in-view.ts +48 -0
- package/src/client/canvas/import-files.ts +64 -0
- package/src/client/canvas/kind-colors.ts +22 -0
- package/src/client/canvas/snap-guides.ts +1 -1
- package/src/client/canvas/use-focus-trap.ts +54 -0
- package/src/client/canvas/use-now.ts +17 -0
- package/src/client/canvas/use-pan-zoom.ts +13 -2
- package/src/client/icons.tsx +57 -18
- package/src/client/nodes/ExtAppFrame.tsx +86 -20
- package/src/client/nodes/GroupNode.tsx +9 -28
- package/src/client/nodes/InlineFormatBar.tsx +4 -3
- package/src/client/nodes/InlineMarkdownEditor.tsx +27 -0
- package/src/client/nodes/MarkdownNode.tsx +59 -24
- package/src/client/nodes/MdFormatBar.tsx +2 -4
- package/src/client/nodes/inline-editor-commands.ts +17 -6
- package/src/client/nodes/use-surface-frame.ts +3 -1
- package/src/client/state/attention-bridge.ts +3 -0
- package/src/client/state/canvas-store.ts +285 -90
- package/src/client/state/human-store.ts +175 -0
- package/src/client/state/iframe-mode.ts +33 -9
- package/src/client/state/intent-bridge.ts +75 -13
- package/src/client/state/node-factory.ts +2 -8
- package/src/client/state/presence-store.ts +168 -0
- package/src/client/state/session-store.ts +419 -0
- package/src/client/state/sse-bridge.ts +93 -30
- package/src/client/theme/global.css +3071 -796
- package/src/client/types.ts +3 -1
- package/src/client/utils/platform.ts +6 -1
- package/src/mcp/canvas-access.ts +2 -2
- package/src/server/agent-presence.ts +755 -0
- package/src/server/ax-context.ts +45 -0
- package/src/server/ax-gate-ttl.ts +61 -0
- package/src/server/ax-interaction.ts +29 -1
- package/src/server/ax-state-manager.ts +78 -6
- package/src/server/ax-state.ts +82 -15
- package/src/server/ax-wait.ts +33 -0
- package/src/server/canvas-db.ts +103 -33
- package/src/server/canvas-operations.ts +58 -14
- package/src/server/canvas-state.ts +131 -58
- package/src/server/canvas-validation.ts +3 -53
- package/src/server/demo-state.json +2 -70
- package/src/server/html-surface.ts +11 -2
- package/src/server/human-presence.ts +160 -0
- package/src/server/index.ts +111 -6
- package/src/server/intent-registry.ts +13 -0
- package/src/server/mutation-history.ts +47 -2
- package/src/server/operations/composites.ts +6 -3
- package/src/server/operations/http.ts +10 -1
- package/src/server/operations/index.ts +4 -0
- package/src/server/operations/invoker.ts +33 -6
- package/src/server/operations/ops/ax-presence.ts +62 -0
- package/src/server/operations/ops/ax-read.ts +8 -0
- package/src/server/operations/ops/ax-state.ts +106 -1
- package/src/server/operations/ops/ax-timeline.ts +54 -5
- package/src/server/operations/ops/ax-work.ts +46 -0
- package/src/server/operations/ops/batch.ts +3 -1
- package/src/server/operations/ops/edges.ts +88 -2
- package/src/server/operations/ops/groups.ts +4 -3
- package/src/server/operations/ops/human-presence.ts +35 -0
- package/src/server/operations/ops/json-render.ts +11 -1
- package/src/server/operations/ops/nodes.ts +77 -16
- package/src/server/operations/ops/query.ts +1 -0
- package/src/server/operations/ops/snapshots.ts +7 -4
- package/src/server/operations/registry.ts +196 -1
- package/src/server/operations/types.ts +1 -1
- package/src/server/scope-fence.ts +207 -0
- package/src/server/server.ts +177 -79
- package/src/server/trace-manager.ts +0 -2
- package/src/server/web-artifacts.ts +0 -1
- package/src/shared/agent-presence.ts +162 -0
- package/src/shared/approval-gates.ts +35 -0
- package/src/shared/auto-arrange.ts +121 -13
- package/src/shared/ax-kinds.ts +23 -0
- package/src/shared/human-presence.ts +30 -0
- package/src/shared/iframe-probe.ts +8 -0
- package/src/shared/scope-fence.ts +31 -0
- package/dist/types/client/canvas/DockedNode.d.ts +0 -4
- package/src/client/canvas/DockedNode.tsx +0 -165
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import type { ComponentChildren } from 'preact';
|
|
2
|
+
import { useEffect, useState } from 'preact/hooks';
|
|
3
|
+
import { IconFitAll, IconZoomIn, IconZoomOut } from '../icons';
|
|
4
|
+
import {
|
|
5
|
+
animateViewport,
|
|
6
|
+
connectionStatus,
|
|
7
|
+
edges,
|
|
8
|
+
fitAll,
|
|
9
|
+
hasInitialServerLayout,
|
|
10
|
+
nodes,
|
|
11
|
+
sessionId,
|
|
12
|
+
traceEnabled,
|
|
13
|
+
viewport,
|
|
14
|
+
zoomByFactor,
|
|
15
|
+
} from '../state/canvas-store';
|
|
16
|
+
import { modChord } from '../utils/platform';
|
|
17
|
+
import { canvasArea } from './canvas-area';
|
|
18
|
+
import { BarHint } from './BarHint';
|
|
19
|
+
import { degradedState } from './ConnectionBanner';
|
|
20
|
+
import { ExternalWriterIndicator } from './ExternalWriters';
|
|
21
|
+
import { useNow } from './use-now';
|
|
22
|
+
import { agentPhaseLabel } from '../../shared/agent-presence.js';
|
|
23
|
+
import { agentIdentityHue } from '../../shared/agent-presence.js';
|
|
24
|
+
import { activeSession, agentPresences, attachedSessions, contextBudget } from '../state/presence-store';
|
|
25
|
+
import { endSession, pendingGates, startSession } from '../state/session-store';
|
|
26
|
+
import { formatCountdown, gateRemainingMs } from '../../shared/approval-gates.js';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The attached session's chip (rail-chrome-v2 phase 3): phase-colored, with a
|
|
30
|
+
* dot that pulses while the agent thinks or runs a tool. Renders ONLY while a
|
|
31
|
+
* session is attached — the quiet board shows nothing here until phase 5
|
|
32
|
+
* lands the "Start agent session" affordance.
|
|
33
|
+
*/
|
|
34
|
+
function AgentChip() {
|
|
35
|
+
// Every attached session gets its chip — with several agents on the board,
|
|
36
|
+
// showing only the first hid the rest from the top bar entirely.
|
|
37
|
+
const sessions = attachedSessions.value;
|
|
38
|
+
if (sessions.length === 0) return null;
|
|
39
|
+
return (
|
|
40
|
+
<>
|
|
41
|
+
{sessions.map((session) => {
|
|
42
|
+
// Fleet roll-up: workers declaring this session as their parent count
|
|
43
|
+
// into its chip instead of growing the bar one chip per worker.
|
|
44
|
+
const workers = agentPresences.value.filter(
|
|
45
|
+
(presence) =>
|
|
46
|
+
presence.parentAgentId != null &&
|
|
47
|
+
(presence.parentAgentId === session.sessionId || presence.parentAgentId === session.source),
|
|
48
|
+
).length;
|
|
49
|
+
return (
|
|
50
|
+
<BarHint
|
|
51
|
+
key={session.sessionId}
|
|
52
|
+
label={`Agent session — ${session.label}`}
|
|
53
|
+
tapToOpen
|
|
54
|
+
body="What this attached agent is doing right now: idle, thinking, running a tool, or waiting on your approval. Steer it from the composer below."
|
|
55
|
+
>
|
|
56
|
+
<span
|
|
57
|
+
class={`agent-chip phase-${session.phase}`}
|
|
58
|
+
data-phase={session.phase}
|
|
59
|
+
style={{ '--identity-color': `hsl(${agentIdentityHue(session.sessionId)} 65% 62%)` }}
|
|
60
|
+
>
|
|
61
|
+
<span class="agent-chip-dot" aria-hidden="true" />
|
|
62
|
+
<span class="agent-chip-label">{agentPhaseLabel(session)}</span>
|
|
63
|
+
<span class="agent-chip-who hud-collapsible-text">{session.label}</span>
|
|
64
|
+
{workers > 0 && (
|
|
65
|
+
<span class="agent-chip-workers">
|
|
66
|
+
+{workers} worker{workers === 1 ? '' : 's'}
|
|
67
|
+
</span>
|
|
68
|
+
)}
|
|
69
|
+
<button
|
|
70
|
+
type="button"
|
|
71
|
+
class="agent-chip-end"
|
|
72
|
+
aria-label={`End ${session.label} session`}
|
|
73
|
+
title={`End ${session.label}'s session`}
|
|
74
|
+
onClick={(e) => {
|
|
75
|
+
// The chip sits inside a tap-to-open hint — ending a session
|
|
76
|
+
// must not also open it.
|
|
77
|
+
e.stopPropagation();
|
|
78
|
+
void endSession({ source: session.source, agentId: session.agentId });
|
|
79
|
+
}}
|
|
80
|
+
>
|
|
81
|
+
×
|
|
82
|
+
</button>
|
|
83
|
+
</span>
|
|
84
|
+
</BarHint>
|
|
85
|
+
);
|
|
86
|
+
})}
|
|
87
|
+
</>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Amber escalation badge while any approval gate is pending in an attached
|
|
93
|
+
* session: "1 gate · 4:31" — the countdown is the soonest auto-hold.
|
|
94
|
+
*/
|
|
95
|
+
function GateBadge() {
|
|
96
|
+
const count = pendingGates.value.length;
|
|
97
|
+
const active = activeSession.value !== null && count > 0;
|
|
98
|
+
const now = useNow(active ? 1000 : 0);
|
|
99
|
+
if (!active) return null;
|
|
100
|
+
const soonest = pendingGates.value
|
|
101
|
+
.map((gate) => gateRemainingMs(gate, now))
|
|
102
|
+
.filter((ms): ms is number => ms !== null)
|
|
103
|
+
.sort((a, b) => a - b)[0];
|
|
104
|
+
return (
|
|
105
|
+
<BarHint
|
|
106
|
+
label="Approval gates waiting on you"
|
|
107
|
+
tapToOpen
|
|
108
|
+
body="The agent is blocked until you approve or reject in the session panel; unanswered gates auto-hold when the countdown runs out."
|
|
109
|
+
>
|
|
110
|
+
<span class="gate-badge">
|
|
111
|
+
{count} gate{count === 1 ? '' : 's'}
|
|
112
|
+
{soonest !== undefined && ` · ${formatCountdown(soonest)}`}
|
|
113
|
+
</span>
|
|
114
|
+
</BarHint>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function budgetTone(ratio: number): 'ok' | 'warn' | 'danger' {
|
|
119
|
+
if (ratio > 0.9) return 'danger';
|
|
120
|
+
if (ratio >= 0.7) return 'warn';
|
|
121
|
+
return 'ok';
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function formatTokens(value: number): string {
|
|
125
|
+
return value >= 1000 ? `${(value / 1000).toFixed(value >= 10_000 ? 0 : 1)}k` : String(value);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Context meter (rail-chrome-v2 phase 5). Two honest modes:
|
|
130
|
+
* - the host reported the agent's REAL window (`contextUsage` on its presence)
|
|
131
|
+
* → "Context": used / window, with the pinned payload as a note;
|
|
132
|
+
* - nothing reported (no adapter does yet) → "Pins": the pinned-context
|
|
133
|
+
* payload estimate against the configured budget
|
|
134
|
+
* (PMX_CANVAS_CONTEXT_BUDGET_TOKENS). Never presented as the agent's window.
|
|
135
|
+
* Session-only, like the chip; the estimate moves within a frame of a pin toggle.
|
|
136
|
+
*/
|
|
137
|
+
function ContextBudget() {
|
|
138
|
+
const session = activeSession.value;
|
|
139
|
+
if (!session) return null;
|
|
140
|
+
const budget = contextBudget.value;
|
|
141
|
+
const real = session.contextUsage;
|
|
142
|
+
const used = real ? real.used : budget.used;
|
|
143
|
+
const total = real ? real.total : budget.total;
|
|
144
|
+
const ratio = total > 0 ? Math.min(1, used / total) : 0;
|
|
145
|
+
const pct = Math.round(ratio * 100);
|
|
146
|
+
const hint = real
|
|
147
|
+
? {
|
|
148
|
+
label: `Context window — reported by ${session.label}`,
|
|
149
|
+
body: `${formatTokens(real.used)} of ${formatTokens(real.total)} tokens of the agent's actual context window are used. Your ✦-pinned nodes account for ≈ ${formatTokens(budget.used)} tokens of it.`,
|
|
150
|
+
}
|
|
151
|
+
: {
|
|
152
|
+
label: 'Pins — pinned-context size (estimate)',
|
|
153
|
+
body: `The nodes you pinned (✦) are what the agent is asked to carry as context: ≈ ${formatTokens(budget.used)} of a ${formatTokens(budget.total)}-token budget. When the agent's host reports its real context window, this meter switches to showing that instead.`,
|
|
154
|
+
};
|
|
155
|
+
return (
|
|
156
|
+
<BarHint label={hint.label} body={hint.body} tapToOpen>
|
|
157
|
+
<span class={`context-budget tone-${budgetTone(ratio)}`} data-mode={real ? 'window' : 'pins'}>
|
|
158
|
+
<span class="context-budget-caption hud-collapsible-text">{real ? 'Context' : 'Pins'}</span>
|
|
159
|
+
<span class="context-budget-track" aria-hidden="true">
|
|
160
|
+
<span class="context-budget-fill" style={{ width: `${pct}%` }} />
|
|
161
|
+
</span>
|
|
162
|
+
<span class="context-budget-label" data-testid="budget-label">
|
|
163
|
+
{used > 0 && pct === 0 ? '<1' : pct}%
|
|
164
|
+
</span>
|
|
165
|
+
</span>
|
|
166
|
+
</BarHint>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Quiet-board affordance (rail-chrome-v2 phase 5): attach a human-started
|
|
172
|
+
* session. The agent's subsequent MCP/HTTP writes are attributed to it, which
|
|
173
|
+
* is what turns the quiet board into a Focus Session with a cursor and panel.
|
|
174
|
+
*/
|
|
175
|
+
function StartSessionButton() {
|
|
176
|
+
const [busy, setBusy] = useState(false);
|
|
177
|
+
if (activeSession.value) return null;
|
|
178
|
+
return (
|
|
179
|
+
<BarHint
|
|
180
|
+
label="Start an agent session"
|
|
181
|
+
body="Attaches a session to this board: the next agent that writes here is adopted into it, and you get the panel, composer, and receipt."
|
|
182
|
+
>
|
|
183
|
+
<button
|
|
184
|
+
type="button"
|
|
185
|
+
class="start-session-btn"
|
|
186
|
+
disabled={busy}
|
|
187
|
+
onClick={() => {
|
|
188
|
+
setBusy(true);
|
|
189
|
+
void startSession().finally(() => setBusy(false));
|
|
190
|
+
}}
|
|
191
|
+
>
|
|
192
|
+
<span class="start-session-dot" aria-hidden="true" />
|
|
193
|
+
Start agent session
|
|
194
|
+
<span class="beta-tag">beta</span>
|
|
195
|
+
</button>
|
|
196
|
+
</BarHint>
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The slim 44px top bar (rail-chrome-v2 phase 1): connection state, board
|
|
202
|
+
* identity on the left, view controls on the right. `overflow:hidden` +
|
|
203
|
+
* `min-width:0` discipline throughout — the title ellipsizes, meta collapses
|
|
204
|
+
* below 1180px, and nothing ever wraps to a second row.
|
|
205
|
+
*/
|
|
206
|
+
export function TopBar() {
|
|
207
|
+
const status = connectionStatus.value;
|
|
208
|
+
const hasSynced = hasInitialServerLayout.value;
|
|
209
|
+
const v = viewport.value;
|
|
210
|
+
const nodeCount = nodes.value.size;
|
|
211
|
+
const edgeCount = edges.value.size;
|
|
212
|
+
const isTraceOn = traceEnabled.value;
|
|
213
|
+
const traceNodeCount = Array.from(nodes.value.values()).filter((n) => n.type === 'trace').length;
|
|
214
|
+
|
|
215
|
+
// Board identity = workspace basename, fetched once from /health. The
|
|
216
|
+
// canvas has no board-name concept — the workspace IS the board.
|
|
217
|
+
const [workspaceName, setWorkspaceName] = useState<string>('');
|
|
218
|
+
useEffect(() => {
|
|
219
|
+
let cancelled = false;
|
|
220
|
+
fetch('/health')
|
|
221
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
222
|
+
.then((data: { workspace?: string } | null) => {
|
|
223
|
+
if (cancelled || typeof data?.workspace !== 'string' || !data.workspace) return;
|
|
224
|
+
const base = data.workspace
|
|
225
|
+
.replace(/[\\/]+$/, '')
|
|
226
|
+
.split(/[\\/]/)
|
|
227
|
+
.pop();
|
|
228
|
+
if (base) setWorkspaceName(base);
|
|
229
|
+
})
|
|
230
|
+
.catch(() => {});
|
|
231
|
+
return () => {
|
|
232
|
+
cancelled = true;
|
|
233
|
+
};
|
|
234
|
+
}, []);
|
|
235
|
+
|
|
236
|
+
const degraded = degradedState.value;
|
|
237
|
+
const statusTitle = degraded ?? (status === 'connected' && !hasSynced ? 'syncing' : status);
|
|
238
|
+
const countsLabel = hasSynced
|
|
239
|
+
? [
|
|
240
|
+
`${nodeCount} node${nodeCount !== 1 ? 's' : ''}`,
|
|
241
|
+
...(edgeCount > 0 ? [`${edgeCount} edge${edgeCount !== 1 ? 's' : ''}`] : []),
|
|
242
|
+
...(traceNodeCount > 0
|
|
243
|
+
? [`${traceNodeCount} trace${traceNodeCount !== 1 ? 's' : ''}`]
|
|
244
|
+
: isTraceOn
|
|
245
|
+
? ['trace armed']
|
|
246
|
+
: []),
|
|
247
|
+
].join(' · ')
|
|
248
|
+
: 'Syncing canvas…';
|
|
249
|
+
|
|
250
|
+
const handleFit = () => {
|
|
251
|
+
const area = canvasArea();
|
|
252
|
+
fitAll(area.width, area.height);
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
return (
|
|
256
|
+
<div class="top-bar">
|
|
257
|
+
<BarHint
|
|
258
|
+
label={`Canvas status: ${statusTitle}`}
|
|
259
|
+
tapToOpen
|
|
260
|
+
body="Live updates stream in over SSE; amber means reconnecting."
|
|
261
|
+
align="start"
|
|
262
|
+
>
|
|
263
|
+
<span class={`connection-dot ${degraded ?? status}`} aria-label={`Canvas status: ${statusTitle}`} />
|
|
264
|
+
</BarHint>
|
|
265
|
+
<span class="top-bar-title" title={workspaceName || 'PMX Canvas'}>
|
|
266
|
+
{workspaceName || 'PMX Canvas'}
|
|
267
|
+
</span>
|
|
268
|
+
<span class="top-bar-meta hud-collapsible-text">{sessionId.value ? sessionId.value.slice(0, 12) : '…'}</span>
|
|
269
|
+
<span class="top-bar-meta hud-collapsible-text">{countsLabel}</span>
|
|
270
|
+
|
|
271
|
+
<span class="top-bar-spacer" />
|
|
272
|
+
|
|
273
|
+
<AgentChip />
|
|
274
|
+
<GateBadge />
|
|
275
|
+
<ContextBudget />
|
|
276
|
+
<ExternalWriterIndicator />
|
|
277
|
+
<StartSessionButton />
|
|
278
|
+
|
|
279
|
+
<div class="top-bar-sep" />
|
|
280
|
+
|
|
281
|
+
<BarHint label="Zoom out" shortcut={modChord('\u2212')}>
|
|
282
|
+
<button type="button" class="top-bar-btn" onClick={() => zoomByFactor(1 / 1.25)} aria-label="Zoom out">
|
|
283
|
+
<IconZoomOut />
|
|
284
|
+
</button>
|
|
285
|
+
</BarHint>
|
|
286
|
+
<BarHint label="Reset zoom" shortcut={modChord('0')}>
|
|
287
|
+
<button
|
|
288
|
+
type="button"
|
|
289
|
+
class="top-bar-zoom-label"
|
|
290
|
+
onClick={() => animateViewport({ x: 0, y: 0, scale: 1 }, 250)}
|
|
291
|
+
aria-label="Reset view"
|
|
292
|
+
>
|
|
293
|
+
{Math.round(v.scale * 100)}%
|
|
294
|
+
</button>
|
|
295
|
+
</BarHint>
|
|
296
|
+
<BarHint label="Zoom in" shortcut={modChord('+')}>
|
|
297
|
+
<button type="button" class="top-bar-btn" onClick={() => zoomByFactor(1.25)} aria-label="Zoom in">
|
|
298
|
+
<IconZoomIn />
|
|
299
|
+
</button>
|
|
300
|
+
</BarHint>
|
|
301
|
+
<BarHint label="Fit all" shortcut="F" align="end">
|
|
302
|
+
<button type="button" class="top-bar-btn" onClick={handleFit} aria-label="Fit canvas">
|
|
303
|
+
<IconFitAll />
|
|
304
|
+
</button>
|
|
305
|
+
</BarHint>
|
|
306
|
+
</div>
|
|
307
|
+
);
|
|
308
|
+
}
|
|
@@ -45,11 +45,7 @@ function isContentFitSurface(node: CanvasNodeState): boolean {
|
|
|
45
45
|
* whose height is controlled elsewhere. */
|
|
46
46
|
function isAutoSizeExempt(node: CanvasNodeState): boolean {
|
|
47
47
|
return (
|
|
48
|
-
node.collapsed === true ||
|
|
49
|
-
node.dockPosition != null ||
|
|
50
|
-
node.data.strictSize === true ||
|
|
51
|
-
node.data.userResized === true ||
|
|
52
|
-
node.type === 'group'
|
|
48
|
+
node.collapsed === true || node.data.strictSize === true || node.data.userResized === true || node.type === 'group'
|
|
53
49
|
);
|
|
54
50
|
}
|
|
55
51
|
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The canvas region's box in screen coordinates. With the rail + top-bar shell
|
|
3
|
+
* the canvas no longer spans the window, so every "centre of the view" and
|
|
4
|
+
* "how big is the view" computation must use this region — anchoring zoom at
|
|
5
|
+
* `window.innerWidth / 2` would drift the board toward the rail on every step,
|
|
6
|
+
* and reporting the window to the server makes an agent `fit` compute a scale
|
|
7
|
+
* that hides nodes under the chrome.
|
|
8
|
+
*
|
|
9
|
+
* The shell registers its canvas-region element on mount; everything else asks
|
|
10
|
+
* for the rect. The window fallback keeps unit tests and any chromeless embed
|
|
11
|
+
* working unchanged.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface CanvasAreaRect {
|
|
15
|
+
left: number;
|
|
16
|
+
top: number;
|
|
17
|
+
width: number;
|
|
18
|
+
height: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let canvasAreaEl: HTMLElement | null = null;
|
|
22
|
+
|
|
23
|
+
export function registerCanvasArea(el: HTMLElement | null): void {
|
|
24
|
+
canvasAreaEl = el;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function canvasArea(): CanvasAreaRect {
|
|
28
|
+
if (canvasAreaEl?.isConnected) {
|
|
29
|
+
const rect = canvasAreaEl.getBoundingClientRect();
|
|
30
|
+
if (rect.width > 0 && rect.height > 0) {
|
|
31
|
+
return { left: rect.left, top: rect.top, width: rect.width, height: rect.height };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return { left: 0, top: 0, width: window.innerWidth, height: window.innerHeight };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Screen-space centre of the canvas region — the anchor for centred zoom/focus. */
|
|
38
|
+
export function canvasAreaCenter(): { x: number; y: number } {
|
|
39
|
+
const rect = canvasArea();
|
|
40
|
+
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface CanvasFitInsets {
|
|
44
|
+
top: number;
|
|
45
|
+
right: number;
|
|
46
|
+
bottom: number;
|
|
47
|
+
left: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Screen-space insets Fit must reserve for the floating chrome that overlays
|
|
52
|
+
* the canvas region: the bottom command bar (whose pinned-chip row grows it)
|
|
53
|
+
* and the bottom-right minimap. Measured from the live elements — fit's old
|
|
54
|
+
* world-space padding shrank with the fit zoom and parked nodes underneath
|
|
55
|
+
* both (0.5.0 Amp report, finding A; architecture rule 9's trap).
|
|
56
|
+
*/
|
|
57
|
+
export function canvasFitInsets(): CanvasFitInsets {
|
|
58
|
+
let bottom = 16;
|
|
59
|
+
try {
|
|
60
|
+
const area = canvasArea();
|
|
61
|
+
const areaBottom = area.top + area.height;
|
|
62
|
+
for (const selector of ['.command-bar', '.minimap']) {
|
|
63
|
+
const el = document.querySelector(selector);
|
|
64
|
+
if (!(el instanceof HTMLElement) || !el.isConnected) continue;
|
|
65
|
+
const rect = el.getBoundingClientRect();
|
|
66
|
+
if (rect.height <= 0) continue;
|
|
67
|
+
const overlap = areaBottom - rect.top;
|
|
68
|
+
if (overlap > 0) bottom = Math.max(bottom, overlap + 12);
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
// Chromeless embeds and unit DOMs: the defaults below are the contract.
|
|
72
|
+
}
|
|
73
|
+
return { top: 16, right: 16, bottom, left: 16 };
|
|
74
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { focusNode, viewport } from '../state/canvas-store';
|
|
2
|
+
import { createNodeFromClient } from '../state/intent-bridge';
|
|
3
|
+
import { canvasAreaCenter } from './canvas-area';
|
|
4
|
+
|
|
5
|
+
/** Default frames for human creates that pass no size (the server clamps to the type floor). */
|
|
6
|
+
const DEFAULT_FRAME: Record<string, { width: number; height: number }> = {
|
|
7
|
+
markdown: { width: 520, height: 360 },
|
|
8
|
+
group: { width: 600, height: 400 },
|
|
9
|
+
html: { width: 720, height: 640 },
|
|
10
|
+
image: { width: 720, height: 520 },
|
|
11
|
+
file: { width: 720, height: 600 },
|
|
12
|
+
webpage: { width: 520, height: 420 },
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A human create from the rail, keyboard, palette, or empty state lands
|
|
17
|
+
* centred in the CURRENT viewport — where the human is looking — instead of
|
|
18
|
+
* at the server's board-relative auto-placement, which on a busy board can
|
|
19
|
+
* be far off-screen (the rail's Group landed out of view). Explicit x/y win.
|
|
20
|
+
*/
|
|
21
|
+
export async function createNodeInView(opts: {
|
|
22
|
+
type: string;
|
|
23
|
+
title?: string;
|
|
24
|
+
content?: string;
|
|
25
|
+
x?: number;
|
|
26
|
+
y?: number;
|
|
27
|
+
width?: number;
|
|
28
|
+
height?: number;
|
|
29
|
+
}): Promise<{ ok: boolean; id?: string }> {
|
|
30
|
+
if (opts.x !== undefined && opts.y !== undefined) return createNodeFromClient(opts);
|
|
31
|
+
const frame = DEFAULT_FRAME[opts.type] ?? { width: 360, height: 200 };
|
|
32
|
+
const width = opts.width ?? frame.width;
|
|
33
|
+
const height = opts.height ?? frame.height;
|
|
34
|
+
const centre = canvasAreaCenter();
|
|
35
|
+
const v = viewport.value;
|
|
36
|
+
const result = await createNodeFromClient({
|
|
37
|
+
...opts,
|
|
38
|
+
width,
|
|
39
|
+
height,
|
|
40
|
+
x: Math.round((centre.x - v.x) / v.scale - width / 2),
|
|
41
|
+
y: Math.round((centre.y - v.y) / v.scale - height / 2),
|
|
42
|
+
});
|
|
43
|
+
// The shared viewport can move between the ask (a prompted dialog) and the
|
|
44
|
+
// create — focus the node so it can never land off-screen (0.5.0 readiness,
|
|
45
|
+
// Codex: Open image… required a palette hunt to find its node).
|
|
46
|
+
if (result.ok && result.id) focusNode(result.id);
|
|
47
|
+
return result;
|
|
48
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { createNodeFromClient } from '../state/intent-bridge';
|
|
2
|
+
|
|
3
|
+
const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'bmp', 'ico', 'avif']);
|
|
4
|
+
const MD_EXTS = new Set(['md', 'mdx', 'markdown']);
|
|
5
|
+
|
|
6
|
+
export function nodeTypeFromFilename(name: string): 'image' | 'markdown' | 'file' {
|
|
7
|
+
const ext = name.split('.').pop()?.toLowerCase() ?? '';
|
|
8
|
+
if (IMAGE_EXTS.has(ext)) return 'image';
|
|
9
|
+
if (MD_EXTS.has(ext)) return 'markdown';
|
|
10
|
+
return 'file';
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Turn local files into nodes laid out in a grid around a world point — the
|
|
15
|
+
* one implementation behind the viewport's drop zone and the empty state's
|
|
16
|
+
* file picker. Images become image nodes (data URI), markdown becomes
|
|
17
|
+
* markdown, everything else a file node with the text inlined.
|
|
18
|
+
*/
|
|
19
|
+
export async function importFiles(files: File[], baseWx: number, baseWy: number): Promise<void> {
|
|
20
|
+
const nodeW = 400;
|
|
21
|
+
const nodeH = 300;
|
|
22
|
+
const spacing = 20;
|
|
23
|
+
const cols = Math.ceil(Math.sqrt(files.length));
|
|
24
|
+
|
|
25
|
+
for (let i = 0; i < files.length; i++) {
|
|
26
|
+
const file = files[i]!;
|
|
27
|
+
const col = i % cols;
|
|
28
|
+
const row = Math.floor(i / cols);
|
|
29
|
+
const wx = baseWx - (cols * (nodeW + spacing)) / 2 + col * (nodeW + spacing);
|
|
30
|
+
const wy = baseWy - nodeH / 2 + row * (nodeH + spacing);
|
|
31
|
+
|
|
32
|
+
const type = nodeTypeFromFilename(file.name);
|
|
33
|
+
const fileName = file.name;
|
|
34
|
+
|
|
35
|
+
if (type === 'image') {
|
|
36
|
+
const reader = new FileReader();
|
|
37
|
+
const dataUri: string = await new Promise((resolve) => {
|
|
38
|
+
reader.onload = () => resolve(reader.result as string);
|
|
39
|
+
reader.readAsDataURL(file);
|
|
40
|
+
});
|
|
41
|
+
await createNodeFromClient({
|
|
42
|
+
type: 'image',
|
|
43
|
+
title: fileName,
|
|
44
|
+
content: dataUri,
|
|
45
|
+
x: wx,
|
|
46
|
+
y: wy,
|
|
47
|
+
width: nodeW,
|
|
48
|
+
height: nodeH,
|
|
49
|
+
});
|
|
50
|
+
} else {
|
|
51
|
+
const text = await file.text();
|
|
52
|
+
const isWide = type === 'markdown' || type === 'file';
|
|
53
|
+
await createNodeFromClient({
|
|
54
|
+
type,
|
|
55
|
+
title: fileName,
|
|
56
|
+
content: text,
|
|
57
|
+
x: wx,
|
|
58
|
+
y: wy,
|
|
59
|
+
width: isWide ? 720 : nodeW,
|
|
60
|
+
height: isWide ? 500 : nodeH,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { CanvasNodeState } from '../types';
|
|
2
|
+
|
|
3
|
+
/** Kind color per node type, as CSS tokens so every theme keeps its palette. */
|
|
4
|
+
export const KIND_COLOR: Record<CanvasNodeState['type'], string> = {
|
|
5
|
+
markdown: 'var(--c-accent)',
|
|
6
|
+
'mcp-app': 'var(--c-ok)',
|
|
7
|
+
webpage: 'var(--c-warn)',
|
|
8
|
+
'json-render': 'var(--c-ok)',
|
|
9
|
+
graph: 'var(--c-purple)',
|
|
10
|
+
prompt: 'var(--c-accent)',
|
|
11
|
+
response: 'var(--c-ok)',
|
|
12
|
+
status: 'var(--c-warn)',
|
|
13
|
+
context: 'var(--c-muted)',
|
|
14
|
+
ledger: 'var(--c-dim)',
|
|
15
|
+
trace: 'var(--c-purple)',
|
|
16
|
+
file: 'var(--c-accent)',
|
|
17
|
+
diff: 'var(--c-ok)',
|
|
18
|
+
mermaid: 'var(--c-purple)',
|
|
19
|
+
image: 'var(--c-ok)',
|
|
20
|
+
html: 'var(--c-warn)',
|
|
21
|
+
group: 'var(--c-accent)',
|
|
22
|
+
};
|
|
@@ -81,7 +81,7 @@ export function buildSnapCache(dragId: string, allNodes: Iterable<CanvasNodeStat
|
|
|
81
81
|
const nodeList = Array.from(allNodes);
|
|
82
82
|
const excludedIds = collectExcludedReferenceIds(dragId, nodeList);
|
|
83
83
|
for (const n of nodeList) {
|
|
84
|
-
if (excludedIds.has(n.id)
|
|
84
|
+
if (excludedIds.has(n.id)) continue;
|
|
85
85
|
const l = n.position.x;
|
|
86
86
|
const r = n.position.x + n.size.width;
|
|
87
87
|
const cx = n.position.x + n.size.width / 2;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { useEffect } from 'preact/hooks';
|
|
2
|
+
|
|
3
|
+
const FOCUSABLE =
|
|
4
|
+
'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Focus trap for modal overlays (rail-chrome-v2 item 18): while `active`, Tab
|
|
8
|
+
* cycles inside `ref`, the first focusable takes focus on open unless
|
|
9
|
+
* `initial` names one, and the element focused before the overlay opened gets
|
|
10
|
+
* focus back on close.
|
|
11
|
+
*/
|
|
12
|
+
export function useFocusTrap(
|
|
13
|
+
ref: { current: HTMLElement | null },
|
|
14
|
+
active: boolean,
|
|
15
|
+
options: { initial?: { current: HTMLElement | null }; restoreTo?: () => HTMLElement | null } = {},
|
|
16
|
+
): void {
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
if (!active) return;
|
|
19
|
+
const root = ref.current;
|
|
20
|
+
if (!root) return;
|
|
21
|
+
const previous = document.activeElement as HTMLElement | null;
|
|
22
|
+
const restoreTo = options.restoreTo;
|
|
23
|
+
const focusables = () => Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE));
|
|
24
|
+
const initial = options.initial?.current ?? focusables()[0];
|
|
25
|
+
if (initial && !root.contains(document.activeElement)) initial.focus();
|
|
26
|
+
const onKey = (e: KeyboardEvent) => {
|
|
27
|
+
if (e.key !== 'Tab') return;
|
|
28
|
+
const items = focusables();
|
|
29
|
+
if (items.length === 0) return;
|
|
30
|
+
const first = items[0]!;
|
|
31
|
+
const last = items[items.length - 1]!;
|
|
32
|
+
const current = document.activeElement as HTMLElement | null;
|
|
33
|
+
if (e.shiftKey && (current === first || !root.contains(current))) {
|
|
34
|
+
e.preventDefault();
|
|
35
|
+
last.focus();
|
|
36
|
+
} else if (!e.shiftKey && current === last) {
|
|
37
|
+
e.preventDefault();
|
|
38
|
+
first.focus();
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
root.addEventListener('keydown', onKey);
|
|
42
|
+
return () => {
|
|
43
|
+
root.removeEventListener('keydown', onKey);
|
|
44
|
+
// `restoreTo` wins: an overlay that replaced the element it was opened
|
|
45
|
+
// from (focus mode unmounts the node while expanded) names the element
|
|
46
|
+
// to return to, which may only exist after the next frame.
|
|
47
|
+
if (restoreTo) {
|
|
48
|
+
requestAnimationFrame(() => restoreTo()?.focus());
|
|
49
|
+
} else if (previous && document.contains(previous) && previous !== document.body) {
|
|
50
|
+
previous.focus();
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
}, [ref, active, options.initial, options.restoreTo]);
|
|
54
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { useEffect, useState } from 'preact/hooks';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A ticking "now" for countdowns and relative ages. `intervalMs` 0 stops the
|
|
5
|
+
* ticker (the value still reads as the mount time) — pass 0 while the surface
|
|
6
|
+
* that needs it is hidden so an idle page runs no timers.
|
|
7
|
+
*/
|
|
8
|
+
export function useNow(intervalMs = 1000): number {
|
|
9
|
+
const [now, setNow] = useState(() => Date.now());
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
if (intervalMs <= 0) return;
|
|
12
|
+
setNow(Date.now());
|
|
13
|
+
const timer = window.setInterval(() => setNow(Date.now()), intervalMs);
|
|
14
|
+
return () => window.clearInterval(timer);
|
|
15
|
+
}, [intervalMs]);
|
|
16
|
+
return now;
|
|
17
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Signal } from '@preact/signals';
|
|
2
|
+
import { isPanModeActive } from '../state/canvas-store';
|
|
2
3
|
import { useCallback, useEffect, useRef } from 'preact/hooks';
|
|
3
4
|
import type { ViewportState } from '../types';
|
|
4
5
|
|
|
@@ -100,9 +101,19 @@ export function usePanZoom({ viewport, onViewportChange, onViewportCommit, disab
|
|
|
100
101
|
const handlePointerDown = useCallback(
|
|
101
102
|
(e: PointerEvent) => {
|
|
102
103
|
if (disabled) return;
|
|
103
|
-
// Only pan when clicking the canvas background (not nodes)
|
|
104
104
|
const container = containerRef.current;
|
|
105
|
-
if (!container
|
|
105
|
+
if (!container) return;
|
|
106
|
+
// Middle-drag pans from anywhere, any tool — standard canvas UX.
|
|
107
|
+
if (e.button === 1) {
|
|
108
|
+
e.preventDefault();
|
|
109
|
+
} else if (isPanModeActive()) {
|
|
110
|
+
// Pan tool / held Space: the world layer is pointer-inert (CSS), so
|
|
111
|
+
// the event target is the container even over nodes.
|
|
112
|
+
if (e.target !== container) return;
|
|
113
|
+
} else {
|
|
114
|
+
// Select tool: background drag belongs to the lasso (CanvasViewport).
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
106
117
|
isPanning.current = true;
|
|
107
118
|
lastPointer.current = { x: e.clientX, y: e.clientY };
|
|
108
119
|
container.setPointerCapture(e.pointerId);
|