@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.
- package/apps/studio/annotations-model.ts +33 -1
- package/apps/studio/api.ts +19 -0
- package/apps/studio/canvas-comment-mount.tsx +48 -3
- package/apps/studio/canvas-lib.tsx +5 -0
- package/apps/studio/canvas-shell.tsx +110 -14
- package/apps/studio/canvas-source-memo.ts +39 -0
- package/apps/studio/canvas-text-patch.ts +117 -0
- package/apps/studio/client/app.jsx +108 -60
- package/apps/studio/client/comments-overlay.css +10 -0
- package/apps/studio/client/file-tree.jsx +157 -0
- package/apps/studio/client/index.html +1 -1
- package/apps/studio/client/panels/DiffView.jsx +37 -8
- package/apps/studio/client/panels/GitPanel.jsx +2 -1
- package/apps/studio/client/panels/SourceConflictPanel.jsx +19 -17
- package/apps/studio/client/styles/3-shell-maude.css +21 -9
- package/apps/studio/client/styles/4-components.css +4 -2
- package/apps/studio/collab/index.ts +9 -0
- package/apps/studio/collab/persistence.ts +49 -1
- package/apps/studio/comment-anchor.ts +116 -0
- package/apps/studio/comments-overlay.tsx +78 -35
- package/apps/studio/context.ts +1 -0
- package/apps/studio/cursors-overlay.tsx +181 -107
- package/apps/studio/dist/client.bundle.js +1086 -1086
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/hmr-broadcast.ts +59 -3
- package/apps/studio/http.ts +3 -0
- package/apps/studio/sync/accepted-cold-start.ts +95 -7
- package/apps/studio/sync/accepted-link.ts +165 -29
- package/apps/studio/sync/agent.ts +28 -2
- package/apps/studio/sync/comment-ledger.ts +229 -0
- package/apps/studio/sync/file-plane.ts +101 -2
- package/apps/studio/sync/index.ts +124 -17
- package/apps/studio/sync/journal-client.ts +9 -3
- package/apps/studio/sync/migrate-seed.ts +9 -2
- package/apps/studio/sync/projection.ts +114 -11
- package/apps/studio/sync/remote-docs.ts +3 -2
- package/apps/studio/sync/source-recovery.ts +67 -0
- package/apps/studio/sync/status.ts +11 -3
- package/apps/studio/sync/transaction-client.ts +49 -4
- package/apps/studio/use-collab.tsx +28 -1
- package/apps/studio/use-selection-set.tsx +13 -3
- package/apps/studio/use-undo-stack.tsx +22 -2
- package/apps/studio/whats-new.json +45 -0
- package/cli/lib/workspace-plan.mjs +10 -0
- package/package.json +8 -8
- 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)
|
|
37
|
-
*
|
|
38
|
-
*
|
|
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
|
|
840
|
-
//
|
|
841
|
-
//
|
|
842
|
-
|
|
843
|
-
|
|
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
|
|
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
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
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
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
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
|
|
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="([^"]+)"/);
|
package/apps/studio/context.ts
CHANGED
|
@@ -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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
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(
|
|
179
|
-
peer
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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(
|
|
235
|
-
peer
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
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 [,
|
|
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 = () =>
|
|
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
|
|
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} />
|