diff --git a/tests/web/comments-mentions.spec.ts b/tests/web/comments-mentions.spec.ts new file mode 100644 index 0000000..97b19a6 --- /dev/null +++ b/tests/web/comments-mentions.spec.ts @@ -0,0 +1,82 @@ +import { test, expect, type Page } from '@playwright/test'; +import { openOverlayMenu } from './overlay-menu'; + +/** + * @-mention e2e (comments-ux 0001 E): with alice and bob live in the same + * room, alice types `@b` in the composer → the combobox offers bob (the + * presence-roster fallback covers backends without a members model), + * keyboard-accepts → `@bob ` lands in the body, the sent message renders a + * mention chip, and bob's unread badge takes the mention accent. + */ + +const SCOPE = 'default'; +const ROUTE = 'demo.kicad_sch'; +const TITLE = /demo — Schematic Editor/i; + +async function bootAs(page: Page, user: string): Promise { + await page.goto(`/${SCOPE}/projects/demo/${ROUTE}?user=${user}`); + await expect(page.locator('#canvas')).toBeVisible({ timeout: 120000 }); + await expect + .poll(() => page.title(), { timeout: 120000, intervals: [1000] }) + .toMatch(TITLE); + await openOverlayMenu(page); + await expect(page.getByTestId('comment-mode-toggle')).toBeVisible({ timeout: 30000 }); +} + +async function deleteAllThreads(page: Page): Promise { + 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('mentions: autocomplete from the live roster, chip render, mention-accent badge', 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'); + + // Both present: alice's roster knows bob (fallback mention source). + await expect(page.getByTestId('overlay-menu-badge')).toHaveText('1', { timeout: 30000 }); + + await page.getByTestId('comment-mode-toggle').click(); + await page.getByTestId('comment-click-catcher').click({ position: { x: 400, y: 250 } }); + const textarea = page.getByTestId('comment-composer').locator('textarea'); + await textarea.fill('please check this '); + await textarea.press('End'); + await textarea.pressSequentially('@b'); + + const combobox = page.getByTestId('mention-combobox'); + await expect(combobox).toBeVisible(); + await expect(combobox.locator('[data-slug="bob"]')).toBeVisible(); + await textarea.press('Enter'); // accept the selected completion + await expect(textarea).toHaveValue('please check this @bob '); + await expect(combobox).toHaveCount(0); + + await page.getByTestId('comment-submit').click(); + + // The sent message renders the mention as a chip. + await expect(page.getByTestId('comment-popover')).toBeVisible(); + await expect(page.getByTestId('comment-mention')).toHaveText('@bob'); + + // bob's unread badge wears the mention accent (rose, not amber). + const badgeB = pageB.getByTestId('overlay-menu-unread-badge'); + await expect(badgeB).toHaveText('1', { timeout: 20000 }); + await expect(badgeB).toHaveClass(/bg-rose-500/); + // …and on bob's side the message chip carries his slug + amber self accent. + await pageB.getByTestId('comment-pin').click(); + await expect(pageB.getByTestId('comment-mention')).toHaveClass(/text-amber-300/); + + await pageB.close(); +}); diff --git a/tests/web/comments-panel.spec.ts b/tests/web/comments-panel.spec.ts new file mode 100644 index 0000000..cbcbd9d --- /dev/null +++ b/tests/web/comments-panel.spec.ts @@ -0,0 +1,129 @@ +import { test, expect, type Page } from '@playwright/test'; +import { openOverlayMenu } from './overlay-menu'; + +/** + * Floating comments panel e2e (comments-ux 0001 B): the list is a draggable + * window with its own scrollbar, independent of the overlay menu, with an + * always-onscreen restore guarantee (a stored position that no longer fits + * the viewport — e.g. a gone secondary display — resets to the default + * anchor; live shrinks clamp it back in). + */ + +const SCOPE = 'default'; +const ROUTE = 'demo.kicad_sch'; +const TITLE = /demo — Schematic Editor/i; + +async function bootAs(page: Page, user: string): Promise { + await page.goto(`/${SCOPE}/projects/demo/${ROUTE}?user=${user}`); + await expect(page.locator('#canvas')).toBeVisible({ timeout: 120000 }); + await expect + .poll(() => page.title(), { timeout: 120000, intervals: [1000] }) + .toMatch(TITLE); + await openOverlayMenu(page); + await expect(page.getByTestId('comment-mode-toggle')).toBeVisible({ timeout: 30000 }); +} + +async function resetComments(page: Page): Promise { + await page.evaluate(() => { + const ctl = (window as unknown as { + __pcbjamComments?: { + threads(): Array<{ id: string }>; + deleteThread(id: string): void; + create(anchor: { pos: { x: number; y: number } }, body: string): string; + }; + }).__pcbjamComments; + ctl?.threads().forEach((t) => ctl.deleteThread(t.id)); + }); + await expect(page.getByTestId('comment-pin')).toHaveCount(0); +} + +function createThread(page: Page, body: string, x: number): Promise { + return page.evaluate( + ([bodyText, wx]) => { + const ctl = (window as unknown as { + __pcbjamComments: { + create(anchor: { pos: { x: number; y: number } }, body: string): string; + }; + }).__pcbjamComments; + ctl.create({ pos: { x: Number(wx), y: 0 } }, String(bodyText)); + }, + [body, String(x)], + ); +} + +test('floating panel: open, scroll, drag, offscreen-reset, clamp', async ({ page }) => { + test.setTimeout(300000); // one full tool boot + + await bootAs(page, 'alice'); + await resetComments(page); + + for (let i = 0; i < 12; i++) await createThread(page, `note ${i}`, i * 100000); + + // A stored position from a "bigger display" must NOT be honored: the panel + // opens at its default anchor instead. (Legacy px entry deliberately.) + await page.evaluate(() => + localStorage.setItem('pcbjam:comments-panel-pos', JSON.stringify({ x: 5000, y: 120 })), + ); + + await openOverlayMenu(page); + await page.getByTestId('comment-panel-toggle').click(); + const panel = page.getByTestId('comments-panel'); + await expect(panel).toBeVisible(); + await expect(page.getByTestId('comment-panel-item')).toHaveCount(12); + + const vp = page.viewportSize()!; + let box = (await panel.boundingBox())!; + expect(box.x + box.width).toBeLessThanOrEqual(vp.width + 1); + expect(box.x).toBeGreaterThanOrEqual(-1); + + // The list scrolls inside the panel (12 rows > max height). + const scrollable = await page + .getByTestId('comments-panel-list') + .evaluate((el) => el.scrollHeight > el.clientHeight); + expect(scrollable).toBe(true); + + // Header carries the primary comment actions: "+" arms comment mode + // (click catcher appears), the eye hides the pin hit targets. + await page.getByTestId('comments-panel-add').click(); + await expect(page.getByTestId('comment-click-catcher')).toBeVisible(); + await page.getByTestId('comments-panel-add').click(); // cancel again + await expect(page.getByTestId('comment-click-catcher')).toHaveCount(0); + await page.getByTestId('comments-panel-pins').click(); + await expect(page.getByTestId('comment-pin')).toHaveCount(0); + await page.getByTestId('comments-panel-pins').click(); + // ≥1, not 12: pins whose anchors fall outside the viewport render no DOM + // hit target (culling), and the fit depends on the demo sheet. + await expect(page.getByTestId('comment-pin').first()).toBeVisible(); + + // Collapse to header-only; the state survives close/reopen. + await page.getByTestId('comments-panel-collapse').click(); + await expect(page.getByTestId('comments-panel-list')).toHaveCount(0); + await expect(page.getByTestId('comments-panel-header')).toBeVisible(); + await page.getByTestId('comments-panel-collapse').click(); + await expect(page.getByTestId('comments-panel-list')).toBeVisible(); + + // Drag by the header to a chosen spot. + const header = page.getByTestId('comments-panel-header'); + const hb = (await header.boundingBox())!; + await page.mouse.move(hb.x + 40, hb.y + hb.height / 2); + await page.mouse.down(); + await page.mouse.move(120, 300, { steps: 5 }); + await page.mouse.up(); + box = (await panel.boundingBox())!; + expect(box.y).toBeGreaterThan(200); + expect(box.x).toBeLessThan(200); + + // Live viewport shrink clamps the handle back onscreen. + await page.setViewportSize({ width: 700, height: 400 }); + await expect + .poll(async () => { + const b = (await panel.boundingBox())!; + return b.x >= 0 && b.y >= 0 && b.x + 288 <= 700 + 1 && b.y + 36 <= 400 + 1; + }) + .toBe(true); + + // Close persists; the open state itself is storage-backed. + await page.getByTestId('comments-panel-close').click(); + await expect(panel).toHaveCount(0); + expect(await page.evaluate(() => localStorage.getItem('pcbjam:comments-panel-open'))).toBe('0'); +}); diff --git a/tests/web/comments-reactions.spec.ts b/tests/web/comments-reactions.spec.ts new file mode 100644 index 0000000..7ed5024 --- /dev/null +++ b/tests/web/comments-reactions.spec.ts @@ -0,0 +1,86 @@ +import { test, expect, type Page } from '@playwright/test'; +import { openOverlayMenu } from './overlay-menu'; + +/** + * Emoji reactions e2e (comments-ux 0001 D): quick-row toggle on/off, two + * users reacting with the same emoji both counted (flat own-key writes — no + * LWW clobber), and the lazy full picker mounting on demand. + */ + +const SCOPE = 'default'; +const ROUTE = 'demo.kicad_sch'; +const TITLE = /demo — Schematic Editor/i; + +async function bootAs(page: Page, user: string): Promise { + await page.goto(`/${SCOPE}/projects/demo/${ROUTE}?user=${user}`); + await expect(page.locator('#canvas')).toBeVisible({ timeout: 120000 }); + await expect + .poll(() => page.title(), { timeout: 120000, intervals: [1000] }) + .toMatch(TITLE); + await openOverlayMenu(page); + await expect(page.getByTestId('comment-mode-toggle')).toBeVisible({ timeout: 30000 }); +} + +async function deleteAllThreads(page: Page): Promise { + 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('reactions: quick-row toggle, concurrent same-emoji, lazy full picker', 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'); + + // alice creates and keeps the popover open. + await page.getByTestId('comment-mode-toggle').click(); + await page.getByTestId('comment-click-catcher').click({ position: { x: 400, y: 250 } }); + await page.getByTestId('comment-composer').locator('textarea').fill('react to me'); + await page.getByTestId('comment-submit').click(); + await expect(page.getByTestId('comment-popover')).toBeVisible(); + + // alice: 👍 via the quick row ("add reaction" shows on message hover). + await page.getByTestId('comment-message').hover(); + await page.getByTestId('comment-react').click(); + await page.getByTestId('comment-quick-react').locator('[data-emoji="👍"]').click(); + const chip = page.getByTestId('comment-reaction-chip'); + await expect(chip).toHaveCount(1); + await expect(chip).toContainText('1'); + + // bob opens the same thread and clicks the existing chip → count 2 in BOTH. + await expect(pageB.getByTestId('comment-pin')).toHaveCount(1, { timeout: 20000 }); + await pageB.getByTestId('comment-pin').click(); + const chipB = pageB.getByTestId('comment-reaction-chip'); + await expect(chipB).toContainText('1', { timeout: 20000 }); + await chipB.click(); + await expect(chipB).toContainText('2', { timeout: 20000 }); + await expect(chip).toContainText('2', { timeout: 20000 }); + + // Toggle off (bob) → back to 1 everywhere. + await chipB.click(); + await expect(chip).toContainText('1', { timeout: 20000 }); + + // Full picker: quick row's "+" mounts the lazy emoji-mart chunk. + await page.getByTestId('comment-message').hover(); + await page.getByTestId('comment-react').click(); + await page.getByTestId('comment-react-more').click(); + await expect(page.getByTestId('emoji-picker')).toBeVisible(); + await expect(page.locator('em-emoji-picker')).toBeVisible({ timeout: 30000 }); + await page.keyboard.press('Escape'); + await expect(page.getByTestId('emoji-picker')).toHaveCount(0); + + await pageB.close(); +}); diff --git a/tests/web/comments-seen.spec.ts b/tests/web/comments-seen.spec.ts new file mode 100644 index 0000000..c4560df --- /dev/null +++ b/tests/web/comments-seen.spec.ts @@ -0,0 +1,93 @@ +import { test, expect, type Page } from '@playwright/test'; +import { openOverlayMenu } from './overlay-menu'; + +/** + * Seen/unread e2e (comments-ux 0001 C): alice comments → bob's FAB grows an + * unread badge and the panel row an unread dot; opening the thread clears + * them (event-driven mark-seen — popover open, own writes); a reply re-arms + * alice's badge, and "mark all as seen" clears it without opening. + */ + +const SCOPE = 'default'; +const ROUTE = 'demo.kicad_sch'; +const TITLE = /demo — Schematic Editor/i; + +async function bootAs(page: Page, user: string): Promise { + await page.goto(`/${SCOPE}/projects/demo/${ROUTE}?user=${user}`); + await expect(page.locator('#canvas')).toBeVisible({ timeout: 120000 }); + await expect + .poll(() => page.title(), { timeout: 120000, intervals: [1000] }) + .toMatch(TITLE); + await openOverlayMenu(page); + await expect(page.getByTestId('comment-mode-toggle')).toBeVisible({ timeout: 30000 }); +} + +async function deleteAllThreads(page: Page): Promise { + 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('unread badges: create → badge for the peer only → open clears → reply re-arms → mark-all', 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'); + + // alice creates via comment mode (own write = auto-seen for alice). + await page.getByTestId('comment-mode-toggle').click(); + await page.getByTestId('comment-click-catcher').click({ position: { x: 400, y: 250 } }); + await page.getByTestId('comment-composer').locator('textarea').fill('unseen by bob'); + await page.getByTestId('comment-submit').click(); + await expect(page.getByTestId('comment-popover')).toBeVisible(); + + // bob: FAB unread badge + menu-row badge + panel unread dot. alice: none. + await expect(pageB.getByTestId('overlay-menu-unread-badge')).toHaveText('1', { + timeout: 20000, + }); + await expect(page.getByTestId('overlay-menu-unread-badge')).toHaveCount(0); + + await openOverlayMenu(pageB); + await expect(pageB.getByTestId('comment-unread-badge')).toHaveText('1'); + await pageB.getByTestId('comment-panel-toggle').click(); + await expect(pageB.getByTestId('comment-unread-dot')).toHaveCount(1); + + // Opening the thread marks it seen — badges and dot clear, GAL pins get + // unread=false (asserted indirectly: the DOM state is the driver). + await pageB.getByTestId('comment-pin').click(); + await expect(pageB.getByTestId('comment-popover')).toBeVisible(); + await expect(pageB.getByTestId('overlay-menu-unread-badge')).toHaveCount(0, { + timeout: 20000, + }); + await expect(pageB.getByTestId('comment-unread-dot')).toHaveCount(0); + + // bob replies from his open popover → alice (popover closed first) unreads. + await page.getByTestId('comment-popover').getByTitle('Close').click(); + await pageB.getByTestId('comment-reply').fill('now you have mail'); + await pageB.getByTestId('comment-reply').press('Enter'); + await expect(page.getByTestId('overlay-menu-unread-badge')).toHaveText('1', { + timeout: 20000, + }); + + // "Mark all as seen" from alice's panel header clears without opening. + await openOverlayMenu(page); + await page.getByTestId('comment-panel-toggle').click(); + await page.getByTestId('comments-mark-all-seen').click(); + await expect(page.getByTestId('overlay-menu-unread-badge')).toHaveCount(0, { + timeout: 20000, + }); + + await pageB.close(); +}); diff --git a/tests/web/comments-viewport-resize.spec.ts b/tests/web/comments-viewport-resize.spec.ts index 2c9f599..72870f1 100644 --- a/tests/web/comments-viewport-resize.spec.ts +++ b/tests/web/comments-viewport-resize.spec.ts @@ -67,8 +67,13 @@ async function pinDelta(page: Page): Promise<{ dx: number; dy: number }> { const r = gl.getBoundingClientRect(); const ratio = r.width / vp.w; const world = win.__pcbjamComments.threads()[0].world; - const truthX = r.x + ((world.x - vp.cx) * vp.scale + vp.w / 2) * ratio; - const truthY = r.y + ((world.y - vp.cy) * vp.scale + vp.h / 2) * ratio; + // Bubble-pin geometry (comments-ux 0001 A): the DOM target centers on the + // bubble BODY at anchor + (r, -r) — the anchored point is the bubble's + // sharp bottom-left corner. Keep in sync with standalone + // src/wasm/collab/pin-geometry.ts (PIN_RADIUS_PX). + const bubbleR = 9 * ratio; + const truthX = r.x + ((world.x - vp.cx) * vp.scale + vp.w / 2) * ratio + bubbleR; + const truthY = r.y + ((world.y - vp.cy) * vp.scale + vp.h / 2) * ratio - bubbleR; const pin = document.querySelector('[data-testid="comment-pin"]') as HTMLElement; const pr = pin.getBoundingClientRect(); return { dx: pr.x + pr.width / 2 - truthX, dy: pr.y + pr.height / 2 - truthY }; diff --git a/tests/web/theme.spec.ts b/tests/web/theme.spec.ts new file mode 100644 index 0000000..16d757b --- /dev/null +++ b/tests/web/theme.spec.ts @@ -0,0 +1,36 @@ +import { test, expect } from '@playwright/test'; + +/** + * Standalone theme follow e2e (comments-ux 0002): resolution precedence + * (?theme= param > storage > OS), no-flash application before first paint, + * persistence across a param-less reload, and the shell toggle. Kept on the + * HOME page — no tool boot needed; the canvas half (seeded KiCad color theme + * + the kicadSetColorTheme bridge) is covered by unit seeding assertions and + * manual Firefox verification per the standalone UI workflow. + */ + +test('?theme=dark applies before paint, persists, and the toggle flips it', async ({ page }) => { + await page.goto('/?theme=dark', { waitUntil: 'domcontentloaded' }); + // The inline boot script ran before first paint — no React needed yet. + await expect(page.locator('html')).toHaveClass(/dark/); + + // The param persisted; a param-less reload stays dark. + await page.goto('/'); + await expect(page.locator('html')).toHaveClass(/dark/); + expect(await page.evaluate(() => localStorage.getItem('pcbjam-theme'))).toBe('dark'); + + // Shell toggle flips class + storage. + await page.getByTestId('theme-toggle').click(); + await expect(page.locator('html')).not.toHaveClass(/dark/); + expect(await page.evaluate(() => localStorage.getItem('pcbjam-theme'))).toBe('light'); + + // A later ?theme= wins over the stored choice. + await page.goto('/?theme=dark', { waitUntil: 'domcontentloaded' }); + await expect(page.locator('html')).toHaveClass(/dark/); +}); + +test('invalid ?theme= falls back to the stored/OS preference', async ({ page }) => { + await page.emulateMedia({ colorScheme: 'light' }); + await page.goto('/?theme=purple', { waitUntil: 'domcontentloaded' }); + await expect(page.locator('html')).not.toHaveClass(/dark/); +}); diff --git a/wasm/bindings/collab_presence_core.h b/wasm/bindings/collab_presence_core.h index 77e66e4..d5909b8 100644 --- a/wasm/bindings/collab_presence_core.h +++ b/wasm/bindings/collab_presence_core.h @@ -66,6 +66,7 @@ struct PIN VECTOR2D pos; // world coords (IU) KIGFX::COLOR4D color; bool resolved = false; + bool unread = false; // comments-ux 0001 C: accent ring }; inline long long nowMs() @@ -384,7 +385,7 @@ struct CORE for( const PIN& pin : pins ) { KIGFX::COLOR4D color = peerColor( style, pin.name, pin.color ); - drawPin( chipOverlay.get(), pin.pos, color, pin.resolved, px, style ); + drawPin( chipOverlay.get(), pin.pos, color, pin.resolved, pin.unread, px, style ); } view->Update( overlay.get() ); @@ -532,9 +533,9 @@ struct CORE scheduleRedraw(); } - /** kicadCollabSetPins (0005): comment pin dots — `{pins:[{id,name,x,y, - * color,resolved}]}`, world IU coords resolved by the TS side from the - * ydoc anchors. Snapshot semantics like setRemote. */ + /** kicadCollabSetPins (0005): comment pins — `{pins:[{id,name,x,y, + * color,resolved,unread}]}`, world IU coords resolved by the TS side + * from the ydoc anchors. Snapshot semantics like setRemote. */ void setPins( const std::string& aJson ) { json j = json::parse( aJson, nullptr, /*allow_exceptions*/ false ); @@ -552,6 +553,7 @@ struct CORE pin.pos = VECTOR2D( p.value( "x", 0.0 ), p.value( "y", 0.0 ) ); pin.color = parsePeerColor( p.value( "color", "" ) ); pin.resolved = p.value( "resolved", false ); + pin.unread = p.value( "unread", false ); parsed.push_back( std::move( pin ) ); } diff --git a/wasm/bindings/collab_presence_style.h b/wasm/bindings/collab_presence_style.h index 9807653..7cb69a0 100644 --- a/wasm/bindings/collab_presence_style.h +++ b/wasm/bindings/collab_presence_style.h @@ -24,6 +24,7 @@ #include #include #include +#include #include #include #include @@ -75,12 +76,20 @@ struct STYLE std::string fixedColor; std::vector palette; - // ── comment pin dots ────────────────────────────────────────────────── + // ── comment pins ────────────────────────────────────────────────────── + // 0 circle dot · 1 figma-style bubble: a round body whose bottom-left + // corner is squared off — the SHARP CORNER is the anchored point + // (comments-ux 0001 A, reshaped per user feedback 2026-07-24). + // Shipped look picked with the tuner 2026-07-24: r9 body, 4px ring, + // slightly translucent ring+fill. pinRadiusPx is MIRRORED in the + // standalone's pin-geometry.ts (DEFAULT_PIN_RADIUS_PX) — change together. + int pinShape = 1; double pinRadiusPx = 9.0; - double pinRingPx = 3.0; - double pinRingAlpha = 1.0; - double pinFillAlpha = 1.0; + double pinRingPx = 4.0; + double pinRingAlpha = 0.9; + double pinFillAlpha = 0.9; double pinResolvedAlpha = 0.3; + std::string pinUnreadRingColor = "#ffb020"; // unread accent ring ("" = plain white) // ── cross-app "ghost" selection (0006) ──────────────────────────────── // A peer's selection in the OTHER editor (eeschema symbol ⇄ pcbnew @@ -159,11 +168,13 @@ inline void patchStyle( STYLE& aStyle, const json& j ) } } + aStyle.pinShape = j.value( "pinShape", aStyle.pinShape ); aStyle.pinRadiusPx = j.value( "pinRadiusPx", aStyle.pinRadiusPx ); aStyle.pinRingPx = j.value( "pinRingPx", aStyle.pinRingPx ); aStyle.pinRingAlpha = j.value( "pinRingAlpha", aStyle.pinRingAlpha ); aStyle.pinFillAlpha = j.value( "pinFillAlpha", aStyle.pinFillAlpha ); aStyle.pinResolvedAlpha = j.value( "pinResolvedAlpha", aStyle.pinResolvedAlpha ); + aStyle.pinUnreadRingColor = j.value( "pinUnreadRingColor", aStyle.pinUnreadRingColor ); aStyle.xselAlphaScale = j.value( "xselAlphaScale", aStyle.xselAlphaScale ); } @@ -494,21 +505,60 @@ inline void drawCursor( KIGFX::VIEW_OVERLAY* aOv, KIGFX::VIEW_OVERLAY* aChipOv, } } -/** Comment pin dot. Draw onto the CHIPS overlay: at the shapes depth an - * earlier-painted selection fill would reject the dot's fragments (the 0005 - * "drawn last sits above" comment had it backwards — later fragments LOSE). */ +/** Comment pin. Draw onto the CHIPS overlay: at the shapes depth an + * earlier-painted selection fill would reject the pin's fragments (the 0005 + * "drawn last sits above" comment had it backwards — later fragments LOSE). + * Bubble shape (comments-ux 0001 A, figma-style): a round body whose + * bottom-left corner is squared off; `aPos` is that SHARP CORNER = the + * anchored world point, the body center sits at aPos + (r, -r) (screen + * up-right; KiCad IU y grows downward). One closed polygon — three sampled + * round corners + the sharp one — so the stroke traces the outline exactly + * with no depth-order seams. */ inline void drawPin( KIGFX::VIEW_OVERLAY* aOv, const VECTOR2D& aPos, const KIGFX::COLOR4D& aColor, - bool aResolved, double aPx, const STYLE& aS ) + bool aResolved, bool aUnread, double aPx, const STYLE& aS ) { double fillAlpha = aResolved ? aS.pinResolvedAlpha : aS.pinFillAlpha; double ringAlpha = aResolved ? aS.pinRingAlpha * 0.4 : aS.pinRingAlpha; + KIGFX::COLOR4D ring( 1, 1, 1, ringAlpha ); + + if( aUnread && !aResolved && !aS.pinUnreadRingColor.empty() ) + ring = parseHexColor( aS.pinUnreadRingColor, ring ).WithAlpha( ringAlpha ); + aOv->SetIsStroke( true ); aOv->SetIsFill( true ); aOv->SetFillColor( aColor.WithAlpha( fillAlpha ) ); - aOv->SetStrokeColor( KIGFX::COLOR4D( 1, 1, 1, ringAlpha ) ); + aOv->SetStrokeColor( ring ); aOv->SetLineWidth( aS.pinRingPx * aPx ); - aOv->Circle( aPos, aS.pinRadiusPx * aPx ); + + if( aS.pinShape == 0 ) + { + aOv->Circle( aPos, aS.pinRadiusPx * aPx ); + return; + } + + double r = aS.pinRadiusPx * aPx; + VECTOR2D c = aPos + VECTOR2D( r, -r ); + + constexpr int SEGS = 24; // sampling of the 270° round part + VECTOR2D pts[SEGS + 3]; + int n = 0; + + pts[n++] = aPos; // the sharp corner + + for( int i = 0; i <= SEGS; i++ ) + { + // South (90° in y-down coords) → east → north → west: the round part. + double th = ( 90.0 - 270.0 * i / SEGS ) * M_PI / 180.0; + pts[n++] = c + VECTOR2D( cos( th ) * r, sin( th ) * r ); + } + + // Close the outline explicitly: the overlay strokes the point list as a + // polyline, so without repeating the first point the west → sharp-corner + // edge would have fill but no ring. + pts[n++] = aPos; + + aOv->Polygon( pts, n ); } } // namespace pcbjam_presence diff --git a/wasm/bindings/eeschema_embind.cpp b/wasm/bindings/eeschema_embind.cpp index 0f058f2..0f65b3c 100644 --- a/wasm/bindings/eeschema_embind.cpp +++ b/wasm/bindings/eeschema_embind.cpp @@ -61,6 +61,7 @@ #include "collab_common.h" #include "collab_presence_core.h" #include "collab_presence_style.h" +#include "pcbjam_theme.h" #include "pcbjam_libs_reload.h" #include @@ -1658,6 +1659,18 @@ void schCollabSetStyle( std::string aJson ) presenceCore().setStyle( aJson ); } +// JS → C++ (comments-ux 0002 F4): live color-theme switch (see pcbjam_theme.h). +void schSetColorTheme( std::string aTheme ) +{ + pcbjam_theme::setColorTheme( schFrame(), aTheme ); +} + +// Pre-main chrome appearance seed (called at onRuntimeInitialized). +void schSetDarkChrome( bool aDark ) +{ + pcbjam_theme::setDarkChromeFlag( aDark ); +} + // Tuner helper: a VARIED demo-selection set for the current sheet — smallest + // largest symbol and two bundles of wires (net-ish), mirroring pcbnew's // pcbCollabTestDemoSet so the style preview shows the range of shapes. @@ -2027,6 +2040,9 @@ EMSCRIPTEN_BINDINGS(eeschema) { // Follow-user (collab-presence 0008). function("kicadCollabFitViewport", &schCollabFitViewport); function("kicadCollabSetStyle", &schCollabSetStyle); + // Live color-theme switch (comments-ux 0002 F4). + function("kicadSetColorTheme", &schSetColorTheme); + function("kicadSetDarkChrome", &schSetDarkChrome); function("kicadCollabTestListItems", &schCollabTestListItems); function("kicadCollabTestDemoSet", &schCollabTestDemoSet); function("kicadCollabGetViewport", &schCollabGetViewport); diff --git a/wasm/bindings/kicad_editor_embind.cpp b/wasm/bindings/kicad_editor_embind.cpp index ab2d9e4..d951550 100644 --- a/wasm/bindings/kicad_editor_embind.cpp +++ b/wasm/bindings/kicad_editor_embind.cpp @@ -79,6 +79,9 @@ void pcbCollabReleaseSelection( std::string aUuidsJson, std::string aHold std::string pcbCollabTestGetLocked(); std::string pcbCollabTestSelectFirst(); bool pcbCollabTestClearSelection(); +// Live color-theme switch (comments-ux 0002 F4). +void pcbSetColorTheme( std::string aTheme ); +void pcbSetDarkChrome( bool aDark ); bool schEditorActive(); int schLibsSymbolUsage( std::string aLibNickname, std::string aSymbolName ); @@ -101,6 +104,8 @@ void schCollabSetViewport( double aCx, double aCy ); // Follow-user (collab-presence 0008). void schCollabFitViewport( double aCx, double aCy, double aHalfW, double aHalfH ); void schCollabSetStyle( std::string aJson ); +// Live color-theme switch (comments-ux 0002 F4). +void schSetColorTheme( std::string aTheme ); std::string schCollabTestListItems( int aCount ); std::string schCollabTestDemoSet(); std::string schCollabGetViewport(); @@ -388,6 +393,21 @@ static void collabSetStyle( std::string aJson ) pcbEditorActive() ? pcbCollabSetStyle( aJson ) : schCollabSetStyle( aJson ); } +// Theme switch (comments-ux 0002 F4): BOTH editors, not just the active one — +// a later frame switch (eeschema-switch-nav) must come up already themed. +// Each side no-ops on a null frame. +static void setColorTheme( std::string aTheme ) +{ + pcbSetColorTheme( aTheme ); + schSetColorTheme( aTheme ); +} + +// The chrome flag is process-global — one call suffices. +static void setDarkChrome( bool aDark ) +{ + pcbSetDarkChrome( aDark ); +} + static std::string collabTestListItems( int aCount ) { return pcbEditorActive() ? pcbCollabTestListItems( aCount ) : schCollabTestListItems( aCount ); @@ -490,6 +510,9 @@ EMSCRIPTEN_BINDINGS(kicad_editor) { function("kicadCollabSetRemote", &collabSetRemote); function("kicadCollabSetPins", &collabSetPins); function("kicadCollabSetViewport", &collabSetViewport); + // Live color-theme switch (comments-ux 0002 F4). + function("kicadSetColorTheme", &setColorTheme); + function("kicadSetDarkChrome", &setDarkChrome); // Follow-user (collab-presence 0008). function("kicadCollabFitViewport", &collabFitViewport); function("kicadCollabSetStyle", &collabSetStyle); diff --git a/wasm/bindings/pcbjam_theme.h b/wasm/bindings/pcbjam_theme.h new file mode 100644 index 0000000..e4829b7 --- /dev/null +++ b/wasm/bindings/pcbjam_theme.h @@ -0,0 +1,101 @@ +/* + * Live color-theme switch (comments-ux 0002 F4): kicadSetColorTheme(name) + * re-points the frame's app settings `color_theme` and drives the frame's own + * CommonSettingsChanged — the exact path the desktop Preferences dialog uses + * to hot-apply a theme (SCH_BASE_FRAME / PCB_BASE_FRAME reload COLOR_SETTINGS + * into the painter, recache the view, refresh). SETTINGS_MANAGER resolves + * colors/.json from the MEMFS config dir on demand, so the JSON only + * has to exist (standalone boot seeding writes it — web/standalone + * src/wasm/boot.ts). The changed app settings are saved back to MEMFS so a + * same-session relaunch (warm pool) comes up already themed. + */ + +#pragma once + +#ifdef __EMSCRIPTEN__ + +#include +#include +#include +#include +#include +#include +#include + +#include "collab_common.h" + +// wx wasm port chrome appearance (wxwidgets src/wasm/settings.cpp): the +// system-colour table every widget paints from. +extern "C" void wxWasmSetDarkAppearance( bool dark ); +extern "C" bool wxWasmGetDarkAppearance(); + +namespace pcbjam_theme { + +/** Set the chrome appearance FLAG only — no widget traffic, no fiber. Safe + * from the browser main thread at any point (it writes one bool in shared + * wasm memory); the embedder calls it at onRuntimeInitialized, BEFORE main() + * spawns on the KiCad pthread, so the first widget paint is already themed. + * (Module.ENV proved unreliable for this: the pthread builds its environ + * from its own worker's ENV, not the main runtime's.) */ +inline void setDarkChromeFlag( bool aDark ) +{ + wxWasmSetDarkAppearance( aDark ); +} + +/** Flip the wx CHROME (panels/toolbars/dialogs — the system-colour table) and + * broadcast the change so every live window repaints: + * wxWindowBase::OnSysColourChanged recurses to children, and + * EDA_BASE_FRAME's handler additionally re-themes icons and rebuilds + * toolbars/menubar. No-op when the appearance didn't change (the merged + * image calls the theme entry once per editor). No DOM probing here — this + * runs on the KiCad pthread, which has no `document`. */ +inline void syncChromeAppearance( bool aDark ) +{ + if( aDark == wxWasmGetDarkAppearance() ) + return; + + wxWasmSetDarkAppearance( aDark ); + + for( wxWindowList::const_iterator it = wxTopLevelWindows.begin(); + it != wxTopLevelWindows.end(); ++it ) + { + wxWindow* tlw = *it; + wxSysColourChangedEvent evt; + evt.SetEventObject( tlw ); + tlw->GetEventHandler()->ProcessEvent( evt ); + tlw->Refresh(); + } +} + +/** Apply `aTheme` ("pcbjam-dark", "_builtin_default", …) to one frame. Runs + * on the frame's fiber: CommonSettingsChanged reaches tool/view internals + * that must not run from a bare JS callback. Null frame no-ops (the merged + * dispatcher calls every editor, open or not). */ +inline void setColorTheme( EDA_DRAW_FRAME* aFrame, const std::string& aTheme ) +{ + if( !aFrame ) + return; + + pcbjam_collab::runOnFiber( aFrame, [aFrame, aTheme]() { + if( APP_SETTINGS_BASE* cfg = aFrame->config() ) + { + cfg->m_ColorTheme = wxString::FromUTF8( aTheme.c_str() ); + // Persist now — wasm sessions never exit cleanly, so the normal + // save-on-close path would lose the choice. + Pgm().GetSettingsManager().Save( cfg ); + } + + // wx chrome first (panels/toolbars), then the GAL canvas colors. The + // shell only ever sends our dark theme name or the builtin default, + // so the chrome appearance rides on that distinction. + syncChromeAppearance( aTheme != "_builtin_default" ); + + // 0 flags: no env/text vars changed; the frame's override chain still + // unconditionally reloads colors and recaches the view. + aFrame->CommonSettingsChanged( 0 ); + } ); +} + +} // namespace pcbjam_theme + +#endif // __EMSCRIPTEN__ diff --git a/wasm/bindings/pcbnew_embind.cpp b/wasm/bindings/pcbnew_embind.cpp index e2c59e2..6d48879 100644 --- a/wasm/bindings/pcbnew_embind.cpp +++ b/wasm/bindings/pcbnew_embind.cpp @@ -53,6 +53,7 @@ #include "collab_common.h" #include "collab_presence_core.h" #include "collab_presence_style.h" +#include "pcbjam_theme.h" #include "pcbjam_libs_reload.h" #include #include @@ -1603,6 +1604,18 @@ void pcbCollabSetStyle( std::string aJson ) presenceCore().setStyle( aJson ); } +// JS → C++ (comments-ux 0002 F4): live color-theme switch (see pcbjam_theme.h). +void pcbSetColorTheme( std::string aTheme ) +{ + pcbjam_theme::setColorTheme( pcbFrame(), aTheme ); +} + +// Pre-main chrome appearance seed (called at onRuntimeInitialized). +void pcbSetDarkChrome( bool aDark ) +{ + pcbjam_theme::setDarkChromeFlag( aDark ); +} + // Tuner helper: a VARIED demo-selection set — labeled uuid groups (smallest + // largest footprint, the two busiest nets' track segments) so the style // preview shows the real range of shapes instead of two overlapping items. @@ -2351,6 +2364,9 @@ EMSCRIPTEN_BINDINGS(pcbnew) { // Follow-user (collab-presence 0008). function("kicadCollabFitViewport", &pcbCollabFitViewport); function("kicadCollabSetStyle", &pcbCollabSetStyle); + // Live color-theme switch (comments-ux 0002 F4). + function("kicadSetColorTheme", &pcbSetColorTheme); + function("kicadSetDarkChrome", &pcbSetDarkChrome); function("kicadCollabTestListItems", &pcbCollabTestListItems); function("kicadCollabTestDemoSet", &pcbCollabTestDemoSet); function("kicadCollabGetViewport", &pcbCollabGetViewport); diff --git a/web/backend/src/server.ts b/web/backend/src/server.ts index 66709ca..03fad09 100644 --- a/web/backend/src/server.ts +++ b/web/backend/src/server.ts @@ -210,6 +210,12 @@ export async function buildApp(): Promise { } return { status: 200 as const, body: await walk(PROJECT_DIR) }; }, + // No members model on the example backend — mention autocomplete falls + // back to the presence roster + comment authors (contract-sanctioned 404). + listCollaborators: async () => ({ + status: 404 as const, + body: { message: "no members model on this backend" }, + }), listLibs: async ({ headers, query }) => { const owner = userOf(headers); // Origins filtered by item kind (?kind); user libs are kind-agnostic diff --git a/web/pcbjam-shared b/web/pcbjam-shared index b17af7c..5d35bcd 160000 --- a/web/pcbjam-shared +++ b/web/pcbjam-shared @@ -1 +1 @@ -Subproject commit b17af7c971f702d5e1ce2a753843160160797dc7 +Subproject commit 5d35bcd2adc5f0c54d3b655912b680dfc6b0c5ce diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index f90a62b..5fb85fd 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -77,6 +77,12 @@ importers: standalone: dependencies: + '@emoji-mart/data': + specifier: ^1.2.1 + version: 1.2.1 + '@emoji-mart/react': + specifier: ^1.1.1 + version: 1.1.1(emoji-mart@5.6.0)(react@18.3.1) '@hocuspocus/provider': specifier: ^4.1.1 version: 4.1.1(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31) @@ -107,6 +113,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + emoji-mart: + specifier: ^5.6.0 + version: 5.6.0 lucide-react: specifier: ^0.469.0 version: 0.469.0(react@18.3.1) @@ -258,6 +267,15 @@ packages: '@cloudflare/workers-types@4.20260610.1': resolution: {integrity: sha512-Mk/f3lUygeIHzQ4HnJjU/JvGg/kllgp9gISty9nylHE/2M2MFeKO+hgAKSgiPpmwUbuhewdYGgqFGgT/ADK0/g==} + '@emoji-mart/data@1.2.1': + resolution: {integrity: sha512-no2pQMWiBy6gpBEiqGeU77/bFejDqUTRY7KX+0+iur13op3bqUsXdnwoZs6Xb1zbv0gAj5VvS1PWoUUckSr5Dw==} + + '@emoji-mart/react@1.1.1': + resolution: {integrity: sha512-NMlFNeWgv1//uPsvLxvGQoIerPuVdXwK/EUek8OOkJ6wVOWPUizRBJU0hDqWZCOROVpfBgCemaC3m6jDOXi03g==} + peerDependencies: + emoji-mart: ^5.2 + react: ^16.8 || ^17 || ^18 + '@esbuild/aix-ppc64@0.25.12': resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} engines: {node: '>=18'} @@ -1254,6 +1272,9 @@ packages: electron-to-chromium@1.5.364: resolution: {integrity: sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw==} + emoji-mart@5.6.0: + resolution: {integrity: sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow==} + es-errors@1.3.0: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} @@ -2090,6 +2111,13 @@ snapshots: '@cloudflare/workers-types@4.20260610.1': {} + '@emoji-mart/data@1.2.1': {} + + '@emoji-mart/react@1.1.1(emoji-mart@5.6.0)(react@18.3.1)': + dependencies: + emoji-mart: 5.6.0 + react: 18.3.1 + '@esbuild/aix-ppc64@0.25.12': optional: true @@ -2819,6 +2847,8 @@ snapshots: electron-to-chromium@1.5.364: {} + emoji-mart@5.6.0: {} + es-errors@1.3.0: {} es-module-lexer@1.7.0: {} diff --git a/web/standalone/index.html b/web/standalone/index.html index 241dd8f..1432cde 100644 --- a/web/standalone/index.html +++ b/web/standalone/index.html @@ -7,6 +7,25 @@ PCBJam - KiCad demo + + void; + /** Live presence peers as mention candidates (0001 E fallback roster). */ + mentionPeers?: Collaborator[]; }) { const [threads, setThreads] = React.useState(controller.threads()); const [mode, setMode] = React.useState(false); const [openId, setOpenId] = React.useState(null); - const [panel, setPanel] = React.useState(false); + // The floating panel's open state survives reloads (position does too, via + // useDraggablePanel inside CommentsPanel). + const [panel, setPanelState] = React.useState(() => { + try { + return localStorage.getItem(PANEL_OPEN_KEY) === "1"; + } catch { + return false; + } + }); + const setPanel = (next: boolean | ((p: boolean) => boolean)) => { + setPanelState((prev) => { + const v = typeof next === "function" ? next(prev) : next; + try { + localStorage.setItem(PANEL_OPEN_KEY, v ? "1" : "0"); + } catch { + /* private mode */ + } + return v; + }); + }; 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); @@ -118,6 +176,35 @@ export function CommentLayer({ return controller.subscribe(setThreads); }, [controller]); + // Viewing an open thread marks it seen — including replies that arrive + // WHILE it is open (threads dep). markSeen is forward-only, so the + // write-observe-rerun cycle settles instead of looping. + React.useEffect(() => { + if (openId) controller.markSeen(openId); + }, [openId, threads, controller]); + + const unreadThreads = threads.filter((t) => threadUnreadCount(t, currentUser) > 0).length; + const mentioned = threads.some((t) => threadMentionsUnread(t, currentUser)); + + // Mention candidates (0001 E): backend roster (lazy, session-cached) ∪ + // presence peers ∪ authors already in the doc; never yourself. + const threadsRef = React.useRef(threads); + threadsRef.current = threads; + const mentionPeersRef = React.useRef(mentionPeers); + mentionPeersRef.current = mentionPeers; + const getMentionCandidates = React.useCallback(async (): Promise => { + const server = (await collaborators()) ?? []; + const peers = mentionPeersRef.current ?? []; + const authors: Collaborator[] = threadsRef.current.flatMap((t) => + t.messages.map((m) => ({ slug: m.author, name: m.authorName || m.author })), + ); + return mergeCandidates(server, peers, authors).filter((c) => c.slug !== currentUser); + }, [currentUser]); + + React.useEffect(() => { + onUnreadChange?.(unreadThreads, mentioned); + }, [unreadThreads, mentioned, onUnreadChange]); + React.useEffect(() => { const measure = () => setGlRect(glCanvasRect()); measure(); @@ -219,11 +306,23 @@ export function CommentLayer({ } }; + // The GAL bubble's body floats up-right of the anchored point (sharp + // corner) — DOM (hit target, popover) must sit on the body, not the tip. + // The radius is LIVE (PresenceTuner re-styles the GAL pins at runtime), so + // subscribe rather than reading a constant. + const pinR = React.useSyncExternalStore(subscribePinRadius, pinRadiusPx); + const bubbleOff = bubbleCenterOffsetPx(); + const toBodyCss = (css: { x: number; y: number }) => ({ + x: css.x + bubbleOff.dx * cssRatio, + y: css.y + bubbleOff.dy * cssRatio, + }); + 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 openAnchorCss = open ? toCss(open.world) : null; const openCss = open - ? (toCss(open.world) ?? { x: window.innerWidth / 2 - 150, y: 120 }) + ? (openAnchorCss ? toBodyCss(openAnchorCss) : { x: window.innerWidth / 2 - 150, y: 120 }) : null; const visibleThreads = threads.filter((t) => showResolved || !t.resolved); const pinThreads = hidden ? [] : visibleThreads; @@ -247,11 +346,11 @@ export function CommentLayer({ setMode((m) => !m); setDraft(null); }} - className={`${overlayRowClass} ${mode ? "bg-amber-500/20 text-amber-200" : ""}`} + className={`${overlayRowClass} ${mode ? "bg-amber-500/20 text-amber-600 dark:text-amber-200" : ""}`} > - + {mode ? "Placing comment…" : "Add comment"} - + {mode ? "Esc" : ""} @@ -261,11 +360,22 @@ export function CommentLayer({ aria-pressed={panel} title="Show every comment in this file" onClick={() => setPanel((p) => !p)} - className={`${overlayRowClass} ${panel ? "bg-white/10" : ""}`} + className={`${overlayRowClass} ${panel ? "bg-black/10 dark:bg-white/10" : ""}`} > - - {panel ? "Hide list" : "Show list"} - + + {panel ? "Close comments panel" : "Open comments panel"} + + {unreadThreads > 0 && ( + + {unreadThreads} + + )} {threads.length} @@ -278,62 +388,14 @@ export function CommentLayer({ className={overlayRowClass} > {hidden ? ( - + ) : ( - + )} {hidden ? "Show pins" : "Hide pins"} - {/* Threads panel (filter + jump-to). */} - {panel && ( -
-
- Comments ({visibleThreads.length}) - -
-
- {visibleThreads.length === 0 && ( -

No comments yet.

- )} - {visibleThreads.map((t) => ( - - ))} -
-
- )} , menuSlot, ) @@ -343,6 +405,33 @@ export function CommentLayer({ <> {menuUi} + {/* Floating comments panel (comments-ux 0001 B): draggable, scrollable, + independent of the overlay menu's open state. */} + {panel && ( + { + if (hidden) toggleHidden(); + setMode((m) => !m); + setDraft(null); + }} + pinsHidden={hidden} + onTogglePins={toggleHidden} + onJump={(t) => { + if (hidden) toggleHidden(); + controller.jumpTo(t.id); + setOpenId(t.id); + }} + onClose={() => setPanel(false)} + /> + )} + {/* Comment-mode click catcher over the drawing area only. */} {mode && glRect && (
{ - const css = drag?.id === t.id ? drag.css : toCss(t.world); - if (!css) return null; + const anchorCss = drag?.id === t.id ? drag.css : toCss(t.world); + if (!anchorCss) return null; + const css = toBodyCss(anchorCss); + // The GAL bubble's CSS size (device px × ratio) — the visible ring + // must hug the drawn body, while the transparent button keeps a + // finger-friendly hit area regardless of DPI. +4 ≈ the default ring + // width (collab_presence_style.h pinRingPx) so the highlight clears + // the stroke's outer edge. + const bubbleD = 2 * pinR * cssRatio + 4; + const hitD = Math.max(26, bubbleD + 8); return ( ); })} @@ -378,9 +500,10 @@ export function CommentLayer({ {draft && ( setDraft(null)} - onSubmit={(body) => { - const id = controller.create(draft.anchor, body); + onSubmit={(body, mentions) => { + const id = controller.create(draft.anchor, body, mentions); setDraft(null); setOpenId(id); }} @@ -394,6 +517,7 @@ export function CommentLayer({ css={openCss} currentUser={currentUser} controller={controller} + getCandidates={getMentionCandidates} onClose={() => setOpenId(null)} /> )} @@ -402,41 +526,257 @@ export function CommentLayer({ ); } +/** + * Floating comments panel (comments-ux 0001 B): a draggable window listing + * every thread, newest activity first, with its own scrollbar. The header + * carries the primary comment actions too (add pin, show/hide pins) and the + * panel COLLAPSES to just that header. Position, open and collapsed state + * persist per browser; useDraggablePanel guarantees the header can never be + * restored or stranded offscreen. + */ +function CommentsPanel({ + threads, + total, + currentUser, + showResolved, + onShowResolved, + controller, + mode, + onToggleMode, + pinsHidden, + onTogglePins, + onJump, + onClose, +}: { + threads: ResolvedThread[]; + total: number; + currentUser: string; + showResolved: boolean; + onShowResolved: (v: boolean) => void; + controller: CommentsController; + mode: boolean; + onToggleMode: () => void; + pinsHidden: boolean; + onTogglePins: () => void; + onJump: (t: ResolvedThread) => void; + onClose: () => void; +}) { + const rootRef = React.useRef(null); + const drag = useDraggablePanel({ + storageKey: PANEL_POS_KEY, + handleWidth: PANEL_W, + handleHeight: PANEL_HEADER_H, + }); + const [collapsed, setCollapsedState] = React.useState(() => { + try { + return localStorage.getItem(PANEL_COLLAPSED_KEY) === "1"; + } catch { + return false; + } + }); + const setCollapsed = (v: boolean) => { + setCollapsedState(v); + try { + localStorage.setItem(PANEL_COLLAPSED_KEY, v ? "1" : "0"); + } catch { + /* private mode */ + } + }; + // Default anchor: top-right area but CLEAR of the overlay menu's panel + // (which opens at the FAB, right-anchored, z-50 above us) — the panel is + // usually opened FROM that menu, so spawning underneath it would hide it + // and swallow its header drags. + const style: React.CSSProperties = drag.pos + ? { left: drag.pos.x, top: drag.pos.y } + : { right: 308, top: 12 }; + + const lastActivity = (t: ResolvedThread) => + t.messages[t.messages.length - 1]?.createdAt ?? t.createdAt; + const sorted = [...threads].sort((a, b) => lastActivity(b) - lastActivity(a)); + + return ( +
+ {/* Header = drag handle. Interactive children stop pointerdown so they + don't start a drag. */} +
drag.onPointerDown(e, rootRef.current!.getBoundingClientRect())} + onPointerMove={(e) => void drag.onPointerMove(e)} + onPointerUp={() => void drag.onPointerUp()} + > + + Comments ({threads.length}) + e.stopPropagation()} + > + + + {threads.some((t) => threadUnreadCount(t, currentUser) > 0) && ( + + )} + + +
+ + {!collapsed && ( + + )} + + {!collapsed && ( +
+ {sorted.length === 0 && ( +

+ {total === 0 ? "No comments yet." : "Nothing to show — check the resolved filter."} +

+ )} + {sorted.map((t) => ( + + ))} +
+ )} +
+ ); +} + +/** Slugs actually still `@`-present in the sent body (a mention accepted then + * deleted from the text doesn't count). */ +function presentMentions(body: string, accepted: Set): string[] { + return [...accepted].filter((slug) => body.includes(`@${slug}`)); +} + function Composer({ css, + getCandidates, onSubmit, onCancel, }: { css: { x: number; y: number }; - onSubmit: (body: string) => void; + getCandidates: () => Promise; + onSubmit: (body: string, mentions: string[]) => void; onCancel: () => void; }) { const [body, setBody] = React.useState(""); + const acceptedRef = React.useRef>(new Set()); const submit = () => { - if (body.trim()) onSubmit(body.trim()); + if (body.trim()) onSubmit(body.trim(), presentMentions(body, acceptedRef.current)); else onCancel(); }; return (
-