@1agh/maude 1.4.6 → 1.5.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/apps/studio/annotations/ai-read.ts +288 -0
- package/apps/studio/annotations/ai-write.ts +533 -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.ts +474 -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 +248 -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 +63 -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 +95 -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,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file annotations/replica.ts — the annotations-v2 Yjs replica (DDR-242 §5)
|
|
3
|
+
* @scope apps/studio/annotations/replica.ts
|
|
4
|
+
* @purpose One codec for the board inside a Y.Doc, used by every writer: the
|
|
5
|
+
* collab room, the studio sync agent + projection, and the hub
|
|
6
|
+
* kernel. The replica is
|
|
7
|
+
*
|
|
8
|
+
* Y.Map('annotations2') element id → Y.Map(field → JSON value)
|
|
9
|
+
* '~v' = 2 format marker (present once v2 wrote)
|
|
10
|
+
* '~action' = <id> the action that produced the last write
|
|
11
|
+
*
|
|
12
|
+
* A NEW type name on purpose: a v1 peer keeps writing
|
|
13
|
+
* `Y.Map('annotations').svg`, which v2 never reads once '~v' is set,
|
|
14
|
+
* so a stale peer can't erase a v2 board (the DDR-223 failure class).
|
|
15
|
+
* Before any v2 write, the legacy value is read through the v1→v2
|
|
16
|
+
* migration (lazy, read-only).
|
|
17
|
+
*
|
|
18
|
+
* Writes are DIFFS: callers hand over a whole board (or ops) and
|
|
19
|
+
* only changed elements / fields become Yjs updates — so every lane
|
|
20
|
+
* "replace" in the sync code now crosses the wire per element.
|
|
21
|
+
*
|
|
22
|
+
* The doc is peer-writable in legacy mode, so every read validates
|
|
23
|
+
* (DDR-054): a malformed element is dropped, never trusted.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import * as Y from 'yjs';
|
|
27
|
+
import { MAX_LEGACY_SVG_BYTES } from './constants.ts';
|
|
28
|
+
import { jsonEq } from './fields.ts';
|
|
29
|
+
import { migrateSvg } from './migrate-v1.ts';
|
|
30
|
+
import { type ApplyResult, applyOps, type Op } from './ops.ts';
|
|
31
|
+
import { type BoardResult, parseBoard, serializeBoard, validateElements } from './schema.ts';
|
|
32
|
+
import type { AnnotationElement } from './types.ts';
|
|
33
|
+
|
|
34
|
+
export const REPLICA_TYPE = 'annotations2';
|
|
35
|
+
/** The v1 map (`svg` key). Read only for lazy migration. */
|
|
36
|
+
export const LEGACY_TYPE = 'annotations';
|
|
37
|
+
export const FORMAT_KEY = '~v';
|
|
38
|
+
export const ACTION_KEY = '~action';
|
|
39
|
+
export const REPLICA_VERSION = 2;
|
|
40
|
+
|
|
41
|
+
const ACTION_RE = /^[A-Za-z0-9_-]{1,96}$/;
|
|
42
|
+
|
|
43
|
+
export function validActionId(v: unknown): v is string {
|
|
44
|
+
return typeof v === 'string' && ACTION_RE.test(v);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function outer(doc: Y.Doc): Y.Map<unknown> {
|
|
48
|
+
return doc.getMap<unknown>(REPLICA_TYPE);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** True once any v2 writer has written this doc. */
|
|
52
|
+
export function hasReplica(doc: Y.Doc): boolean {
|
|
53
|
+
return outer(doc).get(FORMAT_KEY) === REPLICA_VERSION;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function legacySvg(doc: Y.Doc): string | null {
|
|
57
|
+
const svg = doc.getMap<unknown>(LEGACY_TYPE).get('svg');
|
|
58
|
+
return typeof svg === 'string' && svg.trim() ? svg : null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The board held by the doc, or null when it was never populated (neither a v2
|
|
63
|
+
* write nor a legacy value) — the cold-start "unknown" state, distinct from an
|
|
64
|
+
* empty board.
|
|
65
|
+
*/
|
|
66
|
+
export function readReplica(doc: Y.Doc): BoardResult | null {
|
|
67
|
+
const map = outer(doc);
|
|
68
|
+
if (map.get(FORMAT_KEY) === REPLICA_VERSION) {
|
|
69
|
+
const raw: unknown[] = [];
|
|
70
|
+
for (const [key, value] of map.entries()) {
|
|
71
|
+
if (key.startsWith('~')) continue;
|
|
72
|
+
if (value instanceof Y.Map) raw.push({ ...value.toJSON(), id: key });
|
|
73
|
+
}
|
|
74
|
+
return validateElements(raw);
|
|
75
|
+
}
|
|
76
|
+
const svg = legacySvg(doc);
|
|
77
|
+
if (svg !== null) {
|
|
78
|
+
// A peer-written legacy value far beyond the v1 cap is not parsed at all.
|
|
79
|
+
if (svg.length > MAX_LEGACY_SVG_BYTES) {
|
|
80
|
+
return {
|
|
81
|
+
elements: [],
|
|
82
|
+
dropped: [{ reason: 'legacy annotations value exceeds the size cap' }],
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
const m = migrateSvg(svg);
|
|
86
|
+
return { elements: m.elements, dropped: m.report };
|
|
87
|
+
}
|
|
88
|
+
if (doc.getMap<unknown>(LEGACY_TYPE).has('svg')) return { elements: [], dropped: [] };
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Canonical board text of the doc, or null when never populated. */
|
|
93
|
+
export function replicaBoardText(doc: Y.Doc): string | null {
|
|
94
|
+
const r = readReplica(doc);
|
|
95
|
+
return r ? serializeBoard(r.elements) : null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The last action id recorded by a v2 writer (echo suppression). */
|
|
99
|
+
export function replicaActionId(doc: Y.Doc): string | undefined {
|
|
100
|
+
const v = outer(doc).get(ACTION_KEY);
|
|
101
|
+
return validActionId(v) ? v : undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The board text a doc's replica last AGREED with on disk — noted by every
|
|
106
|
+
* doc→disk projector (the room's flush, the sync agent's writer) and by the
|
|
107
|
+
* disk→doc importer. Keyed by the doc, so all projectors of a shared doc see
|
|
108
|
+
* the same base (sync/codec.ts `importAnnotationsFromDisk`).
|
|
109
|
+
*/
|
|
110
|
+
const onDisk = new WeakMap<Y.Doc, string>();
|
|
111
|
+
|
|
112
|
+
/** Record that disk holds `text` as projected from / imported into `doc`. */
|
|
113
|
+
export function noteAnnotationsOnDisk(doc: Y.Doc, text: string): void {
|
|
114
|
+
onDisk.set(doc, text);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function annotationsOnDiskOf(doc: Y.Doc): string | undefined {
|
|
118
|
+
return onDisk.get(doc);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface WriteOpts {
|
|
122
|
+
/** Recorded under '~action' so the author can recognise its own echo. */
|
|
123
|
+
actionId?: string;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Make the replica hold exactly `elements` (already canonical), touching only
|
|
128
|
+
* what differs. Returns whether anything changed. One transaction, `origin`
|
|
129
|
+
* tagged, so peers receive ONE update.
|
|
130
|
+
*/
|
|
131
|
+
export function writeReplica(
|
|
132
|
+
doc: Y.Doc,
|
|
133
|
+
elements: readonly AnnotationElement[],
|
|
134
|
+
origin?: unknown,
|
|
135
|
+
opts: WriteOpts = {}
|
|
136
|
+
): boolean {
|
|
137
|
+
const map = outer(doc);
|
|
138
|
+
const target = new Map(elements.map((e) => [e.id, e]));
|
|
139
|
+
let changed = false;
|
|
140
|
+
doc.transact(() => {
|
|
141
|
+
if (map.get(FORMAT_KEY) !== REPLICA_VERSION) {
|
|
142
|
+
map.set(FORMAT_KEY, REPLICA_VERSION);
|
|
143
|
+
changed = true;
|
|
144
|
+
}
|
|
145
|
+
for (const key of [...map.keys()]) {
|
|
146
|
+
if (key.startsWith('~') || target.has(key)) continue;
|
|
147
|
+
map.delete(key);
|
|
148
|
+
changed = true;
|
|
149
|
+
}
|
|
150
|
+
for (const [id, el] of target) {
|
|
151
|
+
const cur = map.get(id);
|
|
152
|
+
const fields = Object.entries(el).filter(([k]) => k !== 'id');
|
|
153
|
+
if (!(cur instanceof Y.Map)) {
|
|
154
|
+
const inner = new Y.Map<unknown>();
|
|
155
|
+
for (const [k, v] of fields) inner.set(k, v);
|
|
156
|
+
map.set(id, inner);
|
|
157
|
+
changed = true;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
const keep = new Set(fields.map(([k]) => k));
|
|
161
|
+
for (const k of [...cur.keys()]) {
|
|
162
|
+
if (!keep.has(k)) {
|
|
163
|
+
cur.delete(k);
|
|
164
|
+
changed = true;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
for (const [k, v] of fields) {
|
|
168
|
+
if (!jsonEq(cur.get(k), v)) {
|
|
169
|
+
cur.set(k, v);
|
|
170
|
+
changed = true;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (changed && opts.actionId && validActionId(opts.actionId))
|
|
175
|
+
map.set(ACTION_KEY, opts.actionId);
|
|
176
|
+
else if (changed) map.delete(ACTION_KEY);
|
|
177
|
+
}, origin);
|
|
178
|
+
return changed;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** `writeReplica` from untrusted board text. Returns null when the text is not a board. */
|
|
182
|
+
export function writeReplicaText(
|
|
183
|
+
doc: Y.Doc,
|
|
184
|
+
text: string | null,
|
|
185
|
+
origin?: unknown,
|
|
186
|
+
opts: WriteOpts = {}
|
|
187
|
+
): boolean | null {
|
|
188
|
+
if (text === null || text === '') return writeReplica(doc, [], origin, opts);
|
|
189
|
+
const parsed = parseBoard(text);
|
|
190
|
+
if (parsed.dropped.some((d) => d.id === undefined)) return null; // not a board document
|
|
191
|
+
return writeReplica(doc, parsed.elements, origin, opts);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Apply ops to the replica under the DDR-242 merge rule; writes only the diff. */
|
|
195
|
+
export function applyOpsToReplica(
|
|
196
|
+
doc: Y.Doc,
|
|
197
|
+
ops: readonly Op[],
|
|
198
|
+
origin?: unknown,
|
|
199
|
+
opts: WriteOpts = {}
|
|
200
|
+
): ApplyResult {
|
|
201
|
+
const cur = readReplica(doc)?.elements ?? [];
|
|
202
|
+
const r = applyOps(new Map(cur.map((e) => [e.id, e])), ops);
|
|
203
|
+
if (r.touched.size || !hasReplica(doc)) writeReplica(doc, [...r.state.values()], origin, opts);
|
|
204
|
+
return r;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Observe the replica. `cb` receives the whole validated board, the ids whose
|
|
209
|
+
* records changed, and the action id of the write (undefined for a legacy /
|
|
210
|
+
* unattributed write). Fires once immediately with the current state — but
|
|
211
|
+
* never for a doc that was never populated: "no value yet" is not an empty
|
|
212
|
+
* board, and reporting it as one would wipe what the caller already loaded
|
|
213
|
+
* (the DDR-223 lesson).
|
|
214
|
+
*/
|
|
215
|
+
export function observeReplica(
|
|
216
|
+
doc: Y.Doc,
|
|
217
|
+
cb: (
|
|
218
|
+
elements: AnnotationElement[],
|
|
219
|
+
changed: ReadonlySet<string>,
|
|
220
|
+
actionId: string | undefined
|
|
221
|
+
) => void
|
|
222
|
+
): () => void {
|
|
223
|
+
const map = outer(doc);
|
|
224
|
+
const legacy = doc.getMap<unknown>(LEGACY_TYPE);
|
|
225
|
+
const emit = (changed: Set<string>) => {
|
|
226
|
+
const r = readReplica(doc);
|
|
227
|
+
if (r === null) return;
|
|
228
|
+
cb(r.elements, changed, replicaActionId(doc));
|
|
229
|
+
};
|
|
230
|
+
const onDeep = (events: Y.YEvent<Y.AbstractType<unknown>>[]) => {
|
|
231
|
+
const changed = new Set<string>();
|
|
232
|
+
for (const ev of events) {
|
|
233
|
+
if (ev.target === map) {
|
|
234
|
+
for (const k of (ev as Y.YMapEvent<unknown>).keysChanged)
|
|
235
|
+
if (!k.startsWith('~')) changed.add(k);
|
|
236
|
+
} else {
|
|
237
|
+
const id = ev.path[0];
|
|
238
|
+
if (typeof id === 'string') changed.add(id);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
if (changed.size) emit(changed);
|
|
242
|
+
};
|
|
243
|
+
// Until a v2 writer has written, a legacy peer's `svg` is the source.
|
|
244
|
+
const onLegacy = (ev: Y.YMapEvent<unknown>) => {
|
|
245
|
+
if (!hasReplica(doc) && ev.keysChanged.has('svg')) emit(new Set());
|
|
246
|
+
};
|
|
247
|
+
map.observeDeep(onDeep);
|
|
248
|
+
legacy.observe(onLegacy);
|
|
249
|
+
emit(new Set());
|
|
250
|
+
return () => {
|
|
251
|
+
map.unobserveDeep(onDeep);
|
|
252
|
+
legacy.unobserve(onLegacy);
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Zero elements — the cold-start emptiness test (DDR-223), for board text or legacy SVG. */
|
|
257
|
+
export function isEmptyBoardText(text: string | null): boolean {
|
|
258
|
+
if (text === null || text.trim() === '') return true;
|
|
259
|
+
if (/^\s*</.test(text)) {
|
|
260
|
+
if (text.length > MAX_LEGACY_SVG_BYTES) return false; // unparsed ≠ empty (DDR-223)
|
|
261
|
+
return migrateSvg(text).elements.length === 0;
|
|
262
|
+
}
|
|
263
|
+
return parseBoard(text).elements.length === 0;
|
|
264
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file annotations/scene.ts — world-space view over a set of elements
|
|
3
|
+
* @scope apps/studio/annotations/scene.ts
|
|
4
|
+
* @purpose Elements store PARENT-relative geometry (DDR-242 §1). The scene
|
|
5
|
+
* turns that into world space: parent origins, world boxes, arrow
|
|
6
|
+
* endpoints, paint order (a container is followed by its subtree),
|
|
7
|
+
* subtree queries and top-most hit-testing. Pure and React-free —
|
|
8
|
+
* the canvas, the AI projection and the hub all use this one.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { MAX_NESTING_DEPTH } from './constants.ts';
|
|
12
|
+
import { arrowEnds, type ResolvedArrow } from './elements/arrow.model.ts';
|
|
13
|
+
import { textBindable } from './elements/text.model.ts';
|
|
14
|
+
import { compareOrder } from './fractional-index.ts';
|
|
15
|
+
import { defOf } from './registry.ts';
|
|
16
|
+
import type { AnnotationElement, Box, GeomCtx } from './types.ts';
|
|
17
|
+
|
|
18
|
+
const TOP = '\u0000top';
|
|
19
|
+
|
|
20
|
+
export class Scene {
|
|
21
|
+
readonly byId: ReadonlyMap<string, AnnotationElement>;
|
|
22
|
+
private readonly kids = new Map<string, AnnotationElement[]>();
|
|
23
|
+
private readonly originCache = new Map<string, { x: number; y: number }>();
|
|
24
|
+
|
|
25
|
+
constructor(elements: Iterable<AnnotationElement>) {
|
|
26
|
+
const byId = new Map<string, AnnotationElement>();
|
|
27
|
+
for (const el of elements) byId.set(el.id, el);
|
|
28
|
+
this.byId = byId;
|
|
29
|
+
for (const el of byId.values()) {
|
|
30
|
+
const p = this.effectiveParent(el);
|
|
31
|
+
const key = p ?? TOP;
|
|
32
|
+
const list = this.kids.get(key);
|
|
33
|
+
if (list) list.push(el);
|
|
34
|
+
else this.kids.set(key, [el]);
|
|
35
|
+
}
|
|
36
|
+
for (const list of this.kids.values()) list.sort(compareOrder);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
get(id: string): AnnotationElement | undefined {
|
|
40
|
+
return this.byId.get(id);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
get size(): number {
|
|
44
|
+
return this.byId.size;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The parent an element is actually drawn under: its `parent` when that names
|
|
49
|
+
* an existing CONTAINER reachable without a cycle, else top level. A dangling
|
|
50
|
+
* parent (deleted concurrently) degrades to top level — never to a lost node.
|
|
51
|
+
*/
|
|
52
|
+
effectiveParent(el: AnnotationElement): string | null {
|
|
53
|
+
const seen = new Set<string>([el.id]);
|
|
54
|
+
let cur = el.parent;
|
|
55
|
+
let depth = 0;
|
|
56
|
+
let first: string | null = null;
|
|
57
|
+
while (cur !== undefined) {
|
|
58
|
+
const p = this.byId.get(cur);
|
|
59
|
+
if (!p || seen.has(cur) || !defOf(p.type)?.caps.container || depth >= MAX_NESTING_DEPTH) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
if (first === null) first = cur;
|
|
63
|
+
seen.add(cur);
|
|
64
|
+
cur = p.parent;
|
|
65
|
+
depth++;
|
|
66
|
+
}
|
|
67
|
+
return first;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Children of `parent` (null = top level) in paint order. */
|
|
71
|
+
childrenOf(parent: string | null): readonly AnnotationElement[] {
|
|
72
|
+
return this.kids.get(parent ?? TOP) ?? [];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Every element in paint order: siblings by (index, id), a container before its subtree. */
|
|
76
|
+
paintOrder(): AnnotationElement[] {
|
|
77
|
+
const out: AnnotationElement[] = [];
|
|
78
|
+
const walk = (parent: string | null) => {
|
|
79
|
+
for (const el of this.childrenOf(parent)) {
|
|
80
|
+
out.push(el);
|
|
81
|
+
if (defOf(el.type)?.caps.container) walk(el.id);
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
walk(null);
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** All descendants of `id` (not including it), in paint order. */
|
|
89
|
+
descendants(id: string): AnnotationElement[] {
|
|
90
|
+
const out: AnnotationElement[] = [];
|
|
91
|
+
const walk = (parent: string) => {
|
|
92
|
+
for (const el of this.childrenOf(parent)) {
|
|
93
|
+
out.push(el);
|
|
94
|
+
walk(el.id);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
walk(id);
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Ancestor ids, nearest first. */
|
|
102
|
+
ancestors(id: string): string[] {
|
|
103
|
+
const out: string[] = [];
|
|
104
|
+
const el = this.byId.get(id);
|
|
105
|
+
let cur = el ? this.effectiveParent(el) : null;
|
|
106
|
+
while (cur) {
|
|
107
|
+
out.push(cur);
|
|
108
|
+
const p = this.byId.get(cur);
|
|
109
|
+
cur = p ? this.effectiveParent(p) : null;
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** World origin of the element's parent (0,0 at top level). */
|
|
115
|
+
originOf(el: AnnotationElement): { x: number; y: number } {
|
|
116
|
+
const p = this.effectiveParent(el);
|
|
117
|
+
if (!p) return { x: 0, y: 0 };
|
|
118
|
+
const cached = this.originCache.get(p);
|
|
119
|
+
if (cached) return cached;
|
|
120
|
+
const parent = this.byId.get(p) as AnnotationElement;
|
|
121
|
+
const po = this.originOf(parent);
|
|
122
|
+
const o = {
|
|
123
|
+
x: po.x + (typeof parent.x === 'number' ? parent.x : 0),
|
|
124
|
+
y: po.y + (typeof parent.y === 'number' ? parent.y : 0),
|
|
125
|
+
};
|
|
126
|
+
this.originCache.set(p, o);
|
|
127
|
+
return o;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
ctx(el: AnnotationElement): GeomCtx {
|
|
131
|
+
return { origin: this.originOf(el), resolve: (id) => this.resolve(id) };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private resolve(id: string): { box: Box; rot: number; bindable: boolean } | null {
|
|
135
|
+
const el = this.byId.get(id);
|
|
136
|
+
if (!el) return null;
|
|
137
|
+
const def = defOf(el.type);
|
|
138
|
+
if (!def?.caps.box) return null;
|
|
139
|
+
const box = this.worldBox(el);
|
|
140
|
+
if (!box) return null;
|
|
141
|
+
let bindable = def.caps.bindable;
|
|
142
|
+
if (el.type === 'text') bindable = textBindable(box.w, box.h);
|
|
143
|
+
return { box, rot: typeof el.rot === 'number' ? el.rot : 0, bindable };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Unrotated world bounds, or null (unknown type without a box, degenerate arrow…). */
|
|
147
|
+
worldBox(elOrId: AnnotationElement | string): Box | null {
|
|
148
|
+
const el = typeof elOrId === 'string' ? this.byId.get(elOrId) : elOrId;
|
|
149
|
+
if (!el) return null;
|
|
150
|
+
const def = defOf(el.type);
|
|
151
|
+
let local: Box | null;
|
|
152
|
+
if (def) {
|
|
153
|
+
// Arrows resolve their hosts in world space and return parent-space bounds.
|
|
154
|
+
local = def.bounds(el, this.ctx(el));
|
|
155
|
+
} else {
|
|
156
|
+
const { x, y, w, h } = el as Record<string, unknown>;
|
|
157
|
+
local =
|
|
158
|
+
typeof x === 'number' &&
|
|
159
|
+
typeof y === 'number' &&
|
|
160
|
+
typeof w === 'number' &&
|
|
161
|
+
typeof h === 'number'
|
|
162
|
+
? { x, y, w, h }
|
|
163
|
+
: null;
|
|
164
|
+
}
|
|
165
|
+
if (!local) return null;
|
|
166
|
+
const o = this.originOf(el);
|
|
167
|
+
return { x: local.x + o.x, y: local.y + o.y, w: local.w, h: local.h };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** World endpoints of an arrow. */
|
|
171
|
+
arrowWorld(el: AnnotationElement): ResolvedArrow | null {
|
|
172
|
+
if (el.type !== 'arrow') return null;
|
|
173
|
+
const r = arrowEnds(el, this.ctx(el));
|
|
174
|
+
if (!r) return null;
|
|
175
|
+
const o = this.originOf(el);
|
|
176
|
+
return { ...r, x1: r.x1 + o.x, y1: r.y1 + o.y, x2: r.x2 + o.x, y2: r.y2 + o.y };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Top-most element whose hit-test accepts the WORLD point, optionally filtered. */
|
|
180
|
+
hitTest(
|
|
181
|
+
wx: number,
|
|
182
|
+
wy: number,
|
|
183
|
+
tol: number,
|
|
184
|
+
accept: (el: AnnotationElement) => boolean = () => true
|
|
185
|
+
): AnnotationElement | null {
|
|
186
|
+
const order = this.paintOrder();
|
|
187
|
+
for (let i = order.length - 1; i >= 0; i--) {
|
|
188
|
+
const el = order[i] as AnnotationElement;
|
|
189
|
+
if (!accept(el)) continue;
|
|
190
|
+
const def = defOf(el.type);
|
|
191
|
+
const o = this.originOf(el);
|
|
192
|
+
if (def) {
|
|
193
|
+
if (def.hitTest(el, wx - o.x, wy - o.y, tol, this.ctx(el))) return el;
|
|
194
|
+
} else {
|
|
195
|
+
const b = this.worldBox(el);
|
|
196
|
+
if (
|
|
197
|
+
b &&
|
|
198
|
+
wx >= b.x - tol &&
|
|
199
|
+
wx <= b.x + b.w + tol &&
|
|
200
|
+
wy >= b.y - tol &&
|
|
201
|
+
wy <= b.y + b.h + tol
|
|
202
|
+
) {
|
|
203
|
+
return el;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Deepest container whose world box contains the world point — the section an
|
|
212
|
+
* element dropped at that point belongs to. `exclude` skips a subtree (the
|
|
213
|
+
* dragged selection must not become its own parent).
|
|
214
|
+
*/
|
|
215
|
+
containerAt(
|
|
216
|
+
wx: number,
|
|
217
|
+
wy: number,
|
|
218
|
+
exclude: ReadonlySet<string> = new Set()
|
|
219
|
+
): AnnotationElement | null {
|
|
220
|
+
let best: AnnotationElement | null = null;
|
|
221
|
+
let bestDepth = -1;
|
|
222
|
+
for (const el of this.byId.values()) {
|
|
223
|
+
if (exclude.has(el.id) || !defOf(el.type)?.caps.container) continue;
|
|
224
|
+
if (this.ancestors(el.id).some((a) => exclude.has(a))) continue;
|
|
225
|
+
const b = this.worldBox(el);
|
|
226
|
+
if (!b || wx < b.x || wx > b.x + b.w || wy < b.y || wy > b.y + b.h) continue;
|
|
227
|
+
const depth = this.ancestors(el.id).length;
|
|
228
|
+
if (depth > bestDepth) {
|
|
229
|
+
best = el;
|
|
230
|
+
bestDepth = depth;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return best;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file annotations/schema.ts — the annotations-v2 board: validate, parse, serialize
|
|
3
|
+
* @scope apps/studio/annotations/schema.ts
|
|
4
|
+
* @purpose DDR-242 §2–§3. A board is `{"format":"maude.annotations","v":2,
|
|
5
|
+
* "elements":[…]}` written with ONE element per line in a
|
|
6
|
+
* deterministic order, so the same board is the same bytes on every
|
|
7
|
+
* machine and a git diff shows exactly which element changed.
|
|
8
|
+
*
|
|
9
|
+
* Validation is TOTAL and per element: a bad element is dropped and
|
|
10
|
+
* reported, never the whole board (the DDR-223 lesson — emptiness
|
|
11
|
+
* must never be manufactured from a parse problem). Board-level
|
|
12
|
+
* rules: unique ids, a valid `index` (repaired, not dropped), no
|
|
13
|
+
* parent cycles, nesting depth ≤ MAX_NESTING_DEPTH, element and byte
|
|
14
|
+
* caps.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { MAX_BOARD_BYTES, MAX_ELEMENTS, MAX_NESTING_DEPTH } from './constants.ts';
|
|
18
|
+
import { parseJsonSafe } from './fields.ts';
|
|
19
|
+
import { compareOrder, isValidOrderKey, keyBetween } from './fractional-index.ts';
|
|
20
|
+
import { defOf, validateElement } from './registry.ts';
|
|
21
|
+
import type { AnnotationElement } from './types.ts';
|
|
22
|
+
|
|
23
|
+
export const BOARD_FORMAT = 'maude.annotations';
|
|
24
|
+
export const BOARD_VERSION = 2;
|
|
25
|
+
|
|
26
|
+
export interface Dropped {
|
|
27
|
+
id?: string;
|
|
28
|
+
reason: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface BoardResult {
|
|
32
|
+
elements: AnnotationElement[];
|
|
33
|
+
dropped: Dropped[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Sort key: depth first, then parent, then (index, id) — parents always precede children. */
|
|
37
|
+
function canonicalOrder(elements: readonly AnnotationElement[]): AnnotationElement[] {
|
|
38
|
+
const byId = new Map(elements.map((e) => [e.id, e]));
|
|
39
|
+
const depth = (e: AnnotationElement): number => {
|
|
40
|
+
let d = 0;
|
|
41
|
+
let cur = e.parent;
|
|
42
|
+
while (cur !== undefined && d <= MAX_NESTING_DEPTH) {
|
|
43
|
+
d++;
|
|
44
|
+
cur = byId.get(cur)?.parent;
|
|
45
|
+
}
|
|
46
|
+
return d;
|
|
47
|
+
};
|
|
48
|
+
const depths = new Map(elements.map((e) => [e.id, depth(e)]));
|
|
49
|
+
return [...elements].sort((a, b) => {
|
|
50
|
+
const da = depths.get(a.id) as number;
|
|
51
|
+
const db = depths.get(b.id) as number;
|
|
52
|
+
if (da !== db) return da - db;
|
|
53
|
+
const pa = a.parent ?? '';
|
|
54
|
+
const pb = b.parent ?? '';
|
|
55
|
+
if (pa !== pb) return pa < pb ? -1 : 1;
|
|
56
|
+
return compareOrder(a, b);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Validate an untrusted element list into canonical records.
|
|
62
|
+
* - invalid records are dropped + reported;
|
|
63
|
+
* - a duplicate id keeps the FIRST occurrence;
|
|
64
|
+
* - an invalid `index` is repaired (appended after its siblings) — a z-order
|
|
65
|
+
* glitch is never a reason to lose content;
|
|
66
|
+
* - a `parent` that is missing, not a container, cyclic or too deep is cleared
|
|
67
|
+
* (the element is kept at top level, its coordinates unchanged — the caller
|
|
68
|
+
* of a live edit never produces that; this only guards hostile/stale input).
|
|
69
|
+
*/
|
|
70
|
+
export function validateElements(raw: readonly unknown[]): BoardResult {
|
|
71
|
+
const dropped: Dropped[] = [];
|
|
72
|
+
const out: AnnotationElement[] = [];
|
|
73
|
+
const seen = new Set<string>();
|
|
74
|
+
const needsIndex: Record<string, unknown>[] = [];
|
|
75
|
+
for (const item of raw.slice(0, MAX_ELEMENTS)) {
|
|
76
|
+
let candidate = item;
|
|
77
|
+
// Repair a bad index before validation so the element survives it.
|
|
78
|
+
if (candidate && typeof candidate === 'object' && !Array.isArray(candidate)) {
|
|
79
|
+
const idx = (candidate as Record<string, unknown>).index;
|
|
80
|
+
if (typeof idx !== 'string' || !isValidOrderKey(idx)) {
|
|
81
|
+
candidate = { ...(candidate as Record<string, unknown>), index: 'a0' };
|
|
82
|
+
needsIndex.push(candidate as Record<string, unknown>);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const r = validateElement(candidate);
|
|
86
|
+
if (!r.ok) {
|
|
87
|
+
dropped.push({ id: r.id, reason: r.reason });
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (seen.has(r.el.id)) {
|
|
91
|
+
dropped.push({ id: r.el.id, reason: 'duplicate id' });
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
seen.add(r.el.id);
|
|
95
|
+
out.push(r.el);
|
|
96
|
+
}
|
|
97
|
+
if (raw.length > MAX_ELEMENTS) {
|
|
98
|
+
dropped.push({
|
|
99
|
+
reason: `board exceeds ${MAX_ELEMENTS} elements; ${raw.length - MAX_ELEMENTS} dropped`,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
const byId = new Map(out.map((e) => [e.id, e]));
|
|
103
|
+
// Parent sanity: missing / non-container / cycle / too deep → top level.
|
|
104
|
+
for (const el of out) {
|
|
105
|
+
if (el.parent === undefined) continue;
|
|
106
|
+
const seenChain = new Set<string>([el.id]);
|
|
107
|
+
let cur: string | undefined = el.parent;
|
|
108
|
+
let depth = 0;
|
|
109
|
+
let ok = true;
|
|
110
|
+
while (cur !== undefined) {
|
|
111
|
+
const p = byId.get(cur);
|
|
112
|
+
if (
|
|
113
|
+
!p ||
|
|
114
|
+
seenChain.has(cur) ||
|
|
115
|
+
!defOf(p.type)?.caps.container ||
|
|
116
|
+
++depth > MAX_NESTING_DEPTH
|
|
117
|
+
) {
|
|
118
|
+
ok = false;
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
seenChain.add(cur);
|
|
122
|
+
cur = p.parent;
|
|
123
|
+
}
|
|
124
|
+
if (!ok) {
|
|
125
|
+
delete el.parent;
|
|
126
|
+
dropped.push({ id: el.id, reason: 'invalid parent cleared (kept at top level)' });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
// Index repair: append after the siblings' current maximum, in input order.
|
|
130
|
+
const repaired = new Set(needsIndex.map((c) => c.id));
|
|
131
|
+
for (const el of out) {
|
|
132
|
+
if (!repaired.has(el.id)) continue;
|
|
133
|
+
let max: string | null = null;
|
|
134
|
+
for (const s of out) {
|
|
135
|
+
if (s === el || repaired.has(s.id) || s.parent !== el.parent) continue;
|
|
136
|
+
if (max === null || s.index > max) max = s.index;
|
|
137
|
+
}
|
|
138
|
+
el.index = keyBetween(max, null);
|
|
139
|
+
repaired.delete(el.id);
|
|
140
|
+
}
|
|
141
|
+
return { elements: canonicalOrder(out), dropped };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Parse a board file (untrusted text). A malformed file yields zero elements + a report. */
|
|
145
|
+
export function parseBoard(text: string): BoardResult {
|
|
146
|
+
if (text.length > MAX_BOARD_BYTES) {
|
|
147
|
+
return { elements: [], dropped: [{ reason: 'board file exceeds the byte cap' }] };
|
|
148
|
+
}
|
|
149
|
+
const doc = parseJsonSafe(text);
|
|
150
|
+
if (doc === null || typeof doc !== 'object' || Array.isArray(doc)) {
|
|
151
|
+
return { elements: [], dropped: [{ reason: 'not a board document' }] };
|
|
152
|
+
}
|
|
153
|
+
const d = doc as Record<string, unknown>;
|
|
154
|
+
if (d.format !== BOARD_FORMAT || typeof d.v !== 'number') {
|
|
155
|
+
return { elements: [], dropped: [{ reason: 'unknown board format' }] };
|
|
156
|
+
}
|
|
157
|
+
if (d.v > BOARD_VERSION) {
|
|
158
|
+
return {
|
|
159
|
+
elements: [],
|
|
160
|
+
dropped: [{ reason: `board format v${d.v} is newer than this client` }],
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
return validateElements(Array.isArray(d.elements) ? d.elements : []);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Canonical bytes. Records are already canonical (spec key order, defaults
|
|
168
|
+
* omitted); this fixes the element order and the one-element-per-line layout.
|
|
169
|
+
*/
|
|
170
|
+
export function serializeBoard(elements: readonly AnnotationElement[]): string {
|
|
171
|
+
const ordered = canonicalOrder(elements);
|
|
172
|
+
if (ordered.length === 0) {
|
|
173
|
+
return `{"format":"${BOARD_FORMAT}","v":${BOARD_VERSION},"elements":[]}\n`;
|
|
174
|
+
}
|
|
175
|
+
const lines = ordered.map((e) => JSON.stringify(e));
|
|
176
|
+
return `{"format":"${BOARD_FORMAT}","v":${BOARD_VERSION},"elements":[\n${lines.join(',\n')}\n]}\n`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function isEmptyBoard(elements: readonly unknown[]): boolean {
|
|
180
|
+
return elements.length === 0;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Canonical form of one record (re-validated). Throws only on programmer error. */
|
|
184
|
+
export function canonical(el: Record<string, unknown>): AnnotationElement {
|
|
185
|
+
const r = validateElement(el);
|
|
186
|
+
if (!r.ok) throw new Error(`invalid element ${String(el.id)}: ${r.reason}`);
|
|
187
|
+
return r.el;
|
|
188
|
+
}
|