@1agh/maude 1.4.6 → 1.5.1
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/apps/studio/annotations/ai-read.ts +288 -0
- package/apps/studio/annotations/ai-write.ts +534 -0
- package/apps/studio/annotations/board-io.ts +94 -0
- package/apps/studio/annotations/board-text.ts +45 -0
- package/apps/studio/annotations/constants.ts +58 -0
- package/apps/studio/annotations/elements/_shared.ts +125 -0
- package/apps/studio/annotations/elements/arrow.model.ts +196 -0
- package/apps/studio/annotations/elements/media.model.ts +79 -0
- package/apps/studio/annotations/elements/pen.model.ts +82 -0
- package/apps/studio/annotations/elements/section.model.ts +70 -0
- package/apps/studio/annotations/elements/shape.model.ts +141 -0
- package/apps/studio/annotations/elements/sticky.model.ts +48 -0
- package/apps/studio/annotations/elements/text.model.ts +52 -0
- package/apps/studio/annotations/fields.ts +347 -0
- package/apps/studio/annotations/fractional-index.ts +234 -0
- package/apps/studio/annotations/legacy/mini-dom.ts +207 -0
- package/apps/studio/annotations/migrate-boot.ts +172 -0
- package/apps/studio/annotations/migrate-cli.ts +37 -0
- package/apps/studio/annotations/migrate-v1.ts +383 -0
- package/apps/studio/annotations/ops-merge.ts +15 -0
- package/apps/studio/annotations/ops.ts +499 -0
- package/apps/studio/annotations/registry.ts +159 -0
- package/apps/studio/annotations/replica.ts +264 -0
- package/apps/studio/annotations/scene.ts +235 -0
- package/apps/studio/annotations/schema.ts +188 -0
- package/apps/studio/annotations/types.ts +77 -0
- package/apps/studio/annotations/ui/board.ts +126 -0
- package/apps/studio/annotations/ui/containment.ts +118 -0
- package/apps/studio/annotations/ui/edit-actions.ts +551 -0
- package/apps/studio/annotations/ui/editor-channel.ts +62 -0
- package/apps/studio/annotations/ui/element-node.tsx +993 -0
- package/apps/studio/annotations/ui/pipeline-context.ts +17 -0
- package/apps/studio/annotations/ui/pointer-pipeline.ts +150 -0
- package/apps/studio/annotations/ui/render-model.ts +264 -0
- package/apps/studio/annotations/ui/scene.tsx +63 -0
- package/apps/studio/annotations/ui/text-editor.tsx +420 -0
- package/apps/studio/annotations/ui/text-session.ts +140 -0
- package/apps/studio/annotations/ui/text-style.ts +111 -0
- package/apps/studio/annotations/ui/world.ts +47 -0
- package/apps/studio/annotations/v1-adapter.ts +428 -0
- package/apps/studio/annotations-align.ts +21 -6
- package/apps/studio/annotations-bindings.ts +2 -0
- package/apps/studio/annotations-context-toolbar.tsx +9 -13
- package/apps/studio/annotations-groups.ts +3 -0
- package/apps/studio/annotations-layer.tsx +923 -1925
- package/apps/studio/annotations-model.ts +65 -4
- package/apps/studio/annotations-sync.ts +4 -47
- package/apps/studio/api.ts +249 -69
- package/apps/studio/bin/_import-figma.mjs +22 -12
- package/apps/studio/bin/annotate.mjs +331 -838
- package/apps/studio/bin/annotate.sh +4 -4
- package/apps/studio/bin/perf.sh +21 -7
- package/apps/studio/bin/read-annotations.mjs +184 -666
- package/apps/studio/bin/read-annotations.sh +9 -5
- package/apps/studio/canvas-artifacts.ts +9 -0
- package/apps/studio/canvas-lib.tsx +13 -1
- package/apps/studio/canvas-shell.tsx +6 -0
- package/apps/studio/client/app.jsx +98 -35
- package/apps/studio/client/hmr.mjs +1 -1
- package/apps/studio/client/panels/git-grouping.js +2 -2
- package/apps/studio/client/tree-expansion.js +217 -0
- package/apps/studio/collab/index.ts +49 -5
- package/apps/studio/collab/persistence.ts +31 -10
- package/apps/studio/collab/registry.ts +64 -26
- package/apps/studio/commands/annotation-ops-command.ts +72 -0
- package/apps/studio/cursors-overlay.tsx +158 -4
- package/apps/studio/dist/client.bundle.js +850 -850
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/figma/to-strokes.ts +31 -10
- package/apps/studio/git/endpoints.ts +1 -1
- package/apps/studio/git/service.ts +1 -1
- package/apps/studio/git/watch.ts +1 -1
- package/apps/studio/http.ts +90 -15
- package/apps/studio/server.ts +16 -0
- package/apps/studio/sync/accepted-cold-start.ts +12 -4
- package/apps/studio/sync/agent.ts +8 -4
- package/apps/studio/sync/codec.ts +96 -40
- package/apps/studio/sync/file-membership.ts +12 -2
- package/apps/studio/sync/file-plane.ts +1 -1
- package/apps/studio/sync/index.ts +38 -16
- package/apps/studio/sync/journal-client.ts +5 -0
- package/apps/studio/sync/limits.ts +7 -2
- package/apps/studio/sync/migrate-seed.ts +8 -5
- package/apps/studio/sync/projection.ts +7 -2
- package/apps/studio/sync/remote-docs.ts +34 -0
- package/apps/studio/sync/writer-registry.ts +10 -0
- package/apps/studio/text-caret.ts +11 -2
- package/apps/studio/tree-state.ts +45 -0
- package/apps/studio/undo-stack.ts +2 -2
- package/apps/studio/use-annotation-resize.tsx +48 -23
- package/apps/studio/use-annotation-selection.tsx +9 -2
- package/apps/studio/use-collab.tsx +76 -0
- package/apps/studio/whats-new.json +27 -0
- package/cli/lib/design-link.mjs +5 -1
- package/cli/lib/gitignore-block.mjs +1 -1
- package/cli/lib/gitignore-drift.mjs +2 -1
- package/package.json +9 -8
- package/plugins/design/templates/brief-board.tsx.template +1 -1
- package/apps/studio/annotation-edit-base.ts +0 -36
- package/apps/studio/commands/annotation-strokes-command.ts +0 -137
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file annotations/ai-write.ts — the AI write surface on the registry (DDR-242 AD9)
|
|
3
|
+
* @scope apps/studio/annotations/ai-write.ts
|
|
4
|
+
* @purpose The engine behind `maude design annotate`. An agent speaks in
|
|
5
|
+
* WORLD coordinates and element ids; this turns each request into
|
|
6
|
+
* the same `put | patch | delete` ops the canvas sends
|
|
7
|
+
* (annotations/ops.ts), applying them to a local copy as it goes so
|
|
8
|
+
* later requests in the batch see earlier results (refs, geometry).
|
|
9
|
+
*
|
|
10
|
+
* Everything type-specific comes from the registry: which fields a
|
|
11
|
+
* type has (and so what `update` accepts), where its text lives,
|
|
12
|
+
* whether an arrow can bind to it, how it translates. Nothing here
|
|
13
|
+
* is a per-type list that a new element type would have to extend.
|
|
14
|
+
*
|
|
15
|
+
* Every created element is stamped `author: {kind: 'ai'}`. Updates
|
|
16
|
+
* are field patches that carry `expect` (the values the agent's
|
|
17
|
+
* read saw), so a text edit merges with a concurrent human edit
|
|
18
|
+
* instead of overwriting it (DDR-242 §4).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { anchorPoint, facingAnchor } from './elements/arrow.model.ts';
|
|
22
|
+
import { compareOrder, keyBetween } from './fractional-index.ts';
|
|
23
|
+
import { type ApplyResult, applyOps, type Op } from './ops.ts';
|
|
24
|
+
import './ops-merge.ts';
|
|
25
|
+
import { defOf, REGISTRY, specOf } from './registry.ts';
|
|
26
|
+
import { Scene } from './scene.ts';
|
|
27
|
+
import type { AnnotationElement, Box } from './types.ts';
|
|
28
|
+
|
|
29
|
+
export class AiOpError extends Error {}
|
|
30
|
+
|
|
31
|
+
/** Creation defaults per type — sizes only; colours etc. are the registry defaults. */
|
|
32
|
+
export const CREATE_SIZE: Readonly<Record<string, { w: number; h: number }>> = {
|
|
33
|
+
sticky: { w: 200, h: 200 },
|
|
34
|
+
shape: { w: 180, h: 80 },
|
|
35
|
+
section: { w: 480, h: 320 },
|
|
36
|
+
image: { w: 240, h: 160 },
|
|
37
|
+
link: { w: 280, h: 72 },
|
|
38
|
+
mediaref: { w: 280, h: 72 },
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** Keys an op may carry that are placement / batch plumbing, not element fields. */
|
|
42
|
+
const PLUMBING = new Set([
|
|
43
|
+
'op',
|
|
44
|
+
'type',
|
|
45
|
+
'id',
|
|
46
|
+
'ref',
|
|
47
|
+
'in',
|
|
48
|
+
'near',
|
|
49
|
+
'pin',
|
|
50
|
+
'pointer',
|
|
51
|
+
'parent',
|
|
52
|
+
'flowX',
|
|
53
|
+
'flowY',
|
|
54
|
+
'boardX',
|
|
55
|
+
'boardY',
|
|
56
|
+
'from',
|
|
57
|
+
'to',
|
|
58
|
+
'x1',
|
|
59
|
+
'y1',
|
|
60
|
+
'x2',
|
|
61
|
+
'y2',
|
|
62
|
+
'expect',
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
/** Fields `update` must not set directly (they have their own verbs). */
|
|
66
|
+
const IMMUTABLE = new Set(['id', 'type', 'index', 'parent', 'author']);
|
|
67
|
+
|
|
68
|
+
export function mintId(prefix = 's'): string {
|
|
69
|
+
return `${prefix}_${Math.random().toString(36).slice(2, 10).padEnd(8, '0')}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** One-line description of every registry type and its fields (the verb's help). */
|
|
73
|
+
export function describeTypes(): string {
|
|
74
|
+
const lines: string[] = [];
|
|
75
|
+
for (const [type, def] of REGISTRY) {
|
|
76
|
+
const fields = Object.keys(def.fields).join(' ');
|
|
77
|
+
const extra = def.caps.textSlot ? ` · text → ${def.caps.textSlot}` : '';
|
|
78
|
+
const bind = def.caps.bindable ? ' · bindable' : '';
|
|
79
|
+
lines.push(` ${type.padEnd(9)} ${fields}${extra}${bind}`);
|
|
80
|
+
}
|
|
81
|
+
return lines.join('\n');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function isRec(v: unknown): v is Record<string, unknown> {
|
|
85
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function num(v: unknown): v is number {
|
|
89
|
+
return typeof v === 'number' && Number.isFinite(v);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Estimated box of a text block (the editor re-measures it on the next edit). */
|
|
93
|
+
export function textBox(text: string, fontSize: number): { w: number; h: number } {
|
|
94
|
+
const lines = text.split('\n');
|
|
95
|
+
const longest = lines.reduce((m, l) => Math.max(m, l.length), 0);
|
|
96
|
+
return {
|
|
97
|
+
w: Math.max(8, Math.round(longest * fontSize * 0.55)),
|
|
98
|
+
h: Math.round(lines.length <= 1 ? fontSize * 1.2 : lines.length * fontSize * 1.25),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export class AiBatch {
|
|
103
|
+
state: Map<string, AnnotationElement>;
|
|
104
|
+
/** The ops to send / persist, in order. */
|
|
105
|
+
readonly ops: Op[] = [];
|
|
106
|
+
/** `@ref` → minted id. */
|
|
107
|
+
readonly refs = new Map<string, string>();
|
|
108
|
+
readonly created: string[] = [];
|
|
109
|
+
readonly updated = new Set<string>();
|
|
110
|
+
readonly deleted: string[] = [];
|
|
111
|
+
private sceneCache: Scene | null = null;
|
|
112
|
+
|
|
113
|
+
constructor(elements: Iterable<AnnotationElement>) {
|
|
114
|
+
this.state = new Map([...elements].map((e) => [e.id, e]));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
get scene(): Scene {
|
|
118
|
+
this.sceneCache ??= new Scene(this.state.values());
|
|
119
|
+
return this.sceneCache;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
get elements(): AnnotationElement[] {
|
|
123
|
+
return [...this.state.values()];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Apply ops to the local copy; any rejection fails the whole batch loudly. */
|
|
127
|
+
private apply(ops: Op[], what: string): ApplyResult {
|
|
128
|
+
const r = applyOps(this.state, ops);
|
|
129
|
+
if (r.rejected.length) {
|
|
130
|
+
const x = r.rejected[0] as ApplyResult['rejected'][number];
|
|
131
|
+
const id = 'id' in x.op ? x.op.id : x.op.el?.id;
|
|
132
|
+
throw new AiOpError(`${what}: the board refused the change to "${id}" (${x.reason})`);
|
|
133
|
+
}
|
|
134
|
+
this.state = r.state;
|
|
135
|
+
this.sceneCache = null;
|
|
136
|
+
this.ops.push(...ops);
|
|
137
|
+
return r;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** An id or `@ref` → the existing element's id. */
|
|
141
|
+
resolve(idOrRef: unknown, what: string): string {
|
|
142
|
+
if (typeof idOrRef !== 'string' || !idOrRef) throw new AiOpError(`${what}: missing id`);
|
|
143
|
+
const id = idOrRef.startsWith('@') ? this.refs.get(idOrRef) : idOrRef;
|
|
144
|
+
if (!id) throw new AiOpError(`${what}: unknown ref "${idOrRef}"`);
|
|
145
|
+
if (!this.state.has(id)) throw new AiOpError(`${what}: unknown id "${id}"`);
|
|
146
|
+
return id;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private get(id: string): AnnotationElement {
|
|
150
|
+
return this.state.get(id) as AnnotationElement;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** World origin of the container `parent` (0,0 at top level). */
|
|
154
|
+
private originIn(parent: string | undefined): { x: number; y: number } {
|
|
155
|
+
if (!parent) return { x: 0, y: 0 };
|
|
156
|
+
const p = this.get(parent);
|
|
157
|
+
const po = this.scene.originOf(p);
|
|
158
|
+
return { x: po.x + (num(p.x) ? p.x : 0), y: po.y + (num(p.y) ? p.y : 0) };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** A key above every current child of `parent`. */
|
|
162
|
+
private topIndex(parent: string | undefined): string {
|
|
163
|
+
const sibs = this.scene.childrenOf(parent ?? null);
|
|
164
|
+
const last = sibs[sibs.length - 1];
|
|
165
|
+
return keyBetween(last ? last.index : null, null);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private container(idOrRef: unknown, what: string): string {
|
|
169
|
+
const id = this.resolve(idOrRef, what);
|
|
170
|
+
if (!defOf(this.get(id).type)?.caps.container) {
|
|
171
|
+
throw new AiOpError(`${what}: "${id}" is not a section`);
|
|
172
|
+
}
|
|
173
|
+
return id;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Map the agent's field vocabulary onto the type's real fields:
|
|
178
|
+
* text → the type's text slot (text / shape label / section title)
|
|
179
|
+
* color → `fill` on a type with no `color` field (a sticky's paper)
|
|
180
|
+
* shape → shape `kind` (rounded = rect with radius 8, circle = ellipse)
|
|
181
|
+
* label → a shape's label text
|
|
182
|
+
* null → reset the field to its default
|
|
183
|
+
* Unknown fields and values the type can't hold are errors, never dropped.
|
|
184
|
+
*/
|
|
185
|
+
private fields(
|
|
186
|
+
type: string,
|
|
187
|
+
raw: Record<string, unknown>,
|
|
188
|
+
cur: AnnotationElement | null,
|
|
189
|
+
what: string
|
|
190
|
+
): { set: Record<string, unknown>; unset: string[] } {
|
|
191
|
+
const spec = specOf(type);
|
|
192
|
+
const def = defOf(type);
|
|
193
|
+
if (!spec || !def) throw new AiOpError(`${what}: unknown type "${type}"`);
|
|
194
|
+
const set: Record<string, unknown> = {};
|
|
195
|
+
const unset: string[] = [];
|
|
196
|
+
const put = (k: string, v: unknown) => {
|
|
197
|
+
if (v === null) unset.push(k);
|
|
198
|
+
else set[k] = v;
|
|
199
|
+
};
|
|
200
|
+
for (const [k0, v] of Object.entries(raw)) {
|
|
201
|
+
if (v === undefined || PLUMBING.has(k0) || k0 === 'x' || k0 === 'y') continue;
|
|
202
|
+
let k = k0;
|
|
203
|
+
let val = v;
|
|
204
|
+
if (k === 'text' && def.caps.textSlot === 'label') {
|
|
205
|
+
k = 'label';
|
|
206
|
+
val = { ...((cur?.label as object) ?? {}), text: v ?? '' };
|
|
207
|
+
} else if (k === 'text' && def.caps.textSlot === 'title') {
|
|
208
|
+
k = 'label';
|
|
209
|
+
} else if (k === 'label' && def.caps.textSlot === 'label' && typeof v === 'string') {
|
|
210
|
+
val = { ...((cur?.label as object) ?? {}), text: v };
|
|
211
|
+
} else if (k === 'color' && !('color' in def.fields) && 'fill' in def.fields) {
|
|
212
|
+
k = 'fill';
|
|
213
|
+
} else if (k === 'shape' && type === 'shape') {
|
|
214
|
+
k = 'kind';
|
|
215
|
+
if (v === 'rounded') {
|
|
216
|
+
val = 'rect';
|
|
217
|
+
if (!('radius' in raw)) set.radius = 8;
|
|
218
|
+
} else if (v === 'square') val = 'rect';
|
|
219
|
+
else if (v === 'circle') val = 'ellipse';
|
|
220
|
+
}
|
|
221
|
+
if (IMMUTABLE.has(k)) {
|
|
222
|
+
throw new AiOpError(
|
|
223
|
+
`${what}: "${k}" can't be set here${k === 'parent' ? ' — use reparent' : ''}`
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
if (!Object.hasOwn(def.fields, k)) {
|
|
227
|
+
throw new AiOpError(
|
|
228
|
+
`${what}: ${type} has no field "${k0}" (fields: ${Object.keys(def.fields).join(', ')})`
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
if (val !== null && spec[k]?.parse(val) === undefined) {
|
|
232
|
+
throw new AiOpError(`${what}: invalid value for ${type}.${k}: ${JSON.stringify(val)}`);
|
|
233
|
+
}
|
|
234
|
+
put(k, val);
|
|
235
|
+
}
|
|
236
|
+
return { set, unset };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Create an element. Coordinates are WORLD; `parent` (id / @ref / null)
|
|
241
|
+
* pins the container, else a box element joins the deepest section its
|
|
242
|
+
* centre lands in — exactly where a person dropping it would put it.
|
|
243
|
+
*/
|
|
244
|
+
create(type: string, op: Record<string, unknown>): string {
|
|
245
|
+
const what = `create ${type}`;
|
|
246
|
+
const def = defOf(type);
|
|
247
|
+
if (!def) {
|
|
248
|
+
throw new AiOpError(
|
|
249
|
+
`create: unknown type "${type}" (types: ${[...REGISTRY.keys()].join(', ')})`
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
const { set } = this.fields(type, op, null, what);
|
|
253
|
+
const world: Record<string, unknown> = { ...set };
|
|
254
|
+
if (def.caps.box) {
|
|
255
|
+
const size = CREATE_SIZE[type];
|
|
256
|
+
let w = num(op.w) ? op.w : size?.w;
|
|
257
|
+
let h = num(op.h) ? op.h : size?.h;
|
|
258
|
+
if (type === 'text' && (w === undefined || h === undefined)) {
|
|
259
|
+
const tb = textBox(
|
|
260
|
+
typeof op.text === 'string' ? op.text : '',
|
|
261
|
+
num(op.fontSize) ? op.fontSize : 14
|
|
262
|
+
);
|
|
263
|
+
w ??= tb.w;
|
|
264
|
+
h ??= tb.h;
|
|
265
|
+
}
|
|
266
|
+
if (!num(op.x) || !num(op.y) || !num(w) || !num(h)) {
|
|
267
|
+
throw new AiOpError(`${what}: needs x, y (world) and a size`);
|
|
268
|
+
}
|
|
269
|
+
Object.assign(world, { x: op.x, y: op.y, w, h });
|
|
270
|
+
} else if (type === 'arrow') {
|
|
271
|
+
world.start = this.arrowEnd(op, 'start', what);
|
|
272
|
+
world.end = this.arrowEnd(op, 'end', what);
|
|
273
|
+
} else if (type === 'pen') {
|
|
274
|
+
if (!Array.isArray(op.points)) throw new AiOpError(`${what}: needs points [x0,y0,x1,y1,…]`);
|
|
275
|
+
}
|
|
276
|
+
const id = mintId();
|
|
277
|
+
let draft = { id, type, index: 'a0', ...world } as AnnotationElement;
|
|
278
|
+
// Which container: explicit, else where the centre lands (box elements).
|
|
279
|
+
let parent: string | undefined;
|
|
280
|
+
if (op.parent !== undefined && op.parent !== null) parent = this.container(op.parent, what);
|
|
281
|
+
else if (op.parent === undefined && def.caps.box) {
|
|
282
|
+
const b = def.bounds(draft, { origin: { x: 0, y: 0 }, resolve: () => null });
|
|
283
|
+
const hit = b ? this.scene.containerAt(b.x + b.w / 2, b.y + b.h / 2) : null;
|
|
284
|
+
if (hit) parent = hit.id;
|
|
285
|
+
}
|
|
286
|
+
if (parent) {
|
|
287
|
+
const o = this.originIn(parent);
|
|
288
|
+
draft = { ...draft, ...def.translate(draft, -o.x, -o.y), parent };
|
|
289
|
+
}
|
|
290
|
+
draft.index = this.topIndex(parent);
|
|
291
|
+
draft.author = { kind: 'ai' };
|
|
292
|
+
if (!def.meaningful(draft)) {
|
|
293
|
+
throw new AiOpError(
|
|
294
|
+
`${what}: too small or empty to keep (a ${type} needs real content/size)`
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
this.apply([{ op: 'put', el: draft }], what);
|
|
298
|
+
if (typeof op.ref === 'string' && op.ref.startsWith('@')) this.refs.set(op.ref, id);
|
|
299
|
+
this.created.push(id);
|
|
300
|
+
return id;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
private arrowEnd(op: Record<string, unknown>, which: 'start' | 'end', what: string): unknown {
|
|
304
|
+
const bound = which === 'start' ? op.from : op.to;
|
|
305
|
+
if (bound !== undefined) {
|
|
306
|
+
const id = this.resolve(bound, what);
|
|
307
|
+
if (!this.bindable(id)) {
|
|
308
|
+
throw new AiOpError(`${what}: "${id}" (${this.get(id).type}) can't take an arrow end`);
|
|
309
|
+
}
|
|
310
|
+
return { el: id };
|
|
311
|
+
}
|
|
312
|
+
const raw = op[which];
|
|
313
|
+
if (isRec(raw) && num(raw.x) && num(raw.y)) return { x: raw.x, y: raw.y };
|
|
314
|
+
const [kx, ky] = which === 'start' ? ['x1', 'y1'] : ['x2', 'y2'];
|
|
315
|
+
if (num(op[kx]) && num(op[ky])) return { x: op[kx], y: op[ky] };
|
|
316
|
+
throw new AiOpError(`${what}: needs ${which === 'start' ? 'from or x1/y1' : 'to or x2/y2'}`);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Whether an arrow end can bind to `id` — the registry's answer, not a list. */
|
|
320
|
+
bindable(id: string): boolean {
|
|
321
|
+
const el = this.state.get(id);
|
|
322
|
+
return !!el && !!this.scene.ctx(el).resolve(id)?.bindable;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** World box of an element (arrows: the bounds of their endpoints). */
|
|
326
|
+
worldBox(id: string): Box | null {
|
|
327
|
+
return this.scene.worldBox(id);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** A bound connector between two elements; a label becomes a text at its midpoint. */
|
|
331
|
+
connect(op: Record<string, unknown>): string {
|
|
332
|
+
const id = this.create('arrow', {
|
|
333
|
+
...Object.fromEntries(
|
|
334
|
+
Object.entries(op).filter(([k]) => k !== 'label' && k !== 'text' && k !== 'op')
|
|
335
|
+
),
|
|
336
|
+
});
|
|
337
|
+
const label = typeof op.label === 'string' ? op.label : undefined;
|
|
338
|
+
if (label) {
|
|
339
|
+
const w = this.scene.arrowWorld(this.get(id));
|
|
340
|
+
if (w) {
|
|
341
|
+
this.create('text', {
|
|
342
|
+
text: label,
|
|
343
|
+
fontSize: 12,
|
|
344
|
+
x: (w.x1 + w.x2) / 2 + 6,
|
|
345
|
+
y: (w.y1 + w.y2) / 2 - 18,
|
|
346
|
+
parent: null,
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return id;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** A free pointer arrow from a note to a world rect (a DOM element — not bindable). */
|
|
354
|
+
pointer(fromId: string, target: Box): string | null {
|
|
355
|
+
const from = this.worldBox(fromId);
|
|
356
|
+
if (!from) return null;
|
|
357
|
+
const a = facingAnchor(target, from.x + from.w / 2, from.y + from.h / 2);
|
|
358
|
+
const [ex, ey] = anchorPoint(target, 0, a.nx, a.ny);
|
|
359
|
+
return this.create('arrow', { from: fromId, x2: ex, y2: ey, parent: null });
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Patch fields of an element (world x/y). `text` goes to the type's text slot. */
|
|
363
|
+
update(op: Record<string, unknown>): void {
|
|
364
|
+
const id = this.resolve(op.id, 'update');
|
|
365
|
+
if (op.parent !== undefined)
|
|
366
|
+
throw new AiOpError('update: "parent" can\'t be set here — use reparent');
|
|
367
|
+
const cur = this.get(id);
|
|
368
|
+
const def = defOf(cur.type);
|
|
369
|
+
if (!def)
|
|
370
|
+
throw new AiOpError(`update: "${id}" is an unknown type (${cur.type}) — read-only here`);
|
|
371
|
+
const { set, unset } = this.fields(cur.type, op, cur, `update ${cur.type}`);
|
|
372
|
+
// The agent speaks WORLD coordinates; the record stores parent-relative ones.
|
|
373
|
+
const o = this.scene.originOf(cur);
|
|
374
|
+
if (num(op.x) || num(op.y)) {
|
|
375
|
+
if (!def.caps.box) {
|
|
376
|
+
throw new AiOpError(`update: ${cur.type} has no x/y — use move`);
|
|
377
|
+
}
|
|
378
|
+
if (num(op.x)) set.x = op.x - o.x;
|
|
379
|
+
if (num(op.y)) set.y = op.y - o.y;
|
|
380
|
+
}
|
|
381
|
+
if (Array.isArray(set.points)) {
|
|
382
|
+
set.points = (set.points as number[]).map((v, i) => v - (i % 2 === 0 ? o.x : o.y));
|
|
383
|
+
}
|
|
384
|
+
for (const k of ['start', 'end'] as const) {
|
|
385
|
+
const e = set[k];
|
|
386
|
+
if (isRec(e) && num(e.x) && num(e.y)) set[k] = { x: e.x - o.x, y: e.y - o.y };
|
|
387
|
+
}
|
|
388
|
+
if (!Object.keys(set).length && !unset.length) throw new AiOpError('update: no fields to set');
|
|
389
|
+
const keys = [...Object.keys(set), ...unset];
|
|
390
|
+
const expect: Record<string, unknown> = {};
|
|
391
|
+
for (const k of keys) expect[k] = cur[k];
|
|
392
|
+
this.apply(
|
|
393
|
+
[{ op: 'patch', id, set, ...(unset.length ? { unset } : {}), expect }],
|
|
394
|
+
`update ${id}`
|
|
395
|
+
);
|
|
396
|
+
this.updated.add(id);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Move by `dx`/`dy`, or so the element's world box starts at `x`/`y`. The
|
|
401
|
+
* element then belongs to the section its centre lands in (the drop rule),
|
|
402
|
+
* unless `keepParent` is set. A bound arrow end follows its host — moving an
|
|
403
|
+
* arrow moves only its free ends.
|
|
404
|
+
*/
|
|
405
|
+
move(op: Record<string, unknown>): void {
|
|
406
|
+
const id = this.resolve(op.id, 'move');
|
|
407
|
+
const cur = this.get(id);
|
|
408
|
+
const def = defOf(cur.type);
|
|
409
|
+
if (!def) throw new AiOpError(`move: "${id}" is an unknown type (${cur.type})`);
|
|
410
|
+
let dx: number;
|
|
411
|
+
let dy: number;
|
|
412
|
+
if (num(op.dx) || num(op.dy)) {
|
|
413
|
+
dx = num(op.dx) ? op.dx : 0;
|
|
414
|
+
dy = num(op.dy) ? op.dy : 0;
|
|
415
|
+
} else if (num(op.x) && num(op.y)) {
|
|
416
|
+
const b = this.worldBox(id);
|
|
417
|
+
if (!b) throw new AiOpError(`move: "${id}" has no position`);
|
|
418
|
+
dx = op.x - b.x;
|
|
419
|
+
dy = op.y - b.y;
|
|
420
|
+
} else {
|
|
421
|
+
throw new AiOpError('move: needs x/y (world) or dx/dy');
|
|
422
|
+
}
|
|
423
|
+
const set = def.translate(cur, dx, dy);
|
|
424
|
+
if (!Object.keys(set).length) {
|
|
425
|
+
throw new AiOpError(`move: "${id}" is bound at both ends — move the elements it connects`);
|
|
426
|
+
}
|
|
427
|
+
const expect: Record<string, unknown> = {};
|
|
428
|
+
for (const k of Object.keys(set)) expect[k] = cur[k];
|
|
429
|
+
this.apply([{ op: 'patch', id, set, expect }], `move ${id}`);
|
|
430
|
+
this.updated.add(id);
|
|
431
|
+
if (op.keepParent === true || !def.caps.box) return;
|
|
432
|
+
const b = this.worldBox(id);
|
|
433
|
+
if (!b) return;
|
|
434
|
+
const exclude = new Set([id]);
|
|
435
|
+
const hit = this.scene.containerAt(b.x + b.w / 2, b.y + b.h / 2, exclude);
|
|
436
|
+
const target = hit?.id ?? null;
|
|
437
|
+
if (target !== (this.scene.effectiveParent(this.get(id)) ?? null)) {
|
|
438
|
+
this.reparent({ id, parent: target });
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** Move an element into a section (or to top level with `parent: null`), keeping its world position. */
|
|
443
|
+
reparent(op: Record<string, unknown>): void {
|
|
444
|
+
const id = this.resolve(op.id, 'reparent');
|
|
445
|
+
const cur = this.get(id);
|
|
446
|
+
const def = defOf(cur.type);
|
|
447
|
+
if (!def) throw new AiOpError(`reparent: "${id}" is an unknown type (${cur.type})`);
|
|
448
|
+
if (op.parent === undefined)
|
|
449
|
+
throw new AiOpError('reparent: needs parent (a section id, or null)');
|
|
450
|
+
const next = op.parent === null ? undefined : this.container(op.parent, 'reparent');
|
|
451
|
+
if (next === id || (next && this.scene.ancestors(next).includes(id))) {
|
|
452
|
+
throw new AiOpError(`reparent: "${id}" can't go inside itself`);
|
|
453
|
+
}
|
|
454
|
+
const from = this.scene.originOf(cur);
|
|
455
|
+
const to = this.originIn(next);
|
|
456
|
+
const set: Record<string, unknown> = {
|
|
457
|
+
...def.translate(cur, from.x - to.x, from.y - to.y),
|
|
458
|
+
index: this.topIndex(next),
|
|
459
|
+
};
|
|
460
|
+
const unset: string[] = [];
|
|
461
|
+
if (next) set.parent = next;
|
|
462
|
+
else unset.push('parent');
|
|
463
|
+
const expect: Record<string, unknown> = {};
|
|
464
|
+
for (const k of [...Object.keys(set), ...unset]) expect[k] = cur[k];
|
|
465
|
+
this.apply(
|
|
466
|
+
[{ op: 'patch', id, set, ...(unset.length ? { unset } : {}), expect }],
|
|
467
|
+
`reparent ${id}`
|
|
468
|
+
);
|
|
469
|
+
this.updated.add(id);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Change paint order among siblings: `to: "front" | "back" | "forward" |
|
|
474
|
+
* "backward"`, or `before` / `after` another sibling id. One key changes —
|
|
475
|
+
* the neighbours are never renumbered.
|
|
476
|
+
*/
|
|
477
|
+
reorder(op: Record<string, unknown>): void {
|
|
478
|
+
const id = this.resolve(op.id, 'reorder');
|
|
479
|
+
const cur = this.get(id);
|
|
480
|
+
const parent = this.scene.effectiveParent(cur);
|
|
481
|
+
const sibs = [...this.scene.childrenOf(parent)].sort(compareOrder);
|
|
482
|
+
const at = sibs.findIndex((s) => s.id === id);
|
|
483
|
+
const rest = sibs.filter((s) => s.id !== id);
|
|
484
|
+
let pos: number; // insert position in `rest`
|
|
485
|
+
if (op.before !== undefined || op.after !== undefined) {
|
|
486
|
+
const ref = this.resolve(op.before ?? op.after, 'reorder');
|
|
487
|
+
const i = rest.findIndex((s) => s.id === ref);
|
|
488
|
+
if (i < 0) throw new AiOpError(`reorder: "${ref}" is not a sibling of "${id}"`);
|
|
489
|
+
pos = op.before !== undefined ? i : i + 1;
|
|
490
|
+
} else if (op.to === 'front') pos = rest.length;
|
|
491
|
+
else if (op.to === 'back') pos = 0;
|
|
492
|
+
else if (op.to === 'forward') pos = Math.min(rest.length, at + 1);
|
|
493
|
+
else if (op.to === 'backward') pos = Math.max(0, at - 1);
|
|
494
|
+
else throw new AiOpError('reorder: needs to (front|back|forward|backward) or before/after');
|
|
495
|
+
const lo = rest[pos - 1]?.index ?? null;
|
|
496
|
+
const hi = rest[pos]?.index ?? null;
|
|
497
|
+
if (pos === at) return; // already there
|
|
498
|
+
let index: string;
|
|
499
|
+
try {
|
|
500
|
+
index = keyBetween(lo, hi);
|
|
501
|
+
} catch {
|
|
502
|
+
throw new AiOpError(
|
|
503
|
+
`reorder: its neighbours share one order key — use to: "front" or "back" instead`
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
this.apply(
|
|
507
|
+
[{ op: 'patch', id, set: { index }, expect: { index: cur.index } }],
|
|
508
|
+
`reorder ${id}`
|
|
509
|
+
);
|
|
510
|
+
this.updated.add(id);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** Put the elements in one new group (appended as their outermost group). */
|
|
514
|
+
group(op: Record<string, unknown>): string {
|
|
515
|
+
const ids = Array.isArray(op.ids) ? op.ids.map((r) => this.resolve(r, 'group')) : [];
|
|
516
|
+
if (new Set(ids).size < 2) throw new AiOpError('group: needs at least two ids');
|
|
517
|
+
const gid = mintId('g');
|
|
518
|
+
const ops: Op[] = ids.map((id) => {
|
|
519
|
+
const cur = this.get(id);
|
|
520
|
+
const groups = Array.isArray(cur.groups) ? (cur.groups as string[]) : [];
|
|
521
|
+
return { op: 'patch', id, set: { groups: [...groups, gid] }, expect: { groups: cur.groups } };
|
|
522
|
+
});
|
|
523
|
+
this.apply(ops, 'group');
|
|
524
|
+
for (const id of ids) this.updated.add(id);
|
|
525
|
+
return gid;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** Delete (a section's children move up; arrows bound to it keep their endpoint). */
|
|
529
|
+
delete(op: Record<string, unknown>): void {
|
|
530
|
+
const id = this.resolve(op.id, 'delete');
|
|
531
|
+
this.apply([{ op: 'delete', id }], `delete ${id}`);
|
|
532
|
+
this.deleted.push(id);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file annotations/board-io.ts — headless board file I/O (DDR-242)
|
|
3
|
+
* @scope apps/studio/annotations/board-io.ts
|
|
4
|
+
* @purpose How the headless verbs (`maude design read-annotations` /
|
|
5
|
+
* `annotate` / `import-figma`) read and write a canvas's
|
|
6
|
+
* `<slug>.annotations.json` without a dev server. Mirrors the
|
|
7
|
+
* server's strict read (api.ts `readBoard`): an oversized or
|
|
8
|
+
* unreadable file is REPORTED, never treated as an empty board —
|
|
9
|
+
* a write over it would erase whatever it holds (code review H2).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
existsSync,
|
|
14
|
+
lstatSync,
|
|
15
|
+
mkdirSync,
|
|
16
|
+
readFileSync,
|
|
17
|
+
realpathSync,
|
|
18
|
+
renameSync,
|
|
19
|
+
rmSync,
|
|
20
|
+
writeFileSync,
|
|
21
|
+
} from 'node:fs';
|
|
22
|
+
import { join, sep } from 'node:path';
|
|
23
|
+
import { canonicalAnnotations } from './board-text.ts';
|
|
24
|
+
import { MAX_BOARD_BYTES } from './constants.ts';
|
|
25
|
+
import { parseBoard } from './schema.ts';
|
|
26
|
+
import type { AnnotationElement } from './types.ts';
|
|
27
|
+
|
|
28
|
+
export interface BoardFile {
|
|
29
|
+
/** The file on disk is over MAX_BOARD_BYTES — it was NOT read or parsed. */
|
|
30
|
+
tooLarge?: boolean;
|
|
31
|
+
/** The file exists but is not a readable board — it must not be written over. */
|
|
32
|
+
unreadable?: boolean;
|
|
33
|
+
/** The board as canonical text ('' when the canvas has no annotations yet). */
|
|
34
|
+
boardText: string;
|
|
35
|
+
elements: AnnotationElement[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function boardPath(designRoot: string, slug: string): string {
|
|
39
|
+
return join(designRoot, `${slug}.annotations.json`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Read `<slug>.annotations.json`, falling back to a not-yet-migrated `.svg`. */
|
|
43
|
+
export function readBoardFile(designRoot: string, slug: string): BoardFile {
|
|
44
|
+
const json = boardPath(designRoot, slug);
|
|
45
|
+
const legacy = join(designRoot, `${slug}.annotations.svg`);
|
|
46
|
+
const path = existsSync(json) ? json : existsSync(legacy) ? legacy : null;
|
|
47
|
+
if (!path) return { boardText: '', elements: [] };
|
|
48
|
+
// A peer- or git-written file is untrusted (DDR-054): a regular file only
|
|
49
|
+
// (a symlink or a FIFO would read elsewhere or block forever), and the size
|
|
50
|
+
// checked BEFORE reading, so an oversized board never reaches a parser.
|
|
51
|
+
const st = lstatSync(path);
|
|
52
|
+
if (!st.isFile()) return { unreadable: true, boardText: '', elements: [] };
|
|
53
|
+
if (st.size > MAX_BOARD_BYTES) return { tooLarge: true, boardText: '', elements: [] };
|
|
54
|
+
let clean: string | null = null;
|
|
55
|
+
try {
|
|
56
|
+
clean = canonicalAnnotations(readFileSync(path, 'utf8'));
|
|
57
|
+
} catch {
|
|
58
|
+
clean = null;
|
|
59
|
+
}
|
|
60
|
+
if (clean === null) return { unreadable: true, boardText: '', elements: [] };
|
|
61
|
+
return { boardText: clean, elements: parseBoard(clean).elements };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Write board text atomically: a temp file under the (runtime-ignored)
|
|
66
|
+
* `_state/` directory, then one rename — a reader never sees half a board.
|
|
67
|
+
*/
|
|
68
|
+
export function writeBoardFileAtomic(designRoot: string, slug: string, text: string): string {
|
|
69
|
+
const target = boardPath(designRoot, slug);
|
|
70
|
+
// Never write through a symlink (a committed one could point anywhere):
|
|
71
|
+
// the board must be a regular file or absent, and `_state/` a real
|
|
72
|
+
// directory inside the design root (security review W1).
|
|
73
|
+
if (existsSync(target) && !lstatSync(target).isFile()) {
|
|
74
|
+
throw new Error(`${slug}.annotations.json is not a regular file — nothing written`);
|
|
75
|
+
}
|
|
76
|
+
const scratch = join(designRoot, '_state');
|
|
77
|
+
if (existsSync(scratch) && !lstatSync(scratch).isDirectory()) {
|
|
78
|
+
throw new Error('_state is not a directory — nothing written');
|
|
79
|
+
}
|
|
80
|
+
mkdirSync(scratch, { recursive: true });
|
|
81
|
+
const realRoot = realpathSync(designRoot);
|
|
82
|
+
const realScratch = realpathSync(scratch);
|
|
83
|
+
if (realScratch !== realRoot && !realScratch.startsWith(realRoot + sep)) {
|
|
84
|
+
throw new Error('_state resolves outside the design root — nothing written');
|
|
85
|
+
}
|
|
86
|
+
const tmp = join(scratch, `annotations-${process.pid}-${Date.now().toString(36)}.tmp`);
|
|
87
|
+
try {
|
|
88
|
+
writeFileSync(tmp, text, 'utf8');
|
|
89
|
+
renameSync(tmp, target);
|
|
90
|
+
} finally {
|
|
91
|
+
rmSync(tmp, { force: true });
|
|
92
|
+
}
|
|
93
|
+
return target;
|
|
94
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file annotations/board-text.ts — canonical board text from any stored form
|
|
3
|
+
* @scope apps/studio/annotations/board-text.ts
|
|
4
|
+
* @purpose DDR-242: the annotations LANE value everywhere (file, sync lane,
|
|
5
|
+
* hub store) is the canonical board text. This is the one entry
|
|
6
|
+
* point that turns whatever arrives — board JSON, a legacy
|
|
7
|
+
* `.annotations.svg`, or nothing — into that text.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { MAX_LEGACY_SVG_BYTES } from './constants.ts';
|
|
11
|
+
import { migrateSvg } from './migrate-v1.ts';
|
|
12
|
+
import { parseBoard, serializeBoard } from './schema.ts';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Canonical board text for `text`: '' → empty board; legacy SVG → migrated;
|
|
16
|
+
* board JSON → re-validated + canonical. null when it is neither (callers must
|
|
17
|
+
* refuse the write, never treat it as emptiness — DDR-223).
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* The annotations LANE value (accepted revisions, DDR-241): canonical board
|
|
21
|
+
* text, except that an EMPTY board is `''` — the lane's "no value". Studio and
|
|
22
|
+
* kernel must agree byte for byte, because the lane hash is a proposal's base.
|
|
23
|
+
*/
|
|
24
|
+
export function annotationsLaneValue(text: string): string | null {
|
|
25
|
+
const c = canonicalAnnotations(text);
|
|
26
|
+
if (c === null) return null;
|
|
27
|
+
return parseBoard(c).elements.length === 0 ? '' : c;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function canonicalAnnotations(text: string): string | null {
|
|
31
|
+
if (text.trim() === '') return serializeBoard([]);
|
|
32
|
+
// Only an SVG document takes the legacy branch. Any other markup is NOT a
|
|
33
|
+
// board — returning an empty board for it would let one malformed write
|
|
34
|
+
// erase a board with content (the DDR-223 failure shape).
|
|
35
|
+
if (/^\s*<svg[\s>]/i.test(text)) {
|
|
36
|
+
// v1 boards were capped at 1 MB; anything far larger is not a real legacy
|
|
37
|
+
// board and is refused before it reaches a parser.
|
|
38
|
+
if (text.length > MAX_LEGACY_SVG_BYTES) return null;
|
|
39
|
+
return serializeBoard(migrateSvg(text).elements);
|
|
40
|
+
}
|
|
41
|
+
if (/^\s*</.test(text)) return null;
|
|
42
|
+
const parsed = parseBoard(text);
|
|
43
|
+
if (parsed.elements.length === 0 && parsed.dropped.some((d) => d.id === undefined)) return null;
|
|
44
|
+
return serializeBoard(parsed.elements);
|
|
45
|
+
}
|