@1agh/maude 1.4.6 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/studio/annotations/ai-read.ts +288 -0
- package/apps/studio/annotations/ai-write.ts +533 -0
- package/apps/studio/annotations/board-io.ts +94 -0
- package/apps/studio/annotations/board-text.ts +45 -0
- package/apps/studio/annotations/constants.ts +58 -0
- package/apps/studio/annotations/elements/_shared.ts +125 -0
- package/apps/studio/annotations/elements/arrow.model.ts +196 -0
- package/apps/studio/annotations/elements/media.model.ts +79 -0
- package/apps/studio/annotations/elements/pen.model.ts +82 -0
- package/apps/studio/annotations/elements/section.model.ts +70 -0
- package/apps/studio/annotations/elements/shape.model.ts +141 -0
- package/apps/studio/annotations/elements/sticky.model.ts +48 -0
- package/apps/studio/annotations/elements/text.model.ts +52 -0
- package/apps/studio/annotations/fields.ts +347 -0
- package/apps/studio/annotations/fractional-index.ts +234 -0
- package/apps/studio/annotations/legacy/mini-dom.ts +207 -0
- package/apps/studio/annotations/migrate-boot.ts +172 -0
- package/apps/studio/annotations/migrate-cli.ts +37 -0
- package/apps/studio/annotations/migrate-v1.ts +383 -0
- package/apps/studio/annotations/ops.ts +474 -0
- package/apps/studio/annotations/registry.ts +159 -0
- package/apps/studio/annotations/replica.ts +264 -0
- package/apps/studio/annotations/scene.ts +235 -0
- package/apps/studio/annotations/schema.ts +188 -0
- package/apps/studio/annotations/types.ts +77 -0
- package/apps/studio/annotations/ui/board.ts +126 -0
- package/apps/studio/annotations/ui/containment.ts +118 -0
- package/apps/studio/annotations/ui/edit-actions.ts +551 -0
- package/apps/studio/annotations/ui/editor-channel.ts +62 -0
- package/apps/studio/annotations/ui/element-node.tsx +993 -0
- package/apps/studio/annotations/ui/pipeline-context.ts +17 -0
- package/apps/studio/annotations/ui/pointer-pipeline.ts +150 -0
- package/apps/studio/annotations/ui/render-model.ts +264 -0
- package/apps/studio/annotations/ui/scene.tsx +63 -0
- package/apps/studio/annotations/ui/text-editor.tsx +420 -0
- package/apps/studio/annotations/ui/text-session.ts +140 -0
- package/apps/studio/annotations/ui/text-style.ts +111 -0
- package/apps/studio/annotations/ui/world.ts +47 -0
- package/apps/studio/annotations/v1-adapter.ts +428 -0
- package/apps/studio/annotations-align.ts +21 -6
- package/apps/studio/annotations-bindings.ts +2 -0
- package/apps/studio/annotations-context-toolbar.tsx +9 -13
- package/apps/studio/annotations-groups.ts +3 -0
- package/apps/studio/annotations-layer.tsx +923 -1925
- package/apps/studio/annotations-model.ts +65 -4
- package/apps/studio/annotations-sync.ts +4 -47
- package/apps/studio/api.ts +248 -69
- package/apps/studio/bin/_import-figma.mjs +22 -12
- package/apps/studio/bin/annotate.mjs +331 -838
- package/apps/studio/bin/annotate.sh +4 -4
- package/apps/studio/bin/perf.sh +21 -7
- package/apps/studio/bin/read-annotations.mjs +184 -666
- package/apps/studio/bin/read-annotations.sh +9 -5
- package/apps/studio/canvas-artifacts.ts +9 -0
- package/apps/studio/canvas-lib.tsx +13 -1
- package/apps/studio/canvas-shell.tsx +6 -0
- package/apps/studio/client/app.jsx +98 -35
- package/apps/studio/client/hmr.mjs +1 -1
- package/apps/studio/client/panels/git-grouping.js +2 -2
- package/apps/studio/client/tree-expansion.js +217 -0
- package/apps/studio/collab/index.ts +49 -5
- package/apps/studio/collab/persistence.ts +31 -10
- package/apps/studio/collab/registry.ts +63 -26
- package/apps/studio/commands/annotation-ops-command.ts +72 -0
- package/apps/studio/cursors-overlay.tsx +158 -4
- package/apps/studio/dist/client.bundle.js +850 -850
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/figma/to-strokes.ts +31 -10
- package/apps/studio/git/endpoints.ts +1 -1
- package/apps/studio/git/service.ts +1 -1
- package/apps/studio/git/watch.ts +1 -1
- package/apps/studio/http.ts +90 -15
- package/apps/studio/server.ts +16 -0
- package/apps/studio/sync/accepted-cold-start.ts +12 -4
- package/apps/studio/sync/agent.ts +8 -4
- package/apps/studio/sync/codec.ts +95 -40
- package/apps/studio/sync/file-membership.ts +12 -2
- package/apps/studio/sync/file-plane.ts +1 -1
- package/apps/studio/sync/index.ts +38 -16
- package/apps/studio/sync/journal-client.ts +5 -0
- package/apps/studio/sync/limits.ts +7 -2
- package/apps/studio/sync/migrate-seed.ts +8 -5
- package/apps/studio/sync/projection.ts +7 -2
- package/apps/studio/sync/remote-docs.ts +34 -0
- package/apps/studio/sync/writer-registry.ts +10 -0
- package/apps/studio/text-caret.ts +11 -2
- package/apps/studio/tree-state.ts +45 -0
- package/apps/studio/undo-stack.ts +2 -2
- package/apps/studio/use-annotation-resize.tsx +48 -23
- package/apps/studio/use-annotation-selection.tsx +9 -2
- package/apps/studio/use-collab.tsx +76 -0
- package/apps/studio/whats-new.json +27 -0
- package/cli/lib/design-link.mjs +5 -1
- package/cli/lib/gitignore-block.mjs +1 -1
- package/cli/lib/gitignore-drift.mjs +2 -1
- package/package.json +9 -8
- package/plugins/design/templates/brief-board.tsx.template +1 -1
- package/apps/studio/annotation-edit-base.ts +0 -36
- package/apps/studio/commands/annotation-strokes-command.ts +0 -137
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
import { readFileSync, statSync } from 'node:fs';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
|
|
7
|
+
import { canonicalAnnotations } from '../annotations/board-text.ts';
|
|
8
|
+
import { diffToOps } from '../annotations/ops.ts';
|
|
9
|
+
import { parseBoard } from '../annotations/schema.ts';
|
|
7
10
|
import type { Api } from '../api.ts';
|
|
8
11
|
import type { Context } from '../context.ts';
|
|
9
12
|
|
|
@@ -59,10 +62,27 @@ export function createCollab(ctx: Context, api: Api): Collab {
|
|
|
59
62
|
// (the predicate is only consulted at room-seed time, after `registry` is
|
|
60
63
|
// assigned). Flag-OFF → predicate returns true → seed unchanged.
|
|
61
64
|
let registryRef: Registry | null = null;
|
|
65
|
+
// Board texts this process's rooms projected to disk and whose watcher echo
|
|
66
|
+
// hasn't come back yet (a few per slug: flushes can outrun the watcher).
|
|
67
|
+
const ownProjections = new Map<string, string[]>();
|
|
68
|
+
// The board as disk last held it while the room agreed with it (the room's
|
|
69
|
+
// own projection, or an external write it imported). An external write is
|
|
70
|
+
// applied as the CHANGE from this base, never as a replacement: a room edit
|
|
71
|
+
// not yet flushed (a delete, a move) survives a writer that didn't know it.
|
|
72
|
+
const diskBase = new Map<string, string>();
|
|
62
73
|
const persistence = createPersistence({
|
|
63
74
|
ctx,
|
|
64
75
|
api,
|
|
65
76
|
fileForSlug,
|
|
77
|
+
onAnnotationsProjected: (slug, board) => {
|
|
78
|
+
const list = ownProjections.get(slug) ?? [];
|
|
79
|
+
list.push(board);
|
|
80
|
+
ownProjections.set(slug, list.slice(-4));
|
|
81
|
+
diskBase.set(slug, board);
|
|
82
|
+
},
|
|
83
|
+
onAnnotationsSeeded: (slug, board) => {
|
|
84
|
+
diskBase.set(slug, board);
|
|
85
|
+
},
|
|
66
86
|
shouldSeed: (slug) => !(ctx.sharedDoc && registryRef?.isPinned(slug)),
|
|
67
87
|
// Issue #133 — record comment ids as synced only once the hub holds them,
|
|
68
88
|
// and only for the room that IS the hub's doc (pinned). No hub linked:
|
|
@@ -88,9 +108,9 @@ export function createCollab(ctx: Context, api: Api): Collab {
|
|
|
88
108
|
};
|
|
89
109
|
const file = await fileForSlug(slug);
|
|
90
110
|
if (!file) return;
|
|
91
|
-
if (newerThanCache(path.join(ctx.paths.designRoot, `${slug}.annotations.
|
|
92
|
-
const
|
|
93
|
-
if (
|
|
111
|
+
if (newerThanCache(path.join(ctx.paths.designRoot, `${slug}.annotations.json`))) {
|
|
112
|
+
const board = await api.loadAnnotations(file);
|
|
113
|
+
if (board) applyAnnotationsToDoc(doc, board, 'seed');
|
|
94
114
|
}
|
|
95
115
|
if (newerThanCache(path.join(ctx.paths.commentsDir, `${slug}.json`))) {
|
|
96
116
|
applyCommentsToDoc(doc, await api.loadCommentsForFile(file), 'seed');
|
|
@@ -124,7 +144,9 @@ export function createCollab(ctx: Context, api: Api): Collab {
|
|
|
124
144
|
registry.peek(slug) !== null && !registry.isPinned(slug);
|
|
125
145
|
const reseedFromDisk = async (rel: string): Promise<void> => {
|
|
126
146
|
const cm = /^_comments\/(.+)\.json$/.exec(rel);
|
|
127
|
-
|
|
147
|
+
// DDR-242 — the board file. A legacy `.annotations.svg` reappearing on disk
|
|
148
|
+
// is NOT a board write (the boot migration quarantines it).
|
|
149
|
+
const am = /^(.+)\.annotations\.json$/.exec(rel);
|
|
128
150
|
const slug = cm?.[1] ?? am?.[1];
|
|
129
151
|
if (!slug) return;
|
|
130
152
|
const abs = path.join(ctx.paths.designRoot, rel);
|
|
@@ -149,7 +171,29 @@ export function createCollab(ctx: Context, api: Api): Collab {
|
|
|
149
171
|
if (ownsRoomFromDisk(slug)) registry.syncRoomFromComments(slug, parsed);
|
|
150
172
|
if (file) ctx.bus.emit('comments', { file, comments: parsed });
|
|
151
173
|
} else if (ownsRoomFromDisk(slug)) {
|
|
152
|
-
|
|
174
|
+
const text = readFileSync(abs, 'utf8');
|
|
175
|
+
// The room's own projection coming back: the room is at or AHEAD of
|
|
176
|
+
// it (an op may have landed since), so re-seeding from it would revert
|
|
177
|
+
// that op. Consumed once, so a later external write of the same bytes
|
|
178
|
+
// still re-seeds.
|
|
179
|
+
const own = ownProjections.get(slug);
|
|
180
|
+
const at = own ? own.indexOf(canonicalAnnotations(text) ?? '') : -1;
|
|
181
|
+
if (own && at >= 0) {
|
|
182
|
+
own.splice(0, at + 1);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
const next = canonicalAnnotations(text);
|
|
186
|
+
const base = diskBase.get(slug);
|
|
187
|
+
if (next !== null && base !== undefined) {
|
|
188
|
+
const toMap = (t: string) => new Map(parseBoard(t).elements.map((e) => [e.id, e]));
|
|
189
|
+
const ops = diffToOps(toMap(base), toMap(next));
|
|
190
|
+
diskBase.set(slug, next);
|
|
191
|
+
if (!ops.length) return;
|
|
192
|
+
const applied = registry.applyOpsToRoom(slug, ops);
|
|
193
|
+
if (applied !== null && applied !== 'too-large') return;
|
|
194
|
+
}
|
|
195
|
+
if (next !== null) diskBase.set(slug, next);
|
|
196
|
+
registry.syncRoomFromAnnotations(slug, text);
|
|
153
197
|
}
|
|
154
198
|
} catch {
|
|
155
199
|
/* file vanished mid-flight or unreadable — leave state as-is */
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
// DDR-051 persistence wiring — bridges Room callbacks to the existing JSON
|
|
2
|
-
// snapshots (Phase 6 _comments/<slug>.json) +
|
|
2
|
+
// snapshots (Phase 6 _comments/<slug>.json) + the annotations board (DDR-242) + the
|
|
3
3
|
// new `.ydoc.bin` cache under _state/<slug>.ydoc.bin.
|
|
4
4
|
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
|
|
7
7
|
import * as Y from 'yjs';
|
|
8
8
|
|
|
9
|
+
import { noteAnnotationsOnDisk, replicaBoardText, writeReplica } from '../annotations/replica.ts';
|
|
10
|
+
import { parseBoard } from '../annotations/schema.ts';
|
|
9
11
|
import type { Api } from '../api.ts';
|
|
10
12
|
import type { Context } from '../context.ts';
|
|
11
13
|
// From the LEAF, never from `sync/codec.ts` — codec imports `Y_TYPES` from this
|
|
@@ -23,6 +25,8 @@ import { ensureStateDir, type RoomCallbacks } from './room.ts';
|
|
|
23
25
|
*/
|
|
24
26
|
export const Y_TYPES = {
|
|
25
27
|
comments: 'comments',
|
|
28
|
+
/** v1 annotations map (`svg` key) — read only for lazy migration; the v2
|
|
29
|
+
* replica lives in 'annotations2' (annotations/replica.ts, DDR-242). */
|
|
26
30
|
annotations: 'annotations',
|
|
27
31
|
presentation: 'presentation',
|
|
28
32
|
} as const;
|
|
@@ -61,6 +65,16 @@ export interface PersistenceDeps {
|
|
|
61
65
|
* seed). Absent → cache-only restore, the previous behavior.
|
|
62
66
|
*/
|
|
63
67
|
reconcileAfterCache?: (slug: string, doc: Y.Doc, cachedAtMs: number) => Promise<void>;
|
|
68
|
+
/**
|
|
69
|
+
* Called with the board text a flush just projected to
|
|
70
|
+
* `<slug>.annotations.json`. The disk→room re-seed uses it to recognise the
|
|
71
|
+
* room's OWN write coming back through the file watcher: by then the room
|
|
72
|
+
* may be ahead (a later op), and re-seeding it from that echo reverted the
|
|
73
|
+
* later edit.
|
|
74
|
+
*/
|
|
75
|
+
onAnnotationsProjected?: (slug: string, board: string) => void;
|
|
76
|
+
/** The board a room was seeded from — the disk state it starts out agreeing with. */
|
|
77
|
+
onAnnotationsSeeded?: (slug: string, board: string) => void;
|
|
64
78
|
/**
|
|
65
79
|
* Issue #133 — which comment ids this machine synced before (see
|
|
66
80
|
* sync/comment-ledger.ts). Defaults to the process-wide ledger for the design
|
|
@@ -286,10 +300,12 @@ export function createPersistence(deps: PersistenceDeps): RoomCallbacks {
|
|
|
286
300
|
if (missing.length > 0) arr.push(missing);
|
|
287
301
|
}
|
|
288
302
|
if (svg && typeof svg === 'string') {
|
|
289
|
-
|
|
290
|
-
|
|
303
|
+
// DDR-242 — `loadAnnotations` returns canonical board text (a legacy
|
|
304
|
+
// sidecar arrives already migrated); the replica takes it per element.
|
|
305
|
+
writeReplica(doc, parseBoard(svg).elements, 'seed');
|
|
291
306
|
}
|
|
292
307
|
}, 'seed');
|
|
308
|
+
if (svg && typeof svg === 'string') deps.onAnnotationsSeeded?.(slug, svg);
|
|
293
309
|
}
|
|
294
310
|
|
|
295
311
|
async function persistJson(slug: string, doc: Y.Doc): Promise<void> {
|
|
@@ -339,16 +355,21 @@ export function createPersistence(deps: PersistenceDeps): RoomCallbacks {
|
|
|
339
355
|
}
|
|
340
356
|
}
|
|
341
357
|
|
|
342
|
-
// Annotations —
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
358
|
+
// Annotations — the replica → `<slug>.annotations.json` (DDR-242). A doc
|
|
359
|
+
// that was never populated (null) writes nothing, so a cold room can't
|
|
360
|
+
// clobber the file with emptiness (DDR-223).
|
|
361
|
+
const board = replicaBoardText(doc);
|
|
362
|
+
if (board !== null) {
|
|
363
|
+
if (withinCap(slug, 'annotations', board, MAX_ANNOTATIONS_BYTES)) {
|
|
347
364
|
// Projection must never re-enter onAnnotationsChanged: an old flush
|
|
348
|
-
// finishing after a new edit otherwise republishes the old
|
|
365
|
+
// finishing after a new edit otherwise republishes the old board and
|
|
349
366
|
// rolls back every peer. The API checks freshness after async IO and
|
|
350
367
|
// before its atomic rename; a later doc update schedules a new flush.
|
|
351
|
-
|
|
368
|
+
// Recorded BEFORE the write: the watcher event may be delivered before
|
|
369
|
+
// this await resumes.
|
|
370
|
+
deps.onAnnotationsProjected?.(slug, board);
|
|
371
|
+
noteAnnotationsOnDisk(doc, board);
|
|
372
|
+
await api.projectAnnotations(file, board, () => replicaBoardText(doc) === board);
|
|
352
373
|
}
|
|
353
374
|
}
|
|
354
375
|
}
|
|
@@ -9,10 +9,13 @@
|
|
|
9
9
|
import type { Awareness } from 'y-protocols/awareness';
|
|
10
10
|
import type * as Y from 'yjs';
|
|
11
11
|
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
12
|
+
import { canonicalAnnotations } from '../annotations/board-text.ts';
|
|
13
|
+
import { type ApplyResult, applyOps, type Op } from '../annotations/ops.ts';
|
|
14
|
+
import { readReplica, validActionId, writeReplica } from '../annotations/replica.ts';
|
|
15
|
+
import { parseBoard, serializeBoard } from '../annotations/schema.ts';
|
|
16
|
+
import { applyCommentsToDoc, stampAnnotationsEdit } from '../sync/codec.ts';
|
|
17
|
+
import { MAX_ANNOTATIONS_BYTES } from '../sync/limits.ts';
|
|
14
18
|
import { bridgeAwareness } from './awareness-bridge.ts';
|
|
15
|
-
import { Y_TYPES } from './persistence.ts';
|
|
16
19
|
import type { Room, RoomCallbacks } from './room.ts';
|
|
17
20
|
import { createRoom } from './room.ts';
|
|
18
21
|
|
|
@@ -49,12 +52,23 @@ export interface Registry {
|
|
|
49
52
|
*/
|
|
50
53
|
syncRoomFromComments(slug: string, comments: readonly unknown[]): void;
|
|
51
54
|
/**
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
* updated stroke set without waiting for a cold-open re-seed.
|
|
55
|
+
* Same shape as syncRoomFromComments but for the annotations replica
|
|
56
|
+
* (DDR-242). Every annotations write passes the post-write board; the room's
|
|
57
|
+
* replica is diffed to it, so collab peers receive only the changed elements.
|
|
56
58
|
*/
|
|
57
|
-
syncRoomFromAnnotations(slug: string,
|
|
59
|
+
syncRoomFromAnnotations(slug: string, text: string, actionId?: string): void;
|
|
60
|
+
/**
|
|
61
|
+
* The canvas op path while a room is live (code review H1): apply the batch
|
|
62
|
+
* to the room's replica — the freshest state, ahead of a debounced flush —
|
|
63
|
+
* instead of to disk, where a peer's not-yet-flushed edit would be reverted.
|
|
64
|
+
* `null` when no room (or a never-populated one) is live; `'too-large'` when
|
|
65
|
+
* the result would exceed the board cap (nothing written).
|
|
66
|
+
*/
|
|
67
|
+
applyOpsToRoom(
|
|
68
|
+
slug: string,
|
|
69
|
+
ops: readonly Op[],
|
|
70
|
+
actionId?: string
|
|
71
|
+
): ApplyResult | 'too-large' | null;
|
|
58
72
|
/**
|
|
59
73
|
* Phase 30 — project agent editing-presence onto a slug's room awareness so
|
|
60
74
|
* it crosses the hub (the loopback `ai-activity` bus event does not). `null`
|
|
@@ -110,27 +124,24 @@ export interface Registry {
|
|
|
110
124
|
}
|
|
111
125
|
|
|
112
126
|
/**
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
127
|
+
* Make a doc's annotations replica hold `text` (canonical board JSON, or a
|
|
128
|
+
* legacy SVG that is converted) — the one write shape shared by the live
|
|
129
|
+
* disk→room bridge and the cache-restore reconcile (`collab/index.ts`). Only the
|
|
130
|
+
* elements / fields that differ become Yjs updates (DDR-242 §5); identical
|
|
131
|
+
* content is a no-op (no update → no persist loop). `actionId` lets the author's
|
|
132
|
+
* canvas recognise its own echo. Returns whether the doc changed.
|
|
118
133
|
*/
|
|
119
134
|
export function applyAnnotationsToDoc(
|
|
120
135
|
doc: Y.Doc,
|
|
121
|
-
|
|
136
|
+
text: string,
|
|
122
137
|
origin: unknown,
|
|
123
|
-
|
|
138
|
+
actionId?: string
|
|
124
139
|
): boolean {
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
if (id) map.set(ANNOTATION_WRITE_ID, id);
|
|
131
|
-
else map.delete(ANNOTATION_WRITE_ID);
|
|
132
|
-
}, origin);
|
|
133
|
-
return true;
|
|
140
|
+
const board = canonicalAnnotations(text);
|
|
141
|
+
if (board === null) return false;
|
|
142
|
+
return writeReplica(doc, parseBoard(board).elements, origin, {
|
|
143
|
+
...(validActionId(actionId) ? { actionId } : {}),
|
|
144
|
+
});
|
|
134
145
|
}
|
|
135
146
|
|
|
136
147
|
export function createRegistry(callbacks: RoomCallbacks): Registry {
|
|
@@ -218,10 +229,35 @@ export function createRegistry(callbacks: RoomCallbacks): Registry {
|
|
|
218
229
|
applyCommentsToDoc(room.doc, comments as unknown[], 'inspector-write');
|
|
219
230
|
}
|
|
220
231
|
|
|
221
|
-
function syncRoomFromAnnotations(slug: string,
|
|
232
|
+
function syncRoomFromAnnotations(slug: string, text: string, actionId?: string): void {
|
|
222
233
|
const room = rooms.get(slug);
|
|
223
234
|
if (!room) return;
|
|
224
|
-
applyAnnotationsToDoc(room.doc,
|
|
235
|
+
applyAnnotationsToDoc(room.doc, text, 'inspector-write', actionId);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function applyOpsToRoom(
|
|
239
|
+
slug: string,
|
|
240
|
+
ops: readonly Op[],
|
|
241
|
+
actionId?: string
|
|
242
|
+
): ApplyResult | 'too-large' | null {
|
|
243
|
+
const room = rooms.get(slug);
|
|
244
|
+
// A replica that was never populated is not the board's truth — the
|
|
245
|
+
// caller takes the disk path instead (DDR-223: no value ≠ empty).
|
|
246
|
+
const cur = room ? readReplica(room.doc) : null;
|
|
247
|
+
if (!room || cur === null) return null;
|
|
248
|
+
const r = applyOps(new Map(cur.elements.map((e) => [e.id, e])), ops);
|
|
249
|
+
if (!r.touched.size) return r;
|
|
250
|
+
const next = [...r.state.values()];
|
|
251
|
+
if (serializeBoard(next).length > MAX_ANNOTATIONS_BYTES) return 'too-large';
|
|
252
|
+
// One transaction with the lane's newest-wins stamp, so peers get ONE
|
|
253
|
+
// update and the room's persistence projects it to disk.
|
|
254
|
+
room.doc.transact(() => {
|
|
255
|
+
writeReplica(room.doc, next, 'inspector-write', {
|
|
256
|
+
...(validActionId(actionId) ? { actionId } : {}),
|
|
257
|
+
});
|
|
258
|
+
stampAnnotationsEdit(room.doc, 'inspector-write');
|
|
259
|
+
}, 'inspector-write');
|
|
260
|
+
return r;
|
|
225
261
|
}
|
|
226
262
|
|
|
227
263
|
function setAgentEditing(slug: string, state: { name: string; since: number } | null): void {
|
|
@@ -275,6 +311,7 @@ export function createRegistry(callbacks: RoomCallbacks): Registry {
|
|
|
275
311
|
getDoc,
|
|
276
312
|
syncRoomFromComments,
|
|
277
313
|
syncRoomFromAnnotations,
|
|
314
|
+
applyOpsToRoom,
|
|
278
315
|
setAgentEditing,
|
|
279
316
|
attachHubAwareness,
|
|
280
317
|
pin,
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file commands/annotation-ops-command.ts — undo entry for an annotation op batch
|
|
3
|
+
* @scope apps/studio/commands/annotation-ops-command.ts
|
|
4
|
+
* @purpose DDR-242 AD4 (Task 26): one user gesture is one op batch and one
|
|
5
|
+
* undo record; undo is the batch's INVERSE (from `applyOps`),
|
|
6
|
+
* which is peer-safe at field granularity — it reverts only the
|
|
7
|
+
* fields this action set and only while they still hold its value.
|
|
8
|
+
* Nothing here replays a snapshot of the whole board.
|
|
9
|
+
*
|
|
10
|
+
* Rebuilt per iframe mount from a serializable record (DDR-050), so
|
|
11
|
+
* the stack survives canvas switches; the side effect (apply
|
|
12
|
+
* locally + send) is the layer's `annotationOpsFn` sink.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { Op } from '../annotations/ops.ts';
|
|
16
|
+
import type { CommandRecord, EditCommand } from '../undo-stack.ts';
|
|
17
|
+
import { registerCommand } from '../undo-stack.ts';
|
|
18
|
+
|
|
19
|
+
export const ANNOTATION_OPS_KIND = 'annotation-ops';
|
|
20
|
+
|
|
21
|
+
export interface AnnotationOpsPayload {
|
|
22
|
+
ops: readonly Op[];
|
|
23
|
+
inverse: readonly Op[];
|
|
24
|
+
/**
|
|
25
|
+
* The canvas the batch belongs to. The stack's records live in origin-wide
|
|
26
|
+
* sessionStorage, so the layer replays only its own canvas's (security
|
|
27
|
+
* review A5 — another canvas must not steer this one's undo).
|
|
28
|
+
*/
|
|
29
|
+
file?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Applies a batch locally and sends it; returns the batch's own inverse. */
|
|
33
|
+
export type AnnotationOpsFn = (ops: readonly Op[], file?: string) => readonly Op[] | void;
|
|
34
|
+
|
|
35
|
+
export function buildAnnotationOpsRecord(opts: {
|
|
36
|
+
ops: readonly Op[];
|
|
37
|
+
inverse: readonly Op[];
|
|
38
|
+
label: string;
|
|
39
|
+
file?: string;
|
|
40
|
+
}): CommandRecord<AnnotationOpsPayload> {
|
|
41
|
+
return {
|
|
42
|
+
kind: ANNOTATION_OPS_KIND,
|
|
43
|
+
label: opts.label,
|
|
44
|
+
payload: {
|
|
45
|
+
ops: structuredClone(opts.ops),
|
|
46
|
+
inverse: structuredClone(opts.inverse),
|
|
47
|
+
...(opts.file ? { file: opts.file } : {}),
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function createAnnotationOpsCommand(
|
|
53
|
+
record: CommandRecord<AnnotationOpsPayload>,
|
|
54
|
+
fn: AnnotationOpsFn
|
|
55
|
+
): EditCommand {
|
|
56
|
+
return {
|
|
57
|
+
kind: ANNOTATION_OPS_KIND,
|
|
58
|
+
label: record.label,
|
|
59
|
+
async do() {
|
|
60
|
+
fn(record.payload.ops, record.payload.file);
|
|
61
|
+
},
|
|
62
|
+
async undo() {
|
|
63
|
+
fn(record.payload.inverse, record.payload.file);
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
registerCommand<AnnotationOpsPayload>(ANNOTATION_OPS_KIND, (record, sinks) => {
|
|
69
|
+
const fn = sinks.annotationOpsFn as AnnotationOpsFn | undefined;
|
|
70
|
+
if (!fn) return null;
|
|
71
|
+
return createAnnotationOpsCommand(record, fn);
|
|
72
|
+
});
|
|
@@ -20,8 +20,14 @@
|
|
|
20
20
|
import type { JSX } from 'react';
|
|
21
21
|
import { memo, useEffect, useState } from 'react';
|
|
22
22
|
|
|
23
|
+
import { REPLICA_TYPE } from './annotations/replica.ts';
|
|
23
24
|
import { useLiveViewport } from './canvas-lib.tsx';
|
|
24
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
type AnnotationGesture,
|
|
27
|
+
type ForeignAwareness,
|
|
28
|
+
useCollab,
|
|
29
|
+
useForeignAwareness,
|
|
30
|
+
} from './use-collab.tsx';
|
|
25
31
|
|
|
26
32
|
const CURSOR_CSS = `
|
|
27
33
|
.dc-cursor-overlay {
|
|
@@ -77,6 +83,23 @@ const CURSOR_CSS = `
|
|
|
77
83
|
box-sizing: border-box;
|
|
78
84
|
will-change: transform, width, height;
|
|
79
85
|
}
|
|
86
|
+
.dc-peer-gesture {
|
|
87
|
+
position: absolute;
|
|
88
|
+
top: 0;
|
|
89
|
+
left: 0;
|
|
90
|
+
pointer-events: none;
|
|
91
|
+
border: 1.5px dashed;
|
|
92
|
+
border-radius: 3px;
|
|
93
|
+
box-sizing: border-box;
|
|
94
|
+
}
|
|
95
|
+
.dc-peer-gesture-ink {
|
|
96
|
+
position: absolute;
|
|
97
|
+
inset: 0;
|
|
98
|
+
width: 100%;
|
|
99
|
+
height: 100%;
|
|
100
|
+
overflow: visible;
|
|
101
|
+
pointer-events: none;
|
|
102
|
+
}
|
|
80
103
|
.dc-peer-selection__label {
|
|
81
104
|
position: absolute;
|
|
82
105
|
top: -18px;
|
|
@@ -269,6 +292,132 @@ export const PeerAnnotationSelection = memo(
|
|
|
269
292
|
sameIds(a.peer.annotationSelection, b.peer.annotationSelection)
|
|
270
293
|
);
|
|
271
294
|
|
|
295
|
+
/**
|
|
296
|
+
* Task 22 (DDR-242 AD5) — a peer's annotation gesture while it is in flight:
|
|
297
|
+
* the elements they drag, the box they resize or draw, the ink of a pen stroke
|
|
298
|
+
* — so peers watch it move instead of seeing it jump at the commit. Awareness
|
|
299
|
+
* only (sanitized in use-collab); nothing here reads or writes the board. A
|
|
300
|
+
* gesture that stops updating (a peer who vanished mid-drag) fades after
|
|
301
|
+
* GESTURE_STALE_MS even before awareness garbage-collects the peer.
|
|
302
|
+
*/
|
|
303
|
+
const GESTURE_STALE_MS = 3000;
|
|
304
|
+
const MAX_GHOSTS = 64;
|
|
305
|
+
|
|
306
|
+
function sameGesture(
|
|
307
|
+
a: AnnotationGesture | null | undefined,
|
|
308
|
+
b: AnnotationGesture | null | undefined
|
|
309
|
+
): boolean {
|
|
310
|
+
if (a === b) return true;
|
|
311
|
+
if (!a || !b) return false;
|
|
312
|
+
return (
|
|
313
|
+
a.kind === b.kind &&
|
|
314
|
+
a.dx === b.dx &&
|
|
315
|
+
a.dy === b.dy &&
|
|
316
|
+
sameIds(a.ids, b.ids) &&
|
|
317
|
+
a.box?.x === b.box?.x &&
|
|
318
|
+
a.box?.y === b.box?.y &&
|
|
319
|
+
a.box?.w === b.box?.w &&
|
|
320
|
+
a.box?.h === b.box?.h &&
|
|
321
|
+
(a.points?.length ?? 0) === (b.points?.length ?? 0) &&
|
|
322
|
+
a.points?.[a.points.length - 1] === b.points?.[b.points.length - 1]
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
interface PeerAnnotationGestureProps {
|
|
327
|
+
peer: ForeignAwareness;
|
|
328
|
+
viewport: ViewportSnapshot;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export const PeerAnnotationGesture = memo(
|
|
332
|
+
function PeerAnnotationGesture({
|
|
333
|
+
peer,
|
|
334
|
+
viewport,
|
|
335
|
+
}: PeerAnnotationGestureProps): JSX.Element | null {
|
|
336
|
+
const g = peer.annotationGesture;
|
|
337
|
+
const [stale, setStale] = useState(false);
|
|
338
|
+
useEffect(() => {
|
|
339
|
+
setStale(false);
|
|
340
|
+
if (!g) return;
|
|
341
|
+
const t = setTimeout(() => setStale(true), GESTURE_STALE_MS);
|
|
342
|
+
return () => clearTimeout(t);
|
|
343
|
+
}, [g]);
|
|
344
|
+
if (!g || stale || typeof document === 'undefined') return null;
|
|
345
|
+
const z = viewport.zoom || 1;
|
|
346
|
+
const toScreen = (x: number, y: number) => [x * z + viewport.x, y * z + viewport.y] as const;
|
|
347
|
+
const boxes: Array<{ key: string; x: number; y: number; w: number; h: number }> = [];
|
|
348
|
+
if (g.kind === 'move' && g.dx !== undefined && g.dy !== undefined) {
|
|
349
|
+
for (const id of g.ids.slice(0, MAX_GHOSTS)) {
|
|
350
|
+
try {
|
|
351
|
+
const el = document.querySelector(`.dc-annot-scene [data-id="${CSS.escape(id)}"]`);
|
|
352
|
+
if (!el) continue;
|
|
353
|
+
const r = el.getBoundingClientRect();
|
|
354
|
+
if (r.width <= 0 && r.height <= 0) continue;
|
|
355
|
+
boxes.push({
|
|
356
|
+
key: id,
|
|
357
|
+
x: r.left + g.dx * z,
|
|
358
|
+
y: r.top + g.dy * z,
|
|
359
|
+
w: r.width,
|
|
360
|
+
h: r.height,
|
|
361
|
+
});
|
|
362
|
+
} catch {
|
|
363
|
+
/* invalid id token — skip */
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
} else if (g.box) {
|
|
367
|
+
const [x, y] = toScreen(g.box.x, g.box.y);
|
|
368
|
+
boxes.push({ key: 'box', x, y, w: g.box.w * z, h: g.box.h * z });
|
|
369
|
+
}
|
|
370
|
+
let ink: string | null = null;
|
|
371
|
+
if (g.kind === 'draw' && g.points && g.points.length >= 4) {
|
|
372
|
+
const pts: string[] = [];
|
|
373
|
+
for (let i = 0; i + 1 < g.points.length; i += 2) {
|
|
374
|
+
const [sx, sy] = toScreen(g.points[i] as number, g.points[i + 1] as number);
|
|
375
|
+
pts.push(`${sx},${sy}`);
|
|
376
|
+
}
|
|
377
|
+
ink = pts.join(' ');
|
|
378
|
+
}
|
|
379
|
+
if (!boxes.length && !ink) return null;
|
|
380
|
+
return (
|
|
381
|
+
<>
|
|
382
|
+
{boxes.map((b, i) => (
|
|
383
|
+
<div
|
|
384
|
+
key={`g-${b.key}`}
|
|
385
|
+
className="dc-peer-gesture"
|
|
386
|
+
data-peer-gesture={g.kind}
|
|
387
|
+
style={{
|
|
388
|
+
transform: `translate(${b.x}px, ${b.y}px)`,
|
|
389
|
+
width: b.w,
|
|
390
|
+
height: b.h,
|
|
391
|
+
borderColor: peer.color,
|
|
392
|
+
}}
|
|
393
|
+
>
|
|
394
|
+
{i === 0 && (
|
|
395
|
+
<div className="dc-peer-selection__label" style={{ background: peer.color }}>
|
|
396
|
+
{peer.name}
|
|
397
|
+
</div>
|
|
398
|
+
)}
|
|
399
|
+
</div>
|
|
400
|
+
))}
|
|
401
|
+
{ink ? (
|
|
402
|
+
<svg className="dc-peer-gesture-ink" data-peer-gesture="draw" aria-hidden="true">
|
|
403
|
+
<polyline
|
|
404
|
+
points={ink}
|
|
405
|
+
fill="none"
|
|
406
|
+
stroke={peer.color}
|
|
407
|
+
strokeWidth={2}
|
|
408
|
+
strokeOpacity={0.7}
|
|
409
|
+
/>
|
|
410
|
+
</svg>
|
|
411
|
+
) : null}
|
|
412
|
+
</>
|
|
413
|
+
);
|
|
414
|
+
},
|
|
415
|
+
(a, b) =>
|
|
416
|
+
sameViewport(a.viewport, b.viewport) &&
|
|
417
|
+
sameLabel(a.peer, b.peer) &&
|
|
418
|
+
sameGesture(a.peer.annotationGesture, b.peer.annotationGesture)
|
|
419
|
+
);
|
|
420
|
+
|
|
272
421
|
/**
|
|
273
422
|
* Foreign-selection halo for canvas-shell elements (cdId-based selSet).
|
|
274
423
|
* The peer publishes selection.cssPath; we re-resolve in the local DOM
|
|
@@ -355,12 +504,14 @@ export function CursorsOverlay(): JSX.Element {
|
|
|
355
504
|
const [tick, bumpTick] = useState(0);
|
|
356
505
|
useEffect(() => {
|
|
357
506
|
if (!collab) return;
|
|
358
|
-
|
|
507
|
+
// The v2 replica (DDR-242): element records are nested maps, so a field
|
|
508
|
+
// edit only reaches a deep observer.
|
|
509
|
+
const map = collab.doc.getMap(REPLICA_TYPE);
|
|
359
510
|
const onChange = () => bumpTick((n) => n + 1);
|
|
360
|
-
map.
|
|
511
|
+
map.observeDeep(onChange);
|
|
361
512
|
return () => {
|
|
362
513
|
try {
|
|
363
|
-
map.
|
|
514
|
+
map.unobserveDeep(onChange);
|
|
364
515
|
} catch {
|
|
365
516
|
/* doc destroyed */
|
|
366
517
|
}
|
|
@@ -390,6 +541,9 @@ export function CursorsOverlay(): JSX.Element {
|
|
|
390
541
|
tick={tick}
|
|
391
542
|
/>
|
|
392
543
|
))}
|
|
544
|
+
{peers.map((peer) => (
|
|
545
|
+
<PeerAnnotationGesture key={`gest-${peer.clientID}`} peer={peer} viewport={vp} />
|
|
546
|
+
))}
|
|
393
547
|
{peers.map((peer) => (
|
|
394
548
|
<Cursor key={peer.clientID} peer={peer} viewport={vp} />
|
|
395
549
|
))}
|