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

109
tests/web/comments.spec.ts Normal file
View 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();
});

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);

@ -1 +1 @@
Subproject commit a99d1dfff92d1e8e7a77d39dc920c4db7e955208
Subproject commit b83ad94017531fef359c0478e616ab269d1234d0

View 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>
);
}

View file

@ -57,7 +57,14 @@ import {
hasPresenceBridge,
type PresenceKicadWindow,
} from "@/wasm/collab/presence-kicad";
import {
createComments,
hasCommentsBridge,
type CommentsController,
type ViewportState,
} from "@/wasm/collab/comments";
import { PresenceRoster } from "@/components/PresenceRoster";
import { CommentLayer } from "@/components/CommentLayer";
import {
createSheetCollabManager,
registerSheetChangedHook,
@ -633,6 +640,12 @@ 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>();
// 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(
(msg: string) => setLogs((prev) => [...prev.slice(-800), msg]),
@ -787,10 +800,37 @@ export function WasmTool({
mod: win.Module,
win: win as unknown as PresenceKicadWindow,
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
// browser's "save page" dialog (observed in Firefox) — the keydown still
// propagates to the wx canvas handler, which performs the actual save.
@ -921,6 +961,7 @@ export function WasmTool({
driftRef.current?.stop();
driftRef.current = null;
startPresence(activeRoom?.provider, activeRoom?.sheetPath);
startComments(activeRoom?.doc);
if (activeRoom) {
driftRef.current = startDriftDetection({
doc: activeRoom.doc,
@ -949,6 +990,7 @@ export function WasmTool({
});
collabDocRef.current = collabHandle?.doc ?? null;
startPresence(collabHandle?.provider);
startComments(collabHandle?.doc);
if (collabHandle && targetPath && COLLAB_TOOLS.has(tool)) {
driftRef.current = startDriftDetection({
doc: collabHandle.doc,
@ -974,6 +1016,8 @@ export function WasmTool({
return () => {
win.removeEventListener("keydown", swallowBrowserSave, true);
commentsRef.current?.destroy();
commentsRef.current = null;
presenceBridgeRef.current?.destroy();
presenceBridgeRef.current = null;
presenceRef.current?.destroy();
@ -1113,6 +1157,16 @@ export function WasmTool({
</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
unobtrusive indicator so the user knows browsing is still filling in. */}
{ready && libSync && (

View 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 */
}
},
};
}