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:
parent
22528f450b
commit
8ad1cc673a
6 changed files with 1162 additions and 0 deletions
|
|
@ -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();
|
||||
|
|
|
|||
124
web/standalone/src/wasm/collab/presence-kicad.test.ts
Normal file
124
web/standalone/src/wasm/collab/presence-kicad.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
139
web/standalone/src/wasm/collab/presence-kicad.ts
Normal file
139
web/standalone/src/wasm/collab/presence-kicad.ts
Normal 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 */
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue