@nelsonlongxiang/dsh-open-a2a-net 0.6.29 → 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.
Files changed (36) hide show
  1. package/README.md +2 -0
  2. package/README_zh.md +2 -0
  3. package/assets/nexusDist/assets/index-C_x3iAth.js +9 -0
  4. package/assets/nexusDist/assets/index-Dz8N80tI.css +1 -0
  5. package/assets/nexusDist/index.html +2 -2
  6. package/cli/a2a.mjs +624 -624
  7. package/lib/canvas-store.js +21 -1
  8. package/lib/client.js +64 -64
  9. package/lib/client.js.map +1 -1
  10. package/lib/faces/admin.js +12 -1
  11. package/lib/faces/canvas-notify.js +138 -0
  12. package/lib/faces/wire.js +669 -0
  13. package/lib/flood-guard.js +106 -0
  14. package/lib/index.js +69 -663
  15. package/lib/skill/SKILL.md +1 -0
  16. package/lib/skill/references/collaboration.md +355 -0
  17. package/lib/task-ledger.js +34 -4
  18. package/lib/types/canvas-store.d.ts +10 -1
  19. package/lib/types/faces/admin.d.ts +2 -1
  20. package/lib/types/faces/canvas-notify.d.ts +74 -0
  21. package/lib/types/faces/wire.d.ts +171 -0
  22. package/lib/types/flood-guard.d.ts +29 -0
  23. package/lib/types/runtime.d.ts +1 -0
  24. package/lib/types/task-ledger.d.ts +9 -1
  25. package/nexus-stage/src/main.ts +5 -1
  26. package/nexus-stage/src/planning-edges.ts +26 -0
  27. package/nexus-stage/src/planning-mirror.ts +31 -0
  28. package/nexus-stage/src/planning-overlap.ts +36 -11
  29. package/nexus-stage/src/planning-types.ts +1 -0
  30. package/nexus-stage/src/planning-undo.ts +68 -0
  31. package/nexus-stage/src/planning-view.ts +58 -34
  32. package/nexus-stage/src/planning.css +2 -0
  33. package/nexus-stage/src/world.ts +8 -20
  34. package/package.json +1 -1
  35. package/assets/nexusDist/assets/index-BqepxJ7E.css +0 -1
  36. package/assets/nexusDist/assets/index-CTWH6QRh.js +0 -9
@@ -0,0 +1,171 @@
1
+ /**
2
+ * The /a2a/* wire faces (gh#77 batch B2; slice 2 adds log + query).
3
+ *
4
+ * Moved verbatim out of apply() in src/index.ts: the routes register when
5
+ * the web server settles, exactly as before. What used to be closure
6
+ * captures arrives through WireDeps and is destructured once below, so the
7
+ * route bodies read as they did in index.ts (the B1 admin.ts pattern). The
8
+ * one accessor deviation: the log face's `legacyInboundCount += 1` (a let in
9
+ * index.ts) goes through `d.bumpLegacyInbound()`, the same mutable-binding
10
+ * treatment admin.ts gives its six lets. direct stays inline for slice 3.
11
+ * @module src/faces/wire
12
+ */
13
+ import type { IncomingMessage, ServerResponse } from 'node:http';
14
+ import type { Context } from '@deepseek-ai/cordis';
15
+ import type { Agent } from '@deepseek-ai/dsh-agent';
16
+ import type WebServer from '@deepseek-ai/dsh-host-webserver';
17
+ import type { A2aPeerCard } from '../types.ts';
18
+ import { CanvasStore } from '../canvas-store.ts';
19
+ import type { CliDepositStore } from '../cli-deposit.ts';
20
+ import type { IdempotencyStore } from '../idempotency-store.ts';
21
+ import type { JoinedSessions } from '../joined-store.ts';
22
+ import { type TaskLedger } from '../task-ledger.ts';
23
+ import type { Config } from '../index.ts';
24
+ /** The cold-wake answer (mirrors index.ts's local ColdWake). */
25
+ export type ColdWake = {
26
+ readonly kind: 'flight';
27
+ readonly flight: Promise<Agent>;
28
+ } | {
29
+ readonly kind: 'no-face';
30
+ } | {
31
+ readonly kind: 'archived';
32
+ } | {
33
+ readonly kind: 'no-match';
34
+ };
35
+ /** routeIntoAgent's settled answer (mirrors index.ts's local union). */
36
+ export type RouteOutcome = {
37
+ ok: true;
38
+ reply: string;
39
+ status?: string;
40
+ bridge?: 'native-teams';
41
+ placeholder?: boolean;
42
+ } | {
43
+ ok: false;
44
+ error: string;
45
+ };
46
+ interface BridgeTeamsRegistryMirror {
47
+ listTeams(): Array<{
48
+ name: string;
49
+ description: string;
50
+ }>;
51
+ describeTarget(handle: string): Promise<{
52
+ plane: 'local' | 'a2a';
53
+ ambiguous?: boolean;
54
+ localLabel?: string;
55
+ }>;
56
+ startRound(args: {
57
+ team?: string;
58
+ message: string;
59
+ }, parent: {
60
+ id: string;
61
+ session: {
62
+ events?: readonly unknown[];
63
+ };
64
+ }, signal: AbortSignal): Promise<string>;
65
+ }
66
+ /** nativeTeamsPrepare's answer (mirrors index.ts's local union). */
67
+ export type NativePrepared = {
68
+ readonly ok: true;
69
+ readonly teams: BridgeTeamsRegistryMirror;
70
+ readonly parent: {
71
+ id: string;
72
+ session: {
73
+ events?: readonly unknown[];
74
+ };
75
+ };
76
+ } | {
77
+ readonly ok: false;
78
+ readonly reason: 'not-claimed';
79
+ } | {
80
+ readonly ok: false;
81
+ readonly reason: 'error';
82
+ readonly error: string;
83
+ };
84
+ /** nativeTeamsRound's settled answer (mirrors index.ts's local union). */
85
+ export type NativeRoundOutcome = {
86
+ readonly ok: true;
87
+ readonly reply: string;
88
+ readonly settled: boolean;
89
+ } | {
90
+ readonly ok: false;
91
+ readonly error: string;
92
+ readonly phase?: 'pre-dispatch';
93
+ readonly aborted?: boolean;
94
+ };
95
+ /** What verifyOriginClaim answers (see index.ts; reason widens to string here — the faces only interpolate it). */
96
+ export type OriginClaimResult = {
97
+ ok: true;
98
+ caller: string;
99
+ callerUrl: string;
100
+ card: A2aPeerCard;
101
+ } | {
102
+ ok: false;
103
+ reason: string;
104
+ };
105
+ /** The closure captures the wire faces read (grows with each extracted face). */
106
+ export interface WireDeps {
107
+ readonly ctx: Context;
108
+ readonly logger: ReturnType<Context['logger']>;
109
+ readonly config: Config;
110
+ readonly cliDeposits: CliDepositStore;
111
+ readonly canvasStore: CanvasStore;
112
+ readonly sessionNodes: Map<string, Agent>;
113
+ readonly joinedSessions: JoinedSessions;
114
+ readonly idempotencyStore: IdempotencyStore;
115
+ readonly verifyOriginClaim: (req: IncomingMessage) => Promise<OriginClaimResult>;
116
+ readonly callerLinkedToThisHost: (caller: string, callerUrl: string, callerCard: A2aPeerCard | undefined) => boolean;
117
+ readonly recordActivity: (dir: 'in' | 'out', team: string, peer: string, ok: boolean) => void;
118
+ readonly bumpLegacyInbound: () => void;
119
+ readonly secretEqual: (a: string, b: string) => boolean;
120
+ readonly a2aCallerRefusal: (caller: string, targetTeam?: string) => string | undefined;
121
+ readonly scanSessionLogList: () => Array<{
122
+ id: string;
123
+ mtimeMs: number;
124
+ sizeBytes: number;
125
+ }>;
126
+ readonly readSessionLogTail: (id: string, limit: number) => {
127
+ ok: true;
128
+ id: string;
129
+ messages: Array<{
130
+ role: string;
131
+ text: string;
132
+ time?: number;
133
+ }>;
134
+ } | {
135
+ ok: false;
136
+ error: string;
137
+ };
138
+ readonly taskLedger: TaskLedger;
139
+ readonly refuseIfFlooded: (caller: string, message: string, team: string, taskId: string, res: ServerResponse) => boolean;
140
+ readonly settleAndAnnounce: (message: string) => void;
141
+ readonly touchWakeDemand: () => void;
142
+ readonly session: string;
143
+ readonly resolveAgentForTeam: (team: string) => Agent | undefined;
144
+ readonly parseCanvasTeamName: (team: string) => string | undefined;
145
+ readonly canvasLiveAgent: (name: string | undefined, skipId?: string) => Agent | undefined;
146
+ readonly wakeColdTeam: (team: string, skipId?: string) => ColdWake;
147
+ readonly probeConsumption: (agent: Agent, taskId: string) => boolean;
148
+ readonly steerRelay: (agent: Agent, text: string, sender?: string) => void;
149
+ readonly fromClause: (from: string) => string;
150
+ readonly armReceiptAutosend: (target: Agent, taskId: string, callerTeam: string | undefined, deliveredText: string) => void;
151
+ readonly receiptExpectationOf: (receiptTarget: string) => 'receivable' | 'process-label';
152
+ readonly armAsyncNudge: (agent: Agent, team: string, taskId: string, delayMs: number) => void;
153
+ readonly liveAgent: () => Agent | undefined;
154
+ readonly callerIsAgent: (agent: Agent, caller: string) => boolean;
155
+ readonly selfRouteText: (team: string) => string;
156
+ readonly noWakeFaceText: (team: string) => string;
157
+ readonly archivedColdMatchText: (team: string) => string;
158
+ readonly noColdMatchText: (team: string) => string;
159
+ readonly nativeTeamsPrepare: (team: string) => Promise<NativePrepared>;
160
+ readonly nativeTeamsRound: (prepared: Extract<NativePrepared, {
161
+ ok: true;
162
+ }>, team: string, caller: string, message: string, taskId: string | undefined) => Promise<NativeRoundOutcome>;
163
+ readonly routeIntoAgent: (team: string, message: string, caller: string, taskId?: string) => Promise<RouteOutcome>;
164
+ }
165
+ /**
166
+ * Register the small wire faces on a settled web server.
167
+ * @param webServer - the settled host web server.
168
+ * @param d - the captures listed on {@link WireDeps}.
169
+ */
170
+ export declare function registerWireFaces(webServer: WebServer, d: WireDeps): void;
171
+ export {};
@@ -0,0 +1,29 @@
1
+ /** gh#91 transitional ingress brakes. One guard per apply(), never process-global.
2
+ * Run AFTER admission, OriginClaim and the fresh idempotency claim, BEFORE
3
+ * settlement/mailbox/steer. Replays keep 409 and spend no delivery quota.
4
+ * All refusals carry a stable reason, task id, failed query outcome and an
5
+ * activity event. This is not the independent file channel or deployment.
6
+ */
7
+ import type { ServerResponse } from 'node:http';
8
+ import type { IdempotencyStore } from './idempotency-store.ts';
9
+ export declare const WIRE_ERROR_RATE_LIMITED = -32008;
10
+ export declare const MAX_PARTS_PER_TRANSFER = 16;
11
+ export declare const DEFAULT_RATE_PER_MINUTE = 16;
12
+ type Reason = 'sender-rate-limited' | 'file-parts-exceeded' | 'file-part-invalid' | 'file-part-duplicate' | 'flood-state-full';
13
+ export declare class FloodGuard {
14
+ private readonly now;
15
+ private readonly senders;
16
+ private readonly transfers;
17
+ constructor(now?: () => number);
18
+ /** Check/admit one fresh task. No timer; maps and per-key sets are bounded.
19
+ * Active transfer entries NEVER evict to make space (which would reset a
20
+ * transfer's allowance); capacity exhaustion refuses visibly instead.
21
+ * Counts are boot-local: restart resets them, disclosed in the guide.
22
+ */
23
+ check(caller: string, message: string): Reason | undefined;
24
+ }
25
+ /** Host-scoped adapter; rejected fresh keys are FAILED, not stranded pending.
26
+ * HTTP 200 retains the existing client's structured peer-error path.
27
+ */
28
+ export declare function createFloodGuard(outcomes: Pick<IdempotencyStore, 'recordOutcome'>, recordActivity: (dir: 'in' | 'out', team: string, peer: string, ok: boolean) => void): (caller: string, message: string, team: string, taskId: string, res: ServerResponse) => boolean;
29
+ export {};
@@ -31,4 +31,5 @@ export interface InFlightRoute {
31
31
  export interface CardCacheEntry {
32
32
  at: number;
33
33
  card: A2aPeerCard | undefined;
34
+ missAt?: number;
34
35
  }
@@ -53,7 +53,7 @@ export type TaskStatus = 'pending' | 'dead';
53
53
  /** F5 layer-3: why a dead row aged out (stamped by the sweep at flip time). */
54
54
  export type DeadReason = 'fire-forget' | 'unconsumed';
55
55
  /** gh#31: which archive path ended a row — a receipt, a content echo, or the caller. */
56
- export type ArchiveEnd = 'receipt' | 'echo' | 'abandoned' | 'cancelled';
56
+ export type ArchiveEnd = 'receipt' | 'echo' | 'abandoned' | 'cancelled' | 'evicted';
57
57
  /** One tracked outbound task still in the owed book (unsettled). */
58
58
  export interface TaskRecord {
59
59
  readonly taskId: string;
@@ -323,6 +323,14 @@ export declare class TaskLedger {
323
323
  resolveEcho(message: string): string[];
324
324
  /** Move one settled record to the archive head, trimming at the cap. */
325
325
  private archiveSettled;
326
+ /**
327
+ * gh#86 (chief order direct-4f596524): when the owed book exceeds
328
+ * TASK_CAP, the overflow rows go to the ARCHIVE as evicted - visible
329
+ * for audit, never silently dropped. The review seat's recommendation,
330
+ * chief-endorsed: the silent cap slice lost the oldest pending rows
331
+ * with no trace. L8 criterion: zero silently-dropped.
332
+ */
333
+ private trimOwed;
326
334
  /** Lazy dead-letter sweep: overdue pending rows flip status, at most once. */
327
335
  private sweep;
328
336
  /** Load a persisted snapshot on construction, if present. */
@@ -167,7 +167,11 @@ function runCanvasAction(a: CanvasAction): Promise<boolean> {
167
167
  cache: 'no-store',
168
168
  }).catch(() => undefined)
169
169
  const body = r !== undefined ? await r.json().catch(() => undefined) : undefined
170
- if (body?.ok !== true) return false
170
+ if (body?.ok !== true) {
171
+ // Say WHY the peer refused (not linked, untrusted origin, unknown member…), not just that it did.
172
+ planning.notice('error', `跨主机团队操作失败:${String(body?.error ?? '对端无响应')}`)
173
+ return false
174
+ }
171
175
  }
172
176
  return true
173
177
  })()
@@ -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,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
+ }
@@ -9,9 +9,14 @@
9
9
  * frame the user just touched stays where it was put; a remote org frame
10
10
  * never moves on its own (gh#44: its rect is the user's), so a local frame
11
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 moves just
13
- * outside that frame (right of it, else below it, else right of every
14
- * frame). Members are never moved off their own frame.
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.
15
20
  * Callers run it after a frame drag or resize, after 整理成员 / 一键重排,
16
21
  * and once when the board first has content — never on every poll (a poll
17
22
  * does not reposition what the user arranged).
@@ -74,22 +79,45 @@ export function separateFrames(model: WorldModel, remote: ReadonlySet<string>, t
74
79
  return changed
75
80
  }
76
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
+
77
96
  /** Move cards off frames they do not belong to; true when anything moved. */
78
97
  export function evictStrangers(model: WorldModel): boolean {
79
98
  let changed = false
80
99
  const frames = model.allFrames()
81
- const clear = (r: Rect, own: ReadonlySet<string>): boolean => frames.every(([name, f]) => own.has(name) || !hit(r, f))
100
+ const rectOf = new Map(frames)
82
101
  for (const n of model.allNodes()) {
83
102
  const own = new Set(n.memberships.map(m => m.team))
84
- const r = nodeRect(n)
85
- const host = frames.find(([name, f]) => !own.has(name) && hit(r, f))
103
+ const host = frames.find(([name, f]) => !own.has(name) && hit(nodeRect(n), f))
86
104
  if (host === undefined) continue
87
105
  const f = host[1]
88
- const at = (x: number, y: number): Rect => ({ x: x - NODE_W / 2, y: y - NODE_H / 2, w: NODE_W, h: NODE_H })
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)
89
114
  const right = { x: f.x + f.w + OVERLAP_GAP + NODE_W / 2, y: n.y }
90
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.
91
118
  const farX = Math.max(...frames.map(([, g]) => g.x + g.w)) + OVERLAP_GAP + NODE_W / 2
92
- const to = clear(at(right.x, right.y), own) ? right : clear(at(below.x, below.y), own) ? below : { x: farX, y: n.y }
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]!
93
121
  model.dragNodes([n.id], to.x - n.x, to.y - n.y)
94
122
  changed = true
95
123
  }
@@ -109,9 +137,6 @@ export function settleBoard(model: WorldModel, remote: ReadonlySet<string>, touc
109
137
  * fit them. Members of 2+ teams keep their positions.
110
138
  */
111
139
  export function tidyMembers(model: WorldModel): void {
112
- const PAD = 28
113
- const GAP_X = 16
114
- const GAP_Y = 14
115
140
  for (const [name, rect] of model.allFrames()) {
116
141
  const sole = model.teamMemberIds(name)
117
142
  .map(id => model.getNode(id))
@@ -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
@@ -0,0 +1,68 @@
1
+ /**
2
+ * 撤销 for the board layout (frontend seat, 2026-10-29): with no status-bar
3
+ * hint for automatic moves (owner: 载入时避让挪了卡片,不需要在状态条提示),
4
+ * a wrong move must be one keystroke away from undone. Covers card drags,
5
+ * frame drags and resizes, keyboard nudges, 整理成员, 一键重排 and the
6
+ * load-time settle — positions and frame rects only. Team membership
7
+ * (入队 / 离队, host writes) is not undone here.
8
+ *
9
+ * capture() before an edit, commit(before) after it: only an edit that
10
+ * actually moved something already on the board is recorded, so a click,
11
+ * a pan or a marquee leaves the stack alone, and a card that arrived by
12
+ * poll mid-gesture is not counted as a move. revert(before) is for a
13
+ * gesture the OS cancelled (pointercancel): layout back, nothing recorded.
14
+ * @module nexus-stage/planning-undo
15
+ */
16
+ import type { FrameGeom, WorldModel } from './world'
17
+
18
+ export interface LayoutSnap {
19
+ readonly nodes: ReadonlyMap<string, { readonly x: number; readonly y: number }>
20
+ readonly frames: ReadonlyMap<string, FrameGeom>
21
+ }
22
+
23
+ /** Undo depth (older steps fall off). */
24
+ export const UNDO_LIMIT = 30
25
+
26
+ export function createLayoutUndo(model: WorldModel) {
27
+ const stack: LayoutSnap[] = []
28
+ const capture = (): LayoutSnap => ({
29
+ nodes: new Map(model.allNodes().map(n => [n.id, { x: n.x, y: n.y }])),
30
+ frames: new Map(model.allFrames().map(([name, f]) => [name, { ...f }])),
31
+ })
32
+ /** True when something present both before and now has moved or changed size. */
33
+ const moved = (before: LayoutSnap): boolean =>
34
+ model.allNodes().some(n => { const b = before.nodes.get(n.id); return b !== undefined && (b.x !== n.x || b.y !== n.y) }) ||
35
+ model.allFrames().some(([name, f]) => { const b = before.frames.get(name); return b !== undefined && (b.x !== f.x || b.y !== f.y || b.w !== f.w || b.h !== f.h) })
36
+ const restore = (snap: LayoutSnap): void => {
37
+ for (const n of model.allNodes()) {
38
+ const b = snap.nodes.get(n.id)
39
+ if (b !== undefined && (b.x !== n.x || b.y !== n.y)) model.dragNodes([n.id], b.x - n.x, b.y - n.y)
40
+ }
41
+ for (const [name, f] of model.allFrames()) {
42
+ const b = snap.frames.get(name)
43
+ if (b !== undefined && (b.x !== f.x || b.y !== f.y || b.w !== f.w || b.h !== f.h)) model.setFrame(name, { ...b })
44
+ }
45
+ }
46
+ return {
47
+ capture,
48
+ commit(before: LayoutSnap): void {
49
+ if (!moved(before)) return
50
+ stack.push(before)
51
+ if (stack.length > UNDO_LIMIT) stack.shift()
52
+ },
53
+ /** Restore the last recorded layout; false when there is nothing to undo. */
54
+ undo(): boolean {
55
+ const snap = stack.pop()
56
+ if (snap === undefined) return false
57
+ restore(snap)
58
+ return true
59
+ },
60
+ /** Put back `before` without recording a step (an abandoned gesture); true when something moved. */
61
+ revert(before: LayoutSnap): boolean {
62
+ if (!moved(before)) return false
63
+ restore(before)
64
+ return true
65
+ },
66
+ get depth(): number { return stack.length },
67
+ }
68
+ }