@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.
Files changed (100) hide show
  1. package/apps/studio/annotations/ai-read.ts +288 -0
  2. package/apps/studio/annotations/ai-write.ts +534 -0
  3. package/apps/studio/annotations/board-io.ts +94 -0
  4. package/apps/studio/annotations/board-text.ts +45 -0
  5. package/apps/studio/annotations/constants.ts +58 -0
  6. package/apps/studio/annotations/elements/_shared.ts +125 -0
  7. package/apps/studio/annotations/elements/arrow.model.ts +196 -0
  8. package/apps/studio/annotations/elements/media.model.ts +79 -0
  9. package/apps/studio/annotations/elements/pen.model.ts +82 -0
  10. package/apps/studio/annotations/elements/section.model.ts +70 -0
  11. package/apps/studio/annotations/elements/shape.model.ts +141 -0
  12. package/apps/studio/annotations/elements/sticky.model.ts +48 -0
  13. package/apps/studio/annotations/elements/text.model.ts +52 -0
  14. package/apps/studio/annotations/fields.ts +347 -0
  15. package/apps/studio/annotations/fractional-index.ts +234 -0
  16. package/apps/studio/annotations/legacy/mini-dom.ts +207 -0
  17. package/apps/studio/annotations/migrate-boot.ts +172 -0
  18. package/apps/studio/annotations/migrate-cli.ts +37 -0
  19. package/apps/studio/annotations/migrate-v1.ts +383 -0
  20. package/apps/studio/annotations/ops-merge.ts +15 -0
  21. package/apps/studio/annotations/ops.ts +499 -0
  22. package/apps/studio/annotations/registry.ts +159 -0
  23. package/apps/studio/annotations/replica.ts +264 -0
  24. package/apps/studio/annotations/scene.ts +235 -0
  25. package/apps/studio/annotations/schema.ts +188 -0
  26. package/apps/studio/annotations/types.ts +77 -0
  27. package/apps/studio/annotations/ui/board.ts +126 -0
  28. package/apps/studio/annotations/ui/containment.ts +118 -0
  29. package/apps/studio/annotations/ui/edit-actions.ts +551 -0
  30. package/apps/studio/annotations/ui/editor-channel.ts +62 -0
  31. package/apps/studio/annotations/ui/element-node.tsx +993 -0
  32. package/apps/studio/annotations/ui/pipeline-context.ts +17 -0
  33. package/apps/studio/annotations/ui/pointer-pipeline.ts +150 -0
  34. package/apps/studio/annotations/ui/render-model.ts +264 -0
  35. package/apps/studio/annotations/ui/scene.tsx +63 -0
  36. package/apps/studio/annotations/ui/text-editor.tsx +420 -0
  37. package/apps/studio/annotations/ui/text-session.ts +140 -0
  38. package/apps/studio/annotations/ui/text-style.ts +111 -0
  39. package/apps/studio/annotations/ui/world.ts +47 -0
  40. package/apps/studio/annotations/v1-adapter.ts +428 -0
  41. package/apps/studio/annotations-align.ts +21 -6
  42. package/apps/studio/annotations-bindings.ts +2 -0
  43. package/apps/studio/annotations-context-toolbar.tsx +9 -13
  44. package/apps/studio/annotations-groups.ts +3 -0
  45. package/apps/studio/annotations-layer.tsx +923 -1925
  46. package/apps/studio/annotations-model.ts +65 -4
  47. package/apps/studio/annotations-sync.ts +4 -47
  48. package/apps/studio/api.ts +249 -69
  49. package/apps/studio/bin/_import-figma.mjs +22 -12
  50. package/apps/studio/bin/annotate.mjs +331 -838
  51. package/apps/studio/bin/annotate.sh +4 -4
  52. package/apps/studio/bin/perf.sh +21 -7
  53. package/apps/studio/bin/read-annotations.mjs +184 -666
  54. package/apps/studio/bin/read-annotations.sh +9 -5
  55. package/apps/studio/canvas-artifacts.ts +9 -0
  56. package/apps/studio/canvas-lib.tsx +13 -1
  57. package/apps/studio/canvas-shell.tsx +6 -0
  58. package/apps/studio/client/app.jsx +98 -35
  59. package/apps/studio/client/hmr.mjs +1 -1
  60. package/apps/studio/client/panels/git-grouping.js +2 -2
  61. package/apps/studio/client/tree-expansion.js +217 -0
  62. package/apps/studio/collab/index.ts +49 -5
  63. package/apps/studio/collab/persistence.ts +31 -10
  64. package/apps/studio/collab/registry.ts +64 -26
  65. package/apps/studio/commands/annotation-ops-command.ts +72 -0
  66. package/apps/studio/cursors-overlay.tsx +158 -4
  67. package/apps/studio/dist/client.bundle.js +850 -850
  68. package/apps/studio/dist/comment-mount.js +2 -2
  69. package/apps/studio/figma/to-strokes.ts +31 -10
  70. package/apps/studio/git/endpoints.ts +1 -1
  71. package/apps/studio/git/service.ts +1 -1
  72. package/apps/studio/git/watch.ts +1 -1
  73. package/apps/studio/http.ts +90 -15
  74. package/apps/studio/server.ts +16 -0
  75. package/apps/studio/sync/accepted-cold-start.ts +12 -4
  76. package/apps/studio/sync/agent.ts +8 -4
  77. package/apps/studio/sync/codec.ts +96 -40
  78. package/apps/studio/sync/file-membership.ts +12 -2
  79. package/apps/studio/sync/file-plane.ts +1 -1
  80. package/apps/studio/sync/index.ts +38 -16
  81. package/apps/studio/sync/journal-client.ts +5 -0
  82. package/apps/studio/sync/limits.ts +7 -2
  83. package/apps/studio/sync/migrate-seed.ts +8 -5
  84. package/apps/studio/sync/projection.ts +7 -2
  85. package/apps/studio/sync/remote-docs.ts +34 -0
  86. package/apps/studio/sync/writer-registry.ts +10 -0
  87. package/apps/studio/text-caret.ts +11 -2
  88. package/apps/studio/tree-state.ts +45 -0
  89. package/apps/studio/undo-stack.ts +2 -2
  90. package/apps/studio/use-annotation-resize.tsx +48 -23
  91. package/apps/studio/use-annotation-selection.tsx +9 -2
  92. package/apps/studio/use-collab.tsx +76 -0
  93. package/apps/studio/whats-new.json +27 -0
  94. package/cli/lib/design-link.mjs +5 -1
  95. package/cli/lib/gitignore-block.mjs +1 -1
  96. package/cli/lib/gitignore-drift.mjs +2 -1
  97. package/package.json +9 -8
  98. package/plugins/design/templates/brief-board.tsx.template +1 -1
  99. package/apps/studio/annotation-edit-base.ts +0 -36
  100. 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.svg`))) {
92
- const svg = await api.loadAnnotations(file);
93
- if (svg) applyAnnotationsToDoc(doc, svg, 'seed');
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
- const am = /^(.+)\.annotations\.svg$/.exec(rel);
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
- registry.syncRoomFromAnnotations(slug, readFileSync(abs, 'utf8'));
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) + Phase 5 annotations.svg + the
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
- const map = doc.getMap<string>(Y_TYPES.annotations);
290
- map.set('svg', svg);
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 — Y.Map.svg → annotations.svg file. Task 5.
343
- const map = doc.getMap<unknown>(Y_TYPES.annotations);
344
- const svg = map.get('svg');
345
- if (typeof svg === 'string' && svg) {
346
- if (withinCap(slug, 'annotations', svg, MAX_ANNOTATIONS_BYTES)) {
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 SVG and
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
- await api.projectAnnotations(file, svg, () => map.get('svg') === svg);
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,14 @@
9
9
  import type { Awareness } from 'y-protocols/awareness';
10
10
  import type * as Y from 'yjs';
11
11
 
12
- import { ANNOTATION_WRITE_ID, validAnnotationWriteId } from '../annotations-sync.ts';
13
- import { applyCommentsToDoc } from '../sync/codec.ts';
12
+ import { canonicalAnnotations } from '../annotations/board-text.ts';
13
+ import { type ApplyResult, applyOps, type Op } from '../annotations/ops.ts';
14
+ import '../annotations/ops-merge.ts';
15
+ import { readReplica, validActionId, writeReplica } from '../annotations/replica.ts';
16
+ import { parseBoard, serializeBoard } from '../annotations/schema.ts';
17
+ import { applyCommentsToDoc, stampAnnotationsEdit } from '../sync/codec.ts';
18
+ import { MAX_ANNOTATIONS_BYTES } from '../sync/limits.ts';
14
19
  import { bridgeAwareness } from './awareness-bridge.ts';
15
- import { Y_TYPES } from './persistence.ts';
16
20
  import type { Room, RoomCallbacks } from './room.ts';
17
21
  import { createRoom } from './room.ts';
18
22
 
@@ -49,12 +53,23 @@ export interface Registry {
49
53
  */
50
54
  syncRoomFromComments(slug: string, comments: readonly unknown[]): void;
51
55
  /**
52
- * Phase 8 Task 5 bridge — same shape as syncRoomFromComments but for the
53
- * `annotations` Y.Map. The PUT /_api/annotations endpoint passes the
54
- * post-write SVG; the room replaces `Y.Map.svg` so collab peers see the
55
- * updated stroke set without waiting for a cold-open re-seed.
56
+ * Same shape as syncRoomFromComments but for the annotations replica
57
+ * (DDR-242). Every annotations write passes the post-write board; the room's
58
+ * replica is diffed to it, so collab peers receive only the changed elements.
56
59
  */
57
- syncRoomFromAnnotations(slug: string, svg: string, writeId?: string): void;
60
+ syncRoomFromAnnotations(slug: string, text: string, actionId?: string): void;
61
+ /**
62
+ * The canvas op path while a room is live (code review H1): apply the batch
63
+ * to the room's replica — the freshest state, ahead of a debounced flush —
64
+ * instead of to disk, where a peer's not-yet-flushed edit would be reverted.
65
+ * `null` when no room (or a never-populated one) is live; `'too-large'` when
66
+ * the result would exceed the board cap (nothing written).
67
+ */
68
+ applyOpsToRoom(
69
+ slug: string,
70
+ ops: readonly Op[],
71
+ actionId?: string
72
+ ): ApplyResult | 'too-large' | null;
58
73
  /**
59
74
  * Phase 30 — project agent editing-presence onto a slug's room awareness so
60
75
  * it crosses the hub (the loopback `ai-activity` bus event does not). `null`
@@ -110,27 +125,24 @@ export interface Registry {
110
125
  }
111
126
 
112
127
  /**
113
- * Replace a doc's annotation SVG — the one write shape shared by the live
114
- * disk→room bridge and the cache-restore reconcile (`collab/index.ts`).
115
- * Identical disk notifications stay no-ops (no update → no persist loop); a UI
116
- * operation may deliberately restore identical content under a new write id.
117
- * Returns whether the doc changed.
128
+ * Make a doc's annotations replica hold `text` (canonical board JSON, or a
129
+ * legacy SVG that is converted) — the one write shape shared by the live
130
+ * disk→room bridge and the cache-restore reconcile (`collab/index.ts`). Only the
131
+ * elements / fields that differ become Yjs updates (DDR-242 §5); identical
132
+ * content is a no-op (no update → no persist loop). `actionId` lets the author's
133
+ * canvas recognise its own echo. Returns whether the doc changed.
118
134
  */
119
135
  export function applyAnnotationsToDoc(
120
136
  doc: Y.Doc,
121
- svg: string,
137
+ text: string,
122
138
  origin: unknown,
123
- writeId?: string
139
+ actionId?: string
124
140
  ): boolean {
125
- const map = doc.getMap<string>(Y_TYPES.annotations);
126
- const id = validAnnotationWriteId(writeId) ? writeId : undefined;
127
- if (map.get('svg') === svg && (!id || map.get(ANNOTATION_WRITE_ID) === id)) return false;
128
- doc.transact(() => {
129
- map.set('svg', svg);
130
- if (id) map.set(ANNOTATION_WRITE_ID, id);
131
- else map.delete(ANNOTATION_WRITE_ID);
132
- }, origin);
133
- return true;
141
+ const board = canonicalAnnotations(text);
142
+ if (board === null) return false;
143
+ return writeReplica(doc, parseBoard(board).elements, origin, {
144
+ ...(validActionId(actionId) ? { actionId } : {}),
145
+ });
134
146
  }
135
147
 
136
148
  export function createRegistry(callbacks: RoomCallbacks): Registry {
@@ -218,10 +230,35 @@ export function createRegistry(callbacks: RoomCallbacks): Registry {
218
230
  applyCommentsToDoc(room.doc, comments as unknown[], 'inspector-write');
219
231
  }
220
232
 
221
- function syncRoomFromAnnotations(slug: string, svg: string, writeId?: string): void {
233
+ function syncRoomFromAnnotations(slug: string, text: string, actionId?: string): void {
222
234
  const room = rooms.get(slug);
223
235
  if (!room) return;
224
- applyAnnotationsToDoc(room.doc, svg, 'inspector-write', writeId);
236
+ applyAnnotationsToDoc(room.doc, text, 'inspector-write', actionId);
237
+ }
238
+
239
+ function applyOpsToRoom(
240
+ slug: string,
241
+ ops: readonly Op[],
242
+ actionId?: string
243
+ ): ApplyResult | 'too-large' | null {
244
+ const room = rooms.get(slug);
245
+ // A replica that was never populated is not the board's truth — the
246
+ // caller takes the disk path instead (DDR-223: no value ≠ empty).
247
+ const cur = room ? readReplica(room.doc) : null;
248
+ if (!room || cur === null) return null;
249
+ const r = applyOps(new Map(cur.elements.map((e) => [e.id, e])), ops);
250
+ if (!r.touched.size) return r;
251
+ const next = [...r.state.values()];
252
+ if (serializeBoard(next).length > MAX_ANNOTATIONS_BYTES) return 'too-large';
253
+ // One transaction with the lane's newest-wins stamp, so peers get ONE
254
+ // update and the room's persistence projects it to disk.
255
+ room.doc.transact(() => {
256
+ writeReplica(room.doc, next, 'inspector-write', {
257
+ ...(validActionId(actionId) ? { actionId } : {}),
258
+ });
259
+ stampAnnotationsEdit(room.doc, 'inspector-write');
260
+ }, 'inspector-write');
261
+ return r;
225
262
  }
226
263
 
227
264
  function setAgentEditing(slug: string, state: { name: string; since: number } | null): void {
@@ -275,6 +312,7 @@ export function createRegistry(callbacks: RoomCallbacks): Registry {
275
312
  getDoc,
276
313
  syncRoomFromComments,
277
314
  syncRoomFromAnnotations,
315
+ applyOpsToRoom,
278
316
  setAgentEditing,
279
317
  attachHubAwareness,
280
318
  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 { type ForeignAwareness, useCollab, useForeignAwareness } from './use-collab.tsx';
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
- const map = collab.doc.getMap('annotations');
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.observe(onChange);
511
+ map.observeDeep(onChange);
361
512
  return () => {
362
513
  try {
363
- map.unobserve(onChange);
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
  ))}