@frebreco/canvas 0.0.1 → 0.2.0
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/LICENSE +21 -0
- package/README.md +50 -1
- package/bin/canvas.js +31 -0
- package/package.json +27 -3
- package/src/cli.ts +72 -0
- package/src/server/agent-config.ts +82 -0
- package/src/server/agents.ts +501 -0
- package/src/server/board-mcp.ts +191 -0
- package/src/server/files.ts +136 -0
- package/src/server/server.ts +194 -0
- package/src/server/shared-set.ts +175 -0
- package/src/server/store.ts +98 -0
- package/src/server/terminals.ts +65 -0
- package/src/server/trim-event.ts +74 -0
- package/src/server/web-url.ts +18 -0
- package/src/shared/board-tools.ts +172 -0
- package/src/shared/layout.ts +256 -0
- package/src/shared/protocol.ts +310 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What of an agent event is worth keeping. Every event is stored, and sent
|
|
3
|
+
* to the host's browser and on to every guest that joins, so bulk nobody
|
|
4
|
+
* reads costs on every load:
|
|
5
|
+
*
|
|
6
|
+
* - a text chunk repeats the whole message so far in `content`; the thread
|
|
7
|
+
* is folded from `delta` alone, so the repeat goes;
|
|
8
|
+
* - images a tool returns (a screenshot the agent read) stay as a
|
|
9
|
+
* placeholder with their size;
|
|
10
|
+
* - tool output and arguments are cut to their start and end.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { AgentEvent } from "../shared/protocol";
|
|
14
|
+
|
|
15
|
+
/** Longest tool output or argument string kept, in characters. */
|
|
16
|
+
export const TOOL_TEXT_LIMIT = 4000;
|
|
17
|
+
/** Of a cut string, how much of the end is kept (a command's errors are there). */
|
|
18
|
+
const TAIL = 1000;
|
|
19
|
+
|
|
20
|
+
/** The tool chunk fields that carry the tool's input or output. */
|
|
21
|
+
const TOOL_FIELDS = ["content", "delta", "args", "input"] as const;
|
|
22
|
+
|
|
23
|
+
export function trimEvent(event: AgentEvent): AgentEvent {
|
|
24
|
+
if (event.kind !== "chunk" || !isRecord(event.chunk)) return event;
|
|
25
|
+
const chunk = event.chunk;
|
|
26
|
+
if (chunk.type === "TEXT_MESSAGE_CONTENT" && "content" in chunk) {
|
|
27
|
+
const { content: _, ...rest } = chunk;
|
|
28
|
+
return { ...event, chunk: rest };
|
|
29
|
+
}
|
|
30
|
+
if (typeof chunk.type !== "string" || !chunk.type.startsWith("TOOL_CALL_")) return event;
|
|
31
|
+
let trimmed: Record<string, unknown> | null = null;
|
|
32
|
+
for (const field of TOOL_FIELDS) {
|
|
33
|
+
const value = chunk[field];
|
|
34
|
+
if (typeof value !== "string") continue;
|
|
35
|
+
const next = cut(field === "content" ? withoutImages(value) : value);
|
|
36
|
+
if (next !== value) (trimmed ??= { ...chunk })[field] = next;
|
|
37
|
+
}
|
|
38
|
+
return trimmed ? { ...event, chunk: trimmed } : event;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The start and end of a long string, and how much is missing between them. */
|
|
42
|
+
function cut(text: string): string {
|
|
43
|
+
if (text.length <= TOOL_TEXT_LIMIT) return text;
|
|
44
|
+
const head = TOOL_TEXT_LIMIT - TAIL;
|
|
45
|
+
const missing = text.length - head - TAIL;
|
|
46
|
+
return `${text.slice(0, head)}\n… ${missing} characters cut …\n${text.slice(-TAIL)}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Tool output is a string, JSON when it has more than text: drop image data from it. */
|
|
50
|
+
function withoutImages(content: string): string {
|
|
51
|
+
if (!content.includes('"image"')) return content;
|
|
52
|
+
let parsed: unknown;
|
|
53
|
+
try {
|
|
54
|
+
parsed = JSON.parse(content);
|
|
55
|
+
} catch {
|
|
56
|
+
return content;
|
|
57
|
+
}
|
|
58
|
+
let found = false;
|
|
59
|
+
const walk = (value: unknown): unknown => {
|
|
60
|
+
if (Array.isArray(value)) return value.map(walk);
|
|
61
|
+
if (!isRecord(value)) return value;
|
|
62
|
+
if (value.type === "image") {
|
|
63
|
+
found = true;
|
|
64
|
+
const size = JSON.stringify(value).length;
|
|
65
|
+
return { type: "image", omitted: `${Math.round(size / 1024)} KB` };
|
|
66
|
+
}
|
|
67
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, walk(v)]));
|
|
68
|
+
};
|
|
69
|
+
const next = walk(parsed);
|
|
70
|
+
return found ? JSON.stringify(next) : content;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
74
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The web app a host link opens, when neither `--web-url` nor `CANVAS_WEB_URL`
|
|
3
|
+
* says otherwise. The hosted UI speaks the same `protocol.ts` as the CLI of
|
|
4
|
+
* its release channel, so the default follows the version `canvas serve` runs
|
|
5
|
+
* as: `scripts/build-release.ts` stamps it into the published `package.json`;
|
|
6
|
+
* the repo's own manifest has none, which means a checkout and the Vite dev
|
|
7
|
+
* server.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const STABLE_WEB_URL = "https://ui.canvas.frebreco.de";
|
|
11
|
+
export const NEXT_WEB_URL = `${STABLE_WEB_URL}/next`;
|
|
12
|
+
export const DEV_WEB_URL = "http://localhost:4417";
|
|
13
|
+
|
|
14
|
+
export function defaultWebUrl(version: string | undefined): string {
|
|
15
|
+
if (!version) return DEV_WEB_URL;
|
|
16
|
+
// semver: a pre-release is anything with a `-` suffix, here `X.Y.Z-next.N`.
|
|
17
|
+
return version.includes("-") ? NEXT_WEB_URL : STABLE_WEB_URL;
|
|
18
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tools an agent gets to see and change the board (finding 06), and the
|
|
3
|
+
* priming that tells it what canvas is. `canvas serve` offers them over MCP;
|
|
4
|
+
* the host's browser runs them against the board (`web/lib/board-tools.ts`).
|
|
5
|
+
*
|
|
6
|
+
* The priming goes out as MCP server instructions: every agent shows those to
|
|
7
|
+
* its model next to — never instead of — its own system prompt, and none of
|
|
8
|
+
* it enters the thread. It is static per session (Claude records the system
|
|
9
|
+
* context once); what changes lives in tool results.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const BOARD_TOOL_NAMES = [
|
|
13
|
+
"view_board",
|
|
14
|
+
"open_frame",
|
|
15
|
+
"update_frame",
|
|
16
|
+
"close_frame",
|
|
17
|
+
] as const;
|
|
18
|
+
export type BoardToolName = (typeof BOARD_TOOL_NAMES)[number];
|
|
19
|
+
|
|
20
|
+
/** The MCP server's name, as agents prefix its tools (`mcp__canvas__…`, `canvas_…`). */
|
|
21
|
+
export const BOARD_SERVER_NAME = "canvas";
|
|
22
|
+
|
|
23
|
+
export type FrameKind = "file" | "browser" | "terminal" | "agent";
|
|
24
|
+
export type PlaceSide = "left" | "right" | "above" | "below";
|
|
25
|
+
|
|
26
|
+
export interface ViewBoardArgs {
|
|
27
|
+
readonly scope?: "cluster" | "board";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface Placement {
|
|
31
|
+
/** A frame id; the new or moved frame goes beside it. */
|
|
32
|
+
readonly next_to?: string;
|
|
33
|
+
readonly side?: PlaceSide;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface FileTarget {
|
|
37
|
+
readonly path?: string;
|
|
38
|
+
readonly start_line?: number;
|
|
39
|
+
readonly end_line?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface OpenFrameArgs extends Placement, FileTarget {
|
|
43
|
+
readonly type: FrameKind;
|
|
44
|
+
readonly title?: string;
|
|
45
|
+
readonly url?: string;
|
|
46
|
+
readonly agent?: string;
|
|
47
|
+
readonly draft?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface UpdateFrameArgs extends Placement, FileTarget {
|
|
51
|
+
readonly frame: string;
|
|
52
|
+
readonly title?: string;
|
|
53
|
+
readonly url?: string;
|
|
54
|
+
readonly view?: "preview" | "source";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface CloseFrameArgs {
|
|
58
|
+
readonly frame: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const placement = {
|
|
62
|
+
next_to: {
|
|
63
|
+
type: "string",
|
|
64
|
+
description: "Id of a frame to place it beside. Default: in your own cluster.",
|
|
65
|
+
},
|
|
66
|
+
side: {
|
|
67
|
+
type: "string",
|
|
68
|
+
enum: ["right", "left", "below", "above"],
|
|
69
|
+
description:
|
|
70
|
+
"Side of `next_to`. right/left: same row, same height. below/above: a new row. Default right.",
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const fileTarget = {
|
|
75
|
+
path: {
|
|
76
|
+
type: "string",
|
|
77
|
+
description: "File path relative to the project root, e.g. src/auth/session.ts.",
|
|
78
|
+
},
|
|
79
|
+
start_line: { type: "integer", minimum: 1, description: "First line to show and highlight." },
|
|
80
|
+
end_line: { type: "integer", minimum: 1, description: "Last highlighted line (inclusive)." },
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export const BOARD_TOOLS: ReadonlyArray<{
|
|
84
|
+
readonly name: BoardToolName;
|
|
85
|
+
readonly description: string;
|
|
86
|
+
readonly inputSchema: Record<string, unknown>;
|
|
87
|
+
}> = [
|
|
88
|
+
{
|
|
89
|
+
name: "view_board",
|
|
90
|
+
description:
|
|
91
|
+
"See what is on the canvas board: your own cluster row by row, and the other clusters. " +
|
|
92
|
+
"Every frame is listed with its id, type, title and what it shows. Look before you add.",
|
|
93
|
+
inputSchema: {
|
|
94
|
+
type: "object",
|
|
95
|
+
properties: {
|
|
96
|
+
scope: {
|
|
97
|
+
type: "string",
|
|
98
|
+
enum: ["cluster", "board"],
|
|
99
|
+
description: "cluster (default): your cluster in full, others summarised. board: all.",
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
name: "open_frame",
|
|
106
|
+
description:
|
|
107
|
+
"Open a new frame on the board, in your own cluster unless placed next to another frame. " +
|
|
108
|
+
"file: a project file (read-only, live), optionally at a line range which gets highlighted. " +
|
|
109
|
+
"browser: a URL, loaded by each viewer's own browser. terminal: an idle shell people can " +
|
|
110
|
+
"type into. agent: another agent session; `draft` pre-fills its prompt, a person sends it.",
|
|
111
|
+
inputSchema: {
|
|
112
|
+
type: "object",
|
|
113
|
+
properties: {
|
|
114
|
+
type: { type: "string", enum: ["file", "browser", "terminal", "agent"] },
|
|
115
|
+
...fileTarget,
|
|
116
|
+
url: { type: "string", description: "browser: an http(s) URL." },
|
|
117
|
+
agent: { type: "string", description: "agent: which agent runs it (see view_board)." },
|
|
118
|
+
draft: { type: "string", description: "agent: a prompt draft for people to send." },
|
|
119
|
+
title: { type: "string", description: "Frame title. Default: from what it shows." },
|
|
120
|
+
...placement,
|
|
121
|
+
},
|
|
122
|
+
required: ["type"],
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
name: "update_frame",
|
|
127
|
+
description:
|
|
128
|
+
"Change a frame: point a file frame at another file or line range, switch a markdown " +
|
|
129
|
+
"or HTML file between preview and source, change a browser frame's URL, rename a frame, or move " +
|
|
130
|
+
"it next to another frame.",
|
|
131
|
+
inputSchema: {
|
|
132
|
+
type: "object",
|
|
133
|
+
properties: {
|
|
134
|
+
frame: { type: "string", description: "Id of the frame to change." },
|
|
135
|
+
...fileTarget,
|
|
136
|
+
view: { type: "string", enum: ["preview", "source"] },
|
|
137
|
+
url: { type: "string" },
|
|
138
|
+
title: { type: "string" },
|
|
139
|
+
...placement,
|
|
140
|
+
},
|
|
141
|
+
required: ["frame"],
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
name: "close_frame",
|
|
146
|
+
description:
|
|
147
|
+
"Remove a frame from the board. Only close frames you opened, unless someone asks you to.",
|
|
148
|
+
inputSchema: {
|
|
149
|
+
type: "object",
|
|
150
|
+
properties: { frame: { type: "string", description: "Id of the frame to close." } },
|
|
151
|
+
required: ["frame"],
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
];
|
|
155
|
+
|
|
156
|
+
/** The agent's standing context, sent as MCP server instructions. */
|
|
157
|
+
export function boardInstructions(frameId: string): string {
|
|
158
|
+
return `You are running inside canvas: a shared, multiplayer board that people are looking at together, live. Its frames are coding-agent sessions, files of this project, browser previews and terminals. You are the agent in frame ${frameId}; people write prompts into it and read your replies there. Several people may prompt you.
|
|
159
|
+
|
|
160
|
+
Frames that sit close together form a cluster: people keep related work together that way, and your own cluster is your workspace. Within a cluster frames sit in rows; frames in a row share a height.
|
|
161
|
+
|
|
162
|
+
The ${BOARD_SERVER_NAME} tools let you see and change the board: ${BOARD_TOOL_NAMES.join(", ")}. Use them when showing something on the board helps the people you work with — e.g. asked to show the files relevant to a topic, open them as file frames at the relevant lines instead of pasting code. Don't use them when a plain answer is enough.
|
|
163
|
+
|
|
164
|
+
- Look first (view_board). Reuse or retarget a frame (update_frame) rather than open a duplicate.
|
|
165
|
+
- New frames go into your cluster by default; that is almost always right. Keep it to a handful per request.
|
|
166
|
+
- Don't change or close frames other people opened unless asked. Frames you opened are marked as yours.
|
|
167
|
+
- File frames are read-only and live: they show a file as it is on disk, so they follow your edits. Only files git doesn't ignore can be shown, never secrets.
|
|
168
|
+
- A terminal frame is an idle shell for people; you cannot type into it. Run commands with your own tools.
|
|
169
|
+
- An agent frame starts another agent. You can leave a draft prompt in it; only a person can send it.
|
|
170
|
+
- A browser frame loads its URL in each viewer's own browser, so localhost means their machine, not this one: only the host sees a localhost URL, guests get a notice.
|
|
171
|
+
- To show a page you wrote to everyone, open the HTML file as a file frame: it renders, scripts included, but relative links and assets (CSS, images, other scripts) don't load, so inline them.`;
|
|
172
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The board's layout rules, as pure geometry.
|
|
3
|
+
*
|
|
4
|
+
* Frame positions stay the only truth (the board doc stores x, y, w, h);
|
|
5
|
+
* structure is read off them:
|
|
6
|
+
*
|
|
7
|
+
* cluster frames within `NEAR` of each other, transitively — what people
|
|
8
|
+
* group together belongs together
|
|
9
|
+
* row frames of a cluster whose top edges line up, left to right
|
|
10
|
+
*
|
|
11
|
+
* Placing a frame (by a person's drop or an agent's tool call) returns the
|
|
12
|
+
* new frame's rect and patches for the frames that make room: frames later in
|
|
13
|
+
* the row shift right, rows further down shift down. Nothing outside the
|
|
14
|
+
* anchor's cluster moves. Anything placed away from other frames stays free.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export interface Rect {
|
|
18
|
+
readonly id: string;
|
|
19
|
+
readonly x: number;
|
|
20
|
+
readonly y: number;
|
|
21
|
+
readonly w: number;
|
|
22
|
+
readonly h: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type Box = Omit<Rect, "id">;
|
|
26
|
+
export type Size = Pick<Rect, "w" | "h">;
|
|
27
|
+
export type Patch = { readonly id: string } & Partial<Box>;
|
|
28
|
+
export type Side = "left" | "right" | "above" | "below";
|
|
29
|
+
export interface Target {
|
|
30
|
+
readonly anchor: string;
|
|
31
|
+
readonly side: Side;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface Cluster<R extends Rect = Rect> {
|
|
35
|
+
/** The smallest frame id in it: stable while that frame stays. */
|
|
36
|
+
readonly id: string;
|
|
37
|
+
readonly frames: ReadonlyArray<R>;
|
|
38
|
+
/** Top to bottom; each left to right. */
|
|
39
|
+
readonly rows: ReadonlyArray<ReadonlyArray<R>>;
|
|
40
|
+
readonly bounds: Box;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Space between frames in a cluster. */
|
|
44
|
+
export const GAP = 24;
|
|
45
|
+
/** Frames this close (or closer) are one cluster. */
|
|
46
|
+
export const NEAR = 48;
|
|
47
|
+
/** Top edges this close are one row. */
|
|
48
|
+
const ROW_TOLERANCE = 16;
|
|
49
|
+
|
|
50
|
+
/** Empty space between two rects along each axis (0 when they overlap on it). */
|
|
51
|
+
function gaps(a: Box, b: Box) {
|
|
52
|
+
return {
|
|
53
|
+
x: Math.max(0, Math.max(a.x, b.x) - Math.min(a.x + a.w, b.x + b.w)),
|
|
54
|
+
y: Math.max(0, Math.max(a.y, b.y) - Math.min(a.y + a.h, b.y + b.h)),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const near = (a: Box, b: Box) => {
|
|
59
|
+
const g = gaps(a, b);
|
|
60
|
+
return g.x <= NEAR && g.y <= NEAR;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export function clusters<R extends Rect>(rects: ReadonlyArray<R>): Cluster<R>[] {
|
|
64
|
+
const seen = new Set<string>();
|
|
65
|
+
const found: Cluster<R>[] = [];
|
|
66
|
+
for (const start of rects) {
|
|
67
|
+
if (seen.has(start.id)) continue;
|
|
68
|
+
seen.add(start.id);
|
|
69
|
+
const members = [start];
|
|
70
|
+
for (let i = 0; i < members.length; i++)
|
|
71
|
+
for (const other of rects)
|
|
72
|
+
if (!seen.has(other.id) && near(members[i]!, other)) {
|
|
73
|
+
seen.add(other.id);
|
|
74
|
+
members.push(other);
|
|
75
|
+
}
|
|
76
|
+
found.push(cluster(members));
|
|
77
|
+
}
|
|
78
|
+
return found.sort((a, b) => a.bounds.y - b.bounds.y || a.bounds.x - b.bounds.x);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function cluster<R extends Rect>(members: R[]): Cluster<R> {
|
|
82
|
+
const rows: R[][] = [];
|
|
83
|
+
for (const frame of [...members].sort((a, b) => a.y - b.y || a.x - b.x)) {
|
|
84
|
+
const row = rows.at(-1);
|
|
85
|
+
if (row && Math.abs(frame.y - row[0]!.y) <= ROW_TOLERANCE) row.push(frame);
|
|
86
|
+
else rows.push([frame]);
|
|
87
|
+
}
|
|
88
|
+
for (const row of rows) row.sort((a, b) => a.x - b.x);
|
|
89
|
+
const x = Math.min(...members.map((f) => f.x));
|
|
90
|
+
const y = Math.min(...members.map((f) => f.y));
|
|
91
|
+
return {
|
|
92
|
+
id: members.map((f) => f.id).sort()[0]!,
|
|
93
|
+
frames: members,
|
|
94
|
+
rows,
|
|
95
|
+
bounds: {
|
|
96
|
+
x,
|
|
97
|
+
y,
|
|
98
|
+
w: Math.max(...members.map((f) => f.x + f.w)) - x,
|
|
99
|
+
h: Math.max(...members.map((f) => f.y + f.h)) - y,
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The cluster and row a frame is in. */
|
|
105
|
+
export function locate<R extends Rect>(rects: ReadonlyArray<R>, id: string) {
|
|
106
|
+
for (const c of clusters(rects)) {
|
|
107
|
+
const row = c.rows.find((r) => r.some((f) => f.id === id));
|
|
108
|
+
if (row) return { cluster: c, row, frame: row.find((f) => f.id === id)! };
|
|
109
|
+
}
|
|
110
|
+
throw new Error(`no frame ${id}`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const rowBottom = (row: ReadonlyArray<Rect>) => Math.max(...row.map((f) => f.y + f.h));
|
|
114
|
+
|
|
115
|
+
/** Where a new frame of `size` goes next to `target.anchor`, and what moves for it. */
|
|
116
|
+
export function placeNew(
|
|
117
|
+
rects: ReadonlyArray<Rect>,
|
|
118
|
+
target: Target,
|
|
119
|
+
size: Size,
|
|
120
|
+
): { rect: Box; patches: Patch[] } {
|
|
121
|
+
const { cluster: c, row, frame: anchor } = locate(rects, target.anchor);
|
|
122
|
+
switch (target.side) {
|
|
123
|
+
case "right":
|
|
124
|
+
case "left": {
|
|
125
|
+
const rect = {
|
|
126
|
+
x: target.side === "right" ? anchor.x + anchor.w + GAP : anchor.x,
|
|
127
|
+
y: anchor.y,
|
|
128
|
+
w: size.w,
|
|
129
|
+
h: anchor.h,
|
|
130
|
+
};
|
|
131
|
+
const moves = (f: Rect) => (target.side === "right" ? f.x > anchor.x : f.x >= anchor.x);
|
|
132
|
+
return {
|
|
133
|
+
rect,
|
|
134
|
+
patches: row.filter(moves).map((f) => ({ id: f.id, x: f.x + size.w + GAP })),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
case "below":
|
|
138
|
+
case "above": {
|
|
139
|
+
const y = target.side === "below" ? rowBottom(row) + GAP : row[0]!.y;
|
|
140
|
+
const from = target.side === "below" ? rowBottom(row) : y;
|
|
141
|
+
return {
|
|
142
|
+
rect: { x: anchor.x, y, w: size.w, h: size.h },
|
|
143
|
+
patches: c.frames
|
|
144
|
+
.filter((f) => f.y >= from)
|
|
145
|
+
.map((f) => ({ id: f.id, y: f.y + size.h + GAP })),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Where an agent's new frame goes: its own cluster, without running into
|
|
153
|
+
* another cluster — the end of its row, else a row under it, else under the
|
|
154
|
+
* whole cluster (taken even if it touches another cluster).
|
|
155
|
+
*/
|
|
156
|
+
export function placeNear(
|
|
157
|
+
rects: ReadonlyArray<Rect>,
|
|
158
|
+
selfId: string,
|
|
159
|
+
size: Size,
|
|
160
|
+
): { rect: Box; patches: Patch[] } {
|
|
161
|
+
const { cluster: c, row } = locate(rects, selfId);
|
|
162
|
+
const bottom = {
|
|
163
|
+
rect: { x: c.bounds.x, y: c.bounds.y + c.bounds.h + GAP, ...size },
|
|
164
|
+
patches: [],
|
|
165
|
+
};
|
|
166
|
+
const candidates = [
|
|
167
|
+
placeNew(rects, { anchor: row.at(-1)!.id, side: "right" }, size),
|
|
168
|
+
placeNew(rects, { anchor: row[0]!.id, side: "below" }, size),
|
|
169
|
+
bottom,
|
|
170
|
+
];
|
|
171
|
+
const inCluster = new Set(c.frames.map((f) => f.id));
|
|
172
|
+
const outside = rects.filter((f) => !inCluster.has(f.id));
|
|
173
|
+
return (
|
|
174
|
+
candidates.find(({ rect, patches }) => {
|
|
175
|
+
const moved = [rect, ...applyPatches(c.frames, patches)];
|
|
176
|
+
return !moved.some((m) => outside.some((o) => near(m, o)));
|
|
177
|
+
}) ?? bottom
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** A frame leaves its row: the frames after it close the gap. */
|
|
182
|
+
export function lift(rects: ReadonlyArray<Rect>, id: string): Patch[] {
|
|
183
|
+
const { row, frame } = locate(rects, id);
|
|
184
|
+
return row.filter((f) => f.x > frame.x).map((f) => ({ id: f.id, x: f.x - frame.w - GAP }));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Where a frame being dragged would snap to: next to the nearest frame within reach. */
|
|
188
|
+
export function snapTarget(rects: ReadonlyArray<Rect>, movingId: string): Target | null {
|
|
189
|
+
const moving = rects.find((f) => f.id === movingId);
|
|
190
|
+
if (!moving) return null;
|
|
191
|
+
const centre = (f: Box) => ({ x: f.x + f.w / 2, y: f.y + f.h / 2 });
|
|
192
|
+
const mc = centre(moving);
|
|
193
|
+
let best: { frame: Rect; distance: number; gap: number } | null = null;
|
|
194
|
+
for (const frame of rects) {
|
|
195
|
+
if (frame.id === movingId || !near(moving, frame)) continue;
|
|
196
|
+
const g = gaps(moving, frame);
|
|
197
|
+
const fc = centre(frame);
|
|
198
|
+
const distance = Math.hypot(mc.x - fc.x, mc.y - fc.y);
|
|
199
|
+
const gap = g.x + g.y;
|
|
200
|
+
if (!best || gap < best.gap || (gap === best.gap && distance < best.distance))
|
|
201
|
+
best = { frame, distance, gap };
|
|
202
|
+
}
|
|
203
|
+
if (!best) return null;
|
|
204
|
+
const fc = centre(best.frame);
|
|
205
|
+
const dx = (mc.x - fc.x) / ((best.frame.w + moving.w) / 2);
|
|
206
|
+
const dy = (mc.y - fc.y) / ((best.frame.h + moving.h) / 2);
|
|
207
|
+
const side: Side =
|
|
208
|
+
Math.abs(dx) >= Math.abs(dy) ? (dx >= 0 ? "right" : "left") : dy >= 0 ? "below" : "above";
|
|
209
|
+
return { anchor: best.frame.id, side };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Move an existing frame next to `target`: it leaves its row, then is placed like a new one. */
|
|
213
|
+
export function moveFrame(rects: ReadonlyArray<Rect>, id: string, target: Target): Patch[] {
|
|
214
|
+
const frame = rects.find((f) => f.id === id);
|
|
215
|
+
if (!frame) throw new Error(`no frame ${id}`);
|
|
216
|
+
const lifted = lift(rects, id);
|
|
217
|
+
const rest = applyPatches(rects, lifted).filter((f) => f.id !== id);
|
|
218
|
+
const { rect, patches } = placeNew(rest, target, frame);
|
|
219
|
+
return merge([...lifted, ...patches, { id, ...rect }]);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Resize a frame and keep its row a row: the others take its height, the
|
|
224
|
+
* frames to its right follow its width, and rows below move with the row's
|
|
225
|
+
* bottom edge.
|
|
226
|
+
*/
|
|
227
|
+
export function resizeInRow(rects: ReadonlyArray<Rect>, id: string, size: Size): Patch[] {
|
|
228
|
+
const { cluster: c, row, frame } = locate(rects, id);
|
|
229
|
+
const patches: Patch[] = [{ id, w: size.w, h: size.h }];
|
|
230
|
+
if (row.length === 1) return patches;
|
|
231
|
+
const dw = size.w - frame.w;
|
|
232
|
+
for (const f of row)
|
|
233
|
+
if (f.id !== id)
|
|
234
|
+
patches.push(f.x > frame.x ? { id: f.id, x: f.x + dw, h: size.h } : { id: f.id, h: size.h });
|
|
235
|
+
const before = rowBottom(row);
|
|
236
|
+
const after = rowBottom(applyPatches(row, patches));
|
|
237
|
+
if (after !== before)
|
|
238
|
+
for (const f of c.frames)
|
|
239
|
+
if (!row.includes(f) && f.y >= before) patches.push({ id: f.id, y: f.y + after - before });
|
|
240
|
+
return patches;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function applyPatches<R extends Rect>(
|
|
244
|
+
rects: ReadonlyArray<R>,
|
|
245
|
+
patches: ReadonlyArray<Patch>,
|
|
246
|
+
): R[] {
|
|
247
|
+
const byId = new Map(merge(patches).map((p) => [p.id, p]));
|
|
248
|
+
return rects.map((r) => (byId.has(r.id) ? { ...r, ...byId.get(r.id) } : r));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** One patch per frame; later patches win field by field. */
|
|
252
|
+
function merge(patches: ReadonlyArray<Patch>): Patch[] {
|
|
253
|
+
const byId = new Map<string, Patch>();
|
|
254
|
+
for (const patch of patches) byId.set(patch.id, { ...byId.get(patch.id), ...patch });
|
|
255
|
+
return [...byId.values()];
|
|
256
|
+
}
|