@1agh/maude 1.4.4 → 1.4.6

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 (47) hide show
  1. package/apps/studio/annotations-model.ts +33 -1
  2. package/apps/studio/api.ts +19 -0
  3. package/apps/studio/canvas-comment-mount.tsx +48 -3
  4. package/apps/studio/canvas-lib.tsx +5 -0
  5. package/apps/studio/canvas-shell.tsx +110 -14
  6. package/apps/studio/canvas-source-memo.ts +39 -0
  7. package/apps/studio/canvas-text-patch.ts +117 -0
  8. package/apps/studio/client/app.jsx +108 -60
  9. package/apps/studio/client/comments-overlay.css +10 -0
  10. package/apps/studio/client/file-tree.jsx +157 -0
  11. package/apps/studio/client/index.html +1 -1
  12. package/apps/studio/client/panels/DiffView.jsx +37 -8
  13. package/apps/studio/client/panels/GitPanel.jsx +2 -1
  14. package/apps/studio/client/panels/SourceConflictPanel.jsx +19 -17
  15. package/apps/studio/client/styles/3-shell-maude.css +21 -9
  16. package/apps/studio/client/styles/4-components.css +4 -2
  17. package/apps/studio/collab/index.ts +9 -0
  18. package/apps/studio/collab/persistence.ts +49 -1
  19. package/apps/studio/comment-anchor.ts +116 -0
  20. package/apps/studio/comments-overlay.tsx +78 -35
  21. package/apps/studio/context.ts +1 -0
  22. package/apps/studio/cursors-overlay.tsx +181 -107
  23. package/apps/studio/dist/client.bundle.js +1086 -1086
  24. package/apps/studio/dist/comment-mount.js +2 -2
  25. package/apps/studio/dist/styles.css +1 -1
  26. package/apps/studio/hmr-broadcast.ts +59 -3
  27. package/apps/studio/http.ts +3 -0
  28. package/apps/studio/sync/accepted-cold-start.ts +95 -7
  29. package/apps/studio/sync/accepted-link.ts +165 -29
  30. package/apps/studio/sync/agent.ts +28 -2
  31. package/apps/studio/sync/comment-ledger.ts +229 -0
  32. package/apps/studio/sync/file-plane.ts +101 -2
  33. package/apps/studio/sync/index.ts +124 -17
  34. package/apps/studio/sync/journal-client.ts +9 -3
  35. package/apps/studio/sync/migrate-seed.ts +9 -2
  36. package/apps/studio/sync/projection.ts +114 -11
  37. package/apps/studio/sync/remote-docs.ts +3 -2
  38. package/apps/studio/sync/source-recovery.ts +67 -0
  39. package/apps/studio/sync/status.ts +11 -3
  40. package/apps/studio/sync/transaction-client.ts +49 -4
  41. package/apps/studio/use-collab.tsx +28 -1
  42. package/apps/studio/use-selection-set.tsx +13 -3
  43. package/apps/studio/use-undo-stack.tsx +22 -2
  44. package/apps/studio/whats-new.json +45 -0
  45. package/cli/lib/workspace-plan.mjs +10 -0
  46. package/package.json +8 -8
  47. package/plugins/design/templates/_shell.html +21 -0
@@ -33,9 +33,13 @@
33
33
  * trade-off), which can silently reanchor a comment to the wrong element or to
34
34
  * nothing. `resolveCommentTarget` tries the stored selector first, falls back
35
35
  * to a structural match via `resolveByDomPath` (dom-selection.ts) when the
36
- * direct hit is missing or looks like the wrong element (tag mismatch), and —
37
- * per DDR-034's deferred future-work item — `CommentPin` auto-deletes a
38
- * comment whose target stays unresolvable past a short grace window.
36
+ * direct hit is missing or looks like the wrong element (tag mismatch). A
37
+ * comment whose target stays unresolvable past a short grace window is
38
+ * DETACHED — kept, shown at its last known place, and marked — never deleted:
39
+ * software deleting a comment is how #134/#136 lost every comment placed on a
40
+ * sticky or on empty canvas, and how one peer's missing element deleted a
41
+ * comment for everyone. Only a person deletes a comment. Comments on
42
+ * annotations and on empty canvas anchor through `comment-anchor.ts`.
39
43
  *
40
44
  * Popup placement — `CommentComposer` / `CommentThread` pick a side
41
45
  * (left/right, above/below the anchor) that actually fits the viewport via
@@ -49,6 +53,13 @@
49
53
 
50
54
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
51
55
 
56
+ import {
57
+ annotationElement,
58
+ isAnnotationId,
59
+ isWorldPoint,
60
+ type WorldPoint,
61
+ worldToClient,
62
+ } from './comment-anchor.ts';
52
63
  import { resolveByDomPath } from './dom-selection.ts';
53
64
  import { isReadOnlyCanvas } from './read-only-mode.ts';
54
65
  import { useCollab } from './use-collab.tsx';
@@ -84,6 +95,10 @@ interface ComposeSelection {
84
95
  dom_path: string[];
85
96
  bounds: OverlayBounds | null;
86
97
  html: string;
98
+ /** Set when the click landed on an annotation (comment-anchor.ts). */
99
+ annotationId?: string;
100
+ /** World point of the click, when the canvas has a world plane. */
101
+ world?: WorldPoint;
87
102
  }
88
103
 
89
104
  interface ComposerState {
@@ -121,6 +136,11 @@ export interface OverlayComment {
121
136
  tag?: string;
122
137
  classes?: string;
123
138
  dom_path?: string[];
139
+ /** Anchor on an annotation (sticky, shape, stroke…) — see comment-anchor.ts. */
140
+ annotationId?: string;
141
+ /** World point of the comment — the anchor of a floating comment, and the
142
+ * last known place of an anchored one whose target is gone. */
143
+ world?: WorldPoint;
124
144
  // html_excerpt unused at overlay layer; kept off the type to keep the
125
145
  // surface tight.
126
146
  }
@@ -176,6 +196,7 @@ export interface TargetRef {
176
196
  tag?: string;
177
197
  classes?: string;
178
198
  dom_path?: string[];
199
+ annotationId?: string;
179
200
  }
180
201
 
181
202
  /**
@@ -187,6 +208,9 @@ export interface TargetRef {
187
208
  * match via `resolveByDomPath`.
188
209
  */
189
210
  export function resolveCommentTarget(target: TargetRef): HTMLElement | null {
211
+ if (isAnnotationId(target.annotationId)) {
212
+ return annotationElement(target.annotationId) as HTMLElement | null;
213
+ }
190
214
  if (!target.selector) return null;
191
215
  let el: HTMLElement | null = null;
192
216
  try {
@@ -426,6 +450,8 @@ export function CommentsOverlay(): React.ReactNode {
426
450
  classes: sel.classes,
427
451
  bounds: sel.bounds,
428
452
  html_excerpt: sel.html,
453
+ ...(sel.annotationId ? { annotationId: sel.annotationId } : {}),
454
+ ...(sel.world ? { world: sel.world } : {}),
429
455
  text,
430
456
  };
431
457
  if (typeof window === 'undefined') return;
@@ -547,7 +573,6 @@ export function CommentsOverlay(): React.ReactNode {
547
573
  sequence={n}
548
574
  focused={focusedId === c.id}
549
575
  onClick={handlePinClick}
550
- onOrphaned={handleDelete}
551
576
  />
552
577
  );
553
578
  })}
@@ -836,28 +861,54 @@ function MentionAwareTextarea({
836
861
  // reflow, font load). Falls back to the stored `bounds` when the target is
837
862
  // gone from the DOM.
838
863
 
839
- // How long a pin is allowed to stay unresolvable (no live target AND no
840
- // structural-fallback match) before its comment is presumed orphaned and
841
- // auto-deleted. Long enough to ride out a canvas HMR remount; short enough
842
- // that a genuinely deleted element's comment doesn't linger.
843
- const ORPHAN_GRACE_MS = 3000;
864
+ // How long a pin may stay unresolvable before it is shown as DETACHED. Long
865
+ // enough to ride out a canvas HMR remount. Detached is a view of this client's
866
+ // DOM, never a fact written back: another peer may still resolve the target.
867
+ const DETACH_GRACE_MS = 3000;
868
+
869
+ /** The pin box for a client point — the pin centres on the point. */
870
+ function pointBox(p: { x: number; y: number }): OverlayBounds {
871
+ return { x: p.x - 12, y: p.y - 12, w: 24, h: 24 };
872
+ }
873
+
874
+ /**
875
+ * Where a comment's pin goes this frame, and whether its target is missing.
876
+ *
877
+ * - anchored (element selector or annotation id) and resolved → on the target;
878
+ * - anchored but unresolved → last known place (world point, else the
879
+ * create-time screen bounds), `missing: true`;
880
+ * - floating (no anchor) → its world point, else legacy screen bounds.
881
+ */
882
+ export function locateComment(comment: OverlayComment): {
883
+ pos: OverlayBounds | null;
884
+ missing: boolean;
885
+ } {
886
+ const anchored = !!comment.selector || isAnnotationId(comment.annotationId);
887
+ if (anchored) {
888
+ const live = screenRectFor(comment);
889
+ if (live) return { pos: live, missing: false };
890
+ }
891
+ const at = isWorldPoint(comment.world) ? worldToClient(comment.world) : null;
892
+ const pos = at ? pointBox(at) : (comment.bounds ?? null);
893
+ return { pos, missing: anchored };
894
+ }
844
895
 
845
896
  function CommentPin({
846
897
  comment,
847
898
  sequence,
848
899
  focused,
849
900
  onClick,
850
- onOrphaned,
851
901
  }: {
852
902
  comment: OverlayComment;
853
903
  sequence: number;
854
904
  focused: boolean;
855
905
  onClick: (id: string) => void;
856
- onOrphaned: (id: string) => void;
857
906
  }) {
858
907
  const ref = useRef<HTMLButtonElement | null>(null);
859
908
  const rafRef = useRef<number | null>(null);
860
- const unresolvedSinceRef = useRef<number | null>(null);
909
+ const missingSinceRef = useRef<number | null>(null);
910
+ const [detached, setDetached] = useState(false);
911
+ const detachedRef = useRef(false);
861
912
 
862
913
  useEffect(() => {
863
914
  const tick = () => {
@@ -865,27 +916,18 @@ function CommentPin({
865
916
  const pin = ref.current;
866
917
  if (!pin) return;
867
918
 
868
- // Live screen-coord lookup mirrors SelectionHalos in canvas-shell.tsx
869
- // (resolveCommentTarget tries the stored selector, then a structural
870
- // fallback). Falls back to stored bounds (a screen-coord capture at
871
- // create time) when neither resolves — better than vanishing entirely.
872
- let pos = screenRectFor(comment);
873
- if (pos) {
874
- unresolvedSinceRef.current = null;
919
+ // Live screen-coord lookup mirrors SelectionHalos in canvas-shell.tsx.
920
+ const { pos, missing } = locateComment(comment);
921
+ let nowDetached = false;
922
+ if (missing) {
923
+ if (missingSinceRef.current == null) missingSinceRef.current = Date.now();
924
+ nowDetached = Date.now() - missingSinceRef.current > DETACH_GRACE_MS;
875
925
  } else {
876
- if (unresolvedSinceRef.current == null) {
877
- unresolvedSinceRef.current = Date.now();
878
- } else if (Date.now() - unresolvedSinceRef.current > ORPHAN_GRACE_MS) {
879
- onOrphaned(comment.id);
880
- }
881
- if (comment.bounds) {
882
- pos = {
883
- x: comment.bounds.x,
884
- y: comment.bounds.y,
885
- w: comment.bounds.w,
886
- h: comment.bounds.h,
887
- };
888
- }
926
+ missingSinceRef.current = null;
927
+ }
928
+ if (nowDetached !== detachedRef.current) {
929
+ detachedRef.current = nowDetached;
930
+ setDetached(nowDetached);
889
931
  }
890
932
  if (!pos) {
891
933
  pin.style.display = 'none';
@@ -905,10 +947,10 @@ function CommentPin({
905
947
  return () => {
906
948
  if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
907
949
  };
908
- }, [comment, onOrphaned]);
950
+ }, [comment]);
909
951
 
910
952
  const author = comment.author?.trim() || 'unknown';
911
- const label = `Comment ${sequence} by ${author}`;
953
+ const label = `Comment ${sequence} by ${author}${detached ? ' (detached: its target is gone)' : ''}`;
912
954
 
913
955
  return (
914
956
  <button
@@ -917,6 +959,7 @@ function CommentPin({
917
959
  className="cm-pin"
918
960
  data-resolved={comment.status === 'resolved' ? 'true' : 'false'}
919
961
  data-focused={focused ? 'true' : 'false'}
962
+ data-detached={detached ? 'true' : 'false'}
920
963
  data-comment-pin={comment.id}
921
964
  aria-label={label}
922
965
  aria-expanded={focused}
@@ -1002,7 +1045,7 @@ function CommentComposer({
1002
1045
  // tight inside the 300px card.
1003
1046
  const selectorChip = useMemo(() => {
1004
1047
  const s = state.selection.selector || '';
1005
- if (!s) return state.selection.tag || 'canvas';
1048
+ if (!s) return state.selection.tag || (state.selection.annotationId ? 'annotation' : 'canvas');
1006
1049
  // [data-cd-id="…"] → cd:<id> · keeps the chip readable when stable ids
1007
1050
  // are present.
1008
1051
  const cd = s.match(/data-cd-id="([^"]+)"/);
@@ -274,6 +274,7 @@ export interface Context {
274
274
  | { op: 'dir.move'; from: string; to: string }
275
275
  ): Promise<{ status: 'accepted' | 'rejected'; code?: string; queued?: boolean }> | null;
276
276
  acceptedMode?(): boolean;
277
+ commentsConfirmedOnHub?(slug: string): boolean;
277
278
  acceptedHistory?(q: {
278
279
  limit?: number;
279
280
  before?: number | null;
@@ -111,6 +111,9 @@ function ensureCursorStyles(): void {
111
111
  document.head.appendChild(s);
112
112
  }
113
113
 
114
+ /** How often a peer halo re-measures when nothing it can observe changed. */
115
+ const HALO_REFRESH_MS = 500;
116
+
114
117
  interface ViewportSnapshot {
115
118
  x: number;
116
119
  y: number;
@@ -122,39 +125,75 @@ interface CursorProps {
122
125
  viewport: ViewportSnapshot;
123
126
  }
124
127
 
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;
128
+ // Issue #131 — awareness hands out a FRESH peer object on every change (every
129
+ // 30 Hz cursor publish of every peer), so the default shallow `memo` never
130
+ // held: each cursor move re-rendered every halo, and the halos measure the DOM
131
+ // in render. Each component compares only what it draws.
132
+ const sameViewport = (a: ViewportSnapshot, b: ViewportSnapshot): boolean =>
133
+ a === b || (a.x === b.x && a.y === b.y && a.zoom === b.zoom);
134
+ const sameLabel = (a: ForeignAwareness, b: ForeignAwareness): boolean =>
135
+ a.clientID === b.clientID && a.name === b.name && a.color === b.color;
136
+ function sameSelection(a: ForeignAwareness['selection'], b: ForeignAwareness['selection']) {
137
+ if (a === b) return true;
138
+ if (!a || !b) return false;
139
+ const ab = a.bounds;
140
+ const bb = b.bounds;
131
141
  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"
142
+ a.cssPath === b.cssPath &&
143
+ (ab === bb ||
144
+ (!!ab && !!bb && ab.x === bb.x && ab.y === bb.y && ab.w === bb.w && ab.h === bb.h))
145
+ );
146
+ }
147
+ function sameIds(a: readonly string[] | undefined, b: readonly string[] | undefined): boolean {
148
+ if (a === b) return true;
149
+ if (!a || !b || a.length !== b.length) return false;
150
+ return a.every((id, i) => id === b[i]);
151
+ }
152
+
153
+ const Cursor = memo(
154
+ function Cursor({ peer, viewport }: CursorProps): JSX.Element | null {
155
+ if (!peer.cursor) return null;
156
+ // world → screen: screen = world * zoom + viewport.{x,y}
157
+ const screenX = peer.cursor.x * viewport.zoom + viewport.x;
158
+ const screenY = peer.cursor.y * viewport.zoom + viewport.y;
159
+ const editing = !!peer.editing;
160
+ return (
161
+ <div
162
+ className={`dc-cursor${editing ? ' dc-cursor--editing' : ''}`}
163
+ style={{ transform: `translate(${screenX}px, ${screenY}px)` }}
144
164
  >
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
- )}
165
+ {/* DS colors-presence Pointer — one plain triangle glyph, tinted by its
166
+ owner (the specimen's exact 24-grid path, no tail/notch). */}
167
+ <svg
168
+ className="dc-cursor-arrow"
169
+ width="15"
170
+ height="15"
171
+ viewBox="0 0 24 24"
172
+ aria-hidden="true"
173
+ >
174
+ <path d="M4 3 L20 11.5 L12.5 13 L10 21 Z" fill={peer.color} />
175
+ </svg>
176
+ <div className="dc-cursor-label" style={{ background: peer.color }}>
177
+ {peer.name}
178
+ {editing && (
179
+ <span className="dc-cursor-edit-mark" aria-hidden="true">
180
+ ✎
181
+ </span>
182
+ )}
183
+ </div>
154
184
  </div>
155
- </div>
156
- );
157
- });
185
+ );
186
+ },
187
+ (a, b) =>
188
+ sameViewport(a.viewport, b.viewport) &&
189
+ sameLabel(a.peer, b.peer) &&
190
+ !!a.peer.editing === !!b.peer.editing &&
191
+ (a.peer.cursor === b.peer.cursor ||
192
+ (!!a.peer.cursor &&
193
+ !!b.peer.cursor &&
194
+ a.peer.cursor.x === b.peer.cursor.x &&
195
+ a.peer.cursor.y === b.peer.cursor.y))
196
+ );
158
197
 
159
198
  /**
160
199
  * Foreign-selection halos for stamped annotation strokes. Each peer publishes
@@ -173,50 +212,62 @@ interface PeerAnnotationSelectionProps {
173
212
  * screen space while the stroke it outlines moves away underneath it.
174
213
  */
175
214
  viewport: ViewportSnapshot;
215
+ /**
216
+ * Bumps when what the halo outlines may have moved WITHOUT the camera or the
217
+ * peer's selection changing — an annotation edit, a canvas re-render. It used
218
+ * to be implicit: the halo re-measured on every awareness change (30 Hz per
219
+ * moving peer), which was the #131 cost. Now it is this, and nothing else.
220
+ */
221
+ tick?: number;
176
222
  }
177
223
 
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 */
224
+ export const PeerAnnotationSelection = memo(
225
+ function PeerAnnotationSelection({ peer }: PeerAnnotationSelectionProps): JSX.Element | null {
226
+ if (!peer.annotationSelection || peer.annotationSelection.length === 0) return null;
227
+ if (typeof document === 'undefined') return null;
228
+ const rects: { id: string; x: number; y: number; w: number; h: number }[] = [];
229
+ for (const id of peer.annotationSelection) {
230
+ try {
231
+ const el = document.querySelector(`[data-id="${CSS.escape(id)}"]`);
232
+ if (!el) continue;
233
+ const r = (el as Element).getBoundingClientRect();
234
+ if (r.width <= 0 || r.height <= 0) continue;
235
+ // Pad by 3px so the halo sits OUTSIDE the stroke instead of clipping it.
236
+ rects.push({ id, x: r.left - 3, y: r.top - 3, w: r.width + 6, h: r.height + 6 });
237
+ } catch {
238
+ /* invalid id token — skip */
239
+ }
194
240
  }
195
- }
196
- if (rects.length === 0) return null;
197
- 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
- </>
218
- );
219
- });
241
+ if (rects.length === 0) return null;
242
+ return (
243
+ <>
244
+ {rects.map((r, i) => (
245
+ <div
246
+ key={r.id}
247
+ className="dc-peer-selection"
248
+ style={{
249
+ transform: `translate(${r.x}px, ${r.y}px)`,
250
+ width: r.w,
251
+ height: r.h,
252
+ borderColor: peer.color,
253
+ }}
254
+ >
255
+ {i === 0 && (
256
+ <div className="dc-peer-selection__label" style={{ background: peer.color }}>
257
+ {peer.name}
258
+ </div>
259
+ )}
260
+ </div>
261
+ ))}
262
+ </>
263
+ );
264
+ },
265
+ (a, b) =>
266
+ sameViewport(a.viewport, b.viewport) &&
267
+ a.tick === b.tick &&
268
+ sameLabel(a.peer, b.peer) &&
269
+ sameIds(a.peer.annotationSelection, b.peer.annotationSelection)
270
+ );
220
271
 
221
272
  /**
222
273
  * Foreign-selection halo for canvas-shell elements (cdId-based selSet).
@@ -229,46 +280,53 @@ interface PeerSelectionProps {
229
280
  peer: ForeignAwareness;
230
281
  /** See PeerAnnotationSelectionProps.viewport — the memo-invalidation tick. */
231
282
  viewport: ViewportSnapshot;
283
+ /** See PeerAnnotationSelectionProps.tick. */
284
+ tick?: number;
232
285
  }
233
286
 
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;
287
+ export const PeerSelection = memo(
288
+ function PeerSelection({ peer }: PeerSelectionProps): JSX.Element | null {
289
+ if (!peer.selection) return null;
290
+ const { cssPath, bounds } = peer.selection;
239
291
 
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 };
292
+ let rect: { x: number; y: number; w: number; h: number } | null = bounds ?? null;
293
+ if (cssPath && typeof document !== 'undefined') {
294
+ try {
295
+ const el = document.querySelector(cssPath);
296
+ if (el && el instanceof Element) {
297
+ const r = el.getBoundingClientRect();
298
+ if (r.width > 0 && r.height > 0) {
299
+ rect = { x: r.left, y: r.top, w: r.width, h: r.height };
300
+ }
248
301
  }
302
+ } catch {
303
+ /* invalid selector — fall through to published bounds */
249
304
  }
250
- } catch {
251
- /* invalid selector — fall through to published bounds */
252
305
  }
253
- }
254
- if (!rect) return null;
306
+ if (!rect) return null;
255
307
 
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}
308
+ return (
309
+ <div
310
+ className="dc-peer-selection"
311
+ style={{
312
+ transform: `translate(${rect.x}px, ${rect.y}px)`,
313
+ width: rect.w,
314
+ height: rect.h,
315
+ borderColor: peer.color,
316
+ }}
317
+ >
318
+ <div className="dc-peer-selection__label" style={{ background: peer.color }}>
319
+ {peer.name}
320
+ </div>
268
321
  </div>
269
- </div>
270
- );
271
- });
322
+ );
323
+ },
324
+ (a, b) =>
325
+ sameViewport(a.viewport, b.viewport) &&
326
+ a.tick === b.tick &&
327
+ sameLabel(a.peer, b.peer) &&
328
+ sameSelection(a.peer.selection, b.peer.selection)
329
+ );
272
330
 
273
331
  /**
274
332
  * Subscribes to foreign awareness + the local viewport. Renders one Cursor per
@@ -294,11 +352,11 @@ export function CursorsOverlay(): JSX.Element {
294
352
  // sibling CursorsOverlay wouldn't re-render, so the annotation halo would
295
353
  // sit on the OLD bounds until awareness independently changed.
296
354
  const collab = useCollab();
297
- const [, bumpAnnotTick] = useState(0);
355
+ const [tick, bumpTick] = useState(0);
298
356
  useEffect(() => {
299
357
  if (!collab) return;
300
358
  const map = collab.doc.getMap('annotations');
301
- const onChange = () => bumpAnnotTick((n) => n + 1);
359
+ const onChange = () => bumpTick((n) => n + 1);
302
360
  map.observe(onChange);
303
361
  return () => {
304
362
  try {
@@ -308,13 +366,29 @@ export function CursorsOverlay(): JSX.Element {
308
366
  }
309
367
  };
310
368
  }, [collab]);
369
+ // A peer's selected element can change size or move with no camera or
370
+ // selection change (they edit its text; the canvas re-renders). The halos
371
+ // used to catch that for free by re-measuring on every awareness change —
372
+ // up to 30× a second per moving peer (#131). A slow refresh while somebody
373
+ // has something selected keeps the halo honest for a tiny fraction of that.
374
+ const anySelection = peers.some((p) => !!p.selection || (p.annotationSelection?.length ?? 0) > 0);
375
+ useEffect(() => {
376
+ if (!anySelection) return;
377
+ const id = setInterval(() => bumpTick((n) => n + 1), HALO_REFRESH_MS);
378
+ return () => clearInterval(id);
379
+ }, [anySelection]);
311
380
  return (
312
381
  <div className="dc-cursor-overlay" aria-hidden="true">
313
382
  {peers.map((peer) => (
314
- <PeerSelection key={`sel-${peer.clientID}`} peer={peer} viewport={vp} />
383
+ <PeerSelection key={`sel-${peer.clientID}`} peer={peer} viewport={vp} tick={tick} />
315
384
  ))}
316
385
  {peers.map((peer) => (
317
- <PeerAnnotationSelection key={`asel-${peer.clientID}`} peer={peer} viewport={vp} />
386
+ <PeerAnnotationSelection
387
+ key={`asel-${peer.clientID}`}
388
+ peer={peer}
389
+ viewport={vp}
390
+ tick={tick}
391
+ />
318
392
  ))}
319
393
  {peers.map((peer) => (
320
394
  <Cursor key={peer.clientID} peer={peer} viewport={vp} />