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:
Gergő Törcsvári 2026-07-09 09:30:40 +02:00
commit 1f85e13acf
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
16 changed files with 821 additions and 52 deletions

@ -1 +1 @@
Subproject commit b68ee96a85b4793ebd15afab5a68f296406b53b5
Subproject commit 1c4103c583b7326245a288a5ff63efe18dafcbf1

View file

@ -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">

View file

@ -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} />

View 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]);
});
});

View 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;
},
};
}

View file

@ -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) {

View file

@ -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;

View file

@ -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()) {