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:
parent
f94114efb8
commit
83b3418778
15 changed files with 1662 additions and 3 deletions
2
kicad
2
kicad
|
|
@ -1 +1 @@
|
|||
Subproject commit 9053a260c7f497f3df05c36090c2f5f768bae453
|
||||
Subproject commit 9cde940836ae05b4f6e79c014a4f7a4b75d41045
|
||||
25
tests/collab/browser-entry.ts
Normal file
25
tests/collab/browser-entry.ts
Normal 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
21
tests/collab/build.mjs
Normal 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");
|
||||
228
tests/kicad/pl_editor-collab.spec.ts
Normal file
228
tests/kicad/pl_editor-collab.spec.ts
Normal 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
539
tests/package-lock.json
generated
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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)}`);
|
||||
|
|
|
|||
65
web/apps/frontend/src/wasm/collab/broadcast-transport.ts
Normal file
65
web/apps/frontend/src/wasm/collab/broadcast-transport.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
23
web/apps/frontend/src/wasm/collab/debug.ts
Normal file
23
web/apps/frontend/src/wasm/collab/debug.ts
Normal 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);
|
||||
}
|
||||
82
web/apps/frontend/src/wasm/collab/index.ts
Normal file
82
web/apps/frontend/src/wasm/collab/index.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
203
web/apps/frontend/src/wasm/collab/reconciler.ts
Normal file
203
web/apps/frontend/src/wasm/collab/reconciler.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
36
web/apps/frontend/src/wasm/collab/types.ts
Normal file
36
web/apps/frontend/src/wasm/collab/types.ts
Normal 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
25
web/pnpm-lock.yaml
generated
|
|
@ -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: {}
|
||||
|
|
|
|||
Loading…
Reference in a new issue