@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,422 +1,422 @@
|
|
|
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 { rosterFrameName, rosterFrameTitle } from './planning-roster'
|
|
12
|
-
import type { PlanningSession, PlanningTeam } from './planning-types'
|
|
13
|
-
import type { WorldModel } from './world'
|
|
14
|
-
|
|
15
|
-
export type MenuTarget = { kind: 'node'; id: string } | { kind: 'frame'; name: string }
|
|
16
|
-
export type MenuLevel = 'root' | 'join' | 'leave' | 'promote'
|
|
17
|
-
|
|
18
|
-
export interface MenusDeps {
|
|
19
|
-
readonly root: HTMLElement
|
|
20
|
-
/** The toolbar's 未入网 chip: the dropdown anchors to it and it shows the count. */
|
|
21
|
-
readonly netBtn: HTMLButtonElement
|
|
22
|
-
readonly signal: AddEventListenerOptions
|
|
23
|
-
readonly model: WorldModel
|
|
24
|
-
/** Canvas teams from the latest poll. */
|
|
25
|
-
teams(): ReadonlyArray<PlanningTeam>
|
|
26
|
-
joined(id: string): boolean
|
|
27
|
-
isRoster(frame: string): boolean
|
|
28
|
-
/** The card or frame-head element a menu re-focuses on close. */
|
|
29
|
-
anchorFor(target: MenuTarget): HTMLElement | null
|
|
30
|
-
emit(a: CanvasAction): void
|
|
31
|
-
notice(kind: 'error' | 'info', text: string): void
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export interface PlanningMenus {
|
|
35
|
-
menuItem(label: string, enabled: boolean, onPick: () => void): HTMLButtonElement
|
|
36
|
-
isMenuOpen(): boolean
|
|
37
|
-
isDialogOpen(): boolean
|
|
38
|
-
closeMenu(): void
|
|
39
|
-
openMenu(x: number, y: number, target: MenuTarget, level?: MenuLevel, anchor?: HTMLElement): void
|
|
40
|
-
/** Context menu from a real contextmenu event or the keyboard equivalent. */
|
|
41
|
-
contextMenuAt(x: number, y: number, target: Element | null): void
|
|
42
|
-
openNameDialog(ids: readonly string[]): void
|
|
43
|
-
startCreateFromSelection(): void
|
|
44
|
-
toggleNetMenu(): void
|
|
45
|
-
/** Latest unjoined sessions: refreshes the chip's count and closes an emptied dropdown. */
|
|
46
|
-
setUnjoined(sessions: ReadonlyArray<PlanningSession>): void
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export function createPlanningMenus(d: MenusDeps): PlanningMenus {
|
|
50
|
-
const { root, netBtn, signal, model } = d
|
|
51
|
-
const titleOf = (frame: string): string => d.isRoster(frame) ? rosterFrameTitle(frame) : frame
|
|
52
|
-
|
|
53
|
-
// ── 建队:selection → 命名对话框 → create-team ──
|
|
54
|
-
function selectedByY(): string[] {
|
|
55
|
-
return model
|
|
56
|
-
.selectedIds()
|
|
57
|
-
.map(id => ({ id, y: model.getNode(id)?.y ?? 0 }))
|
|
58
|
-
.sort((a, b) => a.y - b.y)
|
|
59
|
-
.map(r => r.id)
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function startCreateFromSelection(): void {
|
|
63
|
-
if (dialog !== null) return
|
|
64
|
-
// gh#20 W-B: a mixed local+remote selection composes a cross-host team —
|
|
65
|
-
// peers ride their zone/id8 handle at the server boundary. A peer
|
|
66
|
-
// without a handle yet (no remote team rows) cannot join; drop it and
|
|
67
|
-
// let the ≥2 floor judge the rest.
|
|
68
|
-
const ids = selectedByY().filter(id => {
|
|
69
|
-
const node = model.getNode(id)
|
|
70
|
-
if (node === undefined) return false
|
|
71
|
-
return node.remote !== true || (node.remoteHandle !== undefined && node.remoteHandle !== '')
|
|
72
|
-
})
|
|
73
|
-
if (ids.length < 2) { d.notice('info', '框选至少 2 个会话节点'); return }
|
|
74
|
-
openNameDialog(ids)
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
let dialog: {
|
|
78
|
-
wrap: HTMLDivElement
|
|
79
|
-
restoreFocus: HTMLElement | null
|
|
80
|
-
} | null = null
|
|
81
|
-
|
|
82
|
-
function openNameDialog(ids: readonly string[]): void {
|
|
83
|
-
const wrap = document.createElement('div')
|
|
84
|
-
wrap.className = 'p-dialog'
|
|
85
|
-
const panel = document.createElement('div')
|
|
86
|
-
panel.className = 'p-dialog-panel'
|
|
87
|
-
panel.setAttribute('role', 'dialog')
|
|
88
|
-
panel.setAttribute('aria-modal', 'true')
|
|
89
|
-
panel.setAttribute('aria-labelledby', 'p-dialog-title')
|
|
90
|
-
const title = document.createElement('div')
|
|
91
|
-
title.id = 'p-dialog-title'
|
|
92
|
-
title.className = 'p-dialog-title'
|
|
93
|
-
title.textContent = `组建团队(${ids.length} 个节点,自上而下即优先级)`
|
|
94
|
-
// Team type: a visible two-way toggle (a bare <select> read as a label and roster went unnoticed).
|
|
95
|
-
const kind = { value: 'canvas' as 'canvas' | 'roster' }
|
|
96
|
-
const kindGroup = document.createElement('div')
|
|
97
|
-
kindGroup.className = 'p-dialog-kind'
|
|
98
|
-
kindGroup.setAttribute('role', 'radiogroup')
|
|
99
|
-
kindGroup.setAttribute('aria-label', '团队类型')
|
|
100
|
-
const kindHint = document.createElement('div')
|
|
101
|
-
kindHint.className = 'p-dialog-hint'
|
|
102
|
-
const KIND_HINT = {
|
|
103
|
-
canvas: 'canvas 队:画布上的协作组,可以加入远端成员。',
|
|
104
|
-
roster: 'roster 网络队:只收本机已入网的会话;队名会写进属主的精确允许名单。',
|
|
105
|
-
} as const
|
|
106
|
-
const kindButtons = (['canvas', 'roster'] as const).map(value => {
|
|
107
|
-
const b = document.createElement('button')
|
|
108
|
-
b.type = 'button'
|
|
109
|
-
b.dataset.kind = value
|
|
110
|
-
b.setAttribute('role', 'radio')
|
|
111
|
-
b.textContent = value === 'canvas' ? 'canvas 队' : 'roster 网络队'
|
|
112
|
-
b.addEventListener('click', () => setKind(value))
|
|
113
|
-
kindGroup.appendChild(b)
|
|
114
|
-
return b
|
|
115
|
-
})
|
|
116
|
-
function setKind(value: 'canvas' | 'roster'): void {
|
|
117
|
-
kind.value = value
|
|
118
|
-
for (const b of kindButtons) {
|
|
119
|
-
const on = b.dataset.kind === value
|
|
120
|
-
b.setAttribute('aria-checked', String(on))
|
|
121
|
-
b.tabIndex = on ? 0 : -1
|
|
122
|
-
}
|
|
123
|
-
kindHint.textContent = KIND_HINT[value]
|
|
124
|
-
err.textContent = ''
|
|
125
|
-
}
|
|
126
|
-
// Arrow keys move between the two options (radiogroup pattern).
|
|
127
|
-
kindGroup.addEventListener('keydown', (ev) => {
|
|
128
|
-
const e = ev as KeyboardEvent
|
|
129
|
-
if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) return
|
|
130
|
-
e.preventDefault()
|
|
131
|
-
setKind(kind.value === 'canvas' ? 'roster' : 'canvas')
|
|
132
|
-
kindButtons.find(b => b.dataset.kind === kind.value)?.focus()
|
|
133
|
-
})
|
|
134
|
-
const input = document.createElement('input')
|
|
135
|
-
input.className = 'p-dialog-input'
|
|
136
|
-
input.maxLength = 40
|
|
137
|
-
input.setAttribute('aria-label', '团队名')
|
|
138
|
-
const err = document.createElement('div')
|
|
139
|
-
err.className = 'p-dialog-err'
|
|
140
|
-
err.setAttribute('aria-live', 'polite')
|
|
141
|
-
const row = document.createElement('div')
|
|
142
|
-
row.className = 'p-dialog-row'
|
|
143
|
-
const okBtn = document.createElement('button')
|
|
144
|
-
okBtn.type = 'button'
|
|
145
|
-
okBtn.textContent = '组建'
|
|
146
|
-
const cancelBtn = document.createElement('button')
|
|
147
|
-
cancelBtn.type = 'button'
|
|
148
|
-
cancelBtn.textContent = '取消'
|
|
149
|
-
row.append(okBtn, cancelBtn)
|
|
150
|
-
setKind('canvas')
|
|
151
|
-
panel.append(title, kindGroup, kindHint, input, err, row)
|
|
152
|
-
wrap.appendChild(panel)
|
|
153
|
-
root.appendChild(wrap)
|
|
154
|
-
|
|
155
|
-
const restoreFocus = document.activeElement as HTMLElement | null
|
|
156
|
-
const close = (): void => {
|
|
157
|
-
wrap.remove()
|
|
158
|
-
dialog = null
|
|
159
|
-
restoreFocus?.focus()
|
|
160
|
-
}
|
|
161
|
-
const nameError = (): string | null => {
|
|
162
|
-
const name = input.value.trim()
|
|
163
|
-
if (name === '' || name.length > 40 || name.includes('/') || name.startsWith('roster:') || (kind.value === 'roster' && name.includes('*')) || /^\d+$/.test(name)) return '队名需 1..40 字,不含“/”、通配符或保留前缀,不为纯数字'
|
|
164
|
-
if (kind.value === 'roster' && model.getFrame(name) !== undefined) return '同名 canvas 队已存在,不能创建 roster 队'
|
|
165
|
-
if (kind.value === 'roster' && model.getFrame(rosterFrameName(name)) !== undefined && !d.isRoster(rosterFrameName(name))) return 'roster 框名已被 canvas 或镜像队占用'
|
|
166
|
-
if (kind.value === 'canvas' && model.getFrame(rosterFrameName(name)) !== undefined) return '同名 roster 队已存在,不能创建 canvas 队'
|
|
167
|
-
if (kind.value === 'roster' && ids.some(id => model.getNode(id)?.remote === true || !d.joined(id))) return 'roster 仅接受本机已入网的会话'
|
|
168
|
-
return null
|
|
169
|
-
}
|
|
170
|
-
const confirm = (): void => {
|
|
171
|
-
const problem = nameError()
|
|
172
|
-
if (problem !== null) { err.textContent = problem; input.focus(); return }
|
|
173
|
-
const name = kind.value === 'roster' ? rosterFrameName(input.value.trim()) : input.value.trim()
|
|
174
|
-
close()
|
|
175
|
-
if (model.getFrame(name) !== undefined) {
|
|
176
|
-
// Duplicate name: host create is idempotent-ok, but the optimistic
|
|
177
|
-
// create-team would WIPE the existing roster and overwrite its saved
|
|
178
|
-
// frame rect. Join instead — same wire result, nothing destroyed.
|
|
179
|
-
d.notice('info', `已加入现有团队「${kind.value === 'roster' ? rosterFrameTitle(name) : name}」`)
|
|
180
|
-
d.emit({ type: 'add-member', team: name, ids })
|
|
181
|
-
return
|
|
182
|
-
}
|
|
183
|
-
d.emit({ type: 'create-team', name, ids, created: true, ...(kind.value === 'roster' ? { roster: true } : {}) })
|
|
184
|
-
}
|
|
185
|
-
okBtn.addEventListener('click', confirm)
|
|
186
|
-
cancelBtn.addEventListener('click', close)
|
|
187
|
-
input.addEventListener('keydown', (ev) => {
|
|
188
|
-
const e = ev as KeyboardEvent
|
|
189
|
-
if (e.key === 'Enter') { e.preventDefault(); confirm() }
|
|
190
|
-
if (e.key === 'Escape') { e.preventDefault(); close() }
|
|
191
|
-
})
|
|
192
|
-
// Focus trap (aria-modal must not be a false promise): Tab cycles the
|
|
193
|
-
// three focusables; Esc anywhere in the panel cancels.
|
|
194
|
-
wrap.addEventListener('keydown', (ev) => {
|
|
195
|
-
const e = ev as KeyboardEvent
|
|
196
|
-
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); close(); return }
|
|
197
|
-
if (e.key !== 'Tab') return
|
|
198
|
-
e.preventDefault()
|
|
199
|
-
const focusables = Array.from(panel.querySelectorAll<HTMLElement>('input, select, button:not([tabindex="-1"])'))
|
|
200
|
-
const idx = focusables.indexOf(document.activeElement as HTMLElement)
|
|
201
|
-
const next = e.shiftKey
|
|
202
|
-
? focusables[(idx - 1 + focusables.length) % focusables.length]
|
|
203
|
-
: focusables[(idx + 1) % focusables.length]
|
|
204
|
-
next?.focus()
|
|
205
|
-
}, signal)
|
|
206
|
-
dialog = { wrap, restoreFocus }
|
|
207
|
-
input.focus()
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
// ── context menu(节点/框头;下钻单层;键盘全等)──
|
|
211
|
-
let menu: HTMLDivElement | null = null
|
|
212
|
-
let menuTarget: MenuTarget | undefined
|
|
213
|
-
let menuLevel: MenuLevel = 'root'
|
|
214
|
-
let menuAnchor: HTMLElement | null = null
|
|
215
|
-
|
|
216
|
-
// ── 未入网会话 dropdown(面板瘦身后迁入的入网面)──
|
|
217
|
-
let netMenu: HTMLDivElement | null = null
|
|
218
|
-
let lastUnjoined: ReadonlyArray<PlanningSession> = []
|
|
219
|
-
function closeNetMenu(): void {
|
|
220
|
-
netMenu?.remove()
|
|
221
|
-
netMenu = null
|
|
222
|
-
}
|
|
223
|
-
function toggleNetMenu(): void {
|
|
224
|
-
if (netMenu !== null) { closeNetMenu(); return }
|
|
225
|
-
if (lastUnjoined.length === 0) return
|
|
226
|
-
const el = document.createElement('div')
|
|
227
|
-
el.className = 'p-menu p-netmenu'
|
|
228
|
-
el.setAttribute('role', 'menu')
|
|
229
|
-
el.setAttribute('aria-label', '未入网会话')
|
|
230
|
-
for (const s of lastUnjoined) {
|
|
231
|
-
el.appendChild(menuItem(`${s.name !== undefined && s.name !== '' ? s.name : s.label} — 入网`, true, () => {
|
|
232
|
-
closeNetMenu()
|
|
233
|
-
d.emit({ type: 'join-network', id: s.id })
|
|
234
|
-
}))
|
|
235
|
-
}
|
|
236
|
-
netMenu = el
|
|
237
|
-
// Menus append to root (not document.body): the .p-menu styles are
|
|
238
|
-
// #dsh-plan-scoped, and root has no transformed ancestor, so fixed
|
|
239
|
-
// positioning stays viewport-relative.
|
|
240
|
-
root.appendChild(el)
|
|
241
|
-
const rect = netBtn.getBoundingClientRect()
|
|
242
|
-
el.style.left = `${String(Math.min(rect.left, window.innerWidth - 220))}px`
|
|
243
|
-
el.style.top = `${String(Math.max(8, rect.top - (lastUnjoined.length * 30 + 10)))}px`
|
|
244
|
-
el.addEventListener('keydown', (ev) => {
|
|
245
|
-
if ((ev as KeyboardEvent).key === 'Escape') {
|
|
246
|
-
ev.stopPropagation()
|
|
247
|
-
closeNetMenu()
|
|
248
|
-
netBtn.focus()
|
|
249
|
-
}
|
|
250
|
-
})
|
|
251
|
-
el.querySelector('button')?.focus()
|
|
252
|
-
// Outside-click dismissal: the dropdown lives at root level, so canvas
|
|
253
|
-
// presses never reach a menu-local handler — and focus leaves with the
|
|
254
|
-
// first outside click, killing the Escape path too (the stuck-dropdown
|
|
255
|
-
// defect the owner hit). The trigger button stays exempt so its click
|
|
256
|
-
// still toggles.
|
|
257
|
-
document.addEventListener('pointerdown', (ev) => {
|
|
258
|
-
if (netMenu === null) return
|
|
259
|
-
const t = ev.target as Element | null
|
|
260
|
-
if (t !== null && (t.closest('.p-netmenu') !== null || t === netBtn || netBtn.contains(t))) return
|
|
261
|
-
closeNetMenu()
|
|
262
|
-
}, signal)
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
function closeMenu(): void {
|
|
266
|
-
menu?.remove()
|
|
267
|
-
menu = null
|
|
268
|
-
menuTarget = undefined
|
|
269
|
-
menuLevel = 'root'
|
|
270
|
-
// The anchor may have been detached by a poll while the menu was open —
|
|
271
|
-
// focus() on a detached node drops focus to body and the canvas goes deaf.
|
|
272
|
-
if (menuAnchor !== null && menuAnchor.isConnected) menuAnchor.focus()
|
|
273
|
-
else root.focus()
|
|
274
|
-
menuAnchor = null
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
function menuItem(label: string, enabled: boolean, onPick: () => void): HTMLButtonElement {
|
|
278
|
-
const b = document.createElement('button')
|
|
279
|
-
b.type = 'button'
|
|
280
|
-
b.setAttribute('role', 'menuitem')
|
|
281
|
-
b.textContent = label
|
|
282
|
-
if (!enabled) { b.disabled = true; b.setAttribute('aria-disabled', 'true') }
|
|
283
|
-
else b.addEventListener('click', () => { closeMenu(); onPick() })
|
|
284
|
-
return b
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
function openMenu(x: number, y: number, target: MenuTarget, level: MenuLevel = 'root', anchor?: HTMLElement): void {
|
|
288
|
-
// Re-seed the anchor after a drill-down: closeMenu focuses whatever this
|
|
289
|
-
// menu was anchored to, or keyboard focus falls to body and the canvas
|
|
290
|
-
// goes deaf.
|
|
291
|
-
if (anchor !== undefined) menuAnchor = anchor
|
|
292
|
-
if (menuAnchor === null) menuAnchor = d.anchorFor(target)
|
|
293
|
-
menu?.remove()
|
|
294
|
-
menuTarget = target
|
|
295
|
-
menuLevel = level
|
|
296
|
-
const el = document.createElement('div')
|
|
297
|
-
el.className = 'p-menu'
|
|
298
|
-
el.setAttribute('role', 'menu')
|
|
299
|
-
el.setAttribute('aria-label', '团队操作')
|
|
300
|
-
let items: HTMLButtonElement[] = []
|
|
301
|
-
if (target.kind === 'frame') {
|
|
302
|
-
items = [menuItem(`解散团队「${titleOf(target.name)}」`, true, () => d.emit({ type: 'remove-team', name: target.name }))]
|
|
303
|
-
} else {
|
|
304
|
-
const id = target.id
|
|
305
|
-
if (level === 'root') {
|
|
306
|
-
const create = menuItem('组成团队…', model.selectedIds().length >= 2, () => openNameDialog(selectedByY()))
|
|
307
|
-
create.title = '先框选或 Shift 加选至少 2 个节点'
|
|
308
|
-
items.push(create)
|
|
309
|
-
const teamsOf = model.getNode(id)?.memberships.map(m => m.team) ?? []
|
|
310
|
-
if (d.teams().length > 0) {
|
|
311
|
-
items.push(menuItem('加入团队 ▸', true, () => openMenu(x, y, target, 'join')))
|
|
312
|
-
if (teamsOf.length > 0) items.push(menuItem('置顶路由 ▸', true, () => openMenu(x, y, target, 'promote')))
|
|
313
|
-
}
|
|
314
|
-
// A card that is only in mirror frames (a peer's team) still has a team to leave.
|
|
315
|
-
if (teamsOf.length > 0) items.push(menuItem('离队 ▸', true, () => openMenu(x, y, target, 'leave')))
|
|
316
|
-
// Network-level leave (distinct from canvas-team membership): the
|
|
317
|
-
// panel-slim migration moved the join/leave toggle here. Remote
|
|
318
|
-
// nodes are read-only entries — never a leave target.
|
|
319
|
-
if (model.getNode(id)?.remote !== true) {
|
|
320
|
-
items.push(menuItem('退网', true, () => d.emit({ type: 'leave-network', id })))
|
|
321
|
-
}
|
|
322
|
-
} else {
|
|
323
|
-
items.push(menuItem('‹ 返回', true, () => openMenu(x, y, target, 'root')))
|
|
324
|
-
for (const t of d.teams()) {
|
|
325
|
-
if (level === 'join') {
|
|
326
|
-
const already = (model.getNode(id)?.memberships.some(m => m.team === t.name)) ?? true
|
|
327
|
-
const allowed = !d.isRoster(t.name) || (d.joined(id) && model.getNode(id)?.remote !== true)
|
|
328
|
-
const item = menuItem(already ? `${titleOf(t.name)}(已加入)` : titleOf(t.name), !already && allowed, () => d.emit({ type: 'add-member', team: t.name, ids: [id] }))
|
|
329
|
-
if (!allowed) item.title = 'roster 只接受本机已入网会话'
|
|
330
|
-
items.push(item)
|
|
331
|
-
} else if (level === 'leave') {
|
|
332
|
-
const member = (model.getNode(id)?.memberships.some(m => m.team === t.name)) ?? false
|
|
333
|
-
items.push(menuItem(titleOf(t.name), member, () => d.emit({ type: 'remove-member', team: t.name, ids: [id] })))
|
|
334
|
-
} else {
|
|
335
|
-
const member = (model.getNode(id)?.memberships.some(m => m.team === t.name)) ?? false
|
|
336
|
-
if (d.isRoster(t.name)) continue
|
|
337
|
-
items.push(menuItem(titleOf(t.name), member, () => {
|
|
338
|
-
const current = model.teamMemberIds(t.name)
|
|
339
|
-
const desired = [id, ...current.filter(x => x !== id)]
|
|
340
|
-
d.emit({ type: 'reorder', team: t.name, ops: reorderOps(current, desired) })
|
|
341
|
-
}))
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
// d.teams() is this host's own teams; a mirror frame's membership leaves through
|
|
345
|
-
// the owner's relay (the view adds fleetPeer), so list those memberships too.
|
|
346
|
-
if (level === 'leave') {
|
|
347
|
-
const local = new Set(d.teams().map(t => t.name))
|
|
348
|
-
for (const m of model.getNode(id)?.memberships ?? []) {
|
|
349
|
-
if (!local.has(m.team)) items.push(menuItem(titleOf(m.team), true, () => d.emit({ type: 'remove-member', team: m.team, ids: [id] })))
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
el.append(...items)
|
|
355
|
-
el.style.left = `${Math.min(x, window.innerWidth - 200)}px`
|
|
356
|
-
const menuH = items.length * 30 + 8
|
|
357
|
-
el.style.top = `${Math.max(8, Math.min(y, window.innerHeight - menuH - 8))}px`
|
|
358
|
-
el.addEventListener('keydown', (ev) => {
|
|
359
|
-
const e = ev as KeyboardEvent
|
|
360
|
-
const buttons = Array.from(el.querySelectorAll<HTMLButtonElement>('[role=menuitem]'))
|
|
361
|
-
const idx = buttons.indexOf(document.activeElement as HTMLButtonElement)
|
|
362
|
-
if (e.key === 'ArrowDown') { e.preventDefault(); buttons[(idx + 1 + buttons.length) % buttons.length]?.focus() }
|
|
363
|
-
else if (e.key === 'ArrowUp') { e.preventDefault(); buttons[(idx - 1 + buttons.length) % buttons.length]?.focus() }
|
|
364
|
-
else if (e.key === 'Home') { e.preventDefault(); buttons[0]?.focus() }
|
|
365
|
-
else if (e.key === 'End') { e.preventDefault(); buttons[buttons.length - 1]?.focus() }
|
|
366
|
-
else if (e.key === 'Escape') {
|
|
367
|
-
e.preventDefault()
|
|
368
|
-
e.stopPropagation() // drill-back is menu-internal; root must not double-close
|
|
369
|
-
if (menuLevel !== 'root') openMenu(x, y, target, 'root')
|
|
370
|
-
else closeMenu()
|
|
371
|
-
}
|
|
372
|
-
})
|
|
373
|
-
root.appendChild(el)
|
|
374
|
-
menu = el
|
|
375
|
-
items[0]?.focus()
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
/** Context menu from a real contextmenu event or the keyboard equivalent. */
|
|
379
|
-
function contextMenuAt(x: number, y: number, target: Element | null): void {
|
|
380
|
-
if (dialog !== null) return
|
|
381
|
-
// gh#20 W-B: a peer WITH a remote handle gets the roster menu (加入团队
|
|
382
|
-
// composes a cross-host team through the handle boundary); a peer
|
|
383
|
-
// WITHOUT one stays a read-only entry (nothing the server would accept).
|
|
384
|
-
const probeNode = target?.closest<HTMLElement>('.p-node') ?? null
|
|
385
|
-
if (probeNode !== null) {
|
|
386
|
-
const probe = model.getNode(probeNode.dataset.id ?? '')
|
|
387
|
-
if (probe?.remote === true && (probe.remoteHandle === undefined || probe.remoteHandle === '')) return
|
|
388
|
-
}
|
|
389
|
-
const nodeEl = probeNode
|
|
390
|
-
const headEl = target?.closest<HTMLElement>('.p-frame-head') ?? null
|
|
391
|
-
if (nodeEl !== null) {
|
|
392
|
-
const id = nodeEl.dataset.id ?? ''
|
|
393
|
-
if (!model.isSelected(id)) model.setSelection([id])
|
|
394
|
-
menuAnchor = nodeEl
|
|
395
|
-
openMenu(x, y, { kind: 'node', id })
|
|
396
|
-
} else if (headEl !== null) {
|
|
397
|
-
menuAnchor = headEl
|
|
398
|
-
openMenu(x, y, { kind: 'frame', name: headEl.dataset.frame ?? '' })
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
function setUnjoined(sessions: ReadonlyArray<PlanningSession>): void {
|
|
403
|
-
lastUnjoined = sessions
|
|
404
|
-
netBtn.textContent = `未入网${lastUnjoined.length > 0 ? `(${String(lastUnjoined.length)})` : ''}`
|
|
405
|
-
netBtn.disabled = lastUnjoined.length === 0
|
|
406
|
-
netBtn.setAttribute('aria-disabled', String(lastUnjoined.length === 0))
|
|
407
|
-
if (lastUnjoined.length === 0) closeNetMenu()
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
return {
|
|
411
|
-
menuItem,
|
|
412
|
-
isMenuOpen: () => menu !== null,
|
|
413
|
-
isDialogOpen: () => dialog !== null,
|
|
414
|
-
closeMenu,
|
|
415
|
-
openMenu,
|
|
416
|
-
contextMenuAt,
|
|
417
|
-
openNameDialog,
|
|
418
|
-
startCreateFromSelection,
|
|
419
|
-
toggleNetMenu,
|
|
420
|
-
setUnjoined,
|
|
421
|
-
}
|
|
422
|
-
}
|
|
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 { rosterFrameName, rosterFrameTitle } from './planning-roster'
|
|
12
|
+
import type { PlanningSession, PlanningTeam } from './planning-types'
|
|
13
|
+
import type { WorldModel } from './world'
|
|
14
|
+
|
|
15
|
+
export type MenuTarget = { kind: 'node'; id: string } | { kind: 'frame'; name: string }
|
|
16
|
+
export type MenuLevel = 'root' | 'join' | 'leave' | 'promote'
|
|
17
|
+
|
|
18
|
+
export interface MenusDeps {
|
|
19
|
+
readonly root: HTMLElement
|
|
20
|
+
/** The toolbar's 未入网 chip: the dropdown anchors to it and it shows the count. */
|
|
21
|
+
readonly netBtn: HTMLButtonElement
|
|
22
|
+
readonly signal: AddEventListenerOptions
|
|
23
|
+
readonly model: WorldModel
|
|
24
|
+
/** Canvas teams from the latest poll. */
|
|
25
|
+
teams(): ReadonlyArray<PlanningTeam>
|
|
26
|
+
joined(id: string): boolean
|
|
27
|
+
isRoster(frame: string): boolean
|
|
28
|
+
/** The card or frame-head element a menu re-focuses on close. */
|
|
29
|
+
anchorFor(target: MenuTarget): HTMLElement | null
|
|
30
|
+
emit(a: CanvasAction): void
|
|
31
|
+
notice(kind: 'error' | 'info', text: string): void
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface PlanningMenus {
|
|
35
|
+
menuItem(label: string, enabled: boolean, onPick: () => void): HTMLButtonElement
|
|
36
|
+
isMenuOpen(): boolean
|
|
37
|
+
isDialogOpen(): boolean
|
|
38
|
+
closeMenu(): void
|
|
39
|
+
openMenu(x: number, y: number, target: MenuTarget, level?: MenuLevel, anchor?: HTMLElement): void
|
|
40
|
+
/** Context menu from a real contextmenu event or the keyboard equivalent. */
|
|
41
|
+
contextMenuAt(x: number, y: number, target: Element | null): void
|
|
42
|
+
openNameDialog(ids: readonly string[]): void
|
|
43
|
+
startCreateFromSelection(): void
|
|
44
|
+
toggleNetMenu(): void
|
|
45
|
+
/** Latest unjoined sessions: refreshes the chip's count and closes an emptied dropdown. */
|
|
46
|
+
setUnjoined(sessions: ReadonlyArray<PlanningSession>): void
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function createPlanningMenus(d: MenusDeps): PlanningMenus {
|
|
50
|
+
const { root, netBtn, signal, model } = d
|
|
51
|
+
const titleOf = (frame: string): string => d.isRoster(frame) ? rosterFrameTitle(frame) : frame
|
|
52
|
+
|
|
53
|
+
// ── 建队:selection → 命名对话框 → create-team ──
|
|
54
|
+
function selectedByY(): string[] {
|
|
55
|
+
return model
|
|
56
|
+
.selectedIds()
|
|
57
|
+
.map(id => ({ id, y: model.getNode(id)?.y ?? 0 }))
|
|
58
|
+
.sort((a, b) => a.y - b.y)
|
|
59
|
+
.map(r => r.id)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function startCreateFromSelection(): void {
|
|
63
|
+
if (dialog !== null) return
|
|
64
|
+
// gh#20 W-B: a mixed local+remote selection composes a cross-host team —
|
|
65
|
+
// peers ride their zone/id8 handle at the server boundary. A peer
|
|
66
|
+
// without a handle yet (no remote team rows) cannot join; drop it and
|
|
67
|
+
// let the ≥2 floor judge the rest.
|
|
68
|
+
const ids = selectedByY().filter(id => {
|
|
69
|
+
const node = model.getNode(id)
|
|
70
|
+
if (node === undefined) return false
|
|
71
|
+
return node.remote !== true || (node.remoteHandle !== undefined && node.remoteHandle !== '')
|
|
72
|
+
})
|
|
73
|
+
if (ids.length < 2) { d.notice('info', '框选至少 2 个会话节点'); return }
|
|
74
|
+
openNameDialog(ids)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let dialog: {
|
|
78
|
+
wrap: HTMLDivElement
|
|
79
|
+
restoreFocus: HTMLElement | null
|
|
80
|
+
} | null = null
|
|
81
|
+
|
|
82
|
+
function openNameDialog(ids: readonly string[]): void {
|
|
83
|
+
const wrap = document.createElement('div')
|
|
84
|
+
wrap.className = 'p-dialog'
|
|
85
|
+
const panel = document.createElement('div')
|
|
86
|
+
panel.className = 'p-dialog-panel'
|
|
87
|
+
panel.setAttribute('role', 'dialog')
|
|
88
|
+
panel.setAttribute('aria-modal', 'true')
|
|
89
|
+
panel.setAttribute('aria-labelledby', 'p-dialog-title')
|
|
90
|
+
const title = document.createElement('div')
|
|
91
|
+
title.id = 'p-dialog-title'
|
|
92
|
+
title.className = 'p-dialog-title'
|
|
93
|
+
title.textContent = `组建团队(${ids.length} 个节点,自上而下即优先级)`
|
|
94
|
+
// Team type: a visible two-way toggle (a bare <select> read as a label and roster went unnoticed).
|
|
95
|
+
const kind = { value: 'canvas' as 'canvas' | 'roster' }
|
|
96
|
+
const kindGroup = document.createElement('div')
|
|
97
|
+
kindGroup.className = 'p-dialog-kind'
|
|
98
|
+
kindGroup.setAttribute('role', 'radiogroup')
|
|
99
|
+
kindGroup.setAttribute('aria-label', '团队类型')
|
|
100
|
+
const kindHint = document.createElement('div')
|
|
101
|
+
kindHint.className = 'p-dialog-hint'
|
|
102
|
+
const KIND_HINT = {
|
|
103
|
+
canvas: 'canvas 队:画布上的协作组,可以加入远端成员。',
|
|
104
|
+
roster: 'roster 网络队:只收本机已入网的会话;队名会写进属主的精确允许名单。',
|
|
105
|
+
} as const
|
|
106
|
+
const kindButtons = (['canvas', 'roster'] as const).map(value => {
|
|
107
|
+
const b = document.createElement('button')
|
|
108
|
+
b.type = 'button'
|
|
109
|
+
b.dataset.kind = value
|
|
110
|
+
b.setAttribute('role', 'radio')
|
|
111
|
+
b.textContent = value === 'canvas' ? 'canvas 队' : 'roster 网络队'
|
|
112
|
+
b.addEventListener('click', () => setKind(value))
|
|
113
|
+
kindGroup.appendChild(b)
|
|
114
|
+
return b
|
|
115
|
+
})
|
|
116
|
+
function setKind(value: 'canvas' | 'roster'): void {
|
|
117
|
+
kind.value = value
|
|
118
|
+
for (const b of kindButtons) {
|
|
119
|
+
const on = b.dataset.kind === value
|
|
120
|
+
b.setAttribute('aria-checked', String(on))
|
|
121
|
+
b.tabIndex = on ? 0 : -1
|
|
122
|
+
}
|
|
123
|
+
kindHint.textContent = KIND_HINT[value]
|
|
124
|
+
err.textContent = ''
|
|
125
|
+
}
|
|
126
|
+
// Arrow keys move between the two options (radiogroup pattern).
|
|
127
|
+
kindGroup.addEventListener('keydown', (ev) => {
|
|
128
|
+
const e = ev as KeyboardEvent
|
|
129
|
+
if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) return
|
|
130
|
+
e.preventDefault()
|
|
131
|
+
setKind(kind.value === 'canvas' ? 'roster' : 'canvas')
|
|
132
|
+
kindButtons.find(b => b.dataset.kind === kind.value)?.focus()
|
|
133
|
+
})
|
|
134
|
+
const input = document.createElement('input')
|
|
135
|
+
input.className = 'p-dialog-input'
|
|
136
|
+
input.maxLength = 40
|
|
137
|
+
input.setAttribute('aria-label', '团队名')
|
|
138
|
+
const err = document.createElement('div')
|
|
139
|
+
err.className = 'p-dialog-err'
|
|
140
|
+
err.setAttribute('aria-live', 'polite')
|
|
141
|
+
const row = document.createElement('div')
|
|
142
|
+
row.className = 'p-dialog-row'
|
|
143
|
+
const okBtn = document.createElement('button')
|
|
144
|
+
okBtn.type = 'button'
|
|
145
|
+
okBtn.textContent = '组建'
|
|
146
|
+
const cancelBtn = document.createElement('button')
|
|
147
|
+
cancelBtn.type = 'button'
|
|
148
|
+
cancelBtn.textContent = '取消'
|
|
149
|
+
row.append(okBtn, cancelBtn)
|
|
150
|
+
setKind('canvas')
|
|
151
|
+
panel.append(title, kindGroup, kindHint, input, err, row)
|
|
152
|
+
wrap.appendChild(panel)
|
|
153
|
+
root.appendChild(wrap)
|
|
154
|
+
|
|
155
|
+
const restoreFocus = document.activeElement as HTMLElement | null
|
|
156
|
+
const close = (): void => {
|
|
157
|
+
wrap.remove()
|
|
158
|
+
dialog = null
|
|
159
|
+
restoreFocus?.focus()
|
|
160
|
+
}
|
|
161
|
+
const nameError = (): string | null => {
|
|
162
|
+
const name = input.value.trim()
|
|
163
|
+
if (name === '' || name.length > 40 || name.includes('/') || name.startsWith('roster:') || (kind.value === 'roster' && name.includes('*')) || /^\d+$/.test(name)) return '队名需 1..40 字,不含“/”、通配符或保留前缀,不为纯数字'
|
|
164
|
+
if (kind.value === 'roster' && model.getFrame(name) !== undefined) return '同名 canvas 队已存在,不能创建 roster 队'
|
|
165
|
+
if (kind.value === 'roster' && model.getFrame(rosterFrameName(name)) !== undefined && !d.isRoster(rosterFrameName(name))) return 'roster 框名已被 canvas 或镜像队占用'
|
|
166
|
+
if (kind.value === 'canvas' && model.getFrame(rosterFrameName(name)) !== undefined) return '同名 roster 队已存在,不能创建 canvas 队'
|
|
167
|
+
if (kind.value === 'roster' && ids.some(id => model.getNode(id)?.remote === true || !d.joined(id))) return 'roster 仅接受本机已入网的会话'
|
|
168
|
+
return null
|
|
169
|
+
}
|
|
170
|
+
const confirm = (): void => {
|
|
171
|
+
const problem = nameError()
|
|
172
|
+
if (problem !== null) { err.textContent = problem; input.focus(); return }
|
|
173
|
+
const name = kind.value === 'roster' ? rosterFrameName(input.value.trim()) : input.value.trim()
|
|
174
|
+
close()
|
|
175
|
+
if (model.getFrame(name) !== undefined) {
|
|
176
|
+
// Duplicate name: host create is idempotent-ok, but the optimistic
|
|
177
|
+
// create-team would WIPE the existing roster and overwrite its saved
|
|
178
|
+
// frame rect. Join instead — same wire result, nothing destroyed.
|
|
179
|
+
d.notice('info', `已加入现有团队「${kind.value === 'roster' ? rosterFrameTitle(name) : name}」`)
|
|
180
|
+
d.emit({ type: 'add-member', team: name, ids })
|
|
181
|
+
return
|
|
182
|
+
}
|
|
183
|
+
d.emit({ type: 'create-team', name, ids, created: true, ...(kind.value === 'roster' ? { roster: true } : {}) })
|
|
184
|
+
}
|
|
185
|
+
okBtn.addEventListener('click', confirm)
|
|
186
|
+
cancelBtn.addEventListener('click', close)
|
|
187
|
+
input.addEventListener('keydown', (ev) => {
|
|
188
|
+
const e = ev as KeyboardEvent
|
|
189
|
+
if (e.key === 'Enter') { e.preventDefault(); confirm() }
|
|
190
|
+
if (e.key === 'Escape') { e.preventDefault(); close() }
|
|
191
|
+
})
|
|
192
|
+
// Focus trap (aria-modal must not be a false promise): Tab cycles the
|
|
193
|
+
// three focusables; Esc anywhere in the panel cancels.
|
|
194
|
+
wrap.addEventListener('keydown', (ev) => {
|
|
195
|
+
const e = ev as KeyboardEvent
|
|
196
|
+
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); close(); return }
|
|
197
|
+
if (e.key !== 'Tab') return
|
|
198
|
+
e.preventDefault()
|
|
199
|
+
const focusables = Array.from(panel.querySelectorAll<HTMLElement>('input, select, button:not([tabindex="-1"])'))
|
|
200
|
+
const idx = focusables.indexOf(document.activeElement as HTMLElement)
|
|
201
|
+
const next = e.shiftKey
|
|
202
|
+
? focusables[(idx - 1 + focusables.length) % focusables.length]
|
|
203
|
+
: focusables[(idx + 1) % focusables.length]
|
|
204
|
+
next?.focus()
|
|
205
|
+
}, signal)
|
|
206
|
+
dialog = { wrap, restoreFocus }
|
|
207
|
+
input.focus()
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ── context menu(节点/框头;下钻单层;键盘全等)──
|
|
211
|
+
let menu: HTMLDivElement | null = null
|
|
212
|
+
let menuTarget: MenuTarget | undefined
|
|
213
|
+
let menuLevel: MenuLevel = 'root'
|
|
214
|
+
let menuAnchor: HTMLElement | null = null
|
|
215
|
+
|
|
216
|
+
// ── 未入网会话 dropdown(面板瘦身后迁入的入网面)──
|
|
217
|
+
let netMenu: HTMLDivElement | null = null
|
|
218
|
+
let lastUnjoined: ReadonlyArray<PlanningSession> = []
|
|
219
|
+
function closeNetMenu(): void {
|
|
220
|
+
netMenu?.remove()
|
|
221
|
+
netMenu = null
|
|
222
|
+
}
|
|
223
|
+
function toggleNetMenu(): void {
|
|
224
|
+
if (netMenu !== null) { closeNetMenu(); return }
|
|
225
|
+
if (lastUnjoined.length === 0) return
|
|
226
|
+
const el = document.createElement('div')
|
|
227
|
+
el.className = 'p-menu p-netmenu'
|
|
228
|
+
el.setAttribute('role', 'menu')
|
|
229
|
+
el.setAttribute('aria-label', '未入网会话')
|
|
230
|
+
for (const s of lastUnjoined) {
|
|
231
|
+
el.appendChild(menuItem(`${s.name !== undefined && s.name !== '' ? s.name : s.label} — 入网`, true, () => {
|
|
232
|
+
closeNetMenu()
|
|
233
|
+
d.emit({ type: 'join-network', id: s.id })
|
|
234
|
+
}))
|
|
235
|
+
}
|
|
236
|
+
netMenu = el
|
|
237
|
+
// Menus append to root (not document.body): the .p-menu styles are
|
|
238
|
+
// #dsh-plan-scoped, and root has no transformed ancestor, so fixed
|
|
239
|
+
// positioning stays viewport-relative.
|
|
240
|
+
root.appendChild(el)
|
|
241
|
+
const rect = netBtn.getBoundingClientRect()
|
|
242
|
+
el.style.left = `${String(Math.min(rect.left, window.innerWidth - 220))}px`
|
|
243
|
+
el.style.top = `${String(Math.max(8, rect.top - (lastUnjoined.length * 30 + 10)))}px`
|
|
244
|
+
el.addEventListener('keydown', (ev) => {
|
|
245
|
+
if ((ev as KeyboardEvent).key === 'Escape') {
|
|
246
|
+
ev.stopPropagation()
|
|
247
|
+
closeNetMenu()
|
|
248
|
+
netBtn.focus()
|
|
249
|
+
}
|
|
250
|
+
})
|
|
251
|
+
el.querySelector('button')?.focus()
|
|
252
|
+
// Outside-click dismissal: the dropdown lives at root level, so canvas
|
|
253
|
+
// presses never reach a menu-local handler — and focus leaves with the
|
|
254
|
+
// first outside click, killing the Escape path too (the stuck-dropdown
|
|
255
|
+
// defect the owner hit). The trigger button stays exempt so its click
|
|
256
|
+
// still toggles.
|
|
257
|
+
document.addEventListener('pointerdown', (ev) => {
|
|
258
|
+
if (netMenu === null) return
|
|
259
|
+
const t = ev.target as Element | null
|
|
260
|
+
if (t !== null && (t.closest('.p-netmenu') !== null || t === netBtn || netBtn.contains(t))) return
|
|
261
|
+
closeNetMenu()
|
|
262
|
+
}, signal)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function closeMenu(): void {
|
|
266
|
+
menu?.remove()
|
|
267
|
+
menu = null
|
|
268
|
+
menuTarget = undefined
|
|
269
|
+
menuLevel = 'root'
|
|
270
|
+
// The anchor may have been detached by a poll while the menu was open —
|
|
271
|
+
// focus() on a detached node drops focus to body and the canvas goes deaf.
|
|
272
|
+
if (menuAnchor !== null && menuAnchor.isConnected) menuAnchor.focus()
|
|
273
|
+
else root.focus()
|
|
274
|
+
menuAnchor = null
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function menuItem(label: string, enabled: boolean, onPick: () => void): HTMLButtonElement {
|
|
278
|
+
const b = document.createElement('button')
|
|
279
|
+
b.type = 'button'
|
|
280
|
+
b.setAttribute('role', 'menuitem')
|
|
281
|
+
b.textContent = label
|
|
282
|
+
if (!enabled) { b.disabled = true; b.setAttribute('aria-disabled', 'true') }
|
|
283
|
+
else b.addEventListener('click', () => { closeMenu(); onPick() })
|
|
284
|
+
return b
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function openMenu(x: number, y: number, target: MenuTarget, level: MenuLevel = 'root', anchor?: HTMLElement): void {
|
|
288
|
+
// Re-seed the anchor after a drill-down: closeMenu focuses whatever this
|
|
289
|
+
// menu was anchored to, or keyboard focus falls to body and the canvas
|
|
290
|
+
// goes deaf.
|
|
291
|
+
if (anchor !== undefined) menuAnchor = anchor
|
|
292
|
+
if (menuAnchor === null) menuAnchor = d.anchorFor(target)
|
|
293
|
+
menu?.remove()
|
|
294
|
+
menuTarget = target
|
|
295
|
+
menuLevel = level
|
|
296
|
+
const el = document.createElement('div')
|
|
297
|
+
el.className = 'p-menu'
|
|
298
|
+
el.setAttribute('role', 'menu')
|
|
299
|
+
el.setAttribute('aria-label', '团队操作')
|
|
300
|
+
let items: HTMLButtonElement[] = []
|
|
301
|
+
if (target.kind === 'frame') {
|
|
302
|
+
items = [menuItem(`解散团队「${titleOf(target.name)}」`, true, () => d.emit({ type: 'remove-team', name: target.name }))]
|
|
303
|
+
} else {
|
|
304
|
+
const id = target.id
|
|
305
|
+
if (level === 'root') {
|
|
306
|
+
const create = menuItem('组成团队…', model.selectedIds().length >= 2, () => openNameDialog(selectedByY()))
|
|
307
|
+
create.title = '先框选或 Shift 加选至少 2 个节点'
|
|
308
|
+
items.push(create)
|
|
309
|
+
const teamsOf = model.getNode(id)?.memberships.map(m => m.team) ?? []
|
|
310
|
+
if (d.teams().length > 0) {
|
|
311
|
+
items.push(menuItem('加入团队 ▸', true, () => openMenu(x, y, target, 'join')))
|
|
312
|
+
if (teamsOf.length > 0) items.push(menuItem('置顶路由 ▸', true, () => openMenu(x, y, target, 'promote')))
|
|
313
|
+
}
|
|
314
|
+
// A card that is only in mirror frames (a peer's team) still has a team to leave.
|
|
315
|
+
if (teamsOf.length > 0) items.push(menuItem('离队 ▸', true, () => openMenu(x, y, target, 'leave')))
|
|
316
|
+
// Network-level leave (distinct from canvas-team membership): the
|
|
317
|
+
// panel-slim migration moved the join/leave toggle here. Remote
|
|
318
|
+
// nodes are read-only entries — never a leave target.
|
|
319
|
+
if (model.getNode(id)?.remote !== true) {
|
|
320
|
+
items.push(menuItem('退网', true, () => d.emit({ type: 'leave-network', id })))
|
|
321
|
+
}
|
|
322
|
+
} else {
|
|
323
|
+
items.push(menuItem('‹ 返回', true, () => openMenu(x, y, target, 'root')))
|
|
324
|
+
for (const t of d.teams()) {
|
|
325
|
+
if (level === 'join') {
|
|
326
|
+
const already = (model.getNode(id)?.memberships.some(m => m.team === t.name)) ?? true
|
|
327
|
+
const allowed = !d.isRoster(t.name) || (d.joined(id) && model.getNode(id)?.remote !== true)
|
|
328
|
+
const item = menuItem(already ? `${titleOf(t.name)}(已加入)` : titleOf(t.name), !already && allowed, () => d.emit({ type: 'add-member', team: t.name, ids: [id] }))
|
|
329
|
+
if (!allowed) item.title = 'roster 只接受本机已入网会话'
|
|
330
|
+
items.push(item)
|
|
331
|
+
} else if (level === 'leave') {
|
|
332
|
+
const member = (model.getNode(id)?.memberships.some(m => m.team === t.name)) ?? false
|
|
333
|
+
items.push(menuItem(titleOf(t.name), member, () => d.emit({ type: 'remove-member', team: t.name, ids: [id] })))
|
|
334
|
+
} else {
|
|
335
|
+
const member = (model.getNode(id)?.memberships.some(m => m.team === t.name)) ?? false
|
|
336
|
+
if (d.isRoster(t.name)) continue
|
|
337
|
+
items.push(menuItem(titleOf(t.name), member, () => {
|
|
338
|
+
const current = model.teamMemberIds(t.name)
|
|
339
|
+
const desired = [id, ...current.filter(x => x !== id)]
|
|
340
|
+
d.emit({ type: 'reorder', team: t.name, ops: reorderOps(current, desired) })
|
|
341
|
+
}))
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
// d.teams() is this host's own teams; a mirror frame's membership leaves through
|
|
345
|
+
// the owner's relay (the view adds fleetPeer), so list those memberships too.
|
|
346
|
+
if (level === 'leave') {
|
|
347
|
+
const local = new Set(d.teams().map(t => t.name))
|
|
348
|
+
for (const m of model.getNode(id)?.memberships ?? []) {
|
|
349
|
+
if (!local.has(m.team)) items.push(menuItem(titleOf(m.team), true, () => d.emit({ type: 'remove-member', team: m.team, ids: [id] })))
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
el.append(...items)
|
|
355
|
+
el.style.left = `${Math.min(x, window.innerWidth - 200)}px`
|
|
356
|
+
const menuH = items.length * 30 + 8
|
|
357
|
+
el.style.top = `${Math.max(8, Math.min(y, window.innerHeight - menuH - 8))}px`
|
|
358
|
+
el.addEventListener('keydown', (ev) => {
|
|
359
|
+
const e = ev as KeyboardEvent
|
|
360
|
+
const buttons = Array.from(el.querySelectorAll<HTMLButtonElement>('[role=menuitem]'))
|
|
361
|
+
const idx = buttons.indexOf(document.activeElement as HTMLButtonElement)
|
|
362
|
+
if (e.key === 'ArrowDown') { e.preventDefault(); buttons[(idx + 1 + buttons.length) % buttons.length]?.focus() }
|
|
363
|
+
else if (e.key === 'ArrowUp') { e.preventDefault(); buttons[(idx - 1 + buttons.length) % buttons.length]?.focus() }
|
|
364
|
+
else if (e.key === 'Home') { e.preventDefault(); buttons[0]?.focus() }
|
|
365
|
+
else if (e.key === 'End') { e.preventDefault(); buttons[buttons.length - 1]?.focus() }
|
|
366
|
+
else if (e.key === 'Escape') {
|
|
367
|
+
e.preventDefault()
|
|
368
|
+
e.stopPropagation() // drill-back is menu-internal; root must not double-close
|
|
369
|
+
if (menuLevel !== 'root') openMenu(x, y, target, 'root')
|
|
370
|
+
else closeMenu()
|
|
371
|
+
}
|
|
372
|
+
})
|
|
373
|
+
root.appendChild(el)
|
|
374
|
+
menu = el
|
|
375
|
+
items[0]?.focus()
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Context menu from a real contextmenu event or the keyboard equivalent. */
|
|
379
|
+
function contextMenuAt(x: number, y: number, target: Element | null): void {
|
|
380
|
+
if (dialog !== null) return
|
|
381
|
+
// gh#20 W-B: a peer WITH a remote handle gets the roster menu (加入团队
|
|
382
|
+
// composes a cross-host team through the handle boundary); a peer
|
|
383
|
+
// WITHOUT one stays a read-only entry (nothing the server would accept).
|
|
384
|
+
const probeNode = target?.closest<HTMLElement>('.p-node') ?? null
|
|
385
|
+
if (probeNode !== null) {
|
|
386
|
+
const probe = model.getNode(probeNode.dataset.id ?? '')
|
|
387
|
+
if (probe?.remote === true && (probe.remoteHandle === undefined || probe.remoteHandle === '')) return
|
|
388
|
+
}
|
|
389
|
+
const nodeEl = probeNode
|
|
390
|
+
const headEl = target?.closest<HTMLElement>('.p-frame-head') ?? null
|
|
391
|
+
if (nodeEl !== null) {
|
|
392
|
+
const id = nodeEl.dataset.id ?? ''
|
|
393
|
+
if (!model.isSelected(id)) model.setSelection([id])
|
|
394
|
+
menuAnchor = nodeEl
|
|
395
|
+
openMenu(x, y, { kind: 'node', id })
|
|
396
|
+
} else if (headEl !== null) {
|
|
397
|
+
menuAnchor = headEl
|
|
398
|
+
openMenu(x, y, { kind: 'frame', name: headEl.dataset.frame ?? '' })
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function setUnjoined(sessions: ReadonlyArray<PlanningSession>): void {
|
|
403
|
+
lastUnjoined = sessions
|
|
404
|
+
netBtn.textContent = `未入网${lastUnjoined.length > 0 ? `(${String(lastUnjoined.length)})` : ''}`
|
|
405
|
+
netBtn.disabled = lastUnjoined.length === 0
|
|
406
|
+
netBtn.setAttribute('aria-disabled', String(lastUnjoined.length === 0))
|
|
407
|
+
if (lastUnjoined.length === 0) closeNetMenu()
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return {
|
|
411
|
+
menuItem,
|
|
412
|
+
isMenuOpen: () => menu !== null,
|
|
413
|
+
isDialogOpen: () => dialog !== null,
|
|
414
|
+
closeMenu,
|
|
415
|
+
openMenu,
|
|
416
|
+
contextMenuAt,
|
|
417
|
+
openNameDialog,
|
|
418
|
+
startCreateFromSelection,
|
|
419
|
+
toggleNetMenu,
|
|
420
|
+
setUnjoined,
|
|
421
|
+
}
|
|
422
|
+
}
|