From 21e3df8fd0fd8750b0a2f4474726342c22c59c5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20T=C3=B6rcsv=C3=A1ri?= Date: Tue, 7 Jul 2026 11:59:19 +0200 Subject: [PATCH] feat(collab): nth-in-room colors, comment toolbar + drag + global hide, luminance chip text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - presence.ts: colors claimed by ARRIVAL ORDER — each client takes the lowest free palette slot (no birthday-problem hash collisions; the best palette colors go first; N ≤ palette-size editors always distinct). Coordination- free: claims ride awareness; simultaneous-join collisions converge (lower clientID keeps, the other re-claims), same-user tabs adopt one color, claims are sticky across eeschema sheet rebinds (skeletons reuse them). colorOf(userId) resolves live colors; comment pins/popovers follow it (offline authors fall back to the hash). +2 unit tests (verified live: alice=slot0, bob=slot1, roster/pins consistent). - CommentLayer: single comment icon expanding into a horizontal bar — new comment · list · show/hide all (eye empties the GAL pin set + DOM targets; re-shown on new-comment/panel-jump). Pins are DRAGGABLE: live LWW anchor writes while dragging (peers + the GAL dot follow), nearest-item re-snap on drop, click-vs-drag by 4px threshold (verified live: drag synced to the peer tab exactly). Shared setThreadAnchor + controller moveThread/ setPinsVisible/colorFor. - collab_presence_style.h: chip text color by background luminance (dark on light chips, white on dark — BitmapText draws with the stroke color). - comments/presence-roster e2e updated for the bar + passing; collab units 56/56; shared 122/122; shared pointer bump. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CvqUd4QsJSGHN28aunJRTq --- tests/web/comments.spec.ts | 7 +- wasm/bindings/collab_presence_style.h | 13 +- web/pcbjam-shared | 2 +- .../src/components/CommentLayer.tsx | 197 ++++++++++++++---- web/standalone/src/components/WasmTool.tsx | 10 +- web/standalone/src/wasm/collab/comments.ts | 49 +++-- .../src/wasm/collab/presence-kicad.test.ts | 1 + .../src/wasm/collab/presence.test.ts | 70 ++++++- web/standalone/src/wasm/collab/presence.ts | 120 ++++++++++- 9 files changed, 399 insertions(+), 70 deletions(-) diff --git a/tests/web/comments.spec.ts b/tests/web/comments.spec.ts index d199ef2..461f5ee 100644 --- a/tests/web/comments.spec.ts +++ b/tests/web/comments.spec.ts @@ -26,8 +26,11 @@ async function bootAs(page: Page, user: string): Promise { 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 }); + // The comment controls mount once the collab session + bridge are up; the + // action buttons live inside the expandable bar — open it for the test. + await expect(page.getByTestId('comment-bar-toggle')).toBeVisible({ timeout: 30000 }); + await page.getByTestId('comment-bar-toggle').click(); + await expect(page.getByTestId('comment-mode-toggle')).toBeVisible(); } /** Delete every leftover thread from previous runs — comments PERSIST in the diff --git a/wasm/bindings/collab_presence_style.h b/wasm/bindings/collab_presence_style.h index 1290f05..1017ac1 100644 --- a/wasm/bindings/collab_presence_style.h +++ b/wasm/bindings/collab_presence_style.h @@ -165,6 +165,15 @@ inline double textWidth( const std::string& aText, double aGlyphH ) return aText.size() * aGlyphH * 0.75; } +// Legible text color for a chip background: near-black on light colors, +// white on dark ones (BitmapText draws with the GAL stroke color). +inline KIGFX::COLOR4D chipTextColor( const KIGFX::COLOR4D& aBg ) +{ + double lum = 0.299 * aBg.r + 0.587 * aBg.g + 0.114 * aBg.b; + return lum > 0.6 ? KIGFX::COLOR4D( 0.08, 0.08, 0.08, 1.0 ) + : KIGFX::COLOR4D( 1.0, 1.0, 1.0, 1.0 ); +} + /** Name tag next to (or inside) a box, per the label placement knobs. `px` is * world-units-per-screen-pixel. GAL BitmapText CENTERS on its position (the * default justify is CENTER/CENTER — GAL::ResetTextAttributes), so the block @@ -203,7 +212,7 @@ inline void drawLabel( KIGFX::VIEW_OVERLAY* aOv, const BOX2I& aBox, const std::s VECTOR2D( x + w + padX, y + h + padY ) ); aOv->SetIsStroke( true ); aOv->SetIsFill( false ); - aOv->SetStrokeColor( KIGFX::COLOR4D( 1, 1, 1, 1 ) ); + aOv->SetStrokeColor( chipTextColor( aColor ) ); } else { @@ -376,7 +385,7 @@ inline void drawCursor( KIGFX::VIEW_OVERLAY* aOv, const VECTOR2D& aPos, const st at + VECTOR2D( w + padX, h + padY ) ); aOv->SetIsStroke( true ); aOv->SetIsFill( false ); - aOv->SetStrokeColor( KIGFX::COLOR4D( 1, 1, 1, 1 ) ); + aOv->SetStrokeColor( chipTextColor( aColor ) ); } else { diff --git a/web/pcbjam-shared b/web/pcbjam-shared index b83ad94..5f5effa 160000 --- a/web/pcbjam-shared +++ b/web/pcbjam-shared @@ -1 +1 @@ -Subproject commit b83ad94017531fef359c0478e616ab269d1234d0 +Subproject commit 5f5effa4e0d9cab0261a5e0f771c7c4cb31b427d diff --git a/web/standalone/src/components/CommentLayer.tsx b/web/standalone/src/components/CommentLayer.tsx index 84dbe6d..029cfd7 100644 --- a/web/standalone/src/components/CommentLayer.tsx +++ b/web/standalone/src/components/CommentLayer.tsx @@ -1,6 +1,6 @@ import * as React from "react"; -import { colorForUser, type CommentAnchor } from "@pcbjam/shared"; -import { MessageSquarePlus, MessageSquareText, X } from "lucide-react"; +import type { CommentAnchor } from "@pcbjam/shared"; +import { Eye, EyeOff, List, MessageSquarePlus, MessageSquareText, X } from "lucide-react"; import { screenToWorld, worldToScreen, @@ -12,12 +12,13 @@ import { /** * 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. + * click/drag targets over each dot, the thread popover (read/reply/edit/ + * resolve/delete), the comment-mode click catcher + composer, and the list + * panel. One comment icon (top-right) expands into a small horizontal bar: + * new comment · list · show/hide all. Pins are draggable — the anchor is + * re-written live while dragging (LWW), and re-snapped to the nearest item on + * drop. Positions map world→canvas-px via the exported viewport transform, + * then canvas-px→CSS via the GAL panel's bounding rect (`#glcanvas-*`). */ interface CssRect { @@ -46,6 +47,9 @@ function timeAgo(ms: number): string { return `${Math.floor(s / 86400)}d`; } +const DRAG_THRESHOLD_PX = 4; +const DRAG_SYNC_MS = 60; + export function CommentLayer({ controller, viewport, @@ -56,13 +60,25 @@ export function CommentLayer({ currentUser: string; }) { const [threads, setThreads] = React.useState(controller.threads()); + const [barOpen, setBarOpen] = React.useState(false); const [mode, setMode] = React.useState(false); const [openId, setOpenId] = React.useState(null); const [panel, setPanel] = React.useState(false); const [showResolved, setShowResolved] = React.useState(false); + const [hidden, setHidden] = React.useState(!controller.pinsVisible()); 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(null); + // Live drag state: the dragged thread follows the pointer in CSS space (the + // GAL dot follows through the throttled anchor writes). + const [drag, setDrag] = React.useState<{ id: string; css: { x: number; y: number } } | null>(null); + const dragRef = React.useRef<{ + id: string; + startX: number; + startY: number; + moved: boolean; + lastSync: number; + } | null>(null); // Re-seed on controller rebind (eeschema sheet switch swaps the controller; // the useState initializer only covers the first mount). @@ -70,6 +86,7 @@ export function CommentLayer({ setThreads(controller.threads()); setOpenId(null); setDraft(null); + setHidden(!controller.pinsVisible()); return controller.subscribe(setThreads); }, [controller]); @@ -104,19 +121,76 @@ export function CommentLayer({ return { x, y }; }; + const cssToWorld = (css: { x: number; y: number }) => { + if (!viewport || !glRect) return null; + return screenToWorld(viewport, { + x: (css.x - glRect.x) / cssRatio, + y: (css.y - glRect.y) / cssRatio, + }); + }; + + const snapRadiusIu = () => + viewport ? 14 / (viewport.scale * cssRatio) : 0; + 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 } }); + const world = cssToWorld({ x: e.clientX, y: e.clientY }); + if (!world) return; + setDraft({ + anchor: controller.anchorAt(world, snapRadiusIu()), + css: { x: e.clientX, y: e.clientY }, + }); setMode(false); }; + // ── pin dragging ──────────────────────────────────────────────────────── + const onPinPointerDown = (t: ResolvedThread) => (e: React.PointerEvent) => { + (e.target as HTMLElement).setPointerCapture(e.pointerId); + dragRef.current = { id: t.id, startX: e.clientX, startY: e.clientY, moved: false, lastSync: 0 }; + }; + + const onPinPointerMove = (e: React.PointerEvent) => { + const d = dragRef.current; + if (!d) return; + if (!d.moved) { + if (Math.hypot(e.clientX - d.startX, e.clientY - d.startY) < DRAG_THRESHOLD_PX) return; + d.moved = true; + setOpenId(null); + } + setDrag({ id: d.id, css: { x: e.clientX, y: e.clientY } }); + // Throttled live re-anchor (pos-only while dragging — snap happens on + // drop) so the GAL dot and every peer follow the drag. + const now = Date.now(); + if (now - d.lastSync >= DRAG_SYNC_MS) { + d.lastSync = now; + const world = cssToWorld({ x: e.clientX, y: e.clientY }); + if (world) controller.moveThread(d.id, { pos: world }); + } + }; + + const onPinPointerUp = (t: ResolvedThread) => (e: React.PointerEvent) => { + const d = dragRef.current; + dragRef.current = null; + setDrag(null); + if (!d) return; + if (d.moved) { + const world = cssToWorld({ x: e.clientX, y: e.clientY }); + if (world) controller.moveThread(t.id, controller.anchorAt(world, snapRadiusIu())); + } else { + setOpenId((cur) => (cur === t.id ? null : t.id)); + } + }; + + const toggleHidden = () => { + const next = !hidden; + setHidden(next); + controller.setPinsVisible(!next); + if (next) { + setOpenId(null); + setMode(false); + setDraft(null); + } + }; + 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. @@ -124,33 +198,59 @@ export function CommentLayer({ ? (toCss(open.world) ?? { x: window.innerWidth / 2 - 150, y: 120 }) : null; const visibleThreads = threads.filter((t) => showResolved || !t.resolved); + const pinThreads = hidden ? [] : visibleThreads; return ( <> - {/* Mode + panel toggles, below the top-right chips. */} -
+ {/* Comment toolbar: one icon expanding into a small horizontal bar. */} +
- + {barOpen && ( +
+ + + +
+ )}
{/* Comment-mode click catcher over the drawing area only. */} @@ -163,19 +263,23 @@ export function CommentLayer({ /> )} - {/* Pin hit targets (the visual dot is GAL — these are the clickable DOM halves). */} - {visibleThreads.map((t) => { - const css = toCss(t.world); + {/* Pin hit/drag targets (the visual dot is GAL — these are the DOM halves). */} + {pinThreads.map((t) => { + const css = drag?.id === t.id ? drag.css : toCss(t.world); if (!css) return null; return (