@1agh/maude 1.4.5 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/apps/studio/annotations/ai-read.ts +288 -0
  2. package/apps/studio/annotations/ai-write.ts +533 -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.ts +474 -0
  21. package/apps/studio/annotations/registry.ts +159 -0
  22. package/apps/studio/annotations/replica.ts +264 -0
  23. package/apps/studio/annotations/scene.ts +235 -0
  24. package/apps/studio/annotations/schema.ts +188 -0
  25. package/apps/studio/annotations/types.ts +77 -0
  26. package/apps/studio/annotations/ui/board.ts +126 -0
  27. package/apps/studio/annotations/ui/containment.ts +118 -0
  28. package/apps/studio/annotations/ui/edit-actions.ts +551 -0
  29. package/apps/studio/annotations/ui/editor-channel.ts +62 -0
  30. package/apps/studio/annotations/ui/element-node.tsx +993 -0
  31. package/apps/studio/annotations/ui/pipeline-context.ts +17 -0
  32. package/apps/studio/annotations/ui/pointer-pipeline.ts +150 -0
  33. package/apps/studio/annotations/ui/render-model.ts +264 -0
  34. package/apps/studio/annotations/ui/scene.tsx +63 -0
  35. package/apps/studio/annotations/ui/text-editor.tsx +420 -0
  36. package/apps/studio/annotations/ui/text-session.ts +140 -0
  37. package/apps/studio/annotations/ui/text-style.ts +111 -0
  38. package/apps/studio/annotations/ui/world.ts +47 -0
  39. package/apps/studio/annotations/v1-adapter.ts +428 -0
  40. package/apps/studio/annotations-align.ts +21 -6
  41. package/apps/studio/annotations-bindings.ts +2 -0
  42. package/apps/studio/annotations-context-toolbar.tsx +9 -13
  43. package/apps/studio/annotations-groups.ts +3 -0
  44. package/apps/studio/annotations-layer.tsx +923 -1925
  45. package/apps/studio/annotations-model.ts +97 -4
  46. package/apps/studio/annotations-sync.ts +4 -47
  47. package/apps/studio/api.ts +262 -68
  48. package/apps/studio/bin/_import-figma.mjs +22 -12
  49. package/apps/studio/bin/annotate.mjs +331 -838
  50. package/apps/studio/bin/annotate.sh +4 -4
  51. package/apps/studio/bin/perf.sh +21 -7
  52. package/apps/studio/bin/read-annotations.mjs +184 -666
  53. package/apps/studio/bin/read-annotations.sh +9 -5
  54. package/apps/studio/canvas-artifacts.ts +9 -0
  55. package/apps/studio/canvas-comment-mount.tsx +35 -2
  56. package/apps/studio/canvas-lib.tsx +18 -1
  57. package/apps/studio/canvas-shell.tsx +54 -1
  58. package/apps/studio/client/app.jsx +101 -35
  59. package/apps/studio/client/comments-overlay.css +10 -0
  60. package/apps/studio/client/hmr.mjs +1 -1
  61. package/apps/studio/client/panels/git-grouping.js +2 -2
  62. package/apps/studio/client/tree-expansion.js +217 -0
  63. package/apps/studio/collab/index.ts +58 -5
  64. package/apps/studio/collab/persistence.ts +80 -11
  65. package/apps/studio/collab/registry.ts +63 -26
  66. package/apps/studio/commands/annotation-ops-command.ts +72 -0
  67. package/apps/studio/comment-anchor.ts +116 -0
  68. package/apps/studio/comments-overlay.tsx +78 -35
  69. package/apps/studio/context.ts +1 -0
  70. package/apps/studio/cursors-overlay.tsx +337 -109
  71. package/apps/studio/dist/client.bundle.js +850 -850
  72. package/apps/studio/dist/comment-mount.js +2 -2
  73. package/apps/studio/figma/to-strokes.ts +31 -10
  74. package/apps/studio/git/endpoints.ts +1 -1
  75. package/apps/studio/git/service.ts +1 -1
  76. package/apps/studio/git/watch.ts +1 -1
  77. package/apps/studio/http.ts +90 -15
  78. package/apps/studio/server.ts +16 -0
  79. package/apps/studio/sync/accepted-cold-start.ts +72 -8
  80. package/apps/studio/sync/agent.ts +36 -6
  81. package/apps/studio/sync/codec.ts +95 -40
  82. package/apps/studio/sync/comment-ledger.ts +229 -0
  83. package/apps/studio/sync/file-membership.ts +12 -2
  84. package/apps/studio/sync/file-plane.ts +78 -2
  85. package/apps/studio/sync/index.ts +75 -16
  86. package/apps/studio/sync/journal-client.ts +5 -0
  87. package/apps/studio/sync/limits.ts +7 -2
  88. package/apps/studio/sync/migrate-seed.ts +17 -7
  89. package/apps/studio/sync/projection.ts +7 -2
  90. package/apps/studio/sync/remote-docs.ts +34 -0
  91. package/apps/studio/sync/writer-registry.ts +10 -0
  92. package/apps/studio/text-caret.ts +11 -2
  93. package/apps/studio/tree-state.ts +45 -0
  94. package/apps/studio/undo-stack.ts +2 -2
  95. package/apps/studio/use-annotation-resize.tsx +48 -23
  96. package/apps/studio/use-annotation-selection.tsx +9 -2
  97. package/apps/studio/use-collab.tsx +104 -1
  98. package/apps/studio/use-selection-set.tsx +4 -0
  99. package/apps/studio/whats-new.json +63 -0
  100. package/cli/lib/design-link.mjs +5 -1
  101. package/cli/lib/gitignore-block.mjs +1 -1
  102. package/cli/lib/gitignore-drift.mjs +2 -1
  103. package/package.json +9 -8
  104. package/plugins/design/templates/brief-board.tsx.template +1 -1
  105. package/apps/studio/annotation-edit-base.ts +0 -36
  106. package/apps/studio/commands/annotation-strokes-command.ts +0 -137
@@ -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;
@@ -111,6 +134,9 @@ function ensureCursorStyles(): void {
111
134
  document.head.appendChild(s);
112
135
  }
113
136
 
137
+ /** How often a peer halo re-measures when nothing it can observe changed. */
138
+ const HALO_REFRESH_MS = 500;
139
+
114
140
  interface ViewportSnapshot {
115
141
  x: number;
116
142
  y: number;
@@ -122,39 +148,75 @@ interface CursorProps {
122
148
  viewport: ViewportSnapshot;
123
149
  }
124
150
 
125
- const Cursor = memo(function Cursor({ peer, viewport }: CursorProps): JSX.Element | null {
126
- if (!peer.cursor) return null;
127
- // world → screen: screen = world * zoom + viewport.{x,y}
128
- const screenX = peer.cursor.x * viewport.zoom + viewport.x;
129
- const screenY = peer.cursor.y * viewport.zoom + viewport.y;
130
- const editing = !!peer.editing;
151
+ // Issue #131 — awareness hands out a FRESH peer object on every change (every
152
+ // 30 Hz cursor publish of every peer), so the default shallow `memo` never
153
+ // held: each cursor move re-rendered every halo, and the halos measure the DOM
154
+ // in render. Each component compares only what it draws.
155
+ const sameViewport = (a: ViewportSnapshot, b: ViewportSnapshot): boolean =>
156
+ a === b || (a.x === b.x && a.y === b.y && a.zoom === b.zoom);
157
+ const sameLabel = (a: ForeignAwareness, b: ForeignAwareness): boolean =>
158
+ a.clientID === b.clientID && a.name === b.name && a.color === b.color;
159
+ function sameSelection(a: ForeignAwareness['selection'], b: ForeignAwareness['selection']) {
160
+ if (a === b) return true;
161
+ if (!a || !b) return false;
162
+ const ab = a.bounds;
163
+ const bb = b.bounds;
131
164
  return (
132
- <div
133
- className={`dc-cursor${editing ? ' dc-cursor--editing' : ''}`}
134
- style={{ transform: `translate(${screenX}px, ${screenY}px)` }}
135
- >
136
- {/* DS colors-presence Pointer — one plain triangle glyph, tinted by its
137
- owner (the specimen's exact 24-grid path, no tail/notch). */}
138
- <svg
139
- className="dc-cursor-arrow"
140
- width="15"
141
- height="15"
142
- viewBox="0 0 24 24"
143
- aria-hidden="true"
165
+ a.cssPath === b.cssPath &&
166
+ (ab === bb ||
167
+ (!!ab && !!bb && ab.x === bb.x && ab.y === bb.y && ab.w === bb.w && ab.h === bb.h))
168
+ );
169
+ }
170
+ function sameIds(a: readonly string[] | undefined, b: readonly string[] | undefined): boolean {
171
+ if (a === b) return true;
172
+ if (!a || !b || a.length !== b.length) return false;
173
+ return a.every((id, i) => id === b[i]);
174
+ }
175
+
176
+ const Cursor = memo(
177
+ function Cursor({ peer, viewport }: CursorProps): JSX.Element | null {
178
+ if (!peer.cursor) return null;
179
+ // world → screen: screen = world * zoom + viewport.{x,y}
180
+ const screenX = peer.cursor.x * viewport.zoom + viewport.x;
181
+ const screenY = peer.cursor.y * viewport.zoom + viewport.y;
182
+ const editing = !!peer.editing;
183
+ return (
184
+ <div
185
+ className={`dc-cursor${editing ? ' dc-cursor--editing' : ''}`}
186
+ style={{ transform: `translate(${screenX}px, ${screenY}px)` }}
144
187
  >
145
- <path d="M4 3 L20 11.5 L12.5 13 L10 21 Z" fill={peer.color} />
146
- </svg>
147
- <div className="dc-cursor-label" style={{ background: peer.color }}>
148
- {peer.name}
149
- {editing && (
150
- <span className="dc-cursor-edit-mark" aria-hidden="true">
151
- ✎
152
- </span>
153
- )}
188
+ {/* DS colors-presence Pointer — one plain triangle glyph, tinted by its
189
+ owner (the specimen's exact 24-grid path, no tail/notch). */}
190
+ <svg
191
+ className="dc-cursor-arrow"
192
+ width="15"
193
+ height="15"
194
+ viewBox="0 0 24 24"
195
+ aria-hidden="true"
196
+ >
197
+ <path d="M4 3 L20 11.5 L12.5 13 L10 21 Z" fill={peer.color} />
198
+ </svg>
199
+ <div className="dc-cursor-label" style={{ background: peer.color }}>
200
+ {peer.name}
201
+ {editing && (
202
+ <span className="dc-cursor-edit-mark" aria-hidden="true">
203
+ ✎
204
+ </span>
205
+ )}
206
+ </div>
154
207
  </div>
155
- </div>
156
- );
157
- });
208
+ );
209
+ },
210
+ (a, b) =>
211
+ sameViewport(a.viewport, b.viewport) &&
212
+ sameLabel(a.peer, b.peer) &&
213
+ !!a.peer.editing === !!b.peer.editing &&
214
+ (a.peer.cursor === b.peer.cursor ||
215
+ (!!a.peer.cursor &&
216
+ !!b.peer.cursor &&
217
+ a.peer.cursor.x === b.peer.cursor.x &&
218
+ a.peer.cursor.y === b.peer.cursor.y))
219
+ );
158
220
 
159
221
  /**
160
222
  * Foreign-selection halos for stamped annotation strokes. Each peer publishes
@@ -173,50 +235,188 @@ interface PeerAnnotationSelectionProps {
173
235
  * screen space while the stroke it outlines moves away underneath it.
174
236
  */
175
237
  viewport: ViewportSnapshot;
238
+ /**
239
+ * Bumps when what the halo outlines may have moved WITHOUT the camera or the
240
+ * peer's selection changing — an annotation edit, a canvas re-render. It used
241
+ * to be implicit: the halo re-measured on every awareness change (30 Hz per
242
+ * moving peer), which was the #131 cost. Now it is this, and nothing else.
243
+ */
244
+ tick?: number;
176
245
  }
177
246
 
178
- export const PeerAnnotationSelection = memo(function PeerAnnotationSelection({
179
- peer,
180
- }: PeerAnnotationSelectionProps): JSX.Element | null {
181
- if (!peer.annotationSelection || peer.annotationSelection.length === 0) return null;
182
- if (typeof document === 'undefined') return null;
183
- const rects: { id: string; x: number; y: number; w: number; h: number }[] = [];
184
- for (const id of peer.annotationSelection) {
185
- try {
186
- const el = document.querySelector(`[data-id="${CSS.escape(id)}"]`);
187
- if (!el) continue;
188
- const r = (el as Element).getBoundingClientRect();
189
- if (r.width <= 0 || r.height <= 0) continue;
190
- // Pad by 3px so the halo sits OUTSIDE the stroke instead of clipping it.
191
- rects.push({ id, x: r.left - 3, y: r.top - 3, w: r.width + 6, h: r.height + 6 });
192
- } catch {
193
- /* invalid id token — skip */
247
+ export const PeerAnnotationSelection = memo(
248
+ function PeerAnnotationSelection({ peer }: PeerAnnotationSelectionProps): JSX.Element | null {
249
+ if (!peer.annotationSelection || peer.annotationSelection.length === 0) return null;
250
+ if (typeof document === 'undefined') return null;
251
+ const rects: { id: string; x: number; y: number; w: number; h: number }[] = [];
252
+ for (const id of peer.annotationSelection) {
253
+ try {
254
+ const el = document.querySelector(`[data-id="${CSS.escape(id)}"]`);
255
+ if (!el) continue;
256
+ const r = (el as Element).getBoundingClientRect();
257
+ if (r.width <= 0 || r.height <= 0) continue;
258
+ // Pad by 3px so the halo sits OUTSIDE the stroke instead of clipping it.
259
+ rects.push({ id, x: r.left - 3, y: r.top - 3, w: r.width + 6, h: r.height + 6 });
260
+ } catch {
261
+ /* invalid id token — skip */
262
+ }
194
263
  }
195
- }
196
- if (rects.length === 0) return null;
264
+ if (rects.length === 0) return null;
265
+ return (
266
+ <>
267
+ {rects.map((r, i) => (
268
+ <div
269
+ key={r.id}
270
+ className="dc-peer-selection"
271
+ style={{
272
+ transform: `translate(${r.x}px, ${r.y}px)`,
273
+ width: r.w,
274
+ height: r.h,
275
+ borderColor: peer.color,
276
+ }}
277
+ >
278
+ {i === 0 && (
279
+ <div className="dc-peer-selection__label" style={{ background: peer.color }}>
280
+ {peer.name}
281
+ </div>
282
+ )}
283
+ </div>
284
+ ))}
285
+ </>
286
+ );
287
+ },
288
+ (a, b) =>
289
+ sameViewport(a.viewport, b.viewport) &&
290
+ a.tick === b.tick &&
291
+ sameLabel(a.peer, b.peer) &&
292
+ sameIds(a.peer.annotationSelection, b.peer.annotationSelection)
293
+ );
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;
197
312
  return (
198
- <>
199
- {rects.map((r, i) => (
200
- <div
201
- key={r.id}
202
- className="dc-peer-selection"
203
- style={{
204
- transform: `translate(${r.x}px, ${r.y}px)`,
205
- width: r.w,
206
- height: r.h,
207
- borderColor: peer.color,
208
- }}
209
- >
210
- {i === 0 && (
211
- <div className="dc-peer-selection__label" style={{ background: peer.color }}>
212
- {peer.name}
213
- </div>
214
- )}
215
- </div>
216
- ))}
217
- </>
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]
218
323
  );
219
- });
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
+ );
220
420
 
221
421
  /**
222
422
  * Foreign-selection halo for canvas-shell elements (cdId-based selSet).
@@ -229,46 +429,53 @@ interface PeerSelectionProps {
229
429
  peer: ForeignAwareness;
230
430
  /** See PeerAnnotationSelectionProps.viewport — the memo-invalidation tick. */
231
431
  viewport: ViewportSnapshot;
432
+ /** See PeerAnnotationSelectionProps.tick. */
433
+ tick?: number;
232
434
  }
233
435
 
234
- export const PeerSelection = memo(function PeerSelection({
235
- peer,
236
- }: PeerSelectionProps): JSX.Element | null {
237
- if (!peer.selection) return null;
238
- const { cssPath, bounds } = peer.selection;
436
+ export const PeerSelection = memo(
437
+ function PeerSelection({ peer }: PeerSelectionProps): JSX.Element | null {
438
+ if (!peer.selection) return null;
439
+ const { cssPath, bounds } = peer.selection;
239
440
 
240
- let rect: { x: number; y: number; w: number; h: number } | null = bounds ?? null;
241
- if (cssPath && typeof document !== 'undefined') {
242
- try {
243
- const el = document.querySelector(cssPath);
244
- if (el && el instanceof Element) {
245
- const r = el.getBoundingClientRect();
246
- if (r.width > 0 && r.height > 0) {
247
- rect = { x: r.left, y: r.top, w: r.width, h: r.height };
441
+ let rect: { x: number; y: number; w: number; h: number } | null = bounds ?? null;
442
+ if (cssPath && typeof document !== 'undefined') {
443
+ try {
444
+ const el = document.querySelector(cssPath);
445
+ if (el && el instanceof Element) {
446
+ const r = el.getBoundingClientRect();
447
+ if (r.width > 0 && r.height > 0) {
448
+ rect = { x: r.left, y: r.top, w: r.width, h: r.height };
449
+ }
248
450
  }
451
+ } catch {
452
+ /* invalid selector — fall through to published bounds */
249
453
  }
250
- } catch {
251
- /* invalid selector — fall through to published bounds */
252
454
  }
253
- }
254
- if (!rect) return null;
455
+ if (!rect) return null;
255
456
 
256
- return (
257
- <div
258
- className="dc-peer-selection"
259
- style={{
260
- transform: `translate(${rect.x}px, ${rect.y}px)`,
261
- width: rect.w,
262
- height: rect.h,
263
- borderColor: peer.color,
264
- }}
265
- >
266
- <div className="dc-peer-selection__label" style={{ background: peer.color }}>
267
- {peer.name}
457
+ return (
458
+ <div
459
+ className="dc-peer-selection"
460
+ style={{
461
+ transform: `translate(${rect.x}px, ${rect.y}px)`,
462
+ width: rect.w,
463
+ height: rect.h,
464
+ borderColor: peer.color,
465
+ }}
466
+ >
467
+ <div className="dc-peer-selection__label" style={{ background: peer.color }}>
468
+ {peer.name}
469
+ </div>
268
470
  </div>
269
- </div>
270
- );
271
- });
471
+ );
472
+ },
473
+ (a, b) =>
474
+ sameViewport(a.viewport, b.viewport) &&
475
+ a.tick === b.tick &&
476
+ sameLabel(a.peer, b.peer) &&
477
+ sameSelection(a.peer.selection, b.peer.selection)
478
+ );
272
479
 
273
480
  /**
274
481
  * Subscribes to foreign awareness + the local viewport. Renders one Cursor per
@@ -294,27 +501,48 @@ export function CursorsOverlay(): JSX.Element {
294
501
  // sibling CursorsOverlay wouldn't re-render, so the annotation halo would
295
502
  // sit on the OLD bounds until awareness independently changed.
296
503
  const collab = useCollab();
297
- const [, bumpAnnotTick] = useState(0);
504
+ const [tick, bumpTick] = useState(0);
298
505
  useEffect(() => {
299
506
  if (!collab) return;
300
- const map = collab.doc.getMap('annotations');
301
- const onChange = () => bumpAnnotTick((n) => n + 1);
302
- map.observe(onChange);
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);
510
+ const onChange = () => bumpTick((n) => n + 1);
511
+ map.observeDeep(onChange);
303
512
  return () => {
304
513
  try {
305
- map.unobserve(onChange);
514
+ map.unobserveDeep(onChange);
306
515
  } catch {
307
516
  /* doc destroyed */
308
517
  }
309
518
  };
310
519
  }, [collab]);
520
+ // A peer's selected element can change size or move with no camera or
521
+ // selection change (they edit its text; the canvas re-renders). The halos
522
+ // used to catch that for free by re-measuring on every awareness change —
523
+ // up to 30× a second per moving peer (#131). A slow refresh while somebody
524
+ // has something selected keeps the halo honest for a tiny fraction of that.
525
+ const anySelection = peers.some((p) => !!p.selection || (p.annotationSelection?.length ?? 0) > 0);
526
+ useEffect(() => {
527
+ if (!anySelection) return;
528
+ const id = setInterval(() => bumpTick((n) => n + 1), HALO_REFRESH_MS);
529
+ return () => clearInterval(id);
530
+ }, [anySelection]);
311
531
  return (
312
532
  <div className="dc-cursor-overlay" aria-hidden="true">
313
533
  {peers.map((peer) => (
314
- <PeerSelection key={`sel-${peer.clientID}`} peer={peer} viewport={vp} />
534
+ <PeerSelection key={`sel-${peer.clientID}`} peer={peer} viewport={vp} tick={tick} />
535
+ ))}
536
+ {peers.map((peer) => (
537
+ <PeerAnnotationSelection
538
+ key={`asel-${peer.clientID}`}
539
+ peer={peer}
540
+ viewport={vp}
541
+ tick={tick}
542
+ />
315
543
  ))}
316
544
  {peers.map((peer) => (
317
- <PeerAnnotationSelection key={`asel-${peer.clientID}`} peer={peer} viewport={vp} />
545
+ <PeerAnnotationGesture key={`gest-${peer.clientID}`} peer={peer} viewport={vp} />
318
546
  ))}
319
547
  {peers.map((peer) => (
320
548
  <Cursor key={peer.clientID} peer={peer} viewport={vp} />