@1agh/maude 1.4.5 → 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 +97 -4
- package/apps/studio/annotations-sync.ts +4 -47
- package/apps/studio/api.ts +262 -68
- 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-comment-mount.tsx +35 -2
- package/apps/studio/canvas-lib.tsx +18 -1
- package/apps/studio/canvas-shell.tsx +54 -1
- package/apps/studio/client/app.jsx +101 -35
- package/apps/studio/client/comments-overlay.css +10 -0
- 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 +58 -5
- package/apps/studio/collab/persistence.ts +80 -11
- package/apps/studio/collab/registry.ts +63 -26
- package/apps/studio/commands/annotation-ops-command.ts +72 -0
- package/apps/studio/comment-anchor.ts +116 -0
- package/apps/studio/comments-overlay.tsx +78 -35
- package/apps/studio/context.ts +1 -0
- package/apps/studio/cursors-overlay.tsx +337 -109
- 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 +72 -8
- package/apps/studio/sync/agent.ts +36 -6
- package/apps/studio/sync/codec.ts +95 -40
- package/apps/studio/sync/comment-ledger.ts +229 -0
- package/apps/studio/sync/file-membership.ts +12 -2
- package/apps/studio/sync/file-plane.ts +78 -2
- package/apps/studio/sync/index.ts +75 -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 +17 -7
- 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 +104 -1
- package/apps/studio/use-selection-set.tsx +4 -0
- package/apps/studio/whats-new.json +63 -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,474 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file annotations/ops.ts — element operations + the one merge rule (DDR-242 §4)
|
|
3
|
+
* @scope apps/studio/annotations/ops.ts
|
|
4
|
+
* @purpose Every change to a board is a list of ops:
|
|
5
|
+
* put — create, or replace an element wholesale
|
|
6
|
+
* patch — set / unset some fields of one element
|
|
7
|
+
* delete — remove an element
|
|
8
|
+
* The SAME `applyOps` runs in the studio (local + legacy shared doc)
|
|
9
|
+
* and in the hub kernel (accepted revisions), so all three agree
|
|
10
|
+
* on one merge rule:
|
|
11
|
+
* • different elements never conflict;
|
|
12
|
+
* • different fields of one element never conflict;
|
|
13
|
+
* • the same scalar field follows acceptance order (last wins —
|
|
14
|
+
* DDR-241 §5);
|
|
15
|
+
* • the same TEXT field is merged 3-way at character level
|
|
16
|
+
* against the patch's `expect` (sync/source-merge.ts), so two
|
|
17
|
+
* people typing in one sticky both keep their words;
|
|
18
|
+
* • a patch on a missing element is rejected `gone` (the client
|
|
19
|
+
* offers to restore — it never silently re-creates).
|
|
20
|
+
* A `strict` patch (undo) only touches fields that still hold the
|
|
21
|
+
* `expect` value: undo reverts YOUR change, never a peer's later one.
|
|
22
|
+
*
|
|
23
|
+
* Structural fix-ups ride along as ordinary ops so undo and peers
|
|
24
|
+
* see them: deleting an element freezes every arrow end bound to it
|
|
25
|
+
* into a free point; deleting a container re-parents children that
|
|
26
|
+
* are not deleted with it (coordinates converted — they don't jump).
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { mergeSource } from '../sync/source-merge.ts';
|
|
30
|
+
import { MAX_NESTING_DEPTH } from './constants.ts';
|
|
31
|
+
import { DANGEROUS_KEYS, jsonEq } from './fields.ts';
|
|
32
|
+
import { defOf, validateElement } from './registry.ts';
|
|
33
|
+
import { Scene } from './scene.ts';
|
|
34
|
+
import type { AnnotationElement, ArrowEnd } from './types.ts';
|
|
35
|
+
|
|
36
|
+
export type Op =
|
|
37
|
+
| { op: 'put'; el: AnnotationElement }
|
|
38
|
+
| {
|
|
39
|
+
op: 'patch';
|
|
40
|
+
id: string;
|
|
41
|
+
/** Fields to set. */
|
|
42
|
+
set?: Record<string, unknown>;
|
|
43
|
+
/** Fields to reset to their default (removed from the record). */
|
|
44
|
+
unset?: string[];
|
|
45
|
+
/** The values the author saw before editing (merge base for text; guard for strict). */
|
|
46
|
+
expect?: Record<string, unknown>;
|
|
47
|
+
/** Only apply fields whose current value equals `expect` (undo). */
|
|
48
|
+
strict?: boolean;
|
|
49
|
+
}
|
|
50
|
+
| { op: 'delete'; id: string };
|
|
51
|
+
|
|
52
|
+
export type RejectReason = 'gone' | 'invalid' | 'exists' | 'stale';
|
|
53
|
+
|
|
54
|
+
export interface ApplyResult {
|
|
55
|
+
/** The new state (the input map is never mutated). */
|
|
56
|
+
state: Map<string, AnnotationElement>;
|
|
57
|
+
/** Ops actually applied, fix-ups included, in order. */
|
|
58
|
+
applied: Op[];
|
|
59
|
+
/** Ops that undo `applied` (already reversed — apply as one batch). */
|
|
60
|
+
inverse: Op[];
|
|
61
|
+
/** Ops (or parts of strict patches) that did not apply. */
|
|
62
|
+
rejected: Array<{ op: Op; reason: RejectReason; fields?: string[] }>;
|
|
63
|
+
/** Ids whose record changed (created, updated or deleted). */
|
|
64
|
+
touched: Set<string>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Which field of `type` holds its editable text (merged character-wise). */
|
|
68
|
+
function textField(type: string): string | null {
|
|
69
|
+
const slot = defOf(type)?.caps.textSlot;
|
|
70
|
+
if (slot === 'text') return 'text';
|
|
71
|
+
if (slot === 'title') return 'label';
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Character-level merges one op batch may run. Each `mergeSource` is bounded
|
|
77
|
+
* (50 ms), but a batch of thousands was not (security review) — past this,
|
|
78
|
+
* later same-field text edits resolve by acceptance order instead.
|
|
79
|
+
*/
|
|
80
|
+
export const MAX_TEXT_MERGES_PER_BATCH = 50;
|
|
81
|
+
|
|
82
|
+
interface MergeBudget {
|
|
83
|
+
left: number;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Merge a text value: ours vs theirs against base. Overlap → ours (acceptance order). */
|
|
87
|
+
function mergeText(base: unknown, ours: unknown, theirs: unknown, budget: MergeBudget): unknown {
|
|
88
|
+
if (typeof base !== 'string' || typeof ours !== 'string' || typeof theirs !== 'string')
|
|
89
|
+
return ours;
|
|
90
|
+
if (theirs === base) return ours;
|
|
91
|
+
if (ours === base) return theirs;
|
|
92
|
+
if (budget.left <= 0) return ours;
|
|
93
|
+
budget.left--;
|
|
94
|
+
const m = mergeSource(base, ours, theirs);
|
|
95
|
+
return m.ok ? m.merged : ours;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Resolve one field's new value under the merge rule. */
|
|
99
|
+
function mergeField(
|
|
100
|
+
type: string,
|
|
101
|
+
key: string,
|
|
102
|
+
current: unknown,
|
|
103
|
+
next: unknown,
|
|
104
|
+
base: unknown,
|
|
105
|
+
hasBase: boolean,
|
|
106
|
+
budget: MergeBudget
|
|
107
|
+
): unknown {
|
|
108
|
+
if (!hasBase || jsonEq(current, base)) return next;
|
|
109
|
+
if (key === textField(type)) return mergeText(base, next, current, budget);
|
|
110
|
+
// Shape label: merge the nested text, other label fields by acceptance order.
|
|
111
|
+
if (key === 'label' && defOf(type)?.caps.textSlot === 'label') {
|
|
112
|
+
const cur = (current ?? {}) as Record<string, unknown>;
|
|
113
|
+
const nx = (next ?? {}) as Record<string, unknown>;
|
|
114
|
+
const bs = (base ?? {}) as Record<string, unknown>;
|
|
115
|
+
return {
|
|
116
|
+
...cur,
|
|
117
|
+
...nx,
|
|
118
|
+
text: mergeText(bs.text ?? '', nx.text ?? '', cur.text ?? '', budget),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
return next;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** A snapshot of `fields` on `el` (absent fields are reported as `undefined`). */
|
|
125
|
+
function pick(el: AnnotationElement, fields: Iterable<string>): Record<string, unknown> {
|
|
126
|
+
const out: Record<string, unknown> = {};
|
|
127
|
+
for (const f of fields) out[f] = el[f];
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function inversePatch(before: AnnotationElement, after: AnnotationElement, id: string): Op | null {
|
|
132
|
+
const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
|
|
133
|
+
const set: Record<string, unknown> = {};
|
|
134
|
+
const unset: string[] = [];
|
|
135
|
+
const expect: Record<string, unknown> = {};
|
|
136
|
+
for (const k of keys) {
|
|
137
|
+
if (jsonEq(before[k], after[k])) continue;
|
|
138
|
+
if (before[k] === undefined) unset.push(k);
|
|
139
|
+
else set[k] = before[k];
|
|
140
|
+
expect[k] = after[k];
|
|
141
|
+
}
|
|
142
|
+
if (!Object.keys(set).length && !unset.length) return null;
|
|
143
|
+
return { op: 'patch', id, set, ...(unset.length ? { unset } : {}), expect, strict: true };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function applyOps(
|
|
147
|
+
input: ReadonlyMap<string, AnnotationElement>,
|
|
148
|
+
ops: readonly Op[]
|
|
149
|
+
): ApplyResult {
|
|
150
|
+
const state = new Map(input);
|
|
151
|
+
const applied: Op[] = [];
|
|
152
|
+
const inverse: Op[] = [];
|
|
153
|
+
const rejected: ApplyResult['rejected'] = [];
|
|
154
|
+
const touched = new Set<string>();
|
|
155
|
+
const budget: MergeBudget = { left: MAX_TEXT_MERGES_PER_BATCH };
|
|
156
|
+
|
|
157
|
+
// Reverse indexes, built once per batch and kept current on every commit, so
|
|
158
|
+
// a delete finds its bound arrows and children without walking the board
|
|
159
|
+
// (k deletes on an n-element board were O(k·n) — security review).
|
|
160
|
+
let byHost: Map<string, Set<string>> | null = null;
|
|
161
|
+
let byParent: Map<string, Set<string>> | null = null;
|
|
162
|
+
const link = (m: Map<string, Set<string>>, key: string, id: string) => {
|
|
163
|
+
const set = m.get(key);
|
|
164
|
+
if (set) set.add(id);
|
|
165
|
+
else m.set(key, new Set([id]));
|
|
166
|
+
};
|
|
167
|
+
const hostsOf = (el: AnnotationElement): string[] => {
|
|
168
|
+
if (el.type !== 'arrow') return [];
|
|
169
|
+
const out: string[] = [];
|
|
170
|
+
for (const k of ['start', 'end'] as const) {
|
|
171
|
+
const e = el[k] as ArrowEnd | undefined;
|
|
172
|
+
if (e && 'el' in e) out.push(e.el);
|
|
173
|
+
}
|
|
174
|
+
return out;
|
|
175
|
+
};
|
|
176
|
+
const indexes = () => {
|
|
177
|
+
if (!byHost || !byParent) {
|
|
178
|
+
const h = new Map<string, Set<string>>();
|
|
179
|
+
const p = new Map<string, Set<string>>();
|
|
180
|
+
for (const el of state.values()) {
|
|
181
|
+
for (const host of hostsOf(el)) link(h, host, el.id);
|
|
182
|
+
if (el.parent !== undefined) link(p, el.parent, el.id);
|
|
183
|
+
}
|
|
184
|
+
byHost = h;
|
|
185
|
+
byParent = p;
|
|
186
|
+
}
|
|
187
|
+
return { byHost, byParent } as {
|
|
188
|
+
byHost: Map<string, Set<string>>;
|
|
189
|
+
byParent: Map<string, Set<string>>;
|
|
190
|
+
};
|
|
191
|
+
};
|
|
192
|
+
// World geometry is only needed for arrows bound to a deleted host; the scene
|
|
193
|
+
// is rebuilt only after a geometry change, never once per delete.
|
|
194
|
+
let scene: Scene | null = null;
|
|
195
|
+
// Records deleted in this batch — the geometry a surviving grandchild needs
|
|
196
|
+
// to keep its world position when several ancestors go at once.
|
|
197
|
+
const removed = new Map<string, AnnotationElement>();
|
|
198
|
+
|
|
199
|
+
const commit = (
|
|
200
|
+
id: string,
|
|
201
|
+
before: AnnotationElement | undefined,
|
|
202
|
+
after: AnnotationElement | undefined,
|
|
203
|
+
op: Op
|
|
204
|
+
) => {
|
|
205
|
+
if (byHost && byParent) {
|
|
206
|
+
if (before) {
|
|
207
|
+
for (const h of hostsOf(before)) byHost.get(h)?.delete(before.id);
|
|
208
|
+
if (before.parent !== undefined) byParent.get(before.parent)?.delete(before.id);
|
|
209
|
+
}
|
|
210
|
+
if (after) {
|
|
211
|
+
for (const h of hostsOf(after)) link(byHost, h, after.id);
|
|
212
|
+
if (after.parent !== undefined) link(byParent, after.parent, after.id);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
// Only a change to something an arrow can bind to moves endpoints; an arrow
|
|
216
|
+
// edit (every delete fix-up) leaves the geometry the scene answers for as is.
|
|
217
|
+
if (after && after.type !== 'arrow') scene = null;
|
|
218
|
+
if (after) state.set(id, after);
|
|
219
|
+
else state.delete(id);
|
|
220
|
+
touched.add(id);
|
|
221
|
+
applied.push(op);
|
|
222
|
+
if (!before && after) inverse.push({ op: 'delete', id });
|
|
223
|
+
else if (before && !after) inverse.push({ op: 'put', el: before });
|
|
224
|
+
else if (before && after) {
|
|
225
|
+
const inv = inversePatch(before, after, id);
|
|
226
|
+
if (inv) inverse.push(inv);
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const deleteWithFixups = (id: string, op: Op, deleting: ReadonlySet<string>) => {
|
|
231
|
+
const target = state.get(id);
|
|
232
|
+
if (!target) return;
|
|
233
|
+
const idx = indexes();
|
|
234
|
+
// 1. Arrows bound to the deleted element keep their visible endpoint as a free point.
|
|
235
|
+
for (const arrowId of [...(idx.byHost.get(id) ?? [])]) {
|
|
236
|
+
const el = state.get(arrowId);
|
|
237
|
+
if (!el || deleting.has(arrowId)) continue;
|
|
238
|
+
scene ??= new Scene(state.values());
|
|
239
|
+
const ends = scene.arrowWorld(el);
|
|
240
|
+
const origin = scene.originOf(el);
|
|
241
|
+
const set: Record<string, unknown> = {};
|
|
242
|
+
for (const k of ['start', 'end'] as const) {
|
|
243
|
+
const e = el[k] as ArrowEnd | undefined;
|
|
244
|
+
if (!e || !('el' in e) || e.el !== id) continue;
|
|
245
|
+
const [wx, wy] = k === 'start' ? [ends?.x1, ends?.y1] : [ends?.x2, ends?.y2];
|
|
246
|
+
set[k] = { x: (wx ?? origin.x) - origin.x, y: (wy ?? origin.y) - origin.y };
|
|
247
|
+
}
|
|
248
|
+
if (Object.keys(set).length) applyPatch({ op: 'patch', id: el.id, set });
|
|
249
|
+
}
|
|
250
|
+
// 2. Children of a deleted container move up to the nearest ancestor that
|
|
251
|
+
// still exists, keeping their world position. Ancestors already deleted
|
|
252
|
+
// earlier in this batch are skipped too (a parent-first nested delete
|
|
253
|
+
// otherwise left the child pointing at a deleted parent, and it jumped).
|
|
254
|
+
if (defOf(target.type)?.caps.container) {
|
|
255
|
+
let dx = typeof target.x === 'number' ? target.x : 0;
|
|
256
|
+
let dy = typeof target.y === 'number' ? target.y : 0;
|
|
257
|
+
let newParent = target.parent;
|
|
258
|
+
while (newParent !== undefined && !state.has(newParent)) {
|
|
259
|
+
const gone = removed.get(newParent);
|
|
260
|
+
if (!gone) {
|
|
261
|
+
newParent = undefined;
|
|
262
|
+
break;
|
|
263
|
+
}
|
|
264
|
+
dx += typeof gone.x === 'number' ? gone.x : 0;
|
|
265
|
+
dy += typeof gone.y === 'number' ? gone.y : 0;
|
|
266
|
+
newParent = gone.parent;
|
|
267
|
+
}
|
|
268
|
+
for (const childId of [...(idx.byParent.get(id) ?? [])]) {
|
|
269
|
+
const child = state.get(childId);
|
|
270
|
+
if (!child || deleting.has(childId)) continue;
|
|
271
|
+
const set: Record<string, unknown> = {};
|
|
272
|
+
const def = defOf(child.type);
|
|
273
|
+
if (def) Object.assign(set, def.translate(child, dx, dy));
|
|
274
|
+
const patch: Op = newParent
|
|
275
|
+
? { op: 'patch', id: child.id, set: { ...set, parent: newParent } }
|
|
276
|
+
: { op: 'patch', id: child.id, set, unset: ['parent'] };
|
|
277
|
+
applyPatch(patch);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
removed.set(id, target);
|
|
281
|
+
commit(id, target, undefined, op);
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
/** A parent must exist, be a container, and not sit inside the element's own subtree. */
|
|
285
|
+
function parentOk(el: AnnotationElement): boolean {
|
|
286
|
+
if (el.parent === undefined) return true;
|
|
287
|
+
const seen = new Set<string>([el.id]);
|
|
288
|
+
let cur: string | undefined = el.parent;
|
|
289
|
+
let depth = 0;
|
|
290
|
+
while (cur !== undefined) {
|
|
291
|
+
const p = state.get(cur);
|
|
292
|
+
if (!p || seen.has(cur) || !defOf(p.type)?.caps.container || ++depth > MAX_NESTING_DEPTH) {
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
seen.add(cur);
|
|
296
|
+
cur = p.parent;
|
|
297
|
+
}
|
|
298
|
+
return true;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function applyPatch(op: Extract<Op, { op: 'patch' }>, canDefer = false): void {
|
|
302
|
+
const cur = state.get(op.id);
|
|
303
|
+
if (!cur) {
|
|
304
|
+
rejected.push({ op, reason: 'gone' });
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
const next: Record<string, unknown> = { ...cur };
|
|
308
|
+
const expect = op.expect ?? {};
|
|
309
|
+
const skipped: string[] = [];
|
|
310
|
+
for (const [k, v] of Object.entries(op.set ?? {})) {
|
|
311
|
+
if (k === 'id' || k === 'type' || DANGEROUS_KEYS.has(k)) continue;
|
|
312
|
+
const hasBase = Object.hasOwn(expect, k);
|
|
313
|
+
if (op.strict && hasBase && !jsonEq(cur[k], expect[k])) {
|
|
314
|
+
skipped.push(k);
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
const val = mergeField(cur.type, k, cur[k], v, expect[k], hasBase, budget);
|
|
318
|
+
// `null` stays (e.g. `fill: null` = no fill); validation normalizes it.
|
|
319
|
+
if (val === undefined) delete next[k];
|
|
320
|
+
else next[k] = val;
|
|
321
|
+
}
|
|
322
|
+
for (const k of op.unset ?? []) {
|
|
323
|
+
if (k === 'id' || k === 'type' || k === 'index') continue;
|
|
324
|
+
if (op.strict && Object.hasOwn(expect, k) && !jsonEq(cur[k], expect[k])) {
|
|
325
|
+
skipped.push(k);
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
delete next[k];
|
|
329
|
+
}
|
|
330
|
+
if (skipped.length) rejected.push({ op, reason: 'stale', fields: skipped });
|
|
331
|
+
const v = validateElement(next);
|
|
332
|
+
if (
|
|
333
|
+
v.ok &&
|
|
334
|
+
!parentOk(v.el) &&
|
|
335
|
+
canDefer &&
|
|
336
|
+
v.el.parent !== undefined &&
|
|
337
|
+
putIds.has(v.el.parent)
|
|
338
|
+
) {
|
|
339
|
+
// Its new parent is re-created later in this batch (an undo of a nested
|
|
340
|
+
// delete): retry once the puts have landed.
|
|
341
|
+
deferred.push(op);
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
if (!v.ok || !parentOk(v.el)) {
|
|
345
|
+
rejected.push({ op, reason: 'invalid' });
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
if (jsonEq(v.el, cur)) return;
|
|
349
|
+
commit(op.id, cur, v.el, op);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Deletes in one batch are resolved together so a subtree delete doesn't
|
|
353
|
+
// first re-parent children that are about to go too.
|
|
354
|
+
const deleting = new Set(
|
|
355
|
+
ops.filter((o) => o.op === 'delete').map((o) => (o as { id: string }).id)
|
|
356
|
+
);
|
|
357
|
+
|
|
358
|
+
// A put whose parent is created later in the same batch is retried after it.
|
|
359
|
+
const putIds = new Set(
|
|
360
|
+
ops.filter((o) => o.op === 'put').map((o) => (o as { el: { id?: unknown } }).el?.id)
|
|
361
|
+
);
|
|
362
|
+
let deferred: Extract<Op, { op: 'put' } | { op: 'patch' }>[] = [];
|
|
363
|
+
const applyPut = (op: Extract<Op, { op: 'put' }>, canDefer: boolean): void => {
|
|
364
|
+
const v = validateElement(op.el);
|
|
365
|
+
if (!v.ok) {
|
|
366
|
+
rejected.push({ op, reason: 'invalid' });
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
if (!parentOk(v.el)) {
|
|
370
|
+
if (canDefer && v.el.parent !== undefined && putIds.has(v.el.parent)) deferred.push(op);
|
|
371
|
+
else rejected.push({ op, reason: 'invalid' });
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
const before = state.get(v.el.id);
|
|
375
|
+
if (before && jsonEq(before, v.el)) return;
|
|
376
|
+
commit(v.el.id, before, v.el, { op: 'put', el: v.el });
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
for (const op of ops) {
|
|
380
|
+
if (op.op === 'put') {
|
|
381
|
+
applyPut(op, true);
|
|
382
|
+
} else if (op.op === 'patch') {
|
|
383
|
+
applyPatch(op, true);
|
|
384
|
+
} else if (op.op === 'delete') {
|
|
385
|
+
if (!state.has(op.id)) continue; // idempotent: already gone
|
|
386
|
+
deleteWithFixups(op.id, op, deleting);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
for (let pass = 0; deferred.length && pass <= MAX_NESTING_DEPTH; pass++) {
|
|
390
|
+
const retry = deferred;
|
|
391
|
+
deferred = [];
|
|
392
|
+
for (const op of retry) {
|
|
393
|
+
if (op.op === 'put') applyPut(op, pass < MAX_NESTING_DEPTH);
|
|
394
|
+
else applyPatch(op, pass < MAX_NESTING_DEPTH);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
for (const op of deferred) rejected.push({ op, reason: 'invalid' });
|
|
398
|
+
return { state, applied, inverse: inverse.reverse(), rejected, touched };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* The ops that turn `before` into `after` (both canonical). Patches carry
|
|
403
|
+
* `expect` = the before-values, so text edits merge against a concurrent
|
|
404
|
+
* peer edit instead of overwriting it.
|
|
405
|
+
*/
|
|
406
|
+
export function diffToOps(
|
|
407
|
+
before: ReadonlyMap<string, AnnotationElement>,
|
|
408
|
+
after: ReadonlyMap<string, AnnotationElement>
|
|
409
|
+
): Op[] {
|
|
410
|
+
const ops: Op[] = [];
|
|
411
|
+
for (const [id, el] of after) {
|
|
412
|
+
const prev = before.get(id);
|
|
413
|
+
if (!prev) {
|
|
414
|
+
ops.push({ op: 'put', el });
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
if (prev.type !== el.type) {
|
|
418
|
+
ops.push({ op: 'put', el });
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
const set: Record<string, unknown> = {};
|
|
422
|
+
const unset: string[] = [];
|
|
423
|
+
for (const k of new Set([...Object.keys(prev), ...Object.keys(el)])) {
|
|
424
|
+
if (jsonEq(prev[k], el[k])) continue;
|
|
425
|
+
if (el[k] === undefined) unset.push(k);
|
|
426
|
+
else set[k] = el[k];
|
|
427
|
+
}
|
|
428
|
+
if (!Object.keys(set).length && !unset.length) continue;
|
|
429
|
+
const keys = [...Object.keys(set), ...unset];
|
|
430
|
+
ops.push({
|
|
431
|
+
op: 'patch',
|
|
432
|
+
id,
|
|
433
|
+
...(Object.keys(set).length ? { set } : {}),
|
|
434
|
+
...(unset.length ? { unset } : {}),
|
|
435
|
+
expect: pick(prev, keys),
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
for (const id of before.keys()) if (!after.has(id)) ops.push({ op: 'delete', id });
|
|
439
|
+
return ops;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** Validate an untrusted op list (from a peer / the wire) into typed ops; bad ops are dropped. */
|
|
443
|
+
export function parseOps(raw: unknown, max = 5000): { ops: Op[]; dropped: number } {
|
|
444
|
+
if (!Array.isArray(raw)) return { ops: [], dropped: 0 };
|
|
445
|
+
const ops: Op[] = [];
|
|
446
|
+
let dropped = 0;
|
|
447
|
+
const isRec = (v: unknown): v is Record<string, unknown> =>
|
|
448
|
+
v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
449
|
+
for (const o of raw.slice(0, max)) {
|
|
450
|
+
if (!isRec(o)) {
|
|
451
|
+
dropped++;
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
if (o.op === 'put' && isRec(o.el)) {
|
|
455
|
+
ops.push({ op: 'put', el: o.el as AnnotationElement });
|
|
456
|
+
} else if (o.op === 'delete' && typeof o.id === 'string') {
|
|
457
|
+
ops.push({ op: 'delete', id: o.id });
|
|
458
|
+
} else if (o.op === 'patch' && typeof o.id === 'string') {
|
|
459
|
+
const unset = Array.isArray(o.unset)
|
|
460
|
+
? o.unset.filter((k): k is string => typeof k === 'string').slice(0, 64)
|
|
461
|
+
: undefined;
|
|
462
|
+
ops.push({
|
|
463
|
+
op: 'patch',
|
|
464
|
+
id: o.id,
|
|
465
|
+
...(isRec(o.set) ? { set: o.set } : {}),
|
|
466
|
+
...(unset?.length ? { unset } : {}),
|
|
467
|
+
...(isRec(o.expect) ? { expect: o.expect } : {}),
|
|
468
|
+
...(o.strict === true ? { strict: true } : {}),
|
|
469
|
+
});
|
|
470
|
+
} else dropped++;
|
|
471
|
+
}
|
|
472
|
+
if (raw.length > max) dropped += raw.length - max;
|
|
473
|
+
return { ops, dropped };
|
|
474
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file annotations/registry.ts — the element-type registry (DDR-242 §1)
|
|
3
|
+
* @scope apps/studio/annotations/registry.ts
|
|
4
|
+
* @purpose One place that knows every element type. Validation, canonical
|
|
5
|
+
* form, geometry and capabilities all dispatch through here, so a
|
|
6
|
+
* new type is ONE definition file plus a line in `DEFS` — never
|
|
7
|
+
* another `if (tool === …)` chain across the codebase.
|
|
8
|
+
*
|
|
9
|
+
* An UNKNOWN `type` (a newer peer's new tool) is kept verbatim —
|
|
10
|
+
* bounded and sanitized — and rendered as a placeholder, so an
|
|
11
|
+
* older client can never erase what it does not understand.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { MAX_ELEMENT_BYTES, MAX_GROUPS_PER_ELEMENT } from './constants.ts';
|
|
15
|
+
import { arrow } from './elements/arrow.model.ts';
|
|
16
|
+
import { image, link, mediaref } from './elements/media.model.ts';
|
|
17
|
+
import { pen } from './elements/pen.model.ts';
|
|
18
|
+
import { section } from './elements/section.model.ts';
|
|
19
|
+
import { shape } from './elements/shape.model.ts';
|
|
20
|
+
import { sticky } from './elements/sticky.model.ts';
|
|
21
|
+
import { textEl } from './elements/text.model.ts';
|
|
22
|
+
import {
|
|
23
|
+
DANGEROUS_KEYS,
|
|
24
|
+
type FieldSpecMap,
|
|
25
|
+
INDEX_RE,
|
|
26
|
+
id,
|
|
27
|
+
oneOf,
|
|
28
|
+
parseFields,
|
|
29
|
+
record,
|
|
30
|
+
sanitizeJson,
|
|
31
|
+
str,
|
|
32
|
+
stringList,
|
|
33
|
+
} from './fields.ts';
|
|
34
|
+
import type { AnnotationElement, ElementDef } from './types.ts';
|
|
35
|
+
|
|
36
|
+
const DEFS: readonly ElementDef[] = [
|
|
37
|
+
sticky,
|
|
38
|
+
textEl,
|
|
39
|
+
shape,
|
|
40
|
+
arrow,
|
|
41
|
+
pen,
|
|
42
|
+
image,
|
|
43
|
+
link,
|
|
44
|
+
mediaref,
|
|
45
|
+
section,
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
const TYPES = new Map(DEFS.map((d) => [d.type, d]));
|
|
49
|
+
export const REGISTRY: ReadonlyMap<string, ElementDef> = TYPES;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Register an element type at runtime — a plugin, or a test proving that a new
|
|
53
|
+
* type is one definition (the conformance suite's `stamp`). A built-in type
|
|
54
|
+
* cannot be replaced.
|
|
55
|
+
*/
|
|
56
|
+
export function registerElementType(def: ElementDef): void {
|
|
57
|
+
if (!TYPE_RE.test(def.type) || def.type in Object.prototype) {
|
|
58
|
+
throw new Error(`invalid element type "${def.type}"`);
|
|
59
|
+
}
|
|
60
|
+
const cur = TYPES.get(def.type);
|
|
61
|
+
if (cur && DEFS.includes(cur)) throw new Error(`"${def.type}" is a built-in element type`);
|
|
62
|
+
TYPES.set(def.type, def);
|
|
63
|
+
specCache.delete(def.type);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const TYPE_RE = /^[a-z][a-z0-9-]{0,31}$/;
|
|
67
|
+
|
|
68
|
+
/** Fields every element carries first, in canonical order. */
|
|
69
|
+
export const HEAD_FIELDS: FieldSpecMap = {
|
|
70
|
+
id: id(true),
|
|
71
|
+
type: str({ max: 32, re: TYPE_RE, required: true }),
|
|
72
|
+
parent: id(),
|
|
73
|
+
index: str({ max: 64, re: INDEX_RE, required: true }),
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** Fields every element carries last, in canonical order. */
|
|
77
|
+
export const TAIL_FIELDS: FieldSpecMap = {
|
|
78
|
+
groups: stringList(MAX_GROUPS_PER_ELEMENT, id()),
|
|
79
|
+
author: record({
|
|
80
|
+
kind: oneOf(['ai', 'human'] as const, undefined, true),
|
|
81
|
+
name: str({ max: 64, plain: true }),
|
|
82
|
+
id: str({ max: 64, plain: true }),
|
|
83
|
+
}),
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const specCache = new Map<string, FieldSpecMap>();
|
|
87
|
+
|
|
88
|
+
/** Full canonical field spec (head + type fields + tail) of a known type. */
|
|
89
|
+
export function specOf(type: string): FieldSpecMap | null {
|
|
90
|
+
const cached = specCache.get(type);
|
|
91
|
+
if (cached) return cached;
|
|
92
|
+
const def = REGISTRY.get(type);
|
|
93
|
+
if (!def) return null;
|
|
94
|
+
const spec = { ...HEAD_FIELDS, ...def.fields, ...TAIL_FIELDS };
|
|
95
|
+
specCache.set(type, spec);
|
|
96
|
+
return spec;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function defOf(type: string): ElementDef | null {
|
|
100
|
+
return REGISTRY.get(type) ?? null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function isKnownType(type: string): boolean {
|
|
104
|
+
return REGISTRY.has(type);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export type ElementResult =
|
|
108
|
+
| { ok: true; el: AnnotationElement }
|
|
109
|
+
| { ok: false; reason: string; id?: string };
|
|
110
|
+
|
|
111
|
+
const UNKNOWN_KEY_RE = /^[A-Za-z_][A-Za-z0-9_-]{0,63}$/;
|
|
112
|
+
const MAX_UNKNOWN_KEYS = 64;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Validate one untrusted record into its canonical form. Never throws. A known
|
|
116
|
+
* type is parsed field-by-field in spec order (unknown keys dropped, defaults
|
|
117
|
+
* omitted); an unknown type keeps its head fields plus a bounded, sanitized
|
|
118
|
+
* copy of the rest (keys sorted, so its bytes are deterministic too).
|
|
119
|
+
*/
|
|
120
|
+
export function validateElement(raw: unknown): ElementResult {
|
|
121
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
122
|
+
return { ok: false, reason: 'not an object' };
|
|
123
|
+
}
|
|
124
|
+
const r = raw as Record<string, unknown>;
|
|
125
|
+
const rawId = typeof r.id === 'string' ? r.id : undefined;
|
|
126
|
+
if (typeof r.type === 'string' && r.type in Object.prototype) {
|
|
127
|
+
return { ok: false, reason: 'reserved element type', id: rawId };
|
|
128
|
+
}
|
|
129
|
+
const spec = typeof r.type === 'string' ? specOf(r.type) : null;
|
|
130
|
+
let el: Record<string, unknown>;
|
|
131
|
+
if (spec) {
|
|
132
|
+
const res = parseFields(spec, r);
|
|
133
|
+
if (!res.ok) return { ok: false, reason: res.reason, id: rawId };
|
|
134
|
+
el = res.value;
|
|
135
|
+
} else {
|
|
136
|
+
const head = parseFields(HEAD_FIELDS, r);
|
|
137
|
+
if (!head.ok) return { ok: false, reason: head.reason, id: rawId };
|
|
138
|
+
el = { ...head.value };
|
|
139
|
+
// Field-shaped keys only, and a bounded number of them: the keys become
|
|
140
|
+
// Y.Map field names on every peer (security review, low).
|
|
141
|
+
const rest = Object.keys(r)
|
|
142
|
+
.filter(
|
|
143
|
+
(k) => !Object.hasOwn(HEAD_FIELDS, k) && !DANGEROUS_KEYS.has(k) && UNKNOWN_KEY_RE.test(k)
|
|
144
|
+
)
|
|
145
|
+
.sort()
|
|
146
|
+
.slice(0, MAX_UNKNOWN_KEYS);
|
|
147
|
+
for (const k of rest) {
|
|
148
|
+
const v = sanitizeJson(r[k]);
|
|
149
|
+
if (v !== undefined) el[k] = v;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (el.parent !== undefined && el.parent === el.id) {
|
|
153
|
+
return { ok: false, reason: 'element is its own parent', id: rawId };
|
|
154
|
+
}
|
|
155
|
+
if (JSON.stringify(el).length > MAX_ELEMENT_BYTES) {
|
|
156
|
+
return { ok: false, reason: 'element exceeds the per-element byte cap', id: rawId };
|
|
157
|
+
}
|
|
158
|
+
return { ok: true, el: el as AnnotationElement };
|
|
159
|
+
}
|