feat(pl_editor): Yjs collaborative bridge — differ/apply + generic reconciler (yjs-bridge commit 2)

Bidirectional bridge between pl_editor's DS_DATA_MODEL and a Yjs doc, two same-origin
tabs syncing over BroadcastChannel. Full architecture in features/yjs-bridge/0001-0002.

C++ (wasm layer, wasm/bindings/pl_editor_embind.cpp — public DS_DATA_MODEL API only,
zero added fork divergence beyond the OnModify hook):
- snapshot-differ ChangeSource: diff model vs last-emitted snapshot on OnModify,
  emit per-item delta JSON via EM_ASM window.kicadCollab.onDelta
- kicadCollabApply(json): apply remote delta by uuid — scalars (text/segment/rect)
  by field, polygon/bitmap via SetPageLayout-append blob; reseed snapshot + HardRedraw
- kicadCollabSnapshot() (seed/baseline), s_applyingRemote echo guard, and a
  kicadCollabTestAddText() PoC local-edit hook
- wire format: {added:[item],changed:[item],removed:[uuid]}, item = {id,type,...fields}

JS (web/apps/frontend/src/wasm/collab/, generic + schema-agnostic):
- reconciler: uuid-keyed Y.Map of per-item Y.Map; down = onDelta→Y, up = observe→apply,
  origin-tagged echo suppression; seed-once join adopts the doc authoritatively
- broadcast-transport: minimal BroadcastChannel Yjs provider (query/state catch-up)
- WasmTool wiring behind ?collab=1 (pl_editor only); gated debug logging

Tests: tests/kicad/pl_editor-collab.spec.ts — single-page C++ contract (snapshot/apply
changed+removed+added/echo-suppression) + two-tab BroadcastChannel A<->B propagation.
Reconciler+yjs bundled via esbuild (tests/collab/build.mjs, npm run build:collab).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergő Törcsvári 2026-06-03 14:25:22 +02:00
commit 83b3418778
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
15 changed files with 1662 additions and 3 deletions

2
kicad

@ -1 +1 @@
Subproject commit 9053a260c7f497f3df05c36090c2f5f768bae453
Subproject commit 9cde940836ae05b4f6e79c014a4f7a4b75d41045

View file

@ -0,0 +1,25 @@
// Browser bundle entry for the collab e2e: bundles the generic reconciler + Yjs +
// BroadcastChannel transport (from the web frontend) into a single IIFE that the
// pl_editor static harness page can load via <script>. esbuild resolves `yjs` from
// tests/node_modules.
//
// Build: npm run build:collab (tests/) → tests/apps/kicad/collab-bundle.js
import {
startCollab,
type CollabModule,
type CollabWindow,
} from "../../web/apps/frontend/src/wasm/collab/index";
declare global {
interface Window {
KicadCollab?: {
start: (
mod: CollabModule,
win: CollabWindow,
opts: { channel: string; settleMs?: number },
) => ReturnType<typeof startCollab>;
};
}
}
window.KicadCollab = { start: startCollab };

21
tests/collab/build.mjs Normal file
View file

@ -0,0 +1,21 @@
// Bundle the collab browser entry (reconciler + Yjs + BroadcastChannel transport,
// sourced from the web frontend) into a single IIFE the pl_editor harness loads.
// nodePaths lets esbuild resolve `yjs` from tests/node_modules even though the
// reconciler lives under web/. Output: apps/kicad/collab-bundle.js (global KicadCollab).
import { build } from "esbuild";
import path from "node:path";
import { fileURLToPath } from "node:url";
const testsDir = path.resolve(fileURLToPath(new URL("..", import.meta.url)));
await build({
entryPoints: [path.join(testsDir, "collab/browser-entry.ts")],
bundle: true,
format: "iife",
outfile: path.join(testsDir, "apps/kicad/collab-bundle.js"),
nodePaths: [path.join(testsDir, "node_modules")],
logLevel: "info",
target: "es2020",
});
console.log("collab bundle built → apps/kicad/collab-bundle.js");

View file

@ -0,0 +1,228 @@
import { execSync } from "node:child_process";
import path from "node:path";
import type { Page } from "@playwright/test";
import { test, expect } from "./fixtures";
/**
* pl_editor Yjs collaborative bridge (features/yjs-bridge commit 2).
*
* Two layers of coverage:
* 1. single-page the C++ bridge contract in isolation: kicadCollabSnapshot reflects
* the model; kicadCollabApply mutates it by uuid (changed / removed / added scalar);
* applying a remote delta does NOT echo a local onDelta (s_applyingRemote guard).
* 2. two-tab the full loop through the generic reconciler + Yjs + BroadcastChannel:
* a local text insert in tab A appears in tab B, and vice-versa.
*
* The collab reconciler/transport are bundled from web/apps/frontend/src/wasm/collab via
* esbuild into apps/kicad/collab-bundle.js (rebuilt in beforeAll for freshness).
*/
const CHANNEL_BASE = "pl-collab-e2e";
// A drawing sheet with explicit uuids so both tabs load identical item identities.
const U_TITLE = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
const SHEET = `(kicad_wks (version 20220228) (generator "pl_editor") (generator_version "9.0")
(setup (textsize 1.5 1.5)(linewidth 0.15)(textlinewidth 0.15)
(left_margin 10)(right_margin 10)(top_margin 10)(bottom_margin 10))
(rect (uuid "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb") (name border) (start 0 0 ltcorner) (end 0 0 rbcorner))
(tbtext "Title" (uuid "${U_TITLE}") (name title) (pos 100 20 ltcorner) (font (size 2 2)))
)
`;
type FS = { mkdirTree(p: string): void; writeFile(p: string, d: string): void;
readFile(p: string, o: { encoding: "utf8" }): string };
type Mod = {
kicadOpenFile(p: string): unknown;
kicadSaveDrawingSheet(p: string): unknown;
kicadCollabSnapshot(): string;
kicadCollabApply(j: string): unknown;
kicadCollabTestAddText(text: string, x: number, y: number): string;
};
function hasAbort(l: { consoleLogs: string[]; errors: string[] }): boolean {
return [...l.consoleLogs, ...l.errors].some((s) => s.includes("Aborted("));
}
/** Bring a pl_editor tab fully up and open SHEET in its MEMFS. */
async function bootAndOpen(page: Page, name: string): Promise<void> {
await page.goto("/kicad/pl_editor.html");
await expect(page.locator("#canvas")).toBeVisible({ timeout: 90000 });
await page.waitForFunction(() => !!window.wxElementRegistry, null, { timeout: 90000 });
await page.waitForFunction(
() => {
const m = (window as unknown as { Module?: Mod }).Module;
return (
typeof m?.kicadOpenFile === "function" &&
typeof m?.kicadCollabSnapshot === "function" &&
typeof m?.kicadCollabApply === "function" &&
typeof m?.kicadCollabTestAddText === "function"
);
},
null,
{ timeout: 90000 },
);
await page.waitForFunction(
() =>
!!window.wxElementRegistry &&
window.wxElementRegistry
.findAll({ visible: true })
.some((e) => /Frame$/.test(e.typeName) || (e.name || "").endsWith("Frame")),
null,
{ timeout: 90000 },
);
await page.evaluate(
({ content, name }) => {
const w = window as unknown as { FS: FS; Module: Mod };
const dir = "/home/kicad/documents";
try {
w.FS.mkdirTree(dir);
} catch {
/* exists */
}
const p = `${dir}/${name}.kicad_wks`;
w.FS.writeFile(p, content);
w.Module.kicadOpenFile(p);
},
{ content: SHEET, name },
);
await expect.poll(() => page.title(), { timeout: 30000 }).toMatch(new RegExp(name, "i"));
}
/** Read the tab's current model back as text via save-to-MEMFS. */
async function modelText(page: Page): Promise<string> {
return page.evaluate(() => {
const w = window as unknown as { FS: FS; Module: Mod };
const out = "/home/kicad/documents/_dump.kicad_wks";
w.Module.kicadSaveDrawingSheet(out);
return w.FS.readFile(out, { encoding: "utf8" });
});
}
test.beforeAll(() => {
// Rebuild the collab bundle so the test always exercises the current reconciler.
execSync("node collab/build.mjs", {
cwd: path.resolve(__dirname, ".."),
stdio: "inherit",
});
});
test.describe("pl_editor collab bridge — single page (C++ contract)", () => {
test("snapshot reflects model; apply changes/removes/adds by uuid; no echo", async ({
page,
testLogger,
}) => {
await bootAndOpen(page, "single");
// snapshot: both seeded items present, with decomposed fields.
const snap = await page.evaluate(() => JSON.parse(window.Module.kicadCollabSnapshot()));
const ids: string[] = snap.added.map((i: { id: string }) => i.id);
expect(ids).toContain(U_TITLE);
const title = snap.added.find((i: { id: string }) => i.id === U_TITLE);
expect(title.type).toBe("text");
expect(title.text).toBe("Title");
// Install an onDelta capture to prove apply() does NOT echo (s_applyingRemote).
await page.evaluate(() => {
(window as unknown as { __echo: string[] }).__echo = [];
(window as unknown as { kicadCollab: { onDelta: (j: string) => void } }).kicadCollab = {
onDelta: (j: string) => (window as unknown as { __echo: string[] }).__echo.push(j),
};
});
// changed: move the title text. added (scalar): a new line. removed: the border rect.
await page.evaluate((titleId) => {
window.Module.kicadCollabApply(
JSON.stringify({
changed: [{ id: titleId, type: "text", x: 123, y: 45 }],
added: [
{
id: "cccccccc-cccc-cccc-cccc-cccccccccccc",
type: "segment",
name: "seg",
x: 5,
y: 5,
anchor: 3,
ex: 25,
ey: 5,
eanchor: 3,
linewidth: 0.2,
},
],
removed: ["bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"],
}),
);
}, U_TITLE);
const out = await modelText(page);
// title moved
expect(out).toContain(`(uuid "${U_TITLE}")`);
expect(out).toMatch(/\(pos 123 45/);
// new segment added with its uuid
expect(out).toContain(`(uuid "cccccccc-cccc-cccc-cccc-cccccccccccc")`);
expect(out).toContain("(line");
// border rect removed
expect(out).not.toContain("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
// echo suppression: applying a remote delta must not have produced a local delta.
const echoes = await page.evaluate(() => (window as unknown as { __echo: string[] }).__echo);
expect(echoes, "apply() must not echo a local onDelta").toHaveLength(0);
expect(hasAbort(testLogger), "no WASM abort").toBe(false);
});
});
test.describe("pl_editor collab bridge — two tabs (BroadcastChannel)", () => {
test("a local text insert propagates A→B and B→A", async ({ context, testLogger }) => {
const channel = `${CHANNEL_BASE}-${test.info().workerIndex}`;
const bundle = path.resolve(__dirname, "../apps/kicad/collab-bundle.js");
const tabA = await context.newPage();
const tabB = await context.newPage();
await bootAndOpen(tabA, "tabA");
await bootAndOpen(tabB, "tabB");
// Load the reconciler bundle into both tabs.
for (const p of [tabA, tabB]) await p.addScriptTag({ path: bundle });
// Start collab: A first (seeds the doc), then B (adopts via state query).
const startCollab = async (p: Page) =>
p.evaluate(async (ch) => {
const w = window as unknown as {
KicadCollab: { start: (m: unknown, win: unknown, o: unknown) => Promise<unknown> };
Module: unknown;
__collab?: unknown;
};
w.__collab = await w.KicadCollab.start(w.Module, window, { channel: ch, settleMs: 500 });
}, channel);
await startCollab(tabA);
await startCollab(tabB);
// Tab A inserts text locally (the real PlaceItem model path + OnModify → emit).
const uuidA = await tabA.evaluate(() =>
window.Module.kicadCollabTestAddText("Hello from A", 40, 40),
);
expect(uuidA).toMatch(/[0-9a-f-]{36}/);
// Tab B should receive it through Y.Doc + BroadcastChannel + kicadCollabApply.
await expect
.poll(async () => await modelText(tabB), { timeout: 15000, intervals: [300] })
.toContain("Hello from A");
expect(await modelText(tabB)).toContain(`(uuid "${uuidA}")`);
// Reverse direction: B → A.
const uuidB = await tabB.evaluate(() =>
window.Module.kicadCollabTestAddText("Hello from B", 60, 60),
);
await expect
.poll(async () => await modelText(tabA), { timeout: 15000, intervals: [300] })
.toContain("Hello from B");
expect(await modelText(tabA)).toContain(`(uuid "${uuidB}")`);
expect(hasAbort(testLogger), "no WASM abort").toBe(false);
await tabA.close();
await tabB.close();
});
});

539
tests/package-lock.json generated
View file

@ -10,8 +10,452 @@
"devDependencies": {
"@playwright/test": "^1.40.0",
"@types/node": "^24.10.1",
"esbuild": "^0.28.0",
"serve": "^14.2.0",
"typescript": "^5.9.3"
"typescript": "^5.9.3",
"yjs": "^13.6.31"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
"integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
"integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
"integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
"integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
"integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
"integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
"integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
"integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
"integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
"integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
"integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
"integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
"integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
"integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
"integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
"integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
"integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
"integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
"integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
"integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
"integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
"integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
"integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
"integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
"integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
"integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@playwright/test": {
@ -448,6 +892,48 @@
"dev": true,
"license": "MIT"
},
"node_modules/esbuild": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
"integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.0",
"@esbuild/android-arm": "0.28.0",
"@esbuild/android-arm64": "0.28.0",
"@esbuild/android-x64": "0.28.0",
"@esbuild/darwin-arm64": "0.28.0",
"@esbuild/darwin-x64": "0.28.0",
"@esbuild/freebsd-arm64": "0.28.0",
"@esbuild/freebsd-x64": "0.28.0",
"@esbuild/linux-arm": "0.28.0",
"@esbuild/linux-arm64": "0.28.0",
"@esbuild/linux-ia32": "0.28.0",
"@esbuild/linux-loong64": "0.28.0",
"@esbuild/linux-mips64el": "0.28.0",
"@esbuild/linux-ppc64": "0.28.0",
"@esbuild/linux-riscv64": "0.28.0",
"@esbuild/linux-s390x": "0.28.0",
"@esbuild/linux-x64": "0.28.0",
"@esbuild/netbsd-arm64": "0.28.0",
"@esbuild/netbsd-x64": "0.28.0",
"@esbuild/openbsd-arm64": "0.28.0",
"@esbuild/openbsd-x64": "0.28.0",
"@esbuild/openharmony-arm64": "0.28.0",
"@esbuild/sunos-x64": "0.28.0",
"@esbuild/win32-arm64": "0.28.0",
"@esbuild/win32-ia32": "0.28.0",
"@esbuild/win32-x64": "0.28.0"
}
},
"node_modules/execa": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
@ -606,6 +1092,17 @@
"dev": true,
"license": "ISC"
},
"node_modules/isomorphic.js": {
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz",
"integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==",
"dev": true,
"license": "MIT",
"funding": {
"type": "GitHub Sponsors ❤",
"url": "https://github.com/sponsors/dmonad"
}
},
"node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
@ -613,6 +1110,28 @@
"dev": true,
"license": "MIT"
},
"node_modules/lib0": {
"version": "0.2.117",
"resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.117.tgz",
"integrity": "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==",
"dev": true,
"license": "MIT",
"dependencies": {
"isomorphic.js": "^0.2.4"
},
"bin": {
"0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js",
"0gentesthtml": "bin/gentesthtml.js",
"0serve": "bin/0serve.js"
},
"engines": {
"node": ">=16"
},
"funding": {
"type": "GitHub Sponsors ❤",
"url": "https://github.com/sponsors/dmonad"
}
},
"node_modules/merge-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
@ -1152,6 +1671,24 @@
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/yjs": {
"version": "13.6.31",
"resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.31.tgz",
"integrity": "sha512-Eq+5BRfbeGyqGVrTJL3bEcr8gKkxPuyuoHmAwpk52fDb8kOVMrfVSTRPd6yiGgX5Fskb96qCRjzjbRjrL4YEnw==",
"dev": true,
"license": "MIT",
"dependencies": {
"lib0": "^0.2.99"
},
"engines": {
"node": ">=16.0.0",
"npm": ">=8.0.0"
},
"funding": {
"type": "GitHub Sponsors ❤",
"url": "https://github.com/sponsors/dmonad"
}
}
}
}

View file

@ -7,6 +7,7 @@
"test:ui": "playwright test --ui",
"test:headed": "playwright test --headed",
"build-wasm": "cd apps && make -f Makefile.wasm",
"build:collab": "node collab/build.mjs",
"serve": "npx serve apps -p 8080 -c ../serve.json",
"setup:kicad": "./scripts/setup-kicad-wasm.sh",
"test:web": "npm run setup:kicad && playwright test --config=playwright-web.config.ts --project=firefox",
@ -39,7 +40,9 @@
"devDependencies": {
"@playwright/test": "^1.40.0",
"@types/node": "^24.10.1",
"esbuild": "^0.28.0",
"serve": "^14.2.0",
"typescript": "^5.9.3"
"typescript": "^5.9.3",
"yjs": "^13.6.31"
}
}

View file

@ -6,16 +6,25 @@
*/
#ifdef __EMSCRIPTEN__
#include <emscripten.h>
#include <emscripten/bind.h>
#include <kiway_player.h>
#include <kiway.h>
#include <map>
#include <string>
#include <vector>
#include <wx/app.h>
#include <wx/string.h>
#include <wx/window.h>
#include <nlohmann/json.hpp>
#include <eda_draw_frame.h>
#include <kiid.h>
#include <font/text_attributes.h>
#include <drawing_sheet/ds_data_model.h>
#include <drawing_sheet/ds_data_item.h>
using namespace emscripten;
using json = nlohmann::json;
// Programmatically open a drawing-sheet file (.kicad_wks) in the running editor
// frame, without UI automation. Mirrors single_top.cpp's MacOpenFile path: the
@ -49,9 +58,353 @@ void kicadSaveDrawingSheet( std::string path )
DS_DATA_MODEL::GetTheInstance().Save( wxString::FromUTF8( path.c_str() ) );
}
// ───────────────────────────── Yjs collaborative bridge ─────────────────────────────
//
// pl_editor's half of the unified bridge contract (features/yjs-bridge/0001-0002).
// This lives in the wasm layer (not the kicad fork) so fork divergence stays at the
// single OnModify() hook; everything below uses only public DS_DATA_MODEL / DS_DATA_ITEM
// API. The contract is a per-item delta { added:[item], changed:[item], removed:[uuid] }
// where each item is { id, type, …fields }. text/segment/rect use decomposed scalars
// (field-level merge); polygon/bitmap reuse the native per-item s-expr serializer as an
// opaque `sexpr` blob (item-level merge) — see 0002 §field-mapping.
//
// C++ → JS (emit): OnModify → kicadCollabOnModify → snapshot-diff → window.kicadCollab.onDelta(json)
// JS → C++ (apply): peer delta → Module.kicadCollabApply(json) → mutate model by uuid → HardRedraw
//
namespace {
// Guard: set around apply() so the differ ignores model mutations we caused ourselves
// (apply → HardRedraw/OnModify → differ would otherwise echo them back). 0001 §5.
bool s_applyingRemote = false;
// Last-emitted item-set, keyed by uuid → its field json. The differ's baseline.
std::map<std::string, json> s_snapshot;
std::string toUtf8( const wxString& s ) { return std::string( s.utf8_str() ); }
const char* typeStr( DS_DATA_ITEM::DS_ITEM_TYPE t )
{
switch( t )
{
case DS_DATA_ITEM::DS_TEXT: return "text";
case DS_DATA_ITEM::DS_SEGMENT: return "segment";
case DS_DATA_ITEM::DS_RECT: return "rect";
case DS_DATA_ITEM::DS_POLYPOLYGON:return "polygon";
case DS_DATA_ITEM::DS_BITMAP: return "bitmap";
}
return "unknown";
}
EDA_DRAW_FRAME* topFrame()
{
return wxTheApp ? dynamic_cast<EDA_DRAW_FRAME*>( wxTheApp->GetTopWindow() ) : nullptr;
}
DS_DATA_ITEM* findByUuid( DS_DATA_MODEL& aModel, const std::string& aId )
{
for( DS_DATA_ITEM* it : aModel.GetItems() )
{
if( toUtf8( it->m_Uuid.AsString() ) == aId )
return it;
}
return nullptr;
}
// Serialize one item to its opaque s-expr blob via the native per-item formatter. The
// blob already carries (uuid …), so a string compare detects any field change and the
// blob re-parses to a fully-formed item on apply. (0002 mechanism 2.)
std::string itemBlob( DS_DATA_ITEM* aItem )
{
std::vector<DS_DATA_ITEM*> one{ aItem };
wxString str;
DS_DATA_MODEL::GetTheInstance().SaveInString( one, &str );
return toUtf8( str );
}
json itemToJson( DS_DATA_ITEM* aItem )
{
json j;
j["id"] = toUtf8( aItem->m_Uuid.AsString() );
j["type"] = typeStr( aItem->GetType() );
j["name"] = toUtf8( aItem->m_Name );
j["x"] = aItem->m_Pos.m_Pos.x; // mm (model's native units for data items)
j["y"] = aItem->m_Pos.m_Pos.y;
j["anchor"] = aItem->m_Pos.m_Anchor;
switch( aItem->GetType() )
{
case DS_DATA_ITEM::DS_TEXT:
{
auto* t = static_cast<DS_DATA_ITEM_TEXT*>( aItem );
j["text"] = toUtf8( t->m_TextBase );
j["orient"] = t->m_Orient;
j["hjustify"] = (int) t->m_Hjustify;
j["vjustify"] = (int) t->m_Vjustify;
j["italic"] = t->m_Italic;
j["bold"] = t->m_Bold;
j["sizeX"] = t->m_TextSize.x;
j["sizeY"] = t->m_TextSize.y;
break;
}
case DS_DATA_ITEM::DS_SEGMENT:
case DS_DATA_ITEM::DS_RECT:
j["ex"] = aItem->m_End.m_Pos.x;
j["ey"] = aItem->m_End.m_Pos.y;
j["eanchor"] = aItem->m_End.m_Anchor;
j["linewidth"] = aItem->m_LineWidth;
break;
case DS_DATA_ITEM::DS_POLYPOLYGON:
case DS_DATA_ITEM::DS_BITMAP:
j["sexpr"] = itemBlob( aItem ); // opaque; item-level merge
break;
}
return j;
}
std::map<std::string, json> snapshotMap()
{
std::map<std::string, json> out;
for( DS_DATA_ITEM* it : DS_DATA_MODEL::GetTheInstance().GetItems() )
out[ toUtf8( it->m_Uuid.AsString() ) ] = itemToJson( it );
return out;
}
void emit( const json& aDelta )
{
std::string s = aDelta.dump();
EM_ASM( {
if( window.kicadCollab && window.kicadCollab.onDelta )
window.kicadCollab.onDelta( UTF8ToString( $0 ) );
}, s.c_str() );
}
// Apply scalar fields from json onto an existing scalar item (text/segment/rect).
void applyFields( DS_DATA_ITEM* aItem, const json& j )
{
if( j.contains( "name" ) ) aItem->m_Name = wxString::FromUTF8( std::string( j["name"] ).c_str() );
if( j.contains( "x" ) ) aItem->m_Pos.m_Pos.x = j["x"].get<double>();
if( j.contains( "y" ) ) aItem->m_Pos.m_Pos.y = j["y"].get<double>();
if( j.contains( "anchor" ) ) aItem->m_Pos.m_Anchor = j["anchor"].get<int>();
switch( aItem->GetType() )
{
case DS_DATA_ITEM::DS_TEXT:
{
auto* t = static_cast<DS_DATA_ITEM_TEXT*>( aItem );
if( j.contains( "text" ) ) t->m_TextBase = wxString::FromUTF8( std::string( j["text"] ).c_str() );
if( j.contains( "orient" ) ) t->m_Orient = j["orient"].get<double>();
if( j.contains( "hjustify" ) ) t->m_Hjustify = (GR_TEXT_H_ALIGN_T) j["hjustify"].get<int>();
if( j.contains( "vjustify" ) ) t->m_Vjustify = (GR_TEXT_V_ALIGN_T) j["vjustify"].get<int>();
if( j.contains( "italic" ) ) t->m_Italic = j["italic"].get<bool>();
if( j.contains( "bold" ) ) t->m_Bold = j["bold"].get<bool>();
if( j.contains( "sizeX" ) ) t->m_TextSize.x = j["sizeX"].get<double>();
if( j.contains( "sizeY" ) ) t->m_TextSize.y = j["sizeY"].get<double>();
break;
}
case DS_DATA_ITEM::DS_SEGMENT:
case DS_DATA_ITEM::DS_RECT:
if( j.contains( "ex" ) ) aItem->m_End.m_Pos.x = j["ex"].get<double>();
if( j.contains( "ey" ) ) aItem->m_End.m_Pos.y = j["ey"].get<double>();
if( j.contains( "eanchor" ) ) aItem->m_End.m_Anchor = j["eanchor"].get<int>();
if( j.contains( "linewidth" ) ) aItem->m_LineWidth = j["linewidth"].get<double>();
break;
default:
break;
}
}
DS_DATA_ITEM* createScalarItem( const std::string& aType )
{
if( aType == "text" ) return new DS_DATA_ITEM_TEXT( wxEmptyString );
if( aType == "segment" ) return new DS_DATA_ITEM( DS_DATA_ITEM::DS_SEGMENT );
if( aType == "rect" ) return new DS_DATA_ITEM( DS_DATA_ITEM::DS_RECT );
return nullptr;
}
// Reconstruct a polygon/bitmap from its opaque blob by appending it through the normal
// parser (the blob is a self-contained mini (kicad_wks …) model). Its (uuid …) round-trips.
void addBlob( DS_DATA_MODEL& aModel, const json& j )
{
if( !j.contains( "sexpr" ) )
return;
std::string sexpr = j["sexpr"];
aModel.SetPageLayout( sexpr.c_str(), /*aAppend*/ true, wxT( "collab-blob" ) );
}
} // namespace
// JS → C++. Apply a remote per-item delta to the model, by uuid. Guarded so the
// resulting model mutations are not re-emitted as local changes.
void kicadCollabApply( std::string aJson )
{
json delta = json::parse( aJson, nullptr, /*allow_exceptions*/ false );
if( delta.is_discarded() )
return;
s_applyingRemote = true;
DS_DATA_MODEL& model = DS_DATA_MODEL::GetTheInstance();
for( const json& rid : delta.value( "removed", json::array() ) )
{
if( DS_DATA_ITEM* item = findByUuid( model, rid.get<std::string>() ) )
{
model.Remove( item );
delete item;
}
}
for( const json& j : delta.value( "changed", json::array() ) )
{
std::string id = j.value( "id", "" );
std::string type = j.value( "type", "" );
DS_DATA_ITEM* item = findByUuid( model, id );
if( type == "polygon" || type == "bitmap" )
{
// Blob items merge at item granularity: replace wholesale.
if( item )
{
model.Remove( item );
delete item;
}
addBlob( model, j );
}
else if( item )
{
applyFields( item, j );
}
}
for( const json& j : delta.value( "added", json::array() ) )
{
std::string id = j.value( "id", "" );
std::string type = j.value( "type", "" );
if( !id.empty() && findByUuid( model, id ) )
continue; // already present (our own echo)
if( type == "polygon" || type == "bitmap" )
{
addBlob( model, j );
}
else if( DS_DATA_ITEM* item = createScalarItem( type ) )
{
item->m_Uuid = KIID( wxString::FromUTF8( id.c_str() ) );
applyFields( item, j );
model.Append( item );
}
}
// Rebase the differ on the post-apply state so our own mutations aren't echoed,
// then rebuild the GAL view from the model. (Selection re-acquire by uuid is a
// deferred refinement — 0002.)
s_snapshot = snapshotMap();
if( EDA_DRAW_FRAME* fr = topFrame() )
fr->HardRedraw();
s_applyingRemote = false;
}
// C++ → JS. The snapshot-differ ChangeSource: derive per-item add/remove/change events
// by diffing the current model against the last-emitted snapshot, then emit the delta.
// Called from PL_EDITOR_FRAME::OnModify() (pl_editor's single change chokepoint).
extern "C" void kicadCollabOnModify()
{
if( s_applyingRemote )
return;
std::map<std::string, json> cur = snapshotMap();
json added = json::array();
json changed = json::array();
json removed = json::array();
for( const auto& [id, j] : cur )
{
auto prev = s_snapshot.find( id );
if( prev == s_snapshot.end() )
added.push_back( j );
else if( prev->second != j )
changed.push_back( j );
}
for( const auto& [id, j] : s_snapshot )
{
if( !cur.count( id ) )
removed.push_back( id );
}
s_snapshot = std::move( cur );
if( added.empty() && changed.empty() && removed.empty() )
return;
emit( json{ { "added", added }, { "changed", changed }, { "removed", removed } } );
}
// JS pull of the full current model as an all-"added" delta, used to seed the Y.Doc on
// join and to (re)baseline the differ. Idempotent.
std::string kicadCollabSnapshot()
{
std::map<std::string, json> cur = snapshotMap();
json added = json::array();
for( const auto& [id, j] : cur )
added.push_back( j );
s_snapshot = cur;
return json{ { "added", added }, { "changed", json::array() },
{ "removed", json::array() } }.dump();
}
// Test/PoC helper: perform a genuine local text insert (the same model mutation a
// PL_DRAWING_TOOLS::PlaceItem(DS_TEXT) UI click produces — 0002 §text-insert path) and
// fire OnModify, so the differ emits an `added` delta. Lets a two-tab demo / e2e create
// a deterministic local edit in one tab and observe it propagate, without canvas UI
// automation. Returns the new item's uuid.
std::string kicadCollabTestAddText( std::string aText, double aX, double aY )
{
DS_DATA_MODEL& model = DS_DATA_MODEL::GetTheInstance();
auto* item = new DS_DATA_ITEM_TEXT( wxString::FromUTF8( aText.c_str() ) );
item->m_Pos.m_Pos.x = aX;
item->m_Pos.m_Pos.y = aY;
item->m_Pos.m_Anchor = LT_CORNER;
model.Append( item );
if( EDA_DRAW_FRAME* fr = topFrame() )
{
fr->OnModify(); // -> kicadCollabOnModify -> emit added
fr->HardRedraw(); // show it locally
}
return toUtf8( item->m_Uuid.AsString() );
}
EMSCRIPTEN_BINDINGS(pl_editor) {
// Programmatic file open (preferred over UI automation from the web app).
function("kicadOpenFile", &kicadOpenFile);
function("kicadSaveDrawingSheet", &kicadSaveDrawingSheet);
// Yjs collaborative bridge entry points.
function("kicadCollabApply", &kicadCollabApply);
function("kicadCollabSnapshot", &kicadCollabSnapshot);
function("kicadCollabTestAddText", &kicadCollabTestAddText);
}
#endif

View file

@ -25,6 +25,7 @@
"react-router-dom": "^6.28.1",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"yjs": "^13.6.18",
"zod": "^3.24.1"
},
"devDependencies": {

View file

@ -5,6 +5,62 @@ import { fetchFileBytes } from "@/lib/api";
import { WASM_ASSET_BASE_URL } from "@/lib/config";
import { bootKicadTool } from "@/wasm/boot";
import { driveProjectIntoTool } from "@/wasm/kicad-runner";
import type { CollabWindow } from "@/wasm/collab";
import { clog, cwarn } from "@/wasm/collab/debug";
/**
* Opt-in collaborative editing (features/yjs-bridge). Enabled when the URL carries
* `?collab=1` and the tool is pl_editor (the only tool with the collab bridge so far).
* Open the same project URL in two tabs with `?collab=1` to edit together: the channel
* is keyed to project+file, so both tabs share one Y.Doc over BroadcastChannel. Edits
* in the editor (add/move text, lines, ) fire OnModify the differ the peer tab.
*/
async function maybeStartCollab(
win: ToolWindow,
opts: {
tool: Tool;
slug: string;
targetPath?: string;
log: (m: string) => void;
onStatus: (t: string) => void;
},
): Promise<void> {
const enabled = new URLSearchParams(win.location.search).get("collab");
const mod = win.Module;
clog("maybeStartCollab gate:", {
collabParam: enabled,
tool: opts.tool,
hasModule: !!mod,
hasSnapshot: typeof mod?.kicadCollabSnapshot,
hasApply: typeof mod?.kicadCollabApply,
url: win.location.href,
});
if (!enabled || enabled === "0" || enabled === "false") {
clog("disabled (no ?collab=1) — skipping");
return;
}
if (opts.tool !== "pl_editor") {
clog(`tool is ${opts.tool}, not pl_editor — skipping`);
return;
}
if (typeof mod?.kicadCollabSnapshot !== "function") {
cwarn(
"BRIDGE NOT PRESENT: Module.kicadCollabSnapshot is",
typeof mod?.kicadCollabSnapshot,
"— the loaded pl_editor.wasm predates the collab bridge. Rebuild + `npm run setup:kicad` and restart the dev server.",
);
return;
}
const { startCollab } = await import("@/wasm/collab");
const channel = `kicad-collab:${opts.slug}:${opts.targetPath ?? ""}`;
clog("starting on channel", channel);
await startCollab(mod, win as unknown as CollabWindow, { channel });
opts.log(`[collab] connected on ${channel}`);
opts.onStatus("Collab: connected");
clog("connected ✓ — edit in one tab, watch the other");
}
/**
* Boots a KiCad tool directly in this React document (no iframe): builds the
@ -60,6 +116,7 @@ export function WasmTool({
log: append,
onStatus: setStatus,
});
await maybeStartCollab(win, { tool, slug, targetPath, log: append, onStatus: setStatus });
} catch (err) {
append(`[fatal] ${String(err)}`);
setStatus(`Error: ${String(err)}`);

View file

@ -0,0 +1,65 @@
import * as Y from "yjs";
import { clog } from "./debug";
/**
* Minimal BroadcastChannel sync provider for a Y.Doc (features/yjs-bridge/0001 §6,
* PoC transport two same-origin tabs share one doc, zero backend). Encodes Yjs
* updates and ferries them between tabs; on join it requests the current state so a
* late tab catches up.
*
* Remote updates are applied with origin "remote" so (a) the reconciler treats them
* as peer changes (its own writes use a different origin) and (b) we don't re-broadcast
* them into a loop.
*/
export interface Transport {
destroy(): void;
}
type Msg =
| { t: "update"; u: Uint8Array }
| { t: "query" }
| { t: "state"; u: Uint8Array };
export const REMOTE_ORIGIN = "remote";
export function connectBroadcastChannel(
doc: Y.Doc,
channelName: string,
): Transport {
const bc = new BroadcastChannel(channelName);
clog("BroadcastChannel open:", channelName);
const onUpdate = (update: Uint8Array, origin: unknown) => {
if (origin === REMOTE_ORIGIN) return; // don't echo applied remote updates
clog("→ BC send update", update.byteLength, "bytes (origin:", String(origin) + ")");
bc.postMessage({ t: "update", u: update } satisfies Msg);
};
doc.on("update", onUpdate);
bc.onmessage = (e: MessageEvent<Msg>) => {
const msg = e.data;
if (!msg) return;
switch (msg.t) {
case "update":
case "state":
clog("← BC recv", msg.t, msg.u?.byteLength, "bytes → applyUpdate");
Y.applyUpdate(doc, new Uint8Array(msg.u), REMOTE_ORIGIN);
break;
case "query":
clog("← BC recv query → replying with state");
bc.postMessage({ t: "state", u: Y.encodeStateAsUpdate(doc) } satisfies Msg);
break;
}
};
// Ask any existing tab for the current state.
clog("→ BC send query (asking peers for state)");
bc.postMessage({ t: "query" } satisfies Msg);
return {
destroy: () => {
doc.off("update", onUpdate);
bc.close();
},
};
}

View file

@ -0,0 +1,23 @@
// Lightweight collab debug logging to the BROWSER DEVTOOLS console (not the in-app
// log panel). On by default while we bring the bridge up; silence with
// `window.__COLLAB_DEBUG = false` (or `localStorage.collabDebug = "0"`).
function on(): boolean {
const w = window as unknown as { __COLLAB_DEBUG?: boolean };
if (typeof w.__COLLAB_DEBUG === "boolean") return w.__COLLAB_DEBUG;
try {
if (localStorage.getItem("collabDebug") === "0") return false;
} catch {
/* ignore */
}
return true;
}
const STYLE = "color:#0bd;font-weight:bold";
export function clog(...args: unknown[]): void {
if (on()) console.log("%c[collab]", STYLE, ...args);
}
export function cwarn(...args: unknown[]): void {
if (on()) console.warn("%c[collab]", STYLE, ...args);
}

View file

@ -0,0 +1,82 @@
import * as Y from "yjs";
import { connectBroadcastChannel, type Transport } from "./broadcast-transport";
import { clog } from "./debug";
import { createReconciler, type Reconciler } from "./reconciler";
import type { CollabBridge } from "./types";
export type { CollabBridge, CollabDelta, CollabItem } from "./types";
export { createReconciler } from "./reconciler";
export { connectBroadcastChannel } from "./broadcast-transport";
/** The subset of the Emscripten Module the collab bridge needs (embind functions). */
export interface CollabModule {
kicadCollabSnapshot(): string;
kicadCollabApply(deltaJson: string): void;
}
/** The window slot the C++ emit side calls into. */
export interface CollabWindow {
kicadCollab?: { onDelta: (deltaJson: string) => void };
}
export function moduleBridge(mod: CollabModule, win: CollabWindow): CollabBridge {
return {
snapshot: () => mod.kicadCollabSnapshot(),
apply: (deltaJson) => mod.kicadCollabApply(deltaJson),
onDelta: (cb) => {
win.kicadCollab = { onDelta: cb };
clog("registered window.kicadCollab.onDelta (wasm emit sink)");
},
};
}
export interface StartCollabOptions {
/** BroadcastChannel name — tabs sharing this name share the document. */
channel: string;
/**
* How long to wait for an existing tab's state before deciding seed-vs-adopt
* (seed-once rule). First tab: no reply, seeds from its local model. Later tab:
* receives state within this window, then adopts it. Default 300ms.
*/
settleMs?: number;
}
export interface CollabHandle {
doc: Y.Doc;
reconciler: Reconciler;
transport: Transport;
destroy(): void;
}
/**
* Wire a running pl_editor wasm Module into a collaborative session: Module Y.Doc
* BroadcastChannel. Returns once the initial seed/adopt has run. The editor must
* already have its document loaded (so kicadCollabSnapshot reflects it).
*/
export async function startCollab(
mod: CollabModule,
win: CollabWindow,
opts: StartCollabOptions,
): Promise<CollabHandle> {
clog("startCollab: channel =", opts.channel);
const doc = new Y.Doc();
const bridge = moduleBridge(mod, win);
const reconciler = createReconciler(doc, bridge);
const transport = connectBroadcastChannel(doc, opts.channel);
// Let any existing tab answer our state query before we decide to seed.
await new Promise((r) => setTimeout(r, opts.settleMs ?? 300));
reconciler.seed();
clog("startCollab: ready; doc items =", reconciler.items.size);
return {
doc,
reconciler,
transport,
destroy() {
reconciler.destroy();
transport.destroy();
doc.destroy();
},
};
}

View file

@ -0,0 +1,203 @@
import * as Y from "yjs";
import { clog } from "./debug";
import {
type CollabBridge,
type CollabDelta,
type CollabItem,
emptyDelta,
isEmptyDelta,
} from "./types";
/**
* The generic, schema-agnostic reconciler (features/yjs-bridge/0001 §4). It binds a
* KiCad editor's bridge (snapshot/apply/onDelta) to a Y.Doc and keeps them in sync:
*
* DOWN (model Y): bridge.onDelta write changed items into the Y.Map, in a
* transaction tagged with our local origin.
* UP (Y model): observe the Y.Map; on remote-origin events, build a per-item
* delta and call bridge.apply. Own-origin events are skipped
* (standard Yjs echo-suppression).
*
* CRDT shape: a top-level Y.Map keyed by item uuid, each value a Y.Map of scalar
* fields. (0001 names "Y.Array<Y.Map>"; a uuid-keyed Y.Map is the better fit for
* id-stable items O(1) by-id add/remove/change and no index-shift conflicts and
* the reconciler stays equally schema-agnostic.) Adding a C++ field needs zero JS
* change here: fields are copied generically by name.
*/
export interface Reconciler {
/**
* Seed-once join (0001 §2). Reads the local model snapshot; if the shared doc is
* empty this client seeds it, otherwise it adopts the shared doc into the local
* model. Call once after the doc/provider are connected.
*/
seed(): void;
destroy(): void;
/** The underlying items map (exposed for tests/inspection). */
readonly items: Y.Map<Y.Map<unknown>>;
}
const ITEMS_KEY = "items";
function itemToYMap(item: CollabItem): Y.Map<unknown> {
const ym = new Y.Map<unknown>();
for (const [k, v] of Object.entries(item)) {
if (k === "id") continue; // id is the map key, not a field
ym.set(k, v);
}
return ym;
}
/** Copy a delta item's fields into an existing/new Y.Map, writing only real changes. */
function upsertItem(items: Y.Map<Y.Map<unknown>>, item: CollabItem): void {
let ym = items.get(item.id);
if (!ym) {
items.set(item.id, itemToYMap(item));
return;
}
for (const [k, v] of Object.entries(item)) {
if (k === "id") continue;
if (ym.get(k) !== v) ym.set(k, v);
}
}
function yMapToItem(id: string, ym: Y.Map<unknown>): CollabItem {
const item: CollabItem = { id, type: String(ym.get("type") ?? "") };
ym.forEach((v, k) => {
item[k] = v;
});
item.id = id;
return item;
}
function findId(
items: Y.Map<Y.Map<unknown>>,
target: Y.Map<unknown>,
): string | undefined {
let found: string | undefined;
items.forEach((ym, id) => {
if (ym === target) found = id;
});
return found;
}
export function createReconciler(
doc: Y.Doc,
bridge: CollabBridge,
): Reconciler {
const items = doc.getMap<Y.Map<unknown>>(ITEMS_KEY);
// Opaque per-instance origin tag so we can distinguish our own writes from peers'.
const ORIGIN = { local: true };
// DOWN: local model change → Y.Doc
bridge.onDelta((deltaJson: string) => {
let delta: CollabDelta;
try {
delta = JSON.parse(deltaJson);
} catch {
clog("⬇ onDelta from wasm: UNPARSEABLE", deltaJson);
return;
}
clog("⬇ onDelta from wasm (local edit):", {
added: delta.added?.length ?? 0,
changed: delta.changed?.length ?? 0,
removed: delta.removed?.length ?? 0,
});
doc.transact(() => {
for (const it of delta.added ?? []) upsertItem(items, it);
for (const it of delta.changed ?? []) upsertItem(items, it);
for (const id of delta.removed ?? []) items.delete(id);
}, ORIGIN);
});
// UP: remote Y.Doc change → local model
const observer = (events: Y.YEvent<Y.Map<unknown>>[], txn: Y.Transaction) => {
if (txn.origin === ORIGIN) {
clog("⬆ Y change (own origin) — ignored");
return; // our own echo — ignore
}
const delta = emptyDelta();
const changedIds = new Set<string>();
for (const ev of events) {
if (ev.target === items) {
// Top-level: items added / removed (or whole-entry replaced).
(ev as Y.YMapEvent<Y.Map<unknown>>).changes.keys.forEach((change, id) => {
if (change.action === "delete") {
delta.removed.push(id);
} else {
const ym = items.get(id);
if (ym) {
if (change.action === "add") delta.added.push(yMapToItem(id, ym));
else changedIds.add(id); // "update"
}
}
});
} else {
// A child field map changed → that item changed.
const ym = ev.target as Y.Map<unknown>;
const id = findId(items, ym);
if (id) changedIds.add(id);
}
}
for (const id of changedIds) {
const ym = items.get(id);
if (ym) delta.changed.push(yMapToItem(id, ym));
}
if (!isEmptyDelta(delta)) {
clog("⬆ remote Y change → apply to wasm:", {
added: delta.added.length,
changed: delta.changed.length,
removed: delta.removed.length,
});
bridge.apply(JSON.stringify(delta));
}
};
items.observeDeep(observer);
function seed(): void {
let snap: CollabDelta;
try {
snap = JSON.parse(bridge.snapshot());
} catch {
return;
}
clog(
`seed: doc has ${items.size} item(s), local model has ${snap.added?.length ?? 0}`,
items.size === 0 ? "SEEDING doc (first tab)" : "ADOPTING doc (joining)",
);
if (items.size === 0) {
// We're first: seed the shared doc from our local model. Our backfilled uuids win.
doc.transact(() => {
for (const it of snap.added) upsertItem(items, it);
}, ORIGIN);
} else {
// Joining a populated doc: make the local model *match* it (seed-once authority).
// We add/replace the doc's items and drop any local items not in the doc — this
// resolves the never-saved-file cold-open race (0001 §2): a file with no uuids
// gets random backfill, so our local uuids differ from the seeder's; adopting the
// doc's identity (and removing our divergent copies) keeps both clients consistent.
const docIds = new Set<string>();
const added: CollabItem[] = [];
items.forEach((ym, id) => {
docIds.add(id);
added.push(yMapToItem(id, ym));
});
const removed = (snap.added ?? [])
.map((it) => it.id)
.filter((id) => !docIds.has(id));
bridge.apply(JSON.stringify({ added, changed: [], removed }));
}
}
return {
seed,
destroy: () => items.unobserveDeep(observer),
items,
};
}

View file

@ -0,0 +1,36 @@
// Wire contract shared with the C++ bridge (wasm/bindings/pl_editor_embind.cpp).
// Schema-agnostic by design: an item is just { id, type, …arbitrary fields }. The
// reconciler hardcodes no field names — it diffs values keyed by field name.
export type CollabItem = {
id: string;
type: string;
[field: string]: unknown;
};
export type CollabDelta = {
added: CollabItem[];
changed: CollabItem[];
removed: string[]; // uuids
};
/**
* The two C++ bridge entry points + the emit hook, abstracted so the reconciler is
* testable without a real wasm Module. In the browser these map to:
* snapshot() -> Module.kicadCollabSnapshot()
* apply(d) -> Module.kicadCollabApply(d)
* onDelta(cb): set window.kicadCollab = { onDelta: cb }
*/
export interface CollabBridge {
snapshot(): string;
apply(deltaJson: string): void;
onDelta(cb: (deltaJson: string) => void): void;
}
export function emptyDelta(): CollabDelta {
return { added: [], changed: [], removed: [] };
}
export function isEmptyDelta(d: CollabDelta): boolean {
return d.added.length === 0 && d.changed.length === 0 && d.removed.length === 0;
}

25
web/pnpm-lock.yaml generated
View file

@ -59,6 +59,9 @@ importers:
tailwindcss-animate:
specifier: ^1.0.7
version: 1.0.7(tailwindcss@3.4.19(tsx@4.22.4))
yjs:
specifier: ^13.6.18
version: 13.6.31
zod:
specifier: ^3.24.1
version: 3.25.76
@ -1769,6 +1772,9 @@ packages:
resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==}
engines: {node: '>=18'}
isomorphic.js@0.2.5:
resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==}
jiti@1.21.7:
resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==}
hasBin: true
@ -1795,6 +1801,11 @@ packages:
jsonfile@6.2.1:
resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==}
lib0@0.2.117:
resolution: {integrity: sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==}
engines: {node: '>=16'}
hasBin: true
light-my-request@5.14.0:
resolution: {integrity: sha512-aORPWntbpH5esaYpGOOmri0OHDOe3wC5M2MQxZ9dvMLZm6DnaAn0kJlcbU9hwsQgLzmZyReKwFwwPkR+nHu5kA==}
@ -2326,6 +2337,10 @@ packages:
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
yjs@13.6.31:
resolution: {integrity: sha512-Eq+5BRfbeGyqGVrTJL3bEcr8gKkxPuyuoHmAwpk52fDb8kOVMrfVSTRPd6yiGgX5Fskb96qCRjzjbRjrL4YEnw==}
engines: {node: '>=16.0.0', npm: '>=8.0.0'}
zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
@ -3563,6 +3578,8 @@ snapshots:
isexe@3.1.5: {}
isomorphic.js@0.2.5: {}
jiti@1.21.7: {}
js-tokens@4.0.0: {}
@ -3583,6 +3600,10 @@ snapshots:
optionalDependencies:
graceful-fs: 4.2.11
lib0@0.2.117:
dependencies:
isomorphic.js: 0.2.5
light-my-request@5.14.0:
dependencies:
cookie: 0.7.2
@ -4083,4 +4104,8 @@ snapshots:
yallist@3.1.1: {}
yjs@13.6.31:
dependencies:
lib0: 0.2.117
zod@3.25.76: {}