@nelsonlongxiang/dsh-open-a2a-net 0.5.14 → 0.6.24
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/README.md +243 -121
- package/README_zh.md +144 -84
- package/assets/nexusDist/assets/index-034h1cNc.js +3 -0
- package/assets/nexusDist/assets/index-PG9EieGX.css +1 -0
- package/assets/nexusDist/index.html +26 -0
- package/assets/stageDist/assets/index-Bj7S2pA1.css +1 -0
- package/assets/stageDist/assets/index-C-28k_o5.js +974 -0
- package/assets/stageDist/assets/interiors-CQGBjffT.png +0 -0
- package/assets/stageDist/assets/office-tileset-CHQT-uYL.png +0 -0
- package/assets/stageDist/index.html +13 -0
- package/lib/a2a-client.js +190 -13
- package/lib/canvas-store.js +218 -0
- package/lib/card.js +25 -0
- package/lib/client/A2aControl.js +45 -137
- package/lib/client/locales.js +16 -40
- package/lib/client.js +164 -467
- package/lib/client.js.map +1 -1
- package/lib/group-store.js +6 -0
- package/lib/idempotency-store.js +291 -0
- package/lib/index.js +4217 -259
- package/lib/joined-store.js +11 -0
- package/lib/layout-store.js +155 -0
- package/lib/peer-store.js +98 -13
- package/lib/receipt-ladder.js +35 -0
- package/lib/receipt.js +102 -0
- package/lib/self-suppress.js +62 -0
- package/lib/session-transcript.js +150 -0
- package/lib/skill/SKILL.md +77 -0
- package/lib/skill/references/discovery.md +38 -0
- package/lib/skill/references/membership.md +50 -0
- package/lib/skill/references/pitfalls.md +42 -0
- package/lib/skill/references/routing.md +43 -0
- package/lib/skill-registration.js +52 -0
- package/lib/stage-mount.js +32 -0
- package/lib/task-ledger.js +540 -0
- package/lib/team-store.js +122 -0
- package/lib/teams-bridge.js +18 -0
- package/lib/transport-caps.js +31 -0
- package/lib/types/a2a-client.d.ts +72 -1
- package/lib/types/canvas-store.d.ts +102 -0
- package/lib/types/client/A2aControl.d.ts +18 -28
- package/lib/types/client/locales.d.ts +1 -1
- package/lib/types/group-store.d.ts +6 -0
- package/lib/types/idempotency-store.d.ts +164 -0
- package/lib/types/index.d.ts +210 -6
- package/lib/types/joined-store.d.ts +9 -0
- package/lib/types/layout-store.d.ts +63 -0
- package/lib/types/peer-store.d.ts +45 -2
- package/lib/types/receipt-ladder.d.ts +25 -0
- package/lib/types/receipt.d.ts +51 -0
- package/lib/types/self-suppress.d.ts +41 -0
- package/lib/types/session-transcript.d.ts +37 -0
- package/lib/types/skill-registration.d.ts +3 -0
- package/lib/types/stage-mount.d.ts +29 -0
- package/lib/types/task-ledger.d.ts +308 -0
- package/lib/types/team-store.d.ts +55 -0
- package/lib/types/teams-bridge.d.ts +112 -0
- package/lib/types/transport-caps.d.ts +29 -0
- package/lib/types/types.d.ts +43 -0
- package/nexus-stage/package-lock.json +2262 -0
- package/nexus-stage/package.json +21 -0
- package/nexus-stage/scripts/__pycache__/mockhost.cpython-312.pyc +0 -0
- package/nexus-stage/scripts/mockhost.py +253 -0
- package/nexus-stage/src/canvas-ops.ts +175 -0
- package/nexus-stage/src/canvas-wire.ts +156 -0
- package/nexus-stage/src/edges.ts +72 -0
- package/nexus-stage/src/fault.ts +56 -0
- package/nexus-stage/src/federation.ts +234 -0
- package/nexus-stage/src/layout-doc.ts +143 -0
- package/nexus-stage/src/layout-wire.ts +158 -0
- package/nexus-stage/src/main.ts +361 -0
- package/nexus-stage/src/planning-menus.ts +352 -0
- package/nexus-stage/src/planning-nodelist.ts +294 -0
- package/nexus-stage/src/planning-types.ts +111 -0
- package/nexus-stage/src/planning-view.ts +1313 -0
- package/nexus-stage/src/planning.css +280 -0
- package/nexus-stage/src/seat.ts +35 -0
- package/nexus-stage/src/stage-name.ts +13 -0
- package/nexus-stage/src/tokens.ts +32 -0
- package/nexus-stage/src/viewport.ts +83 -0
- package/nexus-stage/src/workbench.css +267 -0
- package/nexus-stage/src/workbench.ts +1530 -0
- package/nexus-stage/src/world.ts +509 -0
- package/nexus-stage/tsconfig.json +25 -0
- package/nexus-stage/vite.config.ts +16 -0
- package/package.json +83 -22
|
@@ -0,0 +1,1313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The 2D planning-mode view: one section#dsh-plan holding a screen-fixed
|
|
3
|
+
* dot-grid layer, the transformed world container (SVG star edges + team
|
|
4
|
+
* frames + node cards), a marquee, and the HUD (toolbar / tabs host /
|
|
5
|
+
* status / legend). This is the ONLY new DOM module; everything spatial
|
|
6
|
+
* lives in the pure modules (viewport/world/edges/layout-doc).
|
|
7
|
+
*
|
|
8
|
+
* Reconcile discipline:
|
|
9
|
+
* - the WorldModel persists across polls and is maintained incrementally
|
|
10
|
+
* (upsert refreshes rendered attributes, never positions - a poll must
|
|
11
|
+
* not snap a dragged card back to its last-saved spot);
|
|
12
|
+
* - card/frame elements are keyed-diffed so focus, the pickup transition,
|
|
13
|
+
* and an in-flight pointer capture survive a poll; SVG edges carry no
|
|
14
|
+
* state and are rebuilt wholesale;
|
|
15
|
+
* - the federation overlay (peer cards + activity/federal edges) renders
|
|
16
|
+
* on every render, OUTSIDE the revision gate, so inFlight ages tick per
|
|
17
|
+
* poll and the badge cards track the local content bounds;
|
|
18
|
+
* - XSS: session labels/teams are attacker-shaped data and land only via
|
|
19
|
+
* createElement + textContent.
|
|
20
|
+
*
|
|
21
|
+
* Gestures (Figma-style ruling): left-blank drag = marquee; pan = Space+drag
|
|
22
|
+
* or middle-drag or Shift+wheel; wheel (with or without Ctrl) =
|
|
23
|
+
* pointer-anchored zoom (owner ruling 2026-09-01).
|
|
24
|
+
* Tests drive the same handlers production binds through `seam`.
|
|
25
|
+
* @module nexus-stage/planning-view
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import type { LampState } from './layout-wire'
|
|
29
|
+
import { buildLayoutDoc, clampDoc, type LayoutDoc, type LayoutRect } from './layout-doc'
|
|
30
|
+
import { FRAME_HEAD_LIFT, starEdges } from './edges'
|
|
31
|
+
import { displayName } from './stage-name'
|
|
32
|
+
import {
|
|
33
|
+
activityEdges, federalEdges, flyBounds, groupRemoteTeams, peerNodeId, peerHostOf, placePeers,
|
|
34
|
+
type FrameAnchor, type InFlightRow, type PeerPlacement, type PeerRow,
|
|
35
|
+
type RemoteTeamRow,
|
|
36
|
+
} from './federation'
|
|
37
|
+
import { clampViewport, fitView, panBy, screenToWorld, worldTransformCss, zoomAt, type LayoutViewport } from './viewport'
|
|
38
|
+
import { WorldModel, nodeRect, deriveInitialFrame, homeTeams, planSeatClear, planTeamClusters } from './world'
|
|
39
|
+
import { applyAction, actionTeam, innermostFrameAt, reorderOps, yOrderedMembers, type CanvasAction } from './canvas-ops'
|
|
40
|
+
import { FRAME_HUES } from './tokens'
|
|
41
|
+
import { createPlanningMenus } from './planning-menus'
|
|
42
|
+
import { createNodeList } from './planning-nodelist'
|
|
43
|
+
import { viewportBucket, type PlanningDeps, type PlanningInput, type PlanningSession, type PlanningTeam, type PlanningView, type SeamKey, type SeamPointer, type ViewportBucket } from './planning-types'
|
|
44
|
+
import './planning.css'
|
|
45
|
+
|
|
46
|
+
export { viewportBucket } from './planning-types'
|
|
47
|
+
export type { PlanningDeps, PlanningInput, PlanningSession, PlanningTeam, PlanningView, SeamKey, SeamPointer, ViewportBucket } from './planning-types'
|
|
48
|
+
|
|
49
|
+
/** Team-name hue, same 31-polynomial family as the 3D scene's getHue. */
|
|
50
|
+
function frameHue(name: string): { line: string; bg: string } {
|
|
51
|
+
let h = 0
|
|
52
|
+
for (let i = 0; i < name.length; i++) h = ((h * 31 + name.charCodeAt(i)) & 0xfffffff) >>> 0
|
|
53
|
+
const rgb = FRAME_HUES[h % FRAME_HUES.length]!
|
|
54
|
+
const line = '#' + rgb.toString(16).padStart(6, '0')
|
|
55
|
+
// 5% alpha fill over the dark base, per the design system.
|
|
56
|
+
const r = (rgb >> 16) & 0xff
|
|
57
|
+
const g = (rgb >> 8) & 0xff
|
|
58
|
+
const b = rgb & 0xff
|
|
59
|
+
return { line, bg: `rgba(${r},${g},${b},0.05)` }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
type Gesture =
|
|
64
|
+
| { readonly kind: 'none' }
|
|
65
|
+
| { readonly kind: 'node'; ids: readonly string[]; clicked: string; last: { x: number; y: number }; moved: boolean; el: Element | null }
|
|
66
|
+
| { readonly kind: 'frame'; name: string; snap: NonNullable<ReturnType<WorldModel['beginFrameDrag']>>; origin: { x: number; y: number }; moved: boolean }
|
|
67
|
+
| { readonly kind: 'frame-resize'; name: string; dir: string; startRect: { x: number; y: number; w: number; h: number }; origin: { x: number; y: number }; moved: boolean }
|
|
68
|
+
| { readonly kind: 'pan'; last: { x: number; y: number }; moved: boolean }
|
|
69
|
+
| { readonly kind: 'marquee'; origin: { x: number; y: number }; last: { x: number; y: number }; additive: boolean }
|
|
70
|
+
|
|
71
|
+
export function createPlanningView(deps: PlanningDeps): PlanningView {
|
|
72
|
+
const model = new WorldModel()
|
|
73
|
+
let layoutDoc: LayoutDoc | undefined
|
|
74
|
+
let vp: LayoutViewport = { ...model.viewport }
|
|
75
|
+
let gesture: Gesture = { kind: 'none' }
|
|
76
|
+
let spaceDown = false
|
|
77
|
+
let renderedRevision = -1
|
|
78
|
+
/** Until the first reconcile with nodes (or an adopted viewport), frame the content once. */
|
|
79
|
+
let firstFitPending = true
|
|
80
|
+
let viewportAdopted = false
|
|
81
|
+
const bucket = (): ViewportBucket => viewportBucket(globalThis.innerWidth || viewW)
|
|
82
|
+
const markDirty = (): void => {
|
|
83
|
+
deps.viewportStore?.set(bucket(), vp)
|
|
84
|
+
deps.onDirty()
|
|
85
|
+
}
|
|
86
|
+
const clock = deps.now ?? Date.now
|
|
87
|
+
const ac = new AbortController()
|
|
88
|
+
const noticeTimers: Array<ReturnType<typeof setTimeout>> = []
|
|
89
|
+
/** Active right-button press: hold-drag pans; a clean release opens the
|
|
90
|
+
* context menu (the contextmenu event itself is always suppressed). */
|
|
91
|
+
let rightPress: { x: number; y: number; target: Element | null; moved: boolean } | null = null
|
|
92
|
+
let suppressNextMenu = false
|
|
93
|
+
const signal = { signal: ac.signal } as AddEventListenerOptions
|
|
94
|
+
|
|
95
|
+
// ── DOM shell (built once) ──
|
|
96
|
+
const root = document.createElement('section')
|
|
97
|
+
root.id = 'dsh-plan'
|
|
98
|
+
root.setAttribute('role', 'tabpanel')
|
|
99
|
+
root.setAttribute('aria-label', 'A2A 规划画布')
|
|
100
|
+
root.tabIndex = 0
|
|
101
|
+
root.style.display = 'none'
|
|
102
|
+
|
|
103
|
+
const grid = document.createElement('div')
|
|
104
|
+
grid.className = 'p-grid'
|
|
105
|
+
|
|
106
|
+
const world = document.createElement('div')
|
|
107
|
+
world.className = 'p-world'
|
|
108
|
+
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
|
109
|
+
svg.setAttribute('class', 'p-edges')
|
|
110
|
+
// NOTE: the svg is intentionally NOT aria-hidden — its .edge-label texts
|
|
111
|
+
// carry the route ages that reduced-motion users rely on.
|
|
112
|
+
// Stable layers: frames must ALWAYS paint (and lose hit-testing) below
|
|
113
|
+
// cards, regardless of which keyed diff created its element first — a
|
|
114
|
+
// frame created after the cards used to sit on top of them and swallow
|
|
115
|
+
// every real-pointer drag inside the team (synthetic tests bypassed it
|
|
116
|
+
// by targeting cards directly).
|
|
117
|
+
const frameLayer = document.createElement('div')
|
|
118
|
+
frameLayer.className = 'p-layer p-frames'
|
|
119
|
+
/** The selected frame keeps its resize handles visible without hover. */
|
|
120
|
+
let selectedFrame: string | undefined
|
|
121
|
+
const markSelectedFrame = (name: string | undefined): void => {
|
|
122
|
+
if (selectedFrame !== undefined) {
|
|
123
|
+
frameEls.get(selectedFrame)?.classList.remove('selected')
|
|
124
|
+
}
|
|
125
|
+
selectedFrame = name
|
|
126
|
+
if (name !== undefined) {
|
|
127
|
+
frameEls.get(name)?.classList.add('selected')
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const nodeLayer = document.createElement('div')
|
|
131
|
+
nodeLayer.className = 'p-layer p-nodes'
|
|
132
|
+
nodeLayer.setAttribute('role', 'listbox')
|
|
133
|
+
nodeLayer.setAttribute('aria-multiselectable', 'true')
|
|
134
|
+
nodeLayer.setAttribute('aria-label', '会话节点')
|
|
135
|
+
world.append(svg, frameLayer, nodeLayer)
|
|
136
|
+
|
|
137
|
+
const marquee = document.createElement('div')
|
|
138
|
+
marquee.className = 'p-marquee'
|
|
139
|
+
|
|
140
|
+
const toolbar = document.createElement('div')
|
|
141
|
+
toolbar.className = 'p-toolbar'
|
|
142
|
+
const zoomLabel = document.createElement('span')
|
|
143
|
+
zoomLabel.className = 'p-zoom mono'
|
|
144
|
+
zoomLabel.textContent = '100%'
|
|
145
|
+
const mkButton = (text: string, title: string, onClick: () => void): HTMLButtonElement => {
|
|
146
|
+
const b = document.createElement('button')
|
|
147
|
+
b.type = 'button'
|
|
148
|
+
b.textContent = text
|
|
149
|
+
b.title = title
|
|
150
|
+
b.addEventListener('click', onClick, signal)
|
|
151
|
+
return b
|
|
152
|
+
}
|
|
153
|
+
toolbar.append(
|
|
154
|
+
mkButton('适配', '适配视图(快捷键 0)', () => fitToContent()),
|
|
155
|
+
mkButton('-', '缩小', () => zoomCenter(1 / 1.2)),
|
|
156
|
+
zoomLabel,
|
|
157
|
+
mkButton('+', '放大', () => zoomCenter(1.2)),
|
|
158
|
+
mkButton('建队', '组成团队(G):先框选至少 2 个节点', () => menus.startCreateFromSelection()),
|
|
159
|
+
)
|
|
160
|
+
// Unjoined sessions ride a toolbar chip + dropdown: the panel-slim
|
|
161
|
+
// migration made the planning view the join surface — without this, an
|
|
162
|
+
// unjoined session had no GUI path into the mesh at all.
|
|
163
|
+
const netBtn = mkButton('未入网', '未入网会话(点击选择入网)', () => menus.toggleNetMenu())
|
|
164
|
+
netBtn.className = 'p-netbtn'
|
|
165
|
+
netBtn.setAttribute('aria-haspopup', 'menu')
|
|
166
|
+
toolbar.appendChild(netBtn)
|
|
167
|
+
// Node list (network-membership-display): the canvas shows teamed nodes
|
|
168
|
+
// only; every other session — teamless joined, unjoined, and each peer's
|
|
169
|
+
// published nodes — is managed from this list, switched by host.
|
|
170
|
+
const nodesBtn = mkButton('节点', '节点清单:本机与对等节点(未组队/未入网在此管理)', () => nodes.toggle())
|
|
171
|
+
nodesBtn.className = 'p-nodebtn'
|
|
172
|
+
nodesBtn.setAttribute('aria-haspopup', 'dialog')
|
|
173
|
+
toolbar.appendChild(nodesBtn)
|
|
174
|
+
const lamp = document.createElement('span')
|
|
175
|
+
lamp.setAttribute('aria-live', 'polite')
|
|
176
|
+
lamp.className = 'p-lamp'
|
|
177
|
+
const lampDot = document.createElement('i')
|
|
178
|
+
const lampText = document.createElement('span')
|
|
179
|
+
lampText.textContent = '布局'
|
|
180
|
+
lamp.append(lampDot, lampText)
|
|
181
|
+
lamp.addEventListener('click', () => { if (lamp.classList.contains('error')) deps.onLampClick() }, signal)
|
|
182
|
+
lamp.addEventListener('keydown', (ev) => {
|
|
183
|
+
const e = ev as KeyboardEvent
|
|
184
|
+
if ((e.key === 'Enter' || e.key === ' ') && lamp.classList.contains('error')) {
|
|
185
|
+
e.preventDefault()
|
|
186
|
+
deps.onLampClick()
|
|
187
|
+
}
|
|
188
|
+
}, signal)
|
|
189
|
+
toolbar.appendChild(lamp)
|
|
190
|
+
|
|
191
|
+
const status = document.createElement('div')
|
|
192
|
+
status.className = 'p-status'
|
|
193
|
+
const statusLive = document.createElement('span')
|
|
194
|
+
const statusCold = document.createElement('span')
|
|
195
|
+
const statusAct = document.createElement('span')
|
|
196
|
+
statusAct.className = 'mut'
|
|
197
|
+
const statusMut = document.createElement('span')
|
|
198
|
+
statusMut.className = 'mut'
|
|
199
|
+
status.append(statusLive, statusCold, statusAct, statusMut)
|
|
200
|
+
// Fly control (PR D): the whole statusbar fits the frames ∪ peers region,
|
|
201
|
+
// keyboard-parity via Enter/Space with a focus ring (a11y parity with 0).
|
|
202
|
+
status.tabIndex = 0
|
|
203
|
+
status.setAttribute('role', 'button')
|
|
204
|
+
status.setAttribute('aria-label', '定位在途路由与联邦对端')
|
|
205
|
+
status.title = '定位在途与联邦(回车)'
|
|
206
|
+
status.addEventListener('click', () => flyToFederation(), signal)
|
|
207
|
+
status.addEventListener('keydown', (ev) => {
|
|
208
|
+
const e = ev as KeyboardEvent
|
|
209
|
+
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); flyToFederation() }
|
|
210
|
+
}, signal)
|
|
211
|
+
|
|
212
|
+
const legend = document.createElement('div')
|
|
213
|
+
legend.className = 'p-legend'
|
|
214
|
+
const legendRow = (dashed: boolean, text: string, color?: string): void => {
|
|
215
|
+
const row = document.createElement('span')
|
|
216
|
+
const bar = document.createElement('i')
|
|
217
|
+
if (dashed) bar.className = 'dashed'
|
|
218
|
+
if (color !== undefined) bar.style.borderTopColor = color
|
|
219
|
+
row.append(bar, document.createTextNode(text))
|
|
220
|
+
legend.appendChild(row)
|
|
221
|
+
}
|
|
222
|
+
legendRow(false, '成员边(星形,框→成员)')
|
|
223
|
+
legendRow(true, '跨队多属(虚线)')
|
|
224
|
+
legendRow(false, '活动边(inFlight 路由,瞬时)', 'var(--accent)')
|
|
225
|
+
legendRow(true, '联邦线(→peer)', 'var(--federal)')
|
|
226
|
+
|
|
227
|
+
const noticeStack = document.createElement('div')
|
|
228
|
+
noticeStack.className = 'p-notice-stack'
|
|
229
|
+
noticeStack.setAttribute('role', 'log')
|
|
230
|
+
noticeStack.setAttribute('aria-live', 'polite')
|
|
231
|
+
|
|
232
|
+
root.append(grid, world, marquee, toolbar, status, legend, noticeStack)
|
|
233
|
+
|
|
234
|
+
// ── viewport application ──
|
|
235
|
+
let viewW = 0
|
|
236
|
+
let viewH = 0
|
|
237
|
+
const measure = (): { w: number; h: number } =>
|
|
238
|
+
deps.viewSize ? deps.viewSize() : { w: root.clientWidth, h: root.clientHeight }
|
|
239
|
+
const syncViewSize = (): void => {
|
|
240
|
+
const size = measure()
|
|
241
|
+
viewW = size.w
|
|
242
|
+
viewH = size.h
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function applyViewport(): void {
|
|
246
|
+
world.style.transform = worldTransformCss(vp)
|
|
247
|
+
const step = 24 * vp.scale
|
|
248
|
+
grid.style.backgroundSize = `${step}px ${step}px`
|
|
249
|
+
grid.style.backgroundPosition = `${-vp.x * vp.scale}px ${-vp.y * vp.scale}px`
|
|
250
|
+
zoomLabel.textContent = `${Math.round(vp.scale * 100)}%`
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function render(): void {
|
|
254
|
+
applyViewport()
|
|
255
|
+
if (model.revision !== renderedRevision) {
|
|
256
|
+
renderedRevision = model.revision
|
|
257
|
+
renderFrames()
|
|
258
|
+
renderNodes()
|
|
259
|
+
renderEdges()
|
|
260
|
+
}
|
|
261
|
+
renderFederation()
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ── keyed diff: frames ──
|
|
265
|
+
// Element maps instead of selector lookups: keyed identity for focus and
|
|
266
|
+
// transitions, no CSS.escape dependency, O(1) per row.
|
|
267
|
+
const frameEls = new Map<string, HTMLElement>()
|
|
268
|
+
const nodeEls = new Map<string, HTMLElement>()
|
|
269
|
+
|
|
270
|
+
function renderFrames(): void {
|
|
271
|
+
const seen = new Set<string>()
|
|
272
|
+
for (const [name, rect] of model.allFrames()) {
|
|
273
|
+
seen.add(name)
|
|
274
|
+
let el = frameEls.get(name)
|
|
275
|
+
if (el === undefined) {
|
|
276
|
+
el = document.createElement('div')
|
|
277
|
+
el.className = 'p-frame'
|
|
278
|
+
el.dataset.name = name
|
|
279
|
+
const head = document.createElement('div')
|
|
280
|
+
head.className = 'p-frame-head'
|
|
281
|
+
head.dataset.frame = name
|
|
282
|
+
head.tabIndex = 0 // keyboard: Shift+F10 menu, Delete 散队
|
|
283
|
+
head.setAttribute('role', 'button')
|
|
284
|
+
head.setAttribute('aria-haspopup', 'menu')
|
|
285
|
+
head.setAttribute('aria-label', `团队框 ${name}(拖动整组,回车菜单)`)
|
|
286
|
+
const title = document.createElement('span')
|
|
287
|
+
title.className = 'ttl'
|
|
288
|
+
const cnt = document.createElement('span')
|
|
289
|
+
cnt.className = 'cnt mono'
|
|
290
|
+
const route = document.createElement('span')
|
|
291
|
+
route.className = 'route mono'
|
|
292
|
+
head.append(title, cnt, route)
|
|
293
|
+
el.appendChild(head)
|
|
294
|
+
// Resize handles (owner ruling: the frame canvas is stretchable and
|
|
295
|
+
// adjustable): eight directions, pointer seam routes them to the
|
|
296
|
+
// frame-resize gesture. Purely visual affordance + hit targets.
|
|
297
|
+
for (const dir of ['n', 's', 'e', 'w', 'ne', 'nw', 'se', 'sw']) {
|
|
298
|
+
const handle = document.createElement('div')
|
|
299
|
+
handle.className = `p-frame-handle p-h-${dir}`
|
|
300
|
+
handle.dataset.frame = name
|
|
301
|
+
handle.dataset.dir = dir
|
|
302
|
+
el.appendChild(handle)
|
|
303
|
+
}
|
|
304
|
+
frameLayer.appendChild(el)
|
|
305
|
+
frameEls.set(name, el)
|
|
306
|
+
}
|
|
307
|
+
const hue = frameHue(name)
|
|
308
|
+
el.style.setProperty('--frame-line', hue.line)
|
|
309
|
+
el.style.setProperty('--frame-bg', hue.bg)
|
|
310
|
+
el.style.left = `${rect.x}px`
|
|
311
|
+
el.style.top = `${rect.y}px`
|
|
312
|
+
el.style.width = `${rect.w}px`
|
|
313
|
+
el.style.height = `${rect.h}px`
|
|
314
|
+
const title = el.querySelector<HTMLElement>('.ttl')!
|
|
315
|
+
const cnt = el.querySelector<HTMLElement>('.cnt')!
|
|
316
|
+
const route = el.querySelector<HTMLElement>('.route')!
|
|
317
|
+
if (title.textContent !== name) title.textContent = name
|
|
318
|
+
let members = 0
|
|
319
|
+
let cross = 0
|
|
320
|
+
for (const m of model.allNodes()) {
|
|
321
|
+
const mem = m.memberships.find(x => x.team === name)
|
|
322
|
+
if (mem === undefined) continue
|
|
323
|
+
members += 1
|
|
324
|
+
if (m.memberships.length > 1) cross += 1
|
|
325
|
+
}
|
|
326
|
+
const cntText = `${members} 成员` + (cross > 0 ? ` +${cross} 跨队` : '')
|
|
327
|
+
if (cnt.textContent !== cntText) cnt.textContent = cntText
|
|
328
|
+
const routeText0 = routeText(name)
|
|
329
|
+
if (route.textContent !== routeText0) route.textContent = routeText0
|
|
330
|
+
}
|
|
331
|
+
for (const [name, el] of [...frameEls]) {
|
|
332
|
+
if (!seen.has(name)) { el.remove(); frameEls.delete(name) }
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Remote card sub line: score + remote team count/names behind this peer. */
|
|
337
|
+
function remoteSubText(host: string): string {
|
|
338
|
+
const rows = lastRemoteTeams.get(host) ?? []
|
|
339
|
+
const names = rows.map(r => r.name !== undefined && r.name !== '' ? r.name : r.team)
|
|
340
|
+
const head = names.slice(0, 2).join('、')
|
|
341
|
+
const more = names.length > 2 ? ` +${names.length - 2}` : ''
|
|
342
|
+
const score = peerScoreByText.get(host)
|
|
343
|
+
return (score !== undefined ? `score ${score} · ` : '') + `${rows.length} 远端团队` + (head !== '' ? `:${head}${more}` : '')
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** The mono route line `<host>/canvas/<name>` when the payload carries it. */
|
|
347
|
+
function routeText(name: string): string {
|
|
348
|
+
const t = lastTeams.find(x => x.name === name)
|
|
349
|
+
return t?.team ?? name
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ── keyed diff: nodes ──
|
|
353
|
+
function renderNodes(): void {
|
|
354
|
+
const seen = new Set<string>()
|
|
355
|
+
for (const n of model.allNodes()) {
|
|
356
|
+
seen.add(n.id)
|
|
357
|
+
let el = nodeEls.get(n.id)
|
|
358
|
+
if (el === undefined) {
|
|
359
|
+
el = document.createElement('div')
|
|
360
|
+
el.className = 'p-node'
|
|
361
|
+
el.dataset.id = n.id
|
|
362
|
+
el.tabIndex = 0
|
|
363
|
+
const nm = document.createElement('div')
|
|
364
|
+
nm.className = 'nm'
|
|
365
|
+
const dot = document.createElement('span')
|
|
366
|
+
dot.className = 'dot'
|
|
367
|
+
const name = document.createElement('span')
|
|
368
|
+
name.className = 'nm-text'
|
|
369
|
+
nm.append(dot, name)
|
|
370
|
+
const sub = document.createElement('div')
|
|
371
|
+
sub.className = 'sub mono'
|
|
372
|
+
const prio = document.createElement('span')
|
|
373
|
+
prio.className = 'prio mono'
|
|
374
|
+
el.append(nm, sub, prio)
|
|
375
|
+
nodeLayer.appendChild(el)
|
|
376
|
+
nodeEls.set(n.id, el)
|
|
377
|
+
}
|
|
378
|
+
const r = nodeRect(n)
|
|
379
|
+
el.style.left = `${r.x}px`
|
|
380
|
+
el.style.top = `${r.y}px`
|
|
381
|
+
el.classList.toggle('cold', !n.live)
|
|
382
|
+
el.classList.toggle('remote', n.remote === true)
|
|
383
|
+
el.classList.toggle('selected', model.isSelected(n.id))
|
|
384
|
+
el.setAttribute('aria-selected', String(model.isSelected(n.id)))
|
|
385
|
+
const host = n.id.slice('peer-'.length)
|
|
386
|
+
const nmText = n.remote === true ? host : displayName(n)
|
|
387
|
+
const nmEl = el.querySelector<HTMLElement>('.nm-text')!
|
|
388
|
+
if (nmEl.textContent !== nmText) nmEl.textContent = nmText
|
|
389
|
+
const subEl = el.querySelector<HTMLElement>('.sub')!
|
|
390
|
+
const subBase = n.team + (n.name !== undefined && n.name !== '' ? ' · ' + n.name : '')
|
|
391
|
+
// When the title fell back to the team label the sub line would repeat
|
|
392
|
+
// it verbatim; collapse to the cross-team suffix alone in that case.
|
|
393
|
+
const subText = n.remote === true
|
|
394
|
+
? remoteSubText(host)
|
|
395
|
+
: (subBase === nmText ? '' : subBase)
|
|
396
|
+
+ (n.memberships.length > 1 ? ` · 跨队×${n.memberships.length}` : '')
|
|
397
|
+
+ (n.teams !== undefined && n.teams.length > 0 ? ` · 网络队×${n.teams.length}` : '')
|
|
398
|
+
if (subEl.textContent !== subText) subEl.textContent = subText
|
|
399
|
+
const prioEl = el.querySelector<HTMLElement>('.prio')!
|
|
400
|
+
const prioText = n.remote === true ? 'peer' : (n.memberships[0] !== undefined ? `P${n.memberships[0]!.index}` : '')
|
|
401
|
+
if (prioEl.textContent !== prioText) prioEl.textContent = prioText
|
|
402
|
+
}
|
|
403
|
+
for (const [id, el] of [...nodeEls]) {
|
|
404
|
+
if (!seen.has(id)) { el.remove(); nodeEls.delete(id) }
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// ── edges: stateless, rebuilt wholesale ──
|
|
409
|
+
// Derived from the MODEL (not lastTeams): optimistic membership changes
|
|
410
|
+
// must repaint their edges immediately, before the host confirms.
|
|
411
|
+
function renderEdges(): void {
|
|
412
|
+
while (svg.firstChild !== null) svg.firstChild.remove()
|
|
413
|
+
const teams = model.allFrames().map(([name]) => ({ name, members: model.teamMemberIds(name).map(id => ({ id })) }))
|
|
414
|
+
for (const e of starEdges(teams, new Map(model.allFrames()), model.positions())) {
|
|
415
|
+
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line')
|
|
416
|
+
line.setAttribute('class', 'e-member' + (e.dashed ? ' dashed' : ''))
|
|
417
|
+
line.setAttribute('x1', String(e.x1))
|
|
418
|
+
line.setAttribute('y1', String(e.y1))
|
|
419
|
+
line.setAttribute('x2', String(e.x2))
|
|
420
|
+
line.setAttribute('y2', String(e.y2))
|
|
421
|
+
svg.appendChild(line)
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// ── federation overlay (PR D): peer chips + activity/federal edges ──
|
|
426
|
+
// Runs on EVERY render, outside the model-revision gate: the poll's
|
|
427
|
+
// inFlight ages must tick even when the model is unchanged, and the
|
|
428
|
+
// badge column tracks the content bounds each reconcile. Its SVG
|
|
429
|
+
// children are tracked separately because renderEdges clears the svg
|
|
430
|
+
// wholesale - this appends after it (render() orders the two).
|
|
431
|
+
let fedSvg: SVGElement[] = []
|
|
432
|
+
let lastPlacements: PeerPlacement[] = []
|
|
433
|
+
let lastPeers: ReadonlyArray<PeerRow> = []
|
|
434
|
+
let lastRemoteTeams: ReadonlyMap<string, RemoteTeamRow[]> = new Map()
|
|
435
|
+
const peerScoreByText = new Map<string, number | undefined>()
|
|
436
|
+
let lastInFlight: ReadonlyArray<InFlightRow> = []
|
|
437
|
+
|
|
438
|
+
function renderFederation(): void {
|
|
439
|
+
for (const el of fedSvg) el.remove()
|
|
440
|
+
fedSvg = []
|
|
441
|
+
// Peer placements come from the MODEL's remote nodes (v1 ruling: peers
|
|
442
|
+
// are cards in the same pipeline, dragged/saved like sessions) — the
|
|
443
|
+
// old chip column is gone.
|
|
444
|
+
const placements: PeerPlacement[] = model.allNodes()
|
|
445
|
+
.filter(n => n.remote === true)
|
|
446
|
+
.map(n => ({ url: n.peerUrl ?? n.id.slice(5), score: n.score, x: n.x, y: n.y }))
|
|
447
|
+
lastPlacements = placements
|
|
448
|
+
const bounds = model.contentBounds()
|
|
449
|
+
if (bounds !== null) {
|
|
450
|
+
// Edges: titlebar anchors (fx + fw/2, fy + 11, edges.ts lockstep).
|
|
451
|
+
const anchors = new Map<string, FrameAnchor>()
|
|
452
|
+
for (const [name, rect] of model.allFrames()) {
|
|
453
|
+
anchors.set(name, { name, x: rect.x + rect.w / 2, y: rect.y + FRAME_HEAD_LIFT })
|
|
454
|
+
}
|
|
455
|
+
for (const e of activityEdges(lastInFlight, anchors, placements, clock())) {
|
|
456
|
+
fedSvg.push(...fedEdge(e, 'e-activity', e.label))
|
|
457
|
+
}
|
|
458
|
+
for (const e of federalEdges(placements, bounds)) {
|
|
459
|
+
fedSvg.push(...fedEdge(e, 'e-federal', e.label))
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** One overlay line + its optional midpoint label (offset +14/+18). */
|
|
465
|
+
function fedEdge(
|
|
466
|
+
e: { x1: number; y1: number; x2: number; y2: number },
|
|
467
|
+
cls: string,
|
|
468
|
+
label: string,
|
|
469
|
+
): SVGElement[] {
|
|
470
|
+
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line')
|
|
471
|
+
line.setAttribute('class', cls)
|
|
472
|
+
line.setAttribute('x1', String(e.x1))
|
|
473
|
+
line.setAttribute('y1', String(e.y1))
|
|
474
|
+
line.setAttribute('x2', String(e.x2))
|
|
475
|
+
line.setAttribute('y2', String(e.y2))
|
|
476
|
+
svg.appendChild(line)
|
|
477
|
+
const out: SVGElement[] = [line]
|
|
478
|
+
if (label !== '') {
|
|
479
|
+
const t = document.createElementNS('http://www.w3.org/2000/svg', 'text')
|
|
480
|
+
t.setAttribute('class', 'edge-label mono')
|
|
481
|
+
t.setAttribute('x', String((e.x1 + e.x2) / 2 + 14))
|
|
482
|
+
t.setAttribute('y', String((e.y1 + e.y2) / 2 + 18))
|
|
483
|
+
t.textContent = label
|
|
484
|
+
svg.appendChild(t)
|
|
485
|
+
out.push(t)
|
|
486
|
+
}
|
|
487
|
+
return out
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** Statusbar fly: fit the frames ∪ peers region (flyBounds pads it). */
|
|
491
|
+
function flyToFederation(): void {
|
|
492
|
+
vp = fitView(flyBounds(model.contentBounds(), lastPlacements), root.clientWidth || viewW, root.clientHeight || viewH)
|
|
493
|
+
applyViewport()
|
|
494
|
+
markDirty()
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// ── status counts ──
|
|
498
|
+
function renderStatus(input: PlanningInput): void {
|
|
499
|
+
const live = input.sessions.filter(s => s.live !== false).length
|
|
500
|
+
const cold = input.sessions.length - live
|
|
501
|
+
statusLive.textContent = ''
|
|
502
|
+
const bLive = document.createElement('b')
|
|
503
|
+
bLive.className = 'ok'
|
|
504
|
+
bLive.textContent = `● ${live} live`
|
|
505
|
+
statusLive.appendChild(bLive)
|
|
506
|
+
statusCold.textContent = ''
|
|
507
|
+
const bCold = document.createElement('b')
|
|
508
|
+
bCold.className = 'warn'
|
|
509
|
+
bCold.textContent = `● ${cold} cold`
|
|
510
|
+
statusCold.appendChild(bCold)
|
|
511
|
+
statusAct.textContent = `${input.inFlight?.length ?? 0} inFlight`
|
|
512
|
+
statusMut.className = 'mut'
|
|
513
|
+
statusMut.textContent = `${input.teams.length} 队 · ${input.peerCount} peer`
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// ── canvas write actions: optimistic apply + team-scoped undo ──
|
|
517
|
+
// Per-team REFERENCE COUNT: two queued actions on one team keep the guard
|
|
518
|
+
// up until BOTH settle (a Set deleted per action let a mid-queue poll wipe
|
|
519
|
+
// the second action's optimistic membership).
|
|
520
|
+
const pendingTeams = new Map<string, number>()
|
|
521
|
+
const pendingAdd = (team: string): void => { pendingTeams.set(team, (pendingTeams.get(team) ?? 0) + 1) }
|
|
522
|
+
const pendingRelease = (team: string): void => {
|
|
523
|
+
const n = (pendingTeams.get(team) ?? 0) - 1
|
|
524
|
+
if (n <= 0) pendingTeams.delete(team)
|
|
525
|
+
else pendingTeams.set(team, n)
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function emitAction(a: CanvasAction): void {
|
|
529
|
+
// Optimistic seats: a write may reference a session the teamed gate
|
|
530
|
+
// keeps OFF the model (no canvas card yet). The write actions read and
|
|
531
|
+
// write memberships through the node table, so materialize those ids
|
|
532
|
+
// from the last poll's inventory first — the next reconcile refreshes
|
|
533
|
+
// label/live/teams and keeps the saved-or-seat position.
|
|
534
|
+
if (a.type === 'add-member' || a.type === 'remove-member' || a.type === 'reorder' || a.type === 'create-team') {
|
|
535
|
+
const ids = a.type === 'reorder' ? a.ops.map(op => op.id) : [...a.ids]
|
|
536
|
+
for (const id of ids) {
|
|
537
|
+
if (id === '' || id.startsWith('peer-')) continue
|
|
538
|
+
if (model.getNode(id) !== undefined) continue
|
|
539
|
+
const s = lastSessions.find(row => row.id === id)
|
|
540
|
+
if (s === undefined) continue
|
|
541
|
+
const saved = layoutDoc?.nodes[id]
|
|
542
|
+
const seat = saved !== undefined ? { x: saved.x, y: saved.y } : seatFor2(id)
|
|
543
|
+
model.upsertNode({
|
|
544
|
+
id, x: seat.x, y: seat.y, label: s.label, team: s.team, name: s.name,
|
|
545
|
+
live: s.live !== false, memberships: [],
|
|
546
|
+
...(s.teams !== undefined && s.teams.length > 0 ? { teams: s.teams } : {}),
|
|
547
|
+
})
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
const undo = applyAction(model, a)
|
|
551
|
+
const team = actionTeam(a)
|
|
552
|
+
pendingAdd(team)
|
|
553
|
+
render()
|
|
554
|
+
void deps.onCanvasAction(a).then(
|
|
555
|
+
ok => {
|
|
556
|
+
pendingRelease(team)
|
|
557
|
+
if (!ok) undo() // host refused: roll this action's optimistic delta back
|
|
558
|
+
render()
|
|
559
|
+
},
|
|
560
|
+
() => {
|
|
561
|
+
// Contract-robustness: a rejecting action channel must not pin the
|
|
562
|
+
// team's guard (and its optimistic state) forever — and per §3.4
|
|
563
|
+
// 不吞错, the rollback surfaces to the user.
|
|
564
|
+
pendingRelease(team)
|
|
565
|
+
undo()
|
|
566
|
+
notice('error', '操作通道异常,已回滚')
|
|
567
|
+
render()
|
|
568
|
+
},
|
|
569
|
+
)
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// ── notices (host errors verbatim; design.md §3.4 不吞错) ──
|
|
573
|
+
function notice(kind: 'error' | 'info', text: string): void {
|
|
574
|
+
while (noticeStack.childElementCount >= 3) noticeStack.firstElementChild?.remove()
|
|
575
|
+
const item = document.createElement('div')
|
|
576
|
+
item.className = 'p-notice' + (kind === 'error' ? ' error' : '')
|
|
577
|
+
item.setAttribute('role', kind === 'error' ? 'alert' : 'status')
|
|
578
|
+
item.textContent = text
|
|
579
|
+
noticeStack.appendChild(item)
|
|
580
|
+
noticeTimers.push(setTimeout(() => item.remove(), 4000))
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// ── popovers: 建队 dialog, context menu, 未入网 dropdown (planning-menus) ──
|
|
584
|
+
const menus = createPlanningMenus({
|
|
585
|
+
root, netBtn, signal, model,
|
|
586
|
+
teams: () => lastTeams,
|
|
587
|
+
anchorFor: (t) => (t.kind === 'node' ? nodeEls.get(t.id) : frameEls.get(t.name)) ?? null,
|
|
588
|
+
emit: emitAction,
|
|
589
|
+
notice,
|
|
590
|
+
})
|
|
591
|
+
|
|
592
|
+
// ── 节点列表 drawer (planning-nodelist) ──
|
|
593
|
+
let lastSessions: ReadonlyArray<PlanningSession> = []
|
|
594
|
+
let lastCanvasMemberIds: ReadonlySet<string> = new Set()
|
|
595
|
+
const nodes = createNodeList({
|
|
596
|
+
root, trigger: nodesBtn, signal, model,
|
|
597
|
+
facts: () => ({ sessions: lastSessions, canvasMemberIds: lastCanvasMemberIds, remoteTeams: lastRemoteTeams, teams: lastTeams }),
|
|
598
|
+
menuItem: menus.menuItem,
|
|
599
|
+
emit: emitAction,
|
|
600
|
+
openNameDialog: menus.openNameDialog,
|
|
601
|
+
locate: locateNode,
|
|
602
|
+
})
|
|
603
|
+
|
|
604
|
+
/** Center the viewport on one canvas node (the 已组队 row's 定位). */
|
|
605
|
+
function locateNode(id: string): void {
|
|
606
|
+
const n = model.getNode(id)
|
|
607
|
+
if (n === undefined) return
|
|
608
|
+
nodes.close()
|
|
609
|
+
const size = deps.viewSize?.() ?? { w: root.clientWidth > 0 ? root.clientWidth : 800, h: root.clientHeight > 0 ? root.clientHeight : 600 }
|
|
610
|
+
vp = clampViewport({ scale: vp.scale, x: n.x - size.w / (2 * vp.scale), y: n.y - size.h / (2 * vp.scale) })
|
|
611
|
+
applyViewport()
|
|
612
|
+
renderedRevision = -1
|
|
613
|
+
render()
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// ── gestures ──
|
|
617
|
+
function localXY(ev: { clientX: number; clientY: number }): { x: number; y: number } {
|
|
618
|
+
const rect = root.getBoundingClientRect()
|
|
619
|
+
return { x: ev.clientX - rect.left, y: ev.clientY - rect.top }
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/** Drop dispatch for a moved node drag: 入队 / 队内 y 排序. Leaving a team
|
|
623
|
+
* is an explicit menu action only — dragging out to blank is a position
|
|
624
|
+
* move (owner removal 2026-09-02: the drag-out-to-blank 离队 path is
|
|
625
|
+
* removed; accidental blank drops wrote whole-group removals). */
|
|
626
|
+
function dropDispatch(gesture: Extract<Gesture, { kind: 'node' }>, ev: SeamPointer): void {
|
|
627
|
+
const p = localXY(ev)
|
|
628
|
+
const w = screenToWorld(vp, p.x, p.y)
|
|
629
|
+
const f = innermostFrameAt(new Map(model.allFrames()), w)
|
|
630
|
+
// 契约不变量 1:成员必须 joined session——对等卡永远不进名单写面。
|
|
631
|
+
const localIds = gesture.ids.filter(id => model.getNode(id)?.remote !== true)
|
|
632
|
+
if (f !== undefined) {
|
|
633
|
+
const fresh = localIds.filter(id => !(model.getNode(id)?.memberships.some(m => m.team === f)))
|
|
634
|
+
if (fresh.length > 0) {
|
|
635
|
+
emitAction({ type: 'add-member', team: f, ids: fresh }) // 入队:不自动离原队(多对多)
|
|
636
|
+
return
|
|
637
|
+
}
|
|
638
|
+
// Reorder only for LOCAL single-card drops: dropping a peer card is a
|
|
639
|
+
// position move, never a roster write (P2 from the incremental seat).
|
|
640
|
+
if (gesture.ids.length === 1 && model.getNode(gesture.clicked)?.remote !== true) {
|
|
641
|
+
// Already a member: an in-frame drop sorts that frame's members by y.
|
|
642
|
+
const current = model.teamMemberIds(f)
|
|
643
|
+
const desired = yOrderedMembers(model, f)
|
|
644
|
+
if (desired.join('\u0000') !== current.join('\u0000')) {
|
|
645
|
+
emitAction({ type: 'reorder', team: f, ops: reorderOps(current, desired) })
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
// Blank drop: a position move only. No roster writes — the explicit
|
|
650
|
+
// context-menu 离队 (and the node drawer) remain the removal paths.
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function pointerDown(ev: SeamPointer): void {
|
|
654
|
+
if (viewW === 0) syncViewSize()
|
|
655
|
+
if (menus.isDialogOpen()) { ev.preventDefault(); return } // modal: no gestures behind the dialog
|
|
656
|
+
const target = ev.target as Element | null
|
|
657
|
+
// Context-menu dismissal: the first pointerdown outside an open menu
|
|
658
|
+
// closes it and is swallowed; a press on the menu's own chrome is
|
|
659
|
+
// swallowed too — only its items act.
|
|
660
|
+
if (menus.isMenuOpen()) {
|
|
661
|
+
if (target !== null && target.closest('.p-menu') !== null) return
|
|
662
|
+
menus.closeMenu()
|
|
663
|
+
ev.preventDefault()
|
|
664
|
+
return
|
|
665
|
+
}
|
|
666
|
+
const p = localXY(ev)
|
|
667
|
+
// 右键按下:按住拖动 = 平移(Figma 惯例),原地抬起 = 上下文菜单。
|
|
668
|
+
// Pan actually starts on the first move beyond RIGHT_DRAG_THRESHOLD px;
|
|
669
|
+
// a clean press falls through to the context menu at pointerup.
|
|
670
|
+
if (ev.button === 2) {
|
|
671
|
+
rightPress = { x: p.x, y: p.y, target: ev.target, moved: false }
|
|
672
|
+
ev.preventDefault()
|
|
673
|
+
try { root.setPointerCapture(ev.pointerId ?? 0) } catch { /* jsdom */ }
|
|
674
|
+
return
|
|
675
|
+
}
|
|
676
|
+
if (target !== null && (target.closest('button') !== null || target.closest('.p-lamp') !== null || target.closest('.p-status') !== null)) return
|
|
677
|
+
const nodeEl = target !== null ? target.closest<HTMLElement>('.p-node') : null
|
|
678
|
+
const headEl = target !== null ? target.closest<HTMLElement>('.p-frame-head') : null
|
|
679
|
+
if ((nodeEl !== null || headEl !== null) && ev.button === 1) {
|
|
680
|
+
// Middle-button anywhere — including on cards and frame heads — pans
|
|
681
|
+
// (baf51a1 claimed this; the guards used to swallow it before the pan
|
|
682
|
+
// branch was reachable, and skipped preventDefault, re-enabling
|
|
683
|
+
// native autoscroll).
|
|
684
|
+
gesture = { kind: 'pan', last: p, moved: false }
|
|
685
|
+
ev.preventDefault()
|
|
686
|
+
try { root.setPointerCapture(ev.pointerId ?? 0) } catch { /* jsdom */ }
|
|
687
|
+
return
|
|
688
|
+
}
|
|
689
|
+
const handleEl = target !== null ? target.closest<HTMLElement>('.p-frame-handle') : null
|
|
690
|
+
// Selection clearing: any press NOT on the selected frame's own chrome
|
|
691
|
+
// (head/handle/body) drops the frame selection.
|
|
692
|
+
if (selectedFrame !== undefined) {
|
|
693
|
+
const selFrameEl = frameEls.get(selectedFrame)
|
|
694
|
+
const onSelectedChrome = target !== null && selFrameEl !== undefined &&
|
|
695
|
+
(selFrameEl.contains(target) || selFrameEl === target)
|
|
696
|
+
const onSelectedHead = headEl !== null && headEl.dataset.frame === selectedFrame
|
|
697
|
+
if (!onSelectedChrome && !onSelectedHead) markSelectedFrame(undefined)
|
|
698
|
+
}
|
|
699
|
+
if (handleEl !== null && ev.button === 0) {
|
|
700
|
+
// Frame resize (owner ruling): record the frame's current rect and
|
|
701
|
+
// the world-space origin; the move branch re-solves the rect per
|
|
702
|
+
// direction with a minimum size clamp.
|
|
703
|
+
const name = handleEl.dataset.frame ?? ''
|
|
704
|
+
markSelectedFrame(name)
|
|
705
|
+
const rect = model.getFrame(name)
|
|
706
|
+
if (rect === undefined) return
|
|
707
|
+
gesture = { kind: 'frame-resize', name, dir: handleEl.dataset.dir ?? 'se', startRect: { ...rect }, origin: screenToWorld(vp, p.x, p.y), moved: false }
|
|
708
|
+
ev.preventDefault()
|
|
709
|
+
try { root.setPointerCapture(ev.pointerId ?? 0) } catch { /* jsdom: no capture */ }
|
|
710
|
+
return
|
|
711
|
+
}
|
|
712
|
+
if (nodeEl !== null) {
|
|
713
|
+
if (ev.button !== 0) return // right on a card: contextmenu only, never a card drag (F3)
|
|
714
|
+
const id = nodeEl.dataset.id ?? ''
|
|
715
|
+
const already = model.isSelected(id)
|
|
716
|
+
if (!already && !ev.shiftKey) model.setSelection([id])
|
|
717
|
+
const ids = ev.shiftKey || already ? (model.isSelected(id) ? model.selectedIds() : [...model.selectedIds(), id]) : [id]
|
|
718
|
+
gesture = { kind: 'node', clicked: id, ids, last: screenToWorld(vp, p.x, p.y), moved: false, el: nodeEl }
|
|
719
|
+
nodeEl.classList.add('dragging')
|
|
720
|
+
} else if (headEl !== null) {
|
|
721
|
+
if (ev.button !== 0) return
|
|
722
|
+
const name = headEl.dataset.frame ?? ''
|
|
723
|
+
// Selecting a frame keeps its resize handles visible (hover-only
|
|
724
|
+
// handles vanished the moment the pointer left).
|
|
725
|
+
markSelectedFrame(name)
|
|
726
|
+
const snap = model.beginFrameDrag(name)
|
|
727
|
+
if (snap === undefined) return
|
|
728
|
+
gesture = { kind: 'frame', name, snap, origin: screenToWorld(vp, p.x, p.y), moved: false }
|
|
729
|
+
} else {
|
|
730
|
+
if (ev.button === 1 || (ev.button === 0 && spaceDown)) {
|
|
731
|
+
gesture = { kind: 'pan', last: p, moved: false }
|
|
732
|
+
} else if (ev.button === 0) {
|
|
733
|
+
gesture = { kind: 'marquee', origin: p, last: p, additive: ev.shiftKey }
|
|
734
|
+
marquee.style.display = 'block'
|
|
735
|
+
marquee.style.left = `${p.x}px`
|
|
736
|
+
marquee.style.top = `${p.y}px`
|
|
737
|
+
marquee.style.width = '0px'
|
|
738
|
+
marquee.style.height = '0px'
|
|
739
|
+
} else return
|
|
740
|
+
}
|
|
741
|
+
ev.preventDefault()
|
|
742
|
+
try { root.setPointerCapture(ev.pointerId ?? 0) } catch { /* jsdom: no capture */ }
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function pointerMove(ev: SeamPointer): void {
|
|
746
|
+
const p = localXY(ev)
|
|
747
|
+
if (rightPress !== null) {
|
|
748
|
+
// Right-hold pan: incremental viewport follow once past the threshold.
|
|
749
|
+
// User ruling (2026-08-30): the drag moves the CONTENT, not the window —
|
|
750
|
+
// drag right and the canvas follows the cursor (Figma convention), so
|
|
751
|
+
// the viewport origin moves OPPOSITE the pointer delta. The wheel and
|
|
752
|
+
// middle/space pans keep their own established directions.
|
|
753
|
+
const dx = p.x - rightPress.x
|
|
754
|
+
const dy = p.y - rightPress.y
|
|
755
|
+
if (Math.hypot(dx, dy) > 4) {
|
|
756
|
+
rightPress.moved = true
|
|
757
|
+
vp = clampViewport(panBy(vp, -dx, -dy))
|
|
758
|
+
rightPress.x = p.x
|
|
759
|
+
rightPress.y = p.y
|
|
760
|
+
applyViewport()
|
|
761
|
+
}
|
|
762
|
+
return
|
|
763
|
+
}
|
|
764
|
+
if (gesture.kind === 'node') {
|
|
765
|
+
const w = screenToWorld(vp, p.x, p.y)
|
|
766
|
+
const dx = w.x - gesture.last.x
|
|
767
|
+
const dy = w.y - gesture.last.y
|
|
768
|
+
if (dx === 0 && dy === 0) return
|
|
769
|
+
gesture.moved = true
|
|
770
|
+
gesture.last = w
|
|
771
|
+
model.dragNodes(gesture.ids, dx, dy)
|
|
772
|
+
// Drop-target highlight: the innermost frame under the pointer, but
|
|
773
|
+
// only when the drop would change something (some id not yet member).
|
|
774
|
+
const f = innermostFrameAt(new Map(model.allFrames()), w)
|
|
775
|
+
const joinables = gesture.ids.filter(id => model.getNode(id)?.remote !== true)
|
|
776
|
+
const relevant = f !== undefined && joinables.length > 0
|
|
777
|
+
for (const [name, el] of frameEls) el.classList.toggle('drop-target', relevant && name === f)
|
|
778
|
+
render()
|
|
779
|
+
} else if (gesture.kind === 'frame') {
|
|
780
|
+
const w = screenToWorld(vp, p.x, p.y)
|
|
781
|
+
const dx = w.x - gesture.origin.x
|
|
782
|
+
const dy = w.y - gesture.origin.y
|
|
783
|
+
if (!gesture.moved && dx === 0 && dy === 0) return
|
|
784
|
+
gesture.moved = true
|
|
785
|
+
model.applyFrameDrag(gesture.snap, dx, dy)
|
|
786
|
+
render()
|
|
787
|
+
} else if (gesture.kind === 'frame-resize') {
|
|
788
|
+
// Re-solve the rect from the start rect plus the world delta, per
|
|
789
|
+
// direction. Minimum 220x120 keeps the titlebar and成员 badges
|
|
790
|
+
// readable; the frame rect is independent of member positions, so
|
|
791
|
+
// no node moves — star edges follow on the next render.
|
|
792
|
+
const w = screenToWorld(vp, p.x, p.y)
|
|
793
|
+
const dx = w.x - gesture.origin.x
|
|
794
|
+
const dy = w.y - gesture.origin.y
|
|
795
|
+
if (!gesture.moved && dx === 0 && dy === 0) return
|
|
796
|
+
gesture.moved = true
|
|
797
|
+
const MIN_W = 220
|
|
798
|
+
const MIN_H = 120
|
|
799
|
+
const r = { ...gesture.startRect }
|
|
800
|
+
let { x, y, w: width, h: height } = r
|
|
801
|
+
if (gesture.dir.includes('e')) width = Math.max(MIN_W, r.w + dx)
|
|
802
|
+
if (gesture.dir.includes('s')) height = Math.max(MIN_H, r.h + dy)
|
|
803
|
+
if (gesture.dir.includes('w')) {
|
|
804
|
+
width = Math.max(MIN_W, r.w - dx)
|
|
805
|
+
x = r.x + (r.w - width)
|
|
806
|
+
}
|
|
807
|
+
if (gesture.dir.includes('n')) {
|
|
808
|
+
height = Math.max(MIN_H, r.h - dy)
|
|
809
|
+
y = r.y + (r.h - height)
|
|
810
|
+
}
|
|
811
|
+
model.setFrame(gesture.name, { x: Math.round(x), y: Math.round(y), w: Math.round(width), h: Math.round(height) })
|
|
812
|
+
render()
|
|
813
|
+
} else if (gesture.kind === 'pan') {
|
|
814
|
+
vp = panBy(vp, p.x - gesture.last.x, p.y - gesture.last.y)
|
|
815
|
+
gesture.last = p
|
|
816
|
+
gesture.moved = true
|
|
817
|
+
applyViewport()
|
|
818
|
+
} else if (gesture.kind === 'marquee') {
|
|
819
|
+
const x = Math.min(gesture.origin.x, p.x)
|
|
820
|
+
const y = Math.min(gesture.origin.y, p.y)
|
|
821
|
+
marquee.style.left = `${x}px`
|
|
822
|
+
marquee.style.top = `${y}px`
|
|
823
|
+
marquee.style.width = `${Math.abs(p.x - gesture.origin.x)}px`
|
|
824
|
+
marquee.style.height = `${Math.abs(p.y - gesture.origin.y)}px`
|
|
825
|
+
gesture.last = p
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function pointerUp(ev: SeamPointer): void {
|
|
830
|
+
if (rightPress !== null) {
|
|
831
|
+
const wasPan = rightPress.moved
|
|
832
|
+
const pressTarget = rightPress.target
|
|
833
|
+
const x = rightPress.x
|
|
834
|
+
const y = rightPress.y
|
|
835
|
+
rightPress = null
|
|
836
|
+
if (wasPan) {
|
|
837
|
+
suppressNextMenu = true
|
|
838
|
+
markDirty() // the viewport persists with the layout
|
|
839
|
+
} else if (pressTarget !== null) {
|
|
840
|
+
menus.contextMenuAt(x, y, pressTarget) // clean right-click: context menu
|
|
841
|
+
}
|
|
842
|
+
return
|
|
843
|
+
}
|
|
844
|
+
if (gesture.kind === 'node') {
|
|
845
|
+
const id = gesture.clicked
|
|
846
|
+
if (!gesture.moved) {
|
|
847
|
+
// A click (no drag) is pure selection — NEVER a drop. The F1 fix:
|
|
848
|
+
// an unmoved multi-select click used to fall through to dropDispatch
|
|
849
|
+
// and write an entire group's 离队 to the host.
|
|
850
|
+
if (ev.shiftKey) {
|
|
851
|
+
const current = model.selectedIds()
|
|
852
|
+
model.setSelection(current.includes(id) ? current.filter(x => x !== id) : [...current, id])
|
|
853
|
+
} else {
|
|
854
|
+
model.setSelection([id])
|
|
855
|
+
// 0.6.13: a clean single click is also a node pick — the host opens
|
|
856
|
+
// the chat drawer. Shift-click (multi-select) stays a pure selection.
|
|
857
|
+
deps.onNodePick?.(id)
|
|
858
|
+
}
|
|
859
|
+
} else {
|
|
860
|
+
dropDispatch(gesture, ev)
|
|
861
|
+
markDirty()
|
|
862
|
+
}
|
|
863
|
+
for (const el of frameEls.values()) el.classList.remove('drop-target')
|
|
864
|
+
gesture.el?.classList.remove('dragging')
|
|
865
|
+
render()
|
|
866
|
+
} else if (gesture.kind === 'frame') {
|
|
867
|
+
if (gesture.moved) markDirty()
|
|
868
|
+
} else if (gesture.kind === 'frame-resize') {
|
|
869
|
+
if (gesture.moved) markDirty() // the resized rect persists with the layout
|
|
870
|
+
} else if (gesture.kind === 'pan') {
|
|
871
|
+
if (gesture.moved) markDirty() // the viewport persists with the layout
|
|
872
|
+
} else if (gesture.kind === 'marquee') {
|
|
873
|
+
const p = gesture.last
|
|
874
|
+
const a = screenToWorld(vp, Math.min(gesture.origin.x, p.x), Math.min(gesture.origin.y, p.y))
|
|
875
|
+
const b = screenToWorld(vp, Math.max(gesture.origin.x, p.x), Math.max(gesture.origin.y, p.y))
|
|
876
|
+
model.marqueeSelect({ x: a.x, y: a.y, w: b.x - a.x, h: b.y - a.y }, gesture.additive)
|
|
877
|
+
marquee.style.display = 'none'
|
|
878
|
+
render()
|
|
879
|
+
}
|
|
880
|
+
gesture = { kind: 'none' }
|
|
881
|
+
try { root.releasePointerCapture(ev.pointerId ?? 0) } catch { /* not captured */ }
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
function wheel(ev: SeamPointer): void {
|
|
885
|
+
if (menus.isMenuOpen()) menus.closeMenu()
|
|
886
|
+
const p = localXY(ev)
|
|
887
|
+
// Wheel = pointer-anchored zoom (owner ruling 2026-09-01: 鼠标滚动 =
|
|
888
|
+
// 放大缩小), with or without Ctrl — both muscle memories land on the
|
|
889
|
+
// same action. Shift+wheel keeps the natural-scroll pan as the escape
|
|
890
|
+
// hatch. Line-mode wheels (Firefox, deltaMode=1) report ~3 per notch.
|
|
891
|
+
if (ev.shiftKey && !ev.ctrlKey) {
|
|
892
|
+
const line = ev.deltaMode === 1 ? 16 : 1
|
|
893
|
+
vp = clampViewport(panBy(vp, (ev.deltaX ?? 0) * line, (ev.deltaY ?? 0) * line))
|
|
894
|
+
} else {
|
|
895
|
+
const line = ev.deltaMode === 1 ? 16 : 1
|
|
896
|
+
const factor = Math.exp(-((ev.deltaY ?? 0) * line) * 0.0015)
|
|
897
|
+
vp = zoomAt(vp, factor, p.x, p.y)
|
|
898
|
+
}
|
|
899
|
+
ev.preventDefault()
|
|
900
|
+
applyViewport()
|
|
901
|
+
markDirty()
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
function zoomCenter(factor: number): void {
|
|
905
|
+
syncViewSize()
|
|
906
|
+
vp = zoomAt(vp, factor, viewW / 2, viewH / 2)
|
|
907
|
+
applyViewport()
|
|
908
|
+
markDirty()
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
function fitToContent(): void {
|
|
912
|
+
syncViewSize()
|
|
913
|
+
vp = fitView(model.contentBounds(), viewW, viewH)
|
|
914
|
+
applyViewport()
|
|
915
|
+
markDirty()
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
function key(ev: SeamKey): void {
|
|
919
|
+
if (menus.isDialogOpen()) return // modal: the dialog input handles Enter/Esc itself
|
|
920
|
+
if (ev.ctrlKey || ev.metaKey) return // browser/OS chords stay browser/OS
|
|
921
|
+
if (menus.isMenuOpen()) {
|
|
922
|
+
// The menu owns most keys (its own handler navigates); Escape still
|
|
923
|
+
// closes from here so a seam/keyboard Esc never gets lost.
|
|
924
|
+
if (ev.key === 'Escape') { menus.closeMenu(); ev.preventDefault() }
|
|
925
|
+
return
|
|
926
|
+
}
|
|
927
|
+
if (ev.key === ' ' && ev.target === root) { spaceDown = true; ev.preventDefault(); return }
|
|
928
|
+
// 键盘全等路径(WCAG 2.2 Dragging Movements):
|
|
929
|
+
const headTarget = (ev.target as Element | null)?.closest?.('.p-frame-head') ?? null
|
|
930
|
+
if ((ev.key === 'Enter' || ev.key === ' ') && headTarget !== null) {
|
|
931
|
+
ev.preventDefault()
|
|
932
|
+
const r = headTarget.getBoundingClientRect()
|
|
933
|
+
menus.contextMenuAt(r.left + r.width / 2, r.top + r.height / 2, headTarget)
|
|
934
|
+
return
|
|
935
|
+
}
|
|
936
|
+
if (ev.key === ' ' && (ev.target as Element | null)?.classList?.contains('p-node')) {
|
|
937
|
+
const el = ev.target as HTMLElement
|
|
938
|
+
const id = el.dataset.id ?? ''
|
|
939
|
+
const current = model.selectedIds()
|
|
940
|
+
model.setSelection(current.includes(id) ? current.filter(x => x !== id) : [...current, id])
|
|
941
|
+
render()
|
|
942
|
+
ev.preventDefault()
|
|
943
|
+
return
|
|
944
|
+
}
|
|
945
|
+
if ((ev.key === 'F10' && ev.shiftKey) || ev.key === 'ContextMenu') {
|
|
946
|
+
ev.preventDefault()
|
|
947
|
+
const fe = (document.activeElement as Element | null) ?? null
|
|
948
|
+
const r = fe?.getBoundingClientRect?.() ?? { left: 0, top: 0, width: 0, height: 0 }
|
|
949
|
+
menus.contextMenuAt(r.left + r.width / 2, r.top + r.height / 2, fe)
|
|
950
|
+
return
|
|
951
|
+
}
|
|
952
|
+
if (ev.key === 'Delete' || ev.key === 'Backspace') { ev.preventDefault(); deleteFocused(); return }
|
|
953
|
+
if (ev.altKey && (ev.key === 'ArrowUp' || ev.key === 'ArrowDown')) { ev.preventDefault(); altMove(ev.key === 'ArrowUp'); return }
|
|
954
|
+
if (ev.key === 'g' || ev.key === 'G') { menus.startCreateFromSelection(); return }
|
|
955
|
+
if (ev.key === '0') { fitToContent(); return }
|
|
956
|
+
if (ev.key === '=' || ev.key === '+') { zoomCenter(1.2); return }
|
|
957
|
+
if (ev.key === '-') { zoomCenter(1 / 1.2); return }
|
|
958
|
+
if (ev.key === 'Escape') {
|
|
959
|
+
if (menus.isMenuOpen()) { menus.closeMenu(); render(); return } // menu > clear selection
|
|
960
|
+
model.setSelection([])
|
|
961
|
+
render()
|
|
962
|
+
return
|
|
963
|
+
}
|
|
964
|
+
const step = ev.shiftKey ? 40 : 8
|
|
965
|
+
const dxdy: Record<string, [number, number]> = {
|
|
966
|
+
ArrowLeft: [-step, 0], ArrowRight: [step, 0], ArrowUp: [0, -step], ArrowDown: [0, step],
|
|
967
|
+
}
|
|
968
|
+
const d = dxdy[ev.key]
|
|
969
|
+
if (d !== undefined) {
|
|
970
|
+
ev.preventDefault()
|
|
971
|
+
const ids = model.selectedIds()
|
|
972
|
+
if (ids.length > 0) {
|
|
973
|
+
model.nudge(ids, d[0]!, d[1]!)
|
|
974
|
+
render()
|
|
975
|
+
markDirty()
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
/** Delete 按焦点对象:单队卡=离队、多队卡=下钻离队菜单、无队=info、框头=散队。 */
|
|
981
|
+
function deleteFocused(): void {
|
|
982
|
+
const fe = document.activeElement as HTMLElement | null
|
|
983
|
+
const headEl = fe?.closest?.<HTMLElement>('.p-frame-head') ?? null
|
|
984
|
+
const nodeEl = fe?.closest?.<HTMLElement>('.p-node') ?? null
|
|
985
|
+
if (headEl !== null) {
|
|
986
|
+
emitAction({ type: 'remove-team', name: headEl.dataset.frame ?? '' })
|
|
987
|
+
return
|
|
988
|
+
}
|
|
989
|
+
if (nodeEl !== null) {
|
|
990
|
+
const id = nodeEl.dataset.id ?? ''
|
|
991
|
+
const teams = model.getNode(id)?.memberships.map(m => m.team) ?? []
|
|
992
|
+
if (teams.length === 0) { notice('info', '该节点未加入任何团队'); return }
|
|
993
|
+
if (teams.length === 1) {
|
|
994
|
+
emitAction({ type: 'remove-member', team: teams[0]!, ids: [id] })
|
|
995
|
+
return
|
|
996
|
+
}
|
|
997
|
+
menus.openMenu(window.innerWidth / 2, window.innerHeight / 2, { kind: 'node', id }, 'leave', nodeEl)
|
|
998
|
+
return
|
|
999
|
+
}
|
|
1000
|
+
notice('info', '焦点不在节点或团队框上')
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
/** Alt+↑/↓: move the focused card within its single team's priority. */
|
|
1004
|
+
function altMove(up: boolean): void {
|
|
1005
|
+
const fe = document.activeElement as HTMLElement | null
|
|
1006
|
+
const nodeEl = fe?.closest?.<HTMLElement>('.p-node') ?? null
|
|
1007
|
+
if (nodeEl === null) return
|
|
1008
|
+
const id = nodeEl.dataset.id ?? ''
|
|
1009
|
+
const mem = model.getNode(id)?.memberships ?? []
|
|
1010
|
+
if (mem.length !== 1) return
|
|
1011
|
+
const team = mem[0]!.team
|
|
1012
|
+
const current = model.teamMemberIds(team)
|
|
1013
|
+
const idx = current.indexOf(id)
|
|
1014
|
+
const to = up ? Math.max(0, idx - 1) : Math.min(current.length - 1, idx + 1)
|
|
1015
|
+
if (to === idx) return
|
|
1016
|
+
const desired = [...current]
|
|
1017
|
+
desired.splice(idx, 1)
|
|
1018
|
+
desired.splice(to, 0, id)
|
|
1019
|
+
emitAction({ type: 'reorder', team, ops: reorderOps(current, desired) })
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
function keyUp(ev: SeamKey): void {
|
|
1023
|
+
if (ev.key === ' ') spaceDown = false
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
root.addEventListener('pointerdown', (ev) => pointerDown(ev as unknown as SeamPointer), signal)
|
|
1027
|
+
root.addEventListener('pointermove', (ev) => pointerMove(ev as unknown as SeamPointer), signal)
|
|
1028
|
+
root.addEventListener('pointerup', (ev) => pointerUp(ev as unknown as SeamPointer), signal)
|
|
1029
|
+
root.addEventListener('wheel', (ev) => wheel(ev as unknown as SeamPointer), { ...signal, passive: false })
|
|
1030
|
+
root.addEventListener('keydown', (ev) => key(ev as unknown as SeamKey), signal)
|
|
1031
|
+
root.addEventListener('keyup', (ev) => keyUp(ev as unknown as SeamKey), signal)
|
|
1032
|
+
root.addEventListener('contextmenu', (ev) => {
|
|
1033
|
+
const e = ev as unknown as SeamPointer
|
|
1034
|
+
e.preventDefault()
|
|
1035
|
+
// Right-drag pans (suppressNextMenu); a clean right-click opens the
|
|
1036
|
+
// menu programmatically at pointerup. Either way the native menu dies.
|
|
1037
|
+
e.preventDefault()
|
|
1038
|
+
if (suppressNextMenu) { suppressNextMenu = false; return }
|
|
1039
|
+
menus.contextMenuAt(e.clientX, e.clientY, e.target)
|
|
1040
|
+
}, signal)
|
|
1041
|
+
|
|
1042
|
+
// ── poll reconcile: incremental, never repositions existing nodes ──
|
|
1043
|
+
let lastTeams: ReadonlyArray<PlanningTeam> = []
|
|
1044
|
+
|
|
1045
|
+
function reconcile(input: PlanningInput): void {
|
|
1046
|
+
lastTeams = input.teams
|
|
1047
|
+
lastPeers = input.peers ?? []
|
|
1048
|
+
lastInFlight = input.inFlight ?? []
|
|
1049
|
+
// Unjoined sessions have no node on the canvas — the panel-slim
|
|
1050
|
+
// migration made this view the join surface, so they ride a compact
|
|
1051
|
+
// toolbar chip instead of vanishing entirely.
|
|
1052
|
+
lastSessions = input.sessions
|
|
1053
|
+
menus.setUnjoined(input.sessions.filter(s => s.joined !== true && !s.id.startsWith('peer-')))
|
|
1054
|
+
const memberships = new Map<string, Array<{ team: string; index: number }>>()
|
|
1055
|
+
for (const team of input.teams) {
|
|
1056
|
+
for (let i = 0; i < team.members.length; i++) {
|
|
1057
|
+
const id = team.members[i]!.id
|
|
1058
|
+
const list = memberships.get(id) ?? []
|
|
1059
|
+
list.push({ team: team.name, index: i })
|
|
1060
|
+
memberships.set(id, list)
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
lastCanvasMemberIds = new Set(memberships.keys())
|
|
1064
|
+
const seen = new Set<string>()
|
|
1065
|
+
// In-flight write guard: membership entries of teams with unsettled
|
|
1066
|
+
// writes come from the model, not the (stale) payload — a poll racing
|
|
1067
|
+
// an optimistic add must not snap the new edge away.
|
|
1068
|
+
const resolveMemberships = (id: string): Array<{ team: string; index: number }> => {
|
|
1069
|
+
const payload = memberships.get(id) ?? []
|
|
1070
|
+
if (pendingTeams.size === 0) return payload
|
|
1071
|
+
const keep = payload.filter(m => !pendingTeams.has(m.team))
|
|
1072
|
+
// In-flight teams read their in-membership from the team roster
|
|
1073
|
+
// (teamMemberIds), NOT from getNode(id).memberships: an optimistic
|
|
1074
|
+
// add can target a node the teamed gate kept OFF the model (no card
|
|
1075
|
+
// yet) — its membership lives only in the roster map, and dropping
|
|
1076
|
+
// it here would pop the node right back off the canvas mid-flight.
|
|
1077
|
+
const pending: Array<{ team: string; index: number }> = []
|
|
1078
|
+
for (const [team] of pendingTeams) {
|
|
1079
|
+
const roster = model.teamMemberIds(team)
|
|
1080
|
+
const index = roster.indexOf(id)
|
|
1081
|
+
if (index >= 0) pending.push({ team, index })
|
|
1082
|
+
}
|
|
1083
|
+
return [...keep, ...pending]
|
|
1084
|
+
}
|
|
1085
|
+
// First build with nothing saved: seat the fleet team by team (see
|
|
1086
|
+
// planTeamClusters). Later arrivals, or any board with a saved layout,
|
|
1087
|
+
// keep the ring fallback.
|
|
1088
|
+
let clusterSeats: Map<string, { x: number; y: number }> | undefined
|
|
1089
|
+
if (layoutDoc === undefined && !model.allNodes().some(n => n.remote !== true)) {
|
|
1090
|
+
const seatIds = input.sessions
|
|
1091
|
+
.filter(s => s.joined === true && (resolveMemberships(s.id).length > 0 || (s.teams?.length ?? 0) > 0))
|
|
1092
|
+
.map(s => s.id)
|
|
1093
|
+
syncViewSize()
|
|
1094
|
+
clusterSeats = planTeamClusters(input.teams, seatIds, viewW > 0 ? viewW : undefined)
|
|
1095
|
+
}
|
|
1096
|
+
for (const s of input.sessions) {
|
|
1097
|
+
if (s.joined !== true) continue
|
|
1098
|
+
// Teamed-only canvas (network-membership-display ruling): a joined
|
|
1099
|
+
// session earns a canvas node only with a team — canvas membership
|
|
1100
|
+
// (optimistic-aware) or a declared roster team. Teamless joined
|
|
1101
|
+
// sessions ride the node list's 未组队 group instead; sessions absent
|
|
1102
|
+
// from `seen` are swept from the model below.
|
|
1103
|
+
const effective = resolveMemberships(s.id)
|
|
1104
|
+
if (effective.length === 0 && (s.teams === undefined || s.teams.length === 0)) continue
|
|
1105
|
+
seen.add(s.id)
|
|
1106
|
+
if (model.getNode(s.id) !== undefined) {
|
|
1107
|
+
model.upsertNode({
|
|
1108
|
+
id: s.id, x: model.getNode(s.id)!.x, y: model.getNode(s.id)!.y,
|
|
1109
|
+
label: s.label, team: s.team, name: s.name, live: s.live !== false,
|
|
1110
|
+
memberships: effective,
|
|
1111
|
+
...(s.teams !== undefined && s.teams.length > 0 ? { teams: s.teams } : {}),
|
|
1112
|
+
})
|
|
1113
|
+
} else {
|
|
1114
|
+
const saved = layoutDoc?.nodes[s.id]
|
|
1115
|
+
let x: number
|
|
1116
|
+
let y: number
|
|
1117
|
+
if (saved !== undefined) { x = saved.x; y = saved.y } else {
|
|
1118
|
+
const seat = clusterSeats?.get(s.id) ?? seatFor2(s.id)
|
|
1119
|
+
x = seat.x
|
|
1120
|
+
y = seat.y
|
|
1121
|
+
}
|
|
1122
|
+
model.upsertNode({
|
|
1123
|
+
id: s.id, x, y, label: s.label, team: s.team, name: s.name, live: s.live !== false,
|
|
1124
|
+
memberships: effective,
|
|
1125
|
+
...(s.teams !== undefined && s.teams.length > 0 ? { teams: s.teams } : {}),
|
|
1126
|
+
})
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
for (const id of model.nodeIds()) {
|
|
1130
|
+
if (!seen.has(id) && !id.startsWith('peer-')) model.removeNode(id)
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
// Peer nodes (v1 ruling): same model pipeline as sessions, `remote` flag
|
|
1134
|
+
// on. Saved positions win; new peers take the deterministic column over
|
|
1135
|
+
// the LOCAL content bounds (remote cards hang outside the field and are
|
|
1136
|
+
// excluded from bounds, so the column cannot feed back into itself).
|
|
1137
|
+
const peerSeen = new Set<string>()
|
|
1138
|
+
lastRemoteTeams = groupRemoteTeams(input.remoteTeams ?? [])
|
|
1139
|
+
peerScoreByText.clear()
|
|
1140
|
+
for (const p of input.peers ?? []) {
|
|
1141
|
+
peerScoreByText.set(peerHostOf(p.url), p.score)
|
|
1142
|
+
}
|
|
1143
|
+
if (input.peers !== undefined) {
|
|
1144
|
+
const localBounds = model.contentBounds()
|
|
1145
|
+
const placements = placePeers(input.peers, localBounds)
|
|
1146
|
+
input.peers.forEach((p, i) => {
|
|
1147
|
+
const id = peerNodeId(p.url)
|
|
1148
|
+
peerSeen.add(id)
|
|
1149
|
+
const saved = layoutDoc?.nodes[id]
|
|
1150
|
+
const existing = model.getNode(id)
|
|
1151
|
+
const base = existing !== undefined
|
|
1152
|
+
? { x: existing.x, y: existing.y }
|
|
1153
|
+
: saved !== undefined
|
|
1154
|
+
? { x: saved.x, y: saved.y }
|
|
1155
|
+
: { x: placements[i]?.x ?? 200, y: placements[i]?.y ?? 0 }
|
|
1156
|
+
model.upsertNode({
|
|
1157
|
+
id, x: base.x, y: base.y,
|
|
1158
|
+
label: p.url, team: '', name: p.url, peerUrl: p.url,
|
|
1159
|
+
live: true, remote: true, memberships: [],
|
|
1160
|
+
})
|
|
1161
|
+
model.upsertScore(id, p.score)
|
|
1162
|
+
})
|
|
1163
|
+
}
|
|
1164
|
+
for (const id of model.nodeIds()) {
|
|
1165
|
+
if (id.startsWith('peer-') && !peerSeen.has(id)) model.removeNode(id)
|
|
1166
|
+
}
|
|
1167
|
+
const homes = homeTeams(input.teams)
|
|
1168
|
+
for (const team of input.teams) {
|
|
1169
|
+
if (model.getFrame(team.name) !== undefined) continue // live rect wins
|
|
1170
|
+
if (pendingTeams.has(team.name)) continue // 散队 in flight: don't resurrect
|
|
1171
|
+
const saved = layoutDoc?.frames[team.name]
|
|
1172
|
+
if (saved !== undefined) { model.setFrame(team.name, { ...saved }); continue }
|
|
1173
|
+
// A derived frame wraps its HOME members only: a cross-team member is
|
|
1174
|
+
// reached by its dashed edge, not by stretching this frame over its
|
|
1175
|
+
// home frame. A team made only of members homed elsewhere falls back
|
|
1176
|
+
// to wrapping them all.
|
|
1177
|
+
const rects: LayoutRect[] = []
|
|
1178
|
+
const all: LayoutRect[] = []
|
|
1179
|
+
for (const m of team.members) {
|
|
1180
|
+
const n = model.getNode(m.id)
|
|
1181
|
+
if (n === undefined) continue
|
|
1182
|
+
all.push(nodeRect(n))
|
|
1183
|
+
if (homes.get(m.id) === team.name) rects.push(nodeRect(n))
|
|
1184
|
+
}
|
|
1185
|
+
const wrap = rects.length > 0 ? rects : all
|
|
1186
|
+
if (wrap.length > 0) model.setFrame(team.name, deriveInitialFrame(wrap))
|
|
1187
|
+
}
|
|
1188
|
+
for (const [name] of model.allFrames()) {
|
|
1189
|
+
if (pendingTeams.has(name)) continue // an in-flight create/remove owns this name
|
|
1190
|
+
if (!input.teams.some(t => t.name === name)) model.removeFrame(name)
|
|
1191
|
+
}
|
|
1192
|
+
// An open node panel follows the poll: facts (joins, leaves, team
|
|
1193
|
+
// declarations, peer arrivals) land within one cadence.
|
|
1194
|
+
nodes.refresh()
|
|
1195
|
+
renderStatus(input)
|
|
1196
|
+
// First-run framing: with no saved layout the ring seats are centered on
|
|
1197
|
+
// world (0,0) while the viewport origin is the board's top-left corner,
|
|
1198
|
+
// so half the fleet landed above/left of the view and a healthy board
|
|
1199
|
+
// opened empty. Fit once, the first time there is something to frame.
|
|
1200
|
+
// Not marked dirty — nothing the user arranged changed; a saved layout
|
|
1201
|
+
// (adopted at any point) keeps its own viewport and cancels this.
|
|
1202
|
+
if (firstFitPending && gesture.kind === 'none' && model.nodeIds().length > 0) {
|
|
1203
|
+
syncViewSize()
|
|
1204
|
+
if (viewW > 0 && viewH > 0) {
|
|
1205
|
+
firstFitPending = false
|
|
1206
|
+
// Peer cards hang outside the local content bounds — frame them too,
|
|
1207
|
+
// or the federation column opens clipped at the board's edge.
|
|
1208
|
+
let bounds = model.contentBounds()
|
|
1209
|
+
for (const n of model.allNodes()) {
|
|
1210
|
+
if (n.remote !== true) continue
|
|
1211
|
+
const r = nodeRect(n)
|
|
1212
|
+
bounds = bounds === null
|
|
1213
|
+
? { minX: r.x, minY: r.y, maxX: r.x + r.w, maxY: r.y + r.h }
|
|
1214
|
+
: { minX: Math.min(bounds.minX, r.x), minY: Math.min(bounds.minY, r.y), maxX: Math.max(bounds.maxX, r.x + r.w), maxY: Math.max(bounds.maxY, r.y + r.h) }
|
|
1215
|
+
}
|
|
1216
|
+
vp = fitView(bounds, viewW, viewH, 1) // frame, never magnify
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
render()
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
// Same fallback contract as WorldModel.fromState: the card-scaled ring.
|
|
1223
|
+
function seatFor2(id: string): { x: number; y: number } {
|
|
1224
|
+
return planSeatClear(id, model.allNodes().filter(n => n.remote !== true))
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
function adoptExternalLayout(layout: unknown): void {
|
|
1228
|
+
const doc = clampDoc(layout)
|
|
1229
|
+
if (doc === undefined) return
|
|
1230
|
+
layoutDoc = doc
|
|
1231
|
+
const store = deps.viewportStore
|
|
1232
|
+
if (store === undefined) {
|
|
1233
|
+
firstFitPending = false
|
|
1234
|
+
if (gesture.kind === 'none') vp = clampViewport({ ...doc.viewport })
|
|
1235
|
+
} else if (!viewportAdopted && gesture.kind === 'none') {
|
|
1236
|
+
viewportAdopted = true
|
|
1237
|
+
const local = store.get(bucket()) as Partial<LayoutViewport> | null | undefined
|
|
1238
|
+
if (local != null && Number.isFinite(local.x) && Number.isFinite(local.y) && Number.isFinite(local.scale)) {
|
|
1239
|
+
vp = clampViewport({ x: local.x!, y: local.y!, scale: local.scale! })
|
|
1240
|
+
firstFitPending = false
|
|
1241
|
+
} else if (bucket() === 'wide') {
|
|
1242
|
+
vp = clampViewport({ ...doc.viewport })
|
|
1243
|
+
firstFitPending = false
|
|
1244
|
+
} // narrow/medium with no memory of its own: keep firstFitPending, frame on reconcile
|
|
1245
|
+
}
|
|
1246
|
+
renderedRevision = -1 // force a repaint of frames/edges on next render
|
|
1247
|
+
render()
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
function snapshotDoc(): unknown {
|
|
1251
|
+
return buildLayoutDoc(vp, model.positions(), model.frameRects())
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
function setLamp(state: LampState, savedAt?: string): void {
|
|
1255
|
+
lamp.classList.remove('pending', 'saved', 'error')
|
|
1256
|
+
if (state === 'pending') lamp.classList.add('pending')
|
|
1257
|
+
if (state === 'saved') lamp.classList.add('saved')
|
|
1258
|
+
if (state === 'error') lamp.classList.add('error')
|
|
1259
|
+
// Error state is a keyboard-reachable retry control (review P0-2).
|
|
1260
|
+
if (state === 'error') {
|
|
1261
|
+
lamp.tabIndex = 0
|
|
1262
|
+
lamp.setAttribute('role', 'button')
|
|
1263
|
+
lamp.setAttribute('aria-label', '布局保存失败,按回车重试')
|
|
1264
|
+
} else {
|
|
1265
|
+
lamp.removeAttribute('tabindex')
|
|
1266
|
+
lamp.removeAttribute('role')
|
|
1267
|
+
lamp.removeAttribute('aria-label')
|
|
1268
|
+
}
|
|
1269
|
+
const text = state === 'pending' ? '布局待保存…'
|
|
1270
|
+
: state === 'saved' ? `布局已保存 ${savedAt ?? ''}`.trimEnd()
|
|
1271
|
+
: state === 'error' ? '保存失败 · 回车重试'
|
|
1272
|
+
: '布局'
|
|
1273
|
+
lampText.textContent = text
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
function activate(): void {
|
|
1277
|
+
root.style.display = 'block'
|
|
1278
|
+
syncViewSize()
|
|
1279
|
+
applyViewport()
|
|
1280
|
+
root.focus()
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
function deactivate(): void { root.style.display = 'none' }
|
|
1284
|
+
|
|
1285
|
+
function destroy(): void {
|
|
1286
|
+
ac.abort()
|
|
1287
|
+
for (const t of noticeTimers) clearTimeout(t)
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
return {
|
|
1291
|
+
root,
|
|
1292
|
+
reconcile,
|
|
1293
|
+
activate,
|
|
1294
|
+
deactivate,
|
|
1295
|
+
adoptExternalLayout,
|
|
1296
|
+
snapshotDoc,
|
|
1297
|
+
setLamp,
|
|
1298
|
+
notice,
|
|
1299
|
+
destroy,
|
|
1300
|
+
seam: {
|
|
1301
|
+
pointerDown,
|
|
1302
|
+
pointerMove,
|
|
1303
|
+
pointerUp,
|
|
1304
|
+
wheel,
|
|
1305
|
+
key,
|
|
1306
|
+
keyUp,
|
|
1307
|
+
contextMenu(ev: SeamPointer): void {
|
|
1308
|
+
ev.preventDefault()
|
|
1309
|
+
menus.contextMenuAt(ev.clientX, ev.clientY, ev.target)
|
|
1310
|
+
},
|
|
1311
|
+
},
|
|
1312
|
+
}
|
|
1313
|
+
}
|