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:
Gergő Törcsvári 2026-07-06 17:50:11 +02:00
commit 635aa2926a
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
8 changed files with 997 additions and 4 deletions

View file

@ -701,7 +701,18 @@ struct PEER
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<PIN> g_pins;
std::shared_ptr<KIGFX::VIEW_OVERLAY> g_overlay;
bool g_started = 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() );
fr->GetCanvas()->ForceRefresh();
}
@ -1587,6 +1610,51 @@ void schCollabSetRemote( std::string aJson )
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
// IU via the GAL matrix (GetScale() is the zoom, not px/IU — pcbnew 0002 lesson).
std::string schCollabGetViewport()
@ -1735,6 +1803,8 @@ EMSCRIPTEN_BINDINGS(eeschema) {
// Presence (collab-presence 0003) — shared names with pcbnew's 0002 set.
function("kicadCollabPresenceStart", &schCollabPresenceStart);
function("kicadCollabSetRemote", &schCollabSetRemote);
function("kicadCollabSetPins", &schCollabSetPins);
function("kicadCollabSetViewport", &schCollabSetViewport);
function("kicadCollabGetViewport", &schCollabGetViewport);
function("kicadCollabGetSelection", &schCollabGetSelection);
function("kicadCollabTestSelectFirst", &schCollabTestSelectFirst);

View file

@ -45,9 +45,11 @@ std::string pcbCollabTestMoveFirst( int aDx, int aDy );
std::string pcbCollabGetPos( std::string aId );
bool pcbCollabTestRemoveItem( std::string aId );
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 pcbCollabSetRemote( std::string aJson );
void pcbCollabSetPins( std::string aJson );
void pcbCollabSetViewport( double aCx, double aCy );
std::string pcbCollabGetViewport();
std::string pcbCollabGetSelection();
std::string pcbCollabTestSelectFirst();
@ -62,9 +64,11 @@ std::string schCollabTestMoveFirst( int aDx, int aDy );
std::string schCollabGetPos( std::string aId );
bool schCollabTestRemoveItem( std::string aId );
bool schCollabTestRotateItem( std::string aId, double aDeg );
// Presence (collab-presence 0003 — eeschema counterparts of the 0002 set).
// Presence (collab-presence 0003 — eeschema counterparts) + pins (0005).
void schCollabPresenceStart();
void schCollabSetRemote( std::string aJson );
void schCollabSetPins( std::string aJson );
void schCollabSetViewport( double aCx, double aCy );
std::string schCollabGetViewport();
std::string schCollabGetSelection();
std::string schCollabTestSelectFirst();
@ -159,6 +163,16 @@ static void collabSetRemote( std::string 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()
{
return pcbEditorActive() ? pcbCollabGetViewport() : schCollabGetViewport();
@ -196,9 +210,11 @@ EMSCRIPTEN_BINDINGS(kicad_editor) {
// endpoint, field text — flow from the per-editor blocks unchanged).
function("kicadCollabTestRemoveItem", &collabTestRemoveItem);
function("kicadCollabTestRotateItem", &collabTestRotateItem);
// Presence (collab-presence 0002).
// Presence (collab-presence 0002/0003) + comment pins/panning (0005).
function("kicadCollabPresenceStart", &collabPresenceStart);
function("kicadCollabSetRemote", &collabSetRemote);
function("kicadCollabSetPins", &collabSetPins);
function("kicadCollabSetViewport", &collabSetViewport);
function("kicadCollabGetViewport", &collabGetViewport);
function("kicadCollabGetSelection", &collabGetSelection);
function("kicadCollabTestSelectFirst", &collabTestSelectFirst);

View file

@ -1106,7 +1106,19 @@ struct PEER
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<PIN> g_pins;
std::shared_ptr<KIGFX::VIEW_OVERLAY> g_overlay;
bool g_started = 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() );
// The canvas repaints on its own only with focus/input — force it, exactly as
// the cross-probe flash does.
@ -1660,6 +1684,54 @@ void pcbCollabSetRemote( std::string aJson )
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):
// `{cx,cy,scale,w,h}` — world center, pixels-per-IU scale, canvas size in px.
std::string pcbCollabGetViewport()
@ -1989,6 +2061,8 @@ EMSCRIPTEN_BINDINGS(pcbnew) {
// with 0003 (the merged image dispatches pcb-only until then).
function("kicadCollabPresenceStart", &pcbCollabPresenceStart);
function("kicadCollabSetRemote", &pcbCollabSetRemote);
function("kicadCollabSetPins", &pcbCollabSetPins);
function("kicadCollabSetViewport", &pcbCollabSetViewport);
function("kicadCollabGetViewport", &pcbCollabGetViewport);
function("kicadCollabGetSelection", &pcbCollabGetSelection);
function("kicadCollabTestSelectFirst", &pcbCollabTestSelectFirst);