@nelsonlongxiang/dsh-open-a2a-net 0.6.28 → 0.6.30
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 +87 -14
- package/README_zh.md +83 -7
- package/assets/nexusDist/assets/index-C_x3iAth.js +9 -0
- package/assets/nexusDist/assets/index-Dz8N80tI.css +1 -0
- package/assets/nexusDist/index.html +2 -2
- package/cli/a2a.mjs +0 -0
- package/cordis.patch.yml +21 -21
- package/lib/canvas-store.js +21 -1
- package/lib/client.js +64 -64
- package/lib/client.js.map +1 -1
- package/lib/faces/admin.js +755 -0
- package/lib/faces/canvas-notify.js +138 -0
- package/lib/faces/wire.js +669 -0
- package/lib/flood-guard.js +106 -0
- package/lib/index.js +200 -1392
- package/lib/recursion-guard.js +78 -0
- package/lib/runtime.js +1 -0
- package/lib/skill/SKILL.md +35 -9
- package/lib/skill/references/cli.md +24 -7
- package/lib/skill/references/collaboration.md +355 -0
- package/lib/skill/references/discovery.md +5 -2
- package/lib/skill/references/pitfalls.md +2 -1
- package/lib/skill/references/routing.md +18 -8
- package/lib/skill/references/setup.md +152 -0
- package/lib/task-ledger.js +40 -4
- package/lib/types/canvas-store.d.ts +10 -1
- package/lib/types/faces/admin.d.ts +133 -0
- package/lib/types/faces/canvas-notify.d.ts +74 -0
- package/lib/types/faces/wire.d.ts +171 -0
- package/lib/types/flood-guard.d.ts +29 -0
- package/lib/types/index.d.ts +11 -3
- package/lib/types/recursion-guard.d.ts +56 -0
- package/lib/types/runtime.d.ts +35 -0
- package/lib/types/task-ledger.d.ts +15 -1
- package/nexus-stage/package-lock.json +2262 -2262
- package/nexus-stage/src/canvas-ops.ts +175 -175
- package/nexus-stage/src/canvas-wire.ts +156 -156
- package/nexus-stage/src/edges.ts +72 -72
- package/nexus-stage/src/federation-overlay.ts +103 -103
- package/nexus-stage/src/federation.ts +240 -240
- package/nexus-stage/src/layout-doc.ts +143 -143
- package/nexus-stage/src/layout-wire.ts +158 -158
- package/nexus-stage/src/main.ts +5 -1
- package/nexus-stage/src/planning-card-text.ts +73 -0
- package/nexus-stage/src/planning-edges.ts +26 -0
- package/nexus-stage/src/planning-fit.ts +45 -0
- package/nexus-stage/src/planning-legend.ts +38 -0
- package/nexus-stage/src/planning-mirror.ts +31 -0
- package/nexus-stage/src/planning-overlap.ts +159 -0
- package/nexus-stage/src/planning-relayout.ts +121 -0
- package/nexus-stage/src/planning-search.ts +168 -0
- package/nexus-stage/src/planning-types.ts +1 -0
- package/nexus-stage/src/planning-undo.ts +68 -0
- package/nexus-stage/src/planning-view.ts +120 -121
- package/nexus-stage/src/planning.css +41 -8
- package/nexus-stage/src/seat.ts +35 -35
- package/nexus-stage/src/stage-name.ts +13 -13
- package/nexus-stage/src/tokens.ts +31 -31
- package/nexus-stage/src/viewport.ts +24 -1
- package/nexus-stage/src/wb-chat.ts +20 -6
- package/nexus-stage/src/wb-ledger.ts +20 -7
- package/nexus-stage/src/wb-names.ts +43 -0
- package/nexus-stage/src/workbench.css +8 -2
- package/nexus-stage/src/workbench.ts +55 -17
- package/nexus-stage/src/world.ts +13 -21
- package/package.json +216 -214
- package/assets/nexusDist/assets/index-1niOBYxp.js +0 -5
- package/assets/nexusDist/assets/index-uQf7NwjF.css +0 -1
- package/nexus-stage/scripts/__pycache__/mockhost.cpython-312.pyc +0 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Board card text (review 2026-10-27): what a card's title, second line and
|
|
3
|
+
* tooltip say. Extracted from planning-view so the card rules read in one
|
|
4
|
+
* place (and the view stays under its M1 module-size cap).
|
|
5
|
+
*
|
|
6
|
+
* - Session cards used to read `dsh/11111111 · scout-01` on the second line:
|
|
7
|
+
* the title repeated, the handle truncated on every card. The line now
|
|
8
|
+
* carries what the session is FOR (its description) and falls back to the
|
|
9
|
+
* handle; the handle always rides the tooltip.
|
|
10
|
+
* - Peer cards titled `192.168.3.157:13080`, clipped by the peer badge with no
|
|
11
|
+
* break point. When the host publishes exactly one named team (its host
|
|
12
|
+
* row), that name is the title and the address moves to the second line.
|
|
13
|
+
* @module nexus-stage/planning-card-text
|
|
14
|
+
*/
|
|
15
|
+
import { displayName } from './stage-name'
|
|
16
|
+
import type { RemoteTeamRow } from './federation'
|
|
17
|
+
import type { WorldNode } from './world'
|
|
18
|
+
|
|
19
|
+
export interface CardText { readonly title: string; readonly sub: string; readonly tip: string }
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A description as one plain line. /state `description` is the session's
|
|
23
|
+
* recent-activity excerpt — the head of its last reply, newlines folded,
|
|
24
|
+
* capped at 160 chars — so on a real host it reads `# ✅ 已纠正——…`,
|
|
25
|
+
* `**全绿**`, `` `direct-…` `` and folded tables (`| 环节 | 结果 | |---|`).
|
|
26
|
+
* The card keeps the prose: it stops at the first table or code fence and
|
|
27
|
+
* drops heading, emphasis, code and link markup.
|
|
28
|
+
*/
|
|
29
|
+
export function plainLine(text: string): string {
|
|
30
|
+
let s = text
|
|
31
|
+
const stop = s.search(/\|| ```|^```/)
|
|
32
|
+
if (stop >= 0) s = s.slice(0, stop)
|
|
33
|
+
return s
|
|
34
|
+
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // [text](url) → text
|
|
35
|
+
.replace(/(^|\s)#{1,6}\s+/g, '$1') // headings
|
|
36
|
+
.replace(/\*\*|__|`/g, '') // bold, code
|
|
37
|
+
.replace(/\s+/g, ' ')
|
|
38
|
+
.trim()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The remote-team summary a peer card carries: score + count + first names. */
|
|
42
|
+
function remoteSummary(rows: ReadonlyArray<RemoteTeamRow>, score: number | undefined): string {
|
|
43
|
+
const names = rows.map(r => r.name !== undefined && r.name !== '' ? r.name : r.team)
|
|
44
|
+
const head = names.slice(0, 2).join('、')
|
|
45
|
+
const more = names.length > 2 ? ` +${names.length - 2}` : ''
|
|
46
|
+
return (score !== undefined ? `score ${score} · ` : '') + `${rows.length} 远端团队` + (head !== '' ? `:${head}${more}` : '')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function cardText(n: WorldNode, remoteRows: ReadonlyArray<RemoteTeamRow>, score: number | undefined): CardText {
|
|
50
|
+
// gh#38: member cards show their display NAME as the title (the whole
|
|
51
|
+
// point is picking a person).
|
|
52
|
+
if (n.id.startsWith('member-')) {
|
|
53
|
+
const title = n.name !== undefined && n.name !== '' ? n.name : n.team
|
|
54
|
+
return { title, sub: n.team, tip: n.team }
|
|
55
|
+
}
|
|
56
|
+
if (n.remote === true) {
|
|
57
|
+
const host = n.id.slice('peer-'.length)
|
|
58
|
+
const named = remoteRows.length === 1 && remoteRows[0]!.name !== undefined && remoteRows[0]!.name !== '' ? remoteRows[0]!.name : undefined
|
|
59
|
+
const summary = remoteSummary(remoteRows, score)
|
|
60
|
+
return named !== undefined
|
|
61
|
+
? { title: named, sub: `${host} · ${summary}`, tip: `${named}\n${host}\n${summary}` }
|
|
62
|
+
: { title: host, sub: summary, tip: `${host}\n${summary}` }
|
|
63
|
+
}
|
|
64
|
+
const title = displayName(n)
|
|
65
|
+
const handle = n.team + (n.name !== undefined && n.name !== '' ? ' · ' + n.name : '')
|
|
66
|
+
const desc = plainLine(n.description ?? '')
|
|
67
|
+
// When the title fell back to the team label the handle would repeat it
|
|
68
|
+
// verbatim; then only the suffixes remain.
|
|
69
|
+
const base = desc !== '' ? desc : handle === title ? '' : n.team
|
|
70
|
+
const suffix = (n.memberships.length > 1 ? ` · 跨队×${n.memberships.length}` : '')
|
|
71
|
+
+ (n.teams !== undefined && n.teams.length > 0 ? ` · 网络队×${n.teams.length}` : '')
|
|
72
|
+
return { title, sub: base + suffix, tip: desc !== '' ? `${handle}\n${desc}` : handle }
|
|
73
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The membership spokes' SVG layer (edges.ts stays pure geometry). Moved
|
|
3
|
+
* out of planning-view (module-size cap) when pointercancel landed.
|
|
4
|
+
* @module nexus-stage/planning-edges
|
|
5
|
+
*/
|
|
6
|
+
import { starEdges } from './edges'
|
|
7
|
+
import type { WorldModel } from './world'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Stateless, rebuilt wholesale. Derived from the MODEL (not the host's
|
|
11
|
+
* teams): optimistic membership changes must repaint their edges
|
|
12
|
+
* immediately, before the host confirms.
|
|
13
|
+
*/
|
|
14
|
+
export function drawMemberEdges(svg: SVGSVGElement, model: WorldModel): void {
|
|
15
|
+
while (svg.firstChild !== null) svg.firstChild.remove()
|
|
16
|
+
const teams = model.allFrames().map(([name]) => ({ name, members: model.teamMemberIds(name).map(id => ({ id })) }))
|
|
17
|
+
for (const e of starEdges(teams, new Map(model.allFrames()), model.positions())) {
|
|
18
|
+
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line')
|
|
19
|
+
line.setAttribute('class', 'e-member' + (e.dashed ? ' dashed' : ''))
|
|
20
|
+
line.setAttribute('x1', String(e.x1))
|
|
21
|
+
line.setAttribute('y1', String(e.y1))
|
|
22
|
+
line.setAttribute('x2', String(e.x2))
|
|
23
|
+
line.setAttribute('y2', String(e.y2))
|
|
24
|
+
svg.appendChild(line)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The board's 适配 (fit) framing (review 2026-10-27).
|
|
3
|
+
*
|
|
4
|
+
* 适配 used to fit the local content into the whole board at any scale: a
|
|
5
|
+
* small board zoomed to 167%, the top frame's title slid under the toolbar,
|
|
6
|
+
* and the peer cards on the right were cut off. 适配 now frames what the
|
|
7
|
+
* first-run fit frames:
|
|
8
|
+
* - local content plus the peer cards;
|
|
9
|
+
* - the frame titles that sit above their frames;
|
|
10
|
+
* - never magnified past 100%;
|
|
11
|
+
* - the area below the HUD (toolbar / status chip), not under it.
|
|
12
|
+
* @module nexus-stage/planning-fit
|
|
13
|
+
*/
|
|
14
|
+
import { FRAME_HEAD_TOP } from './edges'
|
|
15
|
+
import { fitView, type LayoutViewport, type WorldBounds } from './viewport'
|
|
16
|
+
import { nodeRect, type WorldNode } from './world'
|
|
17
|
+
|
|
18
|
+
/** Local content bounds ∪ peer cards, lifted to include the frame titles
|
|
19
|
+
* (a title straddles its frame's top border, FRAME_HEAD_TOP above it). */
|
|
20
|
+
export function boardBounds(content: WorldBounds | null, nodes: Iterable<WorldNode>): WorldBounds | null {
|
|
21
|
+
let b = content === null ? null : { ...content, minY: content.minY + FRAME_HEAD_TOP }
|
|
22
|
+
for (const n of nodes) {
|
|
23
|
+
if (n.remote !== true) continue
|
|
24
|
+
const r = nodeRect(n)
|
|
25
|
+
b = b === null
|
|
26
|
+
? { minX: r.x, minY: r.y, maxX: r.x + r.w, maxY: r.y + r.h }
|
|
27
|
+
: { minX: Math.min(b.minX, r.x), minY: Math.min(b.minY, r.y), maxX: Math.max(b.maxX, r.x + r.w), maxY: Math.max(b.maxY, r.y + r.h) }
|
|
28
|
+
}
|
|
29
|
+
return b
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Fit `bounds` at ≤100% into the board area below the HUD's bottom edge
|
|
33
|
+
* (`hudBottom` px from the board's top; 0 when the HUD is not laid out). */
|
|
34
|
+
export function fitBelowHud(bounds: WorldBounds | null, viewW: number, viewH: number, hudBottom: number): LayoutViewport {
|
|
35
|
+
const inset = hudBottom > 0 && hudBottom < viewH / 2 ? hudBottom : 0
|
|
36
|
+
const vp = fitView(bounds, viewW, viewH - inset, 1)
|
|
37
|
+
return inset === 0 || bounds === null ? vp : { ...vp, y: vp.y - inset / vp.scale }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The HUD's bottom edge inside the board root: the lowest of the toolbar
|
|
41
|
+
* and status chip, plus a small gap; 0 before layout (jsdom, hidden). */
|
|
42
|
+
export function hudBottomOf(...els: ReadonlyArray<HTMLElement>): number {
|
|
43
|
+
const bottom = Math.max(0, ...els.map(e => e.offsetHeight > 0 ? e.offsetTop + e.offsetHeight : 0))
|
|
44
|
+
return bottom > 0 ? bottom + 8 : 0
|
|
45
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The board legend (review 2026-10-27, real-host check): a fixed panel at
|
|
3
|
+
* the bottom-left sat over the lowest frames of a real 66-card board. It is
|
|
4
|
+
* now a native <details> fold — closed by default, a small 图例 chip that
|
|
5
|
+
* covers nothing; open, it shows the edge key. The open state is a
|
|
6
|
+
* per-viewer convenience in browser storage (absent or throwing → closed).
|
|
7
|
+
* @module nexus-stage/planning-legend
|
|
8
|
+
*/
|
|
9
|
+
const KEY = 'dsh-a2a.board.legend'
|
|
10
|
+
|
|
11
|
+
/** Build the legend fold. */
|
|
12
|
+
export function createLegend(): HTMLDetailsElement {
|
|
13
|
+
const legend = document.createElement('details')
|
|
14
|
+
legend.className = 'p-legend'
|
|
15
|
+
const summary = document.createElement('summary')
|
|
16
|
+
summary.textContent = '图例'
|
|
17
|
+
summary.title = '边的含义(点击展开 / 收起)'
|
|
18
|
+
const rows = document.createElement('div')
|
|
19
|
+
rows.className = 'p-legend-rows'
|
|
20
|
+
const row = (dashed: boolean, text: string, color?: string): void => {
|
|
21
|
+
const line = document.createElement('span')
|
|
22
|
+
const bar = document.createElement('i')
|
|
23
|
+
if (dashed) bar.className = 'dashed'
|
|
24
|
+
if (color !== undefined) bar.style.borderTopColor = color
|
|
25
|
+
line.append(bar, document.createTextNode(text))
|
|
26
|
+
rows.appendChild(line)
|
|
27
|
+
}
|
|
28
|
+
row(false, '成员边(星形,框→成员)')
|
|
29
|
+
row(true, '跨队多属(虚线)')
|
|
30
|
+
row(false, '活动边(inFlight 路由,瞬时)', 'var(--accent)')
|
|
31
|
+
row(true, '联邦线(→peer)', 'var(--federal)')
|
|
32
|
+
legend.append(summary, rows)
|
|
33
|
+
try { legend.open = window.localStorage.getItem(KEY) === 'open' } catch { /* closed */ }
|
|
34
|
+
legend.addEventListener('toggle', () => {
|
|
35
|
+
try { window.localStorage.setItem(KEY, legend.open ? 'open' : 'closed') } catch { /* convenience only */ }
|
|
36
|
+
})
|
|
37
|
+
return legend
|
|
38
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Owner vs mirror on the board (owner ruling 2026-10-29, PR#96: canvas sync
|
|
3
|
+
* is single owner + mirror). A team belongs to the host that made it; every
|
|
4
|
+
* other host renders it as a MIRROR of that host's published card and edits
|
|
5
|
+
* it through the fleet relay. The frame head says so: 「镜像 · <owner host>」,
|
|
6
|
+
* and 「同步中…」 from the moment a relayed edit is sent until the owner
|
|
7
|
+
* answers (the relay refreshes the owner's card before replying, so the next
|
|
8
|
+
* poll already shows the result). Local frames carry no marker — this host
|
|
9
|
+
* is their owner.
|
|
10
|
+
* @module nexus-stage/planning-mirror
|
|
11
|
+
*/
|
|
12
|
+
import { peerHostOf } from './federation'
|
|
13
|
+
|
|
14
|
+
export interface MirrorRoute {
|
|
15
|
+
readonly text: string
|
|
16
|
+
readonly title: string
|
|
17
|
+
readonly syncing: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The route line of a remote (mirrored) frame; undefined for a local frame. */
|
|
21
|
+
export function mirrorRoute(ownerUrl: string | undefined, syncing: boolean): MirrorRoute | undefined {
|
|
22
|
+
if (ownerUrl === undefined) return undefined
|
|
23
|
+
const host = peerHostOf(ownerUrl)
|
|
24
|
+
return {
|
|
25
|
+
text: syncing ? `同步中… · ${host}` : `镜像 · ${host}`,
|
|
26
|
+
title: syncing
|
|
27
|
+
? `已把改动交给属主 ${host},等待它确认`
|
|
28
|
+
: `这个团队属于 ${host},这里显示的是它的镜像;拖入或移出成员会经中继交给 ${host} 执行`,
|
|
29
|
+
syncing,
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Board overlap rules (owner, 2026-10-27: 团队方框需要避免遮盖和覆盖 — frames
|
|
3
|
+
* must not overlap each other, and a card must not sit on a frame it is not
|
|
4
|
+
* a member of).
|
|
5
|
+
*
|
|
6
|
+
* settleBoard() resolves both, deterministically:
|
|
7
|
+
* - Frame vs frame: one frame of an overlapping pair steps aside along the
|
|
8
|
+
* shortest of the four directions, carrying its members like a drag. The
|
|
9
|
+
* frame the user just touched stays where it was put; a remote org frame
|
|
10
|
+
* never moves on its own (gh#44: its rect is the user's), so a local frame
|
|
11
|
+
* yields to it; two remote frames are left alone.
|
|
12
|
+
* - Card vs frame: a card lying on a frame it does not belong to goes home
|
|
13
|
+
* first — the first free grid seat inside its own frame (memberships in
|
|
14
|
+
* state order) — and only a card with no frame (or no free seat) goes
|
|
15
|
+
* outside: right of that frame, else below it, else down a column right of
|
|
16
|
+
* every frame. No landing spot covers another card or a foreign frame, and
|
|
17
|
+
* frames are never moved or resized by this step. Members are never moved
|
|
18
|
+
* off their own frame; a member merely outside its frame (PR#85: a frame
|
|
19
|
+
* moves only itself) is left where it is.
|
|
20
|
+
* Callers run it after a frame drag or resize, after 整理成员 / 一键重排,
|
|
21
|
+
* and once when the board first has content — never on every poll (a poll
|
|
22
|
+
* does not reposition what the user arranged).
|
|
23
|
+
*
|
|
24
|
+
* tidyMembers() is the gh#48 整理成员 grid, moved here from planning-view.
|
|
25
|
+
* @module nexus-stage/planning-overlap
|
|
26
|
+
*/
|
|
27
|
+
import { NODE_W, NODE_H, nodeRect, type FrameGeom, type WorldModel } from './world'
|
|
28
|
+
|
|
29
|
+
/** Clearance kept between a moved frame or card and what it stepped off. */
|
|
30
|
+
export const OVERLAP_GAP = 24
|
|
31
|
+
/** Bound on resolution passes (a dense board settles in a few). */
|
|
32
|
+
const MAX_PASSES = 200
|
|
33
|
+
|
|
34
|
+
type Rect = { readonly x: number; readonly y: number; readonly w: number; readonly h: number }
|
|
35
|
+
const hit = (a: Rect, b: Rect): boolean => a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h
|
|
36
|
+
|
|
37
|
+
/** The shortest translation that takes `m` clear of `f` (plus the gap). */
|
|
38
|
+
function stepAside(m: Rect, f: Rect): { dx: number; dy: number } {
|
|
39
|
+
const right = f.x + f.w + OVERLAP_GAP - m.x
|
|
40
|
+
const left = f.x - OVERLAP_GAP - (m.x + m.w)
|
|
41
|
+
const down = f.y + f.h + OVERLAP_GAP - m.y
|
|
42
|
+
const up = f.y - OVERLAP_GAP - (m.y + m.h)
|
|
43
|
+
const dx = Math.abs(right) <= Math.abs(left) ? right : left
|
|
44
|
+
const dy = Math.abs(down) <= Math.abs(up) ? down : up
|
|
45
|
+
return Math.abs(dx) <= Math.abs(dy) ? { dx, dy: 0 } : { dx: 0, dy }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function moveFrame(model: WorldModel, name: string, dx: number, dy: number): void {
|
|
49
|
+
const snap = model.beginFrameDrag(name)
|
|
50
|
+
if (snap !== undefined) model.applyFrameDrag(snap, dx, dy)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Separate overlapping frames; true when anything moved. */
|
|
54
|
+
export function separateFrames(model: WorldModel, remote: ReadonlySet<string>, touched?: string): boolean {
|
|
55
|
+
let changed = false
|
|
56
|
+
for (let pass = 0; pass < MAX_PASSES; pass++) {
|
|
57
|
+
const frames = model.allFrames()
|
|
58
|
+
let pair: [string, FrameGeom, string, FrameGeom] | undefined
|
|
59
|
+
for (let i = 0; i < frames.length && pair === undefined; i++) {
|
|
60
|
+
for (let j = i + 1; j < frames.length; j++) {
|
|
61
|
+
const [a, ra] = frames[i]!
|
|
62
|
+
const [b, rb] = frames[j]!
|
|
63
|
+
if (remote.has(a) && remote.has(b)) continue
|
|
64
|
+
if (hit(ra, rb)) { pair = [a, ra, b, rb]; break }
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (pair === undefined) return changed
|
|
68
|
+
const [a, ra, b, rb] = pair
|
|
69
|
+
// Who yields: the lower stay score moves — remote 2 (never moves on its
|
|
70
|
+
// own), the frame just placed 1, any other 0; a tie moves the later one
|
|
71
|
+
// in board order. (A plain "a stays" check moved a remote b whenever a
|
|
72
|
+
// was the frame just placed — order-dependent: PR #83 review.)
|
|
73
|
+
const stay = (n: string): number => remote.has(n) ? 2 : n === touched ? 1 : 0
|
|
74
|
+
const [mover, mr, fixed] = stay(a) < stay(b) ? [a, ra, rb] : [b, rb, ra]
|
|
75
|
+
const { dx, dy } = stepAside(mr, fixed)
|
|
76
|
+
moveFrame(model, mover, dx, dy)
|
|
77
|
+
changed = true
|
|
78
|
+
}
|
|
79
|
+
return changed
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Grid of the 整理成员 layout, shared by eviction seats. */
|
|
83
|
+
const PAD = 28
|
|
84
|
+
const GAP_X = 16
|
|
85
|
+
const GAP_Y = 14
|
|
86
|
+
|
|
87
|
+
/** Card-centred seats inside a frame, row by row (the 整理成员 grid). */
|
|
88
|
+
function seatsIn(f: Rect): Array<{ x: number; y: number }> {
|
|
89
|
+
const out: Array<{ x: number; y: number }> = []
|
|
90
|
+
for (let y = f.y + PAD; y + NODE_H <= f.y + f.h - PAD; y += NODE_H + GAP_Y) {
|
|
91
|
+
for (let x = f.x + PAD; x + NODE_W <= f.x + f.w - PAD; x += NODE_W + GAP_X) out.push({ x: x + NODE_W / 2, y: y + NODE_H / 2 })
|
|
92
|
+
}
|
|
93
|
+
return out
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Move cards off frames they do not belong to; true when anything moved. */
|
|
97
|
+
export function evictStrangers(model: WorldModel): boolean {
|
|
98
|
+
let changed = false
|
|
99
|
+
const frames = model.allFrames()
|
|
100
|
+
const rectOf = new Map(frames)
|
|
101
|
+
for (const n of model.allNodes()) {
|
|
102
|
+
const own = new Set(n.memberships.map(m => m.team))
|
|
103
|
+
const host = frames.find(([name, f]) => !own.has(name) && hit(nodeRect(n), f))
|
|
104
|
+
if (host === undefined) continue
|
|
105
|
+
const f = host[1]
|
|
106
|
+
// Free = no foreign frame and no other card under it (cards read live: earlier evictions count).
|
|
107
|
+
const free = (p: { x: number; y: number }): boolean => {
|
|
108
|
+
const r = nodeRect(p)
|
|
109
|
+
return frames.every(([name, g]) => own.has(name) || !hit(r, g)) &&
|
|
110
|
+
model.allNodes().every(o => o.id === n.id || !hit(r, nodeRect(o)))
|
|
111
|
+
}
|
|
112
|
+
const home = n.memberships.map(m => rectOf.get(m.team)).filter((g): g is FrameGeom => g !== undefined)
|
|
113
|
+
const seat = home.flatMap(seatsIn).find(free)
|
|
114
|
+
const right = { x: f.x + f.w + OVERLAP_GAP + NODE_W / 2, y: n.y }
|
|
115
|
+
const below = { x: n.x, y: f.y + f.h + OVERLAP_GAP + NODE_H / 2 }
|
|
116
|
+
// Last resort: a column right of every frame, top-down from this frame. A card blocks
|
|
117
|
+
// at most two seats of this pitch, so 2·cards+1 seats always hold a free one.
|
|
118
|
+
const farX = Math.max(...frames.map(([, g]) => g.x + g.w)) + OVERLAP_GAP + NODE_W / 2
|
|
119
|
+
const column = Array.from({ length: 2 * model.allNodes().length + 1 }, (_, i) => ({ x: farX, y: f.y + NODE_H / 2 + i * (NODE_H + GAP_Y) }))
|
|
120
|
+
const to = seat ?? [right, below, ...column].find(free) ?? column[column.length - 1]!
|
|
121
|
+
model.dragNodes([n.id], to.x - n.x, to.y - n.y)
|
|
122
|
+
changed = true
|
|
123
|
+
}
|
|
124
|
+
return changed
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Both rules, frames first (moving a frame can uncover or cover cards). */
|
|
128
|
+
export function settleBoard(model: WorldModel, remote: ReadonlySet<string>, touched?: string): boolean {
|
|
129
|
+
const framesMoved = separateFrames(model, remote, touched)
|
|
130
|
+
const cardsMoved = evictStrangers(model)
|
|
131
|
+
return framesMoved || cardsMoved
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* gh#48 整理成员: inside every frame, the members that belong to exactly that
|
|
136
|
+
* one team re-seat into a padded grid; the frame grows downward (bounded) to
|
|
137
|
+
* fit them. Members of 2+ teams keep their positions.
|
|
138
|
+
*/
|
|
139
|
+
export function tidyMembers(model: WorldModel): void {
|
|
140
|
+
for (const [name, rect] of model.allFrames()) {
|
|
141
|
+
const sole = model.teamMemberIds(name)
|
|
142
|
+
.map(id => model.getNode(id))
|
|
143
|
+
.filter((n): n is NonNullable<typeof n> => n !== undefined)
|
|
144
|
+
.filter(n => n.memberships.length === 1)
|
|
145
|
+
if (sole.length === 0) continue // empty frame / only shared members: untouched
|
|
146
|
+
const perRow = Math.max(1, Math.floor((rect.w - 2 * PAD + GAP_X) / (NODE_W + GAP_X)))
|
|
147
|
+
const rows = Math.ceil(sole.length / perRow)
|
|
148
|
+
const needed = PAD * 2 + rows * NODE_H + (rows - 1) * GAP_Y
|
|
149
|
+
if (needed > rect.h) model.setFrame(name, { ...rect, h: Math.min(needed, rect.h + 400) })
|
|
150
|
+
const startX = rect.x + PAD + NODE_W / 2
|
|
151
|
+
let y = rect.y + PAD + NODE_H / 2
|
|
152
|
+
sole.forEach((n, i) => {
|
|
153
|
+
const col = i % perRow
|
|
154
|
+
if (i > 0 && col === 0) y += NODE_H + GAP_Y
|
|
155
|
+
const x = startX + col * (NODE_W + GAP_X)
|
|
156
|
+
model.dragNodes([n.id], x - n.x, y - n.y)
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 「一键重排」board relayout (supervisor order v2, upgrade of gh#48): a
|
|
3
|
+
* deterministic whole-board re-seat — every LOCAL canvas team frame is
|
|
4
|
+
* resized from its content (sole-member count drives cols/rows; width AND
|
|
5
|
+
* height both derive from the grid), then the frames tile in a row flow
|
|
6
|
+
* (constructively non-overlapping, no collision pass). Multi-team members
|
|
7
|
+
* never enter the size derivation and never move (their dashed edges
|
|
8
|
+
* express membership); remote org frames and frameless nodes are out of
|
|
9
|
+
* scope. Pure geometry: same input, same output — pressing twice is
|
|
10
|
+
* stable by construction.
|
|
11
|
+
* @module nexus-stage/planning-relayout
|
|
12
|
+
*/
|
|
13
|
+
import { NODE_W, NODE_H, type FrameGeom, type WorldModel } from './world'
|
|
14
|
+
|
|
15
|
+
/** Inner padding between frame edge and the card grid (gh#48 family). */
|
|
16
|
+
export const RELAYOUT_PAD = 28
|
|
17
|
+
/** Card gaps inside a frame (same family as the tidy grid). */
|
|
18
|
+
export const RELAYOUT_GAP_X = 16
|
|
19
|
+
export const RELAYOUT_GAP_Y = 14
|
|
20
|
+
/** Gap between neighbouring frames in the row-flow tiling. */
|
|
21
|
+
export const FRAME_GAP = 48
|
|
22
|
+
/** Column cap: perRow = min(4, ceil(sqrt(n))). */
|
|
23
|
+
export const PER_ROW_CAP = 4
|
|
24
|
+
/** Floor widths/heights: keeps the floating title band legible. */
|
|
25
|
+
export const FRAME_MIN_W = 180
|
|
26
|
+
export const FRAME_MIN_H = 90
|
|
27
|
+
/** Default world-space wrap width of one tiling row. */
|
|
28
|
+
export const RELAYOUT_WRAP = 1600
|
|
29
|
+
|
|
30
|
+
/** One local frame to re-seat: its current rect and its SOLE member ids. */
|
|
31
|
+
export interface RelayoutFrameInput {
|
|
32
|
+
readonly name: string
|
|
33
|
+
readonly rect: FrameGeom
|
|
34
|
+
readonly soleIds: readonly string[]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The deterministic outcome: new frame rects and absolute card centers. */
|
|
38
|
+
export interface RelayoutOutput {
|
|
39
|
+
readonly frames: ReadonlyArray<{ name: string; rect: FrameGeom }>
|
|
40
|
+
readonly seats: ReadonlyArray<{ id: string; x: number; y: number }>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Derive the whole-board layout. Frame order in = tiling order out (the
|
|
45
|
+
* caller passes a stable order, e.g. the model's insertion order, which
|
|
46
|
+
* keeps the result deterministic across presses).
|
|
47
|
+
*/
|
|
48
|
+
export function planBoardRelayout(input: {
|
|
49
|
+
frames: ReadonlyArray<RelayoutFrameInput>
|
|
50
|
+
wrapWidth?: number
|
|
51
|
+
}): RelayoutOutput {
|
|
52
|
+
const wrap = input.wrapWidth ?? RELAYOUT_WRAP
|
|
53
|
+
// Pass 1 — content-derived size per frame (multi-team members excluded
|
|
54
|
+
// upstream: soleIds carries exactly the re-seatable cards).
|
|
55
|
+
const sized = input.frames.map(f => {
|
|
56
|
+
const n = Math.max(f.soleIds.length, 1)
|
|
57
|
+
const perRow = Math.min(PER_ROW_CAP, Math.max(1, Math.ceil(Math.sqrt(n))))
|
|
58
|
+
const rows = Math.ceil(n / perRow)
|
|
59
|
+
const w = Math.max(FRAME_MIN_W, RELAYOUT_PAD * 2 + perRow * NODE_W + (perRow - 1) * RELAYOUT_GAP_X)
|
|
60
|
+
const h = Math.max(FRAME_MIN_H, RELAYOUT_PAD * 2 + rows * NODE_H + (rows - 1) * RELAYOUT_GAP_Y)
|
|
61
|
+
return { name: f.name, w, h, rows, perRow, soleIds: f.soleIds }
|
|
62
|
+
})
|
|
63
|
+
// Pass 2 — row-flow tiling: left→right until the wrap width is full,
|
|
64
|
+
// then advance one row by the tallest frame of the band. Constructive
|
|
65
|
+
// disjointness: boxes on one band share the y band and never overlap in
|
|
66
|
+
// x; bands stack with FRAME_GAP.
|
|
67
|
+
const frames: Array<{ name: string; rect: FrameGeom }> = []
|
|
68
|
+
const seats: Array<{ id: string; x: number; y: number }> = []
|
|
69
|
+
let x = 0
|
|
70
|
+
let y = 0
|
|
71
|
+
let bandH = 0
|
|
72
|
+
for (const f of sized) {
|
|
73
|
+
if (x > 0 && x + f.w > wrap) {
|
|
74
|
+
x = 0
|
|
75
|
+
y += bandH + FRAME_GAP
|
|
76
|
+
bandH = 0
|
|
77
|
+
}
|
|
78
|
+
const rect: FrameGeom = { x, y, w: f.w, h: f.h }
|
|
79
|
+
frames.push({ name: f.name, rect })
|
|
80
|
+
// Pass 3 — seat the sole cards on the new rect's grid (the gh#48 rule,
|
|
81
|
+
// driven by the NEW frame geometry).
|
|
82
|
+
const startX = rect.x + RELAYOUT_PAD + NODE_W / 2
|
|
83
|
+
let cy = rect.y + RELAYOUT_PAD + NODE_H / 2
|
|
84
|
+
f.soleIds.forEach((id, i) => {
|
|
85
|
+
const col = i % f.perRow
|
|
86
|
+
if (i > 0 && col === 0) cy += NODE_H + RELAYOUT_GAP_Y
|
|
87
|
+
seats.push({ id, x: startX + col * (NODE_W + RELAYOUT_GAP_X), y: cy })
|
|
88
|
+
})
|
|
89
|
+
x += f.w + FRAME_GAP
|
|
90
|
+
bandH = Math.max(bandH, f.h)
|
|
91
|
+
}
|
|
92
|
+
return { frames, seats }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The model adapter (supervisor order v2): collect the LOCAL frames and
|
|
97
|
+
* their sole members (remote org frames and multi-team members are out of
|
|
98
|
+
* scope by spec), run the pure planner, apply the rects and seat moves
|
|
99
|
+
* through the model's own mutation seam. Returns false when nothing is in
|
|
100
|
+
* scope (the caller decides whether to dirty the layout).
|
|
101
|
+
*/
|
|
102
|
+
export function applyBoardRelayout(model: WorldModel, remoteNames: ReadonlySet<string>): boolean {
|
|
103
|
+
const frames: Array<{ name: string; rect: FrameGeom; soleIds: string[] }> = []
|
|
104
|
+
for (const [name, rect] of model.allFrames()) {
|
|
105
|
+
if (remoteNames.has(name)) continue // remote org frames: untouched
|
|
106
|
+
const soleIds = model.teamMemberIds(name)
|
|
107
|
+
.map(id => model.getNode(id))
|
|
108
|
+
.filter((n): n is NonNullable<typeof n> => n !== undefined)
|
|
109
|
+
.filter(n => n.memberships.length === 1)
|
|
110
|
+
.map(n => n.id)
|
|
111
|
+
frames.push({ name, rect, soleIds })
|
|
112
|
+
}
|
|
113
|
+
if (frames.length === 0) return false
|
|
114
|
+
const out = planBoardRelayout({ frames })
|
|
115
|
+
for (const f of out.frames) model.setFrame(f.name, f.rect)
|
|
116
|
+
for (const seat of out.seats) {
|
|
117
|
+
const n = model.getNode(seat.id)
|
|
118
|
+
if (n !== undefined) model.dragNodes([seat.id], seat.x - n.x, seat.y - n.y)
|
|
119
|
+
}
|
|
120
|
+
return true
|
|
121
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 画板全局搜索(属主令 W1): a toolbar search box (Ctrl+K focuses) over the
|
|
3
|
+
* board's node FACTS — display name, zone/id8 handle, and host spelling, in
|
|
4
|
+
* one substring match (case-insensitive). Picking a hit rides the existing
|
|
5
|
+
* fitView flight chain (the caller frames ~100% and pulses the card);
|
|
6
|
+
* Escape clears. Pure front end: no backend, no routing, no persistence.
|
|
7
|
+
* @module nexus-stage/planning-search
|
|
8
|
+
*/
|
|
9
|
+
import { fitView, type LayoutViewport } from './viewport'
|
|
10
|
+
import { NODE_W, NODE_H } from './world'
|
|
11
|
+
/** One searchable node fact row (already display-resolved by the caller). */
|
|
12
|
+
export interface SearchHit {
|
|
13
|
+
readonly id: string
|
|
14
|
+
/** Display name (member cards' person name, peer hosts' spelling). */
|
|
15
|
+
readonly name: string
|
|
16
|
+
/** The zone/id8 handle (n.team) and any extra host tokens. */
|
|
17
|
+
readonly team: string
|
|
18
|
+
/** Optional host/owner token (peer cards carry the host spelling). */
|
|
19
|
+
readonly host?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Case-insensitive substring match across name / team / host. */
|
|
23
|
+
export function matchHits(rows: ReadonlyArray<SearchHit>, query: string): SearchHit[] {
|
|
24
|
+
const q = query.trim().toLowerCase()
|
|
25
|
+
if (q === '') return []
|
|
26
|
+
return rows.filter(r =>
|
|
27
|
+
r.name.toLowerCase().includes(q) || r.team.toLowerCase().includes(q) || (r.host ?? '').toLowerCase().includes(q))
|
|
28
|
+
.slice(0, 8)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Build the searchable rows from the board model — the SAME display facts
|
|
33
|
+
* the cards render (member display name / peer host spelling / its handle),
|
|
34
|
+
* so every hit is a card the user can see.
|
|
35
|
+
*/
|
|
36
|
+
export function hitsFromModel(model: { allNodes(): ReadonlyArray<{ id: string; name?: string; team: string; remote?: boolean }> }, displayName: (n: never) => string): SearchHit[] {
|
|
37
|
+
return model.allNodes().map(n => {
|
|
38
|
+
const host = n.id.slice('peer-'.length)
|
|
39
|
+
const name = n.id.startsWith('member-')
|
|
40
|
+
? (n.name !== undefined && n.name !== '' ? n.name : n.team)
|
|
41
|
+
: n.remote === true ? host : displayName(n as never)
|
|
42
|
+
return { id: n.id, name, team: n.team, ...(n.remote === true ? { host } : {}) }
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** W1 pulse: briefly ring the picked card (class removed on the timer). */
|
|
47
|
+
export function pulseCard(el: HTMLElement | undefined, ms = 1200): void {
|
|
48
|
+
if (el === undefined) return
|
|
49
|
+
el.classList.add('pulse')
|
|
50
|
+
setTimeout(() => el.classList.remove('pulse'), ms)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The view seams the node flyer needs (the view keeps owning its model). */
|
|
54
|
+
export interface NodeFlyerSpec {
|
|
55
|
+
readonly model: { getNode(id: string): { x: number; y: number } | undefined }
|
|
56
|
+
readonly viewSize: () => { w: number; h: number }
|
|
57
|
+
readonly setVp: (vp: LayoutViewport) => void
|
|
58
|
+
readonly applyViewport: () => void
|
|
59
|
+
readonly markDirty: () => void
|
|
60
|
+
readonly cardEl: (id: string) => HTMLElement | undefined
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* W1 pick flight: frame ONE node's rect at ~100% (the same fitView chain the
|
|
65
|
+
* statusbar fly rides) and pulse its card for 1.2s.
|
|
66
|
+
*/
|
|
67
|
+
export function createNodeFlyer(spec: NodeFlyerSpec): (id: string) => void {
|
|
68
|
+
return id => {
|
|
69
|
+
const n = spec.model.getNode(id)
|
|
70
|
+
if (n === undefined) return
|
|
71
|
+
const size = spec.viewSize()
|
|
72
|
+
// fitView takes WorldBounds ({minX,minY,maxX,maxY}), not the card rect —
|
|
73
|
+
// passing {x,y,w,h} silently yielded NaN → IDENTITY (flight never landed).
|
|
74
|
+
spec.setVp(fitView({ minX: n.x, minY: n.y, maxX: n.x + NODE_W, maxY: n.y + NODE_H }, size.w, size.h, 1))
|
|
75
|
+
spec.applyViewport()
|
|
76
|
+
spec.markDirty()
|
|
77
|
+
pulseCard(spec.cardEl(id))
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface SearchMount extends PlanningSearch {
|
|
82
|
+
/** Rebuild the index from the model (call from the node reconcile). */
|
|
83
|
+
refresh(): void
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Mount the whole search surface into the toolbar — the wiring lines the
|
|
88
|
+
* view used to carry live here (create + append + index builder), keeping
|
|
89
|
+
* the view under the M1 ratchet; the view keeps ONE call.
|
|
90
|
+
*/
|
|
91
|
+
export function mountBoardSearch(
|
|
92
|
+
toolbar: HTMLElement,
|
|
93
|
+
model: Parameters<typeof hitsFromModel>[0],
|
|
94
|
+
displayName: Parameters<typeof hitsFromModel>[1],
|
|
95
|
+
onPick: (id: string) => void,
|
|
96
|
+
): SearchMount {
|
|
97
|
+
const search = createPlanningSearch({ onPick })
|
|
98
|
+
toolbar.appendChild(search.root)
|
|
99
|
+
return { ...search, refresh: () => search.setIndex(hitsFromModel(model, displayName)) }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface PlanningSearch {
|
|
103
|
+
readonly root: HTMLElement
|
|
104
|
+
/** Refresh the searchable index (called from the node reconcile). */
|
|
105
|
+
setIndex(rows: ReadonlyArray<SearchHit>): void
|
|
106
|
+
/** Focus the box (Ctrl+K) and select the current text. */
|
|
107
|
+
focus(): void
|
|
108
|
+
/** Clear the query and the dropdown (Escape elsewhere). */
|
|
109
|
+
clear(): void
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Build the search box widget (input + hit list + keyboard handling). */
|
|
113
|
+
export function createPlanningSearch(opts: { onPick: (id: string) => void }): PlanningSearch {
|
|
114
|
+
const root = document.createElement('div')
|
|
115
|
+
root.className = 'p-search'
|
|
116
|
+
const input = document.createElement('input')
|
|
117
|
+
input.type = 'search'
|
|
118
|
+
input.className = 'p-search-input'
|
|
119
|
+
// Short enough to read whole in the 170px box (review 2026-10-27); the
|
|
120
|
+
// full scope rides the tooltip and the accessible name.
|
|
121
|
+
input.placeholder = '🔍 搜索(Ctrl+K)'
|
|
122
|
+
input.title = '搜索名字 / 句柄 / 宿主(Ctrl+K 聚焦,Esc 清空)'
|
|
123
|
+
input.setAttribute('aria-label', '搜索画板节点:名字、句柄或宿主')
|
|
124
|
+
const list = document.createElement('div')
|
|
125
|
+
list.className = 'p-search-list'
|
|
126
|
+
let rows: ReadonlyArray<SearchHit> = []
|
|
127
|
+
let hits: SearchHit[] = []
|
|
128
|
+
let active = 0
|
|
129
|
+
const close = (): void => { list.style.display = 'none'; list.textContent = '' }
|
|
130
|
+
const paint = (): void => {
|
|
131
|
+
list.textContent = ''
|
|
132
|
+
if (hits.length === 0) { close(); return }
|
|
133
|
+
hits.forEach((h, i) => {
|
|
134
|
+
const row = document.createElement('div')
|
|
135
|
+
row.className = 'p-search-hit' + (i === active ? ' active' : '')
|
|
136
|
+
row.dataset.id = h.id
|
|
137
|
+
row.textContent = h.name === h.team ? h.name : `${h.name} · ${h.team}`
|
|
138
|
+
row.addEventListener('mousedown', ev => { ev.preventDefault(); opts.onPick(h.id); clearBox() })
|
|
139
|
+
list.appendChild(row)
|
|
140
|
+
})
|
|
141
|
+
list.style.display = 'block'
|
|
142
|
+
}
|
|
143
|
+
const clearBox = (): void => { input.value = ''; hits = []; close() }
|
|
144
|
+
const refresh = (): void => { hits = matchHits(rows, input.value); active = 0; paint() }
|
|
145
|
+
input.addEventListener('input', refresh)
|
|
146
|
+
input.addEventListener('keydown', ev => {
|
|
147
|
+
if (ev.key === 'Escape') { clearBox(); input.blur(); ev.stopPropagation(); return }
|
|
148
|
+
if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') {
|
|
149
|
+
if (hits.length === 0) return
|
|
150
|
+
ev.preventDefault()
|
|
151
|
+
active = (active + (ev.key === 'ArrowDown' ? 1 : hits.length - 1)) % hits.length
|
|
152
|
+
paint()
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
if (ev.key === 'Enter' && hits.length > 0) {
|
|
156
|
+
ev.preventDefault()
|
|
157
|
+
opts.onPick(hits[active]!.id)
|
|
158
|
+
clearBox()
|
|
159
|
+
}
|
|
160
|
+
})
|
|
161
|
+
root.append(input, list)
|
|
162
|
+
return {
|
|
163
|
+
root,
|
|
164
|
+
setIndex(next) { rows = next; if (input.value !== '') refresh() },
|
|
165
|
+
focus() { input.focus(); input.select() },
|
|
166
|
+
clear() { clearBox() },
|
|
167
|
+
}
|
|
168
|
+
}
|
|
@@ -105,6 +105,7 @@ export interface PlanningView {
|
|
|
105
105
|
pointerDown(ev: SeamPointer): void
|
|
106
106
|
pointerMove(ev: SeamPointer): void
|
|
107
107
|
pointerUp(ev: SeamPointer): void
|
|
108
|
+
pointerCancel(ev: SeamPointer): void
|
|
108
109
|
wheel(ev: SeamPointer): void
|
|
109
110
|
key(ev: SeamKey): void
|
|
110
111
|
keyUp(ev: SeamKey): void
|