feat(collab): cross-app selection — eeschema symbol ⇄ pcbnew footprint ghost highlight (collab-presence 0006)
Selecting a symbol in eeschema ghost-highlights the linked footprint(s) in
every pcbnew tab of the project, and vice versa — across users AND one
user's own two tabs. Native KIWAY cross-probe is inert in WASM (one frame
per page); this rides the presence layer instead.
- cross-app.ts: project-wide awareness-only room (presenceRoomId), publishes
full PresenceState at selection rate (cursor always null); peers() = other-
TOOL clients incl. own user's other tabs; window.__pcbjamCrossApp test handle
- presence-kicad.ts: parseSelectionEmit (bare array | {uuids,fpPaths}),
xselFromPeerState (pcbnew paths → symbol uuids; eeschema uuids verbatim),
cross peers appended to the kicadCollabSetRemote snapshot as
{id "<user>#x<client>", name "<user> · sch|pcb", xsel}
- C++ (zero fork changes): pcbnew emits {uuids, fpPaths} (FOOTPRINT::GetPath)
and ghost-renders xsel via path-tail suffix scan; eeschema resolves xsel via
ResolveItem gated to the CURRENT sheet (xsel arrives project-wide, unlike
room-scoped selections); ghostStyle = alphas × xselAlphaScale (0.55, tuner-
patchable); new exports kicadCollabGetSelectionFull / TestGetCrossMapped /
TestSelectComponent (skips power symbols — PWR_FLAG has no footprint) +
merged-image dispatch
- tests: presence suites extended (payload shape, ghost render pixel tests,
13/13) + new two-tab tests/web/cross-probe.spec.ts (passing vs real
partykit); eeschema pixel compares now target the #glcanvas-* GAL panel
(the whole-window #canvas compare flaked on the auto-dismissing version
infobar — also fixes the long-known presence-eeschema restore flake);
cross-app + presence-kicad vitest suites
Spec: docs/features/collab-presence/0006 (closed repo).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lg5jwWuhFH5dL8hEcBDuP2
This commit is contained in:
parent
157d60f6b8
commit
3186986a0a
14 changed files with 1276 additions and 48 deletions
|
|
@ -34,6 +34,7 @@ type Mod = {
|
|||
kicadCollabSetRemote(j: string): void;
|
||||
kicadCollabGetViewport(): string;
|
||||
kicadCollabGetSelection(): string;
|
||||
kicadCollabTestGetCrossMapped(): string;
|
||||
kicadCollabTestSelectFirst(): string;
|
||||
kicadCollabTestClearSelection(): boolean;
|
||||
};
|
||||
|
|
@ -49,6 +50,28 @@ function hasAbort(l: { consoleLogs: string[]; errors: string[] }): boolean {
|
|||
return [...l.consoleLogs, ...l.errors].some((s) => s.includes("Aborted("));
|
||||
}
|
||||
|
||||
/**
|
||||
* The visible GAL draw panel — the pixel-compare target for overlay tests.
|
||||
* `#canvas` is the WHOLE wx window; its chrome is not pixel-stable across a
|
||||
* test (the "older KiCad version" infobar auto-dismisses, widget carets
|
||||
* blink), which made whole-window before/after equality flaky. The presence
|
||||
* overlay renders only into the GL panel, so compare exactly that.
|
||||
*/
|
||||
async function galPanel(page: Page) {
|
||||
const glId = await page.evaluate(() => {
|
||||
const visible = Array.from(document.querySelectorAll('[id^="glcanvas-"]'))
|
||||
.map((c) => c as HTMLCanvasElement)
|
||||
.find(
|
||||
(c) =>
|
||||
window.getComputedStyle(c).display !== "none" &&
|
||||
c.getBoundingClientRect().width > 0,
|
||||
);
|
||||
return visible?.id ?? null;
|
||||
});
|
||||
expect(glId, "no visible GAL panel found").toBeTruthy();
|
||||
return page.locator(`#${glId}`);
|
||||
}
|
||||
|
||||
async function bootAndOpen(page: Page): Promise<void> {
|
||||
await page.goto("/kicad/eeschema.html");
|
||||
await expect(page.locator("#canvas")).toBeVisible({ timeout: 90000 });
|
||||
|
|
@ -236,7 +259,7 @@ test("remote render paints the overlay without touching local selection", async
|
|||
}) => {
|
||||
await bootAndOpen(page);
|
||||
|
||||
const canvas = page.locator("#canvas");
|
||||
const canvas = await galPanel(page);
|
||||
const before = await canvas.screenshot();
|
||||
|
||||
await page.evaluate(
|
||||
|
|
@ -295,6 +318,108 @@ test("remote render paints the overlay without touching local selection", async
|
|||
expect(hasAbort(testLogger)).toBe(false);
|
||||
});
|
||||
|
||||
test("cross-app ghost render: a pcbnew peer's xsel uuid resolves on the current sheet (0006)", async ({
|
||||
page,
|
||||
testLogger,
|
||||
}) => {
|
||||
await bootAndOpen(page);
|
||||
|
||||
const canvas = await galPanel(page);
|
||||
const before = await canvas.screenshot();
|
||||
|
||||
// A pcbnew peer snapshot entry: xsel = the symbol uuid derived from the
|
||||
// footprint's path by the TS side. The fixture has no symbols, but the C++
|
||||
// resolve/draw path is uuid-kind-agnostic — a wire uuid exercises it fully.
|
||||
await page.evaluate(
|
||||
({ id }) => {
|
||||
const w = window as unknown as PresenceWindow;
|
||||
w.Module.kicadCollabSetRemote(
|
||||
JSON.stringify({
|
||||
peers: [
|
||||
{
|
||||
id: "alice#x3",
|
||||
name: "alice · pcb",
|
||||
color: "#3b82f6",
|
||||
cursor: null,
|
||||
selection: [],
|
||||
xsel: [id],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
},
|
||||
{ id: WIRE1 },
|
||||
);
|
||||
|
||||
// Resolves onto the current sheet → probe reports it, ghost outline paints.
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate(() =>
|
||||
JSON.parse(
|
||||
(window as unknown as PresenceWindow).Module.kicadCollabTestGetCrossMapped(),
|
||||
),
|
||||
),
|
||||
{ timeout: 10000 },
|
||||
)
|
||||
.toEqual([WIRE1]);
|
||||
await expect
|
||||
.poll(
|
||||
async () => !(await canvas.screenshot()).equals(before),
|
||||
{ timeout: 15000, intervals: [500] },
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
// No selection leak, unknown uuids map to nothing, clear restores pixels.
|
||||
const localSel = await page.evaluate(() =>
|
||||
JSON.parse((window as unknown as PresenceWindow).Module.kicadCollabGetSelection()),
|
||||
);
|
||||
expect(localSel).toEqual([]);
|
||||
|
||||
await page.evaluate(() => {
|
||||
const w = window as unknown as PresenceWindow;
|
||||
w.Module.kicadCollabSetRemote(
|
||||
JSON.stringify({
|
||||
peers: [
|
||||
{
|
||||
id: "alice#x3",
|
||||
name: "alice · pcb",
|
||||
color: "#3b82f6",
|
||||
cursor: null,
|
||||
selection: [],
|
||||
xsel: ["99999999-0000-0000-0000-000000000099"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate(() =>
|
||||
JSON.parse(
|
||||
(window as unknown as PresenceWindow).Module.kicadCollabTestGetCrossMapped(),
|
||||
),
|
||||
),
|
||||
{ timeout: 10000 },
|
||||
)
|
||||
.toEqual([]);
|
||||
|
||||
await page.evaluate(() =>
|
||||
(window as unknown as PresenceWindow).Module.kicadCollabSetRemote(
|
||||
JSON.stringify({ peers: [] }),
|
||||
),
|
||||
);
|
||||
await expect
|
||||
.poll(
|
||||
async () => (await canvas.screenshot()).equals(before),
|
||||
{ timeout: 15000, intervals: [500] },
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
expect(hasAbort(testLogger)).toBe(false);
|
||||
});
|
||||
|
||||
test("viewport export returns a sane world↔screen transform", async ({ page, testLogger }) => {
|
||||
await bootAndOpen(page);
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,9 @@ import { test, expect } from "./fixtures";
|
|||
|
||||
const SEG1 = "44444444-0000-0000-0000-000000000001";
|
||||
const FP1 = "66666666-0000-0000-0000-000000000001";
|
||||
// The footprint's schematic link (0006): its (path …) ends in the symbol uuid.
|
||||
const SYM1 = "aaaaaaaa-0000-0000-0000-000000000001";
|
||||
const FP1_PATH = `/${SYM1}`;
|
||||
const SAMPLE_PCB = `(kicad_pcb
|
||||
\t(version 20241229)
|
||||
\t(generator "pcbnew")
|
||||
|
|
@ -41,6 +44,7 @@ const SAMPLE_PCB = `(kicad_pcb
|
|||
\t\t(layer "F.Cu")
|
||||
\t\t(uuid "${FP1}")
|
||||
\t\t(at 100 100)
|
||||
\t\t(path "${FP1_PATH}")
|
||||
\t\t(attr smd)
|
||||
\t\t(property "Reference" "R1"
|
||||
\t\t\t(at 0 -4.2 0)
|
||||
|
|
@ -72,14 +76,18 @@ type Mod = {
|
|||
kicadCollabSetRemote(j: string): void;
|
||||
kicadCollabGetViewport(): string;
|
||||
kicadCollabGetSelection(): string;
|
||||
kicadCollabGetSelectionFull(): string;
|
||||
kicadCollabTestGetCrossMapped(): string;
|
||||
kicadCollabTestSelectFirst(): string;
|
||||
kicadCollabTestClearSelection(): boolean;
|
||||
};
|
||||
type SelPayload = { uuids: string[]; fpPaths: string[] };
|
||||
type PresenceWindow = {
|
||||
FS: FS;
|
||||
Module: Mod;
|
||||
kicadCollab?: Record<string, unknown>;
|
||||
__selEmits?: string[][];
|
||||
__selPayloads?: SelPayload[];
|
||||
__cursorEmits?: Array<{ x: number; y: number; active: number }>;
|
||||
};
|
||||
|
||||
|
|
@ -134,14 +142,24 @@ async function bootAndOpen(page: Page): Promise<void> {
|
|||
.poll(() => page.title(), { timeout: 60000, intervals: [500] })
|
||||
.toMatch(/presence/i);
|
||||
|
||||
// Install the capture hooks + start the presence input bindings.
|
||||
// Install the capture hooks + start the presence input bindings. Since 0006
|
||||
// pcbnew emits `{uuids, fpPaths}` — capture the uuid arrays (the pre-0006
|
||||
// assertions) AND the full payloads (the cross-app ones).
|
||||
await page.evaluate(() => {
|
||||
const w = window as unknown as PresenceWindow;
|
||||
w.__selEmits = [];
|
||||
w.__selPayloads = [];
|
||||
w.__cursorEmits = [];
|
||||
w.kicadCollab = {
|
||||
...w.kicadCollab,
|
||||
onSelection: (json: string) => w.__selEmits!.push(JSON.parse(json)),
|
||||
onSelection: (json: string) => {
|
||||
const parsed = JSON.parse(json) as SelPayload | string[];
|
||||
const payload: SelPayload = Array.isArray(parsed)
|
||||
? { uuids: parsed, fpPaths: [] }
|
||||
: parsed;
|
||||
w.__selEmits!.push(payload.uuids);
|
||||
w.__selPayloads!.push(payload);
|
||||
},
|
||||
onCursor: (x: number, y: number, active: number) =>
|
||||
w.__cursorEmits!.push({ x, y, active }),
|
||||
};
|
||||
|
|
@ -188,6 +206,49 @@ test("selection emit: programmatic select/clear reaches onSelection with uuids",
|
|||
expect(hasAbort(testLogger)).toBe(false);
|
||||
});
|
||||
|
||||
test("selection emit carries the footprint's schematic path (0006)", async ({
|
||||
page,
|
||||
testLogger,
|
||||
}) => {
|
||||
await bootAndOpen(page);
|
||||
|
||||
// TestSelectFirst picks the first top-level item — the fixture's only
|
||||
// footprint sorts first, but assert by payload rather than by luck: select,
|
||||
// then find the emit whose uuids contain the footprint.
|
||||
await page.evaluate(() =>
|
||||
(window as unknown as PresenceWindow).Module.kicadCollabTestSelectFirst(),
|
||||
);
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate(() => {
|
||||
const w = window as unknown as PresenceWindow;
|
||||
return w.__selPayloads!.at(-1) ?? null;
|
||||
}),
|
||||
{ timeout: 10000 },
|
||||
)
|
||||
.not.toBeNull();
|
||||
|
||||
const payload = await page.evaluate(
|
||||
() => (window as unknown as PresenceWindow).__selPayloads!.at(-1)!,
|
||||
);
|
||||
if (payload.uuids.includes(FP1)) {
|
||||
expect(payload.fpPaths).toEqual([FP1_PATH]);
|
||||
} else {
|
||||
// A non-footprint got selected first — the paths list must then be empty.
|
||||
expect(payload.fpPaths).toEqual([]);
|
||||
}
|
||||
|
||||
// The pull export mirrors the emit payload (cross-app seed at attach).
|
||||
const full = await page.evaluate(() =>
|
||||
JSON.parse((window as unknown as PresenceWindow).Module.kicadCollabGetSelectionFull()),
|
||||
);
|
||||
expect(full.uuids).toEqual(payload.uuids);
|
||||
expect(full.fpPaths).toEqual(payload.fpPaths);
|
||||
|
||||
expect(hasAbort(testLogger)).toBe(false);
|
||||
});
|
||||
|
||||
test("selection emit: a real canvas click drives the wx-layer trigger", async ({
|
||||
page,
|
||||
testLogger,
|
||||
|
|
@ -352,6 +413,107 @@ test("remote render paints the overlay without touching local selection", async
|
|||
expect(hasAbort(testLogger)).toBe(false);
|
||||
});
|
||||
|
||||
test("cross-app ghost render: an eeschema peer's symbol uuid maps to the footprint (0006)", async ({
|
||||
page,
|
||||
testLogger,
|
||||
}) => {
|
||||
await bootAndOpen(page);
|
||||
|
||||
const canvas = page.locator("#canvas");
|
||||
const before = await canvas.screenshot();
|
||||
|
||||
// An eeschema peer snapshot entry: no same-doc selection, xsel = symbol uuid.
|
||||
await page.evaluate(
|
||||
({ sym }) => {
|
||||
const w = window as unknown as PresenceWindow;
|
||||
w.Module.kicadCollabSetRemote(
|
||||
JSON.stringify({
|
||||
peers: [
|
||||
{
|
||||
id: "bob#x7",
|
||||
name: "bob · sch",
|
||||
color: "#d946ef",
|
||||
cursor: null,
|
||||
selection: [],
|
||||
xsel: [sym],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
},
|
||||
{ sym: SYM1 },
|
||||
);
|
||||
|
||||
// The suffix match resolves the symbol to the fixture footprint.
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate(() =>
|
||||
JSON.parse(
|
||||
(window as unknown as PresenceWindow).Module.kicadCollabTestGetCrossMapped(),
|
||||
),
|
||||
),
|
||||
{ timeout: 10000 },
|
||||
)
|
||||
.toEqual([FP1]);
|
||||
|
||||
// …and paints the ghost outline (pixels change), with no selection leak.
|
||||
await expect
|
||||
.poll(
|
||||
async () => !(await canvas.screenshot()).equals(before),
|
||||
{ timeout: 15000, intervals: [500] },
|
||||
)
|
||||
.toBe(true);
|
||||
const localSel = await page.evaluate(() =>
|
||||
JSON.parse((window as unknown as PresenceWindow).Module.kicadCollabGetSelection()),
|
||||
);
|
||||
expect(localSel).toEqual([]);
|
||||
|
||||
// An unknown symbol uuid maps to nothing; clearing restores the pixels.
|
||||
await page.evaluate(() => {
|
||||
const w = window as unknown as PresenceWindow;
|
||||
w.Module.kicadCollabSetRemote(
|
||||
JSON.stringify({
|
||||
peers: [
|
||||
{
|
||||
id: "bob#x7",
|
||||
name: "bob · sch",
|
||||
color: "#d946ef",
|
||||
cursor: null,
|
||||
selection: [],
|
||||
xsel: ["99999999-0000-0000-0000-000000000099"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate(() =>
|
||||
JSON.parse(
|
||||
(window as unknown as PresenceWindow).Module.kicadCollabTestGetCrossMapped(),
|
||||
),
|
||||
),
|
||||
{ timeout: 10000 },
|
||||
)
|
||||
.toEqual([]);
|
||||
|
||||
await page.evaluate(() =>
|
||||
(window as unknown as PresenceWindow).Module.kicadCollabSetRemote(
|
||||
JSON.stringify({ peers: [] }),
|
||||
),
|
||||
);
|
||||
await expect
|
||||
.poll(
|
||||
async () => (await canvas.screenshot()).equals(before),
|
||||
{ timeout: 15000, intervals: [500] },
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
expect(hasAbort(testLogger)).toBe(false);
|
||||
});
|
||||
|
||||
test("viewport export returns a sane world↔screen transform", async ({ page, testLogger }) => {
|
||||
await bootAndOpen(page);
|
||||
|
||||
|
|
|
|||
128
tests/web/cross-probe.spec.ts
Normal file
128
tests/web/cross-probe.spec.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import { test, expect, type Page } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Cross-app selection e2e (collab-presence 0006): one tab on the demo
|
||||
* SCHEMATIC and one on the demo BOARD of the same project share the
|
||||
* project-wide presence room over the default BroadcastChannel provider.
|
||||
* Selecting a symbol in eeschema must ghost-highlight the linked footprint in
|
||||
* pcbnew (footprint `(path …)` tail = symbol uuid — the demo fixtures are
|
||||
* linked), and vice versa. Assertions use the deterministic
|
||||
* `kicadCollabTestGetCrossMapped()` probe (what the ghost render draws from)
|
||||
* rather than pixel diffs — the render path itself is covered by the
|
||||
* tests/kicad specs.
|
||||
*
|
||||
* This is also the single-user cross-probe test by construction: both tabs
|
||||
* run as the same `?user=` in one browser context and still map each other
|
||||
* (cross-app peers are keyed by client, not user).
|
||||
*/
|
||||
|
||||
const SCOPE = 'default';
|
||||
|
||||
type Mod = {
|
||||
kicadCollabTestSelectComponent(): string;
|
||||
kicadCollabTestClearSelection(): boolean;
|
||||
kicadCollabTestGetCrossMapped(): string;
|
||||
kicadCollabGetSelectionFull(): string;
|
||||
};
|
||||
type W = { Module: Mod };
|
||||
|
||||
async function bootTool(
|
||||
page: Page,
|
||||
route: string,
|
||||
titleRe: RegExp,
|
||||
user: string,
|
||||
): Promise<void> {
|
||||
await page.goto(`/${SCOPE}/projects/demo/${route}?user=${user}`);
|
||||
await expect(page.locator('#canvas')).toBeVisible({ timeout: 120000 });
|
||||
await expect
|
||||
.poll(() => page.title(), {
|
||||
message: `${route}: editor never reached the expected title`,
|
||||
timeout: 120000,
|
||||
intervals: [1000],
|
||||
})
|
||||
.toMatch(titleRe);
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const m = (window as unknown as Partial<W>).Module;
|
||||
return (
|
||||
typeof m?.kicadCollabTestSelectComponent === 'function' &&
|
||||
typeof m?.kicadCollabTestGetCrossMapped === 'function'
|
||||
);
|
||||
},
|
||||
null,
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
}
|
||||
|
||||
const crossMapped = (page: Page) =>
|
||||
page.evaluate(() =>
|
||||
JSON.parse((window as unknown as W).Module.kicadCollabTestGetCrossMapped()),
|
||||
);
|
||||
|
||||
test('selecting a symbol in eeschema ghost-highlights the footprint in pcbnew, and back', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
test.setTimeout(480000); // two full editor boots (eeschema + pcbnew wasm)
|
||||
|
||||
const sch = page;
|
||||
await bootTool(sch, 'demo.kicad_sch', /demo — Schematic Editor/i, 'alice');
|
||||
const pcb = await context.newPage();
|
||||
await bootTool(pcb, 'demo.kicad_pcb', /demo — PCB Editor/i, 'bob');
|
||||
|
||||
// ── eeschema → pcbnew ──────────────────────────────────────────────────────
|
||||
const symId = await sch.evaluate(() =>
|
||||
(window as unknown as W).Module.kicadCollabTestSelectComponent(),
|
||||
);
|
||||
expect(symId, 'demo schematic should contain a symbol').toBeTruthy();
|
||||
|
||||
// The board tab maps the symbol to ≥1 footprint (reused sheets map to more).
|
||||
await expect
|
||||
.poll(() => crossMapped(pcb), {
|
||||
timeout: 20000,
|
||||
message: 'pcbnew never received/mapped the eeschema selection',
|
||||
})
|
||||
.not.toEqual([]);
|
||||
|
||||
// The selection emit carried the symbol uuid across (sanity on the wire).
|
||||
const schSel = await sch.evaluate(() =>
|
||||
JSON.parse((window as unknown as W).Module.kicadCollabGetSelectionFull()),
|
||||
);
|
||||
expect(schSel.uuids).toContain(symId);
|
||||
|
||||
// Clearing the schematic selection clears the board ghosts.
|
||||
await sch.evaluate(() =>
|
||||
(window as unknown as W).Module.kicadCollabTestClearSelection(),
|
||||
);
|
||||
await expect
|
||||
.poll(() => crossMapped(pcb), { timeout: 20000 })
|
||||
.toEqual([]);
|
||||
|
||||
// ── pcbnew → eeschema ──────────────────────────────────────────────────────
|
||||
const fpId = await pcb.evaluate(() =>
|
||||
(window as unknown as W).Module.kicadCollabTestSelectComponent(),
|
||||
);
|
||||
expect(fpId, 'demo board should contain a footprint').toBeTruthy();
|
||||
|
||||
// The footprint's path tail resolves to a symbol on the schematic's sheet.
|
||||
await expect
|
||||
.poll(() => crossMapped(sch), {
|
||||
timeout: 20000,
|
||||
message: 'eeschema never received/mapped the pcbnew selection',
|
||||
})
|
||||
.not.toEqual([]);
|
||||
|
||||
// …and the emit carried the footprint's schematic path.
|
||||
const pcbSel = await pcb.evaluate(() =>
|
||||
JSON.parse((window as unknown as W).Module.kicadCollabGetSelectionFull()),
|
||||
);
|
||||
expect(pcbSel.uuids).toContain(fpId);
|
||||
expect(pcbSel.fpPaths.length).toBeGreaterThan(0);
|
||||
|
||||
await pcb.evaluate(() =>
|
||||
(window as unknown as W).Module.kicadCollabTestClearSelection(),
|
||||
);
|
||||
await expect
|
||||
.poll(() => crossMapped(sch), { timeout: 20000 })
|
||||
.toEqual([]);
|
||||
});
|
||||
|
|
@ -82,6 +82,13 @@ struct STYLE
|
|||
double pinFillAlpha = 1.0;
|
||||
double pinResolvedAlpha = 0.3;
|
||||
|
||||
// ── cross-app "ghost" selection (0006) ────────────────────────────────
|
||||
// A peer's selection in the OTHER editor (eeschema symbol ⇄ pcbnew
|
||||
// footprint) renders with the normal selection shape but its stroke/fill
|
||||
// alphas scaled down, so a cross-probe highlight reads distinctly softer
|
||||
// than a direct same-document selection.
|
||||
double xselAlphaScale = 0.55;
|
||||
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -157,6 +164,18 @@ inline void patchStyle( STYLE& aStyle, const json& j )
|
|||
aStyle.pinRingAlpha = j.value( "pinRingAlpha", aStyle.pinRingAlpha );
|
||||
aStyle.pinFillAlpha = j.value( "pinFillAlpha", aStyle.pinFillAlpha );
|
||||
aStyle.pinResolvedAlpha = j.value( "pinResolvedAlpha", aStyle.pinResolvedAlpha );
|
||||
|
||||
aStyle.xselAlphaScale = j.value( "xselAlphaScale", aStyle.xselAlphaScale );
|
||||
}
|
||||
|
||||
/** The style a cross-app (0006) ghost selection draws with: the given style
|
||||
* with its selection stroke/fill alphas scaled by `xselAlphaScale`. */
|
||||
inline STYLE ghostStyle( const STYLE& aStyle )
|
||||
{
|
||||
STYLE g = aStyle;
|
||||
g.selStrokeAlpha *= aStyle.xselAlphaScale;
|
||||
g.selFillAlpha *= aStyle.xselAlphaScale;
|
||||
return g;
|
||||
}
|
||||
|
||||
/** The color a peer renders with under this style (fixed > palette-by-name-hash
|
||||
|
|
|
|||
|
|
@ -701,6 +701,10 @@ struct PEER
|
|||
bool hasCursor = false;
|
||||
VECTOR2D cursor; // world coords (IU)
|
||||
std::vector<KIID> selection;
|
||||
// Cross-app selection (0006): SYMBOL uuids derived from a pcbnew peer's
|
||||
// footprint paths (the TS side strips the KIID_PATH down to its tail).
|
||||
// Ghost-rendered when the symbol resolves onto the CURRENT sheet.
|
||||
std::vector<KIID> xsel;
|
||||
};
|
||||
|
||||
// Comment pin dot (collab-presence 0005): the GAL half of the hybrid pin —
|
||||
|
|
@ -839,6 +843,29 @@ void redrawOverlay()
|
|||
g_style );
|
||||
}
|
||||
|
||||
// Cross-app ghosts (0006): a pcbnew peer's selection, as symbol uuids.
|
||||
// Unlike same-room selections (per-sheet rooms scope those by
|
||||
// construction) these arrive project-wide, so only draw items that
|
||||
// resolve onto the sheet THIS canvas is showing — a bbox from another
|
||||
// sheet's screen would land at meaningless coordinates here.
|
||||
if( !peer.xsel.empty() )
|
||||
{
|
||||
pcbjam_presence::STYLE ghost = pcbjam_presence::ghostStyle( g_style );
|
||||
|
||||
for( const KIID& id : peer.xsel )
|
||||
{
|
||||
SCH_SHEET_PATH path;
|
||||
SCH_ITEM* item = fr->Schematic().ResolveItem( id, &path, /*allowNull*/ true );
|
||||
|
||||
if( !item || path.LastScreen() != fr->GetScreen() )
|
||||
continue;
|
||||
|
||||
pcbjam_presence::drawSelectionBox( g_overlay.get(), g_textOverlay.get(),
|
||||
item->ViewBBox(), peer.name, color, px,
|
||||
ghost );
|
||||
}
|
||||
}
|
||||
|
||||
if( peer.hasCursor )
|
||||
pcbjam_presence::drawCursor( g_overlay.get(), g_textOverlay.get(), peer.cursor,
|
||||
peer.name, color, px, g_style );
|
||||
|
|
@ -1596,6 +1623,13 @@ void schCollabSetRemote( std::string aJson )
|
|||
peer.selection.emplace_back( wxString::FromUTF8( u.get<std::string>().c_str() ) );
|
||||
}
|
||||
|
||||
// Cross-app selection (0006): symbol uuids from a pcbnew peer.
|
||||
for( const json& u : p.value( "xsel", json::array() ) )
|
||||
{
|
||||
if( u.is_string() )
|
||||
peer.xsel.emplace_back( wxString::FromUTF8( u.get<std::string>().c_str() ) );
|
||||
}
|
||||
|
||||
peers.push_back( std::move( peer ) );
|
||||
}
|
||||
|
||||
|
|
@ -1776,6 +1810,48 @@ std::string schCollabGetSelection()
|
|||
return presence::selectionUuids( fr ).dump();
|
||||
}
|
||||
|
||||
// JS pull of the current selection in the 0006 payload shape. eeschema has no
|
||||
// footprint paths — the uuids ARE the symbol uuids — but the export keeps the
|
||||
// merged image's kicadCollabGetSelectionFull contract uniform across editors.
|
||||
std::string schCollabGetSelectionFull()
|
||||
{
|
||||
SCH_EDIT_FRAME* fr = schFrame();
|
||||
|
||||
if( !fr )
|
||||
return "{\"uuids\":[],\"fpPaths\":[]}";
|
||||
|
||||
json payload;
|
||||
payload["uuids"] = presence::selectionUuids( fr );
|
||||
payload["fpPaths"] = json::array();
|
||||
return payload.dump();
|
||||
}
|
||||
|
||||
// Test probe (0006): the schematic-item uuids the current peers' cross-app
|
||||
// selections resolve to ON THE CURRENT SHEET (mirrors the redraw's gate).
|
||||
std::string schCollabTestGetCrossMapped()
|
||||
{
|
||||
json arr = json::array();
|
||||
|
||||
SCH_EDIT_FRAME* fr = schFrame();
|
||||
|
||||
if( !fr )
|
||||
return arr.dump();
|
||||
|
||||
for( const presence::PEER& peer : presence::g_peers )
|
||||
{
|
||||
for( const KIID& id : peer.xsel )
|
||||
{
|
||||
SCH_SHEET_PATH path;
|
||||
SCH_ITEM* item = fr->Schematic().ResolveItem( id, &path, /*allowNull*/ true );
|
||||
|
||||
if( item && path.LastScreen() == fr->GetScreen() )
|
||||
arr.push_back( toUtf8( item->m_Uuid.AsString() ) );
|
||||
}
|
||||
}
|
||||
|
||||
return arr.dump();
|
||||
}
|
||||
|
||||
// Test helper: REALLY select the current sheet's first item through the selection
|
||||
// tool, then run the presence check (programmatic selects close no canvas event).
|
||||
std::string schCollabTestSelectFirst()
|
||||
|
|
@ -1812,6 +1888,48 @@ std::string schCollabTestSelectFirst()
|
|||
return toUtf8( target->m_Uuid.AsString() );
|
||||
}
|
||||
|
||||
// Test helper (0006): REALLY select the first SYMBOL on the current sheet —
|
||||
// the deterministic cross-app subject (TestSelectFirst may pick a wire, which
|
||||
// legitimately maps to nothing in pcbnew). Returns the uuid, "" without one.
|
||||
std::string schCollabTestSelectComponent()
|
||||
{
|
||||
SCH_EDIT_FRAME* fr = schFrame();
|
||||
|
||||
if( !fr )
|
||||
return "";
|
||||
|
||||
SCH_SCREEN* screen = currentScreen( fr );
|
||||
|
||||
if( !screen )
|
||||
return "";
|
||||
|
||||
SCH_ITEM* target = nullptr;
|
||||
|
||||
for( SCH_ITEM* item : screen->Items().OfType( SCH_SYMBOL_T ) )
|
||||
{
|
||||
// Power symbols (PWR_FLAG, GND, …) legitimately have no footprint —
|
||||
// they'd make the cross-app subject map to nothing by construction.
|
||||
if( static_cast<SCH_SYMBOL*>( item )->IsPower() )
|
||||
continue;
|
||||
|
||||
target = item;
|
||||
break;
|
||||
}
|
||||
|
||||
if( !target )
|
||||
return "";
|
||||
|
||||
fr->CallAfter( [fr, target]() {
|
||||
if( SCH_SELECTION_TOOL* st = fr->GetToolManager()->GetTool<SCH_SELECTION_TOOL>() )
|
||||
{
|
||||
st->AddItemToSel( target );
|
||||
schedulePresenceSelCheck();
|
||||
}
|
||||
} );
|
||||
|
||||
return toUtf8( target->m_Uuid.AsString() );
|
||||
}
|
||||
|
||||
// Test helper: clear the selection through the tool + run the presence check.
|
||||
bool schCollabTestClearSelection()
|
||||
{
|
||||
|
|
@ -1903,7 +2021,11 @@ EMSCRIPTEN_BINDINGS(eeschema) {
|
|||
function("kicadCollabTestDemoSet", &schCollabTestDemoSet);
|
||||
function("kicadCollabGetViewport", &schCollabGetViewport);
|
||||
function("kicadCollabGetSelection", &schCollabGetSelection);
|
||||
// Cross-app selection (0006).
|
||||
function("kicadCollabGetSelectionFull", &schCollabGetSelectionFull);
|
||||
function("kicadCollabTestGetCrossMapped", &schCollabTestGetCrossMapped);
|
||||
function("kicadCollabTestSelectFirst", &schCollabTestSelectFirst);
|
||||
function("kicadCollabTestSelectComponent", &schCollabTestSelectComponent);
|
||||
function("kicadCollabTestClearSelection", &schCollabTestClearSelection);
|
||||
#endif // !KICAD_MERGED_EMBIND
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,6 +55,10 @@ std::string pcbCollabTestListItems( int aCount );
|
|||
std::string pcbCollabTestDemoSet();
|
||||
std::string pcbCollabGetViewport();
|
||||
std::string pcbCollabGetSelection();
|
||||
// Cross-app selection (collab-presence 0006).
|
||||
std::string pcbCollabGetSelectionFull();
|
||||
std::string pcbCollabTestGetCrossMapped();
|
||||
std::string pcbCollabTestSelectComponent();
|
||||
std::string pcbCollabTestSelectFirst();
|
||||
bool pcbCollabTestClearSelection();
|
||||
|
||||
|
|
@ -77,6 +81,10 @@ std::string schCollabTestListItems( int aCount );
|
|||
std::string schCollabTestDemoSet();
|
||||
std::string schCollabGetViewport();
|
||||
std::string schCollabGetSelection();
|
||||
// Cross-app selection (collab-presence 0006).
|
||||
std::string schCollabGetSelectionFull();
|
||||
std::string schCollabTestGetCrossMapped();
|
||||
std::string schCollabTestSelectComponent();
|
||||
std::string schCollabTestSelectFirst();
|
||||
bool schCollabTestClearSelection();
|
||||
|
||||
|
|
@ -204,6 +212,21 @@ static std::string collabGetSelection()
|
|||
return pcbEditorActive() ? pcbCollabGetSelection() : schCollabGetSelection();
|
||||
}
|
||||
|
||||
static std::string collabGetSelectionFull()
|
||||
{
|
||||
return pcbEditorActive() ? pcbCollabGetSelectionFull() : schCollabGetSelectionFull();
|
||||
}
|
||||
|
||||
static std::string collabTestGetCrossMapped()
|
||||
{
|
||||
return pcbEditorActive() ? pcbCollabTestGetCrossMapped() : schCollabTestGetCrossMapped();
|
||||
}
|
||||
|
||||
static std::string collabTestSelectComponent()
|
||||
{
|
||||
return pcbEditorActive() ? pcbCollabTestSelectComponent() : schCollabTestSelectComponent();
|
||||
}
|
||||
|
||||
static std::string collabTestSelectFirst()
|
||||
{
|
||||
return pcbEditorActive() ? pcbCollabTestSelectFirst() : schCollabTestSelectFirst();
|
||||
|
|
@ -241,6 +264,10 @@ EMSCRIPTEN_BINDINGS(kicad_editor) {
|
|||
function("kicadCollabTestDemoSet", &collabTestDemoSet);
|
||||
function("kicadCollabGetViewport", &collabGetViewport);
|
||||
function("kicadCollabGetSelection", &collabGetSelection);
|
||||
// Cross-app selection (collab-presence 0006).
|
||||
function("kicadCollabGetSelectionFull", &collabGetSelectionFull);
|
||||
function("kicadCollabTestGetCrossMapped", &collabTestGetCrossMapped);
|
||||
function("kicadCollabTestSelectComponent", &collabTestSelectComponent);
|
||||
function("kicadCollabTestSelectFirst", &collabTestSelectFirst);
|
||||
function("kicadCollabTestClearSelection", &collabTestClearSelection);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1106,6 +1106,10 @@ struct PEER
|
|||
bool hasCursor = false;
|
||||
VECTOR2D cursor; // world coords (IU/nm)
|
||||
std::vector<KIID> selection;
|
||||
// Cross-app selection (0006): SYMBOL uuids an eeschema peer has selected —
|
||||
// rendered as ghost outlines on every footprint whose GetPath() ends in one
|
||||
// (a reused sheet legitimately maps one symbol to N footprints).
|
||||
std::vector<KIID> xsel;
|
||||
};
|
||||
|
||||
// Comment pin dot (collab-presence 0005): the GAL half of the hybrid pin —
|
||||
|
|
@ -1171,8 +1175,36 @@ json selectionUuids( PCB_EDIT_FRAME* aFrame )
|
|||
return uuids;
|
||||
}
|
||||
|
||||
// The full selection emit payload (0006): the uuids plus, for every selected
|
||||
// FOOTPRINT, its schematic link — the GetPath() KIID_PATH string ending in the
|
||||
// symbol uuid. eeschema peers highlight the corresponding symbols from it.
|
||||
json selectionPayload( PCB_EDIT_FRAME* aFrame )
|
||||
{
|
||||
json payload;
|
||||
payload["uuids"] = selectionUuids( aFrame );
|
||||
payload["fpPaths"] = json::array();
|
||||
|
||||
PCB_SELECTION_TOOL* selTool = aFrame->GetToolManager()->GetTool<PCB_SELECTION_TOOL>();
|
||||
|
||||
if( !selTool )
|
||||
return payload;
|
||||
|
||||
for( EDA_ITEM* item : selTool->GetSelection() )
|
||||
{
|
||||
if( item->Type() != PCB_FOOTPRINT_T )
|
||||
continue;
|
||||
|
||||
const KIID_PATH& path = static_cast<FOOTPRINT*>( item )->GetPath();
|
||||
|
||||
if( !path.empty() )
|
||||
payload["fpPaths"].push_back( toUtf8( path.AsString() ) );
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
// Post-settle selection emit: read the selection AFTER the triggering event finished
|
||||
// (CallAfter), dedupe against the last emitted set, hand the uuid list to JS.
|
||||
// (CallAfter), dedupe against the last emitted set, hand the payload to JS.
|
||||
void checkSelection()
|
||||
{
|
||||
g_selCheckScheduled = false;
|
||||
|
|
@ -1182,7 +1214,7 @@ void checkSelection()
|
|||
if( !fr )
|
||||
return;
|
||||
|
||||
std::string s = selectionUuids( fr ).dump();
|
||||
std::string s = selectionPayload( fr ).dump();
|
||||
|
||||
if( s == g_lastSelectionJson )
|
||||
return;
|
||||
|
|
@ -1234,6 +1266,47 @@ void redrawOverlay()
|
|||
// All shapes/sizes/placements come from g_style (collab_presence_style.h);
|
||||
// the drawing itself is shared with eeschema's TU so the editors never
|
||||
// diverge visually.
|
||||
// Draw ONE item's selection box under the given style. Exact-geometry
|
||||
// outline (style shape 5): footprints hug their bounding hull, everything
|
||||
// else its transformed shape. Runs on the coroutine fiber (virtual
|
||||
// dispatch), falls back to the bbox on anything that can't produce a
|
||||
// polygon.
|
||||
auto drawItem = [&]( BOARD_ITEM* item, const std::string& name,
|
||||
const KIGFX::COLOR4D& color, const pcbjam_presence::STYLE& style )
|
||||
{
|
||||
SHAPE_POLY_SET outline;
|
||||
|
||||
if( style.selShape == 5 )
|
||||
{
|
||||
try
|
||||
{
|
||||
int pad = KiROUND( style.selPaddingPx * px );
|
||||
int err = KiROUND( px ) + 1;
|
||||
|
||||
if( item->Type() == PCB_FOOTPRINT_T )
|
||||
{
|
||||
outline = static_cast<FOOTPRINT*>( item )->GetBoundingHull();
|
||||
|
||||
if( pad > 0 )
|
||||
outline.Inflate( pad, CORNER_STRATEGY::ROUND_ALL_CORNERS, err );
|
||||
}
|
||||
else
|
||||
{
|
||||
item->TransformShapeToPolygon( outline, (PCB_LAYER_ID) itemLayer( item ),
|
||||
pad, err, ERROR_OUTSIDE );
|
||||
}
|
||||
}
|
||||
catch( ... )
|
||||
{
|
||||
outline.RemoveAllContours();
|
||||
}
|
||||
}
|
||||
|
||||
pcbjam_presence::drawSelectionBox( g_overlay.get(), g_textOverlay.get(),
|
||||
item->ViewBBox(), name, color, px,
|
||||
style, &outline );
|
||||
};
|
||||
|
||||
for( const PEER& peer : g_peers )
|
||||
{
|
||||
KIGFX::COLOR4D color = pcbjam_presence::peerColor( g_style, peer.name, peer.color );
|
||||
|
|
@ -1245,41 +1318,27 @@ void redrawOverlay()
|
|||
if( !item )
|
||||
continue; // not on this board (yet) — skip silently
|
||||
|
||||
// Exact-geometry outline (style shape 5): footprints hug their
|
||||
// bounding hull, everything else its transformed shape. Runs on
|
||||
// the coroutine fiber (virtual dispatch), falls back to the bbox
|
||||
// on anything that can't produce a polygon.
|
||||
SHAPE_POLY_SET outline;
|
||||
drawItem( item, peer.name, color, g_style );
|
||||
}
|
||||
|
||||
if( g_style.selShape == 5 )
|
||||
// Cross-app ghosts (0006): the peer's eeschema symbol uuids, matched
|
||||
// against every footprint's GetPath() tail. The per-redraw linear scan
|
||||
// reflects the live board with no index to invalidate; boards are small
|
||||
// relative to the redraw's own draw cost.
|
||||
if( !peer.xsel.empty() )
|
||||
{
|
||||
pcbjam_presence::STYLE ghost = pcbjam_presence::ghostStyle( g_style );
|
||||
|
||||
for( const KIID& symId : peer.xsel )
|
||||
{
|
||||
try
|
||||
for( FOOTPRINT* fp : board->Footprints() )
|
||||
{
|
||||
int pad = KiROUND( g_style.selPaddingPx * px );
|
||||
int err = KiROUND( px ) + 1;
|
||||
const KIID_PATH& path = fp->GetPath();
|
||||
|
||||
if( item->Type() == PCB_FOOTPRINT_T )
|
||||
{
|
||||
outline = static_cast<FOOTPRINT*>( item )->GetBoundingHull();
|
||||
|
||||
if( pad > 0 )
|
||||
outline.Inflate( pad, CORNER_STRATEGY::ROUND_ALL_CORNERS, err );
|
||||
}
|
||||
else
|
||||
{
|
||||
item->TransformShapeToPolygon( outline, (PCB_LAYER_ID) itemLayer( item ),
|
||||
pad, err, ERROR_OUTSIDE );
|
||||
}
|
||||
}
|
||||
catch( ... )
|
||||
{
|
||||
outline.RemoveAllContours();
|
||||
if( !path.empty() && path.back() == symId )
|
||||
drawItem( fp, peer.name, color, ghost );
|
||||
}
|
||||
}
|
||||
|
||||
pcbjam_presence::drawSelectionBox( g_overlay.get(), g_textOverlay.get(),
|
||||
item->ViewBBox(), peer.name, color, px,
|
||||
g_style, &outline );
|
||||
}
|
||||
|
||||
if( peer.hasCursor )
|
||||
|
|
@ -1700,6 +1759,14 @@ void pcbCollabSetRemote( std::string aJson )
|
|||
peer.selection.emplace_back( wxString::FromUTF8( u.get<std::string>().c_str() ) );
|
||||
}
|
||||
|
||||
// Cross-app selection (0006): symbol uuids from an eeschema peer,
|
||||
// ghost-rendered on the matching footprints.
|
||||
for( const json& u : p.value( "xsel", json::array() ) )
|
||||
{
|
||||
if( u.is_string() )
|
||||
peer.xsel.emplace_back( wxString::FromUTF8( u.get<std::string>().c_str() ) );
|
||||
}
|
||||
|
||||
peers.push_back( std::move( peer ) );
|
||||
}
|
||||
|
||||
|
|
@ -1886,6 +1953,49 @@ std::string pcbCollabGetSelection()
|
|||
return presence::selectionUuids( fr ).dump();
|
||||
}
|
||||
|
||||
// JS pull of the current selection WITH the footprints' schematic paths (0006):
|
||||
// `{uuids:[…], fpPaths:[…]}` — the same payload checkSelection emits. Seeds the
|
||||
// cross-app presence at attach; GetSelection above keeps its bare-array shape
|
||||
// for the pre-0006 callers and the e2e no-leak probe.
|
||||
std::string pcbCollabGetSelectionFull()
|
||||
{
|
||||
PCB_EDIT_FRAME* fr = pcbFrame();
|
||||
|
||||
if( !fr )
|
||||
return "{\"uuids\":[],\"fpPaths\":[]}";
|
||||
|
||||
return presence::selectionPayload( fr ).dump();
|
||||
}
|
||||
|
||||
// Test probe (0006): the board-item uuids the current peers' cross-app
|
||||
// selections (xsel symbol uuids) resolve to on THIS board — a deterministic
|
||||
// assertion target next to the pixel diff.
|
||||
std::string pcbCollabTestGetCrossMapped()
|
||||
{
|
||||
json arr = json::array();
|
||||
|
||||
PCB_EDIT_FRAME* fr = pcbFrame();
|
||||
|
||||
if( !fr )
|
||||
return arr.dump();
|
||||
|
||||
for( const presence::PEER& peer : presence::g_peers )
|
||||
{
|
||||
for( const KIID& symId : peer.xsel )
|
||||
{
|
||||
for( FOOTPRINT* fp : fr->GetBoard()->Footprints() )
|
||||
{
|
||||
const KIID_PATH& path = fp->GetPath();
|
||||
|
||||
if( !path.empty() && path.back() == symId )
|
||||
arr.push_back( toUtf8( fp->m_Uuid.AsString() ) );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return arr.dump();
|
||||
}
|
||||
|
||||
// Test helper: REALLY select the first top-level item through the selection tool (the
|
||||
// same call the cross-probe flash uses), then run the presence check — a programmatic
|
||||
// select has no closing canvas event. Returns the selected uuid.
|
||||
|
|
@ -1918,6 +2028,39 @@ std::string pcbCollabTestSelectFirst()
|
|||
return toUtf8( target->m_Uuid.AsString() );
|
||||
}
|
||||
|
||||
// Test helper (0006): REALLY select the first FOOTPRINT through the selection
|
||||
// tool — the deterministic cross-app subject (TestSelectFirst may pick a track,
|
||||
// which legitimately maps to nothing in eeschema). Returns the uuid, "" if the
|
||||
// board has no footprints.
|
||||
std::string pcbCollabTestSelectComponent()
|
||||
{
|
||||
PCB_EDIT_FRAME* fr = pcbFrame();
|
||||
|
||||
if( !fr )
|
||||
return "";
|
||||
|
||||
FOOTPRINT* target = nullptr;
|
||||
|
||||
for( FOOTPRINT* fp : fr->GetBoard()->Footprints() )
|
||||
{
|
||||
target = fp;
|
||||
break;
|
||||
}
|
||||
|
||||
if( !target )
|
||||
return "";
|
||||
|
||||
fr->CallAfter( [fr, target]() {
|
||||
if( PCB_SELECTION_TOOL* st = fr->GetToolManager()->GetTool<PCB_SELECTION_TOOL>() )
|
||||
{
|
||||
st->AddItemToSel( target );
|
||||
schedulePresenceSelCheck();
|
||||
}
|
||||
} );
|
||||
|
||||
return toUtf8( target->m_Uuid.AsString() );
|
||||
}
|
||||
|
||||
// Test helper: clear the selection through the tool + run the presence check.
|
||||
bool pcbCollabTestClearSelection()
|
||||
{
|
||||
|
|
@ -2192,7 +2335,11 @@ EMSCRIPTEN_BINDINGS(pcbnew) {
|
|||
function("kicadCollabTestDemoSet", &pcbCollabTestDemoSet);
|
||||
function("kicadCollabGetViewport", &pcbCollabGetViewport);
|
||||
function("kicadCollabGetSelection", &pcbCollabGetSelection);
|
||||
// Cross-app selection (0006).
|
||||
function("kicadCollabGetSelectionFull", &pcbCollabGetSelectionFull);
|
||||
function("kicadCollabTestGetCrossMapped", &pcbCollabTestGetCrossMapped);
|
||||
function("kicadCollabTestSelectFirst", &pcbCollabTestSelectFirst);
|
||||
function("kicadCollabTestSelectComponent", &pcbCollabTestSelectComponent);
|
||||
function("kicadCollabTestClearSelection", &pcbCollabTestClearSelection);
|
||||
#endif // !KICAD_MERGED_EMBIND
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
Subproject commit 5f5effa4e0d9cab0261a5e0f771c7c4cb31b427d
|
||||
Subproject commit b68ee96a85b4793ebd15afab5a68f296406b53b5
|
||||
|
|
@ -58,6 +58,7 @@ import {
|
|||
hasPresenceBridge,
|
||||
type PresenceKicadWindow,
|
||||
} from "@/wasm/collab/presence-kicad";
|
||||
import { startCrossAppPresence, type CrossAppHandle } from "@/wasm/collab/cross-app";
|
||||
import {
|
||||
createComments,
|
||||
hasCommentsBridge,
|
||||
|
|
@ -598,6 +599,9 @@ export function WasmTool({
|
|||
const driftRef = React.useRef<{ stop(): void } | null>(null);
|
||||
const presenceRef = React.useRef<PresenceHandle | null>(null);
|
||||
const presenceBridgeRef = React.useRef<{ destroy(): void } | null>(null);
|
||||
// Project-wide presence room (0006): joined once per session, survives
|
||||
// eeschema sheet rebinds — the bridge re-reads it on every startPresence.
|
||||
const crossAppRef = React.useRef<CrossAppHandle | null>(null);
|
||||
const sheetManagerRef = React.useRef<SheetCollabManager | null>(null);
|
||||
// The single-room collab doc (pcbnew/pl_editor), for the layout save-sync
|
||||
// (miss 08B); eeschema routes per sheet through the manager instead.
|
||||
|
|
@ -811,6 +815,8 @@ export function WasmTool({
|
|||
mod: win.Module,
|
||||
win: win as unknown as PresenceKicadWindow,
|
||||
presence,
|
||||
// Cross-app selection (0006): the project presence room, if joined.
|
||||
crossApp: crossAppRef.current ?? undefined,
|
||||
// Live world↔screen transform for the DOM comment layer (0005).
|
||||
onViewport: setViewportState,
|
||||
});
|
||||
|
|
@ -964,6 +970,26 @@ export function WasmTool({
|
|||
// divergence. Gated on a real collab session; re-targeted per active sheet below.
|
||||
const { startDriftDetection } = await import("@/wasm/collab/drift-detect");
|
||||
|
||||
// Cross-app selection (0006): join the project-wide presence room BEFORE
|
||||
// the per-file collab starts, so the first startPresence bind already
|
||||
// routes xsel. Honors the same ?collab=0 opt-out as the room collab.
|
||||
const collabOptOut =
|
||||
new URLSearchParams(win.location.search).get("collab") === "0" ||
|
||||
new URLSearchParams(win.location.search).get("collab") === "false";
|
||||
if ((tool === "pcbnew" || tool === "eeschema") && !collabOptOut) {
|
||||
crossAppRef.current =
|
||||
(await startCrossAppPresence({
|
||||
projectId,
|
||||
provider: yjsProviderConfig(),
|
||||
user: presenceUser(),
|
||||
tool,
|
||||
})) ?? null;
|
||||
// Test/debug handle (mirrors __pcbjamComments): lets the e2e assert
|
||||
// the project-room peer view without driving pixels.
|
||||
(win as { __pcbjamCrossApp?: CrossAppHandle | null }).__pcbjamCrossApp =
|
||||
crossAppRef.current;
|
||||
}
|
||||
|
||||
if (tool === "eeschema") {
|
||||
// Multi-room (subschema) collab: every .kicad_sch is its own warm room; the
|
||||
// active sheet is bound, navigation re-routes it (C++ onSheetChanged hook).
|
||||
|
|
@ -1042,6 +1068,8 @@ export function WasmTool({
|
|||
presenceBridgeRef.current = null;
|
||||
presenceRef.current?.destroy();
|
||||
presenceRef.current = null;
|
||||
crossAppRef.current?.destroy();
|
||||
crossAppRef.current = null;
|
||||
driftRef.current?.stop();
|
||||
driftRef.current = null;
|
||||
// Tears down every warm room's provider/doc (the only place providers are
|
||||
|
|
|
|||
108
web/standalone/src/wasm/collab/cross-app.test.ts
Normal file
108
web/standalone/src/wasm/collab/cross-app.test.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { colorForUser, type PresenceUser } from "@pcbjam/shared";
|
||||
import { resetPresenceColorClaims } from "./presence";
|
||||
import { startCrossAppPresence, type CrossAppHandle } from "./cross-app";
|
||||
|
||||
/**
|
||||
* Cross-app presence room unit tests (collab-presence 0006): real handles on
|
||||
* the BroadcastChannel provider (node ≥18 has BroadcastChannel globally) stand
|
||||
* in for an eeschema tab and a pcbnew tab of the same project.
|
||||
*/
|
||||
|
||||
const settle = () => new Promise((r) => setTimeout(r, 60));
|
||||
|
||||
function user(id: string): PresenceUser {
|
||||
return { id, name: id, color: colorForUser(id) };
|
||||
}
|
||||
|
||||
let handles: CrossAppHandle[] = [];
|
||||
let projectSeq = 0;
|
||||
|
||||
async function join(projectId: string, userId: string, tool: string): Promise<CrossAppHandle> {
|
||||
const h = await startCrossAppPresence({
|
||||
projectId,
|
||||
provider: { kind: "broadcastchannel", settleMs: 10 },
|
||||
user: user(userId),
|
||||
tool,
|
||||
});
|
||||
if (!h) throw new Error("cross-app handle expected on the BC provider");
|
||||
handles.push(h);
|
||||
return h;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const h of handles) h.destroy();
|
||||
handles = [];
|
||||
resetPresenceColorClaims();
|
||||
});
|
||||
|
||||
describe("startCrossAppPresence", () => {
|
||||
it("returns undefined for the none provider", async () => {
|
||||
const h = await startCrossAppPresence({
|
||||
projectId: "P",
|
||||
provider: { kind: "none" },
|
||||
user: user("alice"),
|
||||
tool: "pcbnew",
|
||||
});
|
||||
expect(h).toBeUndefined();
|
||||
});
|
||||
|
||||
it("peers see only OTHER-tool clients, including the own user's other tab", async () => {
|
||||
const project = `xapp-${projectSeq++}`;
|
||||
const sch = await join(project, "alice", "eeschema");
|
||||
const pcb = await join(project, "alice", "pcbnew"); // same user, other tool
|
||||
const pcb2 = await join(project, "bob", "pcbnew");
|
||||
await settle();
|
||||
|
||||
// The eeschema tab sees both pcbnew tabs (own user's included).
|
||||
const schPeers = sch.peers();
|
||||
expect(schPeers.map((p) => p.state.user.id).sort()).toEqual(["alice", "bob"]);
|
||||
expect(schPeers.every((p) => p.state.tool === "pcbnew")).toBe(true);
|
||||
|
||||
// A pcbnew tab sees only the eeschema tab — the other pcbnew tab is
|
||||
// same-tool (per-file rooms own that relationship).
|
||||
expect(pcb.peers().map((p) => p.state.user.id)).toEqual(["alice"]);
|
||||
expect(pcb2.peers().map((p) => p.state.user.id)).toEqual(["alice"]);
|
||||
});
|
||||
|
||||
it("propagates selection + selectionPaths and clears on destroy", async () => {
|
||||
const project = `xapp-${projectSeq++}`;
|
||||
const sch = await join(project, "alice", "eeschema");
|
||||
const pcb = await join(project, "bob", "pcbnew");
|
||||
await settle();
|
||||
|
||||
pcb.setSelection(["fp-uuid"], ["/sheet/sym-1"]);
|
||||
await settle();
|
||||
const seen = sch.peers().find((p) => p.state.user.id === "bob");
|
||||
expect(seen?.state.selection).toEqual(["fp-uuid"]);
|
||||
expect(seen?.state.selectionPaths).toEqual(["/sheet/sym-1"]);
|
||||
|
||||
// Clearing the paths drops the optional field from the published state.
|
||||
pcb.setSelection([], undefined);
|
||||
await settle();
|
||||
const cleared = sch.peers().find((p) => p.state.user.id === "bob");
|
||||
expect(cleared?.state.selection).toEqual([]);
|
||||
expect(cleared?.state.selectionPaths).toBeUndefined();
|
||||
|
||||
pcb.destroy();
|
||||
await settle();
|
||||
expect(sch.peers()).toEqual([]);
|
||||
});
|
||||
|
||||
it("different projects do not share the presence room", async () => {
|
||||
const a = await join(`xapp-${projectSeq++}`, "alice", "eeschema");
|
||||
await join(`xapp-${projectSeq++}`, "bob", "pcbnew");
|
||||
await settle();
|
||||
expect(a.peers()).toEqual([]);
|
||||
});
|
||||
|
||||
it("notifies subscribers on peer changes", async () => {
|
||||
const project = `xapp-${projectSeq++}`;
|
||||
const sch = await join(project, "alice", "eeschema");
|
||||
let fired = 0;
|
||||
sch.subscribe(() => fired++);
|
||||
await join(project, "bob", "pcbnew");
|
||||
await settle();
|
||||
expect(fired).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
141
web/standalone/src/wasm/collab/cross-app.ts
Normal file
141
web/standalone/src/wasm/collab/cross-app.ts
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import * as Y from "yjs";
|
||||
import {
|
||||
presenceRoomId,
|
||||
presenceStateSchema,
|
||||
type PresenceState,
|
||||
type PresenceUser,
|
||||
} from "@pcbjam/shared";
|
||||
import { connectProvider, type ProviderConfig, type YjsProvider } from "./provider";
|
||||
import { claimedPresenceColor } from "./presence";
|
||||
import { clog } from "./debug";
|
||||
|
||||
/**
|
||||
* Project-wide presence room (collab-presence 0006): one awareness-only room
|
||||
* per PROJECT, joined by every collab-capable editor alongside its per-file
|
||||
* room(s). Per-file rooms keep owning same-document presence (cursors, roster,
|
||||
* selection outlines); this room exists ONLY so cross-app features can see
|
||||
* peers in the project's OTHER documents — an eeschema tab learning what a
|
||||
* pcbnew tab has selected, and vice versa.
|
||||
*
|
||||
* The local client publishes a full `PresenceState` (cursor always null —
|
||||
* world coordinates are meaningless across documents) and updates only its
|
||||
* `selection`/`selectionPaths`, so traffic is selection-rate, not cursor-rate.
|
||||
* The Y.Doc is a required transport sidecar that stays empty; backends skip
|
||||
* persisting `~presence` rooms.
|
||||
*/
|
||||
|
||||
export interface CrossAppPeer {
|
||||
clientId: number;
|
||||
state: PresenceState;
|
||||
}
|
||||
|
||||
export interface CrossAppHandle {
|
||||
/** Publish this tab's selection (uuids + pcbnew footprint paths). */
|
||||
setSelection(uuids: string[], paths?: string[]): void;
|
||||
/**
|
||||
* Peers in a DIFFERENT tool, one entry per awareness client. Unlike the
|
||||
* room roster this INCLUDES the own user's other tabs — one person with
|
||||
* the schematic and the board open gets classic cross-probing.
|
||||
*/
|
||||
peers(): CrossAppPeer[];
|
||||
/** Fires on every awareness change in the project room. Returns unsubscribe. */
|
||||
subscribe(cb: () => void): () => void;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
export async function startCrossAppPresence(opts: {
|
||||
projectId: string;
|
||||
provider: ProviderConfig;
|
||||
user: PresenceUser;
|
||||
tool: string;
|
||||
}): Promise<CrossAppHandle | undefined> {
|
||||
if (opts.provider.kind === "none") return undefined;
|
||||
|
||||
const room = presenceRoomId(opts.projectId);
|
||||
const doc = new Y.Doc();
|
||||
let provider: YjsProvider;
|
||||
try {
|
||||
provider = await connectProvider(doc, opts.provider, { room });
|
||||
} catch (err) {
|
||||
clog("cross-app: provider connect failed —", String(err));
|
||||
doc.destroy();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const awareness = provider.awareness;
|
||||
if (!awareness) {
|
||||
provider.destroy();
|
||||
doc.destroy();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let selection: string[] = [];
|
||||
let selectionPaths: string[] | undefined;
|
||||
|
||||
const publish = () => {
|
||||
const state: PresenceState = {
|
||||
// Reuse the bound room's claimed color so one user is one color
|
||||
// everywhere (same rule as the eeschema skeleton states).
|
||||
user: { ...opts.user, color: claimedPresenceColor(opts.user.id) ?? opts.user.color },
|
||||
tool: opts.tool,
|
||||
cursor: null,
|
||||
selection,
|
||||
...(selectionPaths?.length ? { selectionPaths } : {}),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
awareness.setLocalState(state);
|
||||
};
|
||||
|
||||
publish();
|
||||
clog("cross-app: joined project presence room", room, "as", opts.tool);
|
||||
|
||||
const subscribers = new Set<() => void>();
|
||||
const onChange = () => {
|
||||
for (const cb of subscribers) cb();
|
||||
};
|
||||
awareness.on("change", onChange);
|
||||
|
||||
// Fast removal on tab close (same rationale as presence.ts).
|
||||
const onPageHide = () => awareness.setLocalState(null);
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("pagehide", onPageHide);
|
||||
}
|
||||
|
||||
let destroyed = false;
|
||||
return {
|
||||
setSelection(uuids, paths) {
|
||||
selection = uuids;
|
||||
selectionPaths = paths;
|
||||
publish();
|
||||
},
|
||||
peers() {
|
||||
const out: CrossAppPeer[] = [];
|
||||
for (const [clientId, raw] of awareness.getStates()) {
|
||||
if (clientId === awareness.clientID) continue;
|
||||
const parsed = presenceStateSchema.safeParse(raw);
|
||||
if (!parsed.success) continue;
|
||||
// Same-tool peers are the per-file rooms' business (and may not even
|
||||
// share a document with us) — cross-app only maps across editors.
|
||||
if (parsed.data.tool === opts.tool) continue;
|
||||
out.push({ clientId, state: parsed.data });
|
||||
}
|
||||
return out.sort((a, b) => a.clientId - b.clientId);
|
||||
},
|
||||
subscribe(cb) {
|
||||
subscribers.add(cb);
|
||||
return () => subscribers.delete(cb);
|
||||
},
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
if (typeof window !== "undefined") {
|
||||
window.removeEventListener("pagehide", onPageHide);
|
||||
}
|
||||
awareness.off("change", onChange);
|
||||
subscribers.clear();
|
||||
awareness.setLocalState(null);
|
||||
provider.destroy();
|
||||
doc.destroy();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -1,7 +1,14 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import { colorForUser } from "@pcbjam/shared";
|
||||
import { bindKicadPresence, hasPresenceBridge, type PresenceKicadWindow } from "./presence-kicad";
|
||||
import { colorForUser, type PresenceState } from "@pcbjam/shared";
|
||||
import {
|
||||
bindKicadPresence,
|
||||
hasPresenceBridge,
|
||||
parseSelectionEmit,
|
||||
xselFromPeerState,
|
||||
type PresenceKicadWindow,
|
||||
} from "./presence-kicad";
|
||||
import type { PresenceHandle, PresencePeer } from "./presence";
|
||||
import type { CrossAppHandle, CrossAppPeer } from "./cross-app";
|
||||
|
||||
/**
|
||||
* presence-kicad bridge unit tests (collab-presence 0002): a fake Module +
|
||||
|
|
@ -123,3 +130,120 @@ describe("bindKicadPresence", () => {
|
|||
expect(hasPresenceBridge(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── cross-app selection (0006) ───────────────────────────────────────────────
|
||||
|
||||
function crossState(tool: string, over: Partial<PresenceState> = {}): PresenceState {
|
||||
return {
|
||||
user: { id: "bob", name: "bob", color: colorForUser("bob") },
|
||||
tool,
|
||||
cursor: null,
|
||||
selection: [],
|
||||
updatedAt: 1,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function stubCrossApp(initial: CrossAppPeer[] = []) {
|
||||
let peers = initial;
|
||||
const subscribers = new Set<() => void>();
|
||||
const handle: CrossAppHandle & { firePeers(p: CrossAppPeer[]): void } = {
|
||||
setSelection: vi.fn(),
|
||||
peers: () => peers,
|
||||
subscribe(cb) {
|
||||
subscribers.add(cb);
|
||||
return () => subscribers.delete(cb);
|
||||
},
|
||||
destroy: vi.fn(),
|
||||
firePeers(p) {
|
||||
peers = p;
|
||||
for (const cb of subscribers) cb();
|
||||
},
|
||||
};
|
||||
return handle;
|
||||
}
|
||||
|
||||
describe("parseSelectionEmit", () => {
|
||||
it("handles the bare array (eeschema / pre-0006) and object shapes", () => {
|
||||
expect(parseSelectionEmit('["u1","u2"]')).toEqual({ uuids: ["u1", "u2"] });
|
||||
expect(parseSelectionEmit('{"uuids":["u1"],"fpPaths":["/p1"]}')).toEqual({
|
||||
uuids: ["u1"],
|
||||
fpPaths: ["/p1"],
|
||||
});
|
||||
expect(parseSelectionEmit('{"uuids":["u1"]}')).toEqual({ uuids: ["u1"] });
|
||||
});
|
||||
|
||||
it("drops non-string entries and returns null on malformed payloads", () => {
|
||||
expect(parseSelectionEmit('["u1",42]')).toEqual({ uuids: ["u1"] });
|
||||
expect(parseSelectionEmit("not json")).toBeNull();
|
||||
expect(parseSelectionEmit("42")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("xselFromPeerState", () => {
|
||||
it("maps a pcbnew peer's footprint paths to symbol uuids", () => {
|
||||
expect(
|
||||
xselFromPeerState(crossState("pcbnew", { selectionPaths: ["/sheet-1/sym-1", "/sym-2"] })),
|
||||
).toEqual(["sym-1", "sym-2"]);
|
||||
// Board selection uuids alone (no paths) map to nothing in eeschema.
|
||||
expect(xselFromPeerState(crossState("pcbnew", { selection: ["board-uuid"] }))).toEqual([]);
|
||||
});
|
||||
|
||||
it("passes an eeschema peer's symbol uuids through, and skips other tools", () => {
|
||||
expect(xselFromPeerState(crossState("eeschema", { selection: ["sym-1"] }))).toEqual(["sym-1"]);
|
||||
expect(xselFromPeerState(crossState("pl_editor", { selection: ["u1"] }))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bindKicadPresence × crossApp", () => {
|
||||
it("forwards selection emits (uuids + fpPaths) into the cross-app room", () => {
|
||||
const mod = fakeModule();
|
||||
const win: PresenceKicadWindow = {};
|
||||
const presence = stubPresence();
|
||||
const crossApp = stubCrossApp();
|
||||
bindKicadPresence({ mod, win, presence, crossApp });
|
||||
|
||||
win.kicadCollab!.onSelection!('{"uuids":["u1"],"fpPaths":["/p1"]}');
|
||||
expect(presence.setSelection).toHaveBeenCalledWith(["u1"]);
|
||||
expect(crossApp.setSelection).toHaveBeenCalledWith(["u1"], ["/p1"]);
|
||||
|
||||
win.kicadCollab!.onSelection!('["u2"]');
|
||||
expect(crossApp.setSelection).toHaveBeenCalledWith(["u2"], undefined);
|
||||
});
|
||||
|
||||
it("seeds cross-app from kicadCollabGetSelectionFull when present", () => {
|
||||
const mod = {
|
||||
...fakeModule(),
|
||||
kicadCollabGetSelectionFull: vi.fn(() => '{"uuids":["pre"],"fpPaths":["/pp"]}'),
|
||||
};
|
||||
const crossApp = stubCrossApp();
|
||||
bindKicadPresence({ mod, win: {}, presence: stubPresence(), crossApp });
|
||||
expect(crossApp.setSelection).toHaveBeenCalledWith(["pre"], ["/pp"]);
|
||||
});
|
||||
|
||||
it("appends cross-app peers to the remote snapshot as xsel ghost entries", async () => {
|
||||
const mod = fakeModule();
|
||||
const presence = stubPresence();
|
||||
const crossApp = stubCrossApp();
|
||||
bindKicadPresence({ mod, win: {}, presence, crossApp });
|
||||
|
||||
crossApp.firePeers([
|
||||
{ clientId: 7, state: crossState("eeschema", { selection: ["sym-1"] }) },
|
||||
// Empty mapped selection → no snapshot entry.
|
||||
{ clientId: 8, state: crossState("pcbnew", { selection: ["board-uuid"] }) },
|
||||
]);
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
|
||||
const snapshot = JSON.parse(mod.kicadCollabSetRemote.mock.calls.at(-1)![0]);
|
||||
expect(snapshot.peers).toEqual([
|
||||
{
|
||||
id: "bob#x7",
|
||||
name: "bob · sch",
|
||||
color: colorForUser("bob"),
|
||||
cursor: null,
|
||||
selection: [],
|
||||
xsel: ["sym-1"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { symbolUuidFromFootprintPath } from "@pcbjam/shared";
|
||||
import { clog } from "./debug";
|
||||
import type { PresenceHandle, PresencePeer } from "./presence";
|
||||
import type { CrossAppHandle } from "./cross-app";
|
||||
|
||||
/**
|
||||
* Wire the C++ presence bridge (collab-presence 0002) to the awareness layer:
|
||||
|
|
@ -23,6 +25,9 @@ export interface PresenceKicadModule {
|
|||
kicadCollabSetRemote(json: string): void;
|
||||
kicadCollabGetViewport(): string;
|
||||
kicadCollabGetSelection(): string;
|
||||
/** 0006 (pcbnew builds): `{uuids, fpPaths}` — uuids plus the selected
|
||||
* footprints' schematic paths. Absent on older wasm. */
|
||||
kicadCollabGetSelectionFull?(): string;
|
||||
}
|
||||
|
||||
export interface PresenceKicadWindow {
|
||||
|
|
@ -53,24 +58,78 @@ export function hasPresenceBridge(mod: unknown): mod is PresenceKicadModule {
|
|||
|
||||
const PUSH_THROTTLE_MS = 30;
|
||||
|
||||
/**
|
||||
* Parse a C++ selection emit (0006): pcbnew emits `{uuids, fpPaths}` (paths =
|
||||
* the selected footprints' `GetPath()` strings), eeschema and older builds a
|
||||
* bare uuid array. Returns null on a malformed payload (callers then keep the
|
||||
* last published selection, the pre-0006 behavior).
|
||||
*/
|
||||
export function parseSelectionEmit(
|
||||
json: string,
|
||||
): { uuids: string[]; fpPaths?: string[] } | null {
|
||||
try {
|
||||
const v: unknown = JSON.parse(json);
|
||||
if (Array.isArray(v)) {
|
||||
return { uuids: v.filter((u): u is string => typeof u === "string") };
|
||||
}
|
||||
if (v && typeof v === "object") {
|
||||
const o = v as { uuids?: unknown; fpPaths?: unknown };
|
||||
return {
|
||||
uuids: Array.isArray(o.uuids)
|
||||
? o.uuids.filter((u): u is string => typeof u === "string")
|
||||
: [],
|
||||
...(Array.isArray(o.fpPaths)
|
||||
? { fpPaths: o.fpPaths.filter((p): p is string => typeof p === "string") }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
/* malformed */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The cross-app highlight targets a peer's state maps to in THIS editor
|
||||
* (0006): a pcbnew peer's footprint paths become symbol uuids (eeschema
|
||||
* resolves them directly); an eeschema peer's selection uuids ARE the symbol
|
||||
* uuids (pcbnew suffix-matches them against footprint paths). Tools without
|
||||
* a counterpart map to nothing.
|
||||
*/
|
||||
export function xselFromPeerState(state: {
|
||||
tool: string;
|
||||
selection: string[];
|
||||
selectionPaths?: string[];
|
||||
}): string[] {
|
||||
if (state.tool === "pcbnew") {
|
||||
return (state.selectionPaths ?? [])
|
||||
.map(symbolUuidFromFootprintPath)
|
||||
.filter((u): u is string => u !== null);
|
||||
}
|
||||
if (state.tool === "eeschema") return state.selection;
|
||||
return [];
|
||||
}
|
||||
|
||||
const TOOL_TAG: Record<string, string> = { pcbnew: "pcb", eeschema: "sch" };
|
||||
|
||||
export function bindKicadPresence(opts: {
|
||||
mod: PresenceKicadModule;
|
||||
win: PresenceKicadWindow;
|
||||
presence: PresenceHandle;
|
||||
/** 0006: the project presence room — cross-app selection in/out. */
|
||||
crossApp?: CrossAppHandle;
|
||||
onViewport?: (vp: ViewportState) => void;
|
||||
}): { destroy(): void } {
|
||||
const { mod, win, presence } = opts;
|
||||
const { mod, win, presence, crossApp } = opts;
|
||||
|
||||
// C++ → awareness ------------------------------------------------------------
|
||||
win.kicadCollab = {
|
||||
...win.kicadCollab,
|
||||
onSelection: (uuidsJson) => {
|
||||
try {
|
||||
const uuids = JSON.parse(uuidsJson) as string[];
|
||||
presence.setSelection(Array.isArray(uuids) ? uuids : []);
|
||||
} catch {
|
||||
/* malformed emit — keep the last published selection */
|
||||
}
|
||||
const parsed = parseSelectionEmit(uuidsJson);
|
||||
if (!parsed) return; // malformed emit — keep the last published selection
|
||||
presence.setSelection(parsed.uuids);
|
||||
crossApp?.setSelection(parsed.uuids, parsed.fpPaths);
|
||||
},
|
||||
onCursor: (x, y, active) => {
|
||||
presence.setCursor(active ? { x, y } : null);
|
||||
|
|
@ -80,10 +139,16 @@ export function bindKicadPresence(opts: {
|
|||
},
|
||||
};
|
||||
|
||||
// Seed: the tab may attach with a selection already made (e.g. rebind).
|
||||
// Seed: the tab may attach with a selection already made (e.g. rebind). The
|
||||
// Full variant (pcbnew 0006) also carries footprint paths for cross-app.
|
||||
try {
|
||||
const seed = JSON.parse(mod.kicadCollabGetSelection() || "[]") as string[];
|
||||
if (Array.isArray(seed) && seed.length) presence.setSelection(seed);
|
||||
const seed = parseSelectionEmit(
|
||||
(mod.kicadCollabGetSelectionFull?.() ?? mod.kicadCollabGetSelection()) || "[]",
|
||||
);
|
||||
if (seed?.uuids.length) {
|
||||
presence.setSelection(seed.uuids);
|
||||
crossApp?.setSelection(seed.uuids, seed.fpPaths);
|
||||
}
|
||||
} catch {
|
||||
/* bridge present but frame not up yet — the first emit will seed */
|
||||
}
|
||||
|
|
@ -91,7 +156,16 @@ export function bindKicadPresence(opts: {
|
|||
// awareness → C++ ------------------------------------------------------------
|
||||
const pushRemote = () => {
|
||||
const peers = presence.peers();
|
||||
const snapshot = {
|
||||
const snapshot: {
|
||||
peers: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
cursor: { x: number; y: number } | null;
|
||||
selection: string[];
|
||||
xsel?: string[];
|
||||
}>;
|
||||
} = {
|
||||
peers: peers.map((p: PresencePeer) => ({
|
||||
id: p.user.id,
|
||||
name: p.user.name,
|
||||
|
|
@ -100,6 +174,21 @@ export function bindKicadPresence(opts: {
|
|||
selection: p.selection,
|
||||
})),
|
||||
};
|
||||
// Cross-app peers (0006): rendered as ghost outlines on the mapped items.
|
||||
// One entry per awareness CLIENT (own other tabs included — that's the
|
||||
// single-user cross-probe), tagged with the source editor.
|
||||
for (const p of crossApp?.peers() ?? []) {
|
||||
const xsel = xselFromPeerState(p.state);
|
||||
if (!xsel.length) continue;
|
||||
snapshot.peers.push({
|
||||
id: `${p.state.user.id}#x${p.clientId}`,
|
||||
name: `${p.state.user.name} · ${TOOL_TAG[p.state.tool] ?? p.state.tool}`,
|
||||
color: p.state.user.color,
|
||||
cursor: null,
|
||||
selection: [],
|
||||
xsel,
|
||||
});
|
||||
}
|
||||
mod.kicadCollabSetRemote(JSON.stringify(snapshot));
|
||||
};
|
||||
|
||||
|
|
@ -113,6 +202,7 @@ export function bindKicadPresence(opts: {
|
|||
};
|
||||
|
||||
const unsubscribe = presence.subscribe(schedulePush);
|
||||
const unsubscribeCross = crossApp?.subscribe(schedulePush);
|
||||
|
||||
mod.kicadCollabPresenceStart();
|
||||
pushRemote();
|
||||
|
|
@ -121,6 +211,7 @@ export function bindKicadPresence(opts: {
|
|||
return {
|
||||
destroy() {
|
||||
unsubscribe();
|
||||
unsubscribeCross?.();
|
||||
if (pushTimer) clearTimeout(pushTimer);
|
||||
pushTimer = undefined;
|
||||
if (win.kicadCollab) {
|
||||
|
|
|
|||
|
|
@ -67,6 +67,12 @@ export function resetPresenceColorClaims(): void {
|
|||
g_claims.clear();
|
||||
}
|
||||
|
||||
/** The session's claimed color for a user, if any — sibling rooms (skeletons,
|
||||
* the 0006 project presence room) reuse it so one user is one color everywhere. */
|
||||
export function claimedPresenceColor(userId: string): string | undefined {
|
||||
return g_claims.get(userId);
|
||||
}
|
||||
|
||||
/** Least-used palette color among the OTHER users in `states` — the lowest
|
||||
* free slot while the room is smaller than the palette, fair reuse after. */
|
||||
function lowestFreeColor(
|
||||
|
|
|
|||
Loading…
Reference in a new issue