@nelsonlongxiang/dsh-open-a2a-net 0.5.14 → 0.6.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +243 -121
- package/README_zh.md +144 -84
- package/assets/nexusDist/assets/index-034h1cNc.js +3 -0
- package/assets/nexusDist/assets/index-PG9EieGX.css +1 -0
- package/assets/nexusDist/index.html +26 -0
- package/assets/stageDist/assets/index-Bj7S2pA1.css +1 -0
- package/assets/stageDist/assets/index-C-28k_o5.js +974 -0
- package/assets/stageDist/assets/interiors-CQGBjffT.png +0 -0
- package/assets/stageDist/assets/office-tileset-CHQT-uYL.png +0 -0
- package/assets/stageDist/index.html +13 -0
- package/lib/a2a-client.js +190 -13
- package/lib/canvas-store.js +218 -0
- package/lib/card.js +25 -0
- package/lib/client/A2aControl.js +45 -137
- package/lib/client/locales.js +16 -40
- package/lib/client.js +164 -467
- package/lib/client.js.map +1 -1
- package/lib/group-store.js +6 -0
- package/lib/idempotency-store.js +291 -0
- package/lib/index.js +4217 -259
- package/lib/joined-store.js +11 -0
- package/lib/layout-store.js +155 -0
- package/lib/peer-store.js +98 -13
- package/lib/receipt-ladder.js +35 -0
- package/lib/receipt.js +102 -0
- package/lib/self-suppress.js +62 -0
- package/lib/session-transcript.js +150 -0
- package/lib/skill/SKILL.md +77 -0
- package/lib/skill/references/discovery.md +38 -0
- package/lib/skill/references/membership.md +50 -0
- package/lib/skill/references/pitfalls.md +42 -0
- package/lib/skill/references/routing.md +43 -0
- package/lib/skill-registration.js +52 -0
- package/lib/stage-mount.js +32 -0
- package/lib/task-ledger.js +540 -0
- package/lib/team-store.js +122 -0
- package/lib/teams-bridge.js +18 -0
- package/lib/transport-caps.js +31 -0
- package/lib/types/a2a-client.d.ts +72 -1
- package/lib/types/canvas-store.d.ts +102 -0
- package/lib/types/client/A2aControl.d.ts +18 -28
- package/lib/types/client/locales.d.ts +1 -1
- package/lib/types/group-store.d.ts +6 -0
- package/lib/types/idempotency-store.d.ts +164 -0
- package/lib/types/index.d.ts +210 -6
- package/lib/types/joined-store.d.ts +9 -0
- package/lib/types/layout-store.d.ts +63 -0
- package/lib/types/peer-store.d.ts +45 -2
- package/lib/types/receipt-ladder.d.ts +25 -0
- package/lib/types/receipt.d.ts +51 -0
- package/lib/types/self-suppress.d.ts +41 -0
- package/lib/types/session-transcript.d.ts +37 -0
- package/lib/types/skill-registration.d.ts +3 -0
- package/lib/types/stage-mount.d.ts +29 -0
- package/lib/types/task-ledger.d.ts +308 -0
- package/lib/types/team-store.d.ts +55 -0
- package/lib/types/teams-bridge.d.ts +112 -0
- package/lib/types/transport-caps.d.ts +29 -0
- package/lib/types/types.d.ts +43 -0
- package/nexus-stage/package-lock.json +2262 -0
- package/nexus-stage/package.json +21 -0
- package/nexus-stage/scripts/__pycache__/mockhost.cpython-312.pyc +0 -0
- package/nexus-stage/scripts/mockhost.py +253 -0
- package/nexus-stage/src/canvas-ops.ts +175 -0
- package/nexus-stage/src/canvas-wire.ts +156 -0
- package/nexus-stage/src/edges.ts +72 -0
- package/nexus-stage/src/fault.ts +56 -0
- package/nexus-stage/src/federation.ts +234 -0
- package/nexus-stage/src/layout-doc.ts +143 -0
- package/nexus-stage/src/layout-wire.ts +158 -0
- package/nexus-stage/src/main.ts +361 -0
- package/nexus-stage/src/planning-menus.ts +352 -0
- package/nexus-stage/src/planning-nodelist.ts +294 -0
- package/nexus-stage/src/planning-types.ts +111 -0
- package/nexus-stage/src/planning-view.ts +1313 -0
- package/nexus-stage/src/planning.css +280 -0
- package/nexus-stage/src/seat.ts +35 -0
- package/nexus-stage/src/stage-name.ts +13 -0
- package/nexus-stage/src/tokens.ts +32 -0
- package/nexus-stage/src/viewport.ts +83 -0
- package/nexus-stage/src/workbench.css +267 -0
- package/nexus-stage/src/workbench.ts +1530 -0
- package/nexus-stage/src/world.ts +509 -0
- package/nexus-stage/tsconfig.json +25 -0
- package/nexus-stage/vite.config.ts +16 -0
- package/package.json +83 -22
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The planning board's popovers: the 建队 name dialog (focus-trapped modal),
|
|
3
|
+
* the node / frame-head context menu (single-level drill-down, full
|
|
4
|
+
* keyboard parity) and the 未入网 dropdown on the toolbar. Split out of
|
|
5
|
+
* planning-view (which keeps gestures, rendering and reconcile); the view
|
|
6
|
+
* hands in the model, its element anchors and the write channel, and asks
|
|
7
|
+
* back only through the returned surface.
|
|
8
|
+
* @module nexus-stage/planning-menus
|
|
9
|
+
*/
|
|
10
|
+
import { reorderOps, type CanvasAction } from './canvas-ops'
|
|
11
|
+
import type { PlanningSession, PlanningTeam } from './planning-types'
|
|
12
|
+
import type { WorldModel } from './world'
|
|
13
|
+
|
|
14
|
+
export type MenuTarget = { kind: 'node'; id: string } | { kind: 'frame'; name: string }
|
|
15
|
+
export type MenuLevel = 'root' | 'join' | 'leave' | 'promote'
|
|
16
|
+
|
|
17
|
+
export interface MenusDeps {
|
|
18
|
+
readonly root: HTMLElement
|
|
19
|
+
/** The toolbar's 未入网 chip: the dropdown anchors to it and it shows the count. */
|
|
20
|
+
readonly netBtn: HTMLButtonElement
|
|
21
|
+
readonly signal: AddEventListenerOptions
|
|
22
|
+
readonly model: WorldModel
|
|
23
|
+
/** Canvas teams from the latest poll. */
|
|
24
|
+
teams(): ReadonlyArray<PlanningTeam>
|
|
25
|
+
/** The card or frame-head element a menu re-focuses on close. */
|
|
26
|
+
anchorFor(target: MenuTarget): HTMLElement | null
|
|
27
|
+
emit(a: CanvasAction): void
|
|
28
|
+
notice(kind: 'error' | 'info', text: string): void
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface PlanningMenus {
|
|
32
|
+
menuItem(label: string, enabled: boolean, onPick: () => void): HTMLButtonElement
|
|
33
|
+
isMenuOpen(): boolean
|
|
34
|
+
isDialogOpen(): boolean
|
|
35
|
+
closeMenu(): void
|
|
36
|
+
openMenu(x: number, y: number, target: MenuTarget, level?: MenuLevel, anchor?: HTMLElement): void
|
|
37
|
+
/** Context menu from a real contextmenu event or the keyboard equivalent. */
|
|
38
|
+
contextMenuAt(x: number, y: number, target: Element | null): void
|
|
39
|
+
openNameDialog(ids: readonly string[]): void
|
|
40
|
+
startCreateFromSelection(): void
|
|
41
|
+
toggleNetMenu(): void
|
|
42
|
+
/** Latest unjoined sessions: refreshes the chip's count and closes an emptied dropdown. */
|
|
43
|
+
setUnjoined(sessions: ReadonlyArray<PlanningSession>): void
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function createPlanningMenus(d: MenusDeps): PlanningMenus {
|
|
47
|
+
const { root, netBtn, signal, model } = d
|
|
48
|
+
|
|
49
|
+
// ── 建队:selection → 命名对话框 → create-team ──
|
|
50
|
+
function selectedByY(): string[] {
|
|
51
|
+
return model
|
|
52
|
+
.selectedIds()
|
|
53
|
+
.map(id => ({ id, y: model.getNode(id)?.y ?? 0 }))
|
|
54
|
+
.sort((a, b) => a.y - b.y)
|
|
55
|
+
.map(r => r.id)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function startCreateFromSelection(): void {
|
|
59
|
+
if (dialog !== null) return
|
|
60
|
+
// Peer cards are not joinable sessions: they never enter 建队 ids.
|
|
61
|
+
const ids = selectedByY().filter(id => model.getNode(id)?.remote !== true)
|
|
62
|
+
if (ids.length < 2) { d.notice('info', '框选至少 2 个会话节点'); return }
|
|
63
|
+
openNameDialog(ids)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let dialog: {
|
|
67
|
+
wrap: HTMLDivElement
|
|
68
|
+
restoreFocus: HTMLElement | null
|
|
69
|
+
} | null = null
|
|
70
|
+
|
|
71
|
+
function openNameDialog(ids: readonly string[]): void {
|
|
72
|
+
const wrap = document.createElement('div')
|
|
73
|
+
wrap.className = 'p-dialog'
|
|
74
|
+
const panel = document.createElement('div')
|
|
75
|
+
panel.className = 'p-dialog-panel'
|
|
76
|
+
panel.setAttribute('role', 'dialog')
|
|
77
|
+
panel.setAttribute('aria-modal', 'true')
|
|
78
|
+
panel.setAttribute('aria-labelledby', 'p-dialog-title')
|
|
79
|
+
const title = document.createElement('div')
|
|
80
|
+
title.id = 'p-dialog-title'
|
|
81
|
+
title.className = 'p-dialog-title'
|
|
82
|
+
title.textContent = `组建团队(${ids.length} 个节点,自上而下即优先级)`
|
|
83
|
+
const input = document.createElement('input')
|
|
84
|
+
input.className = 'p-dialog-input'
|
|
85
|
+
input.maxLength = 40
|
|
86
|
+
input.setAttribute('aria-label', '团队名')
|
|
87
|
+
const err = document.createElement('div')
|
|
88
|
+
err.className = 'p-dialog-err'
|
|
89
|
+
err.setAttribute('aria-live', 'polite')
|
|
90
|
+
const row = document.createElement('div')
|
|
91
|
+
row.className = 'p-dialog-row'
|
|
92
|
+
const okBtn = document.createElement('button')
|
|
93
|
+
okBtn.type = 'button'
|
|
94
|
+
okBtn.textContent = '组建'
|
|
95
|
+
const cancelBtn = document.createElement('button')
|
|
96
|
+
cancelBtn.type = 'button'
|
|
97
|
+
cancelBtn.textContent = '取消'
|
|
98
|
+
row.append(okBtn, cancelBtn)
|
|
99
|
+
panel.append(title, input, err, row)
|
|
100
|
+
wrap.appendChild(panel)
|
|
101
|
+
root.appendChild(wrap)
|
|
102
|
+
|
|
103
|
+
const restoreFocus = document.activeElement as HTMLElement | null
|
|
104
|
+
const close = (): void => {
|
|
105
|
+
wrap.remove()
|
|
106
|
+
dialog = null
|
|
107
|
+
restoreFocus?.focus()
|
|
108
|
+
}
|
|
109
|
+
const nameError = (): string | null => {
|
|
110
|
+
const name = input.value.trim()
|
|
111
|
+
if (name === '' || name.length > 40 || name.includes('/') || /^\d+$/.test(name)) {
|
|
112
|
+
return '队名需 1..40 字,不含“/”,不为纯数字'
|
|
113
|
+
}
|
|
114
|
+
return null
|
|
115
|
+
}
|
|
116
|
+
const confirm = (): void => {
|
|
117
|
+
const problem = nameError()
|
|
118
|
+
if (problem !== null) { err.textContent = problem; input.focus(); return }
|
|
119
|
+
const name = input.value.trim()
|
|
120
|
+
close()
|
|
121
|
+
if (model.getFrame(name) !== undefined) {
|
|
122
|
+
// Duplicate name: host create is idempotent-ok, but the optimistic
|
|
123
|
+
// create-team would WIPE the existing roster and overwrite its saved
|
|
124
|
+
// frame rect. Join instead — same wire result, nothing destroyed.
|
|
125
|
+
d.notice('info', `已加入现有团队「${name}」`)
|
|
126
|
+
d.emit({ type: 'add-member', team: name, ids })
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
d.emit({ type: 'create-team', name, ids, created: true })
|
|
130
|
+
}
|
|
131
|
+
okBtn.addEventListener('click', confirm)
|
|
132
|
+
cancelBtn.addEventListener('click', close)
|
|
133
|
+
input.addEventListener('keydown', (ev) => {
|
|
134
|
+
const e = ev as KeyboardEvent
|
|
135
|
+
if (e.key === 'Enter') { e.preventDefault(); confirm() }
|
|
136
|
+
if (e.key === 'Escape') { e.preventDefault(); close() }
|
|
137
|
+
})
|
|
138
|
+
// Focus trap (aria-modal must not be a false promise): Tab cycles the
|
|
139
|
+
// three focusables; Esc anywhere in the panel cancels.
|
|
140
|
+
wrap.addEventListener('keydown', (ev) => {
|
|
141
|
+
const e = ev as KeyboardEvent
|
|
142
|
+
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); close(); return }
|
|
143
|
+
if (e.key !== 'Tab') return
|
|
144
|
+
e.preventDefault()
|
|
145
|
+
const focusables = Array.from(panel.querySelectorAll<HTMLElement>('input, button'))
|
|
146
|
+
const idx = focusables.indexOf(document.activeElement as HTMLElement)
|
|
147
|
+
const next = e.shiftKey
|
|
148
|
+
? focusables[(idx - 1 + focusables.length) % focusables.length]
|
|
149
|
+
: focusables[(idx + 1) % focusables.length]
|
|
150
|
+
next?.focus()
|
|
151
|
+
}, signal)
|
|
152
|
+
dialog = { wrap, restoreFocus }
|
|
153
|
+
input.focus()
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ── context menu(节点/框头;下钻单层;键盘全等)──
|
|
157
|
+
let menu: HTMLDivElement | null = null
|
|
158
|
+
let menuTarget: MenuTarget | undefined
|
|
159
|
+
let menuLevel: MenuLevel = 'root'
|
|
160
|
+
let menuAnchor: HTMLElement | null = null
|
|
161
|
+
|
|
162
|
+
// ── 未入网会话 dropdown(面板瘦身后迁入的入网面)──
|
|
163
|
+
let netMenu: HTMLDivElement | null = null
|
|
164
|
+
let lastUnjoined: ReadonlyArray<PlanningSession> = []
|
|
165
|
+
function closeNetMenu(): void {
|
|
166
|
+
netMenu?.remove()
|
|
167
|
+
netMenu = null
|
|
168
|
+
}
|
|
169
|
+
function toggleNetMenu(): void {
|
|
170
|
+
if (netMenu !== null) { closeNetMenu(); return }
|
|
171
|
+
if (lastUnjoined.length === 0) return
|
|
172
|
+
const el = document.createElement('div')
|
|
173
|
+
el.className = 'p-menu p-netmenu'
|
|
174
|
+
el.setAttribute('role', 'menu')
|
|
175
|
+
el.setAttribute('aria-label', '未入网会话')
|
|
176
|
+
for (const s of lastUnjoined) {
|
|
177
|
+
el.appendChild(menuItem(`${s.name !== undefined && s.name !== '' ? s.name : s.label} — 入网`, true, () => {
|
|
178
|
+
closeNetMenu()
|
|
179
|
+
d.emit({ type: 'join-network', id: s.id })
|
|
180
|
+
}))
|
|
181
|
+
}
|
|
182
|
+
netMenu = el
|
|
183
|
+
// Menus append to root (not document.body): the .p-menu styles are
|
|
184
|
+
// #dsh-plan-scoped, and root has no transformed ancestor, so fixed
|
|
185
|
+
// positioning stays viewport-relative.
|
|
186
|
+
root.appendChild(el)
|
|
187
|
+
const rect = netBtn.getBoundingClientRect()
|
|
188
|
+
el.style.left = `${String(Math.min(rect.left, window.innerWidth - 220))}px`
|
|
189
|
+
el.style.top = `${String(Math.max(8, rect.top - (lastUnjoined.length * 30 + 10)))}px`
|
|
190
|
+
el.addEventListener('keydown', (ev) => {
|
|
191
|
+
if ((ev as KeyboardEvent).key === 'Escape') {
|
|
192
|
+
ev.stopPropagation()
|
|
193
|
+
closeNetMenu()
|
|
194
|
+
netBtn.focus()
|
|
195
|
+
}
|
|
196
|
+
})
|
|
197
|
+
el.querySelector('button')?.focus()
|
|
198
|
+
// Outside-click dismissal: the dropdown lives at root level, so canvas
|
|
199
|
+
// presses never reach a menu-local handler — and focus leaves with the
|
|
200
|
+
// first outside click, killing the Escape path too (the stuck-dropdown
|
|
201
|
+
// defect the owner hit). The trigger button stays exempt so its click
|
|
202
|
+
// still toggles.
|
|
203
|
+
document.addEventListener('pointerdown', (ev) => {
|
|
204
|
+
if (netMenu === null) return
|
|
205
|
+
const t = ev.target as Element | null
|
|
206
|
+
if (t !== null && (t.closest('.p-netmenu') !== null || t === netBtn || netBtn.contains(t))) return
|
|
207
|
+
closeNetMenu()
|
|
208
|
+
}, signal)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function closeMenu(): void {
|
|
212
|
+
menu?.remove()
|
|
213
|
+
menu = null
|
|
214
|
+
menuTarget = undefined
|
|
215
|
+
menuLevel = 'root'
|
|
216
|
+
// The anchor may have been detached by a poll while the menu was open —
|
|
217
|
+
// focus() on a detached node drops focus to body and the canvas goes deaf.
|
|
218
|
+
if (menuAnchor !== null && menuAnchor.isConnected) menuAnchor.focus()
|
|
219
|
+
else root.focus()
|
|
220
|
+
menuAnchor = null
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function menuItem(label: string, enabled: boolean, onPick: () => void): HTMLButtonElement {
|
|
224
|
+
const b = document.createElement('button')
|
|
225
|
+
b.type = 'button'
|
|
226
|
+
b.setAttribute('role', 'menuitem')
|
|
227
|
+
b.textContent = label
|
|
228
|
+
if (!enabled) { b.disabled = true; b.setAttribute('aria-disabled', 'true') }
|
|
229
|
+
else b.addEventListener('click', () => { closeMenu(); onPick() })
|
|
230
|
+
return b
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function openMenu(x: number, y: number, target: MenuTarget, level: MenuLevel = 'root', anchor?: HTMLElement): void {
|
|
234
|
+
// Re-seed the anchor after a drill-down: closeMenu focuses whatever this
|
|
235
|
+
// menu was anchored to, or keyboard focus falls to body and the canvas
|
|
236
|
+
// goes deaf.
|
|
237
|
+
if (anchor !== undefined) menuAnchor = anchor
|
|
238
|
+
if (menuAnchor === null) menuAnchor = d.anchorFor(target)
|
|
239
|
+
menu?.remove()
|
|
240
|
+
menuTarget = target
|
|
241
|
+
menuLevel = level
|
|
242
|
+
const el = document.createElement('div')
|
|
243
|
+
el.className = 'p-menu'
|
|
244
|
+
el.setAttribute('role', 'menu')
|
|
245
|
+
el.setAttribute('aria-label', '团队操作')
|
|
246
|
+
let items: HTMLButtonElement[] = []
|
|
247
|
+
if (target.kind === 'frame') {
|
|
248
|
+
items = [menuItem(`解散团队「${target.name}」`, true, () => d.emit({ type: 'remove-team', name: target.name }))]
|
|
249
|
+
} else {
|
|
250
|
+
const id = target.id
|
|
251
|
+
if (level === 'root') {
|
|
252
|
+
const create = menuItem('组成团队…', model.selectedIds().length >= 2, () => openNameDialog(selectedByY()))
|
|
253
|
+
create.title = '先框选或 Shift 加选至少 2 个节点'
|
|
254
|
+
items.push(create)
|
|
255
|
+
if (d.teams().length > 0) {
|
|
256
|
+
items.push(menuItem('加入团队 ▸', true, () => openMenu(x, y, target, 'join')))
|
|
257
|
+
const teamsOf = model.getNode(id)?.memberships.map(m => m.team) ?? []
|
|
258
|
+
if (teamsOf.length > 0) {
|
|
259
|
+
items.push(menuItem('置顶路由 ▸', true, () => openMenu(x, y, target, 'promote')))
|
|
260
|
+
items.push(menuItem('离队 ▸', true, () => openMenu(x, y, target, 'leave')))
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
// Network-level leave (distinct from canvas-team membership): the
|
|
264
|
+
// panel-slim migration moved the join/leave toggle here. Remote
|
|
265
|
+
// nodes are read-only entries — never a leave target.
|
|
266
|
+
if (model.getNode(id)?.remote !== true) {
|
|
267
|
+
items.push(menuItem('退网', true, () => d.emit({ type: 'leave-network', id })))
|
|
268
|
+
}
|
|
269
|
+
} else {
|
|
270
|
+
items.push(menuItem('‹ 返回', true, () => openMenu(x, y, target, 'root')))
|
|
271
|
+
for (const t of d.teams()) {
|
|
272
|
+
if (level === 'join') {
|
|
273
|
+
const already = (model.getNode(id)?.memberships.some(m => m.team === t.name)) ?? true
|
|
274
|
+
items.push(menuItem(already ? `${t.name}(已加入)` : t.name, !already, () => d.emit({ type: 'add-member', team: t.name, ids: [id] })))
|
|
275
|
+
} else if (level === 'leave') {
|
|
276
|
+
const member = (model.getNode(id)?.memberships.some(m => m.team === t.name)) ?? false
|
|
277
|
+
items.push(menuItem(t.name, member, () => d.emit({ type: 'remove-member', team: t.name, ids: [id] })))
|
|
278
|
+
} else {
|
|
279
|
+
const member = (model.getNode(id)?.memberships.some(m => m.team === t.name)) ?? false
|
|
280
|
+
items.push(menuItem(t.name, member, () => {
|
|
281
|
+
const current = model.teamMemberIds(t.name)
|
|
282
|
+
const desired = [id, ...current.filter(x => x !== id)]
|
|
283
|
+
d.emit({ type: 'reorder', team: t.name, ops: reorderOps(current, desired) })
|
|
284
|
+
}))
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
el.append(...items)
|
|
290
|
+
el.style.left = `${Math.min(x, window.innerWidth - 200)}px`
|
|
291
|
+
const menuH = items.length * 30 + 8
|
|
292
|
+
el.style.top = `${Math.max(8, Math.min(y, window.innerHeight - menuH - 8))}px`
|
|
293
|
+
el.addEventListener('keydown', (ev) => {
|
|
294
|
+
const e = ev as KeyboardEvent
|
|
295
|
+
const buttons = Array.from(el.querySelectorAll<HTMLButtonElement>('[role=menuitem]'))
|
|
296
|
+
const idx = buttons.indexOf(document.activeElement as HTMLButtonElement)
|
|
297
|
+
if (e.key === 'ArrowDown') { e.preventDefault(); buttons[(idx + 1 + buttons.length) % buttons.length]?.focus() }
|
|
298
|
+
else if (e.key === 'ArrowUp') { e.preventDefault(); buttons[(idx - 1 + buttons.length) % buttons.length]?.focus() }
|
|
299
|
+
else if (e.key === 'Home') { e.preventDefault(); buttons[0]?.focus() }
|
|
300
|
+
else if (e.key === 'End') { e.preventDefault(); buttons[buttons.length - 1]?.focus() }
|
|
301
|
+
else if (e.key === 'Escape') {
|
|
302
|
+
e.preventDefault()
|
|
303
|
+
e.stopPropagation() // drill-back is menu-internal; root must not double-close
|
|
304
|
+
if (menuLevel !== 'root') openMenu(x, y, target, 'root')
|
|
305
|
+
else closeMenu()
|
|
306
|
+
}
|
|
307
|
+
})
|
|
308
|
+
root.appendChild(el)
|
|
309
|
+
menu = el
|
|
310
|
+
items[0]?.focus()
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Context menu from a real contextmenu event or the keyboard equivalent. */
|
|
314
|
+
function contextMenuAt(x: number, y: number, target: Element | null): void {
|
|
315
|
+
if (dialog !== null) return
|
|
316
|
+
// Peer endpoints are read-only entries: no roster menu for them.
|
|
317
|
+
const probeNode = target?.closest<HTMLElement>('.p-node') ?? null
|
|
318
|
+
if (probeNode !== null && model.getNode(probeNode.dataset.id ?? '')?.remote === true) return
|
|
319
|
+
const nodeEl = probeNode
|
|
320
|
+
const headEl = target?.closest<HTMLElement>('.p-frame-head') ?? null
|
|
321
|
+
if (nodeEl !== null) {
|
|
322
|
+
const id = nodeEl.dataset.id ?? ''
|
|
323
|
+
if (!model.isSelected(id)) model.setSelection([id])
|
|
324
|
+
menuAnchor = nodeEl
|
|
325
|
+
openMenu(x, y, { kind: 'node', id })
|
|
326
|
+
} else if (headEl !== null) {
|
|
327
|
+
menuAnchor = headEl
|
|
328
|
+
openMenu(x, y, { kind: 'frame', name: headEl.dataset.frame ?? '' })
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function setUnjoined(sessions: ReadonlyArray<PlanningSession>): void {
|
|
333
|
+
lastUnjoined = sessions
|
|
334
|
+
netBtn.textContent = `未入网${lastUnjoined.length > 0 ? `(${String(lastUnjoined.length)})` : ''}`
|
|
335
|
+
netBtn.disabled = lastUnjoined.length === 0
|
|
336
|
+
netBtn.setAttribute('aria-disabled', String(lastUnjoined.length === 0))
|
|
337
|
+
if (lastUnjoined.length === 0) closeNetMenu()
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return {
|
|
341
|
+
menuItem,
|
|
342
|
+
isMenuOpen: () => menu !== null,
|
|
343
|
+
isDialogOpen: () => dialog !== null,
|
|
344
|
+
closeMenu,
|
|
345
|
+
openMenu,
|
|
346
|
+
contextMenuAt,
|
|
347
|
+
openNameDialog,
|
|
348
|
+
startCreateFromSelection,
|
|
349
|
+
toggleNetMenu,
|
|
350
|
+
setUnjoined,
|
|
351
|
+
}
|
|
352
|
+
}
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The 节点 drawer (network-membership-display): the canvas shows teamed
|
|
3
|
+
* nodes only, so every other session — teamless joined, unjoined, and each
|
|
4
|
+
* peer's published nodes — is listed and managed here, switched by host.
|
|
5
|
+
* Split out of planning-view; it reads the latest poll facts through
|
|
6
|
+
* `facts()` and writes only through the view's action channel.
|
|
7
|
+
* @module nexus-stage/planning-nodelist
|
|
8
|
+
*/
|
|
9
|
+
import type { CanvasAction } from './canvas-ops'
|
|
10
|
+
import type { RemoteTeamRow } from './federation'
|
|
11
|
+
import type { PlanningSession, PlanningTeam } from './planning-types'
|
|
12
|
+
import type { WorldModel } from './world'
|
|
13
|
+
|
|
14
|
+
export interface NodeListFacts {
|
|
15
|
+
readonly sessions: ReadonlyArray<PlanningSession>
|
|
16
|
+
readonly canvasMemberIds: ReadonlySet<string>
|
|
17
|
+
/** Remote team rows grouped by peer host (federation.groupRemoteTeams). */
|
|
18
|
+
readonly remoteTeams: ReadonlyMap<string, RemoteTeamRow[]>
|
|
19
|
+
readonly teams: ReadonlyArray<PlanningTeam>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface NodeListDeps {
|
|
23
|
+
readonly root: HTMLElement
|
|
24
|
+
/** The toolbar's 节点 button: the drawer anchors to it and returns focus there. */
|
|
25
|
+
readonly trigger: HTMLButtonElement
|
|
26
|
+
readonly signal: AddEventListenerOptions
|
|
27
|
+
readonly model: WorldModel
|
|
28
|
+
facts(): NodeListFacts
|
|
29
|
+
menuItem(label: string, enabled: boolean, onPick: () => void): HTMLButtonElement
|
|
30
|
+
emit(a: CanvasAction): void
|
|
31
|
+
openNameDialog(ids: readonly string[]): void
|
|
32
|
+
/** Center the board on one canvas node (closes the drawer). */
|
|
33
|
+
locate(id: string): void
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface PlanningNodeList {
|
|
37
|
+
toggle(): void
|
|
38
|
+
close(): void
|
|
39
|
+
/** Re-render an open drawer from the latest facts (no-op when closed). */
|
|
40
|
+
refresh(): void
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function createNodeList(d: NodeListDeps): PlanningNodeList {
|
|
44
|
+
const { root, trigger: nodesBtn, signal, model } = d
|
|
45
|
+
|
|
46
|
+
// ── 节点列表 drawer(组队节点上画布;单一节点按 host/对等节点在列表管理)──
|
|
47
|
+
let nodePanel: HTMLDivElement | null = null
|
|
48
|
+
let nodeHost = '' // '' = 本机;否则为 d.facts().remoteTeams 的 host:port 键
|
|
49
|
+
let nodeJoinMenu: HTMLDivElement | null = null
|
|
50
|
+
|
|
51
|
+
function closeNodeJoinMenu(): void {
|
|
52
|
+
nodeJoinMenu?.remove()
|
|
53
|
+
nodeJoinMenu = null
|
|
54
|
+
}
|
|
55
|
+
function closeNodePanel(): void {
|
|
56
|
+
closeNodeJoinMenu()
|
|
57
|
+
nodePanel?.remove()
|
|
58
|
+
nodePanel = null
|
|
59
|
+
}
|
|
60
|
+
function toggleNodePanel(): void {
|
|
61
|
+
if (nodePanel !== null) { closeNodePanel(); return }
|
|
62
|
+
const el = document.createElement('div')
|
|
63
|
+
el.className = 'p-menu p-nodelist'
|
|
64
|
+
el.setAttribute('role', 'dialog')
|
|
65
|
+
el.setAttribute('aria-label', '节点清单')
|
|
66
|
+
nodePanel = el
|
|
67
|
+
root.appendChild(el)
|
|
68
|
+
const rect = nodesBtn.getBoundingClientRect()
|
|
69
|
+
el.style.left = `${String(Math.min(rect.left, window.innerWidth - 340))}px`
|
|
70
|
+
el.style.top = `${String(Math.max(8, rect.top - 320))}px`
|
|
71
|
+
el.addEventListener('keydown', (ev) => {
|
|
72
|
+
if ((ev as KeyboardEvent).key === 'Escape') {
|
|
73
|
+
ev.stopPropagation()
|
|
74
|
+
closeNodePanel()
|
|
75
|
+
nodesBtn.focus()
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
renderNodePanel()
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Rebuild the panel's host selector + body from the latest poll facts. */
|
|
82
|
+
function renderNodePanel(): void {
|
|
83
|
+
const el = nodePanel
|
|
84
|
+
if (el === null) return
|
|
85
|
+
closeNodeJoinMenu()
|
|
86
|
+
el.textContent = ''
|
|
87
|
+
const head = document.createElement('div')
|
|
88
|
+
head.className = 'p-nodelist-head'
|
|
89
|
+
const headLabel = document.createElement('span')
|
|
90
|
+
headLabel.className = 'p-nodelist-title'
|
|
91
|
+
headLabel.textContent = '节点'
|
|
92
|
+
const sel = document.createElement('select')
|
|
93
|
+
sel.className = 'p-nodesel'
|
|
94
|
+
sel.setAttribute('aria-label', '选择 host 或对等节点')
|
|
95
|
+
const localOpt = document.createElement('option')
|
|
96
|
+
localOpt.value = ''
|
|
97
|
+
localOpt.textContent = '本机'
|
|
98
|
+
sel.appendChild(localOpt)
|
|
99
|
+
for (const [host, rows] of d.facts().remoteTeams) {
|
|
100
|
+
if (host === '') continue
|
|
101
|
+
const opt = document.createElement('option')
|
|
102
|
+
opt.value = host
|
|
103
|
+
opt.textContent = rows[0]?.origin !== undefined && rows[0].origin !== '' ? `${rows[0].origin}(${host})` : host
|
|
104
|
+
sel.appendChild(opt)
|
|
105
|
+
}
|
|
106
|
+
if (nodeHost !== '' && !d.facts().remoteTeams.has(nodeHost)) nodeHost = ''
|
|
107
|
+
sel.value = nodeHost
|
|
108
|
+
sel.addEventListener('change', () => { nodeHost = sel.value; renderNodePanel() })
|
|
109
|
+
head.append(headLabel, sel)
|
|
110
|
+
const body = document.createElement('div')
|
|
111
|
+
body.className = 'p-nodelist-body'
|
|
112
|
+
el.append(head, body)
|
|
113
|
+
if (nodeHost === '') renderLocalNodeGroups(body)
|
|
114
|
+
else renderRemoteNodeGroups(body, d.facts().remoteTeams.get(nodeHost) ?? [])
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function nodeRowDot(live: boolean | undefined, joined: boolean): HTMLSpanElement {
|
|
118
|
+
const dot = document.createElement('span')
|
|
119
|
+
dot.className = 'p-nodedot' + (joined !== true ? ' off' : live === false ? ' cold' : '')
|
|
120
|
+
return dot
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function listBtn(label: string, onPick: () => void): HTMLButtonElement {
|
|
124
|
+
const b = document.createElement('button')
|
|
125
|
+
b.type = 'button'
|
|
126
|
+
b.className = 'p-listbtn'
|
|
127
|
+
b.textContent = label
|
|
128
|
+
b.addEventListener('click', (ev) => { ev.stopPropagation(); onPick() })
|
|
129
|
+
return b
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function groupTitle(text: string, note?: string): HTMLDivElement {
|
|
133
|
+
const t = document.createElement('div')
|
|
134
|
+
t.className = 'p-nodelist-group'
|
|
135
|
+
t.textContent = text
|
|
136
|
+
if (note !== undefined) {
|
|
137
|
+
const n = document.createElement('span')
|
|
138
|
+
n.className = 'p-nodelist-note'
|
|
139
|
+
n.textContent = note
|
|
140
|
+
t.appendChild(n)
|
|
141
|
+
}
|
|
142
|
+
return t
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** 入队▸ submenu: existing canvas teams, plus 新团队… for the bootstrap case. */
|
|
146
|
+
function openNodeJoinMenu(anchor: HTMLElement, id: string): void {
|
|
147
|
+
closeNodeJoinMenu()
|
|
148
|
+
const el = document.createElement('div')
|
|
149
|
+
el.className = 'p-menu'
|
|
150
|
+
el.setAttribute('role', 'menu')
|
|
151
|
+
el.setAttribute('aria-label', '加入团队')
|
|
152
|
+
for (const t of d.facts().teams) {
|
|
153
|
+
const already = (model.getNode(id)?.memberships.some(m => m.team === t.name)) ?? false
|
|
154
|
+
el.appendChild(d.menuItem(already ? `${t.name}(已加入)` : t.name, !already, () => {
|
|
155
|
+
closeNodePanel()
|
|
156
|
+
d.emit({ type: 'add-member', team: t.name, ids: [id] })
|
|
157
|
+
}))
|
|
158
|
+
}
|
|
159
|
+
el.appendChild(d.menuItem('新团队…', true, () => {
|
|
160
|
+
closeNodePanel()
|
|
161
|
+
d.openNameDialog([id])
|
|
162
|
+
}))
|
|
163
|
+
nodeJoinMenu = el
|
|
164
|
+
root.appendChild(el)
|
|
165
|
+
const rect = anchor.getBoundingClientRect()
|
|
166
|
+
el.style.left = `${String(Math.min(rect.right + 4, window.innerWidth - 180))}px`
|
|
167
|
+
el.style.top = `${String(Math.max(8, rect.top - 60))}px`
|
|
168
|
+
el.addEventListener('keydown', (ev) => {
|
|
169
|
+
if ((ev as KeyboardEvent).key === 'Escape') { ev.stopPropagation(); closeNodeJoinMenu(); anchor.focus() }
|
|
170
|
+
})
|
|
171
|
+
el.querySelector('button')?.focus()
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function renderLocalNodeGroups(body: HTMLDivElement): void {
|
|
175
|
+
const teamed = new Set<string>(d.facts().canvasMemberIds)
|
|
176
|
+
for (const s of d.facts().sessions) if (s.teams !== undefined && s.teams.length > 0) teamed.add(s.id)
|
|
177
|
+
const unteamed = d.facts().sessions.filter(s => s.joined === true && !teamed.has(s.id))
|
|
178
|
+
const unjoined = d.facts().sessions.filter(s => s.joined !== true)
|
|
179
|
+
const teamedRows = d.facts().sessions.filter(s => s.joined === true && teamed.has(s.id))
|
|
180
|
+
|
|
181
|
+
body.appendChild(groupTitle(`未组队 (${String(unteamed.length)})`, '无网络能力'))
|
|
182
|
+
for (const s of unteamed) {
|
|
183
|
+
const row = document.createElement('div')
|
|
184
|
+
row.className = 'p-noderow'
|
|
185
|
+
const name = document.createElement('span')
|
|
186
|
+
name.className = 'p-nodename'
|
|
187
|
+
name.textContent = s.name !== undefined && s.name !== '' ? s.name : s.label
|
|
188
|
+
name.title = s.team
|
|
189
|
+
const tag = document.createElement('span')
|
|
190
|
+
tag.className = 'p-nonet'
|
|
191
|
+
tag.textContent = '无网络能力'
|
|
192
|
+
const joinBtn = listBtn('入队▸', () => openNodeJoinMenu(joinBtn, s.id))
|
|
193
|
+
row.append(nodeRowDot(s.live, true), name, tag, joinBtn,
|
|
194
|
+
listBtn('退网', () => { closeNodePanel(); d.emit({ type: 'leave-network', id: s.id }) }))
|
|
195
|
+
body.appendChild(row)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
body.appendChild(groupTitle(`未入网 (${String(unjoined.length)})`))
|
|
199
|
+
for (const s of unjoined) {
|
|
200
|
+
const row = document.createElement('div')
|
|
201
|
+
row.className = 'p-noderow'
|
|
202
|
+
const name = document.createElement('span')
|
|
203
|
+
name.className = 'p-nodename'
|
|
204
|
+
name.textContent = s.name !== undefined && s.name !== '' ? s.name : s.label
|
|
205
|
+
name.title = s.id
|
|
206
|
+
row.append(nodeRowDot(s.live, false), name,
|
|
207
|
+
listBtn('入网', () => { closeNodePanel(); d.emit({ type: 'join-network', id: s.id }) }))
|
|
208
|
+
body.appendChild(row)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
body.appendChild(groupTitle(`已组队 (${String(teamedRows.length)})`, '画布展示'))
|
|
212
|
+
for (const s of teamedRows) {
|
|
213
|
+
const row = document.createElement('div')
|
|
214
|
+
row.className = 'p-noderow'
|
|
215
|
+
const name = document.createElement('span')
|
|
216
|
+
name.className = 'p-nodename'
|
|
217
|
+
name.textContent = s.name !== undefined && s.name !== '' ? s.name : s.label
|
|
218
|
+
name.title = s.team
|
|
219
|
+
const canvasCount = model.getNode(s.id)?.memberships.length ?? 0
|
|
220
|
+
const rosterCount = s.teams?.length ?? 0
|
|
221
|
+
const badge = document.createElement('span')
|
|
222
|
+
badge.className = 'p-teamcount'
|
|
223
|
+
badge.textContent = `队×${String(Math.max(canvasCount, 0) + rosterCount)}`
|
|
224
|
+
row.append(nodeRowDot(s.live, true), name, badge,
|
|
225
|
+
listBtn('定位', () => { d.locate(s.id) }))
|
|
226
|
+
body.appendChild(row)
|
|
227
|
+
}
|
|
228
|
+
if (d.facts().sessions.length === 0) {
|
|
229
|
+
const empty = document.createElement('div')
|
|
230
|
+
empty.className = 'p-nodelist-empty'
|
|
231
|
+
empty.textContent = '无会话节点'
|
|
232
|
+
body.appendChild(empty)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function renderRemoteNodeGroups(body: HTMLDivElement, rows: ReadonlyArray<RemoteTeamRow>): void {
|
|
237
|
+
const unteamed = rows.filter(r => r.teams === undefined || r.teams.length === 0)
|
|
238
|
+
const teamedRows = rows.filter(r => r.teams !== undefined && r.teams.length > 0)
|
|
239
|
+
body.appendChild(groupTitle(`未组队 (${String(unteamed.length)})`, '无网络能力 · 只读'))
|
|
240
|
+
for (const r of unteamed) {
|
|
241
|
+
const row = document.createElement('div')
|
|
242
|
+
row.className = 'p-noderow'
|
|
243
|
+
const name = document.createElement('span')
|
|
244
|
+
name.className = 'p-nodename'
|
|
245
|
+
name.textContent = r.name !== undefined && r.name !== '' ? r.name : r.team
|
|
246
|
+
name.title = r.team
|
|
247
|
+
const tag = document.createElement('span')
|
|
248
|
+
tag.className = 'p-nonet'
|
|
249
|
+
tag.textContent = '无网络能力'
|
|
250
|
+
row.append(nodeRowDot(true, true), name, tag)
|
|
251
|
+
body.appendChild(row)
|
|
252
|
+
}
|
|
253
|
+
body.appendChild(groupTitle(`已组队 (${String(teamedRows.length)})`, '只读'))
|
|
254
|
+
for (const r of teamedRows) {
|
|
255
|
+
const row = document.createElement('div')
|
|
256
|
+
row.className = 'p-noderow'
|
|
257
|
+
const name = document.createElement('span')
|
|
258
|
+
name.className = 'p-nodename'
|
|
259
|
+
name.textContent = r.name !== undefined && r.name !== '' ? r.name : r.team
|
|
260
|
+
name.title = r.team
|
|
261
|
+
const badge = document.createElement('span')
|
|
262
|
+
badge.className = 'p-teamcount'
|
|
263
|
+
badge.textContent = `队×${String(r.teams?.length ?? 0)}`
|
|
264
|
+
row.append(nodeRowDot(true, true), name, badge)
|
|
265
|
+
body.appendChild(row)
|
|
266
|
+
}
|
|
267
|
+
if (rows.length === 0) {
|
|
268
|
+
const empty = document.createElement('div')
|
|
269
|
+
empty.className = 'p-nodelist-empty'
|
|
270
|
+
empty.textContent = '该节点未发布会话'
|
|
271
|
+
body.appendChild(empty)
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Outside-click dismissal for the node panel: the popover appends to root
|
|
276
|
+
// (scoped .p-menu styles apply; no transformed ancestor), so dismissal
|
|
277
|
+
// rides a document listener — the canvas pointer seam never sees presses
|
|
278
|
+
// on body-level chrome. The panel, its 入队 submenu, and the trigger are
|
|
279
|
+
// exempt.
|
|
280
|
+
document.addEventListener('pointerdown', (ev) => {
|
|
281
|
+
if (nodePanel === null) return
|
|
282
|
+
const t = ev.target as Element | null
|
|
283
|
+
if (t === null) { closeNodePanel(); return }
|
|
284
|
+
if (t.closest('.p-nodelist') !== null || t.closest('.p-menu') !== null) return
|
|
285
|
+
if (t === nodesBtn || nodesBtn.contains(t)) return
|
|
286
|
+
closeNodePanel()
|
|
287
|
+
}, signal)
|
|
288
|
+
|
|
289
|
+
return {
|
|
290
|
+
toggle: toggleNodePanel,
|
|
291
|
+
close: closeNodePanel,
|
|
292
|
+
refresh: () => { if (nodePanel !== null) renderNodePanel() },
|
|
293
|
+
}
|
|
294
|
+
}
|