@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,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file annotations/ui/world.ts — the board as the editing UI sees it
|
|
3
|
+
* @scope apps/studio/annotations/ui/world.ts
|
|
4
|
+
* @purpose DDR-242 AD8 (Task 26). The board is the element store
|
|
5
|
+
* (board.ts); elements keep PARENT-relative geometry. The editing
|
|
6
|
+
* tools work in world space, on the world-space `Stroke` view of
|
|
7
|
+
* each element. This projects the board into that view, per
|
|
8
|
+
* element and cached, so an unchanged element keeps its identity
|
|
9
|
+
* and its node skips re-rendering. Pure and React-free.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { Stroke } from '../../annotations-model.ts';
|
|
13
|
+
import type { Scene } from '../scene.ts';
|
|
14
|
+
import type { AnnotationElement } from '../types.ts';
|
|
15
|
+
import { elementStrokes } from '../v1-adapter.ts';
|
|
16
|
+
|
|
17
|
+
export type StrokeCache = WeakMap<AnnotationElement, { key: string; strokes: Stroke[] }>;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The board as world-space strokes, in paint order. An element whose record
|
|
21
|
+
* and world placement are unchanged keeps its strokes' identity; a bound
|
|
22
|
+
* arrow re-projects whenever its resolved ends move.
|
|
23
|
+
*/
|
|
24
|
+
export function projectStrokes(scene: Scene, cache: StrokeCache): Stroke[] {
|
|
25
|
+
const out: Stroke[] = [];
|
|
26
|
+
for (const el of scene.paintOrder()) {
|
|
27
|
+
let key: string;
|
|
28
|
+
if (el.type === 'arrow') {
|
|
29
|
+
const r = scene.arrowWorld(el);
|
|
30
|
+
key = r
|
|
31
|
+
? `${r.x1},${r.y1},${r.x2},${r.y2},${r.startAnchor?.el ?? ''},${r.endAnchor?.el ?? ''}`
|
|
32
|
+
: '';
|
|
33
|
+
} else {
|
|
34
|
+
const o = scene.originOf(el);
|
|
35
|
+
key = `${o.x},${o.y}`;
|
|
36
|
+
}
|
|
37
|
+
const hit = cache.get(el);
|
|
38
|
+
if (hit && hit.key === key) {
|
|
39
|
+
for (const s of hit.strokes) out.push(s);
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
const strokes = elementStrokes(scene, el);
|
|
43
|
+
cache.set(el, { key, strokes });
|
|
44
|
+
for (const s of strokes) out.push(s);
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file annotations/v1-adapter.ts — v2 elements ⇄ the v1 `Stroke[]` UI
|
|
3
|
+
* @scope apps/studio/annotations/v1-adapter.ts
|
|
4
|
+
* @purpose v2-adapter: removed in Task 26.
|
|
5
|
+
*
|
|
6
|
+
* Milestone B of the annotations-v2 plan lands storage + transport
|
|
7
|
+
* UNDER the unchanged v1 UI (annotations-layer.tsx works on
|
|
8
|
+
* `Stroke[]`). This adapter is the seam:
|
|
9
|
+
* • elementsToStrokes — the board as the UI draws it (world
|
|
10
|
+
* coordinates, labels as anchored text, arrow endpoints
|
|
11
|
+
* resolved);
|
|
12
|
+
* • strokesToElements — the UI's next `Stroke[]` back to canonical
|
|
13
|
+
* elements, PRESERVING what the UI cannot express: an element
|
|
14
|
+
* that did not move keeps its parent, sibling order keeps its
|
|
15
|
+
* fractional keys where it can, unknown types are carried over.
|
|
16
|
+
* The caller diffs two adapter outputs (`diffToOps`) — the UI's own
|
|
17
|
+
* before/after — so only what the user changed is sent.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { ArrowBind, Stroke, WorldPoint } from '../annotations-model.ts';
|
|
21
|
+
import {
|
|
22
|
+
DEFAULT_FONT_SIZE,
|
|
23
|
+
DEFAULT_INK,
|
|
24
|
+
DEFAULT_SECTION_COLOR,
|
|
25
|
+
DEFAULT_STICKY_FILL,
|
|
26
|
+
STICKY_RADIUS,
|
|
27
|
+
} from './constants.ts';
|
|
28
|
+
import { keyBetween, orderKeys } from './fractional-index.ts';
|
|
29
|
+
import { v1ToV2 } from './migrate-v1.ts';
|
|
30
|
+
import { defOf, isKnownType } from './registry.ts';
|
|
31
|
+
import { Scene } from './scene.ts';
|
|
32
|
+
import { validateElements } from './schema.ts';
|
|
33
|
+
import type { AnnotationElement, ArrowEnd, Box } from './types.ts';
|
|
34
|
+
|
|
35
|
+
/** Stable id of the v1 anchored-text stroke that shows a shape's label. */
|
|
36
|
+
export function labelStrokeId(shapeId: string): string {
|
|
37
|
+
return `${shapeId}~label`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function num(v: unknown, d = 0): number {
|
|
41
|
+
return typeof v === 'number' ? v : d;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function str(v: unknown, d = ''): string {
|
|
45
|
+
return typeof v === 'string' ? v : d;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
type Fmt = {
|
|
49
|
+
bold?: boolean;
|
|
50
|
+
italic?: boolean;
|
|
51
|
+
strike?: boolean;
|
|
52
|
+
underline?: boolean;
|
|
53
|
+
listType?: 'bullet' | 'number';
|
|
54
|
+
align?: 'left' | 'center' | 'right';
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
function fmtOf(r: Record<string, unknown>, defAlign: 'left' | 'center'): Fmt {
|
|
58
|
+
const out: Fmt = {};
|
|
59
|
+
if (r.bold === true) out.bold = true;
|
|
60
|
+
if (r.italic === true) out.italic = true;
|
|
61
|
+
if (r.strike === true) out.strike = true;
|
|
62
|
+
if (r.underline === true) out.underline = true;
|
|
63
|
+
if (r.list === 'bullet' || r.list === 'number') out.listType = r.list;
|
|
64
|
+
const a = r.align;
|
|
65
|
+
if ((a === 'left' || a === 'center' || a === 'right') && a !== defAlign) out.align = a;
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function shared(el: AnnotationElement): Partial<Stroke> {
|
|
70
|
+
const out: Record<string, unknown> = {};
|
|
71
|
+
if (Array.isArray(el.groups) && el.groups.length) out.groupIds = [...(el.groups as string[])];
|
|
72
|
+
const a = el.author as { kind?: string; name?: string; id?: string } | undefined;
|
|
73
|
+
if (a?.kind === 'ai') out.author = 'ai';
|
|
74
|
+
else if (a?.kind === 'human') {
|
|
75
|
+
if (a.name) out.authorName = a.name;
|
|
76
|
+
if (a.id) out.authorId = a.id;
|
|
77
|
+
}
|
|
78
|
+
if (num(el.rot) !== 0) out.rotation = num(el.rot);
|
|
79
|
+
return out as Partial<Stroke>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The board as the v1 UI draws it, in paint order. Unknown types are not drawn by v1. */
|
|
83
|
+
export function elementsToStrokes(elements: Iterable<AnnotationElement>): Stroke[] {
|
|
84
|
+
const scene = new Scene(elements);
|
|
85
|
+
const out: Stroke[] = [];
|
|
86
|
+
for (const el of scene.paintOrder()) out.push(...elementStrokes(scene, el));
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** One element as the strokes the UI draws for it (a labelled shape is two). */
|
|
91
|
+
export function elementStrokes(scene: Scene, el: AnnotationElement): Stroke[] {
|
|
92
|
+
const out: Stroke[] = [];
|
|
93
|
+
{
|
|
94
|
+
if (!isKnownType(el.type)) return out;
|
|
95
|
+
const o = scene.originOf(el);
|
|
96
|
+
const x = num(el.x) + o.x;
|
|
97
|
+
const y = num(el.y) + o.y;
|
|
98
|
+
const w = num(el.w);
|
|
99
|
+
const h = num(el.h);
|
|
100
|
+
const base = { id: el.id, ...shared(el) };
|
|
101
|
+
switch (el.type) {
|
|
102
|
+
case 'sticky':
|
|
103
|
+
out.push({
|
|
104
|
+
...base,
|
|
105
|
+
tool: 'sticky',
|
|
106
|
+
color: str(el.fill, DEFAULT_STICKY_FILL),
|
|
107
|
+
x,
|
|
108
|
+
y,
|
|
109
|
+
w,
|
|
110
|
+
h,
|
|
111
|
+
text: str(el.text),
|
|
112
|
+
fontSize: num(el.fontSize, DEFAULT_FONT_SIZE),
|
|
113
|
+
cornerRadius: num(el.radius, STICKY_RADIUS),
|
|
114
|
+
...fmtOf(el, 'left'),
|
|
115
|
+
} as Stroke);
|
|
116
|
+
break;
|
|
117
|
+
case 'text':
|
|
118
|
+
out.push({
|
|
119
|
+
...base,
|
|
120
|
+
tool: 'text',
|
|
121
|
+
color: str(el.color, DEFAULT_INK),
|
|
122
|
+
fontSize: num(el.fontSize, DEFAULT_FONT_SIZE),
|
|
123
|
+
text: str(el.text),
|
|
124
|
+
x,
|
|
125
|
+
y,
|
|
126
|
+
...fmtOf(el, 'left'),
|
|
127
|
+
} as Stroke);
|
|
128
|
+
break;
|
|
129
|
+
case 'shape': {
|
|
130
|
+
const kind = str(el.kind, 'rect');
|
|
131
|
+
const common = {
|
|
132
|
+
...base,
|
|
133
|
+
color: str(el.color, DEFAULT_INK),
|
|
134
|
+
width: num(el.width, 2),
|
|
135
|
+
fill: typeof el.fill === 'string' ? el.fill : null,
|
|
136
|
+
...(el.dashed === true ? { dashed: true } : {}),
|
|
137
|
+
};
|
|
138
|
+
if (kind === 'rect')
|
|
139
|
+
out.push({ ...common, tool: 'rect', x, y, w, h, cornerRadius: num(el.radius) } as Stroke);
|
|
140
|
+
else if (kind === 'ellipse')
|
|
141
|
+
out.push({
|
|
142
|
+
...common,
|
|
143
|
+
tool: 'ellipse',
|
|
144
|
+
cx: x + w / 2,
|
|
145
|
+
cy: y + h / 2,
|
|
146
|
+
rx: w / 2,
|
|
147
|
+
ry: h / 2,
|
|
148
|
+
} as Stroke);
|
|
149
|
+
else out.push({ ...common, tool: 'polygon', shape: kind, x, y, w, h } as Stroke);
|
|
150
|
+
const label = el.label as Record<string, unknown> | undefined;
|
|
151
|
+
if (label && typeof label.text === 'string') {
|
|
152
|
+
const lf = fmtOf(label, 'center');
|
|
153
|
+
out.push({
|
|
154
|
+
id: labelStrokeId(el.id),
|
|
155
|
+
tool: 'text',
|
|
156
|
+
anchorId: el.id,
|
|
157
|
+
color: str(label.color, DEFAULT_INK),
|
|
158
|
+
fontSize: num(label.fontSize, DEFAULT_FONT_SIZE),
|
|
159
|
+
text: label.text,
|
|
160
|
+
...lf,
|
|
161
|
+
// v1 anchored text defaults to centre; carry a non-centre alignment explicitly.
|
|
162
|
+
...(label.align === 'left' || label.align === 'right' ? { align: label.align } : {}),
|
|
163
|
+
} as Stroke);
|
|
164
|
+
}
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
case 'arrow': {
|
|
168
|
+
const r = scene.arrowWorld(el);
|
|
169
|
+
if (!r) break;
|
|
170
|
+
const bind = (
|
|
171
|
+
a: typeof r.startAnchor,
|
|
172
|
+
end: ArrowEnd | undefined
|
|
173
|
+
): ArrowBind | undefined => {
|
|
174
|
+
if (!a) return undefined;
|
|
175
|
+
const pinned = !!end && 'el' in end && end.nx !== undefined;
|
|
176
|
+
return { hostId: a.el, nx: a.nx, ny: a.ny, ...(pinned ? { pinned: true } : {}) };
|
|
177
|
+
};
|
|
178
|
+
const sb = bind(r.startAnchor, el.start as ArrowEnd);
|
|
179
|
+
const eb = bind(r.endAnchor, el.end as ArrowEnd);
|
|
180
|
+
const head = (k: string, def: string) =>
|
|
181
|
+
typeof el[k] === 'string' && el[k] !== def ? { [k]: el[k] } : {};
|
|
182
|
+
out.push({
|
|
183
|
+
...base,
|
|
184
|
+
tool: 'arrow',
|
|
185
|
+
color: str(el.color, DEFAULT_INK),
|
|
186
|
+
width: num(el.width, 2),
|
|
187
|
+
x1: r.x1,
|
|
188
|
+
y1: r.y1,
|
|
189
|
+
x2: r.x2,
|
|
190
|
+
y2: r.y2,
|
|
191
|
+
...head('startHead', 'none'),
|
|
192
|
+
...head('endHead', 'triangle'),
|
|
193
|
+
...(el.dashed === true ? { dashed: true } : {}),
|
|
194
|
+
...(typeof el.line === 'string' && el.line !== 'straight' ? { lineType: el.line } : {}),
|
|
195
|
+
...(sb ? { startBind: sb } : {}),
|
|
196
|
+
...(eb ? { endBind: eb } : {}),
|
|
197
|
+
} as Stroke);
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
case 'pen': {
|
|
201
|
+
const p = Array.isArray(el.points) ? (el.points as number[]) : [];
|
|
202
|
+
const points: WorldPoint[] = [];
|
|
203
|
+
for (let i = 0; i + 1 < p.length; i += 2)
|
|
204
|
+
points.push([(p[i] as number) + o.x, (p[i + 1] as number) + o.y]);
|
|
205
|
+
out.push({
|
|
206
|
+
...base,
|
|
207
|
+
tool: 'pen',
|
|
208
|
+
color: str(el.color, DEFAULT_INK),
|
|
209
|
+
width: num(el.width, 2),
|
|
210
|
+
points,
|
|
211
|
+
...(el.highlighter === true ? { highlighter: true } : {}),
|
|
212
|
+
} as Stroke);
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
case 'image':
|
|
216
|
+
out.push({
|
|
217
|
+
...base,
|
|
218
|
+
tool: 'image',
|
|
219
|
+
x,
|
|
220
|
+
y,
|
|
221
|
+
w,
|
|
222
|
+
h,
|
|
223
|
+
href: str(el.href),
|
|
224
|
+
...(el.alt ? { alt: str(el.alt) } : {}),
|
|
225
|
+
} as Stroke);
|
|
226
|
+
break;
|
|
227
|
+
case 'link':
|
|
228
|
+
out.push({
|
|
229
|
+
...base,
|
|
230
|
+
tool: 'link',
|
|
231
|
+
x,
|
|
232
|
+
y,
|
|
233
|
+
w,
|
|
234
|
+
h,
|
|
235
|
+
url: str(el.url),
|
|
236
|
+
title: str(el.title),
|
|
237
|
+
domain: str(el.domain),
|
|
238
|
+
} as Stroke);
|
|
239
|
+
break;
|
|
240
|
+
case 'mediaref':
|
|
241
|
+
out.push({
|
|
242
|
+
...base,
|
|
243
|
+
tool: 'mediaref',
|
|
244
|
+
x,
|
|
245
|
+
y,
|
|
246
|
+
w,
|
|
247
|
+
h,
|
|
248
|
+
src: str(el.src),
|
|
249
|
+
mediaKind: el.media === 'audio' ? 'audio' : 'video',
|
|
250
|
+
title: str(el.title),
|
|
251
|
+
} as Stroke);
|
|
252
|
+
break;
|
|
253
|
+
default: {
|
|
254
|
+
// A registry type with no stroke form of its own (Task 25): carried as
|
|
255
|
+
// its world-space record; geometry comes from its definition.
|
|
256
|
+
const def = defOf(el.type);
|
|
257
|
+
if (!def) break;
|
|
258
|
+
const world: Record<string, unknown> = { ...el, ...def.translate(el, o.x, o.y) };
|
|
259
|
+
delete world.parent;
|
|
260
|
+
out.push({ ...base, tool: 'element', el: world as AnnotationElement } as Stroke);
|
|
261
|
+
break;
|
|
262
|
+
}
|
|
263
|
+
case 'section':
|
|
264
|
+
out.push({
|
|
265
|
+
...base,
|
|
266
|
+
tool: 'section',
|
|
267
|
+
x,
|
|
268
|
+
y,
|
|
269
|
+
w,
|
|
270
|
+
h,
|
|
271
|
+
label: str(el.label, 'Section'),
|
|
272
|
+
color: str(el.color, DEFAULT_SECTION_COLOR),
|
|
273
|
+
} as Stroke);
|
|
274
|
+
break;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return out;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function sameBox(a: Box | null, b: Box | null): boolean {
|
|
281
|
+
if (!a || !b) return false;
|
|
282
|
+
return (
|
|
283
|
+
Math.abs(a.x - b.x) < 0.011 &&
|
|
284
|
+
Math.abs(a.y - b.y) < 0.011 &&
|
|
285
|
+
Math.abs(a.w - b.w) < 0.011 &&
|
|
286
|
+
Math.abs(a.h - b.h) < 0.011
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* The UI's `Stroke[]` → canonical elements, reconciled with the `current`
|
|
292
|
+
* board so that what the v1 UI can't express survives: parents of unmoved
|
|
293
|
+
* elements, sibling keys, unknown element types.
|
|
294
|
+
*/
|
|
295
|
+
export function strokesToElements(
|
|
296
|
+
strokes: readonly Stroke[],
|
|
297
|
+
current: ReadonlyMap<string, AnnotationElement>
|
|
298
|
+
): AnnotationElement[] {
|
|
299
|
+
const flat = v1ToV2(strokes, { flat: true }).elements; // world coords, top level
|
|
300
|
+
const world = new Map(flat.map((e) => [e.id, e]));
|
|
301
|
+
const curScene = new Scene(current.values());
|
|
302
|
+
const nextScene = new Scene(flat); // all top-level → world boxes = own boxes
|
|
303
|
+
const boxOf = (id: string) => nextScene.worldBox(id);
|
|
304
|
+
// Parents: keep when the element did not move and its parent survives; else by centre.
|
|
305
|
+
const sections = flat
|
|
306
|
+
.filter((e) => e.type === 'section')
|
|
307
|
+
.map((e) => ({ id: e.id, box: boxOf(e.id) as Box }))
|
|
308
|
+
.filter((s) => s.box);
|
|
309
|
+
const area = (b: Box) => b.w * b.h;
|
|
310
|
+
const parentOf = new Map<string, string>();
|
|
311
|
+
// Same rule as the migration: a section adopts only what paints above it.
|
|
312
|
+
const zOf = new Map(strokes.map((s, i) => [s.id, i]));
|
|
313
|
+
const inside = (box: Box, x: number, y: number) =>
|
|
314
|
+
x >= box.x && x <= box.x + box.w && y >= box.y && y <= box.y + box.h;
|
|
315
|
+
for (const el of flat) {
|
|
316
|
+
const prev = current.get(el.id);
|
|
317
|
+
const b = boxOf(el.id);
|
|
318
|
+
if (!b) continue;
|
|
319
|
+
const cx = b.x + b.w / 2;
|
|
320
|
+
const cy = b.y + b.h / 2;
|
|
321
|
+
const self = el.type === 'section' ? area(b) : null;
|
|
322
|
+
// The smallest section under the element's centre that it painted above
|
|
323
|
+
// (the v1 rule: what sits on a section is in it).
|
|
324
|
+
let best: { id: string; a: number } | null = null;
|
|
325
|
+
for (const s of sections) {
|
|
326
|
+
if (s.id === el.id || (self !== null && area(s.box) <= self)) continue;
|
|
327
|
+
if ((zOf.get(s.id) ?? 0) >= (zOf.get(el.id) ?? Number.POSITIVE_INFINITY)) continue;
|
|
328
|
+
if (!inside(s.box, cx, cy)) continue;
|
|
329
|
+
// Equal size (a copy lying exactly on its original): the one painted
|
|
330
|
+
// later — nearest below the element — is its container.
|
|
331
|
+
if (!best || area(s.box) <= best.a) best = { id: s.id, a: area(s.box) };
|
|
332
|
+
}
|
|
333
|
+
// An element that did not move keeps its container while it still sits
|
|
334
|
+
// in it — unless a smaller section now holds it (one drawn around it, or
|
|
335
|
+
// moved or resized onto it: it adopts what it lands on, as in v1).
|
|
336
|
+
const moved = !prev || !sameBox(curScene.worldBox(prev), b);
|
|
337
|
+
const kept = prev?.parent;
|
|
338
|
+
const keptBox = kept && world.has(kept) ? boxOf(kept) : null;
|
|
339
|
+
if (!moved && kept && keptBox && inside(keptBox, cx, cy)) {
|
|
340
|
+
parentOf.set(el.id, best && best.a < area(keptBox) ? best.id : kept);
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
if (best) parentOf.set(el.id, best.id);
|
|
344
|
+
}
|
|
345
|
+
// Break any cycle the kept parents could form (walk up; drop the parent that closes a loop).
|
|
346
|
+
for (const id of parentOf.keys()) {
|
|
347
|
+
const seen = new Set<string>([id]);
|
|
348
|
+
let cur = parentOf.get(id);
|
|
349
|
+
while (cur !== undefined) {
|
|
350
|
+
if (seen.has(cur)) {
|
|
351
|
+
parentOf.delete(id);
|
|
352
|
+
break;
|
|
353
|
+
}
|
|
354
|
+
seen.add(cur);
|
|
355
|
+
cur = parentOf.get(cur);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
// World origin of a parent = its world top-left (all `flat` coords are world).
|
|
359
|
+
const originOf = (id: string): { x: number; y: number } => {
|
|
360
|
+
const p = parentOf.get(id);
|
|
361
|
+
if (!p) return { x: 0, y: 0 };
|
|
362
|
+
const pb = world.get(p);
|
|
363
|
+
return { x: num(pb?.x), y: num(pb?.y) };
|
|
364
|
+
};
|
|
365
|
+
// Sibling order from the stroke array; keys reused where still in order.
|
|
366
|
+
const order = new Map(strokes.map((s, i) => [s.id, i]));
|
|
367
|
+
const byParent = new Map<string, string[]>();
|
|
368
|
+
for (const el of flat) {
|
|
369
|
+
const k = parentOf.get(el.id) ?? '';
|
|
370
|
+
const list = byParent.get(k);
|
|
371
|
+
if (list) list.push(el.id);
|
|
372
|
+
else byParent.set(k, [el.id]);
|
|
373
|
+
}
|
|
374
|
+
const indexOf = new Map<string, string>();
|
|
375
|
+
for (const [p, ids] of byParent) {
|
|
376
|
+
ids.sort((a, b) => (order.get(a) ?? 0) - (order.get(b) ?? 0));
|
|
377
|
+
const prevKeys = new Map<string, string>();
|
|
378
|
+
for (const id of ids) {
|
|
379
|
+
const prev = current.get(id);
|
|
380
|
+
if (prev && (prev.parent ?? '') === p) prevKeys.set(id, prev.index);
|
|
381
|
+
}
|
|
382
|
+
for (const [id, k] of orderKeys(ids, prevKeys)) indexOf.set(id, k);
|
|
383
|
+
}
|
|
384
|
+
const out: Record<string, unknown>[] = [];
|
|
385
|
+
for (const el of flat) {
|
|
386
|
+
const o = originOf(el.id);
|
|
387
|
+
const rec: Record<string, unknown> = {
|
|
388
|
+
...el,
|
|
389
|
+
index: indexOf.get(el.id) ?? keyBetween(null, null),
|
|
390
|
+
};
|
|
391
|
+
const p = parentOf.get(el.id);
|
|
392
|
+
if (p) rec.parent = p;
|
|
393
|
+
else delete rec.parent;
|
|
394
|
+
if (o.x !== 0 || o.y !== 0) {
|
|
395
|
+
if (typeof rec.x === 'number') rec.x = (rec.x as number) - o.x;
|
|
396
|
+
if (typeof rec.y === 'number') rec.y = (rec.y as number) - o.y;
|
|
397
|
+
if (Array.isArray(rec.points))
|
|
398
|
+
rec.points = (rec.points as number[]).map((v, i) => v - (i % 2 === 0 ? o.x : o.y));
|
|
399
|
+
for (const k of ['start', 'end']) {
|
|
400
|
+
const e = rec[k] as ArrowEnd | undefined;
|
|
401
|
+
if (e && !('el' in e)) rec[k] = { x: e.x - o.x, y: e.y - o.y };
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
// Standalone text: v1 only knows a character estimate — keep the measured box while the text is unchanged.
|
|
405
|
+
const prev = current.get(el.id);
|
|
406
|
+
if (
|
|
407
|
+
el.type === 'text' &&
|
|
408
|
+
prev?.type === 'text' &&
|
|
409
|
+
prev.text === el.text &&
|
|
410
|
+
typeof prev.w === 'number'
|
|
411
|
+
) {
|
|
412
|
+
rec.w = prev.w;
|
|
413
|
+
rec.h = prev.h;
|
|
414
|
+
}
|
|
415
|
+
out.push(rec);
|
|
416
|
+
}
|
|
417
|
+
// Unknown types the v1 UI never showed are carried over untouched.
|
|
418
|
+
for (const el of current.values()) if (!isKnownType(el.type)) out.push(el);
|
|
419
|
+
return validateElements(out).elements;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** Map form of `strokesToElements` for `diffToOps`. */
|
|
423
|
+
export function strokesToElementMap(
|
|
424
|
+
strokes: readonly Stroke[],
|
|
425
|
+
current: ReadonlyMap<string, AnnotationElement>
|
|
426
|
+
): Map<string, AnnotationElement> {
|
|
427
|
+
return new Map(strokesToElements(strokes, current).map((e) => [e.id, e]));
|
|
428
|
+
}
|
|
@@ -74,6 +74,19 @@ function unitsOf(strokes: readonly Stroke[], ids: readonly string[]): AlignUnit[
|
|
|
74
74
|
return units;
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
/** A unit moves with everything inside it (its contents follow its delta). */
|
|
78
|
+
function setUnitDelta(
|
|
79
|
+
deltas: Map<string, readonly [number, number]>,
|
|
80
|
+
u: AlignUnit,
|
|
81
|
+
d: readonly [number, number],
|
|
82
|
+
contents?: (id: string) => readonly string[]
|
|
83
|
+
): void {
|
|
84
|
+
for (const id of u.ids) {
|
|
85
|
+
deltas.set(id, d);
|
|
86
|
+
if (contents) for (const c of contents(id)) if (!deltas.has(c)) deltas.set(c, d);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
77
90
|
function applyDeltas(
|
|
78
91
|
strokes: readonly Stroke[],
|
|
79
92
|
deltas: Map<string, readonly [number, number]>
|
|
@@ -89,7 +102,9 @@ function applyDeltas(
|
|
|
89
102
|
export function alignStrokes(
|
|
90
103
|
strokes: readonly Stroke[],
|
|
91
104
|
ids: readonly string[],
|
|
92
|
-
edge: AlignEdge
|
|
105
|
+
edge: AlignEdge,
|
|
106
|
+
/** A unit's contents (a section's subtree) — moved by the unit's delta. */
|
|
107
|
+
contents?: (id: string) => readonly string[]
|
|
93
108
|
): Stroke[] {
|
|
94
109
|
const units = unitsOf(strokes, ids);
|
|
95
110
|
if (units.length < 2) return strokes as Stroke[];
|
|
@@ -109,9 +124,7 @@ export function alignStrokes(
|
|
|
109
124
|
else if (edge === 'top') dy = sel.y - u.bbox.y;
|
|
110
125
|
else if (edge === 'v-center') dy = sel.y + sel.h / 2 - (u.bbox.y + u.bbox.h / 2);
|
|
111
126
|
else dy = sel.y + sel.h - (u.bbox.y + u.bbox.h);
|
|
112
|
-
if (dx !== 0 || dy !== 0)
|
|
113
|
-
for (const id of u.ids) deltas.set(id, [dx, dy] as const);
|
|
114
|
-
}
|
|
127
|
+
if (dx !== 0 || dy !== 0) setUnitDelta(deltas, u, [dx, dy], contents);
|
|
115
128
|
}
|
|
116
129
|
return applyDeltas(strokes, deltas);
|
|
117
130
|
}
|
|
@@ -123,7 +136,9 @@ export function alignStrokes(
|
|
|
123
136
|
export function distributeStrokes(
|
|
124
137
|
strokes: readonly Stroke[],
|
|
125
138
|
ids: readonly string[],
|
|
126
|
-
axis: DistributeAxis
|
|
139
|
+
axis: DistributeAxis,
|
|
140
|
+
/** A unit's contents (a section's subtree) — moved by the unit's delta. */
|
|
141
|
+
contents?: (id: string) => readonly string[]
|
|
127
142
|
): Stroke[] {
|
|
128
143
|
const units = unitsOf(strokes, ids);
|
|
129
144
|
if (units.length < 3) return strokes as Stroke[];
|
|
@@ -141,7 +156,7 @@ export function distributeStrokes(
|
|
|
141
156
|
for (const u of sorted) {
|
|
142
157
|
const d = cursor - lead(u);
|
|
143
158
|
if (d !== 0) {
|
|
144
|
-
|
|
159
|
+
setUnitDelta(deltas, u, axis === 'h' ? ([d, 0] as const) : ([0, d] as const), contents);
|
|
145
160
|
}
|
|
146
161
|
cursor += size(u) + gap;
|
|
147
162
|
}
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* endpoint frozen in place (Excalidraw `fixBindingsAfterDeletion`).
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
import { defOf } from './annotations/registry.ts';
|
|
14
15
|
import {
|
|
15
16
|
type ArrowBind,
|
|
16
17
|
type ArrowStroke,
|
|
@@ -60,6 +61,7 @@ export const MAX_TEXT_BIND_H = 480;
|
|
|
60
61
|
* endpoint falls back to the group's geometric bbox and is reported.
|
|
61
62
|
*/
|
|
62
63
|
export function isBindable(s: Stroke): boolean {
|
|
64
|
+
if (s.tool === 'element') return !!defOf(s.el.type)?.caps.bindable;
|
|
63
65
|
if (
|
|
64
66
|
s.tool === 'rect' ||
|
|
65
67
|
s.tool === 'ellipse' ||
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
24
|
+
import { type FormatKey, formatCommands, formatState } from './annotations/ui/editor-channel.ts';
|
|
24
25
|
import type { AlignEdge } from './annotations-align.ts';
|
|
25
26
|
import {
|
|
26
27
|
type ArrowHead,
|
|
@@ -439,9 +440,9 @@ export function AnnotationContextToolbar({
|
|
|
439
440
|
/**
|
|
440
441
|
* FigJam v3 — while an inline text editor is open, the toolbar flips into
|
|
441
442
|
* TEXT mode: size / B / I / S / U / align controls that drive the EDITOR
|
|
442
|
-
* through
|
|
443
|
-
*
|
|
444
|
-
*
|
|
443
|
+
* through editor-channel.ts (mutating the stroke mid-edit would re-render
|
|
444
|
+
* the editor and clobber typed text). The editor echoes its live state back
|
|
445
|
+
* on the same channel.
|
|
445
446
|
*/
|
|
446
447
|
editingId?: string | null;
|
|
447
448
|
} = {}) {
|
|
@@ -461,14 +462,9 @@ export function AnnotationContextToolbar({
|
|
|
461
462
|
align?: string;
|
|
462
463
|
}>({});
|
|
463
464
|
useEffect(() => {
|
|
464
|
-
if (!editingId
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
};
|
|
468
|
-
document.addEventListener('maude:editor-format-state', onState);
|
|
469
|
-
// Ask the already-mounted editor for its current state (mount-order race).
|
|
470
|
-
document.dispatchEvent(new CustomEvent('maude:editor-format-request'));
|
|
471
|
-
return () => document.removeEventListener('maude:editor-format-state', onState);
|
|
465
|
+
if (!editingId) return;
|
|
466
|
+
// Called at once with the editor's current state (it may have mounted first).
|
|
467
|
+
return formatState.listen((s) => setEditorFmt(s ?? {}));
|
|
472
468
|
}, [editingId]);
|
|
473
469
|
// T30 / G_S1 — collapsed Stroke|Fill toggle state. Defaults to 'stroke';
|
|
474
470
|
// auto-reverts to 'stroke' whenever caps.fill is false so the toggle
|
|
@@ -766,8 +762,8 @@ export function AnnotationContextToolbar({
|
|
|
766
762
|
// FigJam v3 — TEXT mode while an inline editor is open: a focused strip of
|
|
767
763
|
// text controls dispatching to the editor (Image-6 FigJam parity).
|
|
768
764
|
if (editingId) {
|
|
769
|
-
const dispatchFmt = (key:
|
|
770
|
-
|
|
765
|
+
const dispatchFmt = (key: FormatKey, value?: unknown) => {
|
|
766
|
+
formatCommands.send({ key, value });
|
|
771
767
|
};
|
|
772
768
|
const fmtBtn = (
|
|
773
769
|
key: 'bold' | 'italic' | 'strike' | 'underline',
|
|
@@ -144,6 +144,8 @@ export interface DuplicateResult {
|
|
|
144
144
|
/** Ids of the selectable clones (anchored-text clones excluded — they're
|
|
145
145
|
* selected through their host, FigJam semantics). */
|
|
146
146
|
newIds: string[];
|
|
147
|
+
/** Original id → its clone's id. */
|
|
148
|
+
idMap: Map<string, string>;
|
|
147
149
|
}
|
|
148
150
|
|
|
149
151
|
/**
|
|
@@ -208,6 +210,7 @@ export function duplicateStrokes(
|
|
|
208
210
|
return {
|
|
209
211
|
strokes: [...strokes, ...clones],
|
|
210
212
|
newIds: clones.filter((c) => !(c.tool === 'text' && c.anchorId)).map((c) => c.id),
|
|
213
|
+
idMap,
|
|
211
214
|
};
|
|
212
215
|
}
|
|
213
216
|
|