feat(collab): follow-user (collab-presence 0008) + chip depth-layer fix
Follow-user: click a peer's roster avatar to mirror their viewport until local input breaks it. - collab_presence_core.h: CORE::fitViewport(cx, cy, halfW, halfH) — fit the leader's world rect with CONTAIN semantics (zoom derived from the follower's own canvas via the ToScreen ratio; GetScale is the zoom, not px/IU). Exported as kicadCollabFitViewport from both editor TUs + the merged dispatcher. - presence-kicad.ts: publish the visible world rect (viewportRect) into awareness, 100 ms trailing throttle; guarded for pre-0008 handles. - follow-user.ts: createFollow — follows an awareness CLIENT (a tab, not a user); applies leader rect changes via FitViewport, dedupes unchanged republishes; break-on-interact compares local onViewport echoes against the last applied rect (2% rel tolerance, echo-grace before the first fit lands); unfollows on leader-left; pauses on eeschema sheet mismatch. - PresenceRoster: avatars are follow toggles (ring on the followed peer); WasmTool renders the "Following <name> — move to stop" banner. - tests: 7 controller units (85/85 collab), fitViewport round-trip e2e in both kicad presence specs (20/20), two-tab tests/web/follow.spec.ts (converge → track → wheel-zoom breaks → subsequent moves ignored). Chip depth-layer fix (user-reported): name chips washed out inside low-alpha selection fills — chip rects shared the shapes overlay's single depth, and same-depth fragments drawn LATER lose the depth test, so an earlier-painted fill rejected the chip's pixels. Now three layers via the fork's VIEW_OVERLAY::SetDepthOffset: text (0) < chips + pin dots (1) < selection shapes (2). drawLabel/drawCursor/drawSelectionBox take the chip overlay explicitly; comment-pin dots move to the chip layer too (the 0005 "drawn last so pins sit above" comment had the rule backwards). Verified with a chip-inside-30%-fill pixel repro + the full presence suite. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013u9h8fkQktH7KRECaHJmUG
This commit is contained in:
parent
9697de7b26
commit
1f85e13acf
16 changed files with 821 additions and 52 deletions
|
|
@ -425,6 +425,52 @@ test("cross-app ghost render: a pcbnew peer's xsel uuid resolves on the current
|
|||
expect(hasAbort(testLogger)).toBe(false);
|
||||
});
|
||||
|
||||
// Follow-user (0008): FitViewport applies a world rect with contain semantics —
|
||||
// verified through the GetViewport round trip (center lands, the contained
|
||||
// axis' half-extent matches the request within the fit's own tolerance).
|
||||
test("fitViewport applies a world rect (contain) — GetViewport round trip", async ({
|
||||
page,
|
||||
testLogger,
|
||||
}) => {
|
||||
await bootAndOpen(page);
|
||||
|
||||
const target = { cx: 120e6, cy: 90e6, halfW: 40e6, halfH: 30e6 };
|
||||
await page.evaluate((t) => {
|
||||
const m = (window as unknown as PresenceWindow).Module as unknown as {
|
||||
kicadCollabFitViewport(cx: number, cy: number, hw: number, hh: number): void;
|
||||
};
|
||||
m.kicadCollabFitViewport(t.cx, t.cy, t.halfW, t.halfH);
|
||||
}, target);
|
||||
|
||||
// The fit is CallAfter+fiber scheduled — poll the transform until it lands.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const vp = await page.evaluate(() =>
|
||||
JSON.parse((window as unknown as PresenceWindow).Module.kicadCollabGetViewport()),
|
||||
);
|
||||
return Math.abs(vp.cx - target.cx) < 1e6 && Math.abs(vp.cy - target.cy) < 1e6;
|
||||
},
|
||||
{ timeout: 20000, intervals: [250] },
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
const vp = await page.evaluate(() =>
|
||||
JSON.parse((window as unknown as PresenceWindow).Module.kicadCollabGetViewport()),
|
||||
);
|
||||
// Contain: at least one axis' visible half-extent matches the request (the
|
||||
// other overshoots by the canvas aspect), and neither is SMALLER (cropping).
|
||||
const halfW = vp.w / 2 / vp.scale;
|
||||
const halfH = vp.h / 2 / vp.scale;
|
||||
expect(halfW).toBeGreaterThan(target.halfW * 0.95);
|
||||
expect(halfH).toBeGreaterThan(target.halfH * 0.95);
|
||||
const relW = Math.abs(halfW - target.halfW) / target.halfW;
|
||||
const relH = Math.abs(halfH - target.halfH) / target.halfH;
|
||||
expect(Math.min(relW, relH)).toBeLessThan(0.05);
|
||||
|
||||
expect(hasAbort(testLogger)).toBe(false);
|
||||
});
|
||||
|
||||
test("viewport export returns a sane world↔screen transform", async ({ page, testLogger }) => {
|
||||
await bootAndOpen(page);
|
||||
|
||||
|
|
|
|||
|
|
@ -518,6 +518,52 @@ test("cross-app ghost render: an eeschema peer's symbol uuid maps to the footpri
|
|||
expect(hasAbort(testLogger)).toBe(false);
|
||||
});
|
||||
|
||||
// Follow-user (0008): FitViewport applies a world rect with contain semantics —
|
||||
// verified through the GetViewport round trip (center lands, the contained
|
||||
// axis' half-extent matches the request within the fit's own tolerance).
|
||||
test("fitViewport applies a world rect (contain) — GetViewport round trip", async ({
|
||||
page,
|
||||
testLogger,
|
||||
}) => {
|
||||
await bootAndOpen(page);
|
||||
|
||||
const target = { cx: 120e6, cy: 90e6, halfW: 40e6, halfH: 30e6 };
|
||||
await page.evaluate((t) => {
|
||||
const m = (window as unknown as PresenceWindow).Module as unknown as {
|
||||
kicadCollabFitViewport(cx: number, cy: number, hw: number, hh: number): void;
|
||||
};
|
||||
m.kicadCollabFitViewport(t.cx, t.cy, t.halfW, t.halfH);
|
||||
}, target);
|
||||
|
||||
// The fit is CallAfter+fiber scheduled — poll the transform until it lands.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const vp = await page.evaluate(() =>
|
||||
JSON.parse((window as unknown as PresenceWindow).Module.kicadCollabGetViewport()),
|
||||
);
|
||||
return Math.abs(vp.cx - target.cx) < 1e6 && Math.abs(vp.cy - target.cy) < 1e6;
|
||||
},
|
||||
{ timeout: 20000, intervals: [250] },
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
const vp = await page.evaluate(() =>
|
||||
JSON.parse((window as unknown as PresenceWindow).Module.kicadCollabGetViewport()),
|
||||
);
|
||||
// Contain: at least one axis' visible half-extent matches the request (the
|
||||
// other overshoots by the canvas aspect), and neither is SMALLER (cropping).
|
||||
const halfW = vp.w / 2 / vp.scale;
|
||||
const halfH = vp.h / 2 / vp.scale;
|
||||
expect(halfW).toBeGreaterThan(target.halfW * 0.95);
|
||||
expect(halfH).toBeGreaterThan(target.halfH * 0.95);
|
||||
const relW = Math.abs(halfW - target.halfW) / target.halfW;
|
||||
const relH = Math.abs(halfH - target.halfH) / target.halfH;
|
||||
expect(Math.min(relW, relH)).toBeLessThan(0.05);
|
||||
|
||||
expect(hasAbort(testLogger)).toBe(false);
|
||||
});
|
||||
|
||||
test("viewport export returns a sane world↔screen transform", async ({ page, testLogger }) => {
|
||||
await bootAndOpen(page);
|
||||
|
||||
|
|
|
|||
125
tests/web/follow.spec.ts
Normal file
125
tests/web/follow.spec.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import { test, expect, type Page } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Follow-user e2e (collab-presence 0008): two tabs on the demo BOARD share
|
||||
* the doc room's awareness. Tab B clicks tab A's roster avatar → B's viewport
|
||||
* continuously mirrors A's published world rect (contain-fit); B's own canvas
|
||||
* input breaks the follow.
|
||||
*
|
||||
* Assertions use the deterministic viewport transforms
|
||||
* (`kicadCollabGetViewport` / `kicadCollabFitViewport`) — the fit math itself
|
||||
* is covered per-editor in tests/kicad; this spec covers the wire + controller
|
||||
* loop: publish (100 ms trailing) → awareness → follow apply → echo
|
||||
* suppression → break-on-interact.
|
||||
*/
|
||||
|
||||
const SCOPE = 'default';
|
||||
|
||||
type Mod = {
|
||||
kicadCollabGetViewport(): string;
|
||||
kicadCollabFitViewport(cx: number, cy: number, hw: number, hh: number): void;
|
||||
};
|
||||
type W = { Module: Mod };
|
||||
type Vp = { cx: number; cy: number; scale: number; w: number; h: number };
|
||||
|
||||
async function bootBoard(page: Page, user: string): Promise<void> {
|
||||
await page.goto(`/${SCOPE}/projects/demo/demo.kicad_pcb?user=${user}`);
|
||||
await expect(page.locator('#canvas')).toBeVisible({ timeout: 120000 });
|
||||
await expect
|
||||
.poll(() => page.title(), {
|
||||
message: `${user}: editor never reached the expected title`,
|
||||
timeout: 120000,
|
||||
intervals: [1000],
|
||||
})
|
||||
.toMatch(/demo — PCB Editor/i);
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const m = (window as unknown as Partial<W>).Module;
|
||||
return (
|
||||
typeof m?.kicadCollabGetViewport === 'function' &&
|
||||
typeof m?.kicadCollabFitViewport === 'function'
|
||||
);
|
||||
},
|
||||
null,
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
}
|
||||
|
||||
const viewport = (page: Page): Promise<Vp> =>
|
||||
page.evaluate(() =>
|
||||
JSON.parse((window as unknown as W).Module.kicadCollabGetViewport()),
|
||||
);
|
||||
|
||||
const fit = (page: Page, cx: number, cy: number, hw: number, hh: number) =>
|
||||
page.evaluate(
|
||||
(t) => (window as unknown as W).Module.kicadCollabFitViewport(t.cx, t.cy, t.hw, t.hh),
|
||||
{ cx, cy, hw, hh },
|
||||
);
|
||||
|
||||
/** Center-convergence check: within 2 % of the target half-width. */
|
||||
const near = (vp: Vp, cx: number, cy: number, tol: number) =>
|
||||
Math.abs(vp.cx - cx) < tol && Math.abs(vp.cy - cy) < tol;
|
||||
|
||||
test('B follows A: viewport mirrors, then local input breaks the follow', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
test.setTimeout(480000); // two full pcbnew wasm boots
|
||||
|
||||
const a = page;
|
||||
await bootBoard(a, 'alice');
|
||||
const b = await context.newPage();
|
||||
await bootBoard(b, 'bob');
|
||||
|
||||
// A frames a distinctive region (world IU; demo board is an A4 sheet).
|
||||
const T1 = { cx: 120e6, cy: 90e6, hw: 40e6, hh: 30e6 };
|
||||
await fit(a, T1.cx, T1.cy, T1.hw, T1.hh);
|
||||
await expect
|
||||
.poll(async () => near(await viewport(a), T1.cx, T1.cy, 1e6), {
|
||||
timeout: 20000,
|
||||
message: 'A never landed on its own fit target',
|
||||
})
|
||||
.toBe(true);
|
||||
|
||||
// B sees alice in the roster and clicks her avatar → follow.
|
||||
const avatar = b.locator('[data-presence-user="alice"]');
|
||||
await expect(avatar).toBeVisible({ timeout: 30000 });
|
||||
await avatar.click();
|
||||
await expect(b.getByTestId('follow-banner')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// B's viewport converges on A's rect (publish 100 ms + awareness relay).
|
||||
await expect
|
||||
.poll(async () => near(await viewport(b), T1.cx, T1.cy, T1.hw * 0.02), {
|
||||
timeout: 30000,
|
||||
message: 'B never converged on the followed viewport',
|
||||
})
|
||||
.toBe(true);
|
||||
|
||||
// A moves again → B tracks.
|
||||
const T2 = { cx: 180e6, cy: 120e6, hw: 25e6, hh: 20e6 };
|
||||
await fit(a, T2.cx, T2.cy, T2.hw, T2.hh);
|
||||
await expect
|
||||
.poll(async () => near(await viewport(b), T2.cx, T2.cy, T2.hw * 0.02), {
|
||||
timeout: 30000,
|
||||
message: 'B never tracked the second viewport move',
|
||||
})
|
||||
.toBe(true);
|
||||
|
||||
// B interacts (real wheel zoom on the canvas) → the follow breaks.
|
||||
const canvasBox = await b.locator('#canvas').boundingBox();
|
||||
if (!canvasBox) throw new Error('no canvas box');
|
||||
await b.mouse.move(canvasBox.x + canvasBox.width / 2, canvasBox.y + canvasBox.height / 2);
|
||||
await b.mouse.wheel(0, -240);
|
||||
await expect(b.getByTestId('follow-banner')).toBeHidden({ timeout: 15000 });
|
||||
|
||||
// A moves once more; B (unfollowed) must NOT track it. A landing on its own
|
||||
// target is the synchronization point — by then A's rect went out and B
|
||||
// demonstrably ignored it (its center is still near T2, zoom aside).
|
||||
const T3 = { cx: 80e6, cy: 60e6, hw: 30e6, hh: 25e6 };
|
||||
await fit(a, T3.cx, T3.cy, T3.hw, T3.hh);
|
||||
await expect
|
||||
.poll(async () => near(await viewport(a), T3.cx, T3.cy, 1e6), { timeout: 20000 })
|
||||
.toBe(true);
|
||||
const bVp = await viewport(b);
|
||||
expect(near(bVp, T3.cx, T3.cy, T3.hw * 0.05), 'B tracked A after unfollow').toBe(false);
|
||||
});
|
||||
|
|
@ -128,8 +128,11 @@ struct CORE
|
|||
STYLE style;
|
||||
|
||||
std::shared_ptr<KIGFX::VIEW_OVERLAY> overlay;
|
||||
// Labels render from their own overlay at the nearest depth (chip rects
|
||||
// would otherwise erase same-depth text) — see PRESENCE_TEXT_OVERLAY.
|
||||
// Name chips + pin dots render one depth unit ABOVE the selection shapes
|
||||
// (else an earlier-painted low-alpha fill rejects the chip's fragments and
|
||||
// the tag washes out to the fill's alpha), and the text one unit above the
|
||||
// chips — see the depth-layering note in collab_presence_style.h.
|
||||
std::shared_ptr<KIGFX::VIEW_OVERLAY> chipOverlay;
|
||||
std::shared_ptr<KIGFX::VIEW_OVERLAY> textOverlay;
|
||||
|
||||
bool started = false;
|
||||
|
|
@ -235,6 +238,38 @@ struct CORE
|
|||
{ "w", sz.x }, { "h", sz.y } }.dump();
|
||||
}
|
||||
|
||||
/** kicadCollabFitViewport (0008 follow-user): fit the given world RECT
|
||||
* (center + half-extents, IU) into this canvas — contain, never crop:
|
||||
* the follower's zoom is derived from ITS OWN canvas size, so leaders
|
||||
* and followers on different monitors see the same world region.
|
||||
* Fiber like every other view mutation from JS. */
|
||||
void fitViewport( double aCx, double aCy, double aHalfW, double aHalfH )
|
||||
{
|
||||
EDA_DRAW_FRAME* fr = frame();
|
||||
|
||||
if( !fr || aHalfW <= 0 || aHalfH <= 0 )
|
||||
return;
|
||||
|
||||
pcbjam_collab::runOnFiber( fr, [this, fr, aCx, aCy, aHalfW, aHalfH]() {
|
||||
KIGFX::VIEW* view = fr->GetCanvas()->GetView();
|
||||
const VECTOR2I& sz = view->GetScreenPixelSize();
|
||||
|
||||
// Pixels-per-IU that CONTAINS the rect in both axes. GetScale() is
|
||||
// the zoom, not px/IU (0002 lesson) — convert via the GAL matrix:
|
||||
// zoom scales linearly with px/IU, so target zoom = current zoom ×
|
||||
// (target px/IU ÷ current px/IU).
|
||||
double pxPerIuNow = view->ToScreen( 1.0 );
|
||||
double pxPerIuFit = std::min( sz.x / ( 2.0 * aHalfW ), sz.y / ( 2.0 * aHalfH ) );
|
||||
|
||||
if( pxPerIuNow > 0 && pxPerIuFit > 0 )
|
||||
view->SetScale( view->GetScale() * ( pxPerIuFit / pxPerIuNow ) );
|
||||
|
||||
view->SetCenter( VECTOR2D( aCx, aCy ) );
|
||||
fr->GetCanvas()->ForceRefresh();
|
||||
emitViewportIfChanged();
|
||||
} );
|
||||
}
|
||||
|
||||
/** kicadCollabSetViewport (0005): pan to a world position (comment panel
|
||||
* "jump to pin"). Fiber like every other view mutation from JS. */
|
||||
void panTo( double aCx, double aCy )
|
||||
|
|
@ -300,16 +335,23 @@ struct CORE
|
|||
|
||||
if( !overlay )
|
||||
{
|
||||
// Depth layering near→deep: text (0) < chips (1) < shapes (2) —
|
||||
// fork SetDepthOffset; see collab_presence_style.h.
|
||||
overlay = view->MakeOverlay();
|
||||
// Shapes sit one depth unit BELOW the text overlay (fork
|
||||
// SetDepthOffset) so labels always render on top of chips/boxes.
|
||||
overlay->SetDepthOffset( PRESENCE_SHAPES_DEPTH_OFFSET );
|
||||
}
|
||||
|
||||
if( !chipOverlay )
|
||||
{
|
||||
chipOverlay = view->MakeOverlay();
|
||||
chipOverlay->SetDepthOffset( PRESENCE_CHIPS_DEPTH_OFFSET );
|
||||
}
|
||||
|
||||
if( !textOverlay )
|
||||
textOverlay = makePresenceTextOverlay( view );
|
||||
|
||||
overlay->Clear();
|
||||
chipOverlay->Clear();
|
||||
textOverlay->Clear();
|
||||
|
||||
// Screen-constant sizing: px → world units, so cursors/outline widths
|
||||
|
|
@ -326,18 +368,20 @@ struct CORE
|
|||
drawPeerShapes( *this, fr, peer, color, px );
|
||||
|
||||
if( peer.hasCursor )
|
||||
drawCursor( overlay.get(), textOverlay.get(), peer.cursor, peer.name, color,
|
||||
px, style );
|
||||
drawCursor( overlay.get(), chipOverlay.get(), textOverlay.get(), peer.cursor,
|
||||
peer.name, color, px, style );
|
||||
}
|
||||
|
||||
// Comment pin dots (0005), drawn last so they sit above selection outlines.
|
||||
// Comment pin dots (0005) — on the CHIPS layer so selection fills
|
||||
// can't reject their fragments (see drawPin).
|
||||
for( const PIN& pin : pins )
|
||||
{
|
||||
KIGFX::COLOR4D color = peerColor( style, pin.name, pin.color );
|
||||
drawPin( overlay.get(), pin.pos, color, pin.resolved, px, style );
|
||||
drawPin( chipOverlay.get(), pin.pos, color, pin.resolved, px, style );
|
||||
}
|
||||
|
||||
view->Update( overlay.get() );
|
||||
view->Update( chipOverlay.get() );
|
||||
view->Update( textOverlay.get() );
|
||||
// The canvas repaints on its own only with focus/input — force it,
|
||||
// exactly as the cross-probe flash does.
|
||||
|
|
|
|||
|
|
@ -245,8 +245,18 @@ public:
|
|||
}
|
||||
};
|
||||
|
||||
/** Depth offset for the SHAPES overlay — one unit deeper than the text. */
|
||||
constexpr double PRESENCE_SHAPES_DEPTH_OFFSET = 1.0;
|
||||
/**
|
||||
* Depth layering (0007 lesson, extended): each overlay draws its WHOLE
|
||||
* command list at one depth, and same-depth fragments drawn later LOSE the
|
||||
* depth test — so anything that must render on top of something else needs
|
||||
* its own overlay one unit nearer. Three layers, near → deep:
|
||||
* text (0) < chips + pin dots (1) < selection shapes/fills (2)
|
||||
* Chips above fills fixes the washed-out name tags inside low-alpha
|
||||
* selection areas (the chip fragments were rejected against the
|
||||
* earlier-drawn fill, leaving only the fill's alpha behind the glyphs).
|
||||
*/
|
||||
constexpr double PRESENCE_CHIPS_DEPTH_OFFSET = 1.0;
|
||||
constexpr double PRESENCE_SHAPES_DEPTH_OFFSET = 2.0;
|
||||
|
||||
/** VIEW::MakeOverlay's body, for the text overlay (make + Add to the view). */
|
||||
inline std::shared_ptr<PRESENCE_TEXT_OVERLAY> makePresenceTextOverlay( KIGFX::VIEW* aView )
|
||||
|
|
@ -257,12 +267,13 @@ inline std::shared_ptr<PRESENCE_TEXT_OVERLAY> makePresenceTextOverlay( KIGFX::VI
|
|||
}
|
||||
|
||||
/** Name tag next to (or inside) a box, per the label placement knobs. `px` is
|
||||
* world-units-per-screen-pixel. The chip rect goes to the SHAPES overlay,
|
||||
* the text to the TEXT overlay (nearest depth + pinned TOP-LEFT justify —
|
||||
* see PRESENCE_TEXT_OVERLAY), so the text always renders on top. */
|
||||
inline void drawLabel( KIGFX::VIEW_OVERLAY* aOv, KIGFX::VIEW_OVERLAY* aTextOv, const BOX2I& aBox,
|
||||
const std::string& aText, const KIGFX::COLOR4D& aColor, double aPx,
|
||||
const STYLE& aS )
|
||||
* world-units-per-screen-pixel. The chip rect goes to the CHIPS overlay
|
||||
* (above selection fills, below text), the text to the TEXT overlay (nearest
|
||||
* depth + pinned TOP-LEFT justify — see PRESENCE_TEXT_OVERLAY), so the tag
|
||||
* renders solid regardless of what selection geometry it overlaps. */
|
||||
inline void drawLabel( KIGFX::VIEW_OVERLAY* aChipOv, KIGFX::VIEW_OVERLAY* aTextOv,
|
||||
const BOX2I& aBox, const std::string& aText,
|
||||
const KIGFX::COLOR4D& aColor, double aPx, const STYLE& aS )
|
||||
{
|
||||
if( !aS.labelShow || aText.empty() )
|
||||
return;
|
||||
|
|
@ -288,13 +299,13 @@ inline void drawLabel( KIGFX::VIEW_OVERLAY* aOv, KIGFX::VIEW_OVERLAY* aTextOv, c
|
|||
if( aS.labelChip )
|
||||
{
|
||||
double padX = 3 * aPx, padY = 2 * aPx;
|
||||
aOv->SetIsStroke( false );
|
||||
aOv->SetIsFill( true );
|
||||
aOv->SetFillColor( aColor.WithAlpha( aS.chipBgAlpha ) );
|
||||
aOv->Rectangle( VECTOR2D( x - padX, y - padY ),
|
||||
VECTOR2D( x + w + padX, y + h + padY ) );
|
||||
aOv->SetIsStroke( true );
|
||||
aOv->SetIsFill( false );
|
||||
aChipOv->SetIsStroke( false );
|
||||
aChipOv->SetIsFill( true );
|
||||
aChipOv->SetFillColor( aColor.WithAlpha( aS.chipBgAlpha ) );
|
||||
aChipOv->Rectangle( VECTOR2D( x - padX, y - padY ),
|
||||
VECTOR2D( x + w + padX, y + h + padY ) );
|
||||
aChipOv->SetIsStroke( true );
|
||||
aChipOv->SetIsFill( false );
|
||||
}
|
||||
|
||||
aTextOv->SetIsStroke( true );
|
||||
|
|
@ -307,8 +318,11 @@ inline void drawLabel( KIGFX::VIEW_OVERLAY* aOv, KIGFX::VIEW_OVERLAY* aTextOv, c
|
|||
|
||||
/** Selection highlight for one item, in the chosen shape. `aOutline` is the
|
||||
* item's exact geometry for selShape 5 (pcbnew supplies it; eeschema passes
|
||||
* nullptr and shape 5 falls back to the bbox rectangle). */
|
||||
inline void drawSelectionBox( KIGFX::VIEW_OVERLAY* aOv, KIGFX::VIEW_OVERLAY* aTextOv, BOX2I aBox,
|
||||
* nullptr and shape 5 falls back to the bbox rectangle). Shapes/fills paint
|
||||
* on `aOv` (deepest layer); the name chip goes to `aChipOv` one unit nearer
|
||||
* so it stays solid over any fill (see the depth-layering note above). */
|
||||
inline void drawSelectionBox( KIGFX::VIEW_OVERLAY* aOv, KIGFX::VIEW_OVERLAY* aChipOv,
|
||||
KIGFX::VIEW_OVERLAY* aTextOv, BOX2I aBox,
|
||||
const std::string& aName, const KIGFX::COLOR4D& aColor, double aPx,
|
||||
const STYLE& aS, const SHAPE_POLY_SET* aOutline = nullptr )
|
||||
{
|
||||
|
|
@ -323,7 +337,7 @@ inline void drawSelectionBox( KIGFX::VIEW_OVERLAY* aOv, KIGFX::VIEW_OVERLAY* aTe
|
|||
|
||||
BOX2I labelBox = aOutline->BBox();
|
||||
labelBox.Inflate( KiROUND( aS.selPaddingPx * aPx ) );
|
||||
drawLabel( aOv, aTextOv, labelBox, aName, aColor, aPx, aS );
|
||||
drawLabel( aChipOv, aTextOv, labelBox, aName, aColor, aPx, aS );
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -403,11 +417,13 @@ inline void drawSelectionBox( KIGFX::VIEW_OVERLAY* aOv, KIGFX::VIEW_OVERLAY* aTe
|
|||
}
|
||||
}
|
||||
|
||||
drawLabel( aOv, aTextOv, aBox, aName, aColor, aPx, aS );
|
||||
drawLabel( aChipOv, aTextOv, aBox, aName, aColor, aPx, aS );
|
||||
}
|
||||
|
||||
/** Remote cursor (+ name label) in the chosen shape. */
|
||||
inline void drawCursor( KIGFX::VIEW_OVERLAY* aOv, KIGFX::VIEW_OVERLAY* aTextOv,
|
||||
/** Remote cursor (+ name label) in the chosen shape. The cursor glyph paints
|
||||
* on `aOv`; its label chip on `aChipOv` (same layering as drawSelectionBox). */
|
||||
inline void drawCursor( KIGFX::VIEW_OVERLAY* aOv, KIGFX::VIEW_OVERLAY* aChipOv,
|
||||
KIGFX::VIEW_OVERLAY* aTextOv,
|
||||
const VECTOR2D& aPos, const std::string& aName,
|
||||
const KIGFX::COLOR4D& aColor, double aPx, const STYLE& aS )
|
||||
{
|
||||
|
|
@ -455,18 +471,18 @@ inline void drawCursor( KIGFX::VIEW_OVERLAY* aOv, KIGFX::VIEW_OVERLAY* aTextOv,
|
|||
VECTOR2D at = aPos + VECTOR2D( ( aS.cursorSizePx + 4 ) * aPx,
|
||||
( aS.cursorSizePx + 4 ) * aPx );
|
||||
|
||||
// Chip rect on the shapes overlay, text on the TEXT overlay (nearest
|
||||
// depth) — text always renders on top of its chip.
|
||||
// Chip rect on the CHIPS overlay, text on the TEXT overlay (nearest
|
||||
// depth) — text on top of its chip, chip on top of selection fills.
|
||||
if( aS.cursorLabelChip )
|
||||
{
|
||||
double padX = 3 * aPx, padY = 2 * aPx;
|
||||
aOv->SetIsStroke( false );
|
||||
aOv->SetIsFill( true );
|
||||
aOv->SetFillColor( aColor.WithAlpha( aS.chipBgAlpha ) );
|
||||
aOv->Rectangle( at + VECTOR2D( -padX, -padY ),
|
||||
at + VECTOR2D( w + padX, h + padY ) );
|
||||
aOv->SetIsStroke( true );
|
||||
aOv->SetIsFill( false );
|
||||
aChipOv->SetIsStroke( false );
|
||||
aChipOv->SetIsFill( true );
|
||||
aChipOv->SetFillColor( aColor.WithAlpha( aS.chipBgAlpha ) );
|
||||
aChipOv->Rectangle( at + VECTOR2D( -padX, -padY ),
|
||||
at + VECTOR2D( w + padX, h + padY ) );
|
||||
aChipOv->SetIsStroke( true );
|
||||
aChipOv->SetIsFill( false );
|
||||
}
|
||||
|
||||
aTextOv->SetIsStroke( true );
|
||||
|
|
@ -478,7 +494,9 @@ inline void drawCursor( KIGFX::VIEW_OVERLAY* aOv, KIGFX::VIEW_OVERLAY* aTextOv,
|
|||
}
|
||||
}
|
||||
|
||||
/** Comment pin dot. */
|
||||
/** Comment pin dot. Draw onto the CHIPS overlay: at the shapes depth an
|
||||
* earlier-painted selection fill would reject the dot's fragments (the 0005
|
||||
* "drawn last sits above" comment had it backwards — later fragments LOSE). */
|
||||
inline void drawPin( KIGFX::VIEW_OVERLAY* aOv, const VECTOR2D& aPos, const KIGFX::COLOR4D& aColor,
|
||||
bool aResolved, double aPx, const STYLE& aS )
|
||||
{
|
||||
|
|
|
|||
|
|
@ -733,7 +733,8 @@ pcbjam_presence::CORE& presenceCore()
|
|||
if( !item )
|
||||
continue; // not in this schematic (yet) — skip silently
|
||||
|
||||
pcbjam_presence::drawSelectionBox( aCore.overlay.get(), aCore.textOverlay.get(),
|
||||
pcbjam_presence::drawSelectionBox( aCore.overlay.get(), aCore.chipOverlay.get(),
|
||||
aCore.textOverlay.get(),
|
||||
item->ViewBBox(), peer.name, color, px,
|
||||
aCore.style );
|
||||
}
|
||||
|
|
@ -745,7 +746,8 @@ pcbjam_presence::CORE& presenceCore()
|
|||
|
||||
for( SCH_ITEM* item : resolveXsel( fr, peer ) )
|
||||
{
|
||||
pcbjam_presence::drawSelectionBox( aCore.overlay.get(), aCore.textOverlay.get(),
|
||||
pcbjam_presence::drawSelectionBox( aCore.overlay.get(), aCore.chipOverlay.get(),
|
||||
aCore.textOverlay.get(),
|
||||
item->ViewBBox(), peer.name, color, px,
|
||||
ghost );
|
||||
}
|
||||
|
|
@ -1467,6 +1469,12 @@ void schCollabSetViewport( double aCx, double aCy )
|
|||
presenceCore().panTo( aCx, aCy );
|
||||
}
|
||||
|
||||
// JS → C++ (0008 follow-user): fit a leader's world rect into this canvas.
|
||||
void schCollabFitViewport( double aCx, double aCy, double aHalfW, double aHalfH )
|
||||
{
|
||||
presenceCore().fitViewport( aCx, aCy, aHalfW, aHalfH );
|
||||
}
|
||||
|
||||
// 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()
|
||||
|
|
@ -1691,6 +1699,8 @@ EMSCRIPTEN_BINDINGS(eeschema) {
|
|||
function("kicadCollabSetRemote", &schCollabSetRemote);
|
||||
function("kicadCollabSetPins", &schCollabSetPins);
|
||||
function("kicadCollabSetViewport", &schCollabSetViewport);
|
||||
// Follow-user (collab-presence 0008).
|
||||
function("kicadCollabFitViewport", &schCollabFitViewport);
|
||||
function("kicadCollabSetStyle", &schCollabSetStyle);
|
||||
function("kicadCollabTestListItems", &schCollabTestListItems);
|
||||
function("kicadCollabTestDemoSet", &schCollabTestDemoSet);
|
||||
|
|
|
|||
|
|
@ -58,6 +58,8 @@ void pcbCollabPresenceStart();
|
|||
void pcbCollabSetRemote( std::string aJson );
|
||||
void pcbCollabSetPins( std::string aJson );
|
||||
void pcbCollabSetViewport( double aCx, double aCy );
|
||||
// Follow-user (collab-presence 0008).
|
||||
void pcbCollabFitViewport( double aCx, double aCy, double aHalfW, double aHalfH );
|
||||
void pcbCollabSetStyle( std::string aJson );
|
||||
std::string pcbCollabTestListItems( int aCount );
|
||||
std::string pcbCollabTestDemoSet();
|
||||
|
|
@ -90,6 +92,8 @@ void schCollabPresenceStart();
|
|||
void schCollabSetRemote( std::string aJson );
|
||||
void schCollabSetPins( std::string aJson );
|
||||
void schCollabSetViewport( double aCx, double aCy );
|
||||
// Follow-user (collab-presence 0008).
|
||||
void schCollabFitViewport( double aCx, double aCy, double aHalfW, double aHalfH );
|
||||
void schCollabSetStyle( std::string aJson );
|
||||
std::string schCollabTestListItems( int aCount );
|
||||
std::string schCollabTestDemoSet();
|
||||
|
|
@ -337,6 +341,13 @@ static void collabSetViewport( double aCx, double aCy )
|
|||
pcbEditorActive() ? pcbCollabSetViewport( aCx, aCy ) : schCollabSetViewport( aCx, aCy );
|
||||
}
|
||||
|
||||
// Follow-user (collab-presence 0008).
|
||||
static void collabFitViewport( double aCx, double aCy, double aHalfW, double aHalfH )
|
||||
{
|
||||
pcbEditorActive() ? pcbCollabFitViewport( aCx, aCy, aHalfW, aHalfH )
|
||||
: schCollabFitViewport( aCx, aCy, aHalfW, aHalfH );
|
||||
}
|
||||
|
||||
static void collabSetStyle( std::string aJson )
|
||||
{
|
||||
pcbEditorActive() ? pcbCollabSetStyle( aJson ) : schCollabSetStyle( aJson );
|
||||
|
|
@ -426,6 +437,8 @@ EMSCRIPTEN_BINDINGS(kicad_editor) {
|
|||
function("kicadCollabSetRemote", &collabSetRemote);
|
||||
function("kicadCollabSetPins", &collabSetPins);
|
||||
function("kicadCollabSetViewport", &collabSetViewport);
|
||||
// Follow-user (collab-presence 0008).
|
||||
function("kicadCollabFitViewport", &collabFitViewport);
|
||||
function("kicadCollabSetStyle", &collabSetStyle);
|
||||
function("kicadCollabTestListItems", &collabTestListItems);
|
||||
function("kicadCollabTestDemoSet", &collabTestDemoSet);
|
||||
|
|
|
|||
|
|
@ -1203,7 +1203,8 @@ pcbjam_presence::CORE& presenceCore()
|
|||
}
|
||||
}
|
||||
|
||||
pcbjam_presence::drawSelectionBox( aCore.overlay.get(), aCore.textOverlay.get(),
|
||||
pcbjam_presence::drawSelectionBox( aCore.overlay.get(), aCore.chipOverlay.get(),
|
||||
aCore.textOverlay.get(),
|
||||
item->ViewBBox(), name, itemColor, px,
|
||||
style, &outline );
|
||||
};
|
||||
|
|
@ -1594,6 +1595,12 @@ void pcbCollabSetViewport( double aCx, double aCy )
|
|||
presenceCore().panTo( aCx, aCy );
|
||||
}
|
||||
|
||||
// JS → C++ (0008 follow-user): fit a leader's world rect into this canvas.
|
||||
void pcbCollabFitViewport( double aCx, double aCy, double aHalfW, double aHalfH )
|
||||
{
|
||||
presenceCore().fitViewport( aCx, aCy, aHalfW, aHalfH );
|
||||
}
|
||||
|
||||
// JS pull of the current viewport transform (world↔screen mapping for the DOM layer):
|
||||
// `{cx,cy,scale,w,h}` — world center, pixels-per-IU scale, canvas size in px.
|
||||
std::string pcbCollabGetViewport()
|
||||
|
|
@ -1981,6 +1988,8 @@ EMSCRIPTEN_BINDINGS(pcbnew) {
|
|||
function("kicadCollabSetRemote", &pcbCollabSetRemote);
|
||||
function("kicadCollabSetPins", &pcbCollabSetPins);
|
||||
function("kicadCollabSetViewport", &pcbCollabSetViewport);
|
||||
// Follow-user (collab-presence 0008).
|
||||
function("kicadCollabFitViewport", &pcbCollabFitViewport);
|
||||
function("kicadCollabSetStyle", &pcbCollabSetStyle);
|
||||
function("kicadCollabTestListItems", &pcbCollabTestListItems);
|
||||
function("kicadCollabTestDemoSet", &pcbCollabTestDemoSet);
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
Subproject commit b68ee96a85b4793ebd15afab5a68f296406b53b5
|
||||
Subproject commit 1c4103c583b7326245a288a5ff63efe18dafcbf1
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import type { PresencePeer } from "@/wasm/collab/presence";
|
||||
import type { FollowTarget } from "@/wasm/collab/follow-user";
|
||||
|
||||
/**
|
||||
* "Who else is in this file" (collab-presence 0001/0003): a compact facepile of
|
||||
|
|
@ -8,6 +9,11 @@ import type { PresencePeer } from "@/wasm/collab/presence";
|
|||
* 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.
|
||||
*
|
||||
* Follow-user (0008): when `onFollow` is provided, clicking a same-sheet
|
||||
* peer's avatar follows their viewport (click again to stop); the followed
|
||||
* peer gets a ring in their color. The parent renders the "Following…"
|
||||
* banner — the roster only toggles.
|
||||
*/
|
||||
const MAX_AVATARS = 5;
|
||||
|
||||
|
|
@ -20,10 +26,16 @@ function sheetLabel(sheetPath?: string): string {
|
|||
export function PresenceRoster({
|
||||
peers,
|
||||
activeSheetPath,
|
||||
following,
|
||||
onFollow,
|
||||
}: {
|
||||
peers: PresencePeer[];
|
||||
/** eeschema: the sheet THIS client is on — peers elsewhere render dimmed. */
|
||||
activeSheetPath?: string;
|
||||
/** 0008: the followed client, when a follow is active. */
|
||||
following?: FollowTarget | null;
|
||||
/** 0008: toggle following a peer (null = stop). Absent = follow UI off. */
|
||||
onFollow?: (target: FollowTarget | null) => void;
|
||||
}) {
|
||||
if (!peers.length) return null;
|
||||
|
||||
|
|
@ -38,6 +50,16 @@ export function PresenceRoster({
|
|||
.join(", ");
|
||||
const shown = ordered.slice(0, MAX_AVATARS);
|
||||
|
||||
const followable = (p: PresencePeer) => !!onFollow && sameSheet(p);
|
||||
const isFollowed = (p: PresencePeer) => following?.clientId === p.clientId;
|
||||
|
||||
const toggleFollow = (p: PresencePeer) => {
|
||||
if (!onFollow) return;
|
||||
onFollow(
|
||||
isFollowed(p) ? null : { clientId: p.clientId, userId: p.user.id, name: p.user.name },
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
data-testid="presence-roster"
|
||||
|
|
@ -45,22 +67,29 @@ export function PresenceRoster({
|
|||
className="inline-flex items-center rounded-full bg-black/70 py-0.5 pl-1 pr-1.5 shadow-sm ring-1 ring-inset ring-white/20"
|
||||
>
|
||||
{shown.map((p) => (
|
||||
<span
|
||||
<button
|
||||
key={p.user.id}
|
||||
type="button"
|
||||
data-presence-user={p.user.id}
|
||||
data-presence-elsewhere={sameSheet(p) ? undefined : "1"}
|
||||
data-presence-following={isFollowed(p) ? "1" : undefined}
|
||||
disabled={!followable(p)}
|
||||
onClick={() => toggleFollow(p)}
|
||||
title={
|
||||
sameSheet(p)
|
||||
? p.user.name
|
||||
? `${p.user.name}${onFollow ? (isFollowed(p) ? " — click to stop following" : " — click to follow") : ""}`
|
||||
: `${p.user.name} — on ${sheetLabel(p.sheetPath) || "another sheet"}`
|
||||
}
|
||||
style={{ backgroundColor: p.user.color }}
|
||||
style={{
|
||||
backgroundColor: p.user.color,
|
||||
...(isFollowed(p) ? { boxShadow: `0 0 0 2px ${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 ${
|
||||
sameSheet(p) ? "" : "opacity-40"
|
||||
sameSheet(p) ? "cursor-pointer" : "opacity-40"
|
||||
}`}
|
||||
>
|
||||
{p.user.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{peers.length > MAX_AVATARS && (
|
||||
<span className="ml-1 text-[10px] font-medium text-white/80">
|
||||
|
|
|
|||
|
|
@ -56,8 +56,14 @@ import {
|
|||
import {
|
||||
bindKicadPresence,
|
||||
hasPresenceBridge,
|
||||
type PresenceKicadModule,
|
||||
type PresenceKicadWindow,
|
||||
} from "@/wasm/collab/presence-kicad";
|
||||
import {
|
||||
createFollow,
|
||||
type FollowHandle,
|
||||
type FollowTarget,
|
||||
} from "@/wasm/collab/follow-user";
|
||||
import { startCrossAppPresence, type CrossAppHandle } from "@/wasm/collab/cross-app";
|
||||
import {
|
||||
createComments,
|
||||
|
|
@ -720,6 +726,8 @@ 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);
|
||||
// Follow-user (0008): mirror a peer's viewport until local input breaks it.
|
||||
const followRef = React.useRef<FollowHandle | 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);
|
||||
|
|
@ -773,6 +781,8 @@ export function WasmTool({
|
|||
// 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>();
|
||||
// Follow-user (0008): the followed roster client, for the ring + banner.
|
||||
const [followingTarget, setFollowingTarget] = React.useState<FollowTarget | null>(null);
|
||||
// Comments (0005): the bound doc's controller + the live viewport transform
|
||||
// the DOM layer maps world→CSS with. Both rebind with the collab session
|
||||
// (per sheet in eeschema).
|
||||
|
|
@ -922,6 +932,9 @@ export function WasmTool({
|
|||
// pcbnew/pl_editor bind once; eeschema rebinds per active sheet, so the
|
||||
// roster shows who is on the SAME sheet (room = sheet).
|
||||
const startPresence = (provider: YjsProvider | undefined, sheetPath?: string) => {
|
||||
followRef.current?.destroy();
|
||||
followRef.current = null;
|
||||
setFollowingTarget(null);
|
||||
presenceBridgeRef.current?.destroy();
|
||||
presenceBridgeRef.current = null;
|
||||
presenceRef.current?.destroy();
|
||||
|
|
@ -945,14 +958,29 @@ export function WasmTool({
|
|||
// 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)) {
|
||||
// Follow-user (0008): available when the wasm exports FitViewport.
|
||||
const fitFn = (win.Module as PresenceKicadModule).kicadCollabFitViewport;
|
||||
if (fitFn) {
|
||||
const follow = createFollow({
|
||||
presence,
|
||||
fit: (cx, cy, halfW, halfH) => fitFn.call(win.Module, cx, cy, halfW, halfH),
|
||||
ownSheetPath: () => sheetPath,
|
||||
});
|
||||
follow.subscribe(setFollowingTarget);
|
||||
followRef.current = follow;
|
||||
}
|
||||
presenceBridgeRef.current = bindKicadPresence({
|
||||
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,
|
||||
// Live world↔screen transform for the DOM comment layer (0005) +
|
||||
// the follow controller's echo/break detection (0008).
|
||||
onViewport: (vp) => {
|
||||
setViewportState(vp);
|
||||
followRef.current?.noteLocalViewport(vp);
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -1212,6 +1240,8 @@ export function WasmTool({
|
|||
win.removeEventListener("keydown", chromeHotkey, true);
|
||||
commentsRef.current?.destroy();
|
||||
commentsRef.current = null;
|
||||
followRef.current?.destroy();
|
||||
followRef.current = null;
|
||||
presenceBridgeRef.current?.destroy();
|
||||
presenceBridgeRef.current = null;
|
||||
presenceRef.current?.destroy();
|
||||
|
|
@ -1392,6 +1422,27 @@ export function WasmTool({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Follow-user (0008): who we're following + how to stop. Esc also works
|
||||
because any canvas key input breaks the follow via noteLocalViewport
|
||||
only when the viewport moves — this banner is the explicit out. */}
|
||||
{ready && followingTarget && (
|
||||
<div
|
||||
data-testid="follow-banner"
|
||||
className="absolute left-1/2 top-3 z-20 flex -translate-x-1/2 items-center gap-2 rounded-full bg-black/70 px-3 py-1 text-xs text-white shadow-sm ring-1 ring-inset ring-white/20"
|
||||
>
|
||||
<span>
|
||||
Following <span className="font-semibold">{followingTarget.name}</span> — move to stop
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full bg-white/15 px-2 py-0.5 font-medium hover:bg-white/25"
|
||||
onClick={() => followRef.current?.unfollow()}
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top-right overlay row: who else is in this file (awareness roster),
|
||||
where this project lives / whether Save persists (chip hidden while
|
||||
the UI is hidden), and the Figma-like hide/show-UI toggle — the one
|
||||
|
|
@ -1402,7 +1453,15 @@ export function WasmTool({
|
|||
(sourceDescriptor && !chromeHidden)) && (
|
||||
<div className="absolute right-3 top-3 z-20 flex items-center gap-2">
|
||||
{peers.length > 0 && (
|
||||
<PresenceRoster peers={peers} activeSheetPath={activeSheetPath} />
|
||||
<PresenceRoster
|
||||
peers={peers}
|
||||
activeSheetPath={activeSheetPath}
|
||||
following={followingTarget}
|
||||
onFollow={(t) => {
|
||||
if (t) followRef.current?.follow(t);
|
||||
else followRef.current?.unfollow();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{sourceDescriptor && !chromeHidden && (
|
||||
<SourceChip descriptor={sourceDescriptor} />
|
||||
|
|
|
|||
147
web/standalone/src/wasm/collab/follow-user.test.ts
Normal file
147
web/standalone/src/wasm/collab/follow-user.test.ts
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { PresencePeer } from "./presence";
|
||||
import type { ViewportState } from "./presence-kicad";
|
||||
import { createFollow, type FollowTarget } from "./follow-user";
|
||||
|
||||
/**
|
||||
* Follow-user controller units (collab-presence 0008): apply/republish
|
||||
* dedupe, echo suppression, break-on-interact, leader-left, sheet gating.
|
||||
* The presence handle is faked — only clients() + subscribe are used.
|
||||
*/
|
||||
|
||||
const LEADER: FollowTarget = { clientId: 7, userId: "u-lead", name: "lead" };
|
||||
|
||||
function peer(over: Partial<PresencePeer> = {}): PresencePeer {
|
||||
return {
|
||||
clientId: 7,
|
||||
user: { id: "u-lead", name: "lead", color: "#ef4444" },
|
||||
tool: "pcbnew",
|
||||
cursor: null,
|
||||
selection: [],
|
||||
viewport: { cx: 100, cy: 200, halfW: 50, halfH: 40 },
|
||||
updatedAt: 1,
|
||||
...over,
|
||||
} as PresencePeer;
|
||||
}
|
||||
|
||||
function fakePresence(initial: PresencePeer[]) {
|
||||
let clients = initial;
|
||||
const subs = new Set<() => void>();
|
||||
return {
|
||||
handle: {
|
||||
clients: () => clients,
|
||||
subscribe(cb: () => void) {
|
||||
subs.add(cb);
|
||||
return () => subs.delete(cb);
|
||||
},
|
||||
} as unknown as import("./presence").PresenceHandle,
|
||||
update(next: PresencePeer[]) {
|
||||
clients = next;
|
||||
for (const cb of subs) cb();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A local onViewport emit whose world rect equals `rect` on a canvas of
|
||||
* aspect w:h (contain fit: the wider axis overshoots). */
|
||||
function echoFor(
|
||||
rect: { cx: number; cy: number; halfW: number; halfH: number },
|
||||
w = 1000,
|
||||
h = 800,
|
||||
): ViewportState {
|
||||
const scale = Math.min(w / (2 * rect.halfW), h / (2 * rect.halfH));
|
||||
return { cx: rect.cx, cy: rect.cy, scale, w, h };
|
||||
}
|
||||
|
||||
describe("createFollow", () => {
|
||||
it("applies the leader's viewport on follow and on change, deduping republish", () => {
|
||||
const p = fakePresence([peer()]);
|
||||
const fit = vi.fn();
|
||||
const follow = createFollow({ presence: p.handle, fit });
|
||||
|
||||
follow.follow(LEADER);
|
||||
expect(fit).toHaveBeenCalledWith(100, 200, 50, 40);
|
||||
|
||||
// Republished but UNCHANGED viewport (e.g. a selection edit) → no re-fit.
|
||||
p.update([peer()]);
|
||||
expect(fit).toHaveBeenCalledTimes(1);
|
||||
|
||||
p.update([peer({ viewport: { cx: 110, cy: 200, halfW: 50, halfH: 40 } })]);
|
||||
expect(fit).toHaveBeenCalledTimes(2);
|
||||
expect(fit).toHaveBeenLastCalledWith(110, 200, 50, 40);
|
||||
});
|
||||
|
||||
it("suppresses the fit's own echo, breaks on a deviating local viewport", () => {
|
||||
const p = fakePresence([peer()]);
|
||||
const fit = vi.fn();
|
||||
const follow = createFollow({ presence: p.handle, fit });
|
||||
follow.follow(LEADER);
|
||||
|
||||
const rect = { cx: 100, cy: 200, halfW: 50, halfH: 40 };
|
||||
follow.noteLocalViewport(echoFor(rect));
|
||||
expect(follow.following()).toEqual(LEADER);
|
||||
|
||||
// User pans far away → follow ends.
|
||||
follow.noteLocalViewport(echoFor({ ...rect, cx: 500 }));
|
||||
expect(follow.following()).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores a stale pre-echo local viewport (the fit hasn't landed yet)", () => {
|
||||
const p = fakePresence([peer()]);
|
||||
const fit = vi.fn();
|
||||
const follow = createFollow({ presence: p.handle, fit });
|
||||
follow.follow(LEADER);
|
||||
|
||||
// The canvas still shows the pre-follow region — not a user break.
|
||||
follow.noteLocalViewport(echoFor({ cx: 9999, cy: 9999, halfW: 10, halfH: 10 }));
|
||||
expect(follow.following()).toEqual(LEADER);
|
||||
});
|
||||
|
||||
it("a user zoom breaks the follow even at the same center", () => {
|
||||
const p = fakePresence([peer()]);
|
||||
const follow = createFollow({ presence: p.handle, fit: vi.fn() });
|
||||
follow.follow(LEADER);
|
||||
|
||||
const rect = { cx: 100, cy: 200, halfW: 50, halfH: 40 };
|
||||
follow.noteLocalViewport(echoFor(rect));
|
||||
// Zoom-in: both half-extents shrink well past the tolerance.
|
||||
follow.noteLocalViewport(echoFor({ ...rect, halfW: 25, halfH: 20 }));
|
||||
expect(follow.following()).toBeNull();
|
||||
});
|
||||
|
||||
it("unfollows when the leader leaves the room", () => {
|
||||
const p = fakePresence([peer()]);
|
||||
const follow = createFollow({ presence: p.handle, fit: vi.fn() });
|
||||
follow.follow(LEADER);
|
||||
|
||||
p.update([]);
|
||||
expect(follow.following()).toBeNull();
|
||||
});
|
||||
|
||||
it("pauses (does not fit) while an eeschema leader is on another sheet", () => {
|
||||
const p = fakePresence([peer({ sheetPath: "sub.kicad_sch" })]);
|
||||
const fit = vi.fn();
|
||||
const follow = createFollow({
|
||||
presence: p.handle,
|
||||
fit,
|
||||
ownSheetPath: () => "demo.kicad_sch",
|
||||
});
|
||||
follow.follow(LEADER);
|
||||
expect(fit).not.toHaveBeenCalled();
|
||||
|
||||
// Leader returns to our sheet → fit resumes.
|
||||
p.update([peer({ sheetPath: "demo.kicad_sch" })]);
|
||||
expect(fit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("notifies subscribers on follow and unfollow", () => {
|
||||
const p = fakePresence([peer()]);
|
||||
const follow = createFollow({ presence: p.handle, fit: vi.fn() });
|
||||
const seen: Array<FollowTarget | null> = [];
|
||||
follow.subscribe((t) => seen.push(t));
|
||||
|
||||
follow.follow(LEADER);
|
||||
follow.unfollow();
|
||||
expect(seen).toEqual([LEADER, null]);
|
||||
});
|
||||
});
|
||||
169
web/standalone/src/wasm/collab/follow-user.ts
Normal file
169
web/standalone/src/wasm/collab/follow-user.ts
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
import type { PresenceViewport } from "@pcbjam/shared";
|
||||
import { clog } from "./debug";
|
||||
import type { PresenceHandle, PresencePeer } from "./presence";
|
||||
import type { ViewportState } from "./presence-kicad";
|
||||
import { viewportRect } from "./presence-kicad";
|
||||
|
||||
/**
|
||||
* Follow-user controller (collab-presence 0008): mirror a peer's viewport
|
||||
* until the local user interacts.
|
||||
*
|
||||
* Follows an awareness CLIENT (a specific tab), not a user — a user's two
|
||||
* tabs can show different regions and "follow" means watching one screen.
|
||||
* On every roster change the leader's published world rect (presence wire
|
||||
* `viewport`) is applied through `kicadCollabFitViewport` (contain: the zoom
|
||||
* derives from OUR canvas, so different window sizes see the same region).
|
||||
*
|
||||
* Break-on-interact needs no extra input hooks: each applied fit remembers
|
||||
* its target, and every local `onViewport` echo is compared against it —
|
||||
* a matching echo (epsilon — the rect round-trips through the GAL matrix)
|
||||
* is our own fit landing; a deviating one means the user panned/zoomed/
|
||||
* jumped locally, so the follow ends. Same-tool, same-sheet only in v1:
|
||||
* an eeschema leader on another sheet pauses the fit (rect coordinates are
|
||||
* per-sheet); cross-tool follow is out of scope.
|
||||
*
|
||||
* Follow loops (A follows B, B follows A) are benign: neither side produces
|
||||
* a non-echo viewport change, so both simply idle.
|
||||
*/
|
||||
|
||||
export interface FollowTarget {
|
||||
clientId: number;
|
||||
userId: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface FollowHandle {
|
||||
/** Start following a roster client; replaces any current follow. */
|
||||
follow(target: FollowTarget): void;
|
||||
/** Stop following (explicit UI action — Esc / click). */
|
||||
unfollow(): void;
|
||||
/** The current follow target, or null. */
|
||||
following(): FollowTarget | null;
|
||||
/** Fires with the new target (or null) on every follow state change. */
|
||||
subscribe(cb: (target: FollowTarget | null) => void): () => void;
|
||||
/** Feed every local onViewport emit here (echo suppression + break). */
|
||||
noteLocalViewport(vp: ViewportState): void;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
/** Relative tolerance for "this local viewport is our own fit's echo": the
|
||||
* rect round-trips world→zoom→world through the GAL matrix, and the canvas
|
||||
* aspect makes one axis overshoot (contain), so compare center and the
|
||||
* CONTAINED axis loosely. */
|
||||
const ECHO_REL_TOLERANCE = 0.02;
|
||||
|
||||
function isEcho(vp: ViewportState, target: PresenceViewport): boolean {
|
||||
const rect = viewportRect(vp);
|
||||
if (!rect) return true; // degenerate transform — never treat as user input
|
||||
|
||||
// Scale-relative center comparison: a pan of >2% of the view is a break.
|
||||
const tolX = Math.max(rect.halfW, target.halfW) * ECHO_REL_TOLERANCE;
|
||||
const tolY = Math.max(rect.halfH, target.halfH) * ECHO_REL_TOLERANCE;
|
||||
if (Math.abs(rect.cx - target.cx) > tolX) return false;
|
||||
if (Math.abs(rect.cy - target.cy) > tolY) return false;
|
||||
|
||||
// Contain-fit: exactly one axis matches the target's half-extent (the other
|
||||
// overshoots by the aspect difference). A zoom by the user changes BOTH away
|
||||
// from the target.
|
||||
const relW = Math.abs(rect.halfW - target.halfW) / target.halfW;
|
||||
const relH = Math.abs(rect.halfH - target.halfH) / target.halfH;
|
||||
return relW <= ECHO_REL_TOLERANCE || relH <= ECHO_REL_TOLERANCE;
|
||||
}
|
||||
|
||||
export function createFollow(opts: {
|
||||
presence: PresenceHandle;
|
||||
/** Module.kicadCollabFitViewport, bound; absent on older wasm → no-op UI. */
|
||||
fit: (cx: number, cy: number, halfW: number, halfH: number) => void;
|
||||
/** The sheet THIS tab shows (eeschema); undefined for single-doc tools. */
|
||||
ownSheetPath?: () => string | undefined;
|
||||
}): FollowHandle {
|
||||
const { presence, fit } = opts;
|
||||
|
||||
let target: FollowTarget | null = null;
|
||||
/** The last rect we asked the canvas to fit — echoes match against this. */
|
||||
let applied: PresenceViewport | null = null;
|
||||
/** Grace: ignore break-checks until the first fit's echo arrived, else the
|
||||
* stale pre-follow viewport emit would instantly end the follow. */
|
||||
let sawEcho = false;
|
||||
|
||||
const subscribers = new Set<(t: FollowTarget | null) => void>();
|
||||
const notify = () => {
|
||||
for (const cb of subscribers) cb(target);
|
||||
};
|
||||
|
||||
const leaderState = (): PresencePeer | undefined =>
|
||||
presence.clients().find((c) => c.clientId === target?.clientId);
|
||||
|
||||
const applyLeader = () => {
|
||||
if (!target) return;
|
||||
const leader = leaderState();
|
||||
if (!leader) {
|
||||
// Leader left the room — follow ends (their awareness state expired).
|
||||
clog("follow: leader left —", target.name);
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
// v1: rect coordinates are per-document; pause while the leader is on a
|
||||
// different sheet than this canvas shows (eeschema warm-pool rooms).
|
||||
const ownSheet = opts.ownSheetPath?.();
|
||||
if (ownSheet !== undefined && leader.sheetPath !== undefined && leader.sheetPath !== ownSheet) {
|
||||
return;
|
||||
}
|
||||
const rect = leader.viewport;
|
||||
if (!rect) return;
|
||||
if (
|
||||
applied &&
|
||||
rect.cx === applied.cx &&
|
||||
rect.cy === applied.cy &&
|
||||
rect.halfW === applied.halfW &&
|
||||
rect.halfH === applied.halfH
|
||||
) {
|
||||
return; // leader republished an unchanged viewport (e.g. selection edit)
|
||||
}
|
||||
applied = rect;
|
||||
sawEcho = false;
|
||||
fit(rect.cx, rect.cy, rect.halfW, rect.halfH);
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
if (!target) return;
|
||||
target = null;
|
||||
applied = null;
|
||||
sawEcho = false;
|
||||
notify();
|
||||
};
|
||||
|
||||
const unsubscribePresence = presence.subscribe(() => applyLeader());
|
||||
|
||||
return {
|
||||
follow(t) {
|
||||
target = t;
|
||||
applied = null;
|
||||
sawEcho = false;
|
||||
clog("follow: following", t.name, "client", t.clientId);
|
||||
notify();
|
||||
applyLeader();
|
||||
},
|
||||
unfollow: stop,
|
||||
following: () => target,
|
||||
subscribe(cb) {
|
||||
subscribers.add(cb);
|
||||
return () => subscribers.delete(cb);
|
||||
},
|
||||
noteLocalViewport(vp) {
|
||||
if (!target || !applied) return;
|
||||
if (isEcho(vp, applied)) {
|
||||
sawEcho = true;
|
||||
return;
|
||||
}
|
||||
if (!sawEcho) return; // pre-fit stale emit — the fit hasn't landed yet
|
||||
clog("follow: local input — stopped following", target.name);
|
||||
stop();
|
||||
},
|
||||
destroy() {
|
||||
unsubscribePresence();
|
||||
subscribers.clear();
|
||||
target = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -41,6 +41,7 @@ function stubPresence(peers: PresencePeer[] = []) {
|
|||
},
|
||||
setCursor: vi.fn(),
|
||||
setSelection: vi.fn(),
|
||||
setViewport: vi.fn(),
|
||||
colorOf: vi.fn((id: string) => colorForUser(id)),
|
||||
destroy: vi.fn(),
|
||||
firePeers(p) {
|
||||
|
|
|
|||
|
|
@ -32,6 +32,9 @@ export interface PresenceKicadModule {
|
|||
/** 0007: tiebreak release — the local client lost an overlapping hold;
|
||||
* the wasm side cancels an in-flight move and unselects exactly these. */
|
||||
kicadCollabReleaseSelection?(uuidsJson: string, holder: string): void;
|
||||
/** 0008: fit a leader's world rect (center + half-extents, IU) into this
|
||||
* canvas — contain semantics. Absent on older wasm builds. */
|
||||
kicadCollabFitViewport?(cx: number, cy: number, halfW: number, halfH: number): void;
|
||||
}
|
||||
|
||||
export interface PresenceKicadWindow {
|
||||
|
|
@ -62,6 +65,20 @@ export function hasPresenceBridge(mod: unknown): mod is PresenceKicadModule {
|
|||
|
||||
const PUSH_THROTTLE_MS = 30;
|
||||
|
||||
// Viewport publishes ride awareness at their own (slower) trailing cadence —
|
||||
// a smooth pan fires onViewport per frame and peers only need follow-rate.
|
||||
const VIEWPORT_PUBLISH_MS = 100;
|
||||
|
||||
/** The follow-user world rect for a GAL transform: half-extents = half the
|
||||
* canvas in world units (scale = px per IU). Null while the frame is not up
|
||||
* (zero-sized canvas / no scale yet). */
|
||||
export function viewportRect(
|
||||
vp: ViewportState,
|
||||
): { cx: number; cy: number; halfW: number; halfH: number } | null {
|
||||
if (!(vp.scale > 0) || !(vp.w > 0) || !(vp.h > 0)) return null;
|
||||
return { cx: vp.cx, cy: vp.cy, halfW: vp.w / 2 / vp.scale, halfH: vp.h / 2 / vp.scale };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a C++ selection emit (0006): pcbnew emits `{uuids, fpPaths}` (paths =
|
||||
* the selected footprints' `GetPath()` strings), eeschema and older builds a
|
||||
|
|
@ -144,10 +161,37 @@ export function bindKicadPresence(opts: {
|
|||
presence.setCursor(active ? { x, y } : null);
|
||||
},
|
||||
onViewport: (cx, cy, scale, w, h) => {
|
||||
opts.onViewport?.({ cx, cy, scale, w, h });
|
||||
const vp = { cx, cy, scale, w, h };
|
||||
opts.onViewport?.(vp);
|
||||
scheduleViewportPublish(vp);
|
||||
},
|
||||
};
|
||||
|
||||
// Follow-user (0008): publish the visible world rect, trailing-throttled.
|
||||
let vpTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let vpLatest: ViewportState | undefined;
|
||||
const scheduleViewportPublish = (vp: ViewportState) => {
|
||||
vpLatest = vp;
|
||||
if (vpTimer) return;
|
||||
vpTimer = setTimeout(() => {
|
||||
vpTimer = undefined;
|
||||
// Guarded: pre-0008 handle implementations (and their test fakes) don't
|
||||
// have setViewport yet.
|
||||
if (vpLatest && typeof presence.setViewport === "function") {
|
||||
presence.setViewport(viewportRect(vpLatest));
|
||||
}
|
||||
}, VIEWPORT_PUBLISH_MS);
|
||||
};
|
||||
|
||||
// Seed the published viewport (pushes only happen on input events after
|
||||
// this) — same pull the comment layer uses.
|
||||
try {
|
||||
const vp = JSON.parse(mod.kicadCollabGetViewport() || "null") as ViewportState | null;
|
||||
if (vp && vp.w > 0) scheduleViewportPublish(vp);
|
||||
} catch {
|
||||
/* frame not up yet — the first input push seeds it */
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
|
@ -241,6 +285,8 @@ export function bindKicadPresence(opts: {
|
|||
unsubscribeCross?.();
|
||||
if (pushTimer) clearTimeout(pushTimer);
|
||||
pushTimer = undefined;
|
||||
if (vpTimer) clearTimeout(vpTimer);
|
||||
vpTimer = undefined;
|
||||
if (win.kicadCollab) {
|
||||
delete win.kicadCollab.onSelection;
|
||||
delete win.kicadCollab.onCursor;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
presenceStateSchema,
|
||||
type PresenceState,
|
||||
type PresenceUser,
|
||||
type PresenceViewport,
|
||||
} from "@pcbjam/shared";
|
||||
import { clog } from "./debug";
|
||||
|
||||
|
|
@ -45,6 +46,9 @@ export interface PresenceHandle {
|
|||
setCursor(pos: { x: number; y: number } | null): void;
|
||||
/** 0002: publish the local selection's KIID strings. */
|
||||
setSelection(uuids: string[]): void;
|
||||
/** 0008: publish the visible world rect (center + half-extents, IU) for
|
||||
* followers; null while unknown. */
|
||||
setViewport(rect: PresenceViewport | null): void;
|
||||
/** A user's current presence color: their published one if they're in the
|
||||
* room, our claimed one for ourselves, else the palette-hash fallback
|
||||
* (offline users, e.g. old comment authors). */
|
||||
|
|
@ -277,6 +281,9 @@ export function createPresence(opts: {
|
|||
setSelection(uuids) {
|
||||
patch({ selection: uuids });
|
||||
},
|
||||
setViewport(rect) {
|
||||
patch({ viewport: rect });
|
||||
},
|
||||
colorOf(userId) {
|
||||
if (userId === user.id) return user.color;
|
||||
for (const [clientId, raw] of awareness.getStates()) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue