feat(collab): presence P3 — eeschema port (collab-presence 0003)
- eeschema_embind.cpp: presence section (0002 pattern, zero fork changes) —
wx canvas triggers + SCHEMATIC_LISTENER piggyback → post-settle
SCH_SELECTION_TOOL emit; throttled cursor; remote VIEW_OVERLAY render
(SCHEMATIC::ResolveItem, name tags, screen-constant via GAL matrix);
schCollab{PresenceStart,SetRemote,GetViewport,GetSelection,TestSelectFirst,
TestClearSelection}; kicad_editor_embind dispatches by active frame.
- sheet-manager: parked rooms carry SKELETON awareness states
({user,tool,sheetPath=bound sheet}) via publishSkeletons on switch + late
warm-up — the bound room's awareness then holds every project peer, so no
multi-room aggregation; presence.ts publishSkeleton helper.
- PresenceRoster: sheet-aware — peers on another sheet render dimmed with
'on <sheet>' tooltip; WasmTool un-gates the kicad presence bridge for
eeschema and threads activeSheetPath.
- tests: presence-eeschema.spec.ts (5 e2e, mirrors pcbnew incl. the px/IU
band at eeschema's 1e4/mm IU); presence.test.ts +2 (skeleton visibility,
no ghost cursor after rebind). eeschema-collab/subschema stay green.
Verified live: two tabs on demo.kicad_sch — peer cursor cross + label,
selection box + name tag, roster avatar.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CvqUd4QsJSGHN28aunJRTq
This commit is contained in:
parent
943e8fe4d2
commit
6bc54de92b
9 changed files with 982 additions and 29 deletions
|
|
@ -2,7 +2,7 @@
|
|||
title: "DevBlog 2026 week 26"
|
||||
description: "Rebasing to KiCad 10.0.4 "
|
||||
author: Gergő Törcsvári
|
||||
pubDate: 2026-07-29
|
||||
pubDate: 2026-06-29
|
||||
---
|
||||
|
||||
# History so far
|
||||
|
|
|
|||
320
tests/kicad/presence-eeschema.spec.ts
Normal file
320
tests/kicad/presence-eeschema.spec.ts
Normal file
|
|
@ -0,0 +1,320 @@
|
|||
import type { Page } from "@playwright/test";
|
||||
import { test, expect } from "./fixtures";
|
||||
|
||||
/**
|
||||
* eeschema collab presence — C++ bridge e2e (collab-presence 0003).
|
||||
*
|
||||
* The eeschema port of presence-pcbnew.spec.ts: selection emit (programmatic +
|
||||
* real box-select), throttled cursor emit, remote VIEW_OVERLAY render with no
|
||||
* local-selection leak, and the viewport unit band (px-per-IU through the GAL
|
||||
* matrix — eeschema IU is 1e4/mm, not pcbnew's 1e6, so only the band differs).
|
||||
* The sheet-scoped awareness layer (skeleton states, rebind on navigation) is
|
||||
* unit-tested in the standalone (presence.test.ts) — this spec covers the wasm
|
||||
* side on the current sheet.
|
||||
*/
|
||||
|
||||
const WIRE1 = "22222222-0000-0000-0000-000000000001";
|
||||
const SAMPLE_SCH = `(kicad_sch
|
||||
\t(version 20250114)
|
||||
\t(generator "eeschema")
|
||||
\t(generator_version "9.0")
|
||||
\t(uuid "11111111-1111-1111-1111-111111111111")
|
||||
\t(paper "A4")
|
||||
\t(lib_symbols)
|
||||
\t(wire (pts (xy 50.8 50.8) (xy 101.6 50.8)) (stroke (width 0) (type default)) (uuid "${WIRE1}"))
|
||||
\t(wire (pts (xy 50.8 76.2) (xy 101.6 76.2)) (stroke (width 0) (type default)) (uuid "22222222-0000-0000-0000-000000000002"))
|
||||
\t(sheet_instances (path "/" (page "1")))
|
||||
)
|
||||
`;
|
||||
|
||||
type FS = { mkdirTree(p: string): void; writeFile(p: string, d: string): void };
|
||||
type Mod = {
|
||||
kicadOpenFile(p: string): unknown;
|
||||
kicadCollabPresenceStart(): void;
|
||||
kicadCollabSetRemote(j: string): void;
|
||||
kicadCollabGetViewport(): string;
|
||||
kicadCollabGetSelection(): string;
|
||||
kicadCollabTestSelectFirst(): string;
|
||||
kicadCollabTestClearSelection(): boolean;
|
||||
};
|
||||
type PresenceWindow = {
|
||||
FS: FS;
|
||||
Module: Mod;
|
||||
kicadCollab?: Record<string, unknown>;
|
||||
__selEmits?: string[][];
|
||||
__cursorEmits?: Array<{ x: number; y: number; active: number }>;
|
||||
};
|
||||
|
||||
function hasAbort(l: { consoleLogs: string[]; errors: string[] }): boolean {
|
||||
return [...l.consoleLogs, ...l.errors].some((s) => s.includes("Aborted("));
|
||||
}
|
||||
|
||||
async function bootAndOpen(page: Page): Promise<void> {
|
||||
await page.goto("/kicad/eeschema.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?: Partial<Mod> }).Module;
|
||||
return (
|
||||
typeof m?.kicadOpenFile === "function" &&
|
||||
typeof m?.kicadCollabSetRemote === "function" &&
|
||||
typeof m?.kicadCollabTestSelectFirst === "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 }) => {
|
||||
const w = window as unknown as PresenceWindow;
|
||||
const dir = "/home/kicad/documents";
|
||||
try {
|
||||
w.FS.mkdirTree(dir);
|
||||
} catch {
|
||||
/* exists */
|
||||
}
|
||||
const p = `${dir}/presence.kicad_sch`;
|
||||
w.FS.writeFile(p, content);
|
||||
w.Module.kicadOpenFile(p);
|
||||
},
|
||||
{ content: SAMPLE_SCH },
|
||||
);
|
||||
|
||||
await expect
|
||||
.poll(() => page.title(), { timeout: 60000, intervals: [500] })
|
||||
.toMatch(/presence/i);
|
||||
|
||||
await page.evaluate(() => {
|
||||
const w = window as unknown as PresenceWindow;
|
||||
w.__selEmits = [];
|
||||
w.__cursorEmits = [];
|
||||
w.kicadCollab = {
|
||||
...w.kicadCollab,
|
||||
onSelection: (json: string) => w.__selEmits!.push(JSON.parse(json)),
|
||||
onCursor: (x: number, y: number, active: number) =>
|
||||
w.__cursorEmits!.push({ x, y, active }),
|
||||
};
|
||||
w.Module.kicadCollabPresenceStart();
|
||||
});
|
||||
}
|
||||
|
||||
test("selection emit: programmatic select/clear reaches onSelection with uuids", async ({
|
||||
page,
|
||||
testLogger,
|
||||
}) => {
|
||||
await bootAndOpen(page);
|
||||
|
||||
const selectedId = await page.evaluate(() =>
|
||||
(window as unknown as PresenceWindow).Module.kicadCollabTestSelectFirst(),
|
||||
);
|
||||
expect(selectedId).toBeTruthy();
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate(() => {
|
||||
const w = window as unknown as PresenceWindow;
|
||||
return w.__selEmits!.at(-1) ?? null;
|
||||
}),
|
||||
{ timeout: 10000 },
|
||||
)
|
||||
.toEqual([selectedId]);
|
||||
|
||||
await page.evaluate(() =>
|
||||
(window as unknown as PresenceWindow).Module.kicadCollabTestClearSelection(),
|
||||
);
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate(() => {
|
||||
const w = window as unknown as PresenceWindow;
|
||||
return w.__selEmits!.at(-1) ?? null;
|
||||
}),
|
||||
{ timeout: 10000 },
|
||||
)
|
||||
.toEqual([]);
|
||||
|
||||
expect(hasAbort(testLogger)).toBe(false);
|
||||
});
|
||||
|
||||
test("selection emit: a real canvas box-select drives the wx-layer trigger", async ({
|
||||
page,
|
||||
testLogger,
|
||||
}) => {
|
||||
await bootAndOpen(page);
|
||||
|
||||
const glId = await page.evaluate(() => {
|
||||
const visible = Array.from(document.querySelectorAll('[id^="glcanvas-"]'))
|
||||
.map((c) => c as HTMLCanvasElement)
|
||||
.find((c) => {
|
||||
const rect = c.getBoundingClientRect();
|
||||
return window.getComputedStyle(c).display !== "none" && rect.width > 0;
|
||||
});
|
||||
return visible?.id ?? null;
|
||||
});
|
||||
expect(glId).toBeTruthy();
|
||||
const box = await page.locator(`#${glId}`).boundingBox();
|
||||
expect(box).toBeTruthy();
|
||||
|
||||
await page.mouse.move(box!.x + box!.width * 0.1, box!.y + box!.height * 0.1);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box!.x + box!.width * 0.9, box!.y + box!.height * 0.9, { steps: 8 });
|
||||
await page.mouse.up();
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate(() =>
|
||||
JSON.parse(
|
||||
(window as unknown as PresenceWindow).Module.kicadCollabGetSelection(),
|
||||
).length,
|
||||
),
|
||||
{ timeout: 10000, message: "box-select never selected anything" },
|
||||
)
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate(() => {
|
||||
const w = window as unknown as PresenceWindow;
|
||||
return w.__selEmits!.some((s) => s.length > 0);
|
||||
}),
|
||||
{ timeout: 10000, message: "selection happened but onSelection never emitted" },
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
expect(hasAbort(testLogger)).toBe(false);
|
||||
});
|
||||
|
||||
test("cursor emit: mouse motion over the canvas produces throttled world coords", async ({
|
||||
page,
|
||||
testLogger,
|
||||
}) => {
|
||||
await bootAndOpen(page);
|
||||
|
||||
const canvas = page.locator("#canvas");
|
||||
const box = await canvas.boundingBox();
|
||||
expect(box).toBeTruthy();
|
||||
|
||||
const cx = box!.x + box!.width / 2;
|
||||
const cy = box!.y + box!.height / 2;
|
||||
for (let i = 0; i < 30; i++) {
|
||||
await page.mouse.move(cx - 150 + i * 10, cy, { steps: 1 });
|
||||
}
|
||||
|
||||
const emits = await page.evaluate(
|
||||
() => (window as unknown as PresenceWindow).__cursorEmits!,
|
||||
);
|
||||
expect(emits.length).toBeGreaterThan(0);
|
||||
expect(emits.length).toBeLessThan(25);
|
||||
|
||||
const active = emits.filter((e) => e.active === 1);
|
||||
expect(active.length).toBeGreaterThan(0);
|
||||
for (const e of active) {
|
||||
expect(Math.abs(e.x)).toBeLessThan(1e8);
|
||||
expect(Math.abs(e.y)).toBeLessThan(1e8);
|
||||
}
|
||||
|
||||
expect(hasAbort(testLogger)).toBe(false);
|
||||
});
|
||||
|
||||
test("remote render paints the overlay without touching local selection", async ({
|
||||
page,
|
||||
testLogger,
|
||||
}) => {
|
||||
await bootAndOpen(page);
|
||||
|
||||
const canvas = page.locator("#canvas");
|
||||
const before = await canvas.screenshot();
|
||||
|
||||
await page.evaluate(
|
||||
({ wire }) => {
|
||||
const w = window as unknown as PresenceWindow;
|
||||
w.Module.kicadCollabSetRemote(
|
||||
JSON.stringify({
|
||||
peers: [
|
||||
{
|
||||
id: "bob",
|
||||
name: "bob",
|
||||
color: "#ef4444",
|
||||
// eeschema IU = 1e4/mm; park the cursor around (90,90) mm.
|
||||
cursor: { x: 90e4, y: 90e4 },
|
||||
selection: [wire],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
},
|
||||
{ wire: WIRE1 },
|
||||
);
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const after = await canvas.screenshot();
|
||||
return !after.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([]);
|
||||
|
||||
await page.evaluate(() =>
|
||||
(window as unknown as PresenceWindow).Module.kicadCollabSetRemote(
|
||||
JSON.stringify({ peers: [] }),
|
||||
),
|
||||
);
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const after = await canvas.screenshot();
|
||||
return after.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);
|
||||
|
||||
const vp = await page.evaluate(() =>
|
||||
JSON.parse((window as unknown as PresenceWindow).Module.kicadCollabGetViewport()),
|
||||
);
|
||||
expect(vp.w).toBeGreaterThan(0);
|
||||
expect(vp.h).toBeGreaterThan(0);
|
||||
// px per IU through the GAL matrix. eeschema IU = 1e4/mm (100× coarser than
|
||||
// pcbnew), so a framed A4 sheet is O(1e-3) px/IU — assert the band + a sane
|
||||
// world width, same guard as pcbnew for the GetScale()-is-zoom bug.
|
||||
expect(vp.scale).toBeGreaterThan(0);
|
||||
expect(vp.scale).toBeLessThan(1);
|
||||
const worldWidthMm = vp.w / vp.scale / 1e4;
|
||||
expect(worldWidthMm).toBeGreaterThan(50);
|
||||
expect(worldWidthMm).toBeLessThan(3000);
|
||||
expect(vp.cx).toBeGreaterThan(0);
|
||||
expect(vp.cx).toBeLessThan(400e4);
|
||||
expect(vp.cy).toBeGreaterThan(0);
|
||||
expect(vp.cy).toBeLessThan(400e4);
|
||||
|
||||
expect(hasAbort(testLogger)).toBe(false);
|
||||
});
|
||||
|
|
@ -29,7 +29,16 @@
|
|||
#include <richio.h>
|
||||
#include <lib_symbol.h>
|
||||
#include <tools/sch_selection.h>
|
||||
#include <tools/sch_selection_tool.h>
|
||||
#include <sch_commit.h>
|
||||
#include <sch_draw_panel.h>
|
||||
#include <geometry/eda_angle.h>
|
||||
#include <math/util.h>
|
||||
#include <tool/tool_manager.h>
|
||||
#include <view/view.h>
|
||||
#include <view/view_overlay.h>
|
||||
#include <chrono>
|
||||
#include <wx/event.h>
|
||||
#include <sch_item.h>
|
||||
#include <sch_line.h>
|
||||
#include <sch_junction.h>
|
||||
|
|
@ -599,6 +608,12 @@ void scheduleSheetSave( SCH_SHEET* aSheet )
|
|||
// ChangeSource: the native SCHEMATIC_LISTENER is just a trigger — the actual change set comes
|
||||
// from the post-settle snapshot diff above. Skipped while applying a remote delta (no echo);
|
||||
// doApply rebaselines instead.
|
||||
//
|
||||
// Presence (collab-presence 0003): schematic changes often change the selection
|
||||
// too (delete, paste) with no closing canvas event — the trigger below also
|
||||
// piggybacks a selection re-check. Defined in the presence section further down.
|
||||
void schedulePresenceSelCheck();
|
||||
|
||||
class COLLAB_LISTENER : public SCHEMATIC_LISTENER
|
||||
{
|
||||
public:
|
||||
|
|
@ -639,6 +654,7 @@ private:
|
|||
noteDirty( item );
|
||||
|
||||
scheduleFlush();
|
||||
schedulePresenceSelCheck();
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -663,6 +679,272 @@ SCHEMATIC* ensureBridge()
|
|||
return &sch;
|
||||
}
|
||||
|
||||
// ───────────────────────── collab presence (collab-presence 0003) ─────────────────────────
|
||||
//
|
||||
// eeschema port of pcbnew's presence layer (0002 — see pcbnew_embind.cpp for the full
|
||||
// design rationale): emit THIS tab's selection + cursor to JS, render REMOTE peers'
|
||||
// cursors + selection outlines into a per-user-colored VIEW_OVERLAY. Zero kicad-fork
|
||||
// changes: wx-layer Bind() triggers on the GAL canvas + the COLLAB_LISTENER piggyback,
|
||||
// selection read post-settle from SCH_SELECTION_TOOL, KIIDs resolved via
|
||||
// SCHEMATIC::ResolveItem. Rooms are per-sheet (warm pool), so peers publishing
|
||||
// cursor/selection in the bound room are BY CONSTRUCTION on this same sheet file —
|
||||
// no sheet filtering is needed here; the JS side rebinds the whole presence layer on
|
||||
// sheet navigation (onSheetChanged) and clears the overlay in between.
|
||||
namespace presence {
|
||||
|
||||
struct PEER
|
||||
{
|
||||
std::string name;
|
||||
KIGFX::COLOR4D color;
|
||||
bool hasCursor = false;
|
||||
VECTOR2D cursor; // world coords (IU)
|
||||
std::vector<KIID> selection;
|
||||
};
|
||||
|
||||
std::vector<PEER> g_peers;
|
||||
std::shared_ptr<KIGFX::VIEW_OVERLAY> g_overlay;
|
||||
bool g_started = false;
|
||||
bool g_redrawScheduled = false;
|
||||
bool g_selCheckScheduled = false;
|
||||
std::string g_lastSelectionJson; // dedupe: emit only when the uuid set changed
|
||||
long long g_lastCursorEmitMs = 0;
|
||||
double g_lastVpScale = 0.0;
|
||||
VECTOR2D g_lastVpCenter;
|
||||
|
||||
long long nowMs()
|
||||
{
|
||||
return std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
std::chrono::steady_clock::now().time_since_epoch() )
|
||||
.count();
|
||||
}
|
||||
|
||||
KIGFX::COLOR4D parseColor( const std::string& aHex )
|
||||
{
|
||||
if( aHex.size() == 7 && aHex[0] == '#' )
|
||||
{
|
||||
long v = strtol( aHex.c_str() + 1, nullptr, 16 );
|
||||
return KIGFX::COLOR4D( ( ( v >> 16 ) & 0xff ) / 255.0, ( ( v >> 8 ) & 0xff ) / 255.0,
|
||||
( v & 0xff ) / 255.0, 0.9 );
|
||||
}
|
||||
|
||||
return KIGFX::COLOR4D( 0.23, 0.51, 0.96, 0.9 ); // palette blue fallback
|
||||
}
|
||||
|
||||
json selectionUuids( SCH_EDIT_FRAME* aFrame )
|
||||
{
|
||||
json uuids = json::array();
|
||||
|
||||
SCH_SELECTION_TOOL* selTool = aFrame->GetToolManager()->GetTool<SCH_SELECTION_TOOL>();
|
||||
|
||||
if( !selTool )
|
||||
return uuids;
|
||||
|
||||
for( EDA_ITEM* item : selTool->GetSelection() )
|
||||
uuids.push_back( toUtf8( item->m_Uuid.AsString() ) );
|
||||
|
||||
return uuids;
|
||||
}
|
||||
|
||||
// Post-settle selection emit (see pcbnew): dedupe against the last emitted set.
|
||||
void checkSelection()
|
||||
{
|
||||
g_selCheckScheduled = false;
|
||||
|
||||
SCH_EDIT_FRAME* fr = schFrame();
|
||||
|
||||
if( !fr )
|
||||
return;
|
||||
|
||||
std::string s = selectionUuids( fr ).dump();
|
||||
|
||||
if( s == g_lastSelectionJson )
|
||||
return;
|
||||
|
||||
g_lastSelectionJson = s;
|
||||
|
||||
EM_ASM( {
|
||||
if( window.kicadCollab && window.kicadCollab.onSelection )
|
||||
window.kicadCollab.onSelection( UTF8ToString( $0 ) );
|
||||
}, s.c_str() );
|
||||
}
|
||||
|
||||
// Repaint the remote-peers overlay (CallAfter + COROUTINE — MakeOverlay's view->Add
|
||||
// and the items' virtual ViewBBox() need the fiber stack).
|
||||
void redrawOverlay()
|
||||
{
|
||||
g_redrawScheduled = false;
|
||||
|
||||
SCH_EDIT_FRAME* fr = schFrame();
|
||||
|
||||
if( !fr )
|
||||
return;
|
||||
|
||||
KIGFX::VIEW* view = fr->GetCanvas()->GetView();
|
||||
|
||||
if( !g_overlay )
|
||||
g_overlay = view->MakeOverlay();
|
||||
|
||||
g_overlay->Clear();
|
||||
|
||||
// Screen-constant sizing via the GAL matrix (GetScale() is the zoom, not px/IU).
|
||||
double px = view->ToWorld( 1.0 );
|
||||
|
||||
for( const PEER& peer : g_peers )
|
||||
{
|
||||
g_overlay->SetIsFill( false );
|
||||
g_overlay->SetIsStroke( true );
|
||||
g_overlay->SetStrokeColor( peer.color );
|
||||
g_overlay->SetLineWidth( 2.5 * px );
|
||||
|
||||
for( const KIID& id : peer.selection )
|
||||
{
|
||||
SCH_SHEET_PATH path;
|
||||
SCH_ITEM* item = fr->Schematic().ResolveItem( id, &path, /*allowNull*/ true );
|
||||
|
||||
if( !item )
|
||||
continue; // not in this schematic (yet) — skip silently
|
||||
|
||||
BOX2I bb = item->ViewBBox();
|
||||
bb.Inflate( KiROUND( 4 * px ) );
|
||||
g_overlay->Rectangle( bb.GetOrigin(), bb.GetEnd() );
|
||||
|
||||
// Who selected it — name tag just above the box's top-left corner.
|
||||
if( !peer.name.empty() )
|
||||
{
|
||||
g_overlay->SetGlyphSize( VECTOR2I( KiROUND( 9 * px ), KiROUND( 9 * px ) ) );
|
||||
g_overlay->BitmapText( wxString::FromUTF8( peer.name.c_str() ),
|
||||
VECTOR2I( bb.GetOrigin().x,
|
||||
KiROUND( bb.GetOrigin().y - 8 * px ) ),
|
||||
ANGLE_0 );
|
||||
}
|
||||
}
|
||||
|
||||
if( peer.hasCursor )
|
||||
{
|
||||
g_overlay->SetLineWidth( 2.0 * px );
|
||||
g_overlay->Cross( peer.cursor, KiROUND( 7 * px ) );
|
||||
|
||||
if( !peer.name.empty() )
|
||||
{
|
||||
g_overlay->SetGlyphSize( VECTOR2I( KiROUND( 10 * px ), KiROUND( 10 * px ) ) );
|
||||
g_overlay->BitmapText( wxString::FromUTF8( peer.name.c_str() ),
|
||||
VECTOR2I( KiROUND( peer.cursor.x + 10 * px ),
|
||||
KiROUND( peer.cursor.y + 16 * px ) ),
|
||||
ANGLE_0 );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
view->Update( g_overlay.get() );
|
||||
fr->GetCanvas()->ForceRefresh();
|
||||
}
|
||||
|
||||
void scheduleRedraw()
|
||||
{
|
||||
if( g_redrawScheduled )
|
||||
return;
|
||||
|
||||
SCH_EDIT_FRAME* fr = schFrame();
|
||||
|
||||
if( !fr )
|
||||
return;
|
||||
|
||||
g_redrawScheduled = true;
|
||||
|
||||
fr->CallAfter( []() {
|
||||
COROUTINE<int, int> cor( []( int ) -> int
|
||||
{
|
||||
redrawOverlay();
|
||||
return 0;
|
||||
} );
|
||||
cor.Call( 0 );
|
||||
} );
|
||||
}
|
||||
|
||||
// Viewport push/pull (world↔screen mapping for the DOM layers, 0005).
|
||||
void emitViewportIfChanged()
|
||||
{
|
||||
SCH_EDIT_FRAME* fr = schFrame();
|
||||
|
||||
if( !fr )
|
||||
return;
|
||||
|
||||
KIGFX::VIEW* view = fr->GetCanvas()->GetView();
|
||||
double scale = view->GetScale(); // zoom — cheap change detector only
|
||||
VECTOR2D c = view->GetCenter();
|
||||
|
||||
if( scale == g_lastVpScale && c == g_lastVpCenter )
|
||||
return;
|
||||
|
||||
g_lastVpScale = scale;
|
||||
g_lastVpCenter = c;
|
||||
|
||||
const VECTOR2I& sz = view->GetScreenPixelSize();
|
||||
// px per IU via the GAL matrix — GetScale() is the zoom, not px/IU.
|
||||
double pxPerIu = view->ToScreen( 1.0 );
|
||||
|
||||
EM_ASM( {
|
||||
if( window.kicadCollab && window.kicadCollab.onViewport )
|
||||
window.kicadCollab.onViewport( $0, $1, $2, $3, $4 );
|
||||
}, c.x, c.y, pxPerIu, sz.x, sz.y );
|
||||
|
||||
if( !g_peers.empty() )
|
||||
scheduleRedraw();
|
||||
}
|
||||
|
||||
void emitCursor( double aX, double aY, bool aActive )
|
||||
{
|
||||
EM_ASM( {
|
||||
if( window.kicadCollab && window.kicadCollab.onCursor )
|
||||
window.kicadCollab.onCursor( $0, $1, $2 );
|
||||
}, aX, aY, aActive ? 1 : 0 );
|
||||
}
|
||||
|
||||
void onMotion( wxMouseEvent& aEvt )
|
||||
{
|
||||
aEvt.Skip();
|
||||
|
||||
long long now = nowMs();
|
||||
|
||||
if( now - g_lastCursorEmitMs < 50 ) // ≤20 emits/s, event-driven (no timers)
|
||||
return;
|
||||
|
||||
g_lastCursorEmitMs = now;
|
||||
|
||||
SCH_EDIT_FRAME* fr = schFrame();
|
||||
|
||||
if( !fr )
|
||||
return;
|
||||
|
||||
wxPoint p = aEvt.GetPosition();
|
||||
VECTOR2D world = fr->GetCanvas()->GetView()->ToWorld( VECTOR2D( p.x, p.y ), true );
|
||||
|
||||
emitCursor( world.x, world.y, true );
|
||||
emitViewportIfChanged(); // catches drag-pan while moving
|
||||
}
|
||||
|
||||
void onLeave( wxMouseEvent& aEvt )
|
||||
{
|
||||
aEvt.Skip();
|
||||
emitCursor( 0, 0, false );
|
||||
}
|
||||
|
||||
} // namespace presence
|
||||
|
||||
void schedulePresenceSelCheck()
|
||||
{
|
||||
if( presence::g_selCheckScheduled )
|
||||
return;
|
||||
|
||||
SCH_EDIT_FRAME* fr = schFrame();
|
||||
|
||||
if( !fr )
|
||||
return;
|
||||
|
||||
presence::g_selCheckScheduled = true;
|
||||
fr->CallAfter( []() { presence::checkSelection(); } );
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
|
||||
|
|
@ -1234,6 +1516,160 @@ extern "C" void kicadCollabOnSave( const char* aPath )
|
|||
}
|
||||
#endif // !KICAD_MERGED_EMBIND
|
||||
|
||||
// ── presence entry points (collab-presence 0003 — eeschema port of the 0002 set) ────────────
|
||||
|
||||
// Install the presence input hooks on the GAL canvas (idempotent). The canvas is the
|
||||
// same window across sheet navigation, so one install serves the whole session.
|
||||
void schCollabPresenceStart()
|
||||
{
|
||||
SCH_EDIT_FRAME* fr = schFrame();
|
||||
|
||||
if( !fr || presence::g_started )
|
||||
return;
|
||||
|
||||
presence::g_started = true;
|
||||
|
||||
wxWindow* canvas = fr->GetCanvas();
|
||||
|
||||
canvas->Bind( wxEVT_MOTION, []( wxMouseEvent& e ) { presence::onMotion( e ); } );
|
||||
canvas->Bind( wxEVT_LEAVE_WINDOW, []( wxMouseEvent& e ) { presence::onLeave( e ); } );
|
||||
|
||||
auto selAndViewport = []( wxEvent& e )
|
||||
{
|
||||
e.Skip();
|
||||
schedulePresenceSelCheck();
|
||||
|
||||
if( SCH_EDIT_FRAME* f = schFrame() )
|
||||
f->CallAfter( []() { presence::emitViewportIfChanged(); } );
|
||||
};
|
||||
|
||||
canvas->Bind( wxEVT_LEFT_UP, [selAndViewport]( wxMouseEvent& e ) { selAndViewport( e ); } );
|
||||
canvas->Bind( wxEVT_RIGHT_UP, [selAndViewport]( wxMouseEvent& e ) { selAndViewport( e ); } );
|
||||
canvas->Bind( wxEVT_KEY_UP, [selAndViewport]( wxKeyEvent& e ) { selAndViewport( e ); } );
|
||||
canvas->Bind( wxEVT_MOUSEWHEEL, [selAndViewport]( wxMouseEvent& e ) { selAndViewport( e ); } );
|
||||
}
|
||||
|
||||
// JS → C++: full remote-peers snapshot (same wire as pcbnew's kicadCollabSetRemote).
|
||||
// Rooms are per-sheet, so the JS rebind pushes a fresh (or empty) snapshot on every
|
||||
// sheet switch — peers here are always this sheet's.
|
||||
void schCollabSetRemote( std::string aJson )
|
||||
{
|
||||
json j = json::parse( aJson, nullptr, /*allow_exceptions*/ false );
|
||||
|
||||
if( j.is_discarded() )
|
||||
return;
|
||||
|
||||
std::vector<presence::PEER> peers;
|
||||
|
||||
for( const json& p : j.value( "peers", json::array() ) )
|
||||
{
|
||||
presence::PEER peer;
|
||||
peer.name = p.value( "name", "" );
|
||||
peer.color = presence::parseColor( p.value( "color", "" ) );
|
||||
|
||||
if( p.contains( "cursor" ) && p["cursor"].is_object() )
|
||||
{
|
||||
peer.hasCursor = true;
|
||||
peer.cursor = VECTOR2D( p["cursor"].value( "x", 0.0 ), p["cursor"].value( "y", 0.0 ) );
|
||||
}
|
||||
|
||||
for( const json& u : p.value( "selection", json::array() ) )
|
||||
{
|
||||
if( u.is_string() )
|
||||
peer.selection.emplace_back( wxString::FromUTF8( u.get<std::string>().c_str() ) );
|
||||
}
|
||||
|
||||
peers.push_back( std::move( peer ) );
|
||||
}
|
||||
|
||||
presence::g_peers = std::move( peers );
|
||||
schCollabPresenceStart();
|
||||
presence::scheduleRedraw();
|
||||
}
|
||||
|
||||
// JS pull of the current viewport transform: `{cx,cy,scale,w,h}` with scale = px per
|
||||
// IU via the GAL matrix (GetScale() is the zoom, not px/IU — pcbnew 0002 lesson).
|
||||
std::string schCollabGetViewport()
|
||||
{
|
||||
SCH_EDIT_FRAME* fr = schFrame();
|
||||
|
||||
if( !fr )
|
||||
return "";
|
||||
|
||||
KIGFX::VIEW* view = fr->GetCanvas()->GetView();
|
||||
VECTOR2D c = view->GetCenter();
|
||||
const VECTOR2I& sz = view->GetScreenPixelSize();
|
||||
|
||||
return json{ { "cx", c.x }, { "cy", c.y }, { "scale", view->ToScreen( 1.0 ) },
|
||||
{ "w", sz.x }, { "h", sz.y } }.dump();
|
||||
}
|
||||
|
||||
// JS pull of the CURRENT selection's uuids (presence seed + e2e no-leak probe).
|
||||
std::string schCollabGetSelection()
|
||||
{
|
||||
SCH_EDIT_FRAME* fr = schFrame();
|
||||
|
||||
if( !fr )
|
||||
return "[]";
|
||||
|
||||
return presence::selectionUuids( fr ).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()
|
||||
{
|
||||
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() )
|
||||
{
|
||||
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()
|
||||
{
|
||||
SCH_EDIT_FRAME* fr = schFrame();
|
||||
|
||||
if( !fr )
|
||||
return false;
|
||||
|
||||
fr->CallAfter( [fr]() {
|
||||
if( SCH_SELECTION_TOOL* st = fr->GetToolManager()->GetTool<SCH_SELECTION_TOOL>() )
|
||||
{
|
||||
st->ClearSelection();
|
||||
schedulePresenceSelCheck();
|
||||
}
|
||||
} );
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Merged-image dispatch probe (kicad_editor_embind.cpp): is the active top window the
|
||||
// schematic editor? Counterpart of pcbnew_embind.cpp's pcbEditorActive().
|
||||
bool schEditorActive()
|
||||
|
|
@ -1296,6 +1732,13 @@ EMSCRIPTEN_BINDINGS(eeschema) {
|
|||
// ysync-review repro hooks shared with pcbnew (dispatched when merged).
|
||||
function("kicadCollabTestRemoveItem", &schCollabTestRemoveItem);
|
||||
function("kicadCollabTestRotateItem", &schCollabTestRotateItem);
|
||||
// Presence (collab-presence 0003) — shared names with pcbnew's 0002 set.
|
||||
function("kicadCollabPresenceStart", &schCollabPresenceStart);
|
||||
function("kicadCollabSetRemote", &schCollabSetRemote);
|
||||
function("kicadCollabGetViewport", &schCollabGetViewport);
|
||||
function("kicadCollabGetSelection", &schCollabGetSelection);
|
||||
function("kicadCollabTestSelectFirst", &schCollabTestSelectFirst);
|
||||
function("kicadCollabTestClearSelection", &schCollabTestClearSelection);
|
||||
#endif // !KICAD_MERGED_EMBIND
|
||||
}
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -62,6 +62,13 @@ std::string schCollabTestMoveFirst( int aDx, int aDy );
|
|||
std::string schCollabGetPos( std::string aId );
|
||||
bool schCollabTestRemoveItem( std::string aId );
|
||||
bool schCollabTestRotateItem( std::string aId, double aDeg );
|
||||
// Presence (collab-presence 0003 — eeschema counterparts of the 0002 set).
|
||||
void schCollabPresenceStart();
|
||||
void schCollabSetRemote( std::string aJson );
|
||||
std::string schCollabGetViewport();
|
||||
std::string schCollabGetSelection();
|
||||
std::string schCollabTestSelectFirst();
|
||||
bool schCollabTestClearSelection();
|
||||
|
||||
|
||||
// Programmatically open a project file in the running editor frame, without UI
|
||||
|
|
@ -140,39 +147,36 @@ static bool collabTestRotateItem( std::string aId, double aDeg )
|
|||
: schCollabTestRotateItem( aId, aDeg );
|
||||
}
|
||||
|
||||
// Presence shims (collab-presence 0002): pcb-only for now — the sch frame no-ops /
|
||||
// returns empty until 0003 lands schCollab* counterparts, matching how the JS side
|
||||
// gates presence on the active tool.
|
||||
// Presence shims (collab-presence 0002 pcbnew / 0003 eeschema): route to the live
|
||||
// editor's implementation, same pattern as the collab bridge shims above.
|
||||
static void collabPresenceStart()
|
||||
{
|
||||
if( pcbEditorActive() )
|
||||
pcbCollabPresenceStart();
|
||||
pcbEditorActive() ? pcbCollabPresenceStart() : schCollabPresenceStart();
|
||||
}
|
||||
|
||||
static void collabSetRemote( std::string aJson )
|
||||
{
|
||||
if( pcbEditorActive() )
|
||||
pcbCollabSetRemote( aJson );
|
||||
pcbEditorActive() ? pcbCollabSetRemote( aJson ) : schCollabSetRemote( aJson );
|
||||
}
|
||||
|
||||
static std::string collabGetViewport()
|
||||
{
|
||||
return pcbEditorActive() ? pcbCollabGetViewport() : std::string();
|
||||
return pcbEditorActive() ? pcbCollabGetViewport() : schCollabGetViewport();
|
||||
}
|
||||
|
||||
static std::string collabGetSelection()
|
||||
{
|
||||
return pcbEditorActive() ? pcbCollabGetSelection() : std::string( "[]" );
|
||||
return pcbEditorActive() ? pcbCollabGetSelection() : schCollabGetSelection();
|
||||
}
|
||||
|
||||
static std::string collabTestSelectFirst()
|
||||
{
|
||||
return pcbEditorActive() ? pcbCollabTestSelectFirst() : std::string();
|
||||
return pcbEditorActive() ? pcbCollabTestSelectFirst() : schCollabTestSelectFirst();
|
||||
}
|
||||
|
||||
static bool collabTestClearSelection()
|
||||
{
|
||||
return pcbEditorActive() ? pcbCollabTestClearSelection() : false;
|
||||
return pcbEditorActive() ? pcbCollabTestClearSelection() : schCollabTestClearSelection();
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,18 +1,43 @@
|
|||
import type { PresencePeer } from "@/wasm/collab/presence";
|
||||
|
||||
/**
|
||||
* "Who else is in this file" (collab-presence 0001): a compact facepile of the
|
||||
* room's OTHER users, one colored initial-avatar per person, fed by the collab
|
||||
* session's awareness. Rendered in the editor's top-right overlay stack next to
|
||||
* SourceChip; the parent hides it when there are no peers. Chip styling mirrors
|
||||
* SourceChip (solid fill + inset ring) so it is legible on any backdrop.
|
||||
* "Who else is in this file" (collab-presence 0001/0003): a compact facepile of
|
||||
* the room's OTHER users, one colored initial-avatar per person, fed by the
|
||||
* collab session's awareness. For eeschema (per-sheet rooms + warm-pool
|
||||
* skeleton states) peers on a DIFFERENT sheet render dimmed, with the sheet
|
||||
* they're on in the tooltip. Rendered in the editor's top-right overlay stack
|
||||
* next to SourceChip; the parent hides it when there are no peers. Chip styling
|
||||
* mirrors SourceChip (solid fill + inset ring) so it is legible on any backdrop.
|
||||
*/
|
||||
const MAX_AVATARS = 5;
|
||||
|
||||
export function PresenceRoster({ peers }: { peers: PresencePeer[] }) {
|
||||
function sheetLabel(sheetPath?: string): string {
|
||||
if (!sheetPath) return "";
|
||||
const base = sheetPath.split("/").pop() ?? sheetPath;
|
||||
return base.replace(/\.kicad_sch$/, "");
|
||||
}
|
||||
|
||||
export function PresenceRoster({
|
||||
peers,
|
||||
activeSheetPath,
|
||||
}: {
|
||||
peers: PresencePeer[];
|
||||
/** eeschema: the sheet THIS client is on — peers elsewhere render dimmed. */
|
||||
activeSheetPath?: string;
|
||||
}) {
|
||||
if (!peers.length) return null;
|
||||
const names = peers.map((p) => p.user.name).join(", ");
|
||||
const shown = peers.slice(0, MAX_AVATARS);
|
||||
|
||||
const sameSheet = (p: PresencePeer) =>
|
||||
(p.sheetPath ?? undefined) === (activeSheetPath ?? undefined);
|
||||
// Same-sheet peers first, then elsewhere (dimmed) — stable within each group.
|
||||
const ordered = [...peers].sort((a, b) => Number(sameSheet(b)) - Number(sameSheet(a)));
|
||||
const names = ordered
|
||||
.map((p) =>
|
||||
sameSheet(p) ? p.user.name : `${p.user.name} (on ${sheetLabel(p.sheetPath) || "another sheet"})`,
|
||||
)
|
||||
.join(", ");
|
||||
const shown = ordered.slice(0, MAX_AVATARS);
|
||||
|
||||
return (
|
||||
<span
|
||||
data-testid="presence-roster"
|
||||
|
|
@ -23,9 +48,16 @@ export function PresenceRoster({ peers }: { peers: PresencePeer[] }) {
|
|||
<span
|
||||
key={p.user.id}
|
||||
data-presence-user={p.user.id}
|
||||
title={p.user.name}
|
||||
data-presence-elsewhere={sameSheet(p) ? undefined : "1"}
|
||||
title={
|
||||
sameSheet(p)
|
||||
? p.user.name
|
||||
: `${p.user.name} — on ${sheetLabel(p.sheetPath) || "another sheet"}`
|
||||
}
|
||||
style={{ backgroundColor: p.user.color }}
|
||||
className="-ml-1.5 flex h-5 w-5 items-center justify-center rounded-full text-[10px] font-semibold text-white ring-2 ring-black/50 first:ml-0"
|
||||
className={`-ml-1.5 flex h-5 w-5 items-center justify-center rounded-full text-[10px] font-semibold text-white ring-2 ring-black/50 first:ml-0 ${
|
||||
sameSheet(p) ? "" : "opacity-40"
|
||||
}`}
|
||||
>
|
||||
{p.user.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -444,6 +444,9 @@ async function startSheetCollab(
|
|||
provider: yjsProviderConfig(),
|
||||
seedDocForPath: (sheet) => seedDocFromMemfs(win, opts.slug, sheet),
|
||||
onActiveChange: opts.onActiveChange,
|
||||
// Parked rooms carry a skeleton presence ("this user is on sheet X") so
|
||||
// any sheet's roster shows the whole schematic's crew (0003).
|
||||
presenceUser: presenceUser(),
|
||||
log: opts.log,
|
||||
initial:
|
||||
opts.session && opts.targetPath
|
||||
|
|
@ -627,6 +630,9 @@ export function WasmTool({
|
|||
// the PresenceRoster chip next to SourceChip. Empty when collab is off, the
|
||||
// provider has no awareness (kind "none"), or nobody else is here.
|
||||
const [peers, setPeers] = React.useState<PresencePeer[]>([]);
|
||||
// eeschema: the sheet THIS client is bound to — the roster dims peers whose
|
||||
// skeleton state says they're on a different sheet (collab-presence 0003).
|
||||
const [activeSheetPath, setActiveSheetPath] = React.useState<string | undefined>();
|
||||
|
||||
const append = React.useCallback(
|
||||
(msg: string) => setLogs((prev) => [...prev.slice(-800), msg]),
|
||||
|
|
@ -772,10 +778,11 @@ export function WasmTool({
|
|||
presenceRef.current = presence;
|
||||
presence.subscribe(setPeers);
|
||||
setPeers(presence.peers());
|
||||
// Canvas presence (0002): cursor + selection emit and the remote
|
||||
// VIEW_OVERLAY render. pcbnew only until the eeschema port (0003); the
|
||||
// bridge gate also skips wasm builds predating the presence exports.
|
||||
if (tool === "pcbnew" && hasPresenceBridge(win.Module)) {
|
||||
setActiveSheetPath(sheetPath);
|
||||
// Canvas presence (0002 pcbnew / 0003 eeschema): cursor + selection emit
|
||||
// and the remote VIEW_OVERLAY render. The bridge gate skips tools without
|
||||
// the exports and wasm builds predating them.
|
||||
if ((tool === "pcbnew" || tool === "eeschema") && hasPresenceBridge(win.Module)) {
|
||||
presenceBridgeRef.current = bindKicadPresence({
|
||||
mod: win.Module,
|
||||
win: win as unknown as PresenceKicadWindow,
|
||||
|
|
@ -1099,7 +1106,9 @@ export function WasmTool({
|
|||
where this project lives / whether Save persists. */}
|
||||
{ready && (peers.length > 0 || sourceDescriptor) && (
|
||||
<div className="absolute right-3 top-3 z-20 flex items-center gap-2">
|
||||
{peers.length > 0 && <PresenceRoster peers={peers} />}
|
||||
{peers.length > 0 && (
|
||||
<PresenceRoster peers={peers} activeSheetPath={activeSheetPath} />
|
||||
)}
|
||||
{sourceDescriptor && <SourceChip descriptor={sourceDescriptor} />}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import * as Y from "yjs";
|
|||
import { Awareness } from "y-protocols/awareness";
|
||||
import { colorForUser, type PresenceUser } from "@pcbjam/shared";
|
||||
import { connectAwarenessBroadcast } from "./awareness-bc";
|
||||
import { createPresence, type PresenceHandle } from "./presence";
|
||||
import { createPresence, publishSkeleton, type PresenceHandle } from "./presence";
|
||||
|
||||
/**
|
||||
* Presence unit tests (collab-presence 0001): two Awareness instances relayed
|
||||
|
|
@ -107,6 +107,86 @@ describe("presence over the BroadcastChannel awareness relay", () => {
|
|||
expect(a.presence.peers()).toEqual([]);
|
||||
});
|
||||
|
||||
it("skeleton states mark parked-room users with their real sheet (0003)", async () => {
|
||||
// Two per-sheet rooms; alice is BOUND to root and PARKED in sub, bob is
|
||||
// bound to sub. Bob's roster (sub room) must show alice as being on root,
|
||||
// with no cursor/selection.
|
||||
const rootCh = `presence-test-${channelSeq++}`;
|
||||
const subCh = `presence-test-${channelSeq++}`;
|
||||
const aliceRoot = client(rootCh);
|
||||
const aliceSub = client(subCh);
|
||||
const bobSub = client(subCh);
|
||||
|
||||
aliceRoot.presence = createPresence({
|
||||
awareness: aliceRoot.awareness,
|
||||
user: user("alice"),
|
||||
tool: "eeschema",
|
||||
sheetPath: "root.kicad_sch",
|
||||
});
|
||||
publishSkeleton(aliceSub.awareness, user("alice"), "eeschema", "root.kicad_sch");
|
||||
bobSub.presence = createPresence({
|
||||
awareness: bobSub.awareness,
|
||||
user: user("bob"),
|
||||
tool: "eeschema",
|
||||
sheetPath: "sub/child.kicad_sch",
|
||||
});
|
||||
await settle();
|
||||
|
||||
const aliceSeenByBob = bobSub.presence.peers();
|
||||
expect(aliceSeenByBob.map((p) => p.user.id)).toEqual(["alice"]);
|
||||
expect(aliceSeenByBob[0]!.sheetPath).toBe("root.kicad_sch");
|
||||
expect(aliceSeenByBob[0]!.cursor).toBeNull();
|
||||
expect(aliceSeenByBob[0]!.selection).toEqual([]);
|
||||
|
||||
// Alice navigates into sub: full presence rebinds there (overwriting the
|
||||
// skeleton) — bob now sees her on HIS sheet, cursor live again.
|
||||
aliceSub.presence = createPresence({
|
||||
awareness: aliceSub.awareness,
|
||||
user: user("alice"),
|
||||
tool: "eeschema",
|
||||
sheetPath: "sub/child.kicad_sch",
|
||||
});
|
||||
aliceSub.presence.setCursor({ x: 1, y: 2 });
|
||||
await settle();
|
||||
|
||||
const after = bobSub.presence.peers();
|
||||
expect(after[0]!.sheetPath).toBe("sub/child.kicad_sch");
|
||||
expect(after[0]!.cursor).toEqual({ x: 1, y: 2 });
|
||||
});
|
||||
|
||||
it("rebind away leaves no ghost cursor in the old room", async () => {
|
||||
// Alice bound in room1 with a live cursor; she navigates away: presence
|
||||
// destroy + skeleton must leave sheetPath pointing elsewhere, cursor null.
|
||||
const ch = `presence-test-${channelSeq++}`;
|
||||
const alice = client(ch);
|
||||
const bob = client(ch);
|
||||
alice.presence = createPresence({
|
||||
awareness: alice.awareness,
|
||||
user: user("alice"),
|
||||
tool: "eeschema",
|
||||
sheetPath: "root.kicad_sch",
|
||||
});
|
||||
alice.presence.setCursor({ x: 9, y: 9 });
|
||||
bob.presence = createPresence({
|
||||
awareness: bob.awareness,
|
||||
user: user("bob"),
|
||||
tool: "eeschema",
|
||||
sheetPath: "root.kicad_sch",
|
||||
});
|
||||
await settle();
|
||||
expect(bob.presence.peers()[0]!.cursor).toEqual({ x: 9, y: 9 });
|
||||
|
||||
alice.presence.destroy();
|
||||
alice.presence = undefined;
|
||||
publishSkeleton(alice.awareness, user("alice"), "eeschema", "sub/child.kicad_sch");
|
||||
await settle();
|
||||
|
||||
const ghost = bob.presence.peers();
|
||||
expect(ghost.map((p) => p.user.id)).toEqual(["alice"]);
|
||||
expect(ghost[0]!.cursor).toBeNull();
|
||||
expect(ghost[0]!.sheetPath).toBe("sub/child.kicad_sch");
|
||||
});
|
||||
|
||||
it("subscribe fires on change and setters keep sibling fields intact", async () => {
|
||||
const channel = `presence-test-${channelSeq++}`;
|
||||
const a = client(channel);
|
||||
|
|
|
|||
|
|
@ -40,6 +40,30 @@ export interface PresenceHandle {
|
|||
destroy(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a SKELETON presence state into a room the user is connected to but
|
||||
* not looking at (eeschema warm pool, collab-presence 0003): identity + which
|
||||
* sheet they are actually on, no cursor/selection. Any sheet's roster can then
|
||||
* answer "who is in this schematic, and where". The bound room's full state is
|
||||
* owned by `createPresence` (which overwrites the skeleton on rebind).
|
||||
*/
|
||||
export function publishSkeleton(
|
||||
awareness: Awareness,
|
||||
user: PresenceUser,
|
||||
tool: string,
|
||||
sheetPath: string,
|
||||
): void {
|
||||
const state: PresenceState = {
|
||||
user,
|
||||
tool,
|
||||
sheetPath,
|
||||
cursor: null,
|
||||
selection: [],
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
awareness.setLocalState(state);
|
||||
}
|
||||
|
||||
export function createPresence(opts: {
|
||||
awareness: Awareness;
|
||||
user: PresenceUser;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,13 @@
|
|||
import type * as Y from "yjs";
|
||||
import { collabRoomId, fileToDoc, syncLayoutToY, type KicadDoc } from "@pcbjam/shared";
|
||||
import {
|
||||
collabRoomId,
|
||||
fileToDoc,
|
||||
syncLayoutToY,
|
||||
type KicadDoc,
|
||||
type PresenceUser,
|
||||
} from "@pcbjam/shared";
|
||||
import { connectKicadDoc, type KicadDocSession } from "./index";
|
||||
import { publishSkeleton } from "./presence";
|
||||
import {
|
||||
bindKicadCollab,
|
||||
moduleItemsBridge,
|
||||
|
|
@ -79,6 +86,15 @@ export interface SheetManagerOptions {
|
|||
* (re)start drift detection on the now-active doc.
|
||||
*/
|
||||
onActiveChange?: (active: ActiveSheet | null) => void;
|
||||
/**
|
||||
* Presence identity (collab-presence 0003). When set, every PARKED room in the
|
||||
* warm pool carries a skeleton awareness state ({user, tool, sheetPath: the
|
||||
* sheet the user is ACTUALLY on}) so any sheet's roster can answer "who is in
|
||||
* this schematic, and where". The BOUND room's full presence (cursor/selection)
|
||||
* is owned by the host via onActiveChange → createPresence, which overwrites
|
||||
* the skeleton on rebind.
|
||||
*/
|
||||
presenceUser?: PresenceUser;
|
||||
log: (m: string) => void;
|
||||
/**
|
||||
* `docSource: "ydoc"` only: the entry sheet's room is already connected (and possibly
|
||||
|
|
@ -132,6 +148,19 @@ export function createSheetCollabManager(opts: SheetManagerOptions): SheetCollab
|
|||
});
|
||||
}
|
||||
|
||||
// Skeleton presence for every PARKED room (0003): mark this user as "in this
|
||||
// schematic, on `activePath`". The bound room is skipped — its full state is
|
||||
// published by the host's presence handle (rebound via onActiveChange).
|
||||
function publishSkeletons(): void {
|
||||
const user = opts.presenceUser;
|
||||
if (!user || !activePath) return;
|
||||
for (const [path, room] of rooms) {
|
||||
if (path === activePath) continue;
|
||||
const awareness = room.session.provider.awareness;
|
||||
if (awareness) publishSkeleton(awareness, user, "eeschema", activePath);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureRoom(sheetPath: string): Promise<Room> {
|
||||
const existing = rooms.get(sheetPath);
|
||||
if (existing) return existing;
|
||||
|
|
@ -152,6 +181,14 @@ export function createSheetCollabManager(opts: SheetManagerOptions): SheetCollab
|
|||
};
|
||||
rooms.set(sheetPath, room);
|
||||
log(`[sheet] warm room connected: ${sheetPath}`);
|
||||
// A room warmed after the first bind starts parked — give it a skeleton
|
||||
// right away so its roster shows this user without waiting for a switch.
|
||||
if (activePath && sheetPath !== activePath && opts.presenceUser) {
|
||||
const awareness = session.provider.awareness;
|
||||
if (awareness) {
|
||||
publishSkeleton(awareness, opts.presenceUser, "eeschema", activePath);
|
||||
}
|
||||
}
|
||||
return room;
|
||||
})();
|
||||
|
||||
|
|
@ -219,6 +256,10 @@ export function createSheetCollabManager(opts: SheetManagerOptions): SheetCollab
|
|||
room.editorMatchesDoc = false; // only meaningful for the first ydoc-entry seed
|
||||
activePath = sheetPath;
|
||||
opts.onActiveChange?.({ sheetPath, doc: room.doc, provider: room.session.provider });
|
||||
// AFTER the host rebound its full presence to the new room: refresh every
|
||||
// parked room's skeleton to point at the new sheet (incl. the old active
|
||||
// room, whose full state the host just cleared).
|
||||
publishSkeletons();
|
||||
}
|
||||
|
||||
function switchTo(sheetPath: string): Promise<void> {
|
||||
|
|
|
|||
Loading…
Reference in a new issue