feat(collab): follow-user (collab-presence 0008) + chip depth-layer fix
Follow-user: click a peer's roster avatar to mirror their viewport until local input breaks it. - collab_presence_core.h: CORE::fitViewport(cx, cy, halfW, halfH) — fit the leader's world rect with CONTAIN semantics (zoom derived from the follower's own canvas via the ToScreen ratio; GetScale is the zoom, not px/IU). Exported as kicadCollabFitViewport from both editor TUs + the merged dispatcher. - presence-kicad.ts: publish the visible world rect (viewportRect) into awareness, 100 ms trailing throttle; guarded for pre-0008 handles. - follow-user.ts: createFollow — follows an awareness CLIENT (a tab, not a user); applies leader rect changes via FitViewport, dedupes unchanged republishes; break-on-interact compares local onViewport echoes against the last applied rect (2% rel tolerance, echo-grace before the first fit lands); unfollows on leader-left; pauses on eeschema sheet mismatch. - PresenceRoster: avatars are follow toggles (ring on the followed peer); WasmTool renders the "Following <name> — move to stop" banner. - tests: 7 controller units (85/85 collab), fitViewport round-trip e2e in both kicad presence specs (20/20), two-tab tests/web/follow.spec.ts (converge → track → wheel-zoom breaks → subsequent moves ignored). Chip depth-layer fix (user-reported): name chips washed out inside low-alpha selection fills — chip rects shared the shapes overlay's single depth, and same-depth fragments drawn LATER lose the depth test, so an earlier-painted fill rejected the chip's pixels. Now three layers via the fork's VIEW_OVERLAY::SetDepthOffset: text (0) < chips + pin dots (1) < selection shapes (2). drawLabel/drawCursor/drawSelectionBox take the chip overlay explicitly; comment-pin dots move to the chip layer too (the 0005 "drawn last so pins sit above" comment had the rule backwards). Verified with a chip-inside-30%-fill pixel repro + the full presence suite. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013u9h8fkQktH7KRECaHJmUG
This commit is contained in:
parent
9697de7b26
commit
1f85e13acf
16 changed files with 821 additions and 52 deletions
|
|
@ -1 +1 @@
|
|||
Subproject commit b68ee96a85b4793ebd15afab5a68f296406b53b5
|
||||
Subproject commit 1c4103c583b7326245a288a5ff63efe18dafcbf1
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import type { PresencePeer } from "@/wasm/collab/presence";
|
||||
import type { FollowTarget } from "@/wasm/collab/follow-user";
|
||||
|
||||
/**
|
||||
* "Who else is in this file" (collab-presence 0001/0003): a compact facepile of
|
||||
|
|
@ -8,6 +9,11 @@ import type { PresencePeer } from "@/wasm/collab/presence";
|
|||
* they're on in the tooltip. Rendered in the editor's top-right overlay stack
|
||||
* next to SourceChip; the parent hides it when there are no peers. Chip styling
|
||||
* mirrors SourceChip (solid fill + inset ring) so it is legible on any backdrop.
|
||||
*
|
||||
* Follow-user (0008): when `onFollow` is provided, clicking a same-sheet
|
||||
* peer's avatar follows their viewport (click again to stop); the followed
|
||||
* peer gets a ring in their color. The parent renders the "Following…"
|
||||
* banner — the roster only toggles.
|
||||
*/
|
||||
const MAX_AVATARS = 5;
|
||||
|
||||
|
|
@ -20,10 +26,16 @@ function sheetLabel(sheetPath?: string): string {
|
|||
export function PresenceRoster({
|
||||
peers,
|
||||
activeSheetPath,
|
||||
following,
|
||||
onFollow,
|
||||
}: {
|
||||
peers: PresencePeer[];
|
||||
/** eeschema: the sheet THIS client is on — peers elsewhere render dimmed. */
|
||||
activeSheetPath?: string;
|
||||
/** 0008: the followed client, when a follow is active. */
|
||||
following?: FollowTarget | null;
|
||||
/** 0008: toggle following a peer (null = stop). Absent = follow UI off. */
|
||||
onFollow?: (target: FollowTarget | null) => void;
|
||||
}) {
|
||||
if (!peers.length) return null;
|
||||
|
||||
|
|
@ -38,6 +50,16 @@ export function PresenceRoster({
|
|||
.join(", ");
|
||||
const shown = ordered.slice(0, MAX_AVATARS);
|
||||
|
||||
const followable = (p: PresencePeer) => !!onFollow && sameSheet(p);
|
||||
const isFollowed = (p: PresencePeer) => following?.clientId === p.clientId;
|
||||
|
||||
const toggleFollow = (p: PresencePeer) => {
|
||||
if (!onFollow) return;
|
||||
onFollow(
|
||||
isFollowed(p) ? null : { clientId: p.clientId, userId: p.user.id, name: p.user.name },
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
data-testid="presence-roster"
|
||||
|
|
@ -45,22 +67,29 @@ export function PresenceRoster({
|
|||
className="inline-flex items-center rounded-full bg-black/70 py-0.5 pl-1 pr-1.5 shadow-sm ring-1 ring-inset ring-white/20"
|
||||
>
|
||||
{shown.map((p) => (
|
||||
<span
|
||||
<button
|
||||
key={p.user.id}
|
||||
type="button"
|
||||
data-presence-user={p.user.id}
|
||||
data-presence-elsewhere={sameSheet(p) ? undefined : "1"}
|
||||
data-presence-following={isFollowed(p) ? "1" : undefined}
|
||||
disabled={!followable(p)}
|
||||
onClick={() => toggleFollow(p)}
|
||||
title={
|
||||
sameSheet(p)
|
||||
? p.user.name
|
||||
? `${p.user.name}${onFollow ? (isFollowed(p) ? " — click to stop following" : " — click to follow") : ""}`
|
||||
: `${p.user.name} — on ${sheetLabel(p.sheetPath) || "another sheet"}`
|
||||
}
|
||||
style={{ backgroundColor: p.user.color }}
|
||||
style={{
|
||||
backgroundColor: p.user.color,
|
||||
...(isFollowed(p) ? { boxShadow: `0 0 0 2px ${p.user.color}` } : {}),
|
||||
}}
|
||||
className={`-ml-1.5 flex h-5 w-5 items-center justify-center rounded-full text-[10px] font-semibold text-white ring-2 ring-black/50 first:ml-0 ${
|
||||
sameSheet(p) ? "" : "opacity-40"
|
||||
sameSheet(p) ? "cursor-pointer" : "opacity-40"
|
||||
}`}
|
||||
>
|
||||
{p.user.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{peers.length > MAX_AVATARS && (
|
||||
<span className="ml-1 text-[10px] font-medium text-white/80">
|
||||
|
|
|
|||
|
|
@ -56,8 +56,14 @@ import {
|
|||
import {
|
||||
bindKicadPresence,
|
||||
hasPresenceBridge,
|
||||
type PresenceKicadModule,
|
||||
type PresenceKicadWindow,
|
||||
} from "@/wasm/collab/presence-kicad";
|
||||
import {
|
||||
createFollow,
|
||||
type FollowHandle,
|
||||
type FollowTarget,
|
||||
} from "@/wasm/collab/follow-user";
|
||||
import { startCrossAppPresence, type CrossAppHandle } from "@/wasm/collab/cross-app";
|
||||
import {
|
||||
createComments,
|
||||
|
|
@ -720,6 +726,8 @@ export function WasmTool({
|
|||
const driftRef = React.useRef<{ stop(): void } | null>(null);
|
||||
const presenceRef = React.useRef<PresenceHandle | null>(null);
|
||||
const presenceBridgeRef = React.useRef<{ destroy(): void } | null>(null);
|
||||
// Follow-user (0008): mirror a peer's viewport until local input breaks it.
|
||||
const followRef = React.useRef<FollowHandle | null>(null);
|
||||
// Project-wide presence room (0006): joined once per session, survives
|
||||
// eeschema sheet rebinds — the bridge re-reads it on every startPresence.
|
||||
const crossAppRef = React.useRef<CrossAppHandle | null>(null);
|
||||
|
|
@ -773,6 +781,8 @@ export function WasmTool({
|
|||
// eeschema: the sheet THIS client is bound to — the roster dims peers whose
|
||||
// skeleton state says they're on a different sheet (collab-presence 0003).
|
||||
const [activeSheetPath, setActiveSheetPath] = React.useState<string | undefined>();
|
||||
// Follow-user (0008): the followed roster client, for the ring + banner.
|
||||
const [followingTarget, setFollowingTarget] = React.useState<FollowTarget | null>(null);
|
||||
// Comments (0005): the bound doc's controller + the live viewport transform
|
||||
// the DOM layer maps world→CSS with. Both rebind with the collab session
|
||||
// (per sheet in eeschema).
|
||||
|
|
@ -922,6 +932,9 @@ export function WasmTool({
|
|||
// pcbnew/pl_editor bind once; eeschema rebinds per active sheet, so the
|
||||
// roster shows who is on the SAME sheet (room = sheet).
|
||||
const startPresence = (provider: YjsProvider | undefined, sheetPath?: string) => {
|
||||
followRef.current?.destroy();
|
||||
followRef.current = null;
|
||||
setFollowingTarget(null);
|
||||
presenceBridgeRef.current?.destroy();
|
||||
presenceBridgeRef.current = null;
|
||||
presenceRef.current?.destroy();
|
||||
|
|
@ -945,14 +958,29 @@ export function WasmTool({
|
|||
// and the remote VIEW_OVERLAY render. The bridge gate skips tools without
|
||||
// the exports and wasm builds predating them.
|
||||
if ((tool === "pcbnew" || tool === "eeschema") && hasPresenceBridge(win.Module)) {
|
||||
// Follow-user (0008): available when the wasm exports FitViewport.
|
||||
const fitFn = (win.Module as PresenceKicadModule).kicadCollabFitViewport;
|
||||
if (fitFn) {
|
||||
const follow = createFollow({
|
||||
presence,
|
||||
fit: (cx, cy, halfW, halfH) => fitFn.call(win.Module, cx, cy, halfW, halfH),
|
||||
ownSheetPath: () => sheetPath,
|
||||
});
|
||||
follow.subscribe(setFollowingTarget);
|
||||
followRef.current = follow;
|
||||
}
|
||||
presenceBridgeRef.current = bindKicadPresence({
|
||||
mod: win.Module,
|
||||
win: win as unknown as PresenceKicadWindow,
|
||||
presence,
|
||||
// Cross-app selection (0006): the project presence room, if joined.
|
||||
crossApp: crossAppRef.current ?? undefined,
|
||||
// Live world↔screen transform for the DOM comment layer (0005).
|
||||
onViewport: setViewportState,
|
||||
// Live world↔screen transform for the DOM comment layer (0005) +
|
||||
// the follow controller's echo/break detection (0008).
|
||||
onViewport: (vp) => {
|
||||
setViewportState(vp);
|
||||
followRef.current?.noteLocalViewport(vp);
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -1212,6 +1240,8 @@ export function WasmTool({
|
|||
win.removeEventListener("keydown", chromeHotkey, true);
|
||||
commentsRef.current?.destroy();
|
||||
commentsRef.current = null;
|
||||
followRef.current?.destroy();
|
||||
followRef.current = null;
|
||||
presenceBridgeRef.current?.destroy();
|
||||
presenceBridgeRef.current = null;
|
||||
presenceRef.current?.destroy();
|
||||
|
|
@ -1392,6 +1422,27 @@ export function WasmTool({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Follow-user (0008): who we're following + how to stop. Esc also works
|
||||
because any canvas key input breaks the follow via noteLocalViewport
|
||||
only when the viewport moves — this banner is the explicit out. */}
|
||||
{ready && followingTarget && (
|
||||
<div
|
||||
data-testid="follow-banner"
|
||||
className="absolute left-1/2 top-3 z-20 flex -translate-x-1/2 items-center gap-2 rounded-full bg-black/70 px-3 py-1 text-xs text-white shadow-sm ring-1 ring-inset ring-white/20"
|
||||
>
|
||||
<span>
|
||||
Following <span className="font-semibold">{followingTarget.name}</span> — move to stop
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full bg-white/15 px-2 py-0.5 font-medium hover:bg-white/25"
|
||||
onClick={() => followRef.current?.unfollow()}
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top-right overlay row: who else is in this file (awareness roster),
|
||||
where this project lives / whether Save persists (chip hidden while
|
||||
the UI is hidden), and the Figma-like hide/show-UI toggle — the one
|
||||
|
|
@ -1402,7 +1453,15 @@ export function WasmTool({
|
|||
(sourceDescriptor && !chromeHidden)) && (
|
||||
<div className="absolute right-3 top-3 z-20 flex items-center gap-2">
|
||||
{peers.length > 0 && (
|
||||
<PresenceRoster peers={peers} activeSheetPath={activeSheetPath} />
|
||||
<PresenceRoster
|
||||
peers={peers}
|
||||
activeSheetPath={activeSheetPath}
|
||||
following={followingTarget}
|
||||
onFollow={(t) => {
|
||||
if (t) followRef.current?.follow(t);
|
||||
else followRef.current?.unfollow();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{sourceDescriptor && !chromeHidden && (
|
||||
<SourceChip descriptor={sourceDescriptor} />
|
||||
|
|
|
|||
147
web/standalone/src/wasm/collab/follow-user.test.ts
Normal file
147
web/standalone/src/wasm/collab/follow-user.test.ts
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { PresencePeer } from "./presence";
|
||||
import type { ViewportState } from "./presence-kicad";
|
||||
import { createFollow, type FollowTarget } from "./follow-user";
|
||||
|
||||
/**
|
||||
* Follow-user controller units (collab-presence 0008): apply/republish
|
||||
* dedupe, echo suppression, break-on-interact, leader-left, sheet gating.
|
||||
* The presence handle is faked — only clients() + subscribe are used.
|
||||
*/
|
||||
|
||||
const LEADER: FollowTarget = { clientId: 7, userId: "u-lead", name: "lead" };
|
||||
|
||||
function peer(over: Partial<PresencePeer> = {}): PresencePeer {
|
||||
return {
|
||||
clientId: 7,
|
||||
user: { id: "u-lead", name: "lead", color: "#ef4444" },
|
||||
tool: "pcbnew",
|
||||
cursor: null,
|
||||
selection: [],
|
||||
viewport: { cx: 100, cy: 200, halfW: 50, halfH: 40 },
|
||||
updatedAt: 1,
|
||||
...over,
|
||||
} as PresencePeer;
|
||||
}
|
||||
|
||||
function fakePresence(initial: PresencePeer[]) {
|
||||
let clients = initial;
|
||||
const subs = new Set<() => void>();
|
||||
return {
|
||||
handle: {
|
||||
clients: () => clients,
|
||||
subscribe(cb: () => void) {
|
||||
subs.add(cb);
|
||||
return () => subs.delete(cb);
|
||||
},
|
||||
} as unknown as import("./presence").PresenceHandle,
|
||||
update(next: PresencePeer[]) {
|
||||
clients = next;
|
||||
for (const cb of subs) cb();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A local onViewport emit whose world rect equals `rect` on a canvas of
|
||||
* aspect w:h (contain fit: the wider axis overshoots). */
|
||||
function echoFor(
|
||||
rect: { cx: number; cy: number; halfW: number; halfH: number },
|
||||
w = 1000,
|
||||
h = 800,
|
||||
): ViewportState {
|
||||
const scale = Math.min(w / (2 * rect.halfW), h / (2 * rect.halfH));
|
||||
return { cx: rect.cx, cy: rect.cy, scale, w, h };
|
||||
}
|
||||
|
||||
describe("createFollow", () => {
|
||||
it("applies the leader's viewport on follow and on change, deduping republish", () => {
|
||||
const p = fakePresence([peer()]);
|
||||
const fit = vi.fn();
|
||||
const follow = createFollow({ presence: p.handle, fit });
|
||||
|
||||
follow.follow(LEADER);
|
||||
expect(fit).toHaveBeenCalledWith(100, 200, 50, 40);
|
||||
|
||||
// Republished but UNCHANGED viewport (e.g. a selection edit) → no re-fit.
|
||||
p.update([peer()]);
|
||||
expect(fit).toHaveBeenCalledTimes(1);
|
||||
|
||||
p.update([peer({ viewport: { cx: 110, cy: 200, halfW: 50, halfH: 40 } })]);
|
||||
expect(fit).toHaveBeenCalledTimes(2);
|
||||
expect(fit).toHaveBeenLastCalledWith(110, 200, 50, 40);
|
||||
});
|
||||
|
||||
it("suppresses the fit's own echo, breaks on a deviating local viewport", () => {
|
||||
const p = fakePresence([peer()]);
|
||||
const fit = vi.fn();
|
||||
const follow = createFollow({ presence: p.handle, fit });
|
||||
follow.follow(LEADER);
|
||||
|
||||
const rect = { cx: 100, cy: 200, halfW: 50, halfH: 40 };
|
||||
follow.noteLocalViewport(echoFor(rect));
|
||||
expect(follow.following()).toEqual(LEADER);
|
||||
|
||||
// User pans far away → follow ends.
|
||||
follow.noteLocalViewport(echoFor({ ...rect, cx: 500 }));
|
||||
expect(follow.following()).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores a stale pre-echo local viewport (the fit hasn't landed yet)", () => {
|
||||
const p = fakePresence([peer()]);
|
||||
const fit = vi.fn();
|
||||
const follow = createFollow({ presence: p.handle, fit });
|
||||
follow.follow(LEADER);
|
||||
|
||||
// The canvas still shows the pre-follow region — not a user break.
|
||||
follow.noteLocalViewport(echoFor({ cx: 9999, cy: 9999, halfW: 10, halfH: 10 }));
|
||||
expect(follow.following()).toEqual(LEADER);
|
||||
});
|
||||
|
||||
it("a user zoom breaks the follow even at the same center", () => {
|
||||
const p = fakePresence([peer()]);
|
||||
const follow = createFollow({ presence: p.handle, fit: vi.fn() });
|
||||
follow.follow(LEADER);
|
||||
|
||||
const rect = { cx: 100, cy: 200, halfW: 50, halfH: 40 };
|
||||
follow.noteLocalViewport(echoFor(rect));
|
||||
// Zoom-in: both half-extents shrink well past the tolerance.
|
||||
follow.noteLocalViewport(echoFor({ ...rect, halfW: 25, halfH: 20 }));
|
||||
expect(follow.following()).toBeNull();
|
||||
});
|
||||
|
||||
it("unfollows when the leader leaves the room", () => {
|
||||
const p = fakePresence([peer()]);
|
||||
const follow = createFollow({ presence: p.handle, fit: vi.fn() });
|
||||
follow.follow(LEADER);
|
||||
|
||||
p.update([]);
|
||||
expect(follow.following()).toBeNull();
|
||||
});
|
||||
|
||||
it("pauses (does not fit) while an eeschema leader is on another sheet", () => {
|
||||
const p = fakePresence([peer({ sheetPath: "sub.kicad_sch" })]);
|
||||
const fit = vi.fn();
|
||||
const follow = createFollow({
|
||||
presence: p.handle,
|
||||
fit,
|
||||
ownSheetPath: () => "demo.kicad_sch",
|
||||
});
|
||||
follow.follow(LEADER);
|
||||
expect(fit).not.toHaveBeenCalled();
|
||||
|
||||
// Leader returns to our sheet → fit resumes.
|
||||
p.update([peer({ sheetPath: "demo.kicad_sch" })]);
|
||||
expect(fit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("notifies subscribers on follow and unfollow", () => {
|
||||
const p = fakePresence([peer()]);
|
||||
const follow = createFollow({ presence: p.handle, fit: vi.fn() });
|
||||
const seen: Array<FollowTarget | null> = [];
|
||||
follow.subscribe((t) => seen.push(t));
|
||||
|
||||
follow.follow(LEADER);
|
||||
follow.unfollow();
|
||||
expect(seen).toEqual([LEADER, null]);
|
||||
});
|
||||
});
|
||||
169
web/standalone/src/wasm/collab/follow-user.ts
Normal file
169
web/standalone/src/wasm/collab/follow-user.ts
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
import type { PresenceViewport } from "@pcbjam/shared";
|
||||
import { clog } from "./debug";
|
||||
import type { PresenceHandle, PresencePeer } from "./presence";
|
||||
import type { ViewportState } from "./presence-kicad";
|
||||
import { viewportRect } from "./presence-kicad";
|
||||
|
||||
/**
|
||||
* Follow-user controller (collab-presence 0008): mirror a peer's viewport
|
||||
* until the local user interacts.
|
||||
*
|
||||
* Follows an awareness CLIENT (a specific tab), not a user — a user's two
|
||||
* tabs can show different regions and "follow" means watching one screen.
|
||||
* On every roster change the leader's published world rect (presence wire
|
||||
* `viewport`) is applied through `kicadCollabFitViewport` (contain: the zoom
|
||||
* derives from OUR canvas, so different window sizes see the same region).
|
||||
*
|
||||
* Break-on-interact needs no extra input hooks: each applied fit remembers
|
||||
* its target, and every local `onViewport` echo is compared against it —
|
||||
* a matching echo (epsilon — the rect round-trips through the GAL matrix)
|
||||
* is our own fit landing; a deviating one means the user panned/zoomed/
|
||||
* jumped locally, so the follow ends. Same-tool, same-sheet only in v1:
|
||||
* an eeschema leader on another sheet pauses the fit (rect coordinates are
|
||||
* per-sheet); cross-tool follow is out of scope.
|
||||
*
|
||||
* Follow loops (A follows B, B follows A) are benign: neither side produces
|
||||
* a non-echo viewport change, so both simply idle.
|
||||
*/
|
||||
|
||||
export interface FollowTarget {
|
||||
clientId: number;
|
||||
userId: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface FollowHandle {
|
||||
/** Start following a roster client; replaces any current follow. */
|
||||
follow(target: FollowTarget): void;
|
||||
/** Stop following (explicit UI action — Esc / click). */
|
||||
unfollow(): void;
|
||||
/** The current follow target, or null. */
|
||||
following(): FollowTarget | null;
|
||||
/** Fires with the new target (or null) on every follow state change. */
|
||||
subscribe(cb: (target: FollowTarget | null) => void): () => void;
|
||||
/** Feed every local onViewport emit here (echo suppression + break). */
|
||||
noteLocalViewport(vp: ViewportState): void;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
/** Relative tolerance for "this local viewport is our own fit's echo": the
|
||||
* rect round-trips world→zoom→world through the GAL matrix, and the canvas
|
||||
* aspect makes one axis overshoot (contain), so compare center and the
|
||||
* CONTAINED axis loosely. */
|
||||
const ECHO_REL_TOLERANCE = 0.02;
|
||||
|
||||
function isEcho(vp: ViewportState, target: PresenceViewport): boolean {
|
||||
const rect = viewportRect(vp);
|
||||
if (!rect) return true; // degenerate transform — never treat as user input
|
||||
|
||||
// Scale-relative center comparison: a pan of >2% of the view is a break.
|
||||
const tolX = Math.max(rect.halfW, target.halfW) * ECHO_REL_TOLERANCE;
|
||||
const tolY = Math.max(rect.halfH, target.halfH) * ECHO_REL_TOLERANCE;
|
||||
if (Math.abs(rect.cx - target.cx) > tolX) return false;
|
||||
if (Math.abs(rect.cy - target.cy) > tolY) return false;
|
||||
|
||||
// Contain-fit: exactly one axis matches the target's half-extent (the other
|
||||
// overshoots by the aspect difference). A zoom by the user changes BOTH away
|
||||
// from the target.
|
||||
const relW = Math.abs(rect.halfW - target.halfW) / target.halfW;
|
||||
const relH = Math.abs(rect.halfH - target.halfH) / target.halfH;
|
||||
return relW <= ECHO_REL_TOLERANCE || relH <= ECHO_REL_TOLERANCE;
|
||||
}
|
||||
|
||||
export function createFollow(opts: {
|
||||
presence: PresenceHandle;
|
||||
/** Module.kicadCollabFitViewport, bound; absent on older wasm → no-op UI. */
|
||||
fit: (cx: number, cy: number, halfW: number, halfH: number) => void;
|
||||
/** The sheet THIS tab shows (eeschema); undefined for single-doc tools. */
|
||||
ownSheetPath?: () => string | undefined;
|
||||
}): FollowHandle {
|
||||
const { presence, fit } = opts;
|
||||
|
||||
let target: FollowTarget | null = null;
|
||||
/** The last rect we asked the canvas to fit — echoes match against this. */
|
||||
let applied: PresenceViewport | null = null;
|
||||
/** Grace: ignore break-checks until the first fit's echo arrived, else the
|
||||
* stale pre-follow viewport emit would instantly end the follow. */
|
||||
let sawEcho = false;
|
||||
|
||||
const subscribers = new Set<(t: FollowTarget | null) => void>();
|
||||
const notify = () => {
|
||||
for (const cb of subscribers) cb(target);
|
||||
};
|
||||
|
||||
const leaderState = (): PresencePeer | undefined =>
|
||||
presence.clients().find((c) => c.clientId === target?.clientId);
|
||||
|
||||
const applyLeader = () => {
|
||||
if (!target) return;
|
||||
const leader = leaderState();
|
||||
if (!leader) {
|
||||
// Leader left the room — follow ends (their awareness state expired).
|
||||
clog("follow: leader left —", target.name);
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
// v1: rect coordinates are per-document; pause while the leader is on a
|
||||
// different sheet than this canvas shows (eeschema warm-pool rooms).
|
||||
const ownSheet = opts.ownSheetPath?.();
|
||||
if (ownSheet !== undefined && leader.sheetPath !== undefined && leader.sheetPath !== ownSheet) {
|
||||
return;
|
||||
}
|
||||
const rect = leader.viewport;
|
||||
if (!rect) return;
|
||||
if (
|
||||
applied &&
|
||||
rect.cx === applied.cx &&
|
||||
rect.cy === applied.cy &&
|
||||
rect.halfW === applied.halfW &&
|
||||
rect.halfH === applied.halfH
|
||||
) {
|
||||
return; // leader republished an unchanged viewport (e.g. selection edit)
|
||||
}
|
||||
applied = rect;
|
||||
sawEcho = false;
|
||||
fit(rect.cx, rect.cy, rect.halfW, rect.halfH);
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
if (!target) return;
|
||||
target = null;
|
||||
applied = null;
|
||||
sawEcho = false;
|
||||
notify();
|
||||
};
|
||||
|
||||
const unsubscribePresence = presence.subscribe(() => applyLeader());
|
||||
|
||||
return {
|
||||
follow(t) {
|
||||
target = t;
|
||||
applied = null;
|
||||
sawEcho = false;
|
||||
clog("follow: following", t.name, "client", t.clientId);
|
||||
notify();
|
||||
applyLeader();
|
||||
},
|
||||
unfollow: stop,
|
||||
following: () => target,
|
||||
subscribe(cb) {
|
||||
subscribers.add(cb);
|
||||
return () => subscribers.delete(cb);
|
||||
},
|
||||
noteLocalViewport(vp) {
|
||||
if (!target || !applied) return;
|
||||
if (isEcho(vp, applied)) {
|
||||
sawEcho = true;
|
||||
return;
|
||||
}
|
||||
if (!sawEcho) return; // pre-fit stale emit — the fit hasn't landed yet
|
||||
clog("follow: local input — stopped following", target.name);
|
||||
stop();
|
||||
},
|
||||
destroy() {
|
||||
unsubscribePresence();
|
||||
subscribers.clear();
|
||||
target = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -41,6 +41,7 @@ function stubPresence(peers: PresencePeer[] = []) {
|
|||
},
|
||||
setCursor: vi.fn(),
|
||||
setSelection: vi.fn(),
|
||||
setViewport: vi.fn(),
|
||||
colorOf: vi.fn((id: string) => colorForUser(id)),
|
||||
destroy: vi.fn(),
|
||||
firePeers(p) {
|
||||
|
|
|
|||
|
|
@ -32,6 +32,9 @@ export interface PresenceKicadModule {
|
|||
/** 0007: tiebreak release — the local client lost an overlapping hold;
|
||||
* the wasm side cancels an in-flight move and unselects exactly these. */
|
||||
kicadCollabReleaseSelection?(uuidsJson: string, holder: string): void;
|
||||
/** 0008: fit a leader's world rect (center + half-extents, IU) into this
|
||||
* canvas — contain semantics. Absent on older wasm builds. */
|
||||
kicadCollabFitViewport?(cx: number, cy: number, halfW: number, halfH: number): void;
|
||||
}
|
||||
|
||||
export interface PresenceKicadWindow {
|
||||
|
|
@ -62,6 +65,20 @@ export function hasPresenceBridge(mod: unknown): mod is PresenceKicadModule {
|
|||
|
||||
const PUSH_THROTTLE_MS = 30;
|
||||
|
||||
// Viewport publishes ride awareness at their own (slower) trailing cadence —
|
||||
// a smooth pan fires onViewport per frame and peers only need follow-rate.
|
||||
const VIEWPORT_PUBLISH_MS = 100;
|
||||
|
||||
/** The follow-user world rect for a GAL transform: half-extents = half the
|
||||
* canvas in world units (scale = px per IU). Null while the frame is not up
|
||||
* (zero-sized canvas / no scale yet). */
|
||||
export function viewportRect(
|
||||
vp: ViewportState,
|
||||
): { cx: number; cy: number; halfW: number; halfH: number } | null {
|
||||
if (!(vp.scale > 0) || !(vp.w > 0) || !(vp.h > 0)) return null;
|
||||
return { cx: vp.cx, cy: vp.cy, halfW: vp.w / 2 / vp.scale, halfH: vp.h / 2 / vp.scale };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a C++ selection emit (0006): pcbnew emits `{uuids, fpPaths}` (paths =
|
||||
* the selected footprints' `GetPath()` strings), eeschema and older builds a
|
||||
|
|
@ -144,10 +161,37 @@ export function bindKicadPresence(opts: {
|
|||
presence.setCursor(active ? { x, y } : null);
|
||||
},
|
||||
onViewport: (cx, cy, scale, w, h) => {
|
||||
opts.onViewport?.({ cx, cy, scale, w, h });
|
||||
const vp = { cx, cy, scale, w, h };
|
||||
opts.onViewport?.(vp);
|
||||
scheduleViewportPublish(vp);
|
||||
},
|
||||
};
|
||||
|
||||
// Follow-user (0008): publish the visible world rect, trailing-throttled.
|
||||
let vpTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let vpLatest: ViewportState | undefined;
|
||||
const scheduleViewportPublish = (vp: ViewportState) => {
|
||||
vpLatest = vp;
|
||||
if (vpTimer) return;
|
||||
vpTimer = setTimeout(() => {
|
||||
vpTimer = undefined;
|
||||
// Guarded: pre-0008 handle implementations (and their test fakes) don't
|
||||
// have setViewport yet.
|
||||
if (vpLatest && typeof presence.setViewport === "function") {
|
||||
presence.setViewport(viewportRect(vpLatest));
|
||||
}
|
||||
}, VIEWPORT_PUBLISH_MS);
|
||||
};
|
||||
|
||||
// Seed the published viewport (pushes only happen on input events after
|
||||
// this) — same pull the comment layer uses.
|
||||
try {
|
||||
const vp = JSON.parse(mod.kicadCollabGetViewport() || "null") as ViewportState | null;
|
||||
if (vp && vp.w > 0) scheduleViewportPublish(vp);
|
||||
} catch {
|
||||
/* frame not up yet — the first input push seeds it */
|
||||
}
|
||||
|
||||
// Seed: the tab may attach with a selection already made (e.g. rebind). The
|
||||
// Full variant (pcbnew 0006) also carries footprint paths for cross-app.
|
||||
try {
|
||||
|
|
@ -241,6 +285,8 @@ export function bindKicadPresence(opts: {
|
|||
unsubscribeCross?.();
|
||||
if (pushTimer) clearTimeout(pushTimer);
|
||||
pushTimer = undefined;
|
||||
if (vpTimer) clearTimeout(vpTimer);
|
||||
vpTimer = undefined;
|
||||
if (win.kicadCollab) {
|
||||
delete win.kicadCollab.onSelection;
|
||||
delete win.kicadCollab.onCursor;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
presenceStateSchema,
|
||||
type PresenceState,
|
||||
type PresenceUser,
|
||||
type PresenceViewport,
|
||||
} from "@pcbjam/shared";
|
||||
import { clog } from "./debug";
|
||||
|
||||
|
|
@ -45,6 +46,9 @@ export interface PresenceHandle {
|
|||
setCursor(pos: { x: number; y: number } | null): void;
|
||||
/** 0002: publish the local selection's KIID strings. */
|
||||
setSelection(uuids: string[]): void;
|
||||
/** 0008: publish the visible world rect (center + half-extents, IU) for
|
||||
* followers; null while unknown. */
|
||||
setViewport(rect: PresenceViewport | null): void;
|
||||
/** A user's current presence color: their published one if they're in the
|
||||
* room, our claimed one for ourselves, else the palette-hash fallback
|
||||
* (offline users, e.g. old comment authors). */
|
||||
|
|
@ -277,6 +281,9 @@ export function createPresence(opts: {
|
|||
setSelection(uuids) {
|
||||
patch({ selection: uuids });
|
||||
},
|
||||
setViewport(rect) {
|
||||
patch({ viewport: rect });
|
||||
},
|
||||
colorOf(userId) {
|
||||
if (userId === user.id) return user.color;
|
||||
for (const [clientId, raw] of awareness.getStates()) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue