feat(comments): figma-like comment pins + threads (collab-presence 0005)
Hybrid pins: the wasm draws the dot (kicadCollabSetPins rides the presence VIEW_OVERLAY, author color + white ring, drawn above selections; zero kicad-fork changes), the DOM owns interaction — - comments.ts: controller gluing the MIT kdoc_comments helpers to the editor: anchor resolution per tool IU (pins track item moves via kdoc_items observation), throttled pin snapshots, anchorAt nearest-item snap, jumpTo via new kicadCollabSetViewport; rebinds per sheet like presence. - CommentLayer.tsx: comment mode (click catcher + composer), pin hit targets over the GAL dots, thread popover (reply/edit/delete own, resolve/reopen, delete thread), panel with resolved filter + jump-to (popover centers when the pin is off-screen). Resolved pins drop figma-style. - WasmTool: controller lifecycle beside presence; live viewport feed; window.__pcbjamComments test handle (threads persist in the room ydoc). - e2e tests/web/comments.spec.ts: two-tab create → reply → resolve → panel filter → delete, passing vs real partykit; presence suites + collab units stay green; shared pointer bump (0004 model). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CvqUd4QsJSGHN28aunJRTq
This commit is contained in:
parent
6bc54de92b
commit
635aa2926a
8 changed files with 997 additions and 4 deletions
109
tests/web/comments.spec.ts
Normal file
109
tests/web/comments.spec.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
import { test, expect, type Page } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Figma-like comments e2e (collab-presence 0005): two tabs of the real React
|
||||||
|
* app on the SAME schematic share the comments map in the per-file Y.Doc.
|
||||||
|
* Flow: alice pins a comment via comment mode → bob sees the pin, opens the
|
||||||
|
* thread, replies → alice sees the reply live → resolve → delete clears both.
|
||||||
|
*
|
||||||
|
* Uses eeschema (demo.kicad_sch — the comment bridge ships in the merged
|
||||||
|
* kicad_editor bundle, so pcbnew/eeschema only; pl_editor has no pins).
|
||||||
|
* The GAL dot is drawn by the wasm; the assertions here drive the DOM halves
|
||||||
|
* (hit targets, composer, popover) which is what this layer owns.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SCOPE = 'default';
|
||||||
|
const ROUTE = 'demo.kicad_sch';
|
||||||
|
const TITLE = /demo — Schematic Editor/i;
|
||||||
|
|
||||||
|
async function bootAs(page: Page, user: string): Promise<void> {
|
||||||
|
await page.goto(`/${SCOPE}/projects/demo/${ROUTE}?user=${user}`);
|
||||||
|
await expect(page.locator('#canvas')).toBeVisible({ timeout: 120000 });
|
||||||
|
await expect
|
||||||
|
.poll(() => page.title(), {
|
||||||
|
message: `${user}: editor never reached the expected title`,
|
||||||
|
timeout: 120000,
|
||||||
|
intervals: [1000],
|
||||||
|
})
|
||||||
|
.toMatch(TITLE);
|
||||||
|
// The comment controls mount once the collab session + bridge are up.
|
||||||
|
await expect(page.getByTestId('comment-mode-toggle')).toBeVisible({ timeout: 30000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete every leftover thread from previous runs — comments PERSIST in the
|
||||||
|
* room's ydoc (that's the feature), so the spec must start from a clean slate.
|
||||||
|
* Uses the controller's test handle; the UI delete path is covered below. */
|
||||||
|
async function deleteAllThreads(page: Page): Promise<void> {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const ctl = (window as unknown as {
|
||||||
|
__pcbjamComments?: {
|
||||||
|
threads(): Array<{ id: string }>;
|
||||||
|
deleteThread(id: string): void;
|
||||||
|
};
|
||||||
|
}).__pcbjamComments;
|
||||||
|
ctl?.threads().forEach((t) => ctl.deleteThread(t.id));
|
||||||
|
});
|
||||||
|
await expect(page.getByTestId('comment-pin')).toHaveCount(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('comment lifecycle across two tabs: create → reply → resolve → delete', async ({
|
||||||
|
page,
|
||||||
|
context,
|
||||||
|
}) => {
|
||||||
|
test.setTimeout(360000); // two full tool boots
|
||||||
|
|
||||||
|
await bootAs(page, 'alice');
|
||||||
|
await deleteAllThreads(page);
|
||||||
|
const pageB = await context.newPage();
|
||||||
|
await bootAs(pageB, 'bob');
|
||||||
|
|
||||||
|
// ── create (alice): comment mode → click the canvas → composer → submit ──
|
||||||
|
await page.getByTestId('comment-mode-toggle').click();
|
||||||
|
const catcher = page.getByTestId('comment-click-catcher');
|
||||||
|
await expect(catcher).toBeVisible();
|
||||||
|
await catcher.click({ position: { x: 400, y: 250 } });
|
||||||
|
|
||||||
|
const composer = page.getByTestId('comment-composer');
|
||||||
|
await expect(composer).toBeVisible();
|
||||||
|
await composer.locator('textarea').fill('check this net');
|
||||||
|
await page.getByTestId('comment-submit').click();
|
||||||
|
|
||||||
|
// The thread popover opens on the fresh pin; the pin target exists.
|
||||||
|
await expect(page.getByTestId('comment-popover')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('comment-pin')).toHaveCount(1);
|
||||||
|
|
||||||
|
// ── bob sees the pin live, opens it, reads alice, replies ──
|
||||||
|
await expect(pageB.getByTestId('comment-pin')).toHaveCount(1, { timeout: 20000 });
|
||||||
|
await pageB.getByTestId('comment-pin').click();
|
||||||
|
await expect(pageB.getByTestId('comment-popover')).toBeVisible();
|
||||||
|
await expect(pageB.getByTestId('comment-message')).toContainText('check this net');
|
||||||
|
|
||||||
|
await pageB.getByTestId('comment-reply').fill('will fix');
|
||||||
|
await pageB.getByTestId('comment-reply').press('Enter');
|
||||||
|
|
||||||
|
// ── alice sees the reply live in her open popover ──
|
||||||
|
await expect(page.getByTestId('comment-message')).toHaveCount(2, { timeout: 20000 });
|
||||||
|
await expect(page.getByTestId('comment-message').nth(1)).toContainText('will fix');
|
||||||
|
|
||||||
|
// ── resolve (alice) → bob's popover flips to resolved ──
|
||||||
|
await page.getByTestId('comment-resolve').click();
|
||||||
|
await expect(pageB.getByTestId('comment-popover')).toContainText('resolved', {
|
||||||
|
timeout: 20000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Resolved pins hide from the default (unresolved) pin set in both tabs…
|
||||||
|
await expect(page.getByTestId('comment-pin')).toHaveCount(0, { timeout: 20000 });
|
||||||
|
// …but stay reachable via the panel's "resolved" filter.
|
||||||
|
await page.getByTestId('comment-panel-toggle').click();
|
||||||
|
await page.getByTestId('comment-show-resolved').check();
|
||||||
|
await expect(page.getByTestId('comment-panel-item')).toHaveCount(1);
|
||||||
|
|
||||||
|
// ── delete (alice, thread author) clears the pin everywhere ──
|
||||||
|
await page.getByTestId('comment-panel-item').click();
|
||||||
|
await expect(page.getByTestId('comment-popover')).toBeVisible();
|
||||||
|
await page.getByTestId('comment-delete-thread').click();
|
||||||
|
|
||||||
|
await expect(page.getByTestId('comment-panel-item')).toHaveCount(0, { timeout: 20000 });
|
||||||
|
await expect(pageB.getByTestId('comment-pin')).toHaveCount(0, { timeout: 20000 });
|
||||||
|
await pageB.close();
|
||||||
|
});
|
||||||
|
|
@ -701,7 +701,18 @@ struct PEER
|
||||||
std::vector<KIID> selection;
|
std::vector<KIID> selection;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Comment pin dot (collab-presence 0005): the GAL half of the hybrid pin —
|
||||||
|
// zero pan/zoom lag; the clickable hit target + thread popover are DOM.
|
||||||
|
struct PIN
|
||||||
|
{
|
||||||
|
std::string id;
|
||||||
|
VECTOR2D pos; // world coords (IU)
|
||||||
|
KIGFX::COLOR4D color;
|
||||||
|
bool resolved = false;
|
||||||
|
};
|
||||||
|
|
||||||
std::vector<PEER> g_peers;
|
std::vector<PEER> g_peers;
|
||||||
|
std::vector<PIN> g_pins;
|
||||||
std::shared_ptr<KIGFX::VIEW_OVERLAY> g_overlay;
|
std::shared_ptr<KIGFX::VIEW_OVERLAY> g_overlay;
|
||||||
bool g_started = false;
|
bool g_started = false;
|
||||||
bool g_redrawScheduled = false;
|
bool g_redrawScheduled = false;
|
||||||
|
|
@ -835,6 +846,18 @@ void redrawOverlay()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Comment pin dots (0005): filled circle in the author's color with a white
|
||||||
|
// ring; resolved pins dim. Drawn last so they sit above selection outlines.
|
||||||
|
for( const PIN& pin : g_pins )
|
||||||
|
{
|
||||||
|
g_overlay->SetIsStroke( true );
|
||||||
|
g_overlay->SetIsFill( true );
|
||||||
|
g_overlay->SetFillColor( pin.resolved ? pin.color.WithAlpha( 0.3 ) : pin.color );
|
||||||
|
g_overlay->SetStrokeColor( KIGFX::COLOR4D( 1, 1, 1, pin.resolved ? 0.35 : 0.9 ) );
|
||||||
|
g_overlay->SetLineWidth( 1.5 * px );
|
||||||
|
g_overlay->Circle( pin.pos, 7 * px );
|
||||||
|
}
|
||||||
|
|
||||||
view->Update( g_overlay.get() );
|
view->Update( g_overlay.get() );
|
||||||
fr->GetCanvas()->ForceRefresh();
|
fr->GetCanvas()->ForceRefresh();
|
||||||
}
|
}
|
||||||
|
|
@ -1587,6 +1610,51 @@ void schCollabSetRemote( std::string aJson )
|
||||||
presence::scheduleRedraw();
|
presence::scheduleRedraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// JS → C++ (collab-presence 0005): comment pin dots (same wire as pcbnew).
|
||||||
|
void schCollabSetPins( std::string aJson )
|
||||||
|
{
|
||||||
|
json j = json::parse( aJson, nullptr, /*allow_exceptions*/ false );
|
||||||
|
|
||||||
|
if( j.is_discarded() )
|
||||||
|
return;
|
||||||
|
|
||||||
|
std::vector<presence::PIN> pins;
|
||||||
|
|
||||||
|
for( const json& p : j.value( "pins", json::array() ) )
|
||||||
|
{
|
||||||
|
presence::PIN pin;
|
||||||
|
pin.id = p.value( "id", "" );
|
||||||
|
pin.pos = VECTOR2D( p.value( "x", 0.0 ), p.value( "y", 0.0 ) );
|
||||||
|
pin.color = presence::parseColor( p.value( "color", "" ) );
|
||||||
|
pin.resolved = p.value( "resolved", false );
|
||||||
|
pins.push_back( std::move( pin ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
presence::g_pins = std::move( pins );
|
||||||
|
schCollabPresenceStart();
|
||||||
|
presence::scheduleRedraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
// JS → C++ (0005): pan the view to a world position (comment panel "jump to pin").
|
||||||
|
void schCollabSetViewport( double aCx, double aCy )
|
||||||
|
{
|
||||||
|
SCH_EDIT_FRAME* fr = schFrame();
|
||||||
|
|
||||||
|
if( !fr )
|
||||||
|
return;
|
||||||
|
|
||||||
|
fr->CallAfter( [fr, aCx, aCy]() {
|
||||||
|
COROUTINE<int, int> cor( [fr, aCx, aCy]( int ) -> int
|
||||||
|
{
|
||||||
|
fr->GetCanvas()->GetView()->SetCenter( VECTOR2D( aCx, aCy ) );
|
||||||
|
fr->GetCanvas()->ForceRefresh();
|
||||||
|
presence::emitViewportIfChanged();
|
||||||
|
return 0;
|
||||||
|
} );
|
||||||
|
cor.Call( 0 );
|
||||||
|
} );
|
||||||
|
}
|
||||||
|
|
||||||
// JS pull of the current viewport transform: `{cx,cy,scale,w,h}` with scale = px per
|
// 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).
|
// IU via the GAL matrix (GetScale() is the zoom, not px/IU — pcbnew 0002 lesson).
|
||||||
std::string schCollabGetViewport()
|
std::string schCollabGetViewport()
|
||||||
|
|
@ -1735,6 +1803,8 @@ EMSCRIPTEN_BINDINGS(eeschema) {
|
||||||
// Presence (collab-presence 0003) — shared names with pcbnew's 0002 set.
|
// Presence (collab-presence 0003) — shared names with pcbnew's 0002 set.
|
||||||
function("kicadCollabPresenceStart", &schCollabPresenceStart);
|
function("kicadCollabPresenceStart", &schCollabPresenceStart);
|
||||||
function("kicadCollabSetRemote", &schCollabSetRemote);
|
function("kicadCollabSetRemote", &schCollabSetRemote);
|
||||||
|
function("kicadCollabSetPins", &schCollabSetPins);
|
||||||
|
function("kicadCollabSetViewport", &schCollabSetViewport);
|
||||||
function("kicadCollabGetViewport", &schCollabGetViewport);
|
function("kicadCollabGetViewport", &schCollabGetViewport);
|
||||||
function("kicadCollabGetSelection", &schCollabGetSelection);
|
function("kicadCollabGetSelection", &schCollabGetSelection);
|
||||||
function("kicadCollabTestSelectFirst", &schCollabTestSelectFirst);
|
function("kicadCollabTestSelectFirst", &schCollabTestSelectFirst);
|
||||||
|
|
|
||||||
|
|
@ -45,9 +45,11 @@ std::string pcbCollabTestMoveFirst( int aDx, int aDy );
|
||||||
std::string pcbCollabGetPos( std::string aId );
|
std::string pcbCollabGetPos( std::string aId );
|
||||||
bool pcbCollabTestRemoveItem( std::string aId );
|
bool pcbCollabTestRemoveItem( std::string aId );
|
||||||
bool pcbCollabTestRotateItem( std::string aId, double aDeg );
|
bool pcbCollabTestRotateItem( std::string aId, double aDeg );
|
||||||
// Presence (collab-presence 0002) — pcbnew only until 0003 adds the sch side.
|
// Presence (collab-presence 0002) + comment pins/panning (0005).
|
||||||
void pcbCollabPresenceStart();
|
void pcbCollabPresenceStart();
|
||||||
void pcbCollabSetRemote( std::string aJson );
|
void pcbCollabSetRemote( std::string aJson );
|
||||||
|
void pcbCollabSetPins( std::string aJson );
|
||||||
|
void pcbCollabSetViewport( double aCx, double aCy );
|
||||||
std::string pcbCollabGetViewport();
|
std::string pcbCollabGetViewport();
|
||||||
std::string pcbCollabGetSelection();
|
std::string pcbCollabGetSelection();
|
||||||
std::string pcbCollabTestSelectFirst();
|
std::string pcbCollabTestSelectFirst();
|
||||||
|
|
@ -62,9 +64,11 @@ std::string schCollabTestMoveFirst( int aDx, int aDy );
|
||||||
std::string schCollabGetPos( std::string aId );
|
std::string schCollabGetPos( std::string aId );
|
||||||
bool schCollabTestRemoveItem( std::string aId );
|
bool schCollabTestRemoveItem( std::string aId );
|
||||||
bool schCollabTestRotateItem( std::string aId, double aDeg );
|
bool schCollabTestRotateItem( std::string aId, double aDeg );
|
||||||
// Presence (collab-presence 0003 — eeschema counterparts of the 0002 set).
|
// Presence (collab-presence 0003 — eeschema counterparts) + pins (0005).
|
||||||
void schCollabPresenceStart();
|
void schCollabPresenceStart();
|
||||||
void schCollabSetRemote( std::string aJson );
|
void schCollabSetRemote( std::string aJson );
|
||||||
|
void schCollabSetPins( std::string aJson );
|
||||||
|
void schCollabSetViewport( double aCx, double aCy );
|
||||||
std::string schCollabGetViewport();
|
std::string schCollabGetViewport();
|
||||||
std::string schCollabGetSelection();
|
std::string schCollabGetSelection();
|
||||||
std::string schCollabTestSelectFirst();
|
std::string schCollabTestSelectFirst();
|
||||||
|
|
@ -159,6 +163,16 @@ static void collabSetRemote( std::string aJson )
|
||||||
pcbEditorActive() ? pcbCollabSetRemote( aJson ) : schCollabSetRemote( aJson );
|
pcbEditorActive() ? pcbCollabSetRemote( aJson ) : schCollabSetRemote( aJson );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void collabSetPins( std::string aJson )
|
||||||
|
{
|
||||||
|
pcbEditorActive() ? pcbCollabSetPins( aJson ) : schCollabSetPins( aJson );
|
||||||
|
}
|
||||||
|
|
||||||
|
static void collabSetViewport( double aCx, double aCy )
|
||||||
|
{
|
||||||
|
pcbEditorActive() ? pcbCollabSetViewport( aCx, aCy ) : schCollabSetViewport( aCx, aCy );
|
||||||
|
}
|
||||||
|
|
||||||
static std::string collabGetViewport()
|
static std::string collabGetViewport()
|
||||||
{
|
{
|
||||||
return pcbEditorActive() ? pcbCollabGetViewport() : schCollabGetViewport();
|
return pcbEditorActive() ? pcbCollabGetViewport() : schCollabGetViewport();
|
||||||
|
|
@ -196,9 +210,11 @@ EMSCRIPTEN_BINDINGS(kicad_editor) {
|
||||||
// endpoint, field text — flow from the per-editor blocks unchanged).
|
// endpoint, field text — flow from the per-editor blocks unchanged).
|
||||||
function("kicadCollabTestRemoveItem", &collabTestRemoveItem);
|
function("kicadCollabTestRemoveItem", &collabTestRemoveItem);
|
||||||
function("kicadCollabTestRotateItem", &collabTestRotateItem);
|
function("kicadCollabTestRotateItem", &collabTestRotateItem);
|
||||||
// Presence (collab-presence 0002).
|
// Presence (collab-presence 0002/0003) + comment pins/panning (0005).
|
||||||
function("kicadCollabPresenceStart", &collabPresenceStart);
|
function("kicadCollabPresenceStart", &collabPresenceStart);
|
||||||
function("kicadCollabSetRemote", &collabSetRemote);
|
function("kicadCollabSetRemote", &collabSetRemote);
|
||||||
|
function("kicadCollabSetPins", &collabSetPins);
|
||||||
|
function("kicadCollabSetViewport", &collabSetViewport);
|
||||||
function("kicadCollabGetViewport", &collabGetViewport);
|
function("kicadCollabGetViewport", &collabGetViewport);
|
||||||
function("kicadCollabGetSelection", &collabGetSelection);
|
function("kicadCollabGetSelection", &collabGetSelection);
|
||||||
function("kicadCollabTestSelectFirst", &collabTestSelectFirst);
|
function("kicadCollabTestSelectFirst", &collabTestSelectFirst);
|
||||||
|
|
|
||||||
|
|
@ -1106,7 +1106,19 @@ struct PEER
|
||||||
std::vector<KIID> selection;
|
std::vector<KIID> selection;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Comment pin dot (collab-presence 0005): the GAL half of the hybrid pin —
|
||||||
|
// zero pan/zoom lag; the clickable hit target + thread popover are DOM,
|
||||||
|
// positioned via the exported viewport transform.
|
||||||
|
struct PIN
|
||||||
|
{
|
||||||
|
std::string id;
|
||||||
|
VECTOR2D pos; // world coords (IU)
|
||||||
|
KIGFX::COLOR4D color;
|
||||||
|
bool resolved = false;
|
||||||
|
};
|
||||||
|
|
||||||
std::vector<PEER> g_peers;
|
std::vector<PEER> g_peers;
|
||||||
|
std::vector<PIN> g_pins;
|
||||||
std::shared_ptr<KIGFX::VIEW_OVERLAY> g_overlay;
|
std::shared_ptr<KIGFX::VIEW_OVERLAY> g_overlay;
|
||||||
bool g_started = false;
|
bool g_started = false;
|
||||||
bool g_redrawScheduled = false;
|
bool g_redrawScheduled = false;
|
||||||
|
|
@ -1247,6 +1259,18 @@ void redrawOverlay()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Comment pin dots (0005): filled circle in the author's color with a white
|
||||||
|
// ring; resolved pins dim. Drawn last so they sit above selection outlines.
|
||||||
|
for( const PIN& pin : g_pins )
|
||||||
|
{
|
||||||
|
g_overlay->SetIsStroke( true );
|
||||||
|
g_overlay->SetIsFill( true );
|
||||||
|
g_overlay->SetFillColor( pin.resolved ? pin.color.WithAlpha( 0.3 ) : pin.color );
|
||||||
|
g_overlay->SetStrokeColor( KIGFX::COLOR4D( 1, 1, 1, pin.resolved ? 0.35 : 0.9 ) );
|
||||||
|
g_overlay->SetLineWidth( 1.5 * px );
|
||||||
|
g_overlay->Circle( pin.pos, 7 * px );
|
||||||
|
}
|
||||||
|
|
||||||
view->Update( g_overlay.get() );
|
view->Update( g_overlay.get() );
|
||||||
// The canvas repaints on its own only with focus/input — force it, exactly as
|
// The canvas repaints on its own only with focus/input — force it, exactly as
|
||||||
// the cross-probe flash does.
|
// the cross-probe flash does.
|
||||||
|
|
@ -1660,6 +1684,54 @@ void pcbCollabSetRemote( std::string aJson )
|
||||||
presence::scheduleRedraw();
|
presence::scheduleRedraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// JS → C++ (collab-presence 0005): comment pin dots — `{pins:[{id,x,y,color,
|
||||||
|
// resolved}]}`, world IU coords resolved by the TS side from the ydoc anchors.
|
||||||
|
// Snapshot semantics like SetRemote: cleared + fully redrawn each push.
|
||||||
|
void pcbCollabSetPins( std::string aJson )
|
||||||
|
{
|
||||||
|
json j = json::parse( aJson, nullptr, /*allow_exceptions*/ false );
|
||||||
|
|
||||||
|
if( j.is_discarded() )
|
||||||
|
return;
|
||||||
|
|
||||||
|
std::vector<presence::PIN> pins;
|
||||||
|
|
||||||
|
for( const json& p : j.value( "pins", json::array() ) )
|
||||||
|
{
|
||||||
|
presence::PIN pin;
|
||||||
|
pin.id = p.value( "id", "" );
|
||||||
|
pin.pos = VECTOR2D( p.value( "x", 0.0 ), p.value( "y", 0.0 ) );
|
||||||
|
pin.color = presence::parseColor( p.value( "color", "" ) );
|
||||||
|
pin.resolved = p.value( "resolved", false );
|
||||||
|
pins.push_back( std::move( pin ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
presence::g_pins = std::move( pins );
|
||||||
|
pcbCollabPresenceStart();
|
||||||
|
presence::scheduleRedraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
// JS → C++ (0005): pan the view to a world position (comment panel "jump to
|
||||||
|
// pin"). CallAfter + COROUTINE like every other view mutation from JS.
|
||||||
|
void pcbCollabSetViewport( double aCx, double aCy )
|
||||||
|
{
|
||||||
|
PCB_EDIT_FRAME* fr = pcbFrame();
|
||||||
|
|
||||||
|
if( !fr )
|
||||||
|
return;
|
||||||
|
|
||||||
|
fr->CallAfter( [fr, aCx, aCy]() {
|
||||||
|
COROUTINE<int, int> cor( [fr, aCx, aCy]( int ) -> int
|
||||||
|
{
|
||||||
|
fr->GetCanvas()->GetView()->SetCenter( VECTOR2D( aCx, aCy ) );
|
||||||
|
fr->GetCanvas()->ForceRefresh();
|
||||||
|
presence::emitViewportIfChanged();
|
||||||
|
return 0;
|
||||||
|
} );
|
||||||
|
cor.Call( 0 );
|
||||||
|
} );
|
||||||
|
}
|
||||||
|
|
||||||
// JS pull of the current viewport transform (world↔screen mapping for the DOM layer):
|
// 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.
|
// `{cx,cy,scale,w,h}` — world center, pixels-per-IU scale, canvas size in px.
|
||||||
std::string pcbCollabGetViewport()
|
std::string pcbCollabGetViewport()
|
||||||
|
|
@ -1989,6 +2061,8 @@ EMSCRIPTEN_BINDINGS(pcbnew) {
|
||||||
// with 0003 (the merged image dispatches pcb-only until then).
|
// with 0003 (the merged image dispatches pcb-only until then).
|
||||||
function("kicadCollabPresenceStart", &pcbCollabPresenceStart);
|
function("kicadCollabPresenceStart", &pcbCollabPresenceStart);
|
||||||
function("kicadCollabSetRemote", &pcbCollabSetRemote);
|
function("kicadCollabSetRemote", &pcbCollabSetRemote);
|
||||||
|
function("kicadCollabSetPins", &pcbCollabSetPins);
|
||||||
|
function("kicadCollabSetViewport", &pcbCollabSetViewport);
|
||||||
function("kicadCollabGetViewport", &pcbCollabGetViewport);
|
function("kicadCollabGetViewport", &pcbCollabGetViewport);
|
||||||
function("kicadCollabGetSelection", &pcbCollabGetSelection);
|
function("kicadCollabGetSelection", &pcbCollabGetSelection);
|
||||||
function("kicadCollabTestSelectFirst", &pcbCollabTestSelectFirst);
|
function("kicadCollabTestSelectFirst", &pcbCollabTestSelectFirst);
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
Subproject commit a99d1dfff92d1e8e7a77d39dc920c4db7e955208
|
Subproject commit b83ad94017531fef359c0478e616ab269d1234d0
|
||||||
435
web/standalone/src/components/CommentLayer.tsx
Normal file
435
web/standalone/src/components/CommentLayer.tsx
Normal file
|
|
@ -0,0 +1,435 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import { colorForUser, type CommentAnchor } from "@pcbjam/shared";
|
||||||
|
import { MessageSquarePlus, MessageSquareText, X } from "lucide-react";
|
||||||
|
import {
|
||||||
|
screenToWorld,
|
||||||
|
worldToScreen,
|
||||||
|
type CommentsController,
|
||||||
|
type ResolvedThread,
|
||||||
|
type ViewportState,
|
||||||
|
} from "@/wasm/collab/comments";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DOM half of the hybrid comment pins (collab-presence 0005): the GAL overlay
|
||||||
|
* draws the dots (zero pan/zoom lag); this layer adds what DOM does better —
|
||||||
|
* invisible click targets over each dot, the thread popover (read/reply/edit/
|
||||||
|
* resolve/delete), the comment-mode click catcher + composer, and a small
|
||||||
|
* list panel with jump-to. Positions map world→canvas-px via the exported
|
||||||
|
* viewport transform, then canvas-px→CSS via the GAL panel's bounding rect
|
||||||
|
* (`#glcanvas-*` — `#canvas` is the whole wx window). A ≤1-frame skew against
|
||||||
|
* the GAL dot during pan is accepted; it only affects the hit target.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface CssRect {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function glCanvasRect(): CssRect | null {
|
||||||
|
const el = Array.from(document.querySelectorAll('[id^="glcanvas-"]')).find((c) => {
|
||||||
|
const r = (c as HTMLElement).getBoundingClientRect();
|
||||||
|
return getComputedStyle(c as HTMLElement).display !== "none" && r.width > 0;
|
||||||
|
}) as HTMLElement | undefined;
|
||||||
|
|
||||||
|
if (!el) return null;
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
return { x: r.x, y: r.y, width: r.width, height: r.height };
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeAgo(ms: number): string {
|
||||||
|
const s = Math.max(0, (Date.now() - ms) / 1000);
|
||||||
|
if (s < 60) return "now";
|
||||||
|
if (s < 3600) return `${Math.floor(s / 60)}m`;
|
||||||
|
if (s < 86400) return `${Math.floor(s / 3600)}h`;
|
||||||
|
return `${Math.floor(s / 86400)}d`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommentLayer({
|
||||||
|
controller,
|
||||||
|
viewport,
|
||||||
|
currentUser,
|
||||||
|
}: {
|
||||||
|
controller: CommentsController;
|
||||||
|
viewport: ViewportState | null;
|
||||||
|
currentUser: string;
|
||||||
|
}) {
|
||||||
|
const [threads, setThreads] = React.useState<ResolvedThread[]>(controller.threads());
|
||||||
|
const [mode, setMode] = React.useState(false);
|
||||||
|
const [openId, setOpenId] = React.useState<string | null>(null);
|
||||||
|
const [panel, setPanel] = React.useState(false);
|
||||||
|
const [showResolved, setShowResolved] = React.useState(false);
|
||||||
|
const [draft, setDraft] = React.useState<{ anchor: CommentAnchor; css: { x: number; y: number } } | null>(null);
|
||||||
|
// The GAL panel's CSS rect — re-measured on viewport pushes + window resize.
|
||||||
|
const [glRect, setGlRect] = React.useState<CssRect | null>(null);
|
||||||
|
|
||||||
|
// Re-seed on controller rebind (eeschema sheet switch swaps the controller;
|
||||||
|
// the useState initializer only covers the first mount).
|
||||||
|
React.useEffect(() => {
|
||||||
|
setThreads(controller.threads());
|
||||||
|
setOpenId(null);
|
||||||
|
setDraft(null);
|
||||||
|
return controller.subscribe(setThreads);
|
||||||
|
}, [controller]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const measure = () => setGlRect(glCanvasRect());
|
||||||
|
measure();
|
||||||
|
window.addEventListener("resize", measure);
|
||||||
|
return () => window.removeEventListener("resize", measure);
|
||||||
|
}, [viewport]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
setMode(false);
|
||||||
|
setDraft(null);
|
||||||
|
setOpenId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const cssRatio = viewport && glRect ? glRect.width / viewport.w : 1;
|
||||||
|
|
||||||
|
const toCss = (world: { x: number; y: number }) => {
|
||||||
|
if (!viewport || !glRect) return null;
|
||||||
|
const px = worldToScreen(viewport, world);
|
||||||
|
const x = glRect.x + px.x * cssRatio;
|
||||||
|
const y = glRect.y + px.y * cssRatio;
|
||||||
|
if (x < glRect.x - 20 || x > glRect.x + glRect.width + 20) return null;
|
||||||
|
if (y < glRect.y - 20 || y > glRect.y + glRect.height + 20) return null;
|
||||||
|
return { x, y };
|
||||||
|
};
|
||||||
|
|
||||||
|
const onModeClick = (e: React.MouseEvent) => {
|
||||||
|
if (!viewport || !glRect) return;
|
||||||
|
const px = {
|
||||||
|
x: (e.clientX - glRect.x) / cssRatio,
|
||||||
|
y: (e.clientY - glRect.y) / cssRatio,
|
||||||
|
};
|
||||||
|
const world = screenToWorld(viewport, px);
|
||||||
|
// Snap to an item within ~14 CSS px so the pin tracks the obvious target.
|
||||||
|
const maxDistIu = 14 / (viewport.scale * cssRatio);
|
||||||
|
setDraft({ anchor: controller.anchorAt(world, maxDistIu), css: { x: e.clientX, y: e.clientY } });
|
||||||
|
setMode(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const open = openId ? threads.find((t) => t.id === openId) : undefined;
|
||||||
|
// A thread opened from the panel may sit off-screen (jump-to clamps at the
|
||||||
|
// view bounds) — fall back to a centered popover rather than rendering none.
|
||||||
|
const openCss = open
|
||||||
|
? (toCss(open.world) ?? { x: window.innerWidth / 2 - 150, y: 120 })
|
||||||
|
: null;
|
||||||
|
const visibleThreads = threads.filter((t) => showResolved || !t.resolved);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Mode + panel toggles, below the top-right chips. */}
|
||||||
|
<div className="absolute right-3 top-12 z-30 flex flex-col items-end gap-2">
|
||||||
|
<button
|
||||||
|
data-testid="comment-mode-toggle"
|
||||||
|
title={mode ? "Cancel comment (Esc)" : "Add a comment"}
|
||||||
|
onClick={() => {
|
||||||
|
setMode((m) => !m);
|
||||||
|
setDraft(null);
|
||||||
|
}}
|
||||||
|
className={`flex h-8 w-8 items-center justify-center rounded-full shadow-sm ring-1 ring-inset ring-white/20 ${
|
||||||
|
mode ? "bg-amber-500 text-black" : "bg-black/70 text-white hover:bg-black/85"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<MessageSquarePlus size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
data-testid="comment-panel-toggle"
|
||||||
|
title="Comments"
|
||||||
|
onClick={() => setPanel((p) => !p)}
|
||||||
|
className="flex h-8 min-w-8 items-center justify-center gap-1 rounded-full bg-black/70 px-2 text-xs text-white shadow-sm ring-1 ring-inset ring-white/20 hover:bg-black/85"
|
||||||
|
>
|
||||||
|
<MessageSquareText size={14} />
|
||||||
|
{threads.length > 0 && <span>{threads.length}</span>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Comment-mode click catcher over the drawing area only. */}
|
||||||
|
{mode && glRect && (
|
||||||
|
<div
|
||||||
|
data-testid="comment-click-catcher"
|
||||||
|
className="absolute z-30 cursor-crosshair"
|
||||||
|
style={{ left: glRect.x, top: glRect.y, width: glRect.width, height: glRect.height }}
|
||||||
|
onClick={onModeClick}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pin hit targets (the visual dot is GAL — these are the clickable DOM halves). */}
|
||||||
|
{visibleThreads.map((t) => {
|
||||||
|
const css = toCss(t.world);
|
||||||
|
if (!css) return null;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
data-testid="comment-pin"
|
||||||
|
data-thread-id={t.id}
|
||||||
|
title={`${t.createdBy}: ${t.messages[0]?.body ?? ""}${t.detached ? " (detached)" : ""}`}
|
||||||
|
onClick={() => setOpenId((cur) => (cur === t.id ? null : t.id))}
|
||||||
|
className="absolute z-30 -translate-x-1/2 -translate-y-1/2 rounded-full hover:ring-2 hover:ring-white/70"
|
||||||
|
style={{ left: css.x, top: css.y, width: 22, height: 22, background: "transparent" }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* New-comment composer at the clicked point. */}
|
||||||
|
{draft && (
|
||||||
|
<Composer
|
||||||
|
css={draft.css}
|
||||||
|
onCancel={() => setDraft(null)}
|
||||||
|
onSubmit={(body) => {
|
||||||
|
const id = controller.create(draft.anchor, body);
|
||||||
|
setDraft(null);
|
||||||
|
setOpenId(id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Thread popover next to its pin. */}
|
||||||
|
{open && openCss && (
|
||||||
|
<ThreadPopover
|
||||||
|
thread={open}
|
||||||
|
css={openCss}
|
||||||
|
currentUser={currentUser}
|
||||||
|
controller={controller}
|
||||||
|
onClose={() => setOpenId(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Threads panel (filter + jump-to). */}
|
||||||
|
{panel && (
|
||||||
|
<div className="absolute right-3 top-24 z-30 flex max-h-[60vh] w-72 flex-col overflow-hidden rounded-lg bg-black/85 text-white shadow-lg ring-1 ring-inset ring-white/20">
|
||||||
|
<div className="flex items-center justify-between px-3 py-2 text-xs font-semibold">
|
||||||
|
<span>Comments ({visibleThreads.length})</span>
|
||||||
|
<label className="flex items-center gap-1 font-normal text-white/70">
|
||||||
|
<input
|
||||||
|
data-testid="comment-show-resolved"
|
||||||
|
type="checkbox"
|
||||||
|
checked={showResolved}
|
||||||
|
onChange={(e) => setShowResolved(e.target.checked)}
|
||||||
|
/>
|
||||||
|
resolved
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-y-auto">
|
||||||
|
{visibleThreads.length === 0 && (
|
||||||
|
<p className="px-3 pb-3 text-xs text-white/50">No comments yet.</p>
|
||||||
|
)}
|
||||||
|
{visibleThreads.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
data-testid="comment-panel-item"
|
||||||
|
onClick={() => {
|
||||||
|
controller.jumpTo(t.id);
|
||||||
|
setOpenId(t.id);
|
||||||
|
}}
|
||||||
|
className="block w-full border-t border-white/10 px-3 py-2 text-left text-xs hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<span className="font-semibold" style={{ color: colorForUser(t.createdBy) }}>
|
||||||
|
{t.createdBy}
|
||||||
|
</span>{" "}
|
||||||
|
<span className="text-white/50">
|
||||||
|
{timeAgo(t.createdAt)} ago{t.resolved ? " · resolved" : ""}
|
||||||
|
</span>
|
||||||
|
<span className="mt-0.5 block truncate text-white/90">
|
||||||
|
{t.messages[0]?.body ?? ""}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Composer({
|
||||||
|
css,
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
css: { x: number; y: number };
|
||||||
|
onSubmit: (body: string) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}) {
|
||||||
|
const [body, setBody] = React.useState("");
|
||||||
|
const submit = () => {
|
||||||
|
if (body.trim()) onSubmit(body.trim());
|
||||||
|
else onCancel();
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="comment-composer"
|
||||||
|
className="absolute z-40 w-64 rounded-lg bg-black/90 p-2 shadow-lg ring-1 ring-inset ring-white/20"
|
||||||
|
style={{ left: Math.min(css.x + 12, window.innerWidth - 280), top: Math.min(css.y, window.innerHeight - 120) }}
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
autoFocus
|
||||||
|
value={body}
|
||||||
|
onChange={(e) => setBody(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
submit();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Add a comment…"
|
||||||
|
className="h-16 w-full resize-none rounded bg-white/10 p-2 text-xs text-white placeholder-white/40 outline-none"
|
||||||
|
/>
|
||||||
|
<div className="mt-1 flex justify-end gap-2">
|
||||||
|
<button onClick={onCancel} className="rounded px-2 py-1 text-xs text-white/70 hover:bg-white/10">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
data-testid="comment-submit"
|
||||||
|
onClick={submit}
|
||||||
|
className="rounded bg-sky-600 px-2 py-1 text-xs text-white hover:bg-sky-500"
|
||||||
|
>
|
||||||
|
Comment
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ThreadPopover({
|
||||||
|
thread,
|
||||||
|
css,
|
||||||
|
currentUser,
|
||||||
|
controller,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
thread: ResolvedThread;
|
||||||
|
css: { x: number; y: number };
|
||||||
|
currentUser: string;
|
||||||
|
controller: CommentsController;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [reply, setReply] = React.useState("");
|
||||||
|
const [editing, setEditing] = React.useState<{ id: string; body: string } | null>(null);
|
||||||
|
|
||||||
|
const sendReply = () => {
|
||||||
|
if (reply.trim()) {
|
||||||
|
controller.reply(thread.id, reply.trim());
|
||||||
|
setReply("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="comment-popover"
|
||||||
|
className="absolute z-40 w-72 rounded-lg bg-black/90 text-white shadow-lg ring-1 ring-inset ring-white/20"
|
||||||
|
style={{
|
||||||
|
left: Math.min(css.x + 16, window.innerWidth - 300),
|
||||||
|
top: Math.min(css.y - 8, window.innerHeight - 260),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between px-3 py-2">
|
||||||
|
<span className="text-xs text-white/60">
|
||||||
|
{thread.detached && "detached pin · "}
|
||||||
|
{thread.resolved ? "resolved" : "open"}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
data-testid="comment-resolve"
|
||||||
|
onClick={() => controller.setResolved(thread.id, !thread.resolved)}
|
||||||
|
className="rounded px-1.5 py-0.5 text-[11px] text-white/80 ring-1 ring-inset ring-white/25 hover:bg-white/10"
|
||||||
|
>
|
||||||
|
{thread.resolved ? "Reopen" : "Resolve"}
|
||||||
|
</button>
|
||||||
|
{thread.createdBy === currentUser && (
|
||||||
|
<button
|
||||||
|
data-testid="comment-delete-thread"
|
||||||
|
title="Delete thread"
|
||||||
|
onClick={() => {
|
||||||
|
controller.deleteThread(thread.id);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
className="rounded px-1.5 py-0.5 text-[11px] text-red-300 ring-1 ring-inset ring-red-400/40 hover:bg-red-500/20"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button onClick={onClose} title="Close" className="text-white/60 hover:text-white">
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-56 overflow-y-auto border-t border-white/10">
|
||||||
|
{thread.messages.map((m) => (
|
||||||
|
<div key={m.id} data-testid="comment-message" className="group px-3 py-2 text-xs">
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<span className="font-semibold" style={{ color: colorForUser(m.author) }}>
|
||||||
|
{m.author}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-white/40">
|
||||||
|
{timeAgo(m.createdAt)} ago{m.editedAt ? " · edited" : ""}
|
||||||
|
</span>
|
||||||
|
{m.author === currentUser && (
|
||||||
|
<span className="ml-auto hidden gap-1 group-hover:flex">
|
||||||
|
<button
|
||||||
|
data-testid="comment-edit"
|
||||||
|
onClick={() => setEditing({ id: m.id, body: m.body })}
|
||||||
|
className="text-[10px] text-white/60 hover:text-white"
|
||||||
|
>
|
||||||
|
edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
data-testid="comment-remove"
|
||||||
|
onClick={() => {
|
||||||
|
if (controller.remove(thread.id, m.id) === "thread-deleted") onClose();
|
||||||
|
}}
|
||||||
|
className="text-[10px] text-red-300/80 hover:text-red-300"
|
||||||
|
>
|
||||||
|
delete
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{editing?.id === m.id ? (
|
||||||
|
<div className="mt-1">
|
||||||
|
<textarea
|
||||||
|
autoFocus
|
||||||
|
value={editing.body}
|
||||||
|
onChange={(e) => setEditing({ id: m.id, body: e.target.value })}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
controller.edit(thread.id, m.id, editing.body.trim());
|
||||||
|
setEditing(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="h-12 w-full resize-none rounded bg-white/10 p-1.5 text-xs text-white outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="mt-0.5 whitespace-pre-wrap text-white/90">{m.body}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-white/10 p-2">
|
||||||
|
<input
|
||||||
|
data-testid="comment-reply"
|
||||||
|
value={reply}
|
||||||
|
onChange={(e) => setReply(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") sendReply();
|
||||||
|
}}
|
||||||
|
placeholder="Reply…"
|
||||||
|
className="w-full rounded bg-white/10 px-2 py-1.5 text-xs text-white placeholder-white/40 outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -57,7 +57,14 @@ import {
|
||||||
hasPresenceBridge,
|
hasPresenceBridge,
|
||||||
type PresenceKicadWindow,
|
type PresenceKicadWindow,
|
||||||
} from "@/wasm/collab/presence-kicad";
|
} from "@/wasm/collab/presence-kicad";
|
||||||
|
import {
|
||||||
|
createComments,
|
||||||
|
hasCommentsBridge,
|
||||||
|
type CommentsController,
|
||||||
|
type ViewportState,
|
||||||
|
} from "@/wasm/collab/comments";
|
||||||
import { PresenceRoster } from "@/components/PresenceRoster";
|
import { PresenceRoster } from "@/components/PresenceRoster";
|
||||||
|
import { CommentLayer } from "@/components/CommentLayer";
|
||||||
import {
|
import {
|
||||||
createSheetCollabManager,
|
createSheetCollabManager,
|
||||||
registerSheetChangedHook,
|
registerSheetChangedHook,
|
||||||
|
|
@ -633,6 +640,12 @@ export function WasmTool({
|
||||||
// eeschema: the sheet THIS client is bound to — the roster dims peers whose
|
// 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).
|
// skeleton state says they're on a different sheet (collab-presence 0003).
|
||||||
const [activeSheetPath, setActiveSheetPath] = React.useState<string | undefined>();
|
const [activeSheetPath, setActiveSheetPath] = React.useState<string | undefined>();
|
||||||
|
// 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).
|
||||||
|
const [commentsCtl, setCommentsCtl] = React.useState<CommentsController | null>(null);
|
||||||
|
const [viewportState, setViewportState] = React.useState<ViewportState | null>(null);
|
||||||
|
const commentsRef = React.useRef<CommentsController | null>(null);
|
||||||
|
|
||||||
const append = React.useCallback(
|
const append = React.useCallback(
|
||||||
(msg: string) => setLogs((prev) => [...prev.slice(-800), msg]),
|
(msg: string) => setLogs((prev) => [...prev.slice(-800), msg]),
|
||||||
|
|
@ -787,10 +800,37 @@ export function WasmTool({
|
||||||
mod: win.Module,
|
mod: win.Module,
|
||||||
win: win as unknown as PresenceKicadWindow,
|
win: win as unknown as PresenceKicadWindow,
|
||||||
presence,
|
presence,
|
||||||
|
// Live world↔screen transform for the DOM comment layer (0005).
|
||||||
|
onViewport: setViewportState,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// (Re)bind the comments controller to the collab doc (collab-presence 0005):
|
||||||
|
// GAL pin dots + the DOM layer's thread data. Follows the same lifecycle as
|
||||||
|
// presence — eeschema rebinds per active sheet.
|
||||||
|
const startComments = (doc: import("yjs").Doc | undefined) => {
|
||||||
|
commentsRef.current?.destroy();
|
||||||
|
commentsRef.current = null;
|
||||||
|
setCommentsCtl(null);
|
||||||
|
if (!doc || (tool !== "pcbnew" && tool !== "eeschema") || !hasCommentsBridge(win.Module)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ctl = createComments({ doc, mod: win.Module, user: presenceUser().id, tool });
|
||||||
|
commentsRef.current = ctl;
|
||||||
|
setCommentsCtl(ctl);
|
||||||
|
// Test/debug handle (mirrors window.kicadCollab): lets the e2e reset
|
||||||
|
// persisted threads deterministically without driving the whole UI.
|
||||||
|
(win as { __pcbjamComments?: CommentsController }).__pcbjamComments = ctl;
|
||||||
|
// Seed the transform (pushes only happen on input events after this).
|
||||||
|
try {
|
||||||
|
const vp = JSON.parse(win.Module.kicadCollabGetViewport() || "null");
|
||||||
|
if (vp && vp.w > 0) setViewportState(vp);
|
||||||
|
} catch {
|
||||||
|
/* frame not up yet — the first input push seeds it */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Cmd/Ctrl+S belongs to the editor: preventDefault suppresses ONLY the
|
// Cmd/Ctrl+S belongs to the editor: preventDefault suppresses ONLY the
|
||||||
// browser's "save page" dialog (observed in Firefox) — the keydown still
|
// browser's "save page" dialog (observed in Firefox) — the keydown still
|
||||||
// propagates to the wx canvas handler, which performs the actual save.
|
// propagates to the wx canvas handler, which performs the actual save.
|
||||||
|
|
@ -921,6 +961,7 @@ export function WasmTool({
|
||||||
driftRef.current?.stop();
|
driftRef.current?.stop();
|
||||||
driftRef.current = null;
|
driftRef.current = null;
|
||||||
startPresence(activeRoom?.provider, activeRoom?.sheetPath);
|
startPresence(activeRoom?.provider, activeRoom?.sheetPath);
|
||||||
|
startComments(activeRoom?.doc);
|
||||||
if (activeRoom) {
|
if (activeRoom) {
|
||||||
driftRef.current = startDriftDetection({
|
driftRef.current = startDriftDetection({
|
||||||
doc: activeRoom.doc,
|
doc: activeRoom.doc,
|
||||||
|
|
@ -949,6 +990,7 @@ export function WasmTool({
|
||||||
});
|
});
|
||||||
collabDocRef.current = collabHandle?.doc ?? null;
|
collabDocRef.current = collabHandle?.doc ?? null;
|
||||||
startPresence(collabHandle?.provider);
|
startPresence(collabHandle?.provider);
|
||||||
|
startComments(collabHandle?.doc);
|
||||||
if (collabHandle && targetPath && COLLAB_TOOLS.has(tool)) {
|
if (collabHandle && targetPath && COLLAB_TOOLS.has(tool)) {
|
||||||
driftRef.current = startDriftDetection({
|
driftRef.current = startDriftDetection({
|
||||||
doc: collabHandle.doc,
|
doc: collabHandle.doc,
|
||||||
|
|
@ -974,6 +1016,8 @@ export function WasmTool({
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
win.removeEventListener("keydown", swallowBrowserSave, true);
|
win.removeEventListener("keydown", swallowBrowserSave, true);
|
||||||
|
commentsRef.current?.destroy();
|
||||||
|
commentsRef.current = null;
|
||||||
presenceBridgeRef.current?.destroy();
|
presenceBridgeRef.current?.destroy();
|
||||||
presenceBridgeRef.current = null;
|
presenceBridgeRef.current = null;
|
||||||
presenceRef.current?.destroy();
|
presenceRef.current?.destroy();
|
||||||
|
|
@ -1113,6 +1157,16 @@ export function WasmTool({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Figma-like comments (0005): GAL pin dots + this DOM layer (hit targets,
|
||||||
|
thread popovers, comment mode, panel). */}
|
||||||
|
{ready && commentsCtl && (
|
||||||
|
<CommentLayer
|
||||||
|
controller={commentsCtl}
|
||||||
|
viewport={viewportState}
|
||||||
|
currentUser={presenceUser().id}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Lib pre-sync still warming IDB after the editor opened (big set) — small
|
{/* Lib pre-sync still warming IDB after the editor opened (big set) — small
|
||||||
unobtrusive indicator so the user knows browsing is still filling in. */}
|
unobtrusive indicator so the user knows browsing is still filling in. */}
|
||||||
{ready && libSync && (
|
{ready && libSync && (
|
||||||
|
|
|
||||||
235
web/standalone/src/wasm/collab/comments.ts
Normal file
235
web/standalone/src/wasm/collab/comments.ts
Normal file
|
|
@ -0,0 +1,235 @@
|
||||||
|
import type * as Y from "yjs";
|
||||||
|
import {
|
||||||
|
addMessage,
|
||||||
|
args,
|
||||||
|
colorForUser,
|
||||||
|
createThread,
|
||||||
|
deleteThread,
|
||||||
|
editMessage,
|
||||||
|
field,
|
||||||
|
kicadItemsMap,
|
||||||
|
listThreads,
|
||||||
|
observeComments,
|
||||||
|
removeMessage,
|
||||||
|
resolveAnchor,
|
||||||
|
setThreadResolved,
|
||||||
|
yToItemUnchecked,
|
||||||
|
type CommentAnchor,
|
||||||
|
type CommentThread,
|
||||||
|
} from "@pcbjam/shared";
|
||||||
|
import { clog } from "./debug";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Comments controller (collab-presence 0005): glues the MIT `kdoc_comments`
|
||||||
|
* helpers (0004) to the editor —
|
||||||
|
* - resolves every thread's anchor to a world position (tracking anchored
|
||||||
|
* items through `kdoc_items` changes),
|
||||||
|
* - feeds the GAL pin dots (`Module.kicadCollabSetPins`, throttled snapshot,
|
||||||
|
* same idempotent contract as the presence overlay),
|
||||||
|
* - exposes the threads + CRUD to the React layer (CommentLayer).
|
||||||
|
* One controller per bound doc; eeschema rebinds it per sheet, exactly like
|
||||||
|
* presence.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** File-mm → editor-IU factor per tool (anchors store IU; item slots store mm). */
|
||||||
|
export const IU_PER_MM: Record<string, number> = {
|
||||||
|
pcbnew: 1e6,
|
||||||
|
eeschema: 1e4,
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface CommentPinsModule {
|
||||||
|
kicadCollabSetPins(json: string): void;
|
||||||
|
kicadCollabSetViewport(cx: number, cy: number): void;
|
||||||
|
kicadCollabGetViewport(): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the loaded wasm exposes the comment-pin bridge (0005 exports). */
|
||||||
|
export function hasCommentsBridge(mod: unknown): mod is CommentPinsModule {
|
||||||
|
const m = mod as Partial<CommentPinsModule> | undefined;
|
||||||
|
return (
|
||||||
|
typeof m?.kicadCollabSetPins === "function" &&
|
||||||
|
typeof m?.kicadCollabSetViewport === "function"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The GAL viewport transform (see presence-kicad ViewportState). */
|
||||||
|
export interface ViewportState {
|
||||||
|
cx: number;
|
||||||
|
cy: number;
|
||||||
|
scale: number; // px per IU (canvas device px)
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** World IU → canvas px (the GAL panel's own pixel space). */
|
||||||
|
export function worldToScreen(vp: ViewportState, p: { x: number; y: number }) {
|
||||||
|
return {
|
||||||
|
x: (p.x - vp.cx) * vp.scale + vp.w / 2,
|
||||||
|
y: (p.y - vp.cy) * vp.scale + vp.h / 2,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Canvas px → world IU. */
|
||||||
|
export function screenToWorld(vp: ViewportState, p: { x: number; y: number }) {
|
||||||
|
return {
|
||||||
|
x: (p.x - vp.w / 2) / vp.scale + vp.cx,
|
||||||
|
y: (p.y - vp.h / 2) / vp.scale + vp.cy,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A thread with its anchor resolved to the current world position. */
|
||||||
|
export interface ResolvedThread extends CommentThread {
|
||||||
|
world: { x: number; y: number };
|
||||||
|
detached: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommentsController {
|
||||||
|
threads(): ResolvedThread[];
|
||||||
|
subscribe(cb: (threads: ResolvedThread[]) => void): () => void;
|
||||||
|
/** Build an anchor for a world-pos click: nearest positioned item within
|
||||||
|
* `maxDistIu` becomes the tracked anchor (+offset), else pos-only. */
|
||||||
|
anchorAt(world: { x: number; y: number }, maxDistIu: number): CommentAnchor;
|
||||||
|
create(anchor: CommentAnchor, body: string): string;
|
||||||
|
reply(threadId: string, body: string): void;
|
||||||
|
edit(threadId: string, messageId: string, body: string): boolean;
|
||||||
|
remove(threadId: string, messageId: string): "removed" | "thread-deleted" | false;
|
||||||
|
setResolved(threadId: string, resolved: boolean): void;
|
||||||
|
deleteThread(threadId: string): void;
|
||||||
|
/** Pan the editor to a thread's pin (comment panel "jump to"). */
|
||||||
|
jumpTo(threadId: string): void;
|
||||||
|
destroy(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PUSH_THROTTLE_MS = 30;
|
||||||
|
|
||||||
|
export function createComments(opts: {
|
||||||
|
doc: Y.Doc;
|
||||||
|
mod: CommentPinsModule;
|
||||||
|
/** Author slug for new messages (presence identity). */
|
||||||
|
user: string;
|
||||||
|
tool: string;
|
||||||
|
}): CommentsController {
|
||||||
|
const { doc, mod, user } = opts;
|
||||||
|
const iuPerMm = IU_PER_MM[opts.tool] ?? 1e6;
|
||||||
|
|
||||||
|
let cache: ResolvedThread[] = [];
|
||||||
|
const subscribers = new Set<(threads: ResolvedThread[]) => void>();
|
||||||
|
|
||||||
|
const recompute = (): ResolvedThread[] => {
|
||||||
|
cache = listThreads(doc).map((t) => {
|
||||||
|
const { x, y, detached } = resolveAnchor(doc, t.anchor, iuPerMm);
|
||||||
|
return { ...t, world: { x, y }, detached };
|
||||||
|
});
|
||||||
|
return cache;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pushPins = () => {
|
||||||
|
mod.kicadCollabSetPins(
|
||||||
|
JSON.stringify({
|
||||||
|
// Resolved threads drop their dot (figma-style) — they stay reachable
|
||||||
|
// through the panel's "resolved" filter. Matches the DOM hit targets.
|
||||||
|
pins: cache
|
||||||
|
.filter((t) => !t.resolved)
|
||||||
|
.map((t) => ({
|
||||||
|
id: t.id,
|
||||||
|
x: t.world.x,
|
||||||
|
y: t.world.y,
|
||||||
|
color: colorForUser(t.createdBy),
|
||||||
|
resolved: t.resolved,
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
const schedule = () => {
|
||||||
|
if (timer) return;
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
timer = undefined;
|
||||||
|
recompute();
|
||||||
|
pushPins();
|
||||||
|
for (const cb of subscribers) cb(cache);
|
||||||
|
}, PUSH_THROTTLE_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Threads change → re-render; anchored ITEMS change (moves) → pins follow.
|
||||||
|
const offComments = observeComments(doc, schedule);
|
||||||
|
const items = kicadItemsMap(doc);
|
||||||
|
const onItems = () => schedule();
|
||||||
|
items.observeDeep(onItems);
|
||||||
|
|
||||||
|
recompute();
|
||||||
|
pushPins();
|
||||||
|
clog("comments: controller bound,", cache.length, "thread(s)");
|
||||||
|
|
||||||
|
return {
|
||||||
|
threads: () => cache,
|
||||||
|
subscribe(cb) {
|
||||||
|
subscribers.add(cb);
|
||||||
|
return () => subscribers.delete(cb);
|
||||||
|
},
|
||||||
|
anchorAt(world, maxDistIu) {
|
||||||
|
let best: { uuid: string; pos: { x: number; y: number }; d2: number } | null = null;
|
||||||
|
|
||||||
|
for (const [uuid, ym] of items) {
|
||||||
|
const item = yToItemUnchecked(ym);
|
||||||
|
const at = field(item.body, "at");
|
||||||
|
const [xs, ys] = at ? args(at) : [];
|
||||||
|
const x = Number(xs) * iuPerMm;
|
||||||
|
const y = Number(ys) * iuPerMm;
|
||||||
|
|
||||||
|
if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
|
||||||
|
|
||||||
|
const d2 = (x - world.x) ** 2 + (y - world.y) ** 2;
|
||||||
|
|
||||||
|
if (d2 <= maxDistIu * maxDistIu && (!best || d2 < best.d2)) {
|
||||||
|
best = { uuid, pos: { x, y }, d2 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (best) {
|
||||||
|
return {
|
||||||
|
itemUuid: best.uuid,
|
||||||
|
pos: { x: world.x, y: world.y },
|
||||||
|
offset: { x: world.x - best.pos.x, y: world.y - best.pos.y },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { pos: { x: world.x, y: world.y } };
|
||||||
|
},
|
||||||
|
create(anchor, body) {
|
||||||
|
return createThread(doc, { anchor, author: user, body });
|
||||||
|
},
|
||||||
|
reply(threadId, body) {
|
||||||
|
addMessage(doc, threadId, { author: user, body });
|
||||||
|
},
|
||||||
|
edit(threadId, messageId, body) {
|
||||||
|
return editMessage(doc, threadId, messageId, body);
|
||||||
|
},
|
||||||
|
remove(threadId, messageId) {
|
||||||
|
return removeMessage(doc, threadId, messageId);
|
||||||
|
},
|
||||||
|
setResolved(threadId, resolved) {
|
||||||
|
setThreadResolved(doc, threadId, resolved);
|
||||||
|
},
|
||||||
|
deleteThread(threadId) {
|
||||||
|
deleteThread(doc, threadId);
|
||||||
|
},
|
||||||
|
jumpTo(threadId) {
|
||||||
|
const t = cache.find((x) => x.id === threadId);
|
||||||
|
if (t) mod.kicadCollabSetViewport(t.world.x, t.world.y);
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
offComments();
|
||||||
|
items.unobserveDeep(onItems);
|
||||||
|
subscribers.clear();
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
timer = undefined;
|
||||||
|
try {
|
||||||
|
mod.kicadCollabSetPins(JSON.stringify({ pins: [] }));
|
||||||
|
} catch {
|
||||||
|
/* wasm may already be gone on teardown */
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue