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

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