feat(collab): presence P2 — pcbnew selection/cursor emit + remote VIEW_OVERLAY render (collab-presence 0002)

Zero kicad-fork changes — all in the embind layer:
- pcbnew_embind.cpp: presence section — canvas wx Bind() triggers (motion/
  leave/up/key/wheel) + COLLAB_LISTENER piggyback → post-settle selection
  check (dedupe) → onSelection; throttled cursor emit via VIEW::ToWorld;
  viewport push/pull (px-per-IU via the GAL matrix — GetScale() is the zoom
  and sized the first cut's overlay nm-small); kicadCollabSetRemote renders
  peers' cursors (cross + name) and selection bbox outlines into one
  per-user-colored VIEW_OVERLAY (CallAfter+COROUTINE), never touching local
  selection; PresenceStart/GetSelection/TestSelectFirst/TestClearSelection.
- kicad_editor_embind.cpp: merged-image dispatch (pcb-only until 0003).
- presence-kicad.ts: bindKicadPresence — routes emits into awareness (0001)
  and pushes trailing-throttled peer snapshots into the wasm; wired from
  WasmTool.startPresence (pcbnew-gated). +5 unit tests.
- tests/kicad/presence-pcbnew.spec.ts: 5 e2e — programmatic + real box-select
  emit, throttled cursor, remote render with no-leak + pixel restore,
  viewport unit band. Existing pcbnew-collab/items-bridge suites stay green.

Verified live: two tabs over partykit — peer cursor cross + label + selection
outline visible on the other tab's canvas.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CvqUd4QsJSGHN28aunJRTq
This commit is contained in:
Gergő Törcsvári 2026-07-06 15:59:51 +02:00
commit 8ad1cc673a
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
6 changed files with 1162 additions and 0 deletions

View file

@ -52,6 +52,11 @@ import {
type PresenceHandle,
type PresencePeer,
} from "@/wasm/collab/presence";
import {
bindKicadPresence,
hasPresenceBridge,
type PresenceKicadWindow,
} from "@/wasm/collab/presence-kicad";
import { PresenceRoster } from "@/components/PresenceRoster";
import {
createSheetCollabManager,
@ -580,6 +585,7 @@ export function WasmTool({
const startedRef = React.useRef(false);
const driftRef = React.useRef<{ stop(): void } | null>(null);
const presenceRef = React.useRef<PresenceHandle | null>(null);
const presenceBridgeRef = React.useRef<{ destroy(): void } | null>(null);
const sheetManagerRef = React.useRef<SheetCollabManager | null>(null);
// The single-room collab doc (pcbnew/pl_editor), for the layout save-sync
// (miss 08B); eeschema routes per sheet through the manager instead.
@ -748,6 +754,8 @@ 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) => {
presenceBridgeRef.current?.destroy();
presenceBridgeRef.current = null;
presenceRef.current?.destroy();
presenceRef.current = null;
const awareness = provider?.awareness;
@ -764,6 +772,16 @@ export function WasmTool({
presenceRef.current = presence;
presence.subscribe(setPeers);
setPeers(presence.peers());
// Canvas presence (0002): cursor + selection emit and the remote
// VIEW_OVERLAY render. pcbnew only until the eeschema port (0003); the
// bridge gate also skips wasm builds predating the presence exports.
if (tool === "pcbnew" && hasPresenceBridge(win.Module)) {
presenceBridgeRef.current = bindKicadPresence({
mod: win.Module,
win: win as unknown as PresenceKicadWindow,
presence,
});
}
};
// Cmd/Ctrl+S belongs to the editor: preventDefault suppresses ONLY the
@ -949,6 +967,8 @@ export function WasmTool({
return () => {
win.removeEventListener("keydown", swallowBrowserSave, true);
presenceBridgeRef.current?.destroy();
presenceBridgeRef.current = null;
presenceRef.current?.destroy();
presenceRef.current = null;
driftRef.current?.stop();

View file

@ -0,0 +1,124 @@
import { describe, expect, it, vi } from "vitest";
import { colorForUser } from "@pcbjam/shared";
import { bindKicadPresence, hasPresenceBridge, type PresenceKicadWindow } from "./presence-kicad";
import type { PresenceHandle, PresencePeer } from "./presence";
/**
* presence-kicad bridge unit tests (collab-presence 0002): a fake Module +
* window stand in for the wasm side; a stub presence handle stands in for
* awareness. Verifies both directions and teardown.
*/
function fakeModule() {
return {
kicadCollabPresenceStart: vi.fn(),
kicadCollabSetRemote: vi.fn(),
kicadCollabGetViewport: vi.fn(() => '{"cx":0,"cy":0,"scale":1,"w":800,"h":600}'),
kicadCollabGetSelection: vi.fn(() => "[]"),
};
}
function stubPresence(peers: PresencePeer[] = []) {
const subscribers = new Set<(p: PresencePeer[]) => void>();
const handle: PresenceHandle & { firePeers(p: PresencePeer[]): void } = {
peers: () => peers,
subscribe(cb) {
subscribers.add(cb);
return () => subscribers.delete(cb);
},
setCursor: vi.fn(),
setSelection: vi.fn(),
destroy: vi.fn(),
firePeers(p) {
peers = p;
for (const cb of subscribers) cb(p);
},
};
return handle;
}
function peer(id: string, over: Partial<PresencePeer> = {}): PresencePeer {
return {
clientId: 1,
user: { id, name: id, color: colorForUser(id) },
tool: "pcbnew",
cursor: null,
selection: [],
updatedAt: 1,
...over,
};
}
describe("bindKicadPresence", () => {
it("routes C++ emits into the presence setters", () => {
const mod = fakeModule();
const win: PresenceKicadWindow = {};
const presence = stubPresence();
bindKicadPresence({ mod, win, presence });
expect(mod.kicadCollabPresenceStart).toHaveBeenCalled();
win.kicadCollab!.onSelection!('["uuid-1","uuid-2"]');
expect(presence.setSelection).toHaveBeenCalledWith(["uuid-1", "uuid-2"]);
win.kicadCollab!.onCursor!(5, 7, 1);
expect(presence.setCursor).toHaveBeenCalledWith({ x: 5, y: 7 });
win.kicadCollab!.onCursor!(0, 0, 0);
expect(presence.setCursor).toHaveBeenCalledWith(null);
});
it("seeds an existing selection from the wasm at attach", () => {
const mod = fakeModule();
mod.kicadCollabGetSelection.mockReturnValue('["pre-selected"]');
const presence = stubPresence();
bindKicadPresence({ mod, win: {}, presence });
expect(presence.setSelection).toHaveBeenCalledWith(["pre-selected"]);
});
it("pushes a throttled remote snapshot on peers change", async () => {
const mod = fakeModule();
const presence = stubPresence();
bindKicadPresence({ mod, win: {}, presence });
expect(mod.kicadCollabSetRemote).toHaveBeenCalledTimes(1); // initial push
presence.firePeers([
peer("bob", { cursor: { x: 10, y: 20 }, selection: ["u1"] }),
]);
presence.firePeers([
peer("bob", { cursor: { x: 11, y: 21 }, selection: ["u1"] }),
]);
await new Promise((r) => setTimeout(r, 60));
// Two rapid changes coalesce into ONE trailing push carrying the latest state.
expect(mod.kicadCollabSetRemote).toHaveBeenCalledTimes(2);
const snapshot = JSON.parse(mod.kicadCollabSetRemote.mock.calls.at(-1)![0]);
expect(snapshot.peers).toEqual([
{
id: "bob",
name: "bob",
color: colorForUser("bob"),
cursor: { x: 11, y: 21 },
selection: ["u1"],
},
]);
});
it("destroy unhooks the window and clears the remote overlay", () => {
const mod = fakeModule();
const win: PresenceKicadWindow = {};
const presence = stubPresence();
const binding = bindKicadPresence({ mod, win, presence });
binding.destroy();
expect(win.kicadCollab?.onSelection).toBeUndefined();
expect(win.kicadCollab?.onCursor).toBeUndefined();
const last = JSON.parse(mod.kicadCollabSetRemote.mock.calls.at(-1)![0]);
expect(last.peers).toEqual([]);
});
it("hasPresenceBridge gates on the 0002 exports", () => {
expect(hasPresenceBridge(fakeModule())).toBe(true);
expect(hasPresenceBridge({})).toBe(false);
expect(hasPresenceBridge(undefined)).toBe(false);
});
});

View file

@ -0,0 +1,139 @@
import { clog } from "./debug";
import type { PresenceHandle, PresencePeer } from "./presence";
/**
* Wire the C++ presence bridge (collab-presence 0002) to the awareness layer:
*
* C++ → awareness: `window.kicadCollab.onSelection/onCursor` (this tab's
* selection uuids + world-coord cursor, emitted by the wasm input hooks)
* publish into the room via the presence handle's setters.
*
* awareness → C++: on every peers change, push a full remote snapshot to
* `Module.kicadCollabSetRemote` — the wasm side clears + redraws its
* VIEW_OVERLAY from it (idempotent), so no delta bookkeeping is needed.
* Pushes are trailing-throttled: N peers × 20 cursor updates/s must not
* cross the JS↔wasm boundary per event.
*
* `onViewport` (world↔screen transform) feeds an optional callback for the
* DOM layers (comment pins, 0005); the roster doesn't need it.
*/
export interface PresenceKicadModule {
kicadCollabPresenceStart(): void;
kicadCollabSetRemote(json: string): void;
kicadCollabGetViewport(): string;
kicadCollabGetSelection(): string;
}
export interface PresenceKicadWindow {
kicadCollab?: {
onSelection?: (uuidsJson: string) => void;
onCursor?: (x: number, y: number, active: number) => void;
onViewport?: (cx: number, cy: number, scale: number, w: number, h: number) => void;
};
}
/** The GAL viewport transform: world center (IU), pixels-per-IU scale, px size. */
export interface ViewportState {
cx: number;
cy: number;
scale: number;
w: number;
h: number;
}
/** True when the loaded wasm exposes the presence bridge (0002 exports). */
export function hasPresenceBridge(mod: unknown): mod is PresenceKicadModule {
const m = mod as Partial<PresenceKicadModule> | undefined;
return (
typeof m?.kicadCollabPresenceStart === "function" &&
typeof m?.kicadCollabSetRemote === "function"
);
}
const PUSH_THROTTLE_MS = 30;
export function bindKicadPresence(opts: {
mod: PresenceKicadModule;
win: PresenceKicadWindow;
presence: PresenceHandle;
onViewport?: (vp: ViewportState) => void;
}): { destroy(): void } {
const { mod, win, presence } = opts;
// C++ → awareness ------------------------------------------------------------
win.kicadCollab = {
...win.kicadCollab,
onSelection: (uuidsJson) => {
try {
const uuids = JSON.parse(uuidsJson) as string[];
presence.setSelection(Array.isArray(uuids) ? uuids : []);
} catch {
/* malformed emit — keep the last published selection */
}
},
onCursor: (x, y, active) => {
presence.setCursor(active ? { x, y } : null);
},
onViewport: (cx, cy, scale, w, h) => {
opts.onViewport?.({ cx, cy, scale, w, h });
},
};
// Seed: the tab may attach with a selection already made (e.g. rebind).
try {
const seed = JSON.parse(mod.kicadCollabGetSelection() || "[]") as string[];
if (Array.isArray(seed) && seed.length) presence.setSelection(seed);
} catch {
/* bridge present but frame not up yet — the first emit will seed */
}
// awareness → C++ ------------------------------------------------------------
const pushRemote = () => {
const peers = presence.peers();
const snapshot = {
peers: peers.map((p: PresencePeer) => ({
id: p.user.id,
name: p.user.name,
color: p.user.color,
cursor: p.cursor,
selection: p.selection,
})),
};
mod.kicadCollabSetRemote(JSON.stringify(snapshot));
};
let pushTimer: ReturnType<typeof setTimeout> | undefined;
const schedulePush = () => {
if (pushTimer) return;
pushTimer = setTimeout(() => {
pushTimer = undefined;
pushRemote();
}, PUSH_THROTTLE_MS);
};
const unsubscribe = presence.subscribe(schedulePush);
mod.kicadCollabPresenceStart();
pushRemote();
clog("presence-kicad: bridge bound (cursor/selection emit + remote overlay)");
return {
destroy() {
unsubscribe();
if (pushTimer) clearTimeout(pushTimer);
pushTimer = undefined;
if (win.kicadCollab) {
delete win.kicadCollab.onSelection;
delete win.kicadCollab.onCursor;
delete win.kicadCollab.onViewport;
}
// Clear the remote overlay so a dead session leaves no ghost cursors.
try {
mod.kicadCollabSetRemote(JSON.stringify({ peers: [] }));
} catch {
/* wasm may already be gone on page teardown */
}
},
};
}