@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,551 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file annotations/ui/edit-actions.ts — what a UI edit sends, as element ops
|
|
3
|
+
* @scope apps/studio/annotations/ui/edit-actions.ts
|
|
4
|
+
* @purpose DDR-242 AD4 + AD8 (Task 26). Every whiteboard edit the canvas
|
|
5
|
+
* makes — move, resize, restyle, create, delete, duplicate, z-order,
|
|
6
|
+
* group, align, re-bind an arrow — is ONE op batch built here from
|
|
7
|
+
* the board and world-space intent, through the element registry.
|
|
8
|
+
* No stroke geometry, no whole-board diff: a move patches x/y of
|
|
9
|
+
* the moved roots (their contents are parent-relative and follow),
|
|
10
|
+
* a bound arrow end is a reference and needs no patch at all.
|
|
11
|
+
*
|
|
12
|
+
* Pure and React-free; the layer applies the batch locally
|
|
13
|
+
* (board.ts) and sends it (DDR-242 §4). Undo is its inverse.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { keyBetween, orderKeys } from '../fractional-index.ts';
|
|
17
|
+
import type { Op } from '../ops.ts';
|
|
18
|
+
import { defOf } from '../registry.ts';
|
|
19
|
+
import type { Scene } from '../scene.ts';
|
|
20
|
+
import type { AnnotationElement, ArrowEnd, Box } from '../types.ts';
|
|
21
|
+
|
|
22
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
23
|
+
// Ids
|
|
24
|
+
|
|
25
|
+
const ID_CHARS = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
|
26
|
+
|
|
27
|
+
export function newId(prefix = 's'): string {
|
|
28
|
+
let out = `${prefix}_`;
|
|
29
|
+
const bytes = new Uint8Array(8);
|
|
30
|
+
crypto.getRandomValues(bytes);
|
|
31
|
+
for (const b of bytes) out += ID_CHARS[b % ID_CHARS.length];
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function newGroupId(): string {
|
|
36
|
+
return newId('g');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
40
|
+
// World ⇄ parent space
|
|
41
|
+
|
|
42
|
+
function num(v: unknown): number {
|
|
43
|
+
return typeof v === 'number' && Number.isFinite(v) ? v : 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Shift every parent-space coordinate of a record by (dx, dy): x/y, pen points, free arrow ends. */
|
|
47
|
+
export function shiftRecord(
|
|
48
|
+
el: Record<string, unknown>,
|
|
49
|
+
dx: number,
|
|
50
|
+
dy: number
|
|
51
|
+
): Record<string, unknown> {
|
|
52
|
+
if (dx === 0 && dy === 0) return el;
|
|
53
|
+
const out = { ...el };
|
|
54
|
+
if (typeof out.x === 'number') out.x = (out.x as number) + dx;
|
|
55
|
+
if (typeof out.y === 'number') out.y = (out.y as number) + dy;
|
|
56
|
+
if (Array.isArray(out.points)) {
|
|
57
|
+
out.points = (out.points as number[]).map((v, i) => v + (i % 2 === 0 ? dx : dy));
|
|
58
|
+
}
|
|
59
|
+
for (const k of ['start', 'end'] as const) {
|
|
60
|
+
const e = out[k] as ArrowEnd | undefined;
|
|
61
|
+
if (e && !('el' in e)) out[k] = { x: e.x + dx, y: e.y + dy };
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** World box → the element's parent space. */
|
|
67
|
+
export function toParentBox(scene: Scene, el: AnnotationElement, world: Box): Box {
|
|
68
|
+
const o = scene.originOf(el);
|
|
69
|
+
return { x: world.x - o.x, y: world.y - o.y, w: world.w, h: world.h };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
73
|
+
// Placement (z-order within a parent)
|
|
74
|
+
|
|
75
|
+
function siblings(scene: Scene, parent: string | null): readonly AnnotationElement[] {
|
|
76
|
+
return scene.childrenOf(parent);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Index key on top of `parent`'s children (or at the back). */
|
|
80
|
+
export function indexAt(scene: Scene, parent: string | null, where: 'top' | 'back'): string {
|
|
81
|
+
const kids = siblings(scene, parent);
|
|
82
|
+
if (!kids.length) return keyBetween(null, null);
|
|
83
|
+
if (where === 'back') return keyBetween(null, (kids[0] as AnnotationElement).index);
|
|
84
|
+
return keyBetween((kids[kids.length - 1] as AnnotationElement).index, null);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
88
|
+
// Create
|
|
89
|
+
|
|
90
|
+
export interface CreateOpts {
|
|
91
|
+
/** Where it goes in paint order among its siblings. Default: on top. */
|
|
92
|
+
z?: 'top' | 'back' | { above: string };
|
|
93
|
+
/** Force a parent (null = top level); default: the container its centre lands in. */
|
|
94
|
+
parent?: string | null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* A new element from a WORLD-space record (no id/index/parent needed): it joins
|
|
99
|
+
* the section its centre lands in — a section inside another nests there —
|
|
100
|
+
* and is converted to that parent's coordinates.
|
|
101
|
+
*/
|
|
102
|
+
export function createOp(
|
|
103
|
+
scene: Scene,
|
|
104
|
+
world: Record<string, unknown> & { type: string },
|
|
105
|
+
opts: CreateOpts = {}
|
|
106
|
+
): { op: Op; id: string } {
|
|
107
|
+
const id = (typeof world.id === 'string' && world.id) || newId();
|
|
108
|
+
const def = defOf(world.type);
|
|
109
|
+
let parent: string | null = opts.parent === undefined ? null : opts.parent;
|
|
110
|
+
if (opts.parent === undefined) {
|
|
111
|
+
const probe = { ...world, id, index: 'a0' } as AnnotationElement;
|
|
112
|
+
const b = def?.bounds(probe, { origin: { x: 0, y: 0 }, resolve: () => null }) ?? null;
|
|
113
|
+
if (b) {
|
|
114
|
+
const exclude = new Set<string>([id]);
|
|
115
|
+
parent = scene.containerAt(b.x + b.w / 2, b.y + b.h / 2, exclude)?.id ?? null;
|
|
116
|
+
// A container only nests inside a strictly larger one.
|
|
117
|
+
if (parent && def?.caps.container) {
|
|
118
|
+
const pb = scene.worldBox(parent);
|
|
119
|
+
if (pb && pb.w * pb.h <= b.w * b.h) parent = null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const origin = parent
|
|
124
|
+
? (() => {
|
|
125
|
+
const p = scene.get(parent) as AnnotationElement;
|
|
126
|
+
const po = scene.originOf(p);
|
|
127
|
+
return { x: po.x + num(p.x), y: po.y + num(p.y) };
|
|
128
|
+
})()
|
|
129
|
+
: { x: 0, y: 0 };
|
|
130
|
+
const z = opts.z ?? 'top';
|
|
131
|
+
let index: string;
|
|
132
|
+
if (typeof z === 'object') {
|
|
133
|
+
const kids = siblings(scene, parent);
|
|
134
|
+
const at = kids.findIndex((k) => k.id === z.above);
|
|
135
|
+
const lo = at >= 0 ? (kids[at] as AnnotationElement).index : null;
|
|
136
|
+
const hi = at >= 0 && at + 1 < kids.length ? (kids[at + 1] as AnnotationElement).index : null;
|
|
137
|
+
index = keyBetween(lo, hi);
|
|
138
|
+
} else {
|
|
139
|
+
index = indexAt(scene, parent, z);
|
|
140
|
+
}
|
|
141
|
+
const rec = shiftRecord({ ...world, id, index }, -origin.x, -origin.y);
|
|
142
|
+
if (parent) rec.parent = parent;
|
|
143
|
+
else delete rec.parent;
|
|
144
|
+
return { op: { op: 'put', el: rec as AnnotationElement }, id };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
148
|
+
// Move (+ re-parent at drop)
|
|
149
|
+
|
|
150
|
+
/** Of `ids`, those not inside another of `ids` — contents ride their container. */
|
|
151
|
+
export function rootsOf(scene: Scene, ids: Iterable<string>): string[] {
|
|
152
|
+
const set = new Set(ids);
|
|
153
|
+
return [...set].filter((id) => scene.get(id) && !scene.ancestors(id).some((a) => set.has(a)));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Move `ids` by a world delta. Only the roots are patched — a section's
|
|
158
|
+
* contents are relative to it. With `reparent`, a root whose centre lands in
|
|
159
|
+
* another section (or out of its own) changes parent at the same world place
|
|
160
|
+
* (the drop).
|
|
161
|
+
*/
|
|
162
|
+
export function moveOps(
|
|
163
|
+
scene: Scene,
|
|
164
|
+
ids: Iterable<string>,
|
|
165
|
+
dx: number,
|
|
166
|
+
dy: number,
|
|
167
|
+
opts: { reparent?: boolean } = {}
|
|
168
|
+
): Op[] {
|
|
169
|
+
const roots = rootsOf(scene, ids);
|
|
170
|
+
const moving = new Set<string>();
|
|
171
|
+
for (const r of roots) {
|
|
172
|
+
moving.add(r);
|
|
173
|
+
for (const d of scene.descendants(r)) moving.add(d.id);
|
|
174
|
+
}
|
|
175
|
+
const ops: Op[] = [];
|
|
176
|
+
for (const id of roots) {
|
|
177
|
+
const el = scene.get(id) as AnnotationElement;
|
|
178
|
+
const def = defOf(el.type);
|
|
179
|
+
let set: Record<string, unknown> = def ? def.translate(el, dx, dy) : shiftRecord(el, dx, dy);
|
|
180
|
+
if (opts.reparent) {
|
|
181
|
+
const wb = scene.worldBox(el);
|
|
182
|
+
if (wb) {
|
|
183
|
+
const cx = wb.x + wb.w / 2 + dx;
|
|
184
|
+
const cy = wb.y + wb.h / 2 + dy;
|
|
185
|
+
let next = scene.containerAt(cx, cy, moving)?.id ?? null;
|
|
186
|
+
if (next && def?.caps.container) {
|
|
187
|
+
const nb = scene.worldBox(next);
|
|
188
|
+
if (nb && nb.w * nb.h <= wb.w * wb.h) next = null;
|
|
189
|
+
}
|
|
190
|
+
const cur = scene.effectiveParent(el);
|
|
191
|
+
if (next !== cur) {
|
|
192
|
+
const from = scene.originOf(el);
|
|
193
|
+
const to = next
|
|
194
|
+
? (() => {
|
|
195
|
+
const p = scene.get(next) as AnnotationElement;
|
|
196
|
+
const po = scene.originOf(p);
|
|
197
|
+
return { x: po.x + num(p.x), y: po.y + num(p.y) };
|
|
198
|
+
})()
|
|
199
|
+
: { x: 0, y: 0 };
|
|
200
|
+
const moved = { ...el, ...set };
|
|
201
|
+
set = { ...set, ...shiftRecord(moved, from.x - to.x, from.y - to.y) };
|
|
202
|
+
for (const k of ['id', 'type', 'index', 'groups', 'author'] as const) delete set[k];
|
|
203
|
+
set.parent = next ?? undefined;
|
|
204
|
+
set.index = indexAt(scene, next, 'top');
|
|
205
|
+
if (!next) {
|
|
206
|
+
ops.push({ op: 'patch', id, set: omitUndef(set), unset: ['parent'] });
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
ops.push({ op: 'patch', id, set: omitUndef(set) });
|
|
213
|
+
}
|
|
214
|
+
return ops;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function omitUndef(o: Record<string, unknown>): Record<string, unknown> {
|
|
218
|
+
const out: Record<string, unknown> = {};
|
|
219
|
+
for (const [k, v] of Object.entries(o)) if (v !== undefined) out[k] = v;
|
|
220
|
+
return out;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
224
|
+
// Resize / rotate / restyle
|
|
225
|
+
|
|
226
|
+
/** Fit an element to a WORLD box (its registry `resize`). */
|
|
227
|
+
export function resizeOp(scene: Scene, id: string, world: Box): Op | null {
|
|
228
|
+
const el = scene.get(id);
|
|
229
|
+
const def = el ? defOf(el.type) : null;
|
|
230
|
+
if (!el || !def) return null;
|
|
231
|
+
const box = toParentBox(scene, el, {
|
|
232
|
+
x: Math.min(world.x, world.x + world.w),
|
|
233
|
+
y: Math.min(world.y, world.y + world.h),
|
|
234
|
+
w: Math.abs(world.w),
|
|
235
|
+
h: Math.abs(world.h),
|
|
236
|
+
});
|
|
237
|
+
return { op: 'patch', id, set: def.resize(el, box, scene.ctx(el)) };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function patchOp(id: string, set: Record<string, unknown>, unset?: string[]): Op {
|
|
241
|
+
return { op: 'patch', id, set, ...(unset?.length ? { unset } : {}) };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Set (or, with `undefined`, reset to default) fields on many elements. */
|
|
245
|
+
export function styleOps(ids: Iterable<string>, fields: Record<string, unknown>): Op[] {
|
|
246
|
+
const set: Record<string, unknown> = {};
|
|
247
|
+
const unset: string[] = [];
|
|
248
|
+
for (const [k, v] of Object.entries(fields)) {
|
|
249
|
+
if (v === undefined || v === null) unset.push(k);
|
|
250
|
+
else set[k] = v;
|
|
251
|
+
}
|
|
252
|
+
return [...ids].map((id) => ({
|
|
253
|
+
op: 'patch' as const,
|
|
254
|
+
id,
|
|
255
|
+
set,
|
|
256
|
+
...(unset.length ? { unset } : {}),
|
|
257
|
+
}));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
261
|
+
// Delete / duplicate
|
|
262
|
+
|
|
263
|
+
export function deleteOps(ids: Iterable<string>): Op[] {
|
|
264
|
+
// Children before their containers: each op then removes a leaf, and the
|
|
265
|
+
// op layer's own fix-ups (freeze bound arrow ends) run per element.
|
|
266
|
+
return [...ids].map((id) => ({ op: 'delete' as const, id })).reverse();
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Copies of `ids` (callers pass the full set — contents included), offset by a
|
|
271
|
+
* world delta. Parents, groups and arrow bindings inside the copied set are
|
|
272
|
+
* remapped to the copies; a binding to an element outside the set stays on it.
|
|
273
|
+
* Copies go on top of their siblings.
|
|
274
|
+
*/
|
|
275
|
+
export function duplicateOps(
|
|
276
|
+
scene: Scene,
|
|
277
|
+
ids: Iterable<string>,
|
|
278
|
+
dx: number,
|
|
279
|
+
dy: number
|
|
280
|
+
): { ops: Op[]; idMap: Map<string, string> } {
|
|
281
|
+
const set = new Set([...ids].filter((id) => scene.get(id)));
|
|
282
|
+
const idMap = new Map<string, string>();
|
|
283
|
+
const groupMap = new Map<string, string>();
|
|
284
|
+
const order = scene.paintOrder().filter((e) => set.has(e.id));
|
|
285
|
+
for (const el of order) idMap.set(el.id, newId());
|
|
286
|
+
const topKey = new Map<string, string | null>();
|
|
287
|
+
const nextKey = (parent: string | null) => {
|
|
288
|
+
const k = parent ?? '';
|
|
289
|
+
const prev = topKey.has(k)
|
|
290
|
+
? (topKey.get(k) as string | null)
|
|
291
|
+
: (() => {
|
|
292
|
+
const kids = siblings(scene, parent);
|
|
293
|
+
return kids.length ? (kids[kids.length - 1] as AnnotationElement).index : null;
|
|
294
|
+
})();
|
|
295
|
+
const key = keyBetween(prev, null);
|
|
296
|
+
topKey.set(k, key);
|
|
297
|
+
return key;
|
|
298
|
+
};
|
|
299
|
+
const ops: Op[] = [];
|
|
300
|
+
for (const el of order) {
|
|
301
|
+
const nid = idMap.get(el.id) as string;
|
|
302
|
+
const p = scene.effectiveParent(el);
|
|
303
|
+
const parent = p && idMap.has(p) ? (idMap.get(p) as string) : p;
|
|
304
|
+
const isRoot = !p || !set.has(p);
|
|
305
|
+
let rec: Record<string, unknown> = { ...el, id: nid, index: nextKey(parent) };
|
|
306
|
+
if (isRoot) rec = shiftRecord(rec, dx, dy);
|
|
307
|
+
if (parent) rec.parent = parent;
|
|
308
|
+
else delete rec.parent;
|
|
309
|
+
if (Array.isArray(el.groups)) {
|
|
310
|
+
rec.groups = (el.groups as string[]).map((g) => {
|
|
311
|
+
let m = groupMap.get(g);
|
|
312
|
+
if (!m) {
|
|
313
|
+
m = newGroupId();
|
|
314
|
+
groupMap.set(g, m);
|
|
315
|
+
}
|
|
316
|
+
return m;
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
for (const k of ['start', 'end'] as const) {
|
|
320
|
+
const e = rec[k] as ArrowEnd | undefined;
|
|
321
|
+
if (e && 'el' in e && idMap.has(e.el)) rec[k] = { ...e, el: idMap.get(e.el) };
|
|
322
|
+
}
|
|
323
|
+
delete rec.author;
|
|
324
|
+
ops.push({ op: 'put', el: rec as AnnotationElement });
|
|
325
|
+
}
|
|
326
|
+
return { ops, idMap };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
330
|
+
// Groups
|
|
331
|
+
|
|
332
|
+
export function outermostGroup(el: AnnotationElement | undefined): string | null {
|
|
333
|
+
const g = el?.groups;
|
|
334
|
+
return Array.isArray(g) && g.length ? (g[g.length - 1] as string) : null;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** `ids` plus every member of their outermost groups. */
|
|
338
|
+
export function expandGroups(scene: Scene, ids: readonly string[]): string[] {
|
|
339
|
+
const groups = new Set<string>();
|
|
340
|
+
for (const id of ids) {
|
|
341
|
+
const g = outermostGroup(scene.get(id));
|
|
342
|
+
if (g) groups.add(g);
|
|
343
|
+
}
|
|
344
|
+
const out = new Set(ids);
|
|
345
|
+
if (groups.size) {
|
|
346
|
+
for (const el of scene.byId.values()) {
|
|
347
|
+
const g = outermostGroup(el);
|
|
348
|
+
if (g && groups.has(g)) out.add(el.id);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return [...out];
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export function groupOps(
|
|
355
|
+
scene: Scene,
|
|
356
|
+
ids: readonly string[]
|
|
357
|
+
): { ops: Op[]; members: string[] } | null {
|
|
358
|
+
const members = expandGroups(scene, ids).filter((id) => scene.get(id));
|
|
359
|
+
if (members.length < 2) return null;
|
|
360
|
+
const g = newGroupId();
|
|
361
|
+
const ops: Op[] = members.map((id) => {
|
|
362
|
+
const cur = (scene.get(id)?.groups as string[] | undefined) ?? [];
|
|
363
|
+
return { op: 'patch', id, set: { groups: [...cur, g] } };
|
|
364
|
+
});
|
|
365
|
+
return { ops, members };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export function ungroupOps(scene: Scene, ids: readonly string[]): Op[] {
|
|
369
|
+
const dissolve = new Set<string>();
|
|
370
|
+
for (const id of ids) {
|
|
371
|
+
const g = outermostGroup(scene.get(id));
|
|
372
|
+
if (g) dissolve.add(g);
|
|
373
|
+
}
|
|
374
|
+
if (!dissolve.size) return [];
|
|
375
|
+
const ops: Op[] = [];
|
|
376
|
+
for (const el of scene.byId.values()) {
|
|
377
|
+
const g = outermostGroup(el);
|
|
378
|
+
if (!g || !dissolve.has(g)) continue;
|
|
379
|
+
const rest = (el.groups as string[]).slice(0, -1);
|
|
380
|
+
ops.push(
|
|
381
|
+
rest.length
|
|
382
|
+
? { op: 'patch', id: el.id, set: { groups: rest } }
|
|
383
|
+
: { op: 'patch', id: el.id, unset: ['groups'] }
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
return ops;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
390
|
+
// Z-order (per parent; a group's members move as one unit)
|
|
391
|
+
|
|
392
|
+
export type ZOrder = 'front' | 'back' | 'forward' | 'backward';
|
|
393
|
+
|
|
394
|
+
export function reorderOps(scene: Scene, ids: readonly string[], op: ZOrder): Op[] {
|
|
395
|
+
const sel = new Set(expandGroups(scene, ids));
|
|
396
|
+
const parents = new Set<string | null>();
|
|
397
|
+
for (const id of sel) {
|
|
398
|
+
const el = scene.get(id);
|
|
399
|
+
if (el) parents.add(scene.effectiveParent(el));
|
|
400
|
+
}
|
|
401
|
+
const ops: Op[] = [];
|
|
402
|
+
for (const parent of parents) {
|
|
403
|
+
const kids = siblings(scene, parent);
|
|
404
|
+
const units: AnnotationElement[][] = [];
|
|
405
|
+
for (const k of kids) {
|
|
406
|
+
const g = outermostGroup(k);
|
|
407
|
+
const last = units[units.length - 1];
|
|
408
|
+
if (last && g != null && outermostGroup(last[0]) === g) last.push(k);
|
|
409
|
+
else units.push([k]);
|
|
410
|
+
}
|
|
411
|
+
const isSel = (u: AnnotationElement[]) => u.some((e) => sel.has(e.id));
|
|
412
|
+
let arr: AnnotationElement[][];
|
|
413
|
+
if (op === 'front') arr = [...units.filter((u) => !isSel(u)), ...units.filter(isSel)];
|
|
414
|
+
else if (op === 'back') arr = [...units.filter(isSel), ...units.filter((u) => !isSel(u))];
|
|
415
|
+
else {
|
|
416
|
+
arr = [...units];
|
|
417
|
+
if (op === 'forward') {
|
|
418
|
+
for (let i = arr.length - 2; i >= 0; i--) {
|
|
419
|
+
const cur = arr[i] as AnnotationElement[];
|
|
420
|
+
const above = arr[i + 1] as AnnotationElement[];
|
|
421
|
+
if (isSel(cur) && !isSel(above)) {
|
|
422
|
+
arr[i] = above;
|
|
423
|
+
arr[i + 1] = cur;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
} else {
|
|
427
|
+
for (let i = 1; i < arr.length; i++) {
|
|
428
|
+
const cur = arr[i] as AnnotationElement[];
|
|
429
|
+
const below = arr[i - 1] as AnnotationElement[];
|
|
430
|
+
if (isSel(cur) && !isSel(below)) {
|
|
431
|
+
arr[i] = below;
|
|
432
|
+
arr[i - 1] = cur;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
const idsInOrder = arr.flat().map((e) => e.id);
|
|
438
|
+
const prev = new Map(kids.map((k) => [k.id, k.index]));
|
|
439
|
+
for (const [id, key] of orderKeys(idsInOrder, prev)) {
|
|
440
|
+
if (prev.get(id) !== key) ops.push({ op: 'patch', id, set: { index: key } });
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
return ops;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
447
|
+
// Align / distribute (units: groups; contents ride their section)
|
|
448
|
+
|
|
449
|
+
export type AlignEdge = 'left' | 'h-center' | 'right' | 'top' | 'v-center' | 'bottom';
|
|
450
|
+
|
|
451
|
+
interface Unit {
|
|
452
|
+
ids: string[];
|
|
453
|
+
box: Box;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function union(a: Box, b: Box): Box {
|
|
457
|
+
const x = Math.min(a.x, b.x);
|
|
458
|
+
const y = Math.min(a.y, b.y);
|
|
459
|
+
return { x, y, w: Math.max(a.x + a.w, b.x + b.w) - x, h: Math.max(a.y + a.h, b.y + b.h) - y };
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function unitsOf(scene: Scene, ids: readonly string[]): Unit[] {
|
|
463
|
+
const roots = rootsOf(scene, expandGroups(scene, ids));
|
|
464
|
+
const byGroup = new Map<string, Unit>();
|
|
465
|
+
const units: Unit[] = [];
|
|
466
|
+
for (const el of scene.paintOrder()) {
|
|
467
|
+
if (!roots.includes(el.id)) continue;
|
|
468
|
+
const b = scene.worldBox(el);
|
|
469
|
+
if (!b) continue;
|
|
470
|
+
const g = outermostGroup(el);
|
|
471
|
+
const u = g ? byGroup.get(g) : undefined;
|
|
472
|
+
if (u) {
|
|
473
|
+
u.ids.push(el.id);
|
|
474
|
+
u.box = union(u.box, b);
|
|
475
|
+
} else {
|
|
476
|
+
const nu = { ids: [el.id], box: b };
|
|
477
|
+
if (g) byGroup.set(g, nu);
|
|
478
|
+
units.push(nu);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
return units;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function unitMoves(scene: Scene, moves: Array<[Unit, number, number]>): Op[] {
|
|
485
|
+
const ops: Op[] = [];
|
|
486
|
+
for (const [u, dx, dy] of moves)
|
|
487
|
+
if (dx !== 0 || dy !== 0) ops.push(...moveOps(scene, u.ids, dx, dy));
|
|
488
|
+
return ops;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
export function alignOps(scene: Scene, ids: readonly string[], edge: AlignEdge): Op[] {
|
|
492
|
+
const units = unitsOf(scene, ids);
|
|
493
|
+
if (units.length < 2) return [];
|
|
494
|
+
const all = units.slice(1).reduce((b, u) => union(b, u.box), (units[0] as Unit).box);
|
|
495
|
+
return unitMoves(
|
|
496
|
+
scene,
|
|
497
|
+
units.map((u) => {
|
|
498
|
+
const b = u.box;
|
|
499
|
+
if (edge === 'left') return [u, all.x - b.x, 0];
|
|
500
|
+
if (edge === 'h-center') return [u, all.x + all.w / 2 - (b.x + b.w / 2), 0];
|
|
501
|
+
if (edge === 'right') return [u, all.x + all.w - (b.x + b.w), 0];
|
|
502
|
+
if (edge === 'top') return [u, 0, all.y - b.y];
|
|
503
|
+
if (edge === 'v-center') return [u, 0, all.y + all.h / 2 - (b.y + b.h / 2)];
|
|
504
|
+
return [u, 0, all.y + all.h - (b.y + b.h)];
|
|
505
|
+
})
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
export function distributeOps(scene: Scene, ids: readonly string[], axis: 'h' | 'v'): Op[] {
|
|
510
|
+
const units = unitsOf(scene, ids);
|
|
511
|
+
if (units.length < 3) return [];
|
|
512
|
+
const lead = (u: Unit) => (axis === 'h' ? u.box.x : u.box.y);
|
|
513
|
+
const size = (u: Unit) => (axis === 'h' ? u.box.w : u.box.h);
|
|
514
|
+
const sorted = [...units].sort((a, b) => lead(a) - lead(b));
|
|
515
|
+
const first = sorted[0] as Unit;
|
|
516
|
+
const last = sorted[sorted.length - 1] as Unit;
|
|
517
|
+
const span = lead(last) + size(last) - lead(first);
|
|
518
|
+
const total = sorted.reduce((m, u) => m + size(u), 0);
|
|
519
|
+
const gap = (span - total) / (sorted.length - 1);
|
|
520
|
+
let cursor = lead(first);
|
|
521
|
+
const moves: Array<[Unit, number, number]> = [];
|
|
522
|
+
for (const u of sorted) {
|
|
523
|
+
const d = cursor - lead(u);
|
|
524
|
+
moves.push(axis === 'h' ? [u, d, 0] : [u, 0, d]);
|
|
525
|
+
cursor += size(u) + gap;
|
|
526
|
+
}
|
|
527
|
+
return unitMoves(scene, moves);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
531
|
+
// Arrows
|
|
532
|
+
|
|
533
|
+
/** Point an arrow end at an element (auto / pinned magnet) or a free WORLD point. */
|
|
534
|
+
export function arrowEndOp(
|
|
535
|
+
scene: Scene,
|
|
536
|
+
arrowId: string,
|
|
537
|
+
which: 'start' | 'end',
|
|
538
|
+
target: { el: string; nx?: number; ny?: number } | { x: number; y: number }
|
|
539
|
+
): Op | null {
|
|
540
|
+
const el = scene.get(arrowId);
|
|
541
|
+
if (!el || el.type !== 'arrow') return null;
|
|
542
|
+
if ('el' in target) {
|
|
543
|
+
const end: ArrowEnd =
|
|
544
|
+
target.nx !== undefined && target.ny !== undefined
|
|
545
|
+
? { el: target.el, nx: target.nx, ny: target.ny }
|
|
546
|
+
: { el: target.el };
|
|
547
|
+
return { op: 'patch', id: arrowId, set: { [which]: end } };
|
|
548
|
+
}
|
|
549
|
+
const o = scene.originOf(el);
|
|
550
|
+
return { op: 'patch', id: arrowId, set: { [which]: { x: target.x - o.x, y: target.y - o.y } } };
|
|
551
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file annotations/ui/editor-channel.ts — the text toolbar ⇄ the open editor
|
|
3
|
+
* @scope apps/studio/annotations/ui/editor-channel.ts
|
|
4
|
+
* @purpose DDR-242 AD8 (Task 21): the edit-mode context toolbar drives the
|
|
5
|
+
* open text editor's formatting (bold, size, align…) and mirrors
|
|
6
|
+
* its live state. They used to talk through document CustomEvents
|
|
7
|
+
* (`maude:editor-format` / `-state` / `-request`); this is the same
|
|
8
|
+
* conversation as a typed in-module channel. The editor keeps the
|
|
9
|
+
* formatting local until commit — writing the element mid-edit
|
|
10
|
+
* would re-render the editor under the caret.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export type FormatKey = 'bold' | 'italic' | 'underline' | 'strike' | 'fontSize' | 'align';
|
|
14
|
+
|
|
15
|
+
export interface FormatCommand {
|
|
16
|
+
key: FormatKey;
|
|
17
|
+
value?: unknown;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface FormatState {
|
|
21
|
+
bold?: boolean;
|
|
22
|
+
italic?: boolean;
|
|
23
|
+
underline?: boolean;
|
|
24
|
+
strike?: boolean;
|
|
25
|
+
fontSize?: number;
|
|
26
|
+
align?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type Listener<T> = (v: T) => void;
|
|
30
|
+
|
|
31
|
+
function channel<T>() {
|
|
32
|
+
const listeners = new Set<Listener<T>>();
|
|
33
|
+
return {
|
|
34
|
+
send(v: T) {
|
|
35
|
+
for (const l of [...listeners]) l(v);
|
|
36
|
+
},
|
|
37
|
+
listen(l: Listener<T>): () => void {
|
|
38
|
+
listeners.add(l);
|
|
39
|
+
return () => {
|
|
40
|
+
listeners.delete(l);
|
|
41
|
+
};
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Toolbar → editor: toggle / set a format. */
|
|
47
|
+
export const formatCommands = channel<FormatCommand>();
|
|
48
|
+
|
|
49
|
+
/** Editor → toolbar: the editor's live format. The last value is kept for a toolbar mounting later. */
|
|
50
|
+
const stateChannel = channel<FormatState | null>();
|
|
51
|
+
let lastState: FormatState | null = null;
|
|
52
|
+
export const formatState = {
|
|
53
|
+
publish(s: FormatState | null) {
|
|
54
|
+
lastState = s;
|
|
55
|
+
stateChannel.send(s);
|
|
56
|
+
},
|
|
57
|
+
/** Subscribe; called at once with the current state (a toolbar opening after the editor). */
|
|
58
|
+
listen(l: Listener<FormatState | null>): () => void {
|
|
59
|
+
l(lastState);
|
|
60
|
+
return stateChannel.listen(l);
|
|
61
|
+
},
|
|
62
|
+
};
|