@nelsonlongxiang/dsh-open-a2a-net 0.6.41 → 0.6.43
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 +2 -1
- package/README_zh.md +2 -1
- package/assets/nexusDist/assets/index-CaA76j0F.js +9 -0
- package/assets/nexusDist/index.html +1 -1
- package/assets/stageDist/assets/index-Bj7S2pA1.css +1 -1
- package/assets/stageDist/assets/index-C-28k_o5.js +974 -974
- package/assets/stageDist/index.html +12 -12
- package/cli/a2a.mjs +24 -4
- package/lib/canvas-store.js +249 -17
- package/lib/client.js +62 -72
- package/lib/client.js.map +1 -1
- package/lib/config.js +1 -0
- package/lib/faces/admin.js +103 -63
- package/lib/faces/wire.js +3 -1
- package/lib/index.js +116 -22
- package/lib/origin-claim.js +4 -0
- package/lib/skill/SKILL.md +117 -117
- package/lib/skill/references/collaboration.md +428 -428
- package/lib/skill/references/discovery.md +78 -78
- package/lib/skill/references/intake.md +107 -107
- package/lib/skill/references/membership.md +73 -73
- package/lib/skill/references/pitfalls.md +51 -51
- package/lib/skill/references/router.md +96 -96
- package/lib/skill/references/routing.md +65 -65
- package/lib/types/canvas-store.d.ts +48 -1
- package/lib/types/config.d.ts +2 -0
- package/lib/types/faces/admin.d.ts +2 -0
- package/lib/types/origin-claim.d.ts +2 -0
- package/nexus-stage/package.json +21 -21
- package/nexus-stage/scripts/mockhost.py +261 -261
- package/nexus-stage/src/canvas-ops.ts +191 -191
- package/nexus-stage/src/canvas-wire.ts +164 -164
- package/nexus-stage/src/cli-home-picker.ts +42 -42
- package/nexus-stage/src/fault.ts +56 -56
- package/nexus-stage/src/federation-overlay.ts +103 -103
- package/nexus-stage/src/federation.ts +241 -241
- package/nexus-stage/src/layout-doc.ts +145 -145
- package/nexus-stage/src/main.ts +520 -513
- package/nexus-stage/src/planning-card-text.ts +73 -73
- package/nexus-stage/src/planning-card.ts +63 -63
- package/nexus-stage/src/planning-edges.ts +26 -26
- package/nexus-stage/src/planning-fit.ts +45 -45
- package/nexus-stage/src/planning-frame-hue.ts +13 -13
- package/nexus-stage/src/planning-legend.ts +39 -39
- package/nexus-stage/src/planning-member-handles.ts +61 -61
- package/nexus-stage/src/planning-menus.ts +422 -422
- package/nexus-stage/src/planning-mirror.ts +75 -75
- package/nexus-stage/src/planning-nodelist.ts +294 -294
- package/nexus-stage/src/planning-overlap.ts +159 -159
- package/nexus-stage/src/planning-reachability.ts +28 -28
- package/nexus-stage/src/planning-relayout.ts +121 -121
- package/nexus-stage/src/planning-roster.ts +15 -15
- package/nexus-stage/src/planning-search.ts +168 -168
- package/nexus-stage/src/planning-types.ts +115 -115
- package/nexus-stage/src/planning-undo.ts +68 -68
- package/nexus-stage/src/planning-view.ts +1499 -1499
- package/nexus-stage/src/planning.css +340 -340
- package/nexus-stage/src/seat-name-editor.ts +31 -31
- package/nexus-stage/src/session-drag.ts +64 -64
- package/nexus-stage/src/viewport.ts +106 -106
- package/nexus-stage/src/wb-chat.ts +684 -684
- package/nexus-stage/src/wb-directory.ts +237 -237
- package/nexus-stage/src/wb-ledger.ts +279 -279
- package/nexus-stage/src/wb-names.ts +43 -43
- package/nexus-stage/src/wb-rail.ts +147 -147
- package/nexus-stage/src/workbench.css +388 -388
- package/nexus-stage/src/workbench.ts +1294 -1272
- package/nexus-stage/src/world.ts +509 -509
- package/nexus-stage/tsconfig.json +24 -24
- package/nexus-stage/vite.config.ts +15 -15
- package/package.json +1 -1
- package/assets/nexusDist/assets/index-NkNSeIx6.js +0 -9
|
@@ -1,1499 +1,1499 @@
|
|
|
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 } from './edges'
|
|
31
|
-
import { drawMemberEdges } from './planning-edges'
|
|
32
|
-
import { createFederationOverlay } from './federation-overlay'
|
|
33
|
-
import { joinedLocalIdsByHandle, membershipIndex, mirrorMemberIds, remoteMemberBoardId } from './planning-member-handles'
|
|
34
|
-
import { attachSessionDrop } from './session-drag'
|
|
35
|
-
import { displayName } from './stage-name'
|
|
36
|
-
import {
|
|
37
|
-
activityEdges, federalEdges, flyBounds, groupRemoteTeams, peerNodeId, peerHostOf, placePeers,
|
|
38
|
-
type FrameAnchor, type InFlightRow, type PeerPlacement, type PeerRow,
|
|
39
|
-
type RemoteTeamRow,
|
|
40
|
-
} from './federation'
|
|
41
|
-
import { clampViewport, fitView, panBy, screenToWorld, worldTransformCss, zoomAt, type LayoutViewport } from './viewport'
|
|
42
|
-
import { boardBounds, fitBelowHud, hudBottomOf } from './planning-fit'
|
|
43
|
-
import { WorldModel, nodeRect, deriveInitialFrame, homeTeams, planSeatClear, planTeamClusters, NODE_W, NODE_H } from './world'
|
|
44
|
-
import { applyAction, settleAction, actionTeam, innermostFrameAt, reorderOps, yOrderedMembers, type CanvasAction } from './canvas-ops'
|
|
45
|
-
import { frameHue } from './planning-frame-hue'
|
|
46
|
-
import { rosterFrameTitle } from './planning-roster'
|
|
47
|
-
import { createPlanningMenus } from './planning-menus'
|
|
48
|
-
import { createNodeFlyer, mountBoardSearch } from './planning-search'
|
|
49
|
-
import { applyBoardRelayout } from './planning-relayout'
|
|
50
|
-
import { createDissolveGuard, mirrorRoute } from './planning-mirror'
|
|
51
|
-
import { settleBoard, tidyMembers } from './planning-overlap'
|
|
52
|
-
import { createLayoutUndo, type LayoutSnap } from './planning-undo'
|
|
53
|
-
import { createNodeList } from './planning-nodelist'
|
|
54
|
-
import { cardText } from './planning-card-text'
|
|
55
|
-
import { createCardElement, paintPrio, workLabel } from './planning-card'
|
|
56
|
-
import { createLegend } from './planning-legend'
|
|
57
|
-
import { UNREACHABLE_HINT, showFrameReachability, unreachableRefs } from './planning-reachability'
|
|
58
|
-
import { viewportBucket, type PlanningDeps, type PlanningInput, type PlanningSession, type PlanningTeam, type PlanningView, type SeamKey, type SeamPointer, type ViewportBucket } from './planning-types'
|
|
59
|
-
import './planning.css'
|
|
60
|
-
|
|
61
|
-
export { viewportBucket } from './planning-types'
|
|
62
|
-
export type { PlanningDeps, PlanningInput, PlanningSession, PlanningTeam, PlanningView, SeamKey, SeamPointer, ViewportBucket } from './planning-types'
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
type Gesture =
|
|
66
|
-
| { readonly kind: 'none' }
|
|
67
|
-
| { readonly kind: 'node'; ids: readonly string[]; clicked: string; rosterOrigin?: string; last: { x: number; y: number }; moved: boolean; el: Element | null }
|
|
68
|
-
| { readonly kind: 'frame'; name: string; snap: NonNullable<ReturnType<WorldModel['beginFrameDrag']>>; origin: { x: number; y: number }; moved: boolean }
|
|
69
|
-
| { readonly kind: 'frame-resize'; name: string; dir: string; startRect: { x: number; y: number; w: number; h: number }; origin: { x: number; y: number }; moved: boolean }
|
|
70
|
-
| { readonly kind: 'pan'; last: { x: number; y: number }; moved: boolean }
|
|
71
|
-
| { readonly kind: 'marquee'; origin: { x: number; y: number }; last: { x: number; y: number }; additive: boolean }
|
|
72
|
-
|
|
73
|
-
// The card skeleton, its 工作中/空闲 chip and the right-hand chip live in planning-card (module-size cap).
|
|
74
|
-
export { workLabel } from './planning-card'
|
|
75
|
-
|
|
76
|
-
export function createPlanningView(deps: PlanningDeps): PlanningView {
|
|
77
|
-
const model = new WorldModel()
|
|
78
|
-
let layoutDoc: LayoutDoc | undefined
|
|
79
|
-
let vp: LayoutViewport = { ...model.viewport }
|
|
80
|
-
let gesture: Gesture = { kind: 'none' }
|
|
81
|
-
let spaceDown = false
|
|
82
|
-
let renderedRevision = -1
|
|
83
|
-
/** Until the first reconcile with nodes (or an adopted viewport), frame the content once. */
|
|
84
|
-
let firstFitPending = true
|
|
85
|
-
let settledOnce = false
|
|
86
|
-
const layoutUndo = createLayoutUndo(model) // 撤销 (planning-undo)
|
|
87
|
-
let undoBefore: LayoutSnap | undefined
|
|
88
|
-
let viewportAdopted = false
|
|
89
|
-
const bucket = (): ViewportBucket => viewportBucket(globalThis.innerWidth || viewW)
|
|
90
|
-
const markDirty = (): void => {
|
|
91
|
-
deps.viewportStore?.set(bucket(), vp)
|
|
92
|
-
deps.onDirty()
|
|
93
|
-
}
|
|
94
|
-
const clock = deps.now ?? Date.now
|
|
95
|
-
const ac = new AbortController()
|
|
96
|
-
const noticeTimers: Array<ReturnType<typeof setTimeout>> = []
|
|
97
|
-
/** Active right-button press: hold-drag pans; a clean release opens the
|
|
98
|
-
* context menu (the contextmenu event itself is always suppressed). */
|
|
99
|
-
let rightPress: { x: number; y: number; target: Element | null; moved: boolean } | null = null
|
|
100
|
-
let suppressNextMenu = false
|
|
101
|
-
const signal = { signal: ac.signal } as AddEventListenerOptions
|
|
102
|
-
|
|
103
|
-
// ── DOM shell (built once) ──
|
|
104
|
-
const root = document.createElement('section')
|
|
105
|
-
root.id = 'dsh-plan'
|
|
106
|
-
root.setAttribute('role', 'tabpanel')
|
|
107
|
-
root.setAttribute('aria-label', 'A2A 规划画布')
|
|
108
|
-
root.tabIndex = 0
|
|
109
|
-
root.style.display = 'none'
|
|
110
|
-
|
|
111
|
-
const grid = document.createElement('div')
|
|
112
|
-
grid.className = 'p-grid'
|
|
113
|
-
|
|
114
|
-
const world = document.createElement('div')
|
|
115
|
-
world.className = 'p-world'
|
|
116
|
-
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
|
117
|
-
svg.setAttribute('class', 'p-edges')
|
|
118
|
-
// NOTE: the svg is intentionally NOT aria-hidden — its .edge-label texts
|
|
119
|
-
// carry the route ages that reduced-motion users rely on.
|
|
120
|
-
// Stable layers: frames must ALWAYS paint (and lose hit-testing) below
|
|
121
|
-
// cards, regardless of which keyed diff created its element first — a
|
|
122
|
-
// frame created after the cards used to sit on top of them and swallow
|
|
123
|
-
// every real-pointer drag inside the team (synthetic tests bypassed it
|
|
124
|
-
// by targeting cards directly).
|
|
125
|
-
const frameLayer = document.createElement('div')
|
|
126
|
-
frameLayer.className = 'p-layer p-frames'
|
|
127
|
-
/** The selected frame keeps its resize handles visible without hover. */
|
|
128
|
-
let selectedFrame: string | undefined
|
|
129
|
-
const markSelectedFrame = (name: string | undefined): void => {
|
|
130
|
-
if (selectedFrame !== undefined) {
|
|
131
|
-
frameEls.get(selectedFrame)?.classList.remove('selected')
|
|
132
|
-
}
|
|
133
|
-
selectedFrame = name
|
|
134
|
-
if (name !== undefined) {
|
|
135
|
-
frameEls.get(name)?.classList.add('selected')
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
const nodeLayer = document.createElement('div')
|
|
139
|
-
nodeLayer.className = 'p-layer p-nodes'
|
|
140
|
-
nodeLayer.setAttribute('role', 'listbox')
|
|
141
|
-
nodeLayer.setAttribute('aria-multiselectable', 'true')
|
|
142
|
-
nodeLayer.setAttribute('aria-label', '会话节点')
|
|
143
|
-
world.append(svg, frameLayer, nodeLayer)
|
|
144
|
-
|
|
145
|
-
const marquee = document.createElement('div')
|
|
146
|
-
marquee.className = 'p-marquee'
|
|
147
|
-
|
|
148
|
-
const toolbar = document.createElement('div')
|
|
149
|
-
toolbar.className = 'p-toolbar'
|
|
150
|
-
const zoomLabel = document.createElement('span')
|
|
151
|
-
zoomLabel.className = 'p-zoom mono'
|
|
152
|
-
zoomLabel.textContent = '100%'
|
|
153
|
-
const mkButton = (text: string, title: string, onClick: () => void): HTMLButtonElement => {
|
|
154
|
-
const b = document.createElement('button')
|
|
155
|
-
b.type = 'button'
|
|
156
|
-
b.textContent = text
|
|
157
|
-
b.title = title
|
|
158
|
-
b.addEventListener('click', onClick, signal)
|
|
159
|
-
return b
|
|
160
|
-
}
|
|
161
|
-
// Review 2026-10-27: the toolbar reads as three groups — view | arrange |
|
|
162
|
-
// network — split by separators; every action stays one click (owner orders).
|
|
163
|
-
const sep = (): HTMLElement => { const s = document.createElement('span'); s.className = 'p-sep'; s.setAttribute('role', 'separator'); s.setAttribute('aria-orientation', 'vertical'); return s }
|
|
164
|
-
toolbar.append(
|
|
165
|
-
mkButton('适配', '适配视图(快捷键 0)', () => fitToContent()),
|
|
166
|
-
mkButton('-', '缩小', () => zoomCenter(1 / 1.2)),
|
|
167
|
-
zoomLabel,
|
|
168
|
-
mkButton('+', '放大', () => zoomCenter(1.2)),
|
|
169
|
-
sep(),
|
|
170
|
-
mkButton('建队', '组成团队(G):先框选至少 2 个节点', () => menus.startCreateFromSelection()),
|
|
171
|
-
// gh#48 (owner order, review-seat spec): one-click tidy — inside every
|
|
172
|
-
// team frame, the members that belong to EXACTLY that one team re-seat
|
|
173
|
-
// into a padded grid flow (the frame grows downward, bounded); members
|
|
174
|
-
// of 2+ teams keep their positions (the dashed edges express their
|
|
175
|
-
// membership). Idempotent; empty frames untouched; remote member cards
|
|
176
|
-
// ride the same rule; the move persists through the ordinary layout
|
|
177
|
-
// save loop exactly like a hand drag.
|
|
178
|
-
mkButton('整理成员', '每队框内仅属一队的成员自动排齐(多队成员不动)', () => tidyTeamMembers()),
|
|
179
|
-
// Supervisor order v2 (upgrades gh#48): one-click WHOLE-BOARD relayout —
|
|
180
|
-
// the two buttons split the job (整理成员 = cards inside one frame;
|
|
181
|
-
// 一键重排 = every local frame AND its cards). Frame size derives from
|
|
182
|
-
// content (width and height both); frames tile in a row flow
|
|
183
|
-
// (constructively disjoint); multi-team members never enter the size
|
|
184
|
-
// math and never move; remote org frames and frameless nodes stay put.
|
|
185
|
-
mkButton('一键重排', '全板队框+成员重排:框尺寸随内容自适应、框位行流式平铺(远端队框与多归属成员不动)', () => relayoutBoard()),
|
|
186
|
-
mkButton('撤销', '撤销上一次布局改动:拖动、缩放、整理、重排、载入时避让(Ctrl+Z;入队离队不撤销)', () => undoLayout()),
|
|
187
|
-
)
|
|
188
|
-
// Unjoined sessions ride a toolbar chip + dropdown: the panel-slim
|
|
189
|
-
// migration made the planning view the join surface — without this, an
|
|
190
|
-
// unjoined session had no GUI path into the mesh at all.
|
|
191
|
-
const netBtn = mkButton('未入网', '未入网会话(点击选择入网)', () => menus.toggleNetMenu())
|
|
192
|
-
netBtn.className = 'p-netbtn'
|
|
193
|
-
netBtn.setAttribute('aria-haspopup', 'menu')
|
|
194
|
-
toolbar.append(sep(), netBtn)
|
|
195
|
-
const search = mountBoardSearch(toolbar, model, displayName, id => flyToNode(id))
|
|
196
|
-
// Node list (network-membership-display): the canvas shows teamed nodes
|
|
197
|
-
// only; every other session — teamless joined, unjoined, and each peer's
|
|
198
|
-
// published nodes — is managed from this list, switched by host.
|
|
199
|
-
const nodesBtn = mkButton('节点', '节点清单:本机与对等节点(未组队/未入网在此管理)', () => nodes.toggle())
|
|
200
|
-
nodesBtn.className = 'p-nodebtn'
|
|
201
|
-
nodesBtn.setAttribute('aria-haspopup', 'dialog')
|
|
202
|
-
toolbar.appendChild(nodesBtn)
|
|
203
|
-
const lamp = document.createElement('span')
|
|
204
|
-
lamp.setAttribute('aria-live', 'polite')
|
|
205
|
-
lamp.className = 'p-lamp'
|
|
206
|
-
const lampDot = document.createElement('i')
|
|
207
|
-
const lampText = document.createElement('span')
|
|
208
|
-
lampText.textContent = '布局'
|
|
209
|
-
lamp.append(lampDot, lampText)
|
|
210
|
-
lamp.addEventListener('click', () => { if (lamp.classList.contains('error')) deps.onLampClick() }, signal)
|
|
211
|
-
lamp.addEventListener('keydown', (ev) => {
|
|
212
|
-
const e = ev as KeyboardEvent
|
|
213
|
-
if ((e.key === 'Enter' || e.key === ' ') && lamp.classList.contains('error')) {
|
|
214
|
-
e.preventDefault()
|
|
215
|
-
deps.onLampClick()
|
|
216
|
-
}
|
|
217
|
-
}, signal)
|
|
218
|
-
toolbar.appendChild(lamp)
|
|
219
|
-
|
|
220
|
-
const status = document.createElement('div')
|
|
221
|
-
status.className = 'p-status'
|
|
222
|
-
const statusLive = document.createElement('span')
|
|
223
|
-
const statusCold = document.createElement('span')
|
|
224
|
-
const statusAct = document.createElement('span')
|
|
225
|
-
statusAct.className = 'mut'
|
|
226
|
-
const statusMut = document.createElement('span')
|
|
227
|
-
statusMut.className = 'mut'
|
|
228
|
-
status.append(statusLive, statusCold, statusAct, statusMut)
|
|
229
|
-
// Fly control (PR D): the whole statusbar fits the frames ∪ peers region,
|
|
230
|
-
// keyboard-parity via Enter/Space with a focus ring (a11y parity with 0).
|
|
231
|
-
status.tabIndex = 0
|
|
232
|
-
status.setAttribute('role', 'button')
|
|
233
|
-
status.setAttribute('aria-label', '定位在途路由与联邦对端')
|
|
234
|
-
status.title = '定位在途与联邦(回车)'
|
|
235
|
-
status.addEventListener('click', () => flyToFederation(), signal)
|
|
236
|
-
status.addEventListener('keydown', (ev) => {
|
|
237
|
-
const e = ev as KeyboardEvent
|
|
238
|
-
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); flyToFederation() }
|
|
239
|
-
}, signal)
|
|
240
|
-
|
|
241
|
-
// Edge key: a closed-by-default fold (planning-legend), not a fixed panel.
|
|
242
|
-
const legend = createLegend()
|
|
243
|
-
|
|
244
|
-
const noticeStack = document.createElement('div')
|
|
245
|
-
noticeStack.className = 'p-notice-stack'
|
|
246
|
-
noticeStack.setAttribute('role', 'log')
|
|
247
|
-
noticeStack.setAttribute('aria-live', 'polite')
|
|
248
|
-
|
|
249
|
-
root.append(grid, world, marquee, toolbar, status, legend, noticeStack)
|
|
250
|
-
|
|
251
|
-
// ── viewport application ──
|
|
252
|
-
let viewW = 0
|
|
253
|
-
let viewH = 0
|
|
254
|
-
const measure = (): { w: number; h: number } =>
|
|
255
|
-
deps.viewSize ? deps.viewSize() : { w: root.clientWidth, h: root.clientHeight }
|
|
256
|
-
const syncViewSize = (): void => {
|
|
257
|
-
const size = measure()
|
|
258
|
-
viewW = size.w
|
|
259
|
-
viewH = size.h
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
function applyViewport(): void {
|
|
263
|
-
world.style.transform = worldTransformCss(vp)
|
|
264
|
-
const step = 24 * vp.scale
|
|
265
|
-
grid.style.backgroundSize = `${step}px ${step}px`
|
|
266
|
-
grid.style.backgroundPosition = `${-vp.x * vp.scale}px ${-vp.y * vp.scale}px`
|
|
267
|
-
zoomLabel.textContent = `${Math.round(vp.scale * 100)}%`
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
/**
|
|
271
|
-
* gh#48 (owner order): one-click member tidy. For every team frame, the
|
|
272
|
-
* members belonging to EXACTLY that one team re-seat into a padded grid
|
|
273
|
-
* flow inside the frame (the frame grows downward if the members need
|
|
274
|
-
* more rows — bounded); members of 2+ teams stay put (their dashed edges
|
|
275
|
-
* express membership). Remote member cards (member-) are ordinary board
|
|
276
|
-
* cards and ride the same rule. The move goes through markDirty()/render()
|
|
277
|
-
* — the same persistence path a hand drag takes.
|
|
278
|
-
*/
|
|
279
|
-
function tidyTeamMembers(): void {
|
|
280
|
-
const before = layoutUndo.capture()
|
|
281
|
-
tidyMembers(model) // gh#48 grid (planning-overlap); then nothing may overlap
|
|
282
|
-
settleBoard(model, new Set(remoteFramesRef.keys()))
|
|
283
|
-
layoutUndo.commit(before)
|
|
284
|
-
markDirty()
|
|
285
|
-
render()
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
/**
|
|
289
|
-
* Supervisor order v2: whole-board deterministic relayout (the planner
|
|
290
|
-
* and the model adapter live in planning-relayout.ts): LOCAL frames +
|
|
291
|
-
* sole members only — remote org frames keep gh#44 live-rect-wins,
|
|
292
|
-
* frameless nodes and multi-team members never move; the same
|
|
293
|
-
* markDirty()/render() persistence path a hand drag takes.
|
|
294
|
-
*/
|
|
295
|
-
function relayoutBoard(): void {
|
|
296
|
-
const before = layoutUndo.capture()
|
|
297
|
-
if (!applyBoardRelayout(model, new Set(remoteFramesRef.keys()))) return
|
|
298
|
-
settleBoard(model, new Set(remoteFramesRef.keys())) // vs remote frames + stray cards
|
|
299
|
-
layoutUndo.commit(before)
|
|
300
|
-
markDirty()
|
|
301
|
-
render()
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
function render(): void {
|
|
305
|
-
applyViewport()
|
|
306
|
-
if (model.revision !== renderedRevision) {
|
|
307
|
-
renderedRevision = model.revision
|
|
308
|
-
renderFrames()
|
|
309
|
-
renderNodes()
|
|
310
|
-
drawMemberEdges(svg, model) // planning-edges
|
|
311
|
-
}
|
|
312
|
-
renderFederation()
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
// ── keyed diff: frames ──
|
|
316
|
-
// Element maps instead of selector lookups: keyed identity for focus and
|
|
317
|
-
// transitions, no CSS.escape dependency, O(1) per row.
|
|
318
|
-
const frameEls = new Map<string, HTMLElement>()
|
|
319
|
-
const nodeEls = new Map<string, HTMLElement>()
|
|
320
|
-
|
|
321
|
-
function renderFrames(): void {
|
|
322
|
-
const seen = new Set<string>()
|
|
323
|
-
for (const [name, rect] of model.allFrames()) {
|
|
324
|
-
seen.add(name)
|
|
325
|
-
let el = frameEls.get(name)
|
|
326
|
-
if (el === undefined) {
|
|
327
|
-
el = document.createElement('div')
|
|
328
|
-
el.className = 'p-frame'
|
|
329
|
-
el.dataset.name = name
|
|
330
|
-
const head = document.createElement('div')
|
|
331
|
-
head.className = 'p-frame-head'
|
|
332
|
-
head.dataset.frame = name
|
|
333
|
-
head.tabIndex = 0 // keyboard: Shift+F10 menu, Delete 散队
|
|
334
|
-
head.setAttribute('role', 'button')
|
|
335
|
-
head.setAttribute('aria-haspopup', 'menu')
|
|
336
|
-
head.setAttribute('aria-label', `团队框 ${name}(拖动整组,回车菜单)`)
|
|
337
|
-
const title = document.createElement('span')
|
|
338
|
-
title.className = 'ttl'
|
|
339
|
-
const cnt = document.createElement('span')
|
|
340
|
-
cnt.className = 'cnt mono'
|
|
341
|
-
const route = document.createElement('span')
|
|
342
|
-
route.className = 'route mono'
|
|
343
|
-
head.append(title, cnt, route)
|
|
344
|
-
el.appendChild(head)
|
|
345
|
-
// Resize handles (owner ruling: the frame canvas is stretchable and
|
|
346
|
-
// adjustable): eight directions, pointer seam routes them to the
|
|
347
|
-
// frame-resize gesture. Purely visual affordance + hit targets.
|
|
348
|
-
for (const dir of ['n', 's', 'e', 'w', 'ne', 'nw', 'se', 'sw']) {
|
|
349
|
-
const handle = document.createElement('div')
|
|
350
|
-
handle.className = `p-frame-handle p-h-${dir}`
|
|
351
|
-
handle.dataset.frame = name
|
|
352
|
-
handle.dataset.dir = dir
|
|
353
|
-
el.appendChild(handle)
|
|
354
|
-
}
|
|
355
|
-
frameLayer.appendChild(el)
|
|
356
|
-
frameEls.set(name, el)
|
|
357
|
-
}
|
|
358
|
-
const hue = frameHue(name)
|
|
359
|
-
el.style.setProperty('--frame-line', hue.line)
|
|
360
|
-
el.style.setProperty('--frame-bg', hue.bg)
|
|
361
|
-
el.style.left = `${rect.x}px`
|
|
362
|
-
el.style.top = `${rect.y}px`
|
|
363
|
-
el.style.width = `${rect.w}px`
|
|
364
|
-
el.style.height = `${rect.h}px`
|
|
365
|
-
const title = el.querySelector<HTMLElement>('.ttl')!
|
|
366
|
-
const cnt = el.querySelector<HTMLElement>('.cnt')!
|
|
367
|
-
const route = el.querySelector<HTMLElement>('.route')!
|
|
368
|
-
const isRoster = rosterFramesRef.has(name)
|
|
369
|
-
const shownName = isRoster ? rosterFrameTitle(name) : name
|
|
370
|
-
if (title.textContent !== shownName) title.textContent = shownName
|
|
371
|
-
el.classList.toggle('is-roster', isRoster)
|
|
372
|
-
let members = 0
|
|
373
|
-
let cross = 0
|
|
374
|
-
for (const m of model.allNodes()) {
|
|
375
|
-
const mem = m.memberships.find(x => x.team === name)
|
|
376
|
-
if (mem === undefined) continue
|
|
377
|
-
members += 1
|
|
378
|
-
if (m.memberships.length > 1) cross += 1
|
|
379
|
-
}
|
|
380
|
-
const cntText = `${members} 成员` + (cross > 0 ? ` +${cross} 跨队` : '')
|
|
381
|
-
if (cnt.textContent !== cntText) cnt.textContent = cntText
|
|
382
|
-
const mirror = mirrorRoute(remoteFramesRef.get(name), pendingTeams.has(name)) // PR#96 single owner + mirror
|
|
383
|
-
const unavailable = mirror !== undefined && reachability.hosts.has(peerHostOf(remoteFramesRef.get(name)!))
|
|
384
|
-
const routeText0 = mirror?.text ?? (lastTeams.find(x => x.name === name)?.team ?? name)
|
|
385
|
-
if (route.textContent !== routeText0) route.textContent = routeText0
|
|
386
|
-
route.title = `${mirror?.title ?? ''}${unavailable ? ` · ${UNREACHABLE_HINT}` : ''}`
|
|
387
|
-
showFrameReachability(el, unavailable)
|
|
388
|
-
el.classList.toggle('is-mirror', mirror !== undefined)
|
|
389
|
-
el.classList.toggle('is-syncing', mirror?.syncing === true)
|
|
390
|
-
const label = `团队框 ${name}${mirror !== undefined ? `(${mirror.text})` : ''}${unavailable ? `(${UNREACHABLE_HINT})` : ''}(拖动整组,回车菜单)`
|
|
391
|
-
const headEl = el.querySelector('.p-frame-head')!
|
|
392
|
-
if (headEl.getAttribute('aria-label') !== label) headEl.setAttribute('aria-label', label) // a rewrite re-announces
|
|
393
|
-
}
|
|
394
|
-
for (const [name, el] of [...frameEls]) {
|
|
395
|
-
if (!seen.has(name)) { el.remove(); frameEls.delete(name) }
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
// ── keyed diff: nodes ──
|
|
400
|
-
function renderNodes(): void {
|
|
401
|
-
const seen = new Set<string>()
|
|
402
|
-
for (const n of model.allNodes()) {
|
|
403
|
-
seen.add(n.id)
|
|
404
|
-
let el = nodeEls.get(n.id)
|
|
405
|
-
if (el === undefined) {
|
|
406
|
-
el = createCardElement(n.id)
|
|
407
|
-
nodeLayer.appendChild(el)
|
|
408
|
-
nodeEls.set(n.id, el)
|
|
409
|
-
}
|
|
410
|
-
const r = nodeRect(n)
|
|
411
|
-
el.style.left = `${r.x}px`
|
|
412
|
-
el.style.top = `${r.y}px`
|
|
413
|
-
el.classList.toggle('cold', !n.live)
|
|
414
|
-
el.classList.toggle('remote', n.remote === true)
|
|
415
|
-
// 工作中/空闲: live local sessions only; the host reports nothing for cold or remote nodes.
|
|
416
|
-
const work = n.live && n.remote !== true ? lastWork.get(n.id) : undefined
|
|
417
|
-
el.classList.toggle('is-working', work?.status === 'running')
|
|
418
|
-
const workText = workLabel(work)
|
|
419
|
-
const workEl = el.querySelector<HTMLElement>('.work')!
|
|
420
|
-
if (workEl.textContent !== workText) workEl.textContent = workText
|
|
421
|
-
el.classList.toggle('selected', model.isSelected(n.id))
|
|
422
|
-
el.setAttribute('aria-selected', String(model.isSelected(n.id)))
|
|
423
|
-
const host = n.id.slice('peer-'.length)
|
|
424
|
-
// Title / second line / tooltip rules: planning-card-text.
|
|
425
|
-
const txt = cardText(n, n.remote === true ? lastRemoteTeams.get(host) ?? [] : [], peerScoreByText.get(host))
|
|
426
|
-
const unavailable = n.remote === true && (n.id.startsWith('member-') ? reachability.members.has(n.id) : reachability.hosts.has(host))
|
|
427
|
-
el.classList.toggle('is-unreachable', unavailable)
|
|
428
|
-
const nmEl = el.querySelector<HTMLElement>('.nm-text')!
|
|
429
|
-
if (nmEl.textContent !== txt.title) nmEl.textContent = txt.title
|
|
430
|
-
const subEl = el.querySelector<HTMLElement>('.sub')!
|
|
431
|
-
const subText = unavailable ? `未应答 · ${txt.sub}` : txt.sub
|
|
432
|
-
if (subEl.textContent !== subText) subEl.textContent = subText
|
|
433
|
-
const tip = unavailable ? `${txt.tip} · ${UNREACHABLE_HINT}` : txt.tip
|
|
434
|
-
if (el.title !== tip) el.title = tip
|
|
435
|
-
// A member card's chip follows the link of the host that publishes its handle.
|
|
436
|
-
const linkHost = n.id.startsWith('member-') ? memberHostByHandle.get(n.remoteHandle ?? '') : host
|
|
437
|
-
paintPrio(el.querySelector<HTMLElement>('.prio')!, n, n.remote === true && linkHost !== undefined ? peerInfoByHost.get(linkHost) : undefined)
|
|
438
|
-
}
|
|
439
|
-
for (const [id, el] of [...nodeEls]) {
|
|
440
|
-
if (!seen.has(id)) { el.remove(); nodeEls.delete(id) }
|
|
441
|
-
}
|
|
442
|
-
search.refresh()
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
// ── federation overlay (PR D): peer cards' activity/federal edges ──
|
|
446
|
-
// Runs on EVERY render, outside the model-revision gate: the poll's
|
|
447
|
-
// inFlight ages must tick even when the model is unchanged, and the
|
|
448
|
-
// badge column tracks the content bounds each reconcile. Its SVG
|
|
449
|
-
// children are tracked separately because drawMemberEdges clears the svg
|
|
450
|
-
// wholesale - this appends after it (render() orders the two).
|
|
451
|
-
// gh#49: the DOM drawing lives in federation-overlay.ts (module-size
|
|
452
|
-
// ruling); this view keeps only the seams (facts + fly).
|
|
453
|
-
const federation = createFederationOverlay(svg, model)
|
|
454
|
-
let lastPeers: ReadonlyArray<PeerRow> = []
|
|
455
|
-
let lastRemoteTeams: ReadonlyMap<string, RemoteTeamRow[]> = new Map()
|
|
456
|
-
let lastWork: ReadonlyMap<string, Pick<PlanningSession, 'status' | 'queued'>> = new Map()
|
|
457
|
-
const peerScoreByText = new Map<string, number | undefined>()
|
|
458
|
-
/** gh#20 W-A: per-host link/verified info for the peer badges. */
|
|
459
|
-
const peerInfoByHost = new Map<string, { link?: 'linked' | 'stranger'; verified?: boolean }>()
|
|
460
|
-
let memberHostByHandle: ReadonlyMap<string, string> = new Map()
|
|
461
|
-
|
|
462
|
-
function renderFederation(): void {
|
|
463
|
-
federation.render(clock())
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
/** Statusbar fly: fit the frames ∪ peers region (flyBounds pads it). */
|
|
467
|
-
function flyToFederation(): void {
|
|
468
|
-
vp = fitView(flyBounds(model.contentBounds(), federation.placements()), root.clientWidth || viewW, root.clientHeight || viewH)
|
|
469
|
-
applyViewport()
|
|
470
|
-
markDirty()
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
/** W1 pick: frame one node at ~100% and pulse its card. */
|
|
474
|
-
const flyToNode = createNodeFlyer({ model, viewSize: measure, setVp: next => { vp = next }, applyViewport, markDirty, cardEl: id => nodeEls.get(id) })
|
|
475
|
-
// ── status counts ──
|
|
476
|
-
function renderStatus(input: PlanningInput): void {
|
|
477
|
-
const live = input.sessions.filter(s => s.live !== false).length
|
|
478
|
-
const dot = (host: HTMLElement, cls: string, text: string): void => {
|
|
479
|
-
const b = document.createElement('b')
|
|
480
|
-
b.className = cls
|
|
481
|
-
b.textContent = text
|
|
482
|
-
host.replaceChildren(b)
|
|
483
|
-
}
|
|
484
|
-
dot(statusLive, 'ok', `● ${live} live`)
|
|
485
|
-
dot(statusCold, 'warn', `● ${input.sessions.length - live} cold`)
|
|
486
|
-
statusAct.textContent = `${input.inFlight?.length ?? 0} inFlight`
|
|
487
|
-
statusMut.className = 'mut'
|
|
488
|
-
statusMut.textContent = `${input.teams.length} 队 · ${input.peerCount} peer`
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
// ── canvas write actions: optimistic apply + team-scoped undo ──
|
|
492
|
-
// Per-team REFERENCE COUNT: two queued actions on one team keep the guard
|
|
493
|
-
// up until BOTH settle (a Set deleted per action let a mid-queue poll wipe
|
|
494
|
-
// the second action's optimistic membership).
|
|
495
|
-
const pendingTeams = new Map<string, number>()
|
|
496
|
-
// Each change re-renders frames: the mirror 「同步中…」 marker reads pendingTeams, not the model revision.
|
|
497
|
-
const pendingAdd = (team: string): void => { pendingTeams.set(team, (pendingTeams.get(team) ?? 0) + 1); renderedRevision = -1 }
|
|
498
|
-
const pendingRelease = (team: string): void => {
|
|
499
|
-
renderedRevision = -1
|
|
500
|
-
const n = (pendingTeams.get(team) ?? 0) - 1
|
|
501
|
-
if (n <= 0) pendingTeams.delete(team)
|
|
502
|
-
else pendingTeams.set(team, n)
|
|
503
|
-
}
|
|
504
|
-
// gh#41: remote-frame registry (frame name → peer URL), refreshed by
|
|
505
|
-
// reconcile and read by emitAction's serverAction to fork remote drops.
|
|
506
|
-
let remoteFramesRef = new Map<string, string>()
|
|
507
|
-
let rosterFramesRef = new Set<string>()
|
|
508
|
-
let reachability = unreachableRefs({})
|
|
509
|
-
const dissolving = createDissolveGuard(deps.now)
|
|
510
|
-
|
|
511
|
-
function emitAction(a: CanvasAction): void {
|
|
512
|
-
const roster = (a.type === 'create-team' && a.roster === true) || rosterFramesRef.has(actionTeam(a))
|
|
513
|
-
if (roster && a.type === 'remove-team' && !window.confirm(`解散网络队「${rosterFrameTitle(a.name)}」并撤回成员与白名单?`)) return
|
|
514
|
-
if (roster && a.type === 'reorder') { notice('error', 'roster 队不支持置顶路由'); return }
|
|
515
|
-
if (roster && (a.type === 'create-team' || a.type === 'add-member') && a.ids.some(id => model.getNode(id)?.remote === true || !lastSessions.some(s => s.id === id && s.joined === true))) {
|
|
516
|
-
notice('error', 'roster 只接受本机已入网会话,远端或未入网成员未写入'); return
|
|
517
|
-
}
|
|
518
|
-
// gh#20 W-B: the SERVER sees zone/id8 remote handles where the model
|
|
519
|
-
// keeps its peer-<host> node id — the canvas member contract is
|
|
520
|
-
// session-id-or-remote-ref. Translation happens here, one boundary;
|
|
521
|
-
// the model action keeps board-local ids so rendering and undo stay
|
|
522
|
-
// node-keyed. A peer without a known handle (no remote team rows yet)
|
|
523
|
-
// is filtered out — the server would refuse the raw peer- id.
|
|
524
|
-
function serverAction(act: CanvasAction): CanvasAction {
|
|
525
|
-
const mapIds = (ids: readonly string[]): string[] => {
|
|
526
|
-
const out: string[] = []
|
|
527
|
-
for (const id of ids) {
|
|
528
|
-
// Both remote card families translate to their zone/id8 handle
|
|
529
|
-
// (gh#38: member- cards join teams like peer- cards do).
|
|
530
|
-
if (!id.startsWith('peer-') && !id.startsWith('member-')) { out.push(id); continue }
|
|
531
|
-
const handle = model.getNode(id)?.remoteHandle
|
|
532
|
-
if (handle !== undefined && handle !== '') out.push(handle)
|
|
533
|
-
}
|
|
534
|
-
return out
|
|
535
|
-
}
|
|
536
|
-
if (act.type === 'add-member' || act.type === 'remove-member' || act.type === 'create-team') {
|
|
537
|
-
const ids = mapIds(act.ids)
|
|
538
|
-
// gh#41: a drop into a REMOTE frame carries the peer URL — main.ts
|
|
539
|
-
// forks it to the fleet relay instead of the local canvas control.
|
|
540
|
-
const fleetPeer = act.type !== 'create-team' ? remoteFramesRef.get(act.team) : undefined
|
|
541
|
-
return ids.length > 0 || fleetPeer !== undefined
|
|
542
|
-
? { ...act, ids, ...(fleetPeer !== undefined ? { fleetPeer } : {}) }
|
|
543
|
-
: act
|
|
544
|
-
}
|
|
545
|
-
// Dissolving a mirror frame is the owner's write as well (relay op 'remove').
|
|
546
|
-
return act.type === 'remove-team' && remoteFramesRef.has(act.name) ? dissolving.fork(act, remoteFramesRef.get(act.name)!) : act
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
// Optimistic seats: a write may reference a session the teamed gate
|
|
550
|
-
// keeps OFF the model (no canvas card yet). The write actions read and
|
|
551
|
-
// write memberships through the node table, so materialize those ids
|
|
552
|
-
// from the last poll's inventory first — the next reconcile refreshes
|
|
553
|
-
// label/live/teams and keeps the saved-or-seat position.
|
|
554
|
-
if (a.type === 'add-member' || a.type === 'remove-member' || a.type === 'reorder' || a.type === 'create-team') {
|
|
555
|
-
const ids = a.type === 'reorder' ? a.ops.map(op => op.id) : [...a.ids]
|
|
556
|
-
for (const id of ids) {
|
|
557
|
-
if (id === '' || id.startsWith('peer-') || id.startsWith('member-')) continue
|
|
558
|
-
if (model.getNode(id) !== undefined) continue
|
|
559
|
-
const s = lastSessions.find(row => row.id === id)
|
|
560
|
-
if (s === undefined) continue
|
|
561
|
-
const saved = layoutDoc?.nodes[id]
|
|
562
|
-
const seat = saved !== undefined ? { x: saved.x, y: saved.y } : seatFor2(id)
|
|
563
|
-
model.upsertNode({
|
|
564
|
-
id, x: seat.x, y: seat.y, label: s.label, team: s.team, name: s.name, description: s.description,
|
|
565
|
-
live: s.live !== false, memberships: [],
|
|
566
|
-
...(s.teams !== undefined && s.teams.length > 0 ? { teams: s.teams } : {}),
|
|
567
|
-
})
|
|
568
|
-
}
|
|
569
|
-
}
|
|
570
|
-
if (roster && a.type === 'create-team') rosterFramesRef.add(a.name)
|
|
571
|
-
const undo = applyAction(model, a)
|
|
572
|
-
const team = actionTeam(a)
|
|
573
|
-
pendingAdd(team)
|
|
574
|
-
render()
|
|
575
|
-
const sent = serverAction(roster ? { ...a, roster: true } : a)
|
|
576
|
-
void deps.onCanvasAction(sent).then(
|
|
577
|
-
ok => {
|
|
578
|
-
pendingRelease(team)
|
|
579
|
-
if (!ok && a.type === 'create-team' && roster) rosterFramesRef.delete(team)
|
|
580
|
-
layoutDoc = settleAction(model, a, ok, undo, layoutDoc); markDirty(); dissolving.settled(sent, ok)
|
|
581
|
-
render()
|
|
582
|
-
},
|
|
583
|
-
() => {
|
|
584
|
-
// Contract-robustness: a rejecting action channel must not pin the
|
|
585
|
-
// team's guard (and its optimistic state) forever — and per §3.4
|
|
586
|
-
// 不吞错, the rollback surfaces to the user.
|
|
587
|
-
pendingRelease(team)
|
|
588
|
-
if (a.type === 'create-team' && roster) rosterFramesRef.delete(team)
|
|
589
|
-
layoutDoc = settleAction(model, a, false, undo, layoutDoc); markDirty(); dissolving.settled(sent, false)
|
|
590
|
-
notice('error', '操作通道异常,已回滚')
|
|
591
|
-
render()
|
|
592
|
-
},
|
|
593
|
-
)
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
// ── notices (host errors verbatim; design.md §3.4 不吞错) ──
|
|
597
|
-
function notice(kind: 'error' | 'info', text: string): void {
|
|
598
|
-
while (noticeStack.childElementCount >= 3) noticeStack.firstElementChild?.remove()
|
|
599
|
-
const item = document.createElement('div')
|
|
600
|
-
item.className = 'p-notice' + (kind === 'error' ? ' error' : '')
|
|
601
|
-
item.setAttribute('role', kind === 'error' ? 'alert' : 'status')
|
|
602
|
-
item.textContent = text
|
|
603
|
-
noticeStack.appendChild(item)
|
|
604
|
-
noticeTimers.push(setTimeout(() => item.remove(), 4000))
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
// ── popovers: 建队 dialog, context menu, 未入网 dropdown (planning-menus) ──
|
|
608
|
-
const menus = createPlanningMenus({
|
|
609
|
-
root, netBtn, signal, model,
|
|
610
|
-
teams: () => lastTeams,
|
|
611
|
-
joined: id => lastSessions.some(s => s.id === id && s.joined === true),
|
|
612
|
-
isRoster: name => rosterFramesRef.has(name),
|
|
613
|
-
anchorFor: (t) => (t.kind === 'node' ? nodeEls.get(t.id) : frameEls.get(t.name)) ?? null,
|
|
614
|
-
emit: emitAction,
|
|
615
|
-
notice,
|
|
616
|
-
})
|
|
617
|
-
|
|
618
|
-
// ── 节点列表 drawer (planning-nodelist) ──
|
|
619
|
-
let lastSessions: ReadonlyArray<PlanningSession> = []
|
|
620
|
-
let lastCanvasMemberIds: ReadonlySet<string> = new Set()
|
|
621
|
-
const nodes = createNodeList({
|
|
622
|
-
root, trigger: nodesBtn, signal, model,
|
|
623
|
-
facts: () => ({ sessions: lastSessions, canvasMemberIds: lastCanvasMemberIds, remoteTeams: lastRemoteTeams, teams: lastTeams }),
|
|
624
|
-
menuItem: menus.menuItem,
|
|
625
|
-
emit: emitAction,
|
|
626
|
-
openNameDialog: menus.openNameDialog,
|
|
627
|
-
locate: locateNode,
|
|
628
|
-
})
|
|
629
|
-
// A rail 本机会话 row dropped on a frame joins that team, like a card drop (session-drag).
|
|
630
|
-
attachSessionDrop(root, {
|
|
631
|
-
signal, frames: () => frameEls,
|
|
632
|
-
frameAt: (clientX, clientY) => { const p = localXY({ clientX, clientY }); return innermostFrameAt(new Map(model.allFrames()), screenToWorld(vp, p.x, p.y)) },
|
|
633
|
-
drop: (team, id) => model.getNode(id)?.memberships.some(m => m.team === team) === true ? notice('info', '该会话已在这个队里') : emitAction({ type: 'add-member', team, ids: [id] }),
|
|
634
|
-
})
|
|
635
|
-
|
|
636
|
-
/** Center the viewport on one canvas node (the 已组队 row's 定位). */
|
|
637
|
-
function locateNode(id: string): void {
|
|
638
|
-
const n = model.getNode(id)
|
|
639
|
-
if (n === undefined) return
|
|
640
|
-
nodes.close()
|
|
641
|
-
const size = deps.viewSize?.() ?? { w: root.clientWidth > 0 ? root.clientWidth : 800, h: root.clientHeight > 0 ? root.clientHeight : 600 }
|
|
642
|
-
vp = clampViewport({ scale: vp.scale, x: n.x - size.w / (2 * vp.scale), y: n.y - size.h / (2 * vp.scale) })
|
|
643
|
-
applyViewport()
|
|
644
|
-
renderedRevision = -1
|
|
645
|
-
render()
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
// ── gestures ──
|
|
649
|
-
function localXY(ev: { clientX: number; clientY: number }): { x: number; y: number } {
|
|
650
|
-
const rect = root.getBoundingClientRect()
|
|
651
|
-
return { x: ev.clientX - rect.left, y: ev.clientY - rect.top }
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
/** Drop dispatch for a moved node drag: 入队 / 队内 y 排序. Leaving a team
|
|
655
|
-
* is an explicit menu action only — dragging out to blank is a position
|
|
656
|
-
* move (owner removal 2026-09-02: the drag-out-to-blank 离队 path is
|
|
657
|
-
* removed; accidental blank drops wrote whole-group removals). */
|
|
658
|
-
function dropDispatch(gesture: Extract<Gesture, { kind: 'node' }>, ev: SeamPointer): void {
|
|
659
|
-
const p = localXY(ev)
|
|
660
|
-
const w = screenToWorld(vp, p.x, p.y)
|
|
661
|
-
const f = innermostFrameAt(new Map(model.allFrames()), w)
|
|
662
|
-
// gh#20 W-B: peer cards join canvas teams too — a mixed local+remote
|
|
663
|
-
// drop composes a cross-host team; the server boundary translates
|
|
664
|
-
// peer ids to zone/id8 handles (emitAction's serverAction). Reorder
|
|
665
|
-
// stays local-only (roster priority is a local-session fact).
|
|
666
|
-
const memberIds = gesture.ids.filter(id => {
|
|
667
|
-
const node = model.getNode(id)
|
|
668
|
-
if (node === undefined) return false
|
|
669
|
-
if (node.remote !== true) return true
|
|
670
|
-
return node.remoteHandle !== undefined && node.remoteHandle !== ''
|
|
671
|
-
})
|
|
672
|
-
if (f !== undefined) {
|
|
673
|
-
const fresh = memberIds.filter(id => !(model.getNode(id)?.memberships.some(m => m.team === f)))
|
|
674
|
-
if (fresh.length > 0) {
|
|
675
|
-
emitAction({ type: 'add-member', team: f, ids: fresh }) // 入队:不自动离原队(多对多)
|
|
676
|
-
return
|
|
677
|
-
}
|
|
678
|
-
// Reorder only for LOCAL single-card drops: dropping a peer card is a
|
|
679
|
-
// position move, never a roster write (P2 from the incremental seat).
|
|
680
|
-
if (gesture.ids.length === 1 && model.getNode(gesture.clicked)?.remote !== true) {
|
|
681
|
-
// Already a member: an in-frame drop sorts that frame's members by y.
|
|
682
|
-
const current = model.teamMemberIds(f)
|
|
683
|
-
const desired = yOrderedMembers(model, f)
|
|
684
|
-
if (desired.join('\u0000') !== current.join('\u0000')) {
|
|
685
|
-
emitAction({ type: 'reorder', team: f, ops: reorderOps(current, desired) })
|
|
686
|
-
}
|
|
687
|
-
}
|
|
688
|
-
}
|
|
689
|
-
// Canvas blank drops move only; leaving a roster frame explicitly retracts those declarations.
|
|
690
|
-
if (gesture.rosterOrigin !== undefined && f !== gesture.rosterOrigin) {
|
|
691
|
-
const leaving = gesture.ids.filter(id => model.teamMemberIds(gesture.rosterOrigin!).includes(id))
|
|
692
|
-
if (leaving.length > 0) emitAction({ type: 'remove-member', team: gesture.rosterOrigin, ids: leaving })
|
|
693
|
-
}
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
function pointerDown(ev: SeamPointer): void {
|
|
697
|
-
if (viewW === 0) syncViewSize()
|
|
698
|
-
if (menus.isDialogOpen()) { ev.preventDefault(); return } // modal: no gestures behind the dialog
|
|
699
|
-
const target = ev.target as Element | null
|
|
700
|
-
// Context-menu dismissal: the first pointerdown outside an open menu
|
|
701
|
-
// closes it and is swallowed; a press on the menu's own chrome is
|
|
702
|
-
// swallowed too — only its items act.
|
|
703
|
-
if (menus.isMenuOpen()) {
|
|
704
|
-
if (target !== null && target.closest('.p-menu') !== null) return
|
|
705
|
-
menus.closeMenu()
|
|
706
|
-
ev.preventDefault()
|
|
707
|
-
return
|
|
708
|
-
}
|
|
709
|
-
if (target !== null && target.closest('button, input, textarea, select, .p-toolbar, .p-search, .p-lamp, .p-status, .p-legend') !== null) return // HUD chrome owns its presses (no marquee / preventDefault: that stole the search box's focus — and, first, its native right-click menu)
|
|
710
|
-
undoBefore = layoutUndo.capture() // committed at pointerUp only if this press moved something
|
|
711
|
-
const p = localXY(ev)
|
|
712
|
-
// 右键按下:按住拖动 = 平移(Figma 惯例),原地抬起 = 上下文菜单。
|
|
713
|
-
// Pan actually starts on the first move beyond RIGHT_DRAG_THRESHOLD px;
|
|
714
|
-
// a clean press falls through to the context menu at pointerup.
|
|
715
|
-
if (ev.button === 2) {
|
|
716
|
-
rightPress = { x: p.x, y: p.y, target: ev.target, moved: false }
|
|
717
|
-
ev.preventDefault()
|
|
718
|
-
try { root.setPointerCapture(ev.pointerId ?? 0) } catch { /* jsdom */ }
|
|
719
|
-
return
|
|
720
|
-
}
|
|
721
|
-
const nodeEl = target !== null ? target.closest<HTMLElement>('.p-node') : null
|
|
722
|
-
const headEl = target !== null ? target.closest<HTMLElement>('.p-frame-head') : null
|
|
723
|
-
if ((nodeEl !== null || headEl !== null) && ev.button === 1) {
|
|
724
|
-
// Middle-button anywhere — including on cards and frame heads — pans
|
|
725
|
-
// (baf51a1 claimed this; the guards used to swallow it before the pan
|
|
726
|
-
// branch was reachable, and skipped preventDefault, re-enabling
|
|
727
|
-
// native autoscroll).
|
|
728
|
-
gesture = { kind: 'pan', last: p, moved: false }
|
|
729
|
-
ev.preventDefault()
|
|
730
|
-
try { root.setPointerCapture(ev.pointerId ?? 0) } catch { /* jsdom */ }
|
|
731
|
-
return
|
|
732
|
-
}
|
|
733
|
-
const handleEl = target !== null ? target.closest<HTMLElement>('.p-frame-handle') : null
|
|
734
|
-
// Selection clearing: any press NOT on the selected frame's own chrome
|
|
735
|
-
// (head/handle/body) drops the frame selection.
|
|
736
|
-
if (selectedFrame !== undefined) {
|
|
737
|
-
const selFrameEl = frameEls.get(selectedFrame)
|
|
738
|
-
const onSelectedChrome = target !== null && selFrameEl !== undefined &&
|
|
739
|
-
(selFrameEl.contains(target) || selFrameEl === target)
|
|
740
|
-
const onSelectedHead = headEl !== null && headEl.dataset.frame === selectedFrame
|
|
741
|
-
if (!onSelectedChrome && !onSelectedHead) markSelectedFrame(undefined)
|
|
742
|
-
}
|
|
743
|
-
if (handleEl !== null && ev.button === 0) {
|
|
744
|
-
// Frame resize (owner ruling): record the frame's current rect and
|
|
745
|
-
// the world-space origin; the move branch re-solves the rect per
|
|
746
|
-
// direction with a minimum size clamp.
|
|
747
|
-
const name = handleEl.dataset.frame ?? ''
|
|
748
|
-
markSelectedFrame(name)
|
|
749
|
-
const rect = model.getFrame(name)
|
|
750
|
-
if (rect === undefined) return
|
|
751
|
-
gesture = { kind: 'frame-resize', name, dir: handleEl.dataset.dir ?? 'se', startRect: { ...rect }, origin: screenToWorld(vp, p.x, p.y), moved: false }
|
|
752
|
-
ev.preventDefault()
|
|
753
|
-
try { root.setPointerCapture(ev.pointerId ?? 0) } catch { /* jsdom: no capture */ }
|
|
754
|
-
return
|
|
755
|
-
}
|
|
756
|
-
if (nodeEl !== null) {
|
|
757
|
-
if (ev.button !== 0) return // right on a card: contextmenu only, never a card drag (F3)
|
|
758
|
-
const id = nodeEl.dataset.id ?? ''
|
|
759
|
-
const already = model.isSelected(id)
|
|
760
|
-
if (!already && !ev.shiftKey) model.setSelection([id])
|
|
761
|
-
const ids = ev.shiftKey || already ? (model.isSelected(id) ? model.selectedIds() : [...model.selectedIds(), id]) : [id]
|
|
762
|
-
const origin = innermostFrameAt(new Map(model.allFrames()), screenToWorld(vp, p.x, p.y))
|
|
763
|
-
gesture = { kind: 'node', clicked: id, ids, ...(origin && rosterFramesRef.has(origin) && model.teamMemberIds(origin).includes(id) ? { rosterOrigin: origin } : {}), last: screenToWorld(vp, p.x, p.y), moved: false, el: nodeEl }
|
|
764
|
-
nodeEl.classList.add('dragging')
|
|
765
|
-
} else if (headEl !== null) {
|
|
766
|
-
if (ev.button !== 0) return
|
|
767
|
-
const name = headEl.dataset.frame ?? ''
|
|
768
|
-
// Selecting a frame keeps its resize handles visible (hover-only
|
|
769
|
-
// handles vanished the moment the pointer left).
|
|
770
|
-
markSelectedFrame(name)
|
|
771
|
-
const snap = model.beginFrameDrag(name)
|
|
772
|
-
if (snap === undefined) return
|
|
773
|
-
gesture = { kind: 'frame', name, snap, origin: screenToWorld(vp, p.x, p.y), moved: false }
|
|
774
|
-
} else {
|
|
775
|
-
if (ev.button === 1 || (ev.button === 0 && spaceDown)) {
|
|
776
|
-
gesture = { kind: 'pan', last: p, moved: false }
|
|
777
|
-
} else if (ev.button === 0) {
|
|
778
|
-
gesture = { kind: 'marquee', origin: p, last: p, additive: ev.shiftKey }
|
|
779
|
-
marquee.style.display = 'block'
|
|
780
|
-
marquee.style.left = `${p.x}px`
|
|
781
|
-
marquee.style.top = `${p.y}px`
|
|
782
|
-
marquee.style.width = '0px'
|
|
783
|
-
marquee.style.height = '0px'
|
|
784
|
-
} else return
|
|
785
|
-
}
|
|
786
|
-
ev.preventDefault()
|
|
787
|
-
try { root.setPointerCapture(ev.pointerId ?? 0) } catch { /* jsdom: no capture */ }
|
|
788
|
-
}
|
|
789
|
-
|
|
790
|
-
function pointerMove(ev: SeamPointer): void {
|
|
791
|
-
const p = localXY(ev)
|
|
792
|
-
if (rightPress !== null) {
|
|
793
|
-
// Right-hold pan: incremental viewport follow once past the threshold.
|
|
794
|
-
// User ruling (2026-08-30): the drag moves the CONTENT, not the window —
|
|
795
|
-
// drag right and the canvas follows the cursor (Figma convention), so
|
|
796
|
-
// the viewport origin moves OPPOSITE the pointer delta. The wheel and
|
|
797
|
-
// middle/space pans keep their own established directions.
|
|
798
|
-
const dx = p.x - rightPress.x
|
|
799
|
-
const dy = p.y - rightPress.y
|
|
800
|
-
if (Math.hypot(dx, dy) > 4) {
|
|
801
|
-
rightPress.moved = true
|
|
802
|
-
vp = clampViewport(panBy(vp, -dx, -dy))
|
|
803
|
-
rightPress.x = p.x
|
|
804
|
-
rightPress.y = p.y
|
|
805
|
-
applyViewport()
|
|
806
|
-
}
|
|
807
|
-
return
|
|
808
|
-
}
|
|
809
|
-
if (gesture.kind === 'node') {
|
|
810
|
-
const w = screenToWorld(vp, p.x, p.y)
|
|
811
|
-
const dx = w.x - gesture.last.x
|
|
812
|
-
const dy = w.y - gesture.last.y
|
|
813
|
-
if (dx === 0 && dy === 0) return
|
|
814
|
-
gesture.moved = true
|
|
815
|
-
gesture.last = w
|
|
816
|
-
model.dragNodes(gesture.ids, dx, dy)
|
|
817
|
-
// Drop-target highlight: the innermost frame under the pointer, but
|
|
818
|
-
// only when the drop would change something (some id not yet member).
|
|
819
|
-
const f = innermostFrameAt(new Map(model.allFrames()), w)
|
|
820
|
-
// W-B: peers with a remote handle are droppable members too.
|
|
821
|
-
const joinables = gesture.ids.filter(id => {
|
|
822
|
-
const node = model.getNode(id)
|
|
823
|
-
if (node === undefined) return false
|
|
824
|
-
return node.remote !== true || (node.remoteHandle !== undefined && node.remoteHandle !== '')
|
|
825
|
-
})
|
|
826
|
-
const relevant = f !== undefined && joinables.length > 0
|
|
827
|
-
for (const [name, el] of frameEls) el.classList.toggle('drop-target', relevant && name === f)
|
|
828
|
-
render()
|
|
829
|
-
} else if (gesture.kind === 'frame') {
|
|
830
|
-
const w = screenToWorld(vp, p.x, p.y)
|
|
831
|
-
const dx = w.x - gesture.origin.x
|
|
832
|
-
const dy = w.y - gesture.origin.y
|
|
833
|
-
if (!gesture.moved && dx === 0 && dy === 0) return
|
|
834
|
-
gesture.moved = true
|
|
835
|
-
model.applyFrameDrag(gesture.snap, dx, dy)
|
|
836
|
-
render()
|
|
837
|
-
} else if (gesture.kind === 'frame-resize') {
|
|
838
|
-
// Re-solve the rect from the start rect plus the world delta, per
|
|
839
|
-
// direction. Minimum 220x120 keeps the titlebar and成员 badges
|
|
840
|
-
// readable; the frame rect is independent of member positions, so
|
|
841
|
-
// no node moves — star edges follow on the next render.
|
|
842
|
-
const w = screenToWorld(vp, p.x, p.y)
|
|
843
|
-
const dx = w.x - gesture.origin.x
|
|
844
|
-
const dy = w.y - gesture.origin.y
|
|
845
|
-
if (!gesture.moved && dx === 0 && dy === 0) return
|
|
846
|
-
gesture.moved = true
|
|
847
|
-
const MIN_W = 220
|
|
848
|
-
const MIN_H = 120
|
|
849
|
-
const r = { ...gesture.startRect }
|
|
850
|
-
let { x, y, w: width, h: height } = r
|
|
851
|
-
if (gesture.dir.includes('e')) width = Math.max(MIN_W, r.w + dx)
|
|
852
|
-
if (gesture.dir.includes('s')) height = Math.max(MIN_H, r.h + dy)
|
|
853
|
-
if (gesture.dir.includes('w')) {
|
|
854
|
-
width = Math.max(MIN_W, r.w - dx)
|
|
855
|
-
x = r.x + (r.w - width)
|
|
856
|
-
}
|
|
857
|
-
if (gesture.dir.includes('n')) {
|
|
858
|
-
height = Math.max(MIN_H, r.h - dy)
|
|
859
|
-
y = r.y + (r.h - height)
|
|
860
|
-
}
|
|
861
|
-
model.setFrame(gesture.name, { x: Math.round(x), y: Math.round(y), w: Math.round(width), h: Math.round(height) })
|
|
862
|
-
render()
|
|
863
|
-
} else if (gesture.kind === 'pan') {
|
|
864
|
-
vp = panBy(vp, p.x - gesture.last.x, p.y - gesture.last.y)
|
|
865
|
-
gesture.last = p
|
|
866
|
-
gesture.moved = true
|
|
867
|
-
applyViewport()
|
|
868
|
-
} else if (gesture.kind === 'marquee') {
|
|
869
|
-
const x = Math.min(gesture.origin.x, p.x)
|
|
870
|
-
const y = Math.min(gesture.origin.y, p.y)
|
|
871
|
-
marquee.style.left = `${x}px`
|
|
872
|
-
marquee.style.top = `${y}px`
|
|
873
|
-
marquee.style.width = `${Math.abs(p.x - gesture.origin.x)}px`
|
|
874
|
-
marquee.style.height = `${Math.abs(p.y - gesture.origin.y)}px`
|
|
875
|
-
gesture.last = p
|
|
876
|
-
}
|
|
877
|
-
}
|
|
878
|
-
|
|
879
|
-
function pointerUp(ev: SeamPointer): void {
|
|
880
|
-
if (rightPress !== null) {
|
|
881
|
-
const wasPan = rightPress.moved
|
|
882
|
-
const pressTarget = rightPress.target
|
|
883
|
-
const x = rightPress.x
|
|
884
|
-
const y = rightPress.y
|
|
885
|
-
rightPress = null
|
|
886
|
-
if (wasPan) {
|
|
887
|
-
suppressNextMenu = true
|
|
888
|
-
markDirty() // the viewport persists with the layout
|
|
889
|
-
} else if (pressTarget !== null) {
|
|
890
|
-
menus.contextMenuAt(x, y, pressTarget) // clean right-click: context menu
|
|
891
|
-
}
|
|
892
|
-
return
|
|
893
|
-
}
|
|
894
|
-
if (gesture.kind === 'node') {
|
|
895
|
-
const id = gesture.clicked
|
|
896
|
-
if (!gesture.moved) {
|
|
897
|
-
// A click (no drag) is pure selection — NEVER a drop. The F1 fix:
|
|
898
|
-
// an unmoved multi-select click used to fall through to dropDispatch
|
|
899
|
-
// and write an entire group's 离队 to the host.
|
|
900
|
-
if (ev.shiftKey) {
|
|
901
|
-
const current = model.selectedIds()
|
|
902
|
-
model.setSelection(current.includes(id) ? current.filter(x => x !== id) : [...current, id])
|
|
903
|
-
} else {
|
|
904
|
-
model.setSelection([id])
|
|
905
|
-
// 0.6.13: a clean single click is also a node pick — the host opens
|
|
906
|
-
// the chat drawer. Shift-click (multi-select) stays a pure selection.
|
|
907
|
-
deps.onNodePick?.(id)
|
|
908
|
-
}
|
|
909
|
-
} else {
|
|
910
|
-
dropDispatch(gesture, ev)
|
|
911
|
-
markDirty()
|
|
912
|
-
}
|
|
913
|
-
for (const el of frameEls.values()) el.classList.remove('drop-target')
|
|
914
|
-
gesture.el?.classList.remove('dragging')
|
|
915
|
-
render()
|
|
916
|
-
} else if (gesture.kind === 'frame' || gesture.kind === 'frame-resize') {
|
|
917
|
-
// The frame stays where it was put; neighbours and stray cards yield (planning-overlap).
|
|
918
|
-
if (gesture.moved) { settleBoard(model, new Set(remoteFramesRef.keys()), gesture.name); markDirty(); render() }
|
|
919
|
-
} else if (gesture.kind === 'pan') {
|
|
920
|
-
if (gesture.moved) markDirty() // the viewport persists with the layout
|
|
921
|
-
} else if (gesture.kind === 'marquee') {
|
|
922
|
-
const p = gesture.last
|
|
923
|
-
const a = screenToWorld(vp, Math.min(gesture.origin.x, p.x), Math.min(gesture.origin.y, p.y))
|
|
924
|
-
const b = screenToWorld(vp, Math.max(gesture.origin.x, p.x), Math.max(gesture.origin.y, p.y))
|
|
925
|
-
model.marqueeSelect({ x: a.x, y: a.y, w: b.x - a.x, h: b.y - a.y }, gesture.additive)
|
|
926
|
-
marquee.style.display = 'none'
|
|
927
|
-
render()
|
|
928
|
-
}
|
|
929
|
-
gesture = { kind: 'none' }
|
|
930
|
-
if (undoBefore !== undefined) { layoutUndo.commit(undoBefore); undoBefore = undefined }
|
|
931
|
-
try { root.releasePointerCapture(ev.pointerId ?? 0) } catch { /* not captured */ }
|
|
932
|
-
}
|
|
933
|
-
|
|
934
|
-
/** The OS took the pointer (touch scroll, system gesture): abandon the press — no drop, no menu, layout back. */
|
|
935
|
-
function pointerCancel(ev: SeamPointer): void {
|
|
936
|
-
for (const el of frameEls.values()) el.classList.remove('drop-target')
|
|
937
|
-
if (gesture.kind === 'node') gesture.el?.classList.remove('dragging')
|
|
938
|
-
const panned = (gesture.kind === 'pan' && gesture.moved) || rightPress?.moved === true // right-drag pans live in rightPress
|
|
939
|
-
marquee.style.display = 'none'
|
|
940
|
-
rightPress = null
|
|
941
|
-
gesture = { kind: 'none' }
|
|
942
|
-
if (undoBefore !== undefined && layoutUndo.revert(undoBefore)) render()
|
|
943
|
-
if (panned) markDirty() // the pan stays (it is the view, not the layout) and persists like a finished one
|
|
944
|
-
undoBefore = undefined
|
|
945
|
-
try { root.releasePointerCapture(ev.pointerId ?? 0) } catch { /* not captured */ }
|
|
946
|
-
}
|
|
947
|
-
|
|
948
|
-
function wheel(ev: SeamPointer): void {
|
|
949
|
-
if (menus.isMenuOpen()) menus.closeMenu()
|
|
950
|
-
const p = localXY(ev)
|
|
951
|
-
// Wheel = pointer-anchored zoom (owner ruling 2026-09-01: 鼠标滚动 =
|
|
952
|
-
// 放大缩小), with or without Ctrl — both muscle memories land on the
|
|
953
|
-
// same action. Shift+wheel keeps the natural-scroll pan as the escape
|
|
954
|
-
// hatch. Line-mode wheels (Firefox, deltaMode=1) report ~3 per notch.
|
|
955
|
-
if (ev.shiftKey && !ev.ctrlKey) {
|
|
956
|
-
const line = ev.deltaMode === 1 ? 16 : 1
|
|
957
|
-
vp = clampViewport(panBy(vp, (ev.deltaX ?? 0) * line, (ev.deltaY ?? 0) * line))
|
|
958
|
-
} else {
|
|
959
|
-
const line = ev.deltaMode === 1 ? 16 : 1
|
|
960
|
-
const factor = Math.exp(-((ev.deltaY ?? 0) * line) * 0.0015)
|
|
961
|
-
vp = zoomAt(vp, factor, p.x, p.y)
|
|
962
|
-
}
|
|
963
|
-
ev.preventDefault()
|
|
964
|
-
applyViewport()
|
|
965
|
-
markDirty()
|
|
966
|
-
}
|
|
967
|
-
|
|
968
|
-
function zoomCenter(factor: number): void {
|
|
969
|
-
syncViewSize()
|
|
970
|
-
vp = zoomAt(vp, factor, viewW / 2, viewH / 2)
|
|
971
|
-
applyViewport()
|
|
972
|
-
markDirty()
|
|
973
|
-
}
|
|
974
|
-
|
|
975
|
-
function undoLayout(): void {
|
|
976
|
-
if (layoutUndo.undo()) { markDirty(); render() } else notice('info', '没有可撤销的布局改动')
|
|
977
|
-
}
|
|
978
|
-
|
|
979
|
-
function fitToContent(): void {
|
|
980
|
-
syncViewSize()
|
|
981
|
-
vp = fitBelowHud(boardBounds(model.contentBounds(), model.allNodes()), viewW, viewH, hudBottomOf(toolbar, status))
|
|
982
|
-
applyViewport()
|
|
983
|
-
markDirty()
|
|
984
|
-
}
|
|
985
|
-
|
|
986
|
-
function key(ev: SeamKey): void {
|
|
987
|
-
if (menus.isDialogOpen() || (ev.target as Element | null)?.closest?.('.p-search') !== null) return // modal / search box own their keys
|
|
988
|
-
if ((ev.ctrlKey || ev.metaKey) && ev.key.toLowerCase() === 'k') { ev.preventDefault(); search.focus(); return } // W1 Ctrl+K
|
|
989
|
-
if ((ev.ctrlKey || ev.metaKey) && !ev.shiftKey && ev.key.toLowerCase() === 'z') { ev.preventDefault(); undoLayout(); return }
|
|
990
|
-
if (ev.ctrlKey || ev.metaKey) return // browser/OS chords stay browser/OS
|
|
991
|
-
if (menus.isMenuOpen()) {
|
|
992
|
-
// The menu owns most keys (its own handler navigates); Escape still
|
|
993
|
-
// closes from here so a seam/keyboard Esc never gets lost.
|
|
994
|
-
if (ev.key === 'Escape') { menus.closeMenu(); ev.preventDefault() }
|
|
995
|
-
return
|
|
996
|
-
}
|
|
997
|
-
if (ev.key === ' ' && ev.target === root) { spaceDown = true; ev.preventDefault(); return }
|
|
998
|
-
// 键盘全等路径(WCAG 2.2 Dragging Movements):
|
|
999
|
-
const headTarget = (ev.target as Element | null)?.closest?.('.p-frame-head') ?? null
|
|
1000
|
-
if ((ev.key === 'Enter' || ev.key === ' ') && headTarget !== null) {
|
|
1001
|
-
ev.preventDefault()
|
|
1002
|
-
const r = headTarget.getBoundingClientRect()
|
|
1003
|
-
menus.contextMenuAt(r.left + r.width / 2, r.top + r.height / 2, headTarget)
|
|
1004
|
-
return
|
|
1005
|
-
}
|
|
1006
|
-
if (ev.key === ' ' && (ev.target as Element | null)?.classList?.contains('p-node')) {
|
|
1007
|
-
const el = ev.target as HTMLElement
|
|
1008
|
-
const id = el.dataset.id ?? ''
|
|
1009
|
-
const current = model.selectedIds()
|
|
1010
|
-
model.setSelection(current.includes(id) ? current.filter(x => x !== id) : [...current, id])
|
|
1011
|
-
render()
|
|
1012
|
-
ev.preventDefault()
|
|
1013
|
-
return
|
|
1014
|
-
}
|
|
1015
|
-
if ((ev.key === 'F10' && ev.shiftKey) || ev.key === 'ContextMenu') {
|
|
1016
|
-
ev.preventDefault()
|
|
1017
|
-
const fe = (document.activeElement as Element | null) ?? null
|
|
1018
|
-
const r = fe?.getBoundingClientRect?.() ?? { left: 0, top: 0, width: 0, height: 0 }
|
|
1019
|
-
menus.contextMenuAt(r.left + r.width / 2, r.top + r.height / 2, fe)
|
|
1020
|
-
return
|
|
1021
|
-
}
|
|
1022
|
-
if (ev.key === 'Delete' || ev.key === 'Backspace') { ev.preventDefault(); deleteFocused(); return }
|
|
1023
|
-
if (ev.altKey && (ev.key === 'ArrowUp' || ev.key === 'ArrowDown')) { ev.preventDefault(); altMove(ev.key === 'ArrowUp'); return }
|
|
1024
|
-
if (ev.key === 'g' || ev.key === 'G') { menus.startCreateFromSelection(); return }
|
|
1025
|
-
if (ev.key === '0') { fitToContent(); return }
|
|
1026
|
-
if (ev.key === '=' || ev.key === '+') { zoomCenter(1.2); return }
|
|
1027
|
-
if (ev.key === '-') { zoomCenter(1 / 1.2); return }
|
|
1028
|
-
if (ev.key === 'Escape') {
|
|
1029
|
-
if (menus.isMenuOpen()) { menus.closeMenu(); render(); return } // menu > clear selection
|
|
1030
|
-
model.setSelection([])
|
|
1031
|
-
render()
|
|
1032
|
-
return
|
|
1033
|
-
}
|
|
1034
|
-
const step = ev.shiftKey ? 40 : 8
|
|
1035
|
-
const dxdy: Record<string, [number, number]> = {
|
|
1036
|
-
ArrowLeft: [-step, 0], ArrowRight: [step, 0], ArrowUp: [0, -step], ArrowDown: [0, step],
|
|
1037
|
-
}
|
|
1038
|
-
const d = dxdy[ev.key]
|
|
1039
|
-
if (d !== undefined) {
|
|
1040
|
-
ev.preventDefault()
|
|
1041
|
-
const ids = model.selectedIds()
|
|
1042
|
-
if (ids.length > 0) {
|
|
1043
|
-
const before = layoutUndo.capture()
|
|
1044
|
-
model.nudge(ids, d[0]!, d[1]!)
|
|
1045
|
-
layoutUndo.commit(before)
|
|
1046
|
-
render()
|
|
1047
|
-
markDirty()
|
|
1048
|
-
}
|
|
1049
|
-
}
|
|
1050
|
-
}
|
|
1051
|
-
|
|
1052
|
-
/** Delete 按焦点对象:单队卡=离队、多队卡=下钻离队菜单、无队=info、框头=散队。 */
|
|
1053
|
-
function deleteFocused(): void {
|
|
1054
|
-
const fe = document.activeElement as HTMLElement | null
|
|
1055
|
-
const headEl = fe?.closest?.<HTMLElement>('.p-frame-head') ?? null
|
|
1056
|
-
const nodeEl = fe?.closest?.<HTMLElement>('.p-node') ?? null
|
|
1057
|
-
if (headEl !== null) {
|
|
1058
|
-
emitAction({ type: 'remove-team', name: headEl.dataset.frame ?? '' })
|
|
1059
|
-
return
|
|
1060
|
-
}
|
|
1061
|
-
if (nodeEl !== null) {
|
|
1062
|
-
const id = nodeEl.dataset.id ?? ''
|
|
1063
|
-
const teams = model.getNode(id)?.memberships.map(m => m.team) ?? []
|
|
1064
|
-
if (teams.length === 0) { notice('info', '该节点未加入任何团队'); return }
|
|
1065
|
-
if (teams.length === 1) {
|
|
1066
|
-
emitAction({ type: 'remove-member', team: teams[0]!, ids: [id] })
|
|
1067
|
-
return
|
|
1068
|
-
}
|
|
1069
|
-
menus.openMenu(window.innerWidth / 2, window.innerHeight / 2, { kind: 'node', id }, 'leave', nodeEl)
|
|
1070
|
-
return
|
|
1071
|
-
}
|
|
1072
|
-
notice('info', '焦点不在节点或团队框上')
|
|
1073
|
-
}
|
|
1074
|
-
|
|
1075
|
-
/** Alt+↑/↓: move the focused card within its single team's priority. */
|
|
1076
|
-
function altMove(up: boolean): void {
|
|
1077
|
-
const fe = document.activeElement as HTMLElement | null
|
|
1078
|
-
const nodeEl = fe?.closest?.<HTMLElement>('.p-node') ?? null
|
|
1079
|
-
if (nodeEl === null) return
|
|
1080
|
-
const id = nodeEl.dataset.id ?? ''
|
|
1081
|
-
const mem = model.getNode(id)?.memberships ?? []
|
|
1082
|
-
if (mem.length !== 1) return
|
|
1083
|
-
const team = mem[0]!.team
|
|
1084
|
-
const current = model.teamMemberIds(team)
|
|
1085
|
-
const idx = current.indexOf(id)
|
|
1086
|
-
const to = up ? Math.max(0, idx - 1) : Math.min(current.length - 1, idx + 1)
|
|
1087
|
-
if (to === idx) return
|
|
1088
|
-
const desired = [...current]
|
|
1089
|
-
desired.splice(idx, 1)
|
|
1090
|
-
desired.splice(to, 0, id)
|
|
1091
|
-
emitAction({ type: 'reorder', team, ops: reorderOps(current, desired) })
|
|
1092
|
-
}
|
|
1093
|
-
|
|
1094
|
-
function keyUp(ev: SeamKey): void {
|
|
1095
|
-
if (ev.key === ' ') spaceDown = false
|
|
1096
|
-
}
|
|
1097
|
-
|
|
1098
|
-
root.addEventListener('pointerdown', (ev) => pointerDown(ev as unknown as SeamPointer), signal)
|
|
1099
|
-
root.addEventListener('pointermove', (ev) => pointerMove(ev as unknown as SeamPointer), signal)
|
|
1100
|
-
root.addEventListener('pointerup', (ev) => pointerUp(ev as unknown as SeamPointer), signal)
|
|
1101
|
-
root.addEventListener('pointercancel', (ev) => pointerCancel(ev as unknown as SeamPointer), signal)
|
|
1102
|
-
root.addEventListener('wheel', (ev) => wheel(ev as unknown as SeamPointer), { ...signal, passive: false })
|
|
1103
|
-
root.addEventListener('keydown', (ev) => key(ev as unknown as SeamKey), signal)
|
|
1104
|
-
root.addEventListener('keyup', (ev) => keyUp(ev as unknown as SeamKey), signal)
|
|
1105
|
-
root.addEventListener('contextmenu', (ev) => {
|
|
1106
|
-
const e = ev as unknown as SeamPointer
|
|
1107
|
-
if ((e.target as Element | null)?.closest?.('input, textarea, .p-search') != null) return // text fields keep the native menu (paste)
|
|
1108
|
-
// Right-drag pans (suppressNextMenu); a clean right-click opens the
|
|
1109
|
-
// menu programmatically at pointerup. Either way the native menu dies.
|
|
1110
|
-
e.preventDefault()
|
|
1111
|
-
if (suppressNextMenu) { suppressNextMenu = false; return }
|
|
1112
|
-
menus.contextMenuAt(e.clientX, e.clientY, e.target)
|
|
1113
|
-
}, signal)
|
|
1114
|
-
|
|
1115
|
-
// ── poll reconcile: incremental, never repositions existing nodes ──
|
|
1116
|
-
let lastTeams: ReadonlyArray<PlanningTeam> = []
|
|
1117
|
-
|
|
1118
|
-
function reconcile(input: PlanningInput): void {
|
|
1119
|
-
// gh#41: remote-frame registry shared with serverAction (drop fork).
|
|
1120
|
-
lastTeams = input.teams
|
|
1121
|
-
rosterFramesRef = new Set([...input.teams.filter(t => t.kind === 'roster').map(t => t.name), ...[...rosterFramesRef].filter(n => pendingTeams.has(n))])
|
|
1122
|
-
lastPeers = input.peers ?? []
|
|
1123
|
-
federation.setInFlight(input.inFlight ?? [])
|
|
1124
|
-
// Unjoined sessions have no node on the canvas — the panel-slim
|
|
1125
|
-
// migration made this view the join surface, so they ride a compact
|
|
1126
|
-
// toolbar chip instead of vanishing entirely.
|
|
1127
|
-
lastSessions = input.sessions
|
|
1128
|
-
menus.setUnjoined(input.sessions.filter(s => s.joined !== true && !s.id.startsWith('peer-')))
|
|
1129
|
-
const memberships = membershipIndex(input.teams)
|
|
1130
|
-
lastCanvasMemberIds = new Set(memberships.keys())
|
|
1131
|
-
const seen = new Set<string>()
|
|
1132
|
-
// In-flight write guard: membership entries of teams with unsettled
|
|
1133
|
-
// writes come from the model, not the (stale) payload — a poll racing
|
|
1134
|
-
// an optimistic add must not snap the new edge away.
|
|
1135
|
-
const resolveMemberships = (id: string): Array<{ team: string; index: number }> => {
|
|
1136
|
-
const payload = memberships.get(id) ?? []
|
|
1137
|
-
if (pendingTeams.size === 0) return payload
|
|
1138
|
-
const keep = payload.filter(m => !pendingTeams.has(m.team))
|
|
1139
|
-
// In-flight teams read their in-membership from the team roster
|
|
1140
|
-
// (teamMemberIds), NOT from getNode(id).memberships: an optimistic
|
|
1141
|
-
// add can target a node the teamed gate kept OFF the model (no card
|
|
1142
|
-
// yet) — its membership lives only in the roster map, and dropping
|
|
1143
|
-
// it here would pop the node right back off the canvas mid-flight.
|
|
1144
|
-
const pending: Array<{ team: string; index: number }> = []
|
|
1145
|
-
for (const [team] of pendingTeams) {
|
|
1146
|
-
const roster = model.teamMemberIds(team)
|
|
1147
|
-
const index = roster.indexOf(id)
|
|
1148
|
-
if (index >= 0) pending.push({ team, index })
|
|
1149
|
-
}
|
|
1150
|
-
return [...keep, ...pending]
|
|
1151
|
-
}
|
|
1152
|
-
// First build with nothing saved: seat the fleet team by team (see
|
|
1153
|
-
// planTeamClusters). Later arrivals, or any board with a saved layout,
|
|
1154
|
-
// keep the ring fallback.
|
|
1155
|
-
let clusterSeats: Map<string, { x: number; y: number }> | undefined
|
|
1156
|
-
if (layoutDoc === undefined && !model.allNodes().some(n => n.remote !== true)) {
|
|
1157
|
-
const seatIds = input.sessions
|
|
1158
|
-
.filter(s => s.joined === true && (resolveMemberships(s.id).length > 0 || (s.teams?.length ?? 0) > 0))
|
|
1159
|
-
.map(s => s.id)
|
|
1160
|
-
syncViewSize()
|
|
1161
|
-
clusterSeats = planTeamClusters(input.teams, seatIds, viewW > 0 ? viewW : undefined)
|
|
1162
|
-
}
|
|
1163
|
-
// A joined session whose only team is a peer's (a mirror frame) is teamed too: without its
|
|
1164
|
-
// card the mirror frame cannot show it, and 离队 has nothing to reach (owner report 2026-11-04).
|
|
1165
|
-
const mirrorMembers = input.peerOrg !== undefined ? mirrorMemberIds(input.peerOrg, input.sessions, input.remoteTeams) : new Set<string>()
|
|
1166
|
-
// The node list reads the same fact: such a session is teamed, not 未组队.
|
|
1167
|
-
if (input.peerOrg !== undefined) lastCanvasMemberIds = new Set([...lastCanvasMemberIds, ...mirrorMembers])
|
|
1168
|
-
for (const s of input.sessions) {
|
|
1169
|
-
if (s.joined !== true) continue
|
|
1170
|
-
// Teamed-only canvas (network-membership-display ruling): a joined
|
|
1171
|
-
// session earns a canvas node only with a team — canvas membership
|
|
1172
|
-
// (optimistic-aware), a declared roster team, or a mirror frame's
|
|
1173
|
-
// membership. Teamless joined sessions ride the node list's 未组队
|
|
1174
|
-
// group instead; sessions absent from `seen` are swept from the model below.
|
|
1175
|
-
const effective = resolveMemberships(s.id)
|
|
1176
|
-
if (effective.length === 0 && (s.teams === undefined || s.teams.length === 0) && !mirrorMembers.has(s.id)) continue
|
|
1177
|
-
seen.add(s.id)
|
|
1178
|
-
if (model.getNode(s.id) !== undefined) {
|
|
1179
|
-
model.upsertNode({
|
|
1180
|
-
id: s.id, x: model.getNode(s.id)!.x, y: model.getNode(s.id)!.y,
|
|
1181
|
-
label: s.label, team: s.team, name: s.name, description: s.description, live: s.live !== false,
|
|
1182
|
-
memberships: effective,
|
|
1183
|
-
...(s.teams !== undefined && s.teams.length > 0 ? { teams: s.teams } : {}),
|
|
1184
|
-
})
|
|
1185
|
-
} else {
|
|
1186
|
-
const saved = layoutDoc?.nodes[s.id]
|
|
1187
|
-
let x: number
|
|
1188
|
-
let y: number
|
|
1189
|
-
if (saved !== undefined) { x = saved.x; y = saved.y } else {
|
|
1190
|
-
const seat = clusterSeats?.get(s.id) ?? seatFor2(s.id)
|
|
1191
|
-
x = seat.x
|
|
1192
|
-
y = seat.y
|
|
1193
|
-
}
|
|
1194
|
-
model.upsertNode({
|
|
1195
|
-
id: s.id, x, y, label: s.label, team: s.team, name: s.name, description: s.description, live: s.live !== false,
|
|
1196
|
-
memberships: effective,
|
|
1197
|
-
...(s.teams !== undefined && s.teams.length > 0 ? { teams: s.teams } : {}),
|
|
1198
|
-
})
|
|
1199
|
-
}
|
|
1200
|
-
}
|
|
1201
|
-
for (const id of model.nodeIds()) {
|
|
1202
|
-
// peer- and member- cards are reconciled by their own blocks below.
|
|
1203
|
-
if (!seen.has(id) && !id.startsWith('peer-') && !id.startsWith('member-')) model.removeNode(id)
|
|
1204
|
-
}
|
|
1205
|
-
|
|
1206
|
-
// Peer nodes (v1 ruling): same model pipeline as sessions, `remote` flag
|
|
1207
|
-
// on. Saved positions win; new peers take the deterministic column over
|
|
1208
|
-
// the LOCAL content bounds (remote cards hang outside the field and are
|
|
1209
|
-
// excluded from bounds, so the column cannot feed back into itself).
|
|
1210
|
-
const peerSeen = new Set<string>()
|
|
1211
|
-
lastRemoteTeams = groupRemoteTeams(input.remoteTeams ?? [])
|
|
1212
|
-
memberHostByHandle = new Map([...lastRemoteTeams].flatMap(([h, rows]) => rows.map(row => [row.team, h] as const)))
|
|
1213
|
-
lastWork = new Map(input.sessions.map(s => [s.id, { status: s.status, queued: s.queued }]))
|
|
1214
|
-
reachability = unreachableRefs(input)
|
|
1215
|
-
peerScoreByText.clear()
|
|
1216
|
-
for (const p of input.peers ?? []) {
|
|
1217
|
-
peerScoreByText.set(peerHostOf(p.url), p.score)
|
|
1218
|
-
peerInfoByHost.set(peerHostOf(p.url), { link: p.link, verified: p.verified })
|
|
1219
|
-
}
|
|
1220
|
-
if (input.peers !== undefined) {
|
|
1221
|
-
const localBounds = model.contentBounds()
|
|
1222
|
-
const placements = placePeers(input.peers, localBounds)
|
|
1223
|
-
input.peers.forEach((p, i) => {
|
|
1224
|
-
const id = peerNodeId(p.url)
|
|
1225
|
-
peerSeen.add(id)
|
|
1226
|
-
const saved = layoutDoc?.nodes[id]
|
|
1227
|
-
const existing = model.getNode(id)
|
|
1228
|
-
const base = existing !== undefined
|
|
1229
|
-
? { x: existing.x, y: existing.y }
|
|
1230
|
-
: saved !== undefined
|
|
1231
|
-
? { x: saved.x, y: saved.y }
|
|
1232
|
-
: { x: placements[i]?.x ?? 200, y: placements[i]?.y ?? 0 }
|
|
1233
|
-
// gh#20 W-B: the peer's server-side member form — its first remote
|
|
1234
|
-
// team row's zone/id8 handle (canvas remote refs are exact handles
|
|
1235
|
-
// per the D1 ruling). Absent until the first remote team row lands.
|
|
1236
|
-
const host = peerHostOf(p.url)
|
|
1237
|
-
// Owner report 2026-11-04: the host's rows now lead with its bare process team (e.g. 'dsh'), which the
|
|
1238
|
-
// server reads as a local session id and refuses — take the first zone/id8 SESSION handle instead.
|
|
1239
|
-
const handle = lastRemoteTeams.get(host)?.find(row => row.team.includes('/'))?.team
|
|
1240
|
-
model.upsertNode({
|
|
1241
|
-
id, x: base.x, y: base.y,
|
|
1242
|
-
label: p.url, team: '', name: p.url, peerUrl: p.url,
|
|
1243
|
-
remoteHandle: handle,
|
|
1244
|
-
live: true, remote: true, memberships: [],
|
|
1245
|
-
})
|
|
1246
|
-
model.upsertScore(id, p.score)
|
|
1247
|
-
})
|
|
1248
|
-
}
|
|
1249
|
-
for (const id of model.nodeIds()) {
|
|
1250
|
-
if (id.startsWith('peer-') && !peerSeen.has(id)) model.removeNode(id)
|
|
1251
|
-
}
|
|
1252
|
-
// gh#38 (owner ruling 「统一到画布里面拖拽」): every remote session-team
|
|
1253
|
-
// HANDLE renders as its own draggable card, stacked beside its host's
|
|
1254
|
-
// peer card — pulling a specific remote member into a local team is a
|
|
1255
|
-
// plain drag (no picker, no hand-typed handle). Host cards stay for
|
|
1256
|
-
// federation context; the member cards carry the actual join gesture.
|
|
1257
|
-
|
|
1258
|
-
const memberSeen = new Set<string>()
|
|
1259
|
-
for (const [host, rows] of lastRemoteTeams) {
|
|
1260
|
-
const hostNode = model.allNodes().find(n => n.remote === true && peerHostOf(n.peerUrl ?? '') === host && !n.id.startsWith('member-'))
|
|
1261
|
-
const baseX = (hostNode?.x ?? 400) + 190
|
|
1262
|
-
let y = hostNode?.y ?? 0
|
|
1263
|
-
for (const row of rows) {
|
|
1264
|
-
if (!row.team.includes('/')) continue
|
|
1265
|
-
const id = `member-${row.team.replace(/[^a-z0-9-]/gi, '-')}`
|
|
1266
|
-
if (memberSeen.has(id)) continue
|
|
1267
|
-
memberSeen.add(id)
|
|
1268
|
-
const saved = layoutDoc?.nodes[id]
|
|
1269
|
-
const existing = model.getNode(id)
|
|
1270
|
-
const pos = existing !== undefined ? { x: existing.x, y: existing.y } : saved !== undefined ? { x: saved.x, y: saved.y } : { x: baseX, y }
|
|
1271
|
-
y += 92
|
|
1272
|
-
model.upsertNode({
|
|
1273
|
-
id, x: pos.x, y: pos.y,
|
|
1274
|
-
label: row.team, team: row.team, name: row.name !== undefined && row.name !== '' ? row.name : row.team,
|
|
1275
|
-
remoteHandle: row.team,
|
|
1276
|
-
live: true, remote: true, memberships: [],
|
|
1277
|
-
})
|
|
1278
|
-
}
|
|
1279
|
-
}
|
|
1280
|
-
for (const id of model.nodeIds()) {
|
|
1281
|
-
if (id.startsWith('member-') && !memberSeen.has(id)) model.removeNode(id)
|
|
1282
|
-
}
|
|
1283
|
-
// gh#42 (owner report: dragged a remote peer into base, no edge): team
|
|
1284
|
-
// edges derive from NODE memberships, but remote cards carried hardcoded
|
|
1285
|
-
// empty memberships — the server truth (base contains dsh/d659a8f9)
|
|
1286
|
-
// never re-attached after a poll or reload. Re-attach here: every local
|
|
1287
|
-
// team's remote member handles resolve onto their cards (member- card
|
|
1288
|
-
// preferred, the peer HOST card as fallback), in-flight teams excluded.
|
|
1289
|
-
const handleToNode = new Map<string, string>()
|
|
1290
|
-
for (const n of model.allNodes()) {
|
|
1291
|
-
if (n.remote !== true || n.remoteHandle === undefined || n.remoteHandle === '') continue
|
|
1292
|
-
if (!handleToNode.has(n.remoteHandle) || n.id.startsWith('member-')) handleToNode.set(n.remoteHandle, n.id)
|
|
1293
|
-
}
|
|
1294
|
-
for (const team of input.teams) {
|
|
1295
|
-
if (pendingTeams.has(team.name)) continue
|
|
1296
|
-
const resolved: string[] = []
|
|
1297
|
-
for (const m of team.members) {
|
|
1298
|
-
const id = handleToNode.get(m.id) ?? m.id
|
|
1299
|
-
if (model.getNode(id) !== undefined) resolved.push(id)
|
|
1300
|
-
}
|
|
1301
|
-
const current = [...model.teamMemberIds(team.name)].sort().join('|')
|
|
1302
|
-
const next = [...resolved].sort().join('|')
|
|
1303
|
-
if (current !== next) model.setTeamMembers(team.name, resolved)
|
|
1304
|
-
}
|
|
1305
|
-
// gh#41 fleet planning: the peers' published canvas org renders as
|
|
1306
|
-
// REMOTE team frames — the owner reads the other side's grouping from
|
|
1307
|
-
// ANY node and regulates it in place. Members attach as board
|
|
1308
|
-
// memberships; drops into a remote frame fork to the host relay (see
|
|
1309
|
-
// serverAction: the action carries fleetPeer, and main.ts routes it to
|
|
1310
|
-
// /__dsh_a2a/fleet-canvas-op instead of the local canvas control).
|
|
1311
|
-
const remoteFrames = new Map<string, string>()
|
|
1312
|
-
const prevRemoteFrames = remoteFramesRef
|
|
1313
|
-
remoteFramesRef = remoteFrames
|
|
1314
|
-
if (input.peerOrg !== undefined) {
|
|
1315
|
-
let slot = 0
|
|
1316
|
-
const localNames = new Set(input.teams.map(t => t.name))
|
|
1317
|
-
dissolving.sweep(input.peerOrg)
|
|
1318
|
-
for (const row of input.peerOrg) {
|
|
1319
|
-
// Resolve joined local handles and id-form legacy members through the pure projection helper.
|
|
1320
|
-
const publishedHandles = new Set((input.remoteTeams ?? []).map(session => session.team))
|
|
1321
|
-
const localIdsByHandle = joinedLocalIdsByHandle(input.sessions, publishedHandles)
|
|
1322
|
-
for (const team of row.org) {
|
|
1323
|
-
if (team.name === '' || localNames.has(team.name) || dissolving.hides(team.name, row.url)) continue
|
|
1324
|
-
const memberIds = team.members.map(member => remoteMemberBoardId(member, localIdsByHandle, input.remoteTeams, row.url)).filter(id => model.getNode(id) !== undefined)
|
|
1325
|
-
const saved = layoutDoc?.frames?.[team.name]
|
|
1326
|
-
// gh#44 (owner: frame position/size never sticks): the LOCAL-team
|
|
1327
|
-
// path keeps an existing frame's live rect ("live rect wins" — a
|
|
1328
|
-
// poll must not snap a frame the user just dragged, the save loop
|
|
1329
|
-
// persists it a beat later). Remote frames re-set unconditionally
|
|
1330
|
-
// every poll, so every drag snapped back before the save landed.
|
|
1331
|
-
// Same contract here: only place the frame when it is absent.
|
|
1332
|
-
if (model.getFrame(team.name) === undefined) {
|
|
1333
|
-
model.setFrame(team.name, saved !== undefined ? { x: saved.x, y: saved.y, w: saved.w, h: saved.h } : { x: 200 + (slot % 4) * 280, y: 940 + Math.floor(slot / 4) * 160, w: 250, h: 130 })
|
|
1334
|
-
}
|
|
1335
|
-
model.setTeamMembers(team.name, memberIds)
|
|
1336
|
-
remoteFrames.set(team.name, row.url)
|
|
1337
|
-
slot += 1
|
|
1338
|
-
}
|
|
1339
|
-
}
|
|
1340
|
-
}
|
|
1341
|
-
for (const [name, url] of prevRemoteFrames) {
|
|
1342
|
-
if (!remoteFrames.has(name)) {
|
|
1343
|
-
model.removeFrame(name)
|
|
1344
|
-
void url
|
|
1345
|
-
}
|
|
1346
|
-
}
|
|
1347
|
-
// gh#20 W-B: the server echoes cross-host members as zone/id8 handles —
|
|
1348
|
-
// map each handle back onto the peer node that carries it, so the
|
|
1349
|
-
// reconcile keeps remote memberships instead of dropping them.
|
|
1350
|
-
const handleToPeer = new Map<string, string>()
|
|
1351
|
-
for (const n of model.allNodes()) {
|
|
1352
|
-
if (n.remote === true && n.remoteHandle !== undefined && n.remoteHandle !== '') handleToPeer.set(n.remoteHandle, n.id)
|
|
1353
|
-
}
|
|
1354
|
-
const homes = homeTeams(input.teams)
|
|
1355
|
-
for (const team of input.teams) {
|
|
1356
|
-
if (model.getFrame(team.name) !== undefined) continue // live rect wins
|
|
1357
|
-
if (pendingTeams.has(team.name)) continue // 散队 in flight: don't resurrect
|
|
1358
|
-
const saved = layoutDoc?.frames[team.name]
|
|
1359
|
-
if (saved !== undefined) { model.setFrame(team.name, { ...saved }); continue }
|
|
1360
|
-
// A derived frame wraps its HOME members only: a cross-team member is
|
|
1361
|
-
// reached by its dashed edge, not by stretching this frame over its
|
|
1362
|
-
// home frame. A team made only of members homed elsewhere falls back
|
|
1363
|
-
// to wrapping them all.
|
|
1364
|
-
const rects: LayoutRect[] = []
|
|
1365
|
-
const all: LayoutRect[] = []
|
|
1366
|
-
for (const m of team.members) {
|
|
1367
|
-
const n = model.getNode(handleToPeer.get(m.id) ?? m.id)
|
|
1368
|
-
if (n === undefined) continue
|
|
1369
|
-
all.push(nodeRect(n))
|
|
1370
|
-
if (homes.get(n.id) === team.name) rects.push(nodeRect(n))
|
|
1371
|
-
}
|
|
1372
|
-
const wrap = rects.length > 0 ? rects : all
|
|
1373
|
-
if (wrap.length > 0) model.setFrame(team.name, deriveInitialFrame(wrap))
|
|
1374
|
-
}
|
|
1375
|
-
for (const [name] of model.allFrames()) {
|
|
1376
|
-
if (pendingTeams.has(name)) continue // an in-flight create/remove owns this name
|
|
1377
|
-
// gh#41: remote org frames are reconciled by the peerOrg block above —
|
|
1378
|
-
// the local-team cleanup must not remove them.
|
|
1379
|
-
if (!input.teams.some(t => t.name === name) && !remoteFrames.has(name)) model.removeFrame(name)
|
|
1380
|
-
}
|
|
1381
|
-
// An open node panel follows the poll: facts (joins, leaves, team
|
|
1382
|
-
// declarations, peer arrivals) land within one cadence.
|
|
1383
|
-
nodes.refresh()
|
|
1384
|
-
renderStatus(input)
|
|
1385
|
-
// First-run framing: with no saved layout the ring seats are centered on
|
|
1386
|
-
// world (0,0) while the viewport origin is the board's top-left corner,
|
|
1387
|
-
// so half the fleet landed above/left of the view and a healthy board
|
|
1388
|
-
// opened empty. Fit once, the first time there is something to frame.
|
|
1389
|
-
// Not marked dirty — nothing the user arranged changed; a saved layout
|
|
1390
|
-
// (adopted at any point) keeps its own viewport and cancels this.
|
|
1391
|
-
// Once per load, a board that arrives overlapped is shown settled (not
|
|
1392
|
-
// saved: no write without a user action; the next edit persists it).
|
|
1393
|
-
if (!settledOnce && gesture.kind === 'none' && model.nodeIds().length > 0) { settledOnce = true; const b = layoutUndo.capture(); settleBoard(model, new Set(remoteFramesRef.keys())); layoutUndo.commit(b) }
|
|
1394
|
-
if (firstFitPending && gesture.kind === 'none' && model.nodeIds().length > 0) {
|
|
1395
|
-
syncViewSize()
|
|
1396
|
-
if (viewW > 0 && viewH > 0) {
|
|
1397
|
-
firstFitPending = false
|
|
1398
|
-
// Peer cards hang outside the local content bounds — frame them too,
|
|
1399
|
-
// or the federation column opens clipped at the board's edge. Same
|
|
1400
|
-
// framing as 适配 (planning-fit): ≤100%, below the HUD.
|
|
1401
|
-
vp = fitBelowHud(boardBounds(model.contentBounds(), model.allNodes()), viewW, viewH, hudBottomOf(toolbar, status))
|
|
1402
|
-
}
|
|
1403
|
-
}
|
|
1404
|
-
render()
|
|
1405
|
-
}
|
|
1406
|
-
|
|
1407
|
-
// Same fallback contract as WorldModel.fromState: the card-scaled ring.
|
|
1408
|
-
function seatFor2(id: string): { x: number; y: number } {
|
|
1409
|
-
return planSeatClear(id, model.allNodes().filter(n => n.remote !== true))
|
|
1410
|
-
}
|
|
1411
|
-
|
|
1412
|
-
function adoptExternalLayout(layout: unknown): void {
|
|
1413
|
-
const doc = clampDoc(layout)
|
|
1414
|
-
if (doc === undefined) return
|
|
1415
|
-
layoutDoc = doc
|
|
1416
|
-
const store = deps.viewportStore
|
|
1417
|
-
if (store === undefined) {
|
|
1418
|
-
firstFitPending = false
|
|
1419
|
-
if (gesture.kind === 'none') vp = clampViewport({ ...doc.viewport })
|
|
1420
|
-
} else if (!viewportAdopted && gesture.kind === 'none') {
|
|
1421
|
-
viewportAdopted = true
|
|
1422
|
-
const local = store.get(bucket()) as Partial<LayoutViewport> | null | undefined
|
|
1423
|
-
if (local != null && Number.isFinite(local.x) && Number.isFinite(local.y) && Number.isFinite(local.scale)) {
|
|
1424
|
-
vp = clampViewport({ x: local.x!, y: local.y!, scale: local.scale! })
|
|
1425
|
-
firstFitPending = false
|
|
1426
|
-
} else if (bucket() === 'wide') {
|
|
1427
|
-
vp = clampViewport({ ...doc.viewport })
|
|
1428
|
-
firstFitPending = false
|
|
1429
|
-
} // narrow/medium with no memory of its own: keep firstFitPending, frame on reconcile
|
|
1430
|
-
}
|
|
1431
|
-
renderedRevision = -1 // force a repaint of frames/edges on next render
|
|
1432
|
-
render()
|
|
1433
|
-
}
|
|
1434
|
-
|
|
1435
|
-
function snapshotDoc(): unknown {
|
|
1436
|
-
return buildLayoutDoc(vp, model.positions(), model.frameRects())
|
|
1437
|
-
}
|
|
1438
|
-
|
|
1439
|
-
function setLamp(state: LampState, savedAt?: string): void {
|
|
1440
|
-
lamp.classList.remove('pending', 'saved', 'error')
|
|
1441
|
-
if (state === 'pending') lamp.classList.add('pending')
|
|
1442
|
-
if (state === 'saved') lamp.classList.add('saved')
|
|
1443
|
-
if (state === 'error') lamp.classList.add('error')
|
|
1444
|
-
// Error state is a keyboard-reachable retry control (review P0-2).
|
|
1445
|
-
if (state === 'error') {
|
|
1446
|
-
lamp.tabIndex = 0
|
|
1447
|
-
lamp.setAttribute('role', 'button')
|
|
1448
|
-
lamp.setAttribute('aria-label', '布局保存失败,按回车重试')
|
|
1449
|
-
} else {
|
|
1450
|
-
lamp.removeAttribute('tabindex')
|
|
1451
|
-
lamp.removeAttribute('role')
|
|
1452
|
-
lamp.removeAttribute('aria-label')
|
|
1453
|
-
}
|
|
1454
|
-
const text = state === 'pending' ? '布局待保存…'
|
|
1455
|
-
: state === 'saved' ? `布局已保存 ${savedAt ?? ''}`.trimEnd()
|
|
1456
|
-
: state === 'error' ? '保存失败 · 回车重试'
|
|
1457
|
-
: '布局'
|
|
1458
|
-
lampText.textContent = text
|
|
1459
|
-
}
|
|
1460
|
-
|
|
1461
|
-
function activate(): void {
|
|
1462
|
-
root.style.display = 'block'
|
|
1463
|
-
syncViewSize()
|
|
1464
|
-
applyViewport()
|
|
1465
|
-
root.focus()
|
|
1466
|
-
}
|
|
1467
|
-
|
|
1468
|
-
function deactivate(): void { root.style.display = 'none' }
|
|
1469
|
-
|
|
1470
|
-
function destroy(): void {
|
|
1471
|
-
ac.abort()
|
|
1472
|
-
for (const t of noticeTimers) clearTimeout(t)
|
|
1473
|
-
}
|
|
1474
|
-
|
|
1475
|
-
return {
|
|
1476
|
-
root,
|
|
1477
|
-
reconcile,
|
|
1478
|
-
activate,
|
|
1479
|
-
deactivate,
|
|
1480
|
-
adoptExternalLayout,
|
|
1481
|
-
snapshotDoc,
|
|
1482
|
-
setLamp,
|
|
1483
|
-
notice,
|
|
1484
|
-
destroy,
|
|
1485
|
-
seam: {
|
|
1486
|
-
pointerDown,
|
|
1487
|
-
pointerMove,
|
|
1488
|
-
pointerUp,
|
|
1489
|
-
pointerCancel,
|
|
1490
|
-
wheel,
|
|
1491
|
-
key,
|
|
1492
|
-
keyUp,
|
|
1493
|
-
contextMenu(ev: SeamPointer): void {
|
|
1494
|
-
ev.preventDefault()
|
|
1495
|
-
menus.contextMenuAt(ev.clientX, ev.clientY, ev.target)
|
|
1496
|
-
},
|
|
1497
|
-
},
|
|
1498
|
-
}
|
|
1499
|
-
}
|
|
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 } from './edges'
|
|
31
|
+
import { drawMemberEdges } from './planning-edges'
|
|
32
|
+
import { createFederationOverlay } from './federation-overlay'
|
|
33
|
+
import { joinedLocalIdsByHandle, membershipIndex, mirrorMemberIds, remoteMemberBoardId } from './planning-member-handles'
|
|
34
|
+
import { attachSessionDrop } from './session-drag'
|
|
35
|
+
import { displayName } from './stage-name'
|
|
36
|
+
import {
|
|
37
|
+
activityEdges, federalEdges, flyBounds, groupRemoteTeams, peerNodeId, peerHostOf, placePeers,
|
|
38
|
+
type FrameAnchor, type InFlightRow, type PeerPlacement, type PeerRow,
|
|
39
|
+
type RemoteTeamRow,
|
|
40
|
+
} from './federation'
|
|
41
|
+
import { clampViewport, fitView, panBy, screenToWorld, worldTransformCss, zoomAt, type LayoutViewport } from './viewport'
|
|
42
|
+
import { boardBounds, fitBelowHud, hudBottomOf } from './planning-fit'
|
|
43
|
+
import { WorldModel, nodeRect, deriveInitialFrame, homeTeams, planSeatClear, planTeamClusters, NODE_W, NODE_H } from './world'
|
|
44
|
+
import { applyAction, settleAction, actionTeam, innermostFrameAt, reorderOps, yOrderedMembers, type CanvasAction } from './canvas-ops'
|
|
45
|
+
import { frameHue } from './planning-frame-hue'
|
|
46
|
+
import { rosterFrameTitle } from './planning-roster'
|
|
47
|
+
import { createPlanningMenus } from './planning-menus'
|
|
48
|
+
import { createNodeFlyer, mountBoardSearch } from './planning-search'
|
|
49
|
+
import { applyBoardRelayout } from './planning-relayout'
|
|
50
|
+
import { createDissolveGuard, mirrorRoute } from './planning-mirror'
|
|
51
|
+
import { settleBoard, tidyMembers } from './planning-overlap'
|
|
52
|
+
import { createLayoutUndo, type LayoutSnap } from './planning-undo'
|
|
53
|
+
import { createNodeList } from './planning-nodelist'
|
|
54
|
+
import { cardText } from './planning-card-text'
|
|
55
|
+
import { createCardElement, paintPrio, workLabel } from './planning-card'
|
|
56
|
+
import { createLegend } from './planning-legend'
|
|
57
|
+
import { UNREACHABLE_HINT, showFrameReachability, unreachableRefs } from './planning-reachability'
|
|
58
|
+
import { viewportBucket, type PlanningDeps, type PlanningInput, type PlanningSession, type PlanningTeam, type PlanningView, type SeamKey, type SeamPointer, type ViewportBucket } from './planning-types'
|
|
59
|
+
import './planning.css'
|
|
60
|
+
|
|
61
|
+
export { viewportBucket } from './planning-types'
|
|
62
|
+
export type { PlanningDeps, PlanningInput, PlanningSession, PlanningTeam, PlanningView, SeamKey, SeamPointer, ViewportBucket } from './planning-types'
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
type Gesture =
|
|
66
|
+
| { readonly kind: 'none' }
|
|
67
|
+
| { readonly kind: 'node'; ids: readonly string[]; clicked: string; rosterOrigin?: string; last: { x: number; y: number }; moved: boolean; el: Element | null }
|
|
68
|
+
| { readonly kind: 'frame'; name: string; snap: NonNullable<ReturnType<WorldModel['beginFrameDrag']>>; origin: { x: number; y: number }; moved: boolean }
|
|
69
|
+
| { readonly kind: 'frame-resize'; name: string; dir: string; startRect: { x: number; y: number; w: number; h: number }; origin: { x: number; y: number }; moved: boolean }
|
|
70
|
+
| { readonly kind: 'pan'; last: { x: number; y: number }; moved: boolean }
|
|
71
|
+
| { readonly kind: 'marquee'; origin: { x: number; y: number }; last: { x: number; y: number }; additive: boolean }
|
|
72
|
+
|
|
73
|
+
// The card skeleton, its 工作中/空闲 chip and the right-hand chip live in planning-card (module-size cap).
|
|
74
|
+
export { workLabel } from './planning-card'
|
|
75
|
+
|
|
76
|
+
export function createPlanningView(deps: PlanningDeps): PlanningView {
|
|
77
|
+
const model = new WorldModel()
|
|
78
|
+
let layoutDoc: LayoutDoc | undefined
|
|
79
|
+
let vp: LayoutViewport = { ...model.viewport }
|
|
80
|
+
let gesture: Gesture = { kind: 'none' }
|
|
81
|
+
let spaceDown = false
|
|
82
|
+
let renderedRevision = -1
|
|
83
|
+
/** Until the first reconcile with nodes (or an adopted viewport), frame the content once. */
|
|
84
|
+
let firstFitPending = true
|
|
85
|
+
let settledOnce = false
|
|
86
|
+
const layoutUndo = createLayoutUndo(model) // 撤销 (planning-undo)
|
|
87
|
+
let undoBefore: LayoutSnap | undefined
|
|
88
|
+
let viewportAdopted = false
|
|
89
|
+
const bucket = (): ViewportBucket => viewportBucket(globalThis.innerWidth || viewW)
|
|
90
|
+
const markDirty = (): void => {
|
|
91
|
+
deps.viewportStore?.set(bucket(), vp)
|
|
92
|
+
deps.onDirty()
|
|
93
|
+
}
|
|
94
|
+
const clock = deps.now ?? Date.now
|
|
95
|
+
const ac = new AbortController()
|
|
96
|
+
const noticeTimers: Array<ReturnType<typeof setTimeout>> = []
|
|
97
|
+
/** Active right-button press: hold-drag pans; a clean release opens the
|
|
98
|
+
* context menu (the contextmenu event itself is always suppressed). */
|
|
99
|
+
let rightPress: { x: number; y: number; target: Element | null; moved: boolean } | null = null
|
|
100
|
+
let suppressNextMenu = false
|
|
101
|
+
const signal = { signal: ac.signal } as AddEventListenerOptions
|
|
102
|
+
|
|
103
|
+
// ── DOM shell (built once) ──
|
|
104
|
+
const root = document.createElement('section')
|
|
105
|
+
root.id = 'dsh-plan'
|
|
106
|
+
root.setAttribute('role', 'tabpanel')
|
|
107
|
+
root.setAttribute('aria-label', 'A2A 规划画布')
|
|
108
|
+
root.tabIndex = 0
|
|
109
|
+
root.style.display = 'none'
|
|
110
|
+
|
|
111
|
+
const grid = document.createElement('div')
|
|
112
|
+
grid.className = 'p-grid'
|
|
113
|
+
|
|
114
|
+
const world = document.createElement('div')
|
|
115
|
+
world.className = 'p-world'
|
|
116
|
+
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
|
117
|
+
svg.setAttribute('class', 'p-edges')
|
|
118
|
+
// NOTE: the svg is intentionally NOT aria-hidden — its .edge-label texts
|
|
119
|
+
// carry the route ages that reduced-motion users rely on.
|
|
120
|
+
// Stable layers: frames must ALWAYS paint (and lose hit-testing) below
|
|
121
|
+
// cards, regardless of which keyed diff created its element first — a
|
|
122
|
+
// frame created after the cards used to sit on top of them and swallow
|
|
123
|
+
// every real-pointer drag inside the team (synthetic tests bypassed it
|
|
124
|
+
// by targeting cards directly).
|
|
125
|
+
const frameLayer = document.createElement('div')
|
|
126
|
+
frameLayer.className = 'p-layer p-frames'
|
|
127
|
+
/** The selected frame keeps its resize handles visible without hover. */
|
|
128
|
+
let selectedFrame: string | undefined
|
|
129
|
+
const markSelectedFrame = (name: string | undefined): void => {
|
|
130
|
+
if (selectedFrame !== undefined) {
|
|
131
|
+
frameEls.get(selectedFrame)?.classList.remove('selected')
|
|
132
|
+
}
|
|
133
|
+
selectedFrame = name
|
|
134
|
+
if (name !== undefined) {
|
|
135
|
+
frameEls.get(name)?.classList.add('selected')
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const nodeLayer = document.createElement('div')
|
|
139
|
+
nodeLayer.className = 'p-layer p-nodes'
|
|
140
|
+
nodeLayer.setAttribute('role', 'listbox')
|
|
141
|
+
nodeLayer.setAttribute('aria-multiselectable', 'true')
|
|
142
|
+
nodeLayer.setAttribute('aria-label', '会话节点')
|
|
143
|
+
world.append(svg, frameLayer, nodeLayer)
|
|
144
|
+
|
|
145
|
+
const marquee = document.createElement('div')
|
|
146
|
+
marquee.className = 'p-marquee'
|
|
147
|
+
|
|
148
|
+
const toolbar = document.createElement('div')
|
|
149
|
+
toolbar.className = 'p-toolbar'
|
|
150
|
+
const zoomLabel = document.createElement('span')
|
|
151
|
+
zoomLabel.className = 'p-zoom mono'
|
|
152
|
+
zoomLabel.textContent = '100%'
|
|
153
|
+
const mkButton = (text: string, title: string, onClick: () => void): HTMLButtonElement => {
|
|
154
|
+
const b = document.createElement('button')
|
|
155
|
+
b.type = 'button'
|
|
156
|
+
b.textContent = text
|
|
157
|
+
b.title = title
|
|
158
|
+
b.addEventListener('click', onClick, signal)
|
|
159
|
+
return b
|
|
160
|
+
}
|
|
161
|
+
// Review 2026-10-27: the toolbar reads as three groups — view | arrange |
|
|
162
|
+
// network — split by separators; every action stays one click (owner orders).
|
|
163
|
+
const sep = (): HTMLElement => { const s = document.createElement('span'); s.className = 'p-sep'; s.setAttribute('role', 'separator'); s.setAttribute('aria-orientation', 'vertical'); return s }
|
|
164
|
+
toolbar.append(
|
|
165
|
+
mkButton('适配', '适配视图(快捷键 0)', () => fitToContent()),
|
|
166
|
+
mkButton('-', '缩小', () => zoomCenter(1 / 1.2)),
|
|
167
|
+
zoomLabel,
|
|
168
|
+
mkButton('+', '放大', () => zoomCenter(1.2)),
|
|
169
|
+
sep(),
|
|
170
|
+
mkButton('建队', '组成团队(G):先框选至少 2 个节点', () => menus.startCreateFromSelection()),
|
|
171
|
+
// gh#48 (owner order, review-seat spec): one-click tidy — inside every
|
|
172
|
+
// team frame, the members that belong to EXACTLY that one team re-seat
|
|
173
|
+
// into a padded grid flow (the frame grows downward, bounded); members
|
|
174
|
+
// of 2+ teams keep their positions (the dashed edges express their
|
|
175
|
+
// membership). Idempotent; empty frames untouched; remote member cards
|
|
176
|
+
// ride the same rule; the move persists through the ordinary layout
|
|
177
|
+
// save loop exactly like a hand drag.
|
|
178
|
+
mkButton('整理成员', '每队框内仅属一队的成员自动排齐(多队成员不动)', () => tidyTeamMembers()),
|
|
179
|
+
// Supervisor order v2 (upgrades gh#48): one-click WHOLE-BOARD relayout —
|
|
180
|
+
// the two buttons split the job (整理成员 = cards inside one frame;
|
|
181
|
+
// 一键重排 = every local frame AND its cards). Frame size derives from
|
|
182
|
+
// content (width and height both); frames tile in a row flow
|
|
183
|
+
// (constructively disjoint); multi-team members never enter the size
|
|
184
|
+
// math and never move; remote org frames and frameless nodes stay put.
|
|
185
|
+
mkButton('一键重排', '全板队框+成员重排:框尺寸随内容自适应、框位行流式平铺(远端队框与多归属成员不动)', () => relayoutBoard()),
|
|
186
|
+
mkButton('撤销', '撤销上一次布局改动:拖动、缩放、整理、重排、载入时避让(Ctrl+Z;入队离队不撤销)', () => undoLayout()),
|
|
187
|
+
)
|
|
188
|
+
// Unjoined sessions ride a toolbar chip + dropdown: the panel-slim
|
|
189
|
+
// migration made the planning view the join surface — without this, an
|
|
190
|
+
// unjoined session had no GUI path into the mesh at all.
|
|
191
|
+
const netBtn = mkButton('未入网', '未入网会话(点击选择入网)', () => menus.toggleNetMenu())
|
|
192
|
+
netBtn.className = 'p-netbtn'
|
|
193
|
+
netBtn.setAttribute('aria-haspopup', 'menu')
|
|
194
|
+
toolbar.append(sep(), netBtn)
|
|
195
|
+
const search = mountBoardSearch(toolbar, model, displayName, id => flyToNode(id))
|
|
196
|
+
// Node list (network-membership-display): the canvas shows teamed nodes
|
|
197
|
+
// only; every other session — teamless joined, unjoined, and each peer's
|
|
198
|
+
// published nodes — is managed from this list, switched by host.
|
|
199
|
+
const nodesBtn = mkButton('节点', '节点清单:本机与对等节点(未组队/未入网在此管理)', () => nodes.toggle())
|
|
200
|
+
nodesBtn.className = 'p-nodebtn'
|
|
201
|
+
nodesBtn.setAttribute('aria-haspopup', 'dialog')
|
|
202
|
+
toolbar.appendChild(nodesBtn)
|
|
203
|
+
const lamp = document.createElement('span')
|
|
204
|
+
lamp.setAttribute('aria-live', 'polite')
|
|
205
|
+
lamp.className = 'p-lamp'
|
|
206
|
+
const lampDot = document.createElement('i')
|
|
207
|
+
const lampText = document.createElement('span')
|
|
208
|
+
lampText.textContent = '布局'
|
|
209
|
+
lamp.append(lampDot, lampText)
|
|
210
|
+
lamp.addEventListener('click', () => { if (lamp.classList.contains('error')) deps.onLampClick() }, signal)
|
|
211
|
+
lamp.addEventListener('keydown', (ev) => {
|
|
212
|
+
const e = ev as KeyboardEvent
|
|
213
|
+
if ((e.key === 'Enter' || e.key === ' ') && lamp.classList.contains('error')) {
|
|
214
|
+
e.preventDefault()
|
|
215
|
+
deps.onLampClick()
|
|
216
|
+
}
|
|
217
|
+
}, signal)
|
|
218
|
+
toolbar.appendChild(lamp)
|
|
219
|
+
|
|
220
|
+
const status = document.createElement('div')
|
|
221
|
+
status.className = 'p-status'
|
|
222
|
+
const statusLive = document.createElement('span')
|
|
223
|
+
const statusCold = document.createElement('span')
|
|
224
|
+
const statusAct = document.createElement('span')
|
|
225
|
+
statusAct.className = 'mut'
|
|
226
|
+
const statusMut = document.createElement('span')
|
|
227
|
+
statusMut.className = 'mut'
|
|
228
|
+
status.append(statusLive, statusCold, statusAct, statusMut)
|
|
229
|
+
// Fly control (PR D): the whole statusbar fits the frames ∪ peers region,
|
|
230
|
+
// keyboard-parity via Enter/Space with a focus ring (a11y parity with 0).
|
|
231
|
+
status.tabIndex = 0
|
|
232
|
+
status.setAttribute('role', 'button')
|
|
233
|
+
status.setAttribute('aria-label', '定位在途路由与联邦对端')
|
|
234
|
+
status.title = '定位在途与联邦(回车)'
|
|
235
|
+
status.addEventListener('click', () => flyToFederation(), signal)
|
|
236
|
+
status.addEventListener('keydown', (ev) => {
|
|
237
|
+
const e = ev as KeyboardEvent
|
|
238
|
+
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); flyToFederation() }
|
|
239
|
+
}, signal)
|
|
240
|
+
|
|
241
|
+
// Edge key: a closed-by-default fold (planning-legend), not a fixed panel.
|
|
242
|
+
const legend = createLegend()
|
|
243
|
+
|
|
244
|
+
const noticeStack = document.createElement('div')
|
|
245
|
+
noticeStack.className = 'p-notice-stack'
|
|
246
|
+
noticeStack.setAttribute('role', 'log')
|
|
247
|
+
noticeStack.setAttribute('aria-live', 'polite')
|
|
248
|
+
|
|
249
|
+
root.append(grid, world, marquee, toolbar, status, legend, noticeStack)
|
|
250
|
+
|
|
251
|
+
// ── viewport application ──
|
|
252
|
+
let viewW = 0
|
|
253
|
+
let viewH = 0
|
|
254
|
+
const measure = (): { w: number; h: number } =>
|
|
255
|
+
deps.viewSize ? deps.viewSize() : { w: root.clientWidth, h: root.clientHeight }
|
|
256
|
+
const syncViewSize = (): void => {
|
|
257
|
+
const size = measure()
|
|
258
|
+
viewW = size.w
|
|
259
|
+
viewH = size.h
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function applyViewport(): void {
|
|
263
|
+
world.style.transform = worldTransformCss(vp)
|
|
264
|
+
const step = 24 * vp.scale
|
|
265
|
+
grid.style.backgroundSize = `${step}px ${step}px`
|
|
266
|
+
grid.style.backgroundPosition = `${-vp.x * vp.scale}px ${-vp.y * vp.scale}px`
|
|
267
|
+
zoomLabel.textContent = `${Math.round(vp.scale * 100)}%`
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* gh#48 (owner order): one-click member tidy. For every team frame, the
|
|
272
|
+
* members belonging to EXACTLY that one team re-seat into a padded grid
|
|
273
|
+
* flow inside the frame (the frame grows downward if the members need
|
|
274
|
+
* more rows — bounded); members of 2+ teams stay put (their dashed edges
|
|
275
|
+
* express membership). Remote member cards (member-) are ordinary board
|
|
276
|
+
* cards and ride the same rule. The move goes through markDirty()/render()
|
|
277
|
+
* — the same persistence path a hand drag takes.
|
|
278
|
+
*/
|
|
279
|
+
function tidyTeamMembers(): void {
|
|
280
|
+
const before = layoutUndo.capture()
|
|
281
|
+
tidyMembers(model) // gh#48 grid (planning-overlap); then nothing may overlap
|
|
282
|
+
settleBoard(model, new Set(remoteFramesRef.keys()))
|
|
283
|
+
layoutUndo.commit(before)
|
|
284
|
+
markDirty()
|
|
285
|
+
render()
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Supervisor order v2: whole-board deterministic relayout (the planner
|
|
290
|
+
* and the model adapter live in planning-relayout.ts): LOCAL frames +
|
|
291
|
+
* sole members only — remote org frames keep gh#44 live-rect-wins,
|
|
292
|
+
* frameless nodes and multi-team members never move; the same
|
|
293
|
+
* markDirty()/render() persistence path a hand drag takes.
|
|
294
|
+
*/
|
|
295
|
+
function relayoutBoard(): void {
|
|
296
|
+
const before = layoutUndo.capture()
|
|
297
|
+
if (!applyBoardRelayout(model, new Set(remoteFramesRef.keys()))) return
|
|
298
|
+
settleBoard(model, new Set(remoteFramesRef.keys())) // vs remote frames + stray cards
|
|
299
|
+
layoutUndo.commit(before)
|
|
300
|
+
markDirty()
|
|
301
|
+
render()
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function render(): void {
|
|
305
|
+
applyViewport()
|
|
306
|
+
if (model.revision !== renderedRevision) {
|
|
307
|
+
renderedRevision = model.revision
|
|
308
|
+
renderFrames()
|
|
309
|
+
renderNodes()
|
|
310
|
+
drawMemberEdges(svg, model) // planning-edges
|
|
311
|
+
}
|
|
312
|
+
renderFederation()
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ── keyed diff: frames ──
|
|
316
|
+
// Element maps instead of selector lookups: keyed identity for focus and
|
|
317
|
+
// transitions, no CSS.escape dependency, O(1) per row.
|
|
318
|
+
const frameEls = new Map<string, HTMLElement>()
|
|
319
|
+
const nodeEls = new Map<string, HTMLElement>()
|
|
320
|
+
|
|
321
|
+
function renderFrames(): void {
|
|
322
|
+
const seen = new Set<string>()
|
|
323
|
+
for (const [name, rect] of model.allFrames()) {
|
|
324
|
+
seen.add(name)
|
|
325
|
+
let el = frameEls.get(name)
|
|
326
|
+
if (el === undefined) {
|
|
327
|
+
el = document.createElement('div')
|
|
328
|
+
el.className = 'p-frame'
|
|
329
|
+
el.dataset.name = name
|
|
330
|
+
const head = document.createElement('div')
|
|
331
|
+
head.className = 'p-frame-head'
|
|
332
|
+
head.dataset.frame = name
|
|
333
|
+
head.tabIndex = 0 // keyboard: Shift+F10 menu, Delete 散队
|
|
334
|
+
head.setAttribute('role', 'button')
|
|
335
|
+
head.setAttribute('aria-haspopup', 'menu')
|
|
336
|
+
head.setAttribute('aria-label', `团队框 ${name}(拖动整组,回车菜单)`)
|
|
337
|
+
const title = document.createElement('span')
|
|
338
|
+
title.className = 'ttl'
|
|
339
|
+
const cnt = document.createElement('span')
|
|
340
|
+
cnt.className = 'cnt mono'
|
|
341
|
+
const route = document.createElement('span')
|
|
342
|
+
route.className = 'route mono'
|
|
343
|
+
head.append(title, cnt, route)
|
|
344
|
+
el.appendChild(head)
|
|
345
|
+
// Resize handles (owner ruling: the frame canvas is stretchable and
|
|
346
|
+
// adjustable): eight directions, pointer seam routes them to the
|
|
347
|
+
// frame-resize gesture. Purely visual affordance + hit targets.
|
|
348
|
+
for (const dir of ['n', 's', 'e', 'w', 'ne', 'nw', 'se', 'sw']) {
|
|
349
|
+
const handle = document.createElement('div')
|
|
350
|
+
handle.className = `p-frame-handle p-h-${dir}`
|
|
351
|
+
handle.dataset.frame = name
|
|
352
|
+
handle.dataset.dir = dir
|
|
353
|
+
el.appendChild(handle)
|
|
354
|
+
}
|
|
355
|
+
frameLayer.appendChild(el)
|
|
356
|
+
frameEls.set(name, el)
|
|
357
|
+
}
|
|
358
|
+
const hue = frameHue(name)
|
|
359
|
+
el.style.setProperty('--frame-line', hue.line)
|
|
360
|
+
el.style.setProperty('--frame-bg', hue.bg)
|
|
361
|
+
el.style.left = `${rect.x}px`
|
|
362
|
+
el.style.top = `${rect.y}px`
|
|
363
|
+
el.style.width = `${rect.w}px`
|
|
364
|
+
el.style.height = `${rect.h}px`
|
|
365
|
+
const title = el.querySelector<HTMLElement>('.ttl')!
|
|
366
|
+
const cnt = el.querySelector<HTMLElement>('.cnt')!
|
|
367
|
+
const route = el.querySelector<HTMLElement>('.route')!
|
|
368
|
+
const isRoster = rosterFramesRef.has(name)
|
|
369
|
+
const shownName = isRoster ? rosterFrameTitle(name) : name
|
|
370
|
+
if (title.textContent !== shownName) title.textContent = shownName
|
|
371
|
+
el.classList.toggle('is-roster', isRoster)
|
|
372
|
+
let members = 0
|
|
373
|
+
let cross = 0
|
|
374
|
+
for (const m of model.allNodes()) {
|
|
375
|
+
const mem = m.memberships.find(x => x.team === name)
|
|
376
|
+
if (mem === undefined) continue
|
|
377
|
+
members += 1
|
|
378
|
+
if (m.memberships.length > 1) cross += 1
|
|
379
|
+
}
|
|
380
|
+
const cntText = `${members} 成员` + (cross > 0 ? ` +${cross} 跨队` : '')
|
|
381
|
+
if (cnt.textContent !== cntText) cnt.textContent = cntText
|
|
382
|
+
const mirror = mirrorRoute(remoteFramesRef.get(name), pendingTeams.has(name)) // PR#96 single owner + mirror
|
|
383
|
+
const unavailable = mirror !== undefined && reachability.hosts.has(peerHostOf(remoteFramesRef.get(name)!))
|
|
384
|
+
const routeText0 = mirror?.text ?? (lastTeams.find(x => x.name === name)?.team ?? name)
|
|
385
|
+
if (route.textContent !== routeText0) route.textContent = routeText0
|
|
386
|
+
route.title = `${mirror?.title ?? ''}${unavailable ? ` · ${UNREACHABLE_HINT}` : ''}`
|
|
387
|
+
showFrameReachability(el, unavailable)
|
|
388
|
+
el.classList.toggle('is-mirror', mirror !== undefined)
|
|
389
|
+
el.classList.toggle('is-syncing', mirror?.syncing === true)
|
|
390
|
+
const label = `团队框 ${name}${mirror !== undefined ? `(${mirror.text})` : ''}${unavailable ? `(${UNREACHABLE_HINT})` : ''}(拖动整组,回车菜单)`
|
|
391
|
+
const headEl = el.querySelector('.p-frame-head')!
|
|
392
|
+
if (headEl.getAttribute('aria-label') !== label) headEl.setAttribute('aria-label', label) // a rewrite re-announces
|
|
393
|
+
}
|
|
394
|
+
for (const [name, el] of [...frameEls]) {
|
|
395
|
+
if (!seen.has(name)) { el.remove(); frameEls.delete(name) }
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ── keyed diff: nodes ──
|
|
400
|
+
function renderNodes(): void {
|
|
401
|
+
const seen = new Set<string>()
|
|
402
|
+
for (const n of model.allNodes()) {
|
|
403
|
+
seen.add(n.id)
|
|
404
|
+
let el = nodeEls.get(n.id)
|
|
405
|
+
if (el === undefined) {
|
|
406
|
+
el = createCardElement(n.id)
|
|
407
|
+
nodeLayer.appendChild(el)
|
|
408
|
+
nodeEls.set(n.id, el)
|
|
409
|
+
}
|
|
410
|
+
const r = nodeRect(n)
|
|
411
|
+
el.style.left = `${r.x}px`
|
|
412
|
+
el.style.top = `${r.y}px`
|
|
413
|
+
el.classList.toggle('cold', !n.live)
|
|
414
|
+
el.classList.toggle('remote', n.remote === true)
|
|
415
|
+
// 工作中/空闲: live local sessions only; the host reports nothing for cold or remote nodes.
|
|
416
|
+
const work = n.live && n.remote !== true ? lastWork.get(n.id) : undefined
|
|
417
|
+
el.classList.toggle('is-working', work?.status === 'running')
|
|
418
|
+
const workText = workLabel(work)
|
|
419
|
+
const workEl = el.querySelector<HTMLElement>('.work')!
|
|
420
|
+
if (workEl.textContent !== workText) workEl.textContent = workText
|
|
421
|
+
el.classList.toggle('selected', model.isSelected(n.id))
|
|
422
|
+
el.setAttribute('aria-selected', String(model.isSelected(n.id)))
|
|
423
|
+
const host = n.id.slice('peer-'.length)
|
|
424
|
+
// Title / second line / tooltip rules: planning-card-text.
|
|
425
|
+
const txt = cardText(n, n.remote === true ? lastRemoteTeams.get(host) ?? [] : [], peerScoreByText.get(host))
|
|
426
|
+
const unavailable = n.remote === true && (n.id.startsWith('member-') ? reachability.members.has(n.id) : reachability.hosts.has(host))
|
|
427
|
+
el.classList.toggle('is-unreachable', unavailable)
|
|
428
|
+
const nmEl = el.querySelector<HTMLElement>('.nm-text')!
|
|
429
|
+
if (nmEl.textContent !== txt.title) nmEl.textContent = txt.title
|
|
430
|
+
const subEl = el.querySelector<HTMLElement>('.sub')!
|
|
431
|
+
const subText = unavailable ? `未应答 · ${txt.sub}` : txt.sub
|
|
432
|
+
if (subEl.textContent !== subText) subEl.textContent = subText
|
|
433
|
+
const tip = unavailable ? `${txt.tip} · ${UNREACHABLE_HINT}` : txt.tip
|
|
434
|
+
if (el.title !== tip) el.title = tip
|
|
435
|
+
// A member card's chip follows the link of the host that publishes its handle.
|
|
436
|
+
const linkHost = n.id.startsWith('member-') ? memberHostByHandle.get(n.remoteHandle ?? '') : host
|
|
437
|
+
paintPrio(el.querySelector<HTMLElement>('.prio')!, n, n.remote === true && linkHost !== undefined ? peerInfoByHost.get(linkHost) : undefined)
|
|
438
|
+
}
|
|
439
|
+
for (const [id, el] of [...nodeEls]) {
|
|
440
|
+
if (!seen.has(id)) { el.remove(); nodeEls.delete(id) }
|
|
441
|
+
}
|
|
442
|
+
search.refresh()
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// ── federation overlay (PR D): peer cards' activity/federal edges ──
|
|
446
|
+
// Runs on EVERY render, outside the model-revision gate: the poll's
|
|
447
|
+
// inFlight ages must tick even when the model is unchanged, and the
|
|
448
|
+
// badge column tracks the content bounds each reconcile. Its SVG
|
|
449
|
+
// children are tracked separately because drawMemberEdges clears the svg
|
|
450
|
+
// wholesale - this appends after it (render() orders the two).
|
|
451
|
+
// gh#49: the DOM drawing lives in federation-overlay.ts (module-size
|
|
452
|
+
// ruling); this view keeps only the seams (facts + fly).
|
|
453
|
+
const federation = createFederationOverlay(svg, model)
|
|
454
|
+
let lastPeers: ReadonlyArray<PeerRow> = []
|
|
455
|
+
let lastRemoteTeams: ReadonlyMap<string, RemoteTeamRow[]> = new Map()
|
|
456
|
+
let lastWork: ReadonlyMap<string, Pick<PlanningSession, 'status' | 'queued'>> = new Map()
|
|
457
|
+
const peerScoreByText = new Map<string, number | undefined>()
|
|
458
|
+
/** gh#20 W-A: per-host link/verified info for the peer badges. */
|
|
459
|
+
const peerInfoByHost = new Map<string, { link?: 'linked' | 'stranger'; verified?: boolean }>()
|
|
460
|
+
let memberHostByHandle: ReadonlyMap<string, string> = new Map()
|
|
461
|
+
|
|
462
|
+
function renderFederation(): void {
|
|
463
|
+
federation.render(clock())
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** Statusbar fly: fit the frames ∪ peers region (flyBounds pads it). */
|
|
467
|
+
function flyToFederation(): void {
|
|
468
|
+
vp = fitView(flyBounds(model.contentBounds(), federation.placements()), root.clientWidth || viewW, root.clientHeight || viewH)
|
|
469
|
+
applyViewport()
|
|
470
|
+
markDirty()
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** W1 pick: frame one node at ~100% and pulse its card. */
|
|
474
|
+
const flyToNode = createNodeFlyer({ model, viewSize: measure, setVp: next => { vp = next }, applyViewport, markDirty, cardEl: id => nodeEls.get(id) })
|
|
475
|
+
// ── status counts ──
|
|
476
|
+
function renderStatus(input: PlanningInput): void {
|
|
477
|
+
const live = input.sessions.filter(s => s.live !== false).length
|
|
478
|
+
const dot = (host: HTMLElement, cls: string, text: string): void => {
|
|
479
|
+
const b = document.createElement('b')
|
|
480
|
+
b.className = cls
|
|
481
|
+
b.textContent = text
|
|
482
|
+
host.replaceChildren(b)
|
|
483
|
+
}
|
|
484
|
+
dot(statusLive, 'ok', `● ${live} live`)
|
|
485
|
+
dot(statusCold, 'warn', `● ${input.sessions.length - live} cold`)
|
|
486
|
+
statusAct.textContent = `${input.inFlight?.length ?? 0} inFlight`
|
|
487
|
+
statusMut.className = 'mut'
|
|
488
|
+
statusMut.textContent = `${input.teams.length} 队 · ${input.peerCount} peer`
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// ── canvas write actions: optimistic apply + team-scoped undo ──
|
|
492
|
+
// Per-team REFERENCE COUNT: two queued actions on one team keep the guard
|
|
493
|
+
// up until BOTH settle (a Set deleted per action let a mid-queue poll wipe
|
|
494
|
+
// the second action's optimistic membership).
|
|
495
|
+
const pendingTeams = new Map<string, number>()
|
|
496
|
+
// Each change re-renders frames: the mirror 「同步中…」 marker reads pendingTeams, not the model revision.
|
|
497
|
+
const pendingAdd = (team: string): void => { pendingTeams.set(team, (pendingTeams.get(team) ?? 0) + 1); renderedRevision = -1 }
|
|
498
|
+
const pendingRelease = (team: string): void => {
|
|
499
|
+
renderedRevision = -1
|
|
500
|
+
const n = (pendingTeams.get(team) ?? 0) - 1
|
|
501
|
+
if (n <= 0) pendingTeams.delete(team)
|
|
502
|
+
else pendingTeams.set(team, n)
|
|
503
|
+
}
|
|
504
|
+
// gh#41: remote-frame registry (frame name → peer URL), refreshed by
|
|
505
|
+
// reconcile and read by emitAction's serverAction to fork remote drops.
|
|
506
|
+
let remoteFramesRef = new Map<string, string>()
|
|
507
|
+
let rosterFramesRef = new Set<string>()
|
|
508
|
+
let reachability = unreachableRefs({})
|
|
509
|
+
const dissolving = createDissolveGuard(deps.now)
|
|
510
|
+
|
|
511
|
+
function emitAction(a: CanvasAction): void {
|
|
512
|
+
const roster = (a.type === 'create-team' && a.roster === true) || rosterFramesRef.has(actionTeam(a))
|
|
513
|
+
if (roster && a.type === 'remove-team' && !window.confirm(`解散网络队「${rosterFrameTitle(a.name)}」并撤回成员与白名单?`)) return
|
|
514
|
+
if (roster && a.type === 'reorder') { notice('error', 'roster 队不支持置顶路由'); return }
|
|
515
|
+
if (roster && (a.type === 'create-team' || a.type === 'add-member') && a.ids.some(id => model.getNode(id)?.remote === true || !lastSessions.some(s => s.id === id && s.joined === true))) {
|
|
516
|
+
notice('error', 'roster 只接受本机已入网会话,远端或未入网成员未写入'); return
|
|
517
|
+
}
|
|
518
|
+
// gh#20 W-B: the SERVER sees zone/id8 remote handles where the model
|
|
519
|
+
// keeps its peer-<host> node id — the canvas member contract is
|
|
520
|
+
// session-id-or-remote-ref. Translation happens here, one boundary;
|
|
521
|
+
// the model action keeps board-local ids so rendering and undo stay
|
|
522
|
+
// node-keyed. A peer without a known handle (no remote team rows yet)
|
|
523
|
+
// is filtered out — the server would refuse the raw peer- id.
|
|
524
|
+
function serverAction(act: CanvasAction): CanvasAction {
|
|
525
|
+
const mapIds = (ids: readonly string[]): string[] => {
|
|
526
|
+
const out: string[] = []
|
|
527
|
+
for (const id of ids) {
|
|
528
|
+
// Both remote card families translate to their zone/id8 handle
|
|
529
|
+
// (gh#38: member- cards join teams like peer- cards do).
|
|
530
|
+
if (!id.startsWith('peer-') && !id.startsWith('member-')) { out.push(id); continue }
|
|
531
|
+
const handle = model.getNode(id)?.remoteHandle
|
|
532
|
+
if (handle !== undefined && handle !== '') out.push(handle)
|
|
533
|
+
}
|
|
534
|
+
return out
|
|
535
|
+
}
|
|
536
|
+
if (act.type === 'add-member' || act.type === 'remove-member' || act.type === 'create-team') {
|
|
537
|
+
const ids = mapIds(act.ids)
|
|
538
|
+
// gh#41: a drop into a REMOTE frame carries the peer URL — main.ts
|
|
539
|
+
// forks it to the fleet relay instead of the local canvas control.
|
|
540
|
+
const fleetPeer = act.type !== 'create-team' ? remoteFramesRef.get(act.team) : undefined
|
|
541
|
+
return ids.length > 0 || fleetPeer !== undefined
|
|
542
|
+
? { ...act, ids, ...(fleetPeer !== undefined ? { fleetPeer } : {}) }
|
|
543
|
+
: act
|
|
544
|
+
}
|
|
545
|
+
// Dissolving a mirror frame is the owner's write as well (relay op 'remove').
|
|
546
|
+
return act.type === 'remove-team' && remoteFramesRef.has(act.name) ? dissolving.fork(act, remoteFramesRef.get(act.name)!) : act
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// Optimistic seats: a write may reference a session the teamed gate
|
|
550
|
+
// keeps OFF the model (no canvas card yet). The write actions read and
|
|
551
|
+
// write memberships through the node table, so materialize those ids
|
|
552
|
+
// from the last poll's inventory first — the next reconcile refreshes
|
|
553
|
+
// label/live/teams and keeps the saved-or-seat position.
|
|
554
|
+
if (a.type === 'add-member' || a.type === 'remove-member' || a.type === 'reorder' || a.type === 'create-team') {
|
|
555
|
+
const ids = a.type === 'reorder' ? a.ops.map(op => op.id) : [...a.ids]
|
|
556
|
+
for (const id of ids) {
|
|
557
|
+
if (id === '' || id.startsWith('peer-') || id.startsWith('member-')) continue
|
|
558
|
+
if (model.getNode(id) !== undefined) continue
|
|
559
|
+
const s = lastSessions.find(row => row.id === id)
|
|
560
|
+
if (s === undefined) continue
|
|
561
|
+
const saved = layoutDoc?.nodes[id]
|
|
562
|
+
const seat = saved !== undefined ? { x: saved.x, y: saved.y } : seatFor2(id)
|
|
563
|
+
model.upsertNode({
|
|
564
|
+
id, x: seat.x, y: seat.y, label: s.label, team: s.team, name: s.name, description: s.description,
|
|
565
|
+
live: s.live !== false, memberships: [],
|
|
566
|
+
...(s.teams !== undefined && s.teams.length > 0 ? { teams: s.teams } : {}),
|
|
567
|
+
})
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
if (roster && a.type === 'create-team') rosterFramesRef.add(a.name)
|
|
571
|
+
const undo = applyAction(model, a)
|
|
572
|
+
const team = actionTeam(a)
|
|
573
|
+
pendingAdd(team)
|
|
574
|
+
render()
|
|
575
|
+
const sent = serverAction(roster ? { ...a, roster: true } : a)
|
|
576
|
+
void deps.onCanvasAction(sent).then(
|
|
577
|
+
ok => {
|
|
578
|
+
pendingRelease(team)
|
|
579
|
+
if (!ok && a.type === 'create-team' && roster) rosterFramesRef.delete(team)
|
|
580
|
+
layoutDoc = settleAction(model, a, ok, undo, layoutDoc); markDirty(); dissolving.settled(sent, ok)
|
|
581
|
+
render()
|
|
582
|
+
},
|
|
583
|
+
() => {
|
|
584
|
+
// Contract-robustness: a rejecting action channel must not pin the
|
|
585
|
+
// team's guard (and its optimistic state) forever — and per §3.4
|
|
586
|
+
// 不吞错, the rollback surfaces to the user.
|
|
587
|
+
pendingRelease(team)
|
|
588
|
+
if (a.type === 'create-team' && roster) rosterFramesRef.delete(team)
|
|
589
|
+
layoutDoc = settleAction(model, a, false, undo, layoutDoc); markDirty(); dissolving.settled(sent, false)
|
|
590
|
+
notice('error', '操作通道异常,已回滚')
|
|
591
|
+
render()
|
|
592
|
+
},
|
|
593
|
+
)
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// ── notices (host errors verbatim; design.md §3.4 不吞错) ──
|
|
597
|
+
function notice(kind: 'error' | 'info', text: string): void {
|
|
598
|
+
while (noticeStack.childElementCount >= 3) noticeStack.firstElementChild?.remove()
|
|
599
|
+
const item = document.createElement('div')
|
|
600
|
+
item.className = 'p-notice' + (kind === 'error' ? ' error' : '')
|
|
601
|
+
item.setAttribute('role', kind === 'error' ? 'alert' : 'status')
|
|
602
|
+
item.textContent = text
|
|
603
|
+
noticeStack.appendChild(item)
|
|
604
|
+
noticeTimers.push(setTimeout(() => item.remove(), 4000))
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// ── popovers: 建队 dialog, context menu, 未入网 dropdown (planning-menus) ──
|
|
608
|
+
const menus = createPlanningMenus({
|
|
609
|
+
root, netBtn, signal, model,
|
|
610
|
+
teams: () => lastTeams,
|
|
611
|
+
joined: id => lastSessions.some(s => s.id === id && s.joined === true),
|
|
612
|
+
isRoster: name => rosterFramesRef.has(name),
|
|
613
|
+
anchorFor: (t) => (t.kind === 'node' ? nodeEls.get(t.id) : frameEls.get(t.name)) ?? null,
|
|
614
|
+
emit: emitAction,
|
|
615
|
+
notice,
|
|
616
|
+
})
|
|
617
|
+
|
|
618
|
+
// ── 节点列表 drawer (planning-nodelist) ──
|
|
619
|
+
let lastSessions: ReadonlyArray<PlanningSession> = []
|
|
620
|
+
let lastCanvasMemberIds: ReadonlySet<string> = new Set()
|
|
621
|
+
const nodes = createNodeList({
|
|
622
|
+
root, trigger: nodesBtn, signal, model,
|
|
623
|
+
facts: () => ({ sessions: lastSessions, canvasMemberIds: lastCanvasMemberIds, remoteTeams: lastRemoteTeams, teams: lastTeams }),
|
|
624
|
+
menuItem: menus.menuItem,
|
|
625
|
+
emit: emitAction,
|
|
626
|
+
openNameDialog: menus.openNameDialog,
|
|
627
|
+
locate: locateNode,
|
|
628
|
+
})
|
|
629
|
+
// A rail 本机会话 row dropped on a frame joins that team, like a card drop (session-drag).
|
|
630
|
+
attachSessionDrop(root, {
|
|
631
|
+
signal, frames: () => frameEls,
|
|
632
|
+
frameAt: (clientX, clientY) => { const p = localXY({ clientX, clientY }); return innermostFrameAt(new Map(model.allFrames()), screenToWorld(vp, p.x, p.y)) },
|
|
633
|
+
drop: (team, id) => model.getNode(id)?.memberships.some(m => m.team === team) === true ? notice('info', '该会话已在这个队里') : emitAction({ type: 'add-member', team, ids: [id] }),
|
|
634
|
+
})
|
|
635
|
+
|
|
636
|
+
/** Center the viewport on one canvas node (the 已组队 row's 定位). */
|
|
637
|
+
function locateNode(id: string): void {
|
|
638
|
+
const n = model.getNode(id)
|
|
639
|
+
if (n === undefined) return
|
|
640
|
+
nodes.close()
|
|
641
|
+
const size = deps.viewSize?.() ?? { w: root.clientWidth > 0 ? root.clientWidth : 800, h: root.clientHeight > 0 ? root.clientHeight : 600 }
|
|
642
|
+
vp = clampViewport({ scale: vp.scale, x: n.x - size.w / (2 * vp.scale), y: n.y - size.h / (2 * vp.scale) })
|
|
643
|
+
applyViewport()
|
|
644
|
+
renderedRevision = -1
|
|
645
|
+
render()
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// ── gestures ──
|
|
649
|
+
function localXY(ev: { clientX: number; clientY: number }): { x: number; y: number } {
|
|
650
|
+
const rect = root.getBoundingClientRect()
|
|
651
|
+
return { x: ev.clientX - rect.left, y: ev.clientY - rect.top }
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/** Drop dispatch for a moved node drag: 入队 / 队内 y 排序. Leaving a team
|
|
655
|
+
* is an explicit menu action only — dragging out to blank is a position
|
|
656
|
+
* move (owner removal 2026-09-02: the drag-out-to-blank 离队 path is
|
|
657
|
+
* removed; accidental blank drops wrote whole-group removals). */
|
|
658
|
+
function dropDispatch(gesture: Extract<Gesture, { kind: 'node' }>, ev: SeamPointer): void {
|
|
659
|
+
const p = localXY(ev)
|
|
660
|
+
const w = screenToWorld(vp, p.x, p.y)
|
|
661
|
+
const f = innermostFrameAt(new Map(model.allFrames()), w)
|
|
662
|
+
// gh#20 W-B: peer cards join canvas teams too — a mixed local+remote
|
|
663
|
+
// drop composes a cross-host team; the server boundary translates
|
|
664
|
+
// peer ids to zone/id8 handles (emitAction's serverAction). Reorder
|
|
665
|
+
// stays local-only (roster priority is a local-session fact).
|
|
666
|
+
const memberIds = gesture.ids.filter(id => {
|
|
667
|
+
const node = model.getNode(id)
|
|
668
|
+
if (node === undefined) return false
|
|
669
|
+
if (node.remote !== true) return true
|
|
670
|
+
return node.remoteHandle !== undefined && node.remoteHandle !== ''
|
|
671
|
+
})
|
|
672
|
+
if (f !== undefined) {
|
|
673
|
+
const fresh = memberIds.filter(id => !(model.getNode(id)?.memberships.some(m => m.team === f)))
|
|
674
|
+
if (fresh.length > 0) {
|
|
675
|
+
emitAction({ type: 'add-member', team: f, ids: fresh }) // 入队:不自动离原队(多对多)
|
|
676
|
+
return
|
|
677
|
+
}
|
|
678
|
+
// Reorder only for LOCAL single-card drops: dropping a peer card is a
|
|
679
|
+
// position move, never a roster write (P2 from the incremental seat).
|
|
680
|
+
if (gesture.ids.length === 1 && model.getNode(gesture.clicked)?.remote !== true) {
|
|
681
|
+
// Already a member: an in-frame drop sorts that frame's members by y.
|
|
682
|
+
const current = model.teamMemberIds(f)
|
|
683
|
+
const desired = yOrderedMembers(model, f)
|
|
684
|
+
if (desired.join('\u0000') !== current.join('\u0000')) {
|
|
685
|
+
emitAction({ type: 'reorder', team: f, ops: reorderOps(current, desired) })
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
// Canvas blank drops move only; leaving a roster frame explicitly retracts those declarations.
|
|
690
|
+
if (gesture.rosterOrigin !== undefined && f !== gesture.rosterOrigin) {
|
|
691
|
+
const leaving = gesture.ids.filter(id => model.teamMemberIds(gesture.rosterOrigin!).includes(id))
|
|
692
|
+
if (leaving.length > 0) emitAction({ type: 'remove-member', team: gesture.rosterOrigin, ids: leaving })
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function pointerDown(ev: SeamPointer): void {
|
|
697
|
+
if (viewW === 0) syncViewSize()
|
|
698
|
+
if (menus.isDialogOpen()) { ev.preventDefault(); return } // modal: no gestures behind the dialog
|
|
699
|
+
const target = ev.target as Element | null
|
|
700
|
+
// Context-menu dismissal: the first pointerdown outside an open menu
|
|
701
|
+
// closes it and is swallowed; a press on the menu's own chrome is
|
|
702
|
+
// swallowed too — only its items act.
|
|
703
|
+
if (menus.isMenuOpen()) {
|
|
704
|
+
if (target !== null && target.closest('.p-menu') !== null) return
|
|
705
|
+
menus.closeMenu()
|
|
706
|
+
ev.preventDefault()
|
|
707
|
+
return
|
|
708
|
+
}
|
|
709
|
+
if (target !== null && target.closest('button, input, textarea, select, .p-toolbar, .p-search, .p-lamp, .p-status, .p-legend') !== null) return // HUD chrome owns its presses (no marquee / preventDefault: that stole the search box's focus — and, first, its native right-click menu)
|
|
710
|
+
undoBefore = layoutUndo.capture() // committed at pointerUp only if this press moved something
|
|
711
|
+
const p = localXY(ev)
|
|
712
|
+
// 右键按下:按住拖动 = 平移(Figma 惯例),原地抬起 = 上下文菜单。
|
|
713
|
+
// Pan actually starts on the first move beyond RIGHT_DRAG_THRESHOLD px;
|
|
714
|
+
// a clean press falls through to the context menu at pointerup.
|
|
715
|
+
if (ev.button === 2) {
|
|
716
|
+
rightPress = { x: p.x, y: p.y, target: ev.target, moved: false }
|
|
717
|
+
ev.preventDefault()
|
|
718
|
+
try { root.setPointerCapture(ev.pointerId ?? 0) } catch { /* jsdom */ }
|
|
719
|
+
return
|
|
720
|
+
}
|
|
721
|
+
const nodeEl = target !== null ? target.closest<HTMLElement>('.p-node') : null
|
|
722
|
+
const headEl = target !== null ? target.closest<HTMLElement>('.p-frame-head') : null
|
|
723
|
+
if ((nodeEl !== null || headEl !== null) && ev.button === 1) {
|
|
724
|
+
// Middle-button anywhere — including on cards and frame heads — pans
|
|
725
|
+
// (baf51a1 claimed this; the guards used to swallow it before the pan
|
|
726
|
+
// branch was reachable, and skipped preventDefault, re-enabling
|
|
727
|
+
// native autoscroll).
|
|
728
|
+
gesture = { kind: 'pan', last: p, moved: false }
|
|
729
|
+
ev.preventDefault()
|
|
730
|
+
try { root.setPointerCapture(ev.pointerId ?? 0) } catch { /* jsdom */ }
|
|
731
|
+
return
|
|
732
|
+
}
|
|
733
|
+
const handleEl = target !== null ? target.closest<HTMLElement>('.p-frame-handle') : null
|
|
734
|
+
// Selection clearing: any press NOT on the selected frame's own chrome
|
|
735
|
+
// (head/handle/body) drops the frame selection.
|
|
736
|
+
if (selectedFrame !== undefined) {
|
|
737
|
+
const selFrameEl = frameEls.get(selectedFrame)
|
|
738
|
+
const onSelectedChrome = target !== null && selFrameEl !== undefined &&
|
|
739
|
+
(selFrameEl.contains(target) || selFrameEl === target)
|
|
740
|
+
const onSelectedHead = headEl !== null && headEl.dataset.frame === selectedFrame
|
|
741
|
+
if (!onSelectedChrome && !onSelectedHead) markSelectedFrame(undefined)
|
|
742
|
+
}
|
|
743
|
+
if (handleEl !== null && ev.button === 0) {
|
|
744
|
+
// Frame resize (owner ruling): record the frame's current rect and
|
|
745
|
+
// the world-space origin; the move branch re-solves the rect per
|
|
746
|
+
// direction with a minimum size clamp.
|
|
747
|
+
const name = handleEl.dataset.frame ?? ''
|
|
748
|
+
markSelectedFrame(name)
|
|
749
|
+
const rect = model.getFrame(name)
|
|
750
|
+
if (rect === undefined) return
|
|
751
|
+
gesture = { kind: 'frame-resize', name, dir: handleEl.dataset.dir ?? 'se', startRect: { ...rect }, origin: screenToWorld(vp, p.x, p.y), moved: false }
|
|
752
|
+
ev.preventDefault()
|
|
753
|
+
try { root.setPointerCapture(ev.pointerId ?? 0) } catch { /* jsdom: no capture */ }
|
|
754
|
+
return
|
|
755
|
+
}
|
|
756
|
+
if (nodeEl !== null) {
|
|
757
|
+
if (ev.button !== 0) return // right on a card: contextmenu only, never a card drag (F3)
|
|
758
|
+
const id = nodeEl.dataset.id ?? ''
|
|
759
|
+
const already = model.isSelected(id)
|
|
760
|
+
if (!already && !ev.shiftKey) model.setSelection([id])
|
|
761
|
+
const ids = ev.shiftKey || already ? (model.isSelected(id) ? model.selectedIds() : [...model.selectedIds(), id]) : [id]
|
|
762
|
+
const origin = innermostFrameAt(new Map(model.allFrames()), screenToWorld(vp, p.x, p.y))
|
|
763
|
+
gesture = { kind: 'node', clicked: id, ids, ...(origin && rosterFramesRef.has(origin) && model.teamMemberIds(origin).includes(id) ? { rosterOrigin: origin } : {}), last: screenToWorld(vp, p.x, p.y), moved: false, el: nodeEl }
|
|
764
|
+
nodeEl.classList.add('dragging')
|
|
765
|
+
} else if (headEl !== null) {
|
|
766
|
+
if (ev.button !== 0) return
|
|
767
|
+
const name = headEl.dataset.frame ?? ''
|
|
768
|
+
// Selecting a frame keeps its resize handles visible (hover-only
|
|
769
|
+
// handles vanished the moment the pointer left).
|
|
770
|
+
markSelectedFrame(name)
|
|
771
|
+
const snap = model.beginFrameDrag(name)
|
|
772
|
+
if (snap === undefined) return
|
|
773
|
+
gesture = { kind: 'frame', name, snap, origin: screenToWorld(vp, p.x, p.y), moved: false }
|
|
774
|
+
} else {
|
|
775
|
+
if (ev.button === 1 || (ev.button === 0 && spaceDown)) {
|
|
776
|
+
gesture = { kind: 'pan', last: p, moved: false }
|
|
777
|
+
} else if (ev.button === 0) {
|
|
778
|
+
gesture = { kind: 'marquee', origin: p, last: p, additive: ev.shiftKey }
|
|
779
|
+
marquee.style.display = 'block'
|
|
780
|
+
marquee.style.left = `${p.x}px`
|
|
781
|
+
marquee.style.top = `${p.y}px`
|
|
782
|
+
marquee.style.width = '0px'
|
|
783
|
+
marquee.style.height = '0px'
|
|
784
|
+
} else return
|
|
785
|
+
}
|
|
786
|
+
ev.preventDefault()
|
|
787
|
+
try { root.setPointerCapture(ev.pointerId ?? 0) } catch { /* jsdom: no capture */ }
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
function pointerMove(ev: SeamPointer): void {
|
|
791
|
+
const p = localXY(ev)
|
|
792
|
+
if (rightPress !== null) {
|
|
793
|
+
// Right-hold pan: incremental viewport follow once past the threshold.
|
|
794
|
+
// User ruling (2026-08-30): the drag moves the CONTENT, not the window —
|
|
795
|
+
// drag right and the canvas follows the cursor (Figma convention), so
|
|
796
|
+
// the viewport origin moves OPPOSITE the pointer delta. The wheel and
|
|
797
|
+
// middle/space pans keep their own established directions.
|
|
798
|
+
const dx = p.x - rightPress.x
|
|
799
|
+
const dy = p.y - rightPress.y
|
|
800
|
+
if (Math.hypot(dx, dy) > 4) {
|
|
801
|
+
rightPress.moved = true
|
|
802
|
+
vp = clampViewport(panBy(vp, -dx, -dy))
|
|
803
|
+
rightPress.x = p.x
|
|
804
|
+
rightPress.y = p.y
|
|
805
|
+
applyViewport()
|
|
806
|
+
}
|
|
807
|
+
return
|
|
808
|
+
}
|
|
809
|
+
if (gesture.kind === 'node') {
|
|
810
|
+
const w = screenToWorld(vp, p.x, p.y)
|
|
811
|
+
const dx = w.x - gesture.last.x
|
|
812
|
+
const dy = w.y - gesture.last.y
|
|
813
|
+
if (dx === 0 && dy === 0) return
|
|
814
|
+
gesture.moved = true
|
|
815
|
+
gesture.last = w
|
|
816
|
+
model.dragNodes(gesture.ids, dx, dy)
|
|
817
|
+
// Drop-target highlight: the innermost frame under the pointer, but
|
|
818
|
+
// only when the drop would change something (some id not yet member).
|
|
819
|
+
const f = innermostFrameAt(new Map(model.allFrames()), w)
|
|
820
|
+
// W-B: peers with a remote handle are droppable members too.
|
|
821
|
+
const joinables = gesture.ids.filter(id => {
|
|
822
|
+
const node = model.getNode(id)
|
|
823
|
+
if (node === undefined) return false
|
|
824
|
+
return node.remote !== true || (node.remoteHandle !== undefined && node.remoteHandle !== '')
|
|
825
|
+
})
|
|
826
|
+
const relevant = f !== undefined && joinables.length > 0
|
|
827
|
+
for (const [name, el] of frameEls) el.classList.toggle('drop-target', relevant && name === f)
|
|
828
|
+
render()
|
|
829
|
+
} else if (gesture.kind === 'frame') {
|
|
830
|
+
const w = screenToWorld(vp, p.x, p.y)
|
|
831
|
+
const dx = w.x - gesture.origin.x
|
|
832
|
+
const dy = w.y - gesture.origin.y
|
|
833
|
+
if (!gesture.moved && dx === 0 && dy === 0) return
|
|
834
|
+
gesture.moved = true
|
|
835
|
+
model.applyFrameDrag(gesture.snap, dx, dy)
|
|
836
|
+
render()
|
|
837
|
+
} else if (gesture.kind === 'frame-resize') {
|
|
838
|
+
// Re-solve the rect from the start rect plus the world delta, per
|
|
839
|
+
// direction. Minimum 220x120 keeps the titlebar and成员 badges
|
|
840
|
+
// readable; the frame rect is independent of member positions, so
|
|
841
|
+
// no node moves — star edges follow on the next render.
|
|
842
|
+
const w = screenToWorld(vp, p.x, p.y)
|
|
843
|
+
const dx = w.x - gesture.origin.x
|
|
844
|
+
const dy = w.y - gesture.origin.y
|
|
845
|
+
if (!gesture.moved && dx === 0 && dy === 0) return
|
|
846
|
+
gesture.moved = true
|
|
847
|
+
const MIN_W = 220
|
|
848
|
+
const MIN_H = 120
|
|
849
|
+
const r = { ...gesture.startRect }
|
|
850
|
+
let { x, y, w: width, h: height } = r
|
|
851
|
+
if (gesture.dir.includes('e')) width = Math.max(MIN_W, r.w + dx)
|
|
852
|
+
if (gesture.dir.includes('s')) height = Math.max(MIN_H, r.h + dy)
|
|
853
|
+
if (gesture.dir.includes('w')) {
|
|
854
|
+
width = Math.max(MIN_W, r.w - dx)
|
|
855
|
+
x = r.x + (r.w - width)
|
|
856
|
+
}
|
|
857
|
+
if (gesture.dir.includes('n')) {
|
|
858
|
+
height = Math.max(MIN_H, r.h - dy)
|
|
859
|
+
y = r.y + (r.h - height)
|
|
860
|
+
}
|
|
861
|
+
model.setFrame(gesture.name, { x: Math.round(x), y: Math.round(y), w: Math.round(width), h: Math.round(height) })
|
|
862
|
+
render()
|
|
863
|
+
} else if (gesture.kind === 'pan') {
|
|
864
|
+
vp = panBy(vp, p.x - gesture.last.x, p.y - gesture.last.y)
|
|
865
|
+
gesture.last = p
|
|
866
|
+
gesture.moved = true
|
|
867
|
+
applyViewport()
|
|
868
|
+
} else if (gesture.kind === 'marquee') {
|
|
869
|
+
const x = Math.min(gesture.origin.x, p.x)
|
|
870
|
+
const y = Math.min(gesture.origin.y, p.y)
|
|
871
|
+
marquee.style.left = `${x}px`
|
|
872
|
+
marquee.style.top = `${y}px`
|
|
873
|
+
marquee.style.width = `${Math.abs(p.x - gesture.origin.x)}px`
|
|
874
|
+
marquee.style.height = `${Math.abs(p.y - gesture.origin.y)}px`
|
|
875
|
+
gesture.last = p
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
function pointerUp(ev: SeamPointer): void {
|
|
880
|
+
if (rightPress !== null) {
|
|
881
|
+
const wasPan = rightPress.moved
|
|
882
|
+
const pressTarget = rightPress.target
|
|
883
|
+
const x = rightPress.x
|
|
884
|
+
const y = rightPress.y
|
|
885
|
+
rightPress = null
|
|
886
|
+
if (wasPan) {
|
|
887
|
+
suppressNextMenu = true
|
|
888
|
+
markDirty() // the viewport persists with the layout
|
|
889
|
+
} else if (pressTarget !== null) {
|
|
890
|
+
menus.contextMenuAt(x, y, pressTarget) // clean right-click: context menu
|
|
891
|
+
}
|
|
892
|
+
return
|
|
893
|
+
}
|
|
894
|
+
if (gesture.kind === 'node') {
|
|
895
|
+
const id = gesture.clicked
|
|
896
|
+
if (!gesture.moved) {
|
|
897
|
+
// A click (no drag) is pure selection — NEVER a drop. The F1 fix:
|
|
898
|
+
// an unmoved multi-select click used to fall through to dropDispatch
|
|
899
|
+
// and write an entire group's 离队 to the host.
|
|
900
|
+
if (ev.shiftKey) {
|
|
901
|
+
const current = model.selectedIds()
|
|
902
|
+
model.setSelection(current.includes(id) ? current.filter(x => x !== id) : [...current, id])
|
|
903
|
+
} else {
|
|
904
|
+
model.setSelection([id])
|
|
905
|
+
// 0.6.13: a clean single click is also a node pick — the host opens
|
|
906
|
+
// the chat drawer. Shift-click (multi-select) stays a pure selection.
|
|
907
|
+
deps.onNodePick?.(id)
|
|
908
|
+
}
|
|
909
|
+
} else {
|
|
910
|
+
dropDispatch(gesture, ev)
|
|
911
|
+
markDirty()
|
|
912
|
+
}
|
|
913
|
+
for (const el of frameEls.values()) el.classList.remove('drop-target')
|
|
914
|
+
gesture.el?.classList.remove('dragging')
|
|
915
|
+
render()
|
|
916
|
+
} else if (gesture.kind === 'frame' || gesture.kind === 'frame-resize') {
|
|
917
|
+
// The frame stays where it was put; neighbours and stray cards yield (planning-overlap).
|
|
918
|
+
if (gesture.moved) { settleBoard(model, new Set(remoteFramesRef.keys()), gesture.name); markDirty(); render() }
|
|
919
|
+
} else if (gesture.kind === 'pan') {
|
|
920
|
+
if (gesture.moved) markDirty() // the viewport persists with the layout
|
|
921
|
+
} else if (gesture.kind === 'marquee') {
|
|
922
|
+
const p = gesture.last
|
|
923
|
+
const a = screenToWorld(vp, Math.min(gesture.origin.x, p.x), Math.min(gesture.origin.y, p.y))
|
|
924
|
+
const b = screenToWorld(vp, Math.max(gesture.origin.x, p.x), Math.max(gesture.origin.y, p.y))
|
|
925
|
+
model.marqueeSelect({ x: a.x, y: a.y, w: b.x - a.x, h: b.y - a.y }, gesture.additive)
|
|
926
|
+
marquee.style.display = 'none'
|
|
927
|
+
render()
|
|
928
|
+
}
|
|
929
|
+
gesture = { kind: 'none' }
|
|
930
|
+
if (undoBefore !== undefined) { layoutUndo.commit(undoBefore); undoBefore = undefined }
|
|
931
|
+
try { root.releasePointerCapture(ev.pointerId ?? 0) } catch { /* not captured */ }
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
/** The OS took the pointer (touch scroll, system gesture): abandon the press — no drop, no menu, layout back. */
|
|
935
|
+
function pointerCancel(ev: SeamPointer): void {
|
|
936
|
+
for (const el of frameEls.values()) el.classList.remove('drop-target')
|
|
937
|
+
if (gesture.kind === 'node') gesture.el?.classList.remove('dragging')
|
|
938
|
+
const panned = (gesture.kind === 'pan' && gesture.moved) || rightPress?.moved === true // right-drag pans live in rightPress
|
|
939
|
+
marquee.style.display = 'none'
|
|
940
|
+
rightPress = null
|
|
941
|
+
gesture = { kind: 'none' }
|
|
942
|
+
if (undoBefore !== undefined && layoutUndo.revert(undoBefore)) render()
|
|
943
|
+
if (panned) markDirty() // the pan stays (it is the view, not the layout) and persists like a finished one
|
|
944
|
+
undoBefore = undefined
|
|
945
|
+
try { root.releasePointerCapture(ev.pointerId ?? 0) } catch { /* not captured */ }
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
function wheel(ev: SeamPointer): void {
|
|
949
|
+
if (menus.isMenuOpen()) menus.closeMenu()
|
|
950
|
+
const p = localXY(ev)
|
|
951
|
+
// Wheel = pointer-anchored zoom (owner ruling 2026-09-01: 鼠标滚动 =
|
|
952
|
+
// 放大缩小), with or without Ctrl — both muscle memories land on the
|
|
953
|
+
// same action. Shift+wheel keeps the natural-scroll pan as the escape
|
|
954
|
+
// hatch. Line-mode wheels (Firefox, deltaMode=1) report ~3 per notch.
|
|
955
|
+
if (ev.shiftKey && !ev.ctrlKey) {
|
|
956
|
+
const line = ev.deltaMode === 1 ? 16 : 1
|
|
957
|
+
vp = clampViewport(panBy(vp, (ev.deltaX ?? 0) * line, (ev.deltaY ?? 0) * line))
|
|
958
|
+
} else {
|
|
959
|
+
const line = ev.deltaMode === 1 ? 16 : 1
|
|
960
|
+
const factor = Math.exp(-((ev.deltaY ?? 0) * line) * 0.0015)
|
|
961
|
+
vp = zoomAt(vp, factor, p.x, p.y)
|
|
962
|
+
}
|
|
963
|
+
ev.preventDefault()
|
|
964
|
+
applyViewport()
|
|
965
|
+
markDirty()
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
function zoomCenter(factor: number): void {
|
|
969
|
+
syncViewSize()
|
|
970
|
+
vp = zoomAt(vp, factor, viewW / 2, viewH / 2)
|
|
971
|
+
applyViewport()
|
|
972
|
+
markDirty()
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
function undoLayout(): void {
|
|
976
|
+
if (layoutUndo.undo()) { markDirty(); render() } else notice('info', '没有可撤销的布局改动')
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
function fitToContent(): void {
|
|
980
|
+
syncViewSize()
|
|
981
|
+
vp = fitBelowHud(boardBounds(model.contentBounds(), model.allNodes()), viewW, viewH, hudBottomOf(toolbar, status))
|
|
982
|
+
applyViewport()
|
|
983
|
+
markDirty()
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
function key(ev: SeamKey): void {
|
|
987
|
+
if (menus.isDialogOpen() || (ev.target as Element | null)?.closest?.('.p-search') !== null) return // modal / search box own their keys
|
|
988
|
+
if ((ev.ctrlKey || ev.metaKey) && ev.key.toLowerCase() === 'k') { ev.preventDefault(); search.focus(); return } // W1 Ctrl+K
|
|
989
|
+
if ((ev.ctrlKey || ev.metaKey) && !ev.shiftKey && ev.key.toLowerCase() === 'z') { ev.preventDefault(); undoLayout(); return }
|
|
990
|
+
if (ev.ctrlKey || ev.metaKey) return // browser/OS chords stay browser/OS
|
|
991
|
+
if (menus.isMenuOpen()) {
|
|
992
|
+
// The menu owns most keys (its own handler navigates); Escape still
|
|
993
|
+
// closes from here so a seam/keyboard Esc never gets lost.
|
|
994
|
+
if (ev.key === 'Escape') { menus.closeMenu(); ev.preventDefault() }
|
|
995
|
+
return
|
|
996
|
+
}
|
|
997
|
+
if (ev.key === ' ' && ev.target === root) { spaceDown = true; ev.preventDefault(); return }
|
|
998
|
+
// 键盘全等路径(WCAG 2.2 Dragging Movements):
|
|
999
|
+
const headTarget = (ev.target as Element | null)?.closest?.('.p-frame-head') ?? null
|
|
1000
|
+
if ((ev.key === 'Enter' || ev.key === ' ') && headTarget !== null) {
|
|
1001
|
+
ev.preventDefault()
|
|
1002
|
+
const r = headTarget.getBoundingClientRect()
|
|
1003
|
+
menus.contextMenuAt(r.left + r.width / 2, r.top + r.height / 2, headTarget)
|
|
1004
|
+
return
|
|
1005
|
+
}
|
|
1006
|
+
if (ev.key === ' ' && (ev.target as Element | null)?.classList?.contains('p-node')) {
|
|
1007
|
+
const el = ev.target as HTMLElement
|
|
1008
|
+
const id = el.dataset.id ?? ''
|
|
1009
|
+
const current = model.selectedIds()
|
|
1010
|
+
model.setSelection(current.includes(id) ? current.filter(x => x !== id) : [...current, id])
|
|
1011
|
+
render()
|
|
1012
|
+
ev.preventDefault()
|
|
1013
|
+
return
|
|
1014
|
+
}
|
|
1015
|
+
if ((ev.key === 'F10' && ev.shiftKey) || ev.key === 'ContextMenu') {
|
|
1016
|
+
ev.preventDefault()
|
|
1017
|
+
const fe = (document.activeElement as Element | null) ?? null
|
|
1018
|
+
const r = fe?.getBoundingClientRect?.() ?? { left: 0, top: 0, width: 0, height: 0 }
|
|
1019
|
+
menus.contextMenuAt(r.left + r.width / 2, r.top + r.height / 2, fe)
|
|
1020
|
+
return
|
|
1021
|
+
}
|
|
1022
|
+
if (ev.key === 'Delete' || ev.key === 'Backspace') { ev.preventDefault(); deleteFocused(); return }
|
|
1023
|
+
if (ev.altKey && (ev.key === 'ArrowUp' || ev.key === 'ArrowDown')) { ev.preventDefault(); altMove(ev.key === 'ArrowUp'); return }
|
|
1024
|
+
if (ev.key === 'g' || ev.key === 'G') { menus.startCreateFromSelection(); return }
|
|
1025
|
+
if (ev.key === '0') { fitToContent(); return }
|
|
1026
|
+
if (ev.key === '=' || ev.key === '+') { zoomCenter(1.2); return }
|
|
1027
|
+
if (ev.key === '-') { zoomCenter(1 / 1.2); return }
|
|
1028
|
+
if (ev.key === 'Escape') {
|
|
1029
|
+
if (menus.isMenuOpen()) { menus.closeMenu(); render(); return } // menu > clear selection
|
|
1030
|
+
model.setSelection([])
|
|
1031
|
+
render()
|
|
1032
|
+
return
|
|
1033
|
+
}
|
|
1034
|
+
const step = ev.shiftKey ? 40 : 8
|
|
1035
|
+
const dxdy: Record<string, [number, number]> = {
|
|
1036
|
+
ArrowLeft: [-step, 0], ArrowRight: [step, 0], ArrowUp: [0, -step], ArrowDown: [0, step],
|
|
1037
|
+
}
|
|
1038
|
+
const d = dxdy[ev.key]
|
|
1039
|
+
if (d !== undefined) {
|
|
1040
|
+
ev.preventDefault()
|
|
1041
|
+
const ids = model.selectedIds()
|
|
1042
|
+
if (ids.length > 0) {
|
|
1043
|
+
const before = layoutUndo.capture()
|
|
1044
|
+
model.nudge(ids, d[0]!, d[1]!)
|
|
1045
|
+
layoutUndo.commit(before)
|
|
1046
|
+
render()
|
|
1047
|
+
markDirty()
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
/** Delete 按焦点对象:单队卡=离队、多队卡=下钻离队菜单、无队=info、框头=散队。 */
|
|
1053
|
+
function deleteFocused(): void {
|
|
1054
|
+
const fe = document.activeElement as HTMLElement | null
|
|
1055
|
+
const headEl = fe?.closest?.<HTMLElement>('.p-frame-head') ?? null
|
|
1056
|
+
const nodeEl = fe?.closest?.<HTMLElement>('.p-node') ?? null
|
|
1057
|
+
if (headEl !== null) {
|
|
1058
|
+
emitAction({ type: 'remove-team', name: headEl.dataset.frame ?? '' })
|
|
1059
|
+
return
|
|
1060
|
+
}
|
|
1061
|
+
if (nodeEl !== null) {
|
|
1062
|
+
const id = nodeEl.dataset.id ?? ''
|
|
1063
|
+
const teams = model.getNode(id)?.memberships.map(m => m.team) ?? []
|
|
1064
|
+
if (teams.length === 0) { notice('info', '该节点未加入任何团队'); return }
|
|
1065
|
+
if (teams.length === 1) {
|
|
1066
|
+
emitAction({ type: 'remove-member', team: teams[0]!, ids: [id] })
|
|
1067
|
+
return
|
|
1068
|
+
}
|
|
1069
|
+
menus.openMenu(window.innerWidth / 2, window.innerHeight / 2, { kind: 'node', id }, 'leave', nodeEl)
|
|
1070
|
+
return
|
|
1071
|
+
}
|
|
1072
|
+
notice('info', '焦点不在节点或团队框上')
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
/** Alt+↑/↓: move the focused card within its single team's priority. */
|
|
1076
|
+
function altMove(up: boolean): void {
|
|
1077
|
+
const fe = document.activeElement as HTMLElement | null
|
|
1078
|
+
const nodeEl = fe?.closest?.<HTMLElement>('.p-node') ?? null
|
|
1079
|
+
if (nodeEl === null) return
|
|
1080
|
+
const id = nodeEl.dataset.id ?? ''
|
|
1081
|
+
const mem = model.getNode(id)?.memberships ?? []
|
|
1082
|
+
if (mem.length !== 1) return
|
|
1083
|
+
const team = mem[0]!.team
|
|
1084
|
+
const current = model.teamMemberIds(team)
|
|
1085
|
+
const idx = current.indexOf(id)
|
|
1086
|
+
const to = up ? Math.max(0, idx - 1) : Math.min(current.length - 1, idx + 1)
|
|
1087
|
+
if (to === idx) return
|
|
1088
|
+
const desired = [...current]
|
|
1089
|
+
desired.splice(idx, 1)
|
|
1090
|
+
desired.splice(to, 0, id)
|
|
1091
|
+
emitAction({ type: 'reorder', team, ops: reorderOps(current, desired) })
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
function keyUp(ev: SeamKey): void {
|
|
1095
|
+
if (ev.key === ' ') spaceDown = false
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
root.addEventListener('pointerdown', (ev) => pointerDown(ev as unknown as SeamPointer), signal)
|
|
1099
|
+
root.addEventListener('pointermove', (ev) => pointerMove(ev as unknown as SeamPointer), signal)
|
|
1100
|
+
root.addEventListener('pointerup', (ev) => pointerUp(ev as unknown as SeamPointer), signal)
|
|
1101
|
+
root.addEventListener('pointercancel', (ev) => pointerCancel(ev as unknown as SeamPointer), signal)
|
|
1102
|
+
root.addEventListener('wheel', (ev) => wheel(ev as unknown as SeamPointer), { ...signal, passive: false })
|
|
1103
|
+
root.addEventListener('keydown', (ev) => key(ev as unknown as SeamKey), signal)
|
|
1104
|
+
root.addEventListener('keyup', (ev) => keyUp(ev as unknown as SeamKey), signal)
|
|
1105
|
+
root.addEventListener('contextmenu', (ev) => {
|
|
1106
|
+
const e = ev as unknown as SeamPointer
|
|
1107
|
+
if ((e.target as Element | null)?.closest?.('input, textarea, .p-search') != null) return // text fields keep the native menu (paste)
|
|
1108
|
+
// Right-drag pans (suppressNextMenu); a clean right-click opens the
|
|
1109
|
+
// menu programmatically at pointerup. Either way the native menu dies.
|
|
1110
|
+
e.preventDefault()
|
|
1111
|
+
if (suppressNextMenu) { suppressNextMenu = false; return }
|
|
1112
|
+
menus.contextMenuAt(e.clientX, e.clientY, e.target)
|
|
1113
|
+
}, signal)
|
|
1114
|
+
|
|
1115
|
+
// ── poll reconcile: incremental, never repositions existing nodes ──
|
|
1116
|
+
let lastTeams: ReadonlyArray<PlanningTeam> = []
|
|
1117
|
+
|
|
1118
|
+
function reconcile(input: PlanningInput): void {
|
|
1119
|
+
// gh#41: remote-frame registry shared with serverAction (drop fork).
|
|
1120
|
+
lastTeams = input.teams
|
|
1121
|
+
rosterFramesRef = new Set([...input.teams.filter(t => t.kind === 'roster').map(t => t.name), ...[...rosterFramesRef].filter(n => pendingTeams.has(n))])
|
|
1122
|
+
lastPeers = input.peers ?? []
|
|
1123
|
+
federation.setInFlight(input.inFlight ?? [])
|
|
1124
|
+
// Unjoined sessions have no node on the canvas — the panel-slim
|
|
1125
|
+
// migration made this view the join surface, so they ride a compact
|
|
1126
|
+
// toolbar chip instead of vanishing entirely.
|
|
1127
|
+
lastSessions = input.sessions
|
|
1128
|
+
menus.setUnjoined(input.sessions.filter(s => s.joined !== true && !s.id.startsWith('peer-')))
|
|
1129
|
+
const memberships = membershipIndex(input.teams)
|
|
1130
|
+
lastCanvasMemberIds = new Set(memberships.keys())
|
|
1131
|
+
const seen = new Set<string>()
|
|
1132
|
+
// In-flight write guard: membership entries of teams with unsettled
|
|
1133
|
+
// writes come from the model, not the (stale) payload — a poll racing
|
|
1134
|
+
// an optimistic add must not snap the new edge away.
|
|
1135
|
+
const resolveMemberships = (id: string): Array<{ team: string; index: number }> => {
|
|
1136
|
+
const payload = memberships.get(id) ?? []
|
|
1137
|
+
if (pendingTeams.size === 0) return payload
|
|
1138
|
+
const keep = payload.filter(m => !pendingTeams.has(m.team))
|
|
1139
|
+
// In-flight teams read their in-membership from the team roster
|
|
1140
|
+
// (teamMemberIds), NOT from getNode(id).memberships: an optimistic
|
|
1141
|
+
// add can target a node the teamed gate kept OFF the model (no card
|
|
1142
|
+
// yet) — its membership lives only in the roster map, and dropping
|
|
1143
|
+
// it here would pop the node right back off the canvas mid-flight.
|
|
1144
|
+
const pending: Array<{ team: string; index: number }> = []
|
|
1145
|
+
for (const [team] of pendingTeams) {
|
|
1146
|
+
const roster = model.teamMemberIds(team)
|
|
1147
|
+
const index = roster.indexOf(id)
|
|
1148
|
+
if (index >= 0) pending.push({ team, index })
|
|
1149
|
+
}
|
|
1150
|
+
return [...keep, ...pending]
|
|
1151
|
+
}
|
|
1152
|
+
// First build with nothing saved: seat the fleet team by team (see
|
|
1153
|
+
// planTeamClusters). Later arrivals, or any board with a saved layout,
|
|
1154
|
+
// keep the ring fallback.
|
|
1155
|
+
let clusterSeats: Map<string, { x: number; y: number }> | undefined
|
|
1156
|
+
if (layoutDoc === undefined && !model.allNodes().some(n => n.remote !== true)) {
|
|
1157
|
+
const seatIds = input.sessions
|
|
1158
|
+
.filter(s => s.joined === true && (resolveMemberships(s.id).length > 0 || (s.teams?.length ?? 0) > 0))
|
|
1159
|
+
.map(s => s.id)
|
|
1160
|
+
syncViewSize()
|
|
1161
|
+
clusterSeats = planTeamClusters(input.teams, seatIds, viewW > 0 ? viewW : undefined)
|
|
1162
|
+
}
|
|
1163
|
+
// A joined session whose only team is a peer's (a mirror frame) is teamed too: without its
|
|
1164
|
+
// card the mirror frame cannot show it, and 离队 has nothing to reach (owner report 2026-11-04).
|
|
1165
|
+
const mirrorMembers = input.peerOrg !== undefined ? mirrorMemberIds(input.peerOrg, input.sessions, input.remoteTeams) : new Set<string>()
|
|
1166
|
+
// The node list reads the same fact: such a session is teamed, not 未组队.
|
|
1167
|
+
if (input.peerOrg !== undefined) lastCanvasMemberIds = new Set([...lastCanvasMemberIds, ...mirrorMembers])
|
|
1168
|
+
for (const s of input.sessions) {
|
|
1169
|
+
if (s.joined !== true) continue
|
|
1170
|
+
// Teamed-only canvas (network-membership-display ruling): a joined
|
|
1171
|
+
// session earns a canvas node only with a team — canvas membership
|
|
1172
|
+
// (optimistic-aware), a declared roster team, or a mirror frame's
|
|
1173
|
+
// membership. Teamless joined sessions ride the node list's 未组队
|
|
1174
|
+
// group instead; sessions absent from `seen` are swept from the model below.
|
|
1175
|
+
const effective = resolveMemberships(s.id)
|
|
1176
|
+
if (effective.length === 0 && (s.teams === undefined || s.teams.length === 0) && !mirrorMembers.has(s.id)) continue
|
|
1177
|
+
seen.add(s.id)
|
|
1178
|
+
if (model.getNode(s.id) !== undefined) {
|
|
1179
|
+
model.upsertNode({
|
|
1180
|
+
id: s.id, x: model.getNode(s.id)!.x, y: model.getNode(s.id)!.y,
|
|
1181
|
+
label: s.label, team: s.team, name: s.name, description: s.description, live: s.live !== false,
|
|
1182
|
+
memberships: effective,
|
|
1183
|
+
...(s.teams !== undefined && s.teams.length > 0 ? { teams: s.teams } : {}),
|
|
1184
|
+
})
|
|
1185
|
+
} else {
|
|
1186
|
+
const saved = layoutDoc?.nodes[s.id]
|
|
1187
|
+
let x: number
|
|
1188
|
+
let y: number
|
|
1189
|
+
if (saved !== undefined) { x = saved.x; y = saved.y } else {
|
|
1190
|
+
const seat = clusterSeats?.get(s.id) ?? seatFor2(s.id)
|
|
1191
|
+
x = seat.x
|
|
1192
|
+
y = seat.y
|
|
1193
|
+
}
|
|
1194
|
+
model.upsertNode({
|
|
1195
|
+
id: s.id, x, y, label: s.label, team: s.team, name: s.name, description: s.description, live: s.live !== false,
|
|
1196
|
+
memberships: effective,
|
|
1197
|
+
...(s.teams !== undefined && s.teams.length > 0 ? { teams: s.teams } : {}),
|
|
1198
|
+
})
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
for (const id of model.nodeIds()) {
|
|
1202
|
+
// peer- and member- cards are reconciled by their own blocks below.
|
|
1203
|
+
if (!seen.has(id) && !id.startsWith('peer-') && !id.startsWith('member-')) model.removeNode(id)
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
// Peer nodes (v1 ruling): same model pipeline as sessions, `remote` flag
|
|
1207
|
+
// on. Saved positions win; new peers take the deterministic column over
|
|
1208
|
+
// the LOCAL content bounds (remote cards hang outside the field and are
|
|
1209
|
+
// excluded from bounds, so the column cannot feed back into itself).
|
|
1210
|
+
const peerSeen = new Set<string>()
|
|
1211
|
+
lastRemoteTeams = groupRemoteTeams(input.remoteTeams ?? [])
|
|
1212
|
+
memberHostByHandle = new Map([...lastRemoteTeams].flatMap(([h, rows]) => rows.map(row => [row.team, h] as const)))
|
|
1213
|
+
lastWork = new Map(input.sessions.map(s => [s.id, { status: s.status, queued: s.queued }]))
|
|
1214
|
+
reachability = unreachableRefs(input)
|
|
1215
|
+
peerScoreByText.clear()
|
|
1216
|
+
for (const p of input.peers ?? []) {
|
|
1217
|
+
peerScoreByText.set(peerHostOf(p.url), p.score)
|
|
1218
|
+
peerInfoByHost.set(peerHostOf(p.url), { link: p.link, verified: p.verified })
|
|
1219
|
+
}
|
|
1220
|
+
if (input.peers !== undefined) {
|
|
1221
|
+
const localBounds = model.contentBounds()
|
|
1222
|
+
const placements = placePeers(input.peers, localBounds)
|
|
1223
|
+
input.peers.forEach((p, i) => {
|
|
1224
|
+
const id = peerNodeId(p.url)
|
|
1225
|
+
peerSeen.add(id)
|
|
1226
|
+
const saved = layoutDoc?.nodes[id]
|
|
1227
|
+
const existing = model.getNode(id)
|
|
1228
|
+
const base = existing !== undefined
|
|
1229
|
+
? { x: existing.x, y: existing.y }
|
|
1230
|
+
: saved !== undefined
|
|
1231
|
+
? { x: saved.x, y: saved.y }
|
|
1232
|
+
: { x: placements[i]?.x ?? 200, y: placements[i]?.y ?? 0 }
|
|
1233
|
+
// gh#20 W-B: the peer's server-side member form — its first remote
|
|
1234
|
+
// team row's zone/id8 handle (canvas remote refs are exact handles
|
|
1235
|
+
// per the D1 ruling). Absent until the first remote team row lands.
|
|
1236
|
+
const host = peerHostOf(p.url)
|
|
1237
|
+
// Owner report 2026-11-04: the host's rows now lead with its bare process team (e.g. 'dsh'), which the
|
|
1238
|
+
// server reads as a local session id and refuses — take the first zone/id8 SESSION handle instead.
|
|
1239
|
+
const handle = lastRemoteTeams.get(host)?.find(row => row.team.includes('/'))?.team
|
|
1240
|
+
model.upsertNode({
|
|
1241
|
+
id, x: base.x, y: base.y,
|
|
1242
|
+
label: p.url, team: '', name: p.url, peerUrl: p.url,
|
|
1243
|
+
remoteHandle: handle,
|
|
1244
|
+
live: true, remote: true, memberships: [],
|
|
1245
|
+
})
|
|
1246
|
+
model.upsertScore(id, p.score)
|
|
1247
|
+
})
|
|
1248
|
+
}
|
|
1249
|
+
for (const id of model.nodeIds()) {
|
|
1250
|
+
if (id.startsWith('peer-') && !peerSeen.has(id)) model.removeNode(id)
|
|
1251
|
+
}
|
|
1252
|
+
// gh#38 (owner ruling 「统一到画布里面拖拽」): every remote session-team
|
|
1253
|
+
// HANDLE renders as its own draggable card, stacked beside its host's
|
|
1254
|
+
// peer card — pulling a specific remote member into a local team is a
|
|
1255
|
+
// plain drag (no picker, no hand-typed handle). Host cards stay for
|
|
1256
|
+
// federation context; the member cards carry the actual join gesture.
|
|
1257
|
+
|
|
1258
|
+
const memberSeen = new Set<string>()
|
|
1259
|
+
for (const [host, rows] of lastRemoteTeams) {
|
|
1260
|
+
const hostNode = model.allNodes().find(n => n.remote === true && peerHostOf(n.peerUrl ?? '') === host && !n.id.startsWith('member-'))
|
|
1261
|
+
const baseX = (hostNode?.x ?? 400) + 190
|
|
1262
|
+
let y = hostNode?.y ?? 0
|
|
1263
|
+
for (const row of rows) {
|
|
1264
|
+
if (!row.team.includes('/')) continue
|
|
1265
|
+
const id = `member-${row.team.replace(/[^a-z0-9-]/gi, '-')}`
|
|
1266
|
+
if (memberSeen.has(id)) continue
|
|
1267
|
+
memberSeen.add(id)
|
|
1268
|
+
const saved = layoutDoc?.nodes[id]
|
|
1269
|
+
const existing = model.getNode(id)
|
|
1270
|
+
const pos = existing !== undefined ? { x: existing.x, y: existing.y } : saved !== undefined ? { x: saved.x, y: saved.y } : { x: baseX, y }
|
|
1271
|
+
y += 92
|
|
1272
|
+
model.upsertNode({
|
|
1273
|
+
id, x: pos.x, y: pos.y,
|
|
1274
|
+
label: row.team, team: row.team, name: row.name !== undefined && row.name !== '' ? row.name : row.team,
|
|
1275
|
+
remoteHandle: row.team,
|
|
1276
|
+
live: true, remote: true, memberships: [],
|
|
1277
|
+
})
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
for (const id of model.nodeIds()) {
|
|
1281
|
+
if (id.startsWith('member-') && !memberSeen.has(id)) model.removeNode(id)
|
|
1282
|
+
}
|
|
1283
|
+
// gh#42 (owner report: dragged a remote peer into base, no edge): team
|
|
1284
|
+
// edges derive from NODE memberships, but remote cards carried hardcoded
|
|
1285
|
+
// empty memberships — the server truth (base contains dsh/d659a8f9)
|
|
1286
|
+
// never re-attached after a poll or reload. Re-attach here: every local
|
|
1287
|
+
// team's remote member handles resolve onto their cards (member- card
|
|
1288
|
+
// preferred, the peer HOST card as fallback), in-flight teams excluded.
|
|
1289
|
+
const handleToNode = new Map<string, string>()
|
|
1290
|
+
for (const n of model.allNodes()) {
|
|
1291
|
+
if (n.remote !== true || n.remoteHandle === undefined || n.remoteHandle === '') continue
|
|
1292
|
+
if (!handleToNode.has(n.remoteHandle) || n.id.startsWith('member-')) handleToNode.set(n.remoteHandle, n.id)
|
|
1293
|
+
}
|
|
1294
|
+
for (const team of input.teams) {
|
|
1295
|
+
if (pendingTeams.has(team.name)) continue
|
|
1296
|
+
const resolved: string[] = []
|
|
1297
|
+
for (const m of team.members) {
|
|
1298
|
+
const id = handleToNode.get(m.id) ?? m.id
|
|
1299
|
+
if (model.getNode(id) !== undefined) resolved.push(id)
|
|
1300
|
+
}
|
|
1301
|
+
const current = [...model.teamMemberIds(team.name)].sort().join('|')
|
|
1302
|
+
const next = [...resolved].sort().join('|')
|
|
1303
|
+
if (current !== next) model.setTeamMembers(team.name, resolved)
|
|
1304
|
+
}
|
|
1305
|
+
// gh#41 fleet planning: the peers' published canvas org renders as
|
|
1306
|
+
// REMOTE team frames — the owner reads the other side's grouping from
|
|
1307
|
+
// ANY node and regulates it in place. Members attach as board
|
|
1308
|
+
// memberships; drops into a remote frame fork to the host relay (see
|
|
1309
|
+
// serverAction: the action carries fleetPeer, and main.ts routes it to
|
|
1310
|
+
// /__dsh_a2a/fleet-canvas-op instead of the local canvas control).
|
|
1311
|
+
const remoteFrames = new Map<string, string>()
|
|
1312
|
+
const prevRemoteFrames = remoteFramesRef
|
|
1313
|
+
remoteFramesRef = remoteFrames
|
|
1314
|
+
if (input.peerOrg !== undefined) {
|
|
1315
|
+
let slot = 0
|
|
1316
|
+
const localNames = new Set(input.teams.map(t => t.name))
|
|
1317
|
+
dissolving.sweep(input.peerOrg)
|
|
1318
|
+
for (const row of input.peerOrg) {
|
|
1319
|
+
// Resolve joined local handles and id-form legacy members through the pure projection helper.
|
|
1320
|
+
const publishedHandles = new Set((input.remoteTeams ?? []).map(session => session.team))
|
|
1321
|
+
const localIdsByHandle = joinedLocalIdsByHandle(input.sessions, publishedHandles)
|
|
1322
|
+
for (const team of row.org) {
|
|
1323
|
+
if (team.name === '' || localNames.has(team.name) || dissolving.hides(team.name, row.url)) continue
|
|
1324
|
+
const memberIds = team.members.map(member => remoteMemberBoardId(member, localIdsByHandle, input.remoteTeams, row.url)).filter(id => model.getNode(id) !== undefined)
|
|
1325
|
+
const saved = layoutDoc?.frames?.[team.name]
|
|
1326
|
+
// gh#44 (owner: frame position/size never sticks): the LOCAL-team
|
|
1327
|
+
// path keeps an existing frame's live rect ("live rect wins" — a
|
|
1328
|
+
// poll must not snap a frame the user just dragged, the save loop
|
|
1329
|
+
// persists it a beat later). Remote frames re-set unconditionally
|
|
1330
|
+
// every poll, so every drag snapped back before the save landed.
|
|
1331
|
+
// Same contract here: only place the frame when it is absent.
|
|
1332
|
+
if (model.getFrame(team.name) === undefined) {
|
|
1333
|
+
model.setFrame(team.name, saved !== undefined ? { x: saved.x, y: saved.y, w: saved.w, h: saved.h } : { x: 200 + (slot % 4) * 280, y: 940 + Math.floor(slot / 4) * 160, w: 250, h: 130 })
|
|
1334
|
+
}
|
|
1335
|
+
model.setTeamMembers(team.name, memberIds)
|
|
1336
|
+
remoteFrames.set(team.name, row.url)
|
|
1337
|
+
slot += 1
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
for (const [name, url] of prevRemoteFrames) {
|
|
1342
|
+
if (!remoteFrames.has(name)) {
|
|
1343
|
+
model.removeFrame(name)
|
|
1344
|
+
void url
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
// gh#20 W-B: the server echoes cross-host members as zone/id8 handles —
|
|
1348
|
+
// map each handle back onto the peer node that carries it, so the
|
|
1349
|
+
// reconcile keeps remote memberships instead of dropping them.
|
|
1350
|
+
const handleToPeer = new Map<string, string>()
|
|
1351
|
+
for (const n of model.allNodes()) {
|
|
1352
|
+
if (n.remote === true && n.remoteHandle !== undefined && n.remoteHandle !== '') handleToPeer.set(n.remoteHandle, n.id)
|
|
1353
|
+
}
|
|
1354
|
+
const homes = homeTeams(input.teams)
|
|
1355
|
+
for (const team of input.teams) {
|
|
1356
|
+
if (model.getFrame(team.name) !== undefined) continue // live rect wins
|
|
1357
|
+
if (pendingTeams.has(team.name)) continue // 散队 in flight: don't resurrect
|
|
1358
|
+
const saved = layoutDoc?.frames[team.name]
|
|
1359
|
+
if (saved !== undefined) { model.setFrame(team.name, { ...saved }); continue }
|
|
1360
|
+
// A derived frame wraps its HOME members only: a cross-team member is
|
|
1361
|
+
// reached by its dashed edge, not by stretching this frame over its
|
|
1362
|
+
// home frame. A team made only of members homed elsewhere falls back
|
|
1363
|
+
// to wrapping them all.
|
|
1364
|
+
const rects: LayoutRect[] = []
|
|
1365
|
+
const all: LayoutRect[] = []
|
|
1366
|
+
for (const m of team.members) {
|
|
1367
|
+
const n = model.getNode(handleToPeer.get(m.id) ?? m.id)
|
|
1368
|
+
if (n === undefined) continue
|
|
1369
|
+
all.push(nodeRect(n))
|
|
1370
|
+
if (homes.get(n.id) === team.name) rects.push(nodeRect(n))
|
|
1371
|
+
}
|
|
1372
|
+
const wrap = rects.length > 0 ? rects : all
|
|
1373
|
+
if (wrap.length > 0) model.setFrame(team.name, deriveInitialFrame(wrap))
|
|
1374
|
+
}
|
|
1375
|
+
for (const [name] of model.allFrames()) {
|
|
1376
|
+
if (pendingTeams.has(name)) continue // an in-flight create/remove owns this name
|
|
1377
|
+
// gh#41: remote org frames are reconciled by the peerOrg block above —
|
|
1378
|
+
// the local-team cleanup must not remove them.
|
|
1379
|
+
if (!input.teams.some(t => t.name === name) && !remoteFrames.has(name)) model.removeFrame(name)
|
|
1380
|
+
}
|
|
1381
|
+
// An open node panel follows the poll: facts (joins, leaves, team
|
|
1382
|
+
// declarations, peer arrivals) land within one cadence.
|
|
1383
|
+
nodes.refresh()
|
|
1384
|
+
renderStatus(input)
|
|
1385
|
+
// First-run framing: with no saved layout the ring seats are centered on
|
|
1386
|
+
// world (0,0) while the viewport origin is the board's top-left corner,
|
|
1387
|
+
// so half the fleet landed above/left of the view and a healthy board
|
|
1388
|
+
// opened empty. Fit once, the first time there is something to frame.
|
|
1389
|
+
// Not marked dirty — nothing the user arranged changed; a saved layout
|
|
1390
|
+
// (adopted at any point) keeps its own viewport and cancels this.
|
|
1391
|
+
// Once per load, a board that arrives overlapped is shown settled (not
|
|
1392
|
+
// saved: no write without a user action; the next edit persists it).
|
|
1393
|
+
if (!settledOnce && gesture.kind === 'none' && model.nodeIds().length > 0) { settledOnce = true; const b = layoutUndo.capture(); settleBoard(model, new Set(remoteFramesRef.keys())); layoutUndo.commit(b) }
|
|
1394
|
+
if (firstFitPending && gesture.kind === 'none' && model.nodeIds().length > 0) {
|
|
1395
|
+
syncViewSize()
|
|
1396
|
+
if (viewW > 0 && viewH > 0) {
|
|
1397
|
+
firstFitPending = false
|
|
1398
|
+
// Peer cards hang outside the local content bounds — frame them too,
|
|
1399
|
+
// or the federation column opens clipped at the board's edge. Same
|
|
1400
|
+
// framing as 适配 (planning-fit): ≤100%, below the HUD.
|
|
1401
|
+
vp = fitBelowHud(boardBounds(model.contentBounds(), model.allNodes()), viewW, viewH, hudBottomOf(toolbar, status))
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
render()
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
// Same fallback contract as WorldModel.fromState: the card-scaled ring.
|
|
1408
|
+
function seatFor2(id: string): { x: number; y: number } {
|
|
1409
|
+
return planSeatClear(id, model.allNodes().filter(n => n.remote !== true))
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
function adoptExternalLayout(layout: unknown): void {
|
|
1413
|
+
const doc = clampDoc(layout)
|
|
1414
|
+
if (doc === undefined) return
|
|
1415
|
+
layoutDoc = doc
|
|
1416
|
+
const store = deps.viewportStore
|
|
1417
|
+
if (store === undefined) {
|
|
1418
|
+
firstFitPending = false
|
|
1419
|
+
if (gesture.kind === 'none') vp = clampViewport({ ...doc.viewport })
|
|
1420
|
+
} else if (!viewportAdopted && gesture.kind === 'none') {
|
|
1421
|
+
viewportAdopted = true
|
|
1422
|
+
const local = store.get(bucket()) as Partial<LayoutViewport> | null | undefined
|
|
1423
|
+
if (local != null && Number.isFinite(local.x) && Number.isFinite(local.y) && Number.isFinite(local.scale)) {
|
|
1424
|
+
vp = clampViewport({ x: local.x!, y: local.y!, scale: local.scale! })
|
|
1425
|
+
firstFitPending = false
|
|
1426
|
+
} else if (bucket() === 'wide') {
|
|
1427
|
+
vp = clampViewport({ ...doc.viewport })
|
|
1428
|
+
firstFitPending = false
|
|
1429
|
+
} // narrow/medium with no memory of its own: keep firstFitPending, frame on reconcile
|
|
1430
|
+
}
|
|
1431
|
+
renderedRevision = -1 // force a repaint of frames/edges on next render
|
|
1432
|
+
render()
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
function snapshotDoc(): unknown {
|
|
1436
|
+
return buildLayoutDoc(vp, model.positions(), model.frameRects())
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
function setLamp(state: LampState, savedAt?: string): void {
|
|
1440
|
+
lamp.classList.remove('pending', 'saved', 'error')
|
|
1441
|
+
if (state === 'pending') lamp.classList.add('pending')
|
|
1442
|
+
if (state === 'saved') lamp.classList.add('saved')
|
|
1443
|
+
if (state === 'error') lamp.classList.add('error')
|
|
1444
|
+
// Error state is a keyboard-reachable retry control (review P0-2).
|
|
1445
|
+
if (state === 'error') {
|
|
1446
|
+
lamp.tabIndex = 0
|
|
1447
|
+
lamp.setAttribute('role', 'button')
|
|
1448
|
+
lamp.setAttribute('aria-label', '布局保存失败,按回车重试')
|
|
1449
|
+
} else {
|
|
1450
|
+
lamp.removeAttribute('tabindex')
|
|
1451
|
+
lamp.removeAttribute('role')
|
|
1452
|
+
lamp.removeAttribute('aria-label')
|
|
1453
|
+
}
|
|
1454
|
+
const text = state === 'pending' ? '布局待保存…'
|
|
1455
|
+
: state === 'saved' ? `布局已保存 ${savedAt ?? ''}`.trimEnd()
|
|
1456
|
+
: state === 'error' ? '保存失败 · 回车重试'
|
|
1457
|
+
: '布局'
|
|
1458
|
+
lampText.textContent = text
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
function activate(): void {
|
|
1462
|
+
root.style.display = 'block'
|
|
1463
|
+
syncViewSize()
|
|
1464
|
+
applyViewport()
|
|
1465
|
+
root.focus()
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
function deactivate(): void { root.style.display = 'none' }
|
|
1469
|
+
|
|
1470
|
+
function destroy(): void {
|
|
1471
|
+
ac.abort()
|
|
1472
|
+
for (const t of noticeTimers) clearTimeout(t)
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
return {
|
|
1476
|
+
root,
|
|
1477
|
+
reconcile,
|
|
1478
|
+
activate,
|
|
1479
|
+
deactivate,
|
|
1480
|
+
adoptExternalLayout,
|
|
1481
|
+
snapshotDoc,
|
|
1482
|
+
setLamp,
|
|
1483
|
+
notice,
|
|
1484
|
+
destroy,
|
|
1485
|
+
seam: {
|
|
1486
|
+
pointerDown,
|
|
1487
|
+
pointerMove,
|
|
1488
|
+
pointerUp,
|
|
1489
|
+
pointerCancel,
|
|
1490
|
+
wheel,
|
|
1491
|
+
key,
|
|
1492
|
+
keyUp,
|
|
1493
|
+
contextMenu(ev: SeamPointer): void {
|
|
1494
|
+
ev.preventDefault()
|
|
1495
|
+
menus.contextMenuAt(ev.clientX, ev.clientY, ev.target)
|
|
1496
|
+
},
|
|
1497
|
+
},
|
|
1498
|
+
}
|
|
1499
|
+
}
|