comments-ux: figma bubble pins, floating panel, seen/reactions/mentions UI, theme follow (0001 A–E + 0002)
- GAL pin = one closed polygon: round body, squared-off bottom-left corner ON the anchor; PIN gains unread (accent ring); tuner knobs; shipped defaults r9/ring4/alpha.9. DOM hit/highlight sized+offset from a LIVE pin-geometry radius store the tuner feeds. - Floating comments panel: draggable (shared useDraggablePanel with always-onscreen restore; overlay FAB retrofitted), collapsible to header, header carries add/show-hide/mark-all; unread badges (rose on mention). - Reactions (emoji-mart lazy, quick-row) + @-mention autocomplete (MentionInput; backend roster with presence/author fallback). - Theme: ?theme= > storage > OS, no-flash boot, toggles (HomePage + overlay View row), boot-seeded pcbjam-dark schematic colors + kicadSetColorTheme / kicadSetDarkChrome bridges (canvas + wx chrome live flip), light/dark variants across all overlay surfaces. - e2e: panel/seen/reactions/mentions/theme specs + resize-spec geometry; bumps pcbjam-shared (flat-key seen/reactions + listCollaborators) and wxwidgets (dark chrome) pointers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HLwn1toiNKi1MgxGKnZTes
This commit is contained in:
parent
d3200e464c
commit
21a96b0440
39 changed files with 2436 additions and 232 deletions
82
tests/web/comments-mentions.spec.ts
Normal file
82
tests/web/comments-mentions.spec.ts
Normal file
|
|
@ -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<void> {
|
||||
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<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('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();
|
||||
});
|
||||
129
tests/web/comments-panel.spec.ts
Normal file
129
tests/web/comments-panel.spec.ts
Normal file
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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');
|
||||
});
|
||||
86
tests/web/comments-reactions.spec.ts
Normal file
86
tests/web/comments-reactions.spec.ts
Normal file
|
|
@ -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<void> {
|
||||
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<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('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();
|
||||
});
|
||||
93
tests/web/comments-seen.spec.ts
Normal file
93
tests/web/comments-seen.spec.ts
Normal file
|
|
@ -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<void> {
|
||||
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<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('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();
|
||||
});
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
36
tests/web/theme.spec.ts
Normal file
36
tests/web/theme.spec.ts
Normal file
|
|
@ -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/);
|
||||
});
|
||||
|
|
@ -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 ) );
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@
|
|||
#include <view/view_overlay.h>
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
|
@ -75,12 +76,20 @@ struct STYLE
|
|||
std::string fixedColor;
|
||||
std::vector<std::string> 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 );
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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 <algorithm>
|
||||
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
101
wasm/bindings/pcbjam_theme.h
Normal file
101
wasm/bindings/pcbjam_theme.h
Normal file
|
|
@ -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/<name>.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 <eda_draw_frame.h>
|
||||
#include <pgm_base.h>
|
||||
#include <settings/app_settings.h>
|
||||
#include <settings/settings_manager.h>
|
||||
#include <string>
|
||||
#include <wx/event.h>
|
||||
#include <wx/window.h>
|
||||
|
||||
#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__
|
||||
|
|
@ -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 <algorithm>
|
||||
#include <chrono>
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -210,6 +210,12 @@ export async function buildApp(): Promise<import("fastify").FastifyInstance> {
|
|||
}
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
Subproject commit b17af7c971f702d5e1ce2a753843160160797dc7
|
||||
Subproject commit 5d35bcd2adc5f0c54d3b655912b680dfc6b0c5ce
|
||||
30
web/pnpm-lock.yaml
generated
30
web/pnpm-lock.yaml
generated
|
|
@ -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: {}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,25 @@
|
|||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<title>PCBJam - KiCad demo</title>
|
||||
<!-- No-flash theme boot (comments-ux 0002): ?theme= (platform hand-off,
|
||||
persisted) > localStorage > OS preference, applied before first paint.
|
||||
Keep the key + precedence in sync with src/lib/theme.ts. -->
|
||||
<script>
|
||||
(function () {
|
||||
try {
|
||||
var p = new URLSearchParams(location.search).get("theme");
|
||||
var t = p === "light" || p === "dark" ? p : null;
|
||||
if (t) localStorage.setItem("pcbjam-theme", t);
|
||||
else t = localStorage.getItem("pcbjam-theme");
|
||||
if (t !== "light" && t !== "dark") {
|
||||
t = matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
}
|
||||
document.documentElement.classList.toggle("dark", t === "dark");
|
||||
} catch (e) {
|
||||
/* storage/matchMedia unavailable — stay light */
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<meta
|
||||
name="description"
|
||||
content="Early-access alpha of PCBJam — KiCad running in your browser. Open the example boards or your own KiCad project. No install, nothing uploaded."
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@
|
|||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emoji-mart/data": "^1.2.1",
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
"@hocuspocus/provider": "^4.1.1",
|
||||
"@pcbjam/shared": "workspace:*",
|
||||
"@pcbjam/sync-client": "workspace:*",
|
||||
|
|
@ -25,6 +27,7 @@
|
|||
"@ts-rest/core": "^3.52.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"emoji-mart": "^5.6.0",
|
||||
"lucide-react": "^0.469.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
|
|
|
|||
|
|
@ -1,8 +1,24 @@
|
|||
import * as React from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { overlayRowClass } from "@/components/OverlayMenu";
|
||||
import type { CommentAnchor } from "@pcbjam/shared";
|
||||
import { Eye, EyeOff, List, MessageSquarePlus, X } from "lucide-react";
|
||||
import {
|
||||
threadMentionsUnread,
|
||||
threadUnreadCount,
|
||||
type Collaborator,
|
||||
type CommentAnchor,
|
||||
} from "@pcbjam/shared";
|
||||
import {
|
||||
CheckCheck,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Eye,
|
||||
EyeOff,
|
||||
List,
|
||||
MessageSquarePlus,
|
||||
Plus,
|
||||
SmilePlus,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
screenToWorld,
|
||||
worldToScreen,
|
||||
|
|
@ -10,6 +26,16 @@ import {
|
|||
type ResolvedThread,
|
||||
type ViewportState,
|
||||
} from "@/wasm/collab/comments";
|
||||
import {
|
||||
bubbleCenterOffsetPx,
|
||||
pinRadiusPx,
|
||||
subscribePinRadius,
|
||||
} from "@/wasm/collab/pin-geometry";
|
||||
import { useDraggablePanel } from "@/components/useDraggablePanel";
|
||||
import { EmojiPickerPopover } from "@/components/EmojiPicker";
|
||||
import { MentionInput } from "@/components/MentionInput";
|
||||
import { noteEmojiUsed, quickEmojis } from "@/lib/emoji-quick";
|
||||
import { cachedCollaborators, collaborators, mergeCandidates } from "@/lib/mentions";
|
||||
|
||||
/**
|
||||
* DOM half of the hybrid comment pins (collab-presence 0005): the GAL overlay
|
||||
|
|
@ -74,11 +100,19 @@ function timeAgo(ms: number): string {
|
|||
const DRAG_THRESHOLD_PX = 4;
|
||||
const DRAG_SYNC_MS = 60;
|
||||
|
||||
const PANEL_OPEN_KEY = "pcbjam:comments-panel-open";
|
||||
const PANEL_COLLAPSED_KEY = "pcbjam:comments-panel-collapsed";
|
||||
const PANEL_POS_KEY = "pcbjam:comments-panel-pos";
|
||||
const PANEL_W = 288; // w-72
|
||||
const PANEL_HEADER_H = 36;
|
||||
|
||||
export function CommentLayer({
|
||||
controller,
|
||||
viewport,
|
||||
currentUser,
|
||||
menuSlot,
|
||||
onUnreadChange,
|
||||
mentionPeers,
|
||||
}: {
|
||||
controller: CommentsController;
|
||||
viewport: ViewportState | null;
|
||||
|
|
@ -87,11 +121,35 @@ export function CommentLayer({
|
|||
* into it while the menu is open; null (menu closed) renders neither.
|
||||
* Pins, popovers, composer and the click catcher stay canvas-anchored. */
|
||||
menuSlot: HTMLElement | null;
|
||||
/** Unread rollup for the overlay FAB badge (0001 C): threads with unread
|
||||
* messages + whether any of them mentions the current user. */
|
||||
onUnreadChange?: (unreadThreads: number, mentioned: boolean) => void;
|
||||
/** Live presence peers as mention candidates (0001 E fallback roster). */
|
||||
mentionPeers?: Collaborator[];
|
||||
}) {
|
||||
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);
|
||||
// The floating panel's open state survives reloads (position does too, via
|
||||
// useDraggablePanel inside CommentsPanel).
|
||||
const [panel, setPanelState] = React.useState<boolean>(() => {
|
||||
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<Collaborator[]> => {
|
||||
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" : ""}`}
|
||||
>
|
||||
<MessageSquarePlus size={14} className="shrink-0 text-white/50" />
|
||||
<MessageSquarePlus size={14} className="shrink-0 text-neutral-400 dark:text-white/50" />
|
||||
<span>{mode ? "Placing comment…" : "Add comment"}</span>
|
||||
<span className="ml-auto text-[10px] text-white/40">
|
||||
<span className="ml-auto text-[10px] text-neutral-400 dark:text-white/40">
|
||||
{mode ? "Esc" : ""}
|
||||
</span>
|
||||
</button>
|
||||
|
|
@ -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" : ""}`}
|
||||
>
|
||||
<List size={14} className="shrink-0 text-white/50" />
|
||||
<span>{panel ? "Hide list" : "Show list"}</span>
|
||||
<span className="ml-auto text-[10px] text-white/40">
|
||||
<List size={14} className="shrink-0 text-neutral-400 dark:text-white/50" />
|
||||
<span>{panel ? "Close comments panel" : "Open comments panel"}</span>
|
||||
<span className="ml-auto flex items-center gap-1 text-[10px] text-neutral-400 dark:text-white/40">
|
||||
{unreadThreads > 0 && (
|
||||
<span
|
||||
data-testid="comment-unread-badge"
|
||||
title={mentioned ? "Unread comments — you were mentioned" : "Unread comments"}
|
||||
className={`flex h-4 min-w-4 items-center justify-center rounded-full px-1 font-semibold text-white ${
|
||||
mentioned ? "bg-rose-500" : "bg-amber-500"
|
||||
}`}
|
||||
>
|
||||
{unreadThreads}
|
||||
</span>
|
||||
)}
|
||||
{threads.length}
|
||||
</span>
|
||||
</button>
|
||||
|
|
@ -278,62 +388,14 @@ export function CommentLayer({
|
|||
className={overlayRowClass}
|
||||
>
|
||||
{hidden ? (
|
||||
<EyeOff size={14} className="shrink-0 text-white/50" />
|
||||
<EyeOff size={14} className="shrink-0 text-neutral-400 dark:text-white/50" />
|
||||
) : (
|
||||
<Eye size={14} className="shrink-0 text-white/50" />
|
||||
<Eye size={14} className="shrink-0 text-neutral-400 dark:text-white/50" />
|
||||
)}
|
||||
<span>{hidden ? "Show pins" : "Hide pins"}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Threads panel (filter + jump-to). */}
|
||||
{panel && (
|
||||
<div className="flex max-h-[50vh] w-full flex-col overflow-hidden rounded-lg bg-black/60 text-white ring-1 ring-inset ring-white/15">
|
||||
<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={() => {
|
||||
if (hidden) toggleHidden();
|
||||
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: controller.colorFor(t.createdBy) }}
|
||||
title={authorLabel(t).title}
|
||||
>
|
||||
{authorLabel(t).text}
|
||||
</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>
|
||||
)}
|
||||
</>,
|
||||
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 && (
|
||||
<CommentsPanel
|
||||
threads={visibleThreads}
|
||||
total={threads.length}
|
||||
currentUser={currentUser}
|
||||
showResolved={showResolved}
|
||||
onShowResolved={setShowResolved}
|
||||
controller={controller}
|
||||
mode={mode}
|
||||
onToggleMode={() => {
|
||||
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 && (
|
||||
<div
|
||||
|
|
@ -355,8 +444,16 @@ export function CommentLayer({
|
|||
|
||||
{/* 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;
|
||||
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 (
|
||||
<button
|
||||
key={t.id}
|
||||
|
|
@ -366,11 +463,36 @@ export function CommentLayer({
|
|||
onPointerDown={onPinPointerDown(t)}
|
||||
onPointerMove={onPinPointerMove}
|
||||
onPointerUp={onPinPointerUp(t)}
|
||||
className={`absolute z-30 -translate-x-1/2 -translate-y-1/2 rounded-full ${
|
||||
drag?.id === t.id ? "cursor-grabbing ring-2 ring-white" : "cursor-grab hover:ring-2 hover:ring-white/70"
|
||||
className={`group absolute z-30 -translate-x-1/2 -translate-y-1/2 ${
|
||||
drag?.id === t.id ? "cursor-grabbing" : "cursor-grab"
|
||||
}`}
|
||||
style={{ left: css.x, top: css.y, width: 22, height: 22, background: "transparent", touchAction: "none" }}
|
||||
style={{
|
||||
left: css.x,
|
||||
top: css.y,
|
||||
width: hitD,
|
||||
height: hitD,
|
||||
background: "transparent",
|
||||
touchAction: "none",
|
||||
}}
|
||||
>
|
||||
{/* Highlight sized + shaped like the GAL bubble (round, sharp
|
||||
bottom-left corner), always slightly padded from the hit
|
||||
area: a hugging soft wash + outline reads as "this pin",
|
||||
not a detached circle. */}
|
||||
<span
|
||||
className={`pointer-events-none absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 transition-opacity ${
|
||||
drag?.id === t.id
|
||||
? "bg-sky-400/25 ring-2 ring-sky-500 dark:ring-sky-300"
|
||||
: "opacity-0 group-hover:opacity-100 bg-sky-400/15 ring-2 ring-sky-500/70 dark:ring-sky-300/70"
|
||||
}`}
|
||||
style={{
|
||||
width: bubbleD + 4,
|
||||
height: bubbleD + 4,
|
||||
borderRadius: "9999px",
|
||||
borderBottomLeftRadius: 0,
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
|
|
@ -378,9 +500,10 @@ export function CommentLayer({
|
|||
{draft && (
|
||||
<Composer
|
||||
css={draft.css}
|
||||
getCandidates={getMentionCandidates}
|
||||
onCancel={() => 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<HTMLDivElement | null>(null);
|
||||
const drag = useDraggablePanel({
|
||||
storageKey: PANEL_POS_KEY,
|
||||
handleWidth: PANEL_W,
|
||||
handleHeight: PANEL_HEADER_H,
|
||||
});
|
||||
const [collapsed, setCollapsedState] = React.useState<boolean>(() => {
|
||||
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 (
|
||||
<div
|
||||
ref={rootRef}
|
||||
data-testid="comments-panel"
|
||||
className="absolute z-40 flex w-72 flex-col overflow-hidden rounded-xl bg-white/95 text-neutral-900 shadow-2xl ring-1 ring-inset ring-black/10 dark:bg-neutral-950/90 dark:text-white dark:ring-white/15 backdrop-blur-sm"
|
||||
style={style}
|
||||
>
|
||||
{/* Header = drag handle. Interactive children stop pointerdown so they
|
||||
don't start a drag. */}
|
||||
<div
|
||||
data-testid="comments-panel-header"
|
||||
className="flex cursor-grab select-none items-center gap-2 px-3 py-2 text-xs font-semibold active:cursor-grabbing"
|
||||
style={{ touchAction: "none" }}
|
||||
title="Comments — drag to move"
|
||||
onPointerDown={(e) => drag.onPointerDown(e, rootRef.current!.getBoundingClientRect())}
|
||||
onPointerMove={(e) => void drag.onPointerMove(e)}
|
||||
onPointerUp={() => void drag.onPointerUp()}
|
||||
>
|
||||
<button
|
||||
data-testid="comments-panel-collapse"
|
||||
aria-expanded={!collapsed}
|
||||
title={collapsed ? "Expand" : "Collapse to header"}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
className="rounded p-0.5 text-neutral-500 hover:bg-black/5 hover:text-neutral-900 dark:text-white/60 dark:hover:bg-white/10 dark:hover:text-white"
|
||||
>
|
||||
{collapsed ? <ChevronRight size={14} /> : <ChevronDown size={14} />}
|
||||
</button>
|
||||
<span>Comments ({threads.length})</span>
|
||||
<span
|
||||
className="ml-auto flex items-center gap-0.5"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
data-testid="comments-panel-add"
|
||||
aria-pressed={mode}
|
||||
title={mode ? "Cancel placing (Esc)" : "Add comment — click the canvas to place a pin"}
|
||||
onClick={onToggleMode}
|
||||
className={`rounded p-0.5 hover:bg-black/5 hover:text-neutral-900 dark:hover:bg-white/10 dark:hover:text-white ${
|
||||
mode ? "bg-amber-500/20 text-amber-600 dark:text-amber-200" : "text-neutral-500 dark:text-white/60"
|
||||
}`}
|
||||
>
|
||||
<MessageSquarePlus size={14} />
|
||||
</button>
|
||||
<button
|
||||
data-testid="comments-panel-pins"
|
||||
aria-pressed={!pinsHidden}
|
||||
title={pinsHidden ? "Show the pins on the canvas" : "Hide the pins on the canvas"}
|
||||
onClick={onTogglePins}
|
||||
className="rounded p-0.5 text-neutral-500 hover:bg-black/5 hover:text-neutral-900 dark:text-white/60 dark:hover:bg-white/10 dark:hover:text-white"
|
||||
>
|
||||
{pinsHidden ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</button>
|
||||
{threads.some((t) => threadUnreadCount(t, currentUser) > 0) && (
|
||||
<button
|
||||
data-testid="comments-mark-all-seen"
|
||||
title="Mark all as seen"
|
||||
onClick={() => {
|
||||
for (const t of threads) {
|
||||
if (threadUnreadCount(t, currentUser) > 0) controller.markSeen(t.id);
|
||||
}
|
||||
}}
|
||||
className="rounded p-0.5 text-neutral-500 hover:bg-black/5 hover:text-neutral-900 dark:text-white/60 dark:hover:bg-white/10 dark:hover:text-white"
|
||||
>
|
||||
<CheckCheck size={14} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
data-testid="comments-panel-close"
|
||||
title="Close"
|
||||
onClick={onClose}
|
||||
className="rounded p-0.5 text-neutral-500 hover:bg-black/5 hover:text-neutral-900 dark:text-white/60 dark:hover:bg-white/10 dark:hover:text-white"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!collapsed && (
|
||||
<label
|
||||
className="flex items-center gap-1.5 border-t border-black/10 px-3 py-1.5 text-[11px] font-normal text-neutral-600 dark:border-white/10 dark:text-white/70"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<input
|
||||
data-testid="comment-show-resolved"
|
||||
type="checkbox"
|
||||
checked={showResolved}
|
||||
onChange={(e) => onShowResolved(e.target.checked)}
|
||||
/>
|
||||
show resolved
|
||||
</label>
|
||||
)}
|
||||
|
||||
{!collapsed && (
|
||||
<div data-testid="comments-panel-list" className="max-h-[60vh] overflow-y-auto">
|
||||
{sorted.length === 0 && (
|
||||
<p className="px-3 pb-3 text-xs text-neutral-500 dark:text-white/50">
|
||||
{total === 0 ? "No comments yet." : "Nothing to show — check the resolved filter."}
|
||||
</p>
|
||||
)}
|
||||
{sorted.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
data-testid="comment-panel-item"
|
||||
onClick={() => onJump(t)}
|
||||
className="block w-full border-t border-black/10 px-3 py-2 text-left text-xs hover:bg-black/5 dark:border-white/10 dark:hover:bg-white/10"
|
||||
>
|
||||
<span className="flex items-center gap-1">
|
||||
<span
|
||||
className="font-semibold"
|
||||
style={{ color: controller.colorFor(t.createdBy) }}
|
||||
title={authorLabel(t).title}
|
||||
>
|
||||
{authorLabel(t).text}
|
||||
</span>{" "}
|
||||
<span className="text-neutral-500 dark:text-white/50">
|
||||
{timeAgo(lastActivity(t))} ago
|
||||
{t.resolved ? " · resolved" : ""}
|
||||
{t.messages.length > 1 ? ` · ${t.messages.length - 1} repl${t.messages.length === 2 ? "y" : "ies"}` : ""}
|
||||
</span>
|
||||
{threadUnreadCount(t, currentUser) > 0 && (
|
||||
<span
|
||||
data-testid="comment-unread-dot"
|
||||
title={threadMentionsUnread(t, currentUser) ? "Unread — you were mentioned" : "Unread"}
|
||||
className={`ml-auto h-2 w-2 shrink-0 rounded-full ${
|
||||
threadMentionsUnread(t, currentUser) ? "bg-rose-400" : "bg-amber-400"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-neutral-800 dark:text-white/90">
|
||||
{t.messages[0]?.body ?? ""}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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>): 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<Collaborator[]>;
|
||||
onSubmit: (body: string, mentions: string[]) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [body, setBody] = React.useState("");
|
||||
const acceptedRef = React.useRef<Set<string>>(new Set());
|
||||
const submit = () => {
|
||||
if (body.trim()) onSubmit(body.trim());
|
||||
if (body.trim()) onSubmit(body.trim(), presentMentions(body, acceptedRef.current));
|
||||
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"
|
||||
className="absolute z-40 w-64 rounded-lg bg-white/95 p-2 shadow-lg ring-1 ring-inset ring-black/15 dark:bg-black/90 dark:ring-white/20"
|
||||
style={{ left: Math.min(css.x + 12, window.innerWidth - 280), top: Math.min(css.y, window.innerHeight - 120) }}
|
||||
>
|
||||
<textarea
|
||||
<MentionInput
|
||||
multiline
|
||||
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"
|
||||
onChange={setBody}
|
||||
onMention={(slug) => acceptedRef.current.add(slug)}
|
||||
onSubmit={submit}
|
||||
getCandidates={getCandidates}
|
||||
placeholder="Add a comment… (@ to mention)"
|
||||
className="h-16 w-full resize-none rounded bg-black/5 p-2 text-xs text-neutral-900 placeholder-neutral-400 outline-none dark:bg-white/10 dark:text-white dark:placeholder-white/40"
|
||||
/>
|
||||
<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">
|
||||
<button onClick={onCancel} className="rounded px-2 py-1 text-xs text-neutral-600 hover:bg-black/5 dark:text-white/70 dark:hover:bg-white/10">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
|
|
@ -451,26 +791,206 @@ function Composer({
|
|||
);
|
||||
}
|
||||
|
||||
const MENTION_RE = /@([A-Za-z0-9][\w.-]*)/g;
|
||||
|
||||
/**
|
||||
* Message body with `@slug` tokens rendered as highlight chips (comments-ux
|
||||
* 0001 E). A token highlights when its slug is in the message's `mentions`
|
||||
* (composer-accepted) or matches a known collaborator from the session-cached
|
||||
* roster (covers hand-typed mentions). Your own slug gets the amber accent.
|
||||
*/
|
||||
function MentionBody({
|
||||
body,
|
||||
mentions,
|
||||
currentUser,
|
||||
}: {
|
||||
body: string;
|
||||
mentions?: string[];
|
||||
currentUser: string;
|
||||
}) {
|
||||
const known = new Set([
|
||||
...(mentions ?? []),
|
||||
...(cachedCollaborators() ?? []).map((c) => c.slug),
|
||||
]);
|
||||
const parts: React.ReactNode[] = [];
|
||||
let last = 0;
|
||||
|
||||
for (const match of body.matchAll(MENTION_RE)) {
|
||||
const slug = match[1];
|
||||
if (!slug || !known.has(slug) || match.index === undefined) continue;
|
||||
parts.push(body.slice(last, match.index));
|
||||
parts.push(
|
||||
<span
|
||||
key={match.index}
|
||||
data-testid="comment-mention"
|
||||
data-slug={slug}
|
||||
title={`@${slug}`}
|
||||
className={`rounded px-0.5 font-medium ${
|
||||
slug === currentUser ? "bg-amber-500/25 text-amber-700 dark:text-amber-300" : "bg-sky-500/15 text-sky-700 dark:bg-sky-500/20 dark:text-sky-300"
|
||||
}`}
|
||||
>
|
||||
{match[0]}
|
||||
</span>,
|
||||
);
|
||||
last = match.index + match[0].length;
|
||||
}
|
||||
parts.push(body.slice(last));
|
||||
|
||||
return <p className="mt-0.5 whitespace-pre-wrap text-neutral-800 dark:text-white/90">{parts}</p>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reaction chips + pickers for one message (comments-ux 0001 D). Chips are
|
||||
* plain text glyphs (no emoji-mart involved); the hover "add" button opens a
|
||||
* quick row of most-used emoji, whose "+" opens the lazy full picker. Both
|
||||
* popovers are body-portals at fixed coords — the popover's scroll container
|
||||
* would clip in-place absolute children.
|
||||
*/
|
||||
function MessageReactions({
|
||||
reactions,
|
||||
currentUser,
|
||||
onToggle,
|
||||
}: {
|
||||
reactions?: Record<string, string[]>;
|
||||
currentUser: string;
|
||||
onToggle: (emoji: string) => void;
|
||||
}) {
|
||||
const [picker, setPicker] = React.useState<{ kind: "quick" | "full"; x: number; y: number } | null>(null);
|
||||
const quickRef = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (picker?.kind !== "quick") return;
|
||||
const onDown = (e: PointerEvent) => {
|
||||
if (!quickRef.current?.contains(e.target as Node)) setPicker(null);
|
||||
};
|
||||
// Capture + stopPropagation: Esc closes the quick row, not the thread
|
||||
// popover underneath (CommentLayer's own window Esc handler).
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
setPicker(null);
|
||||
}
|
||||
};
|
||||
window.addEventListener("pointerdown", onDown, true);
|
||||
window.addEventListener("keydown", onKey, true);
|
||||
return () => {
|
||||
window.removeEventListener("pointerdown", onDown, true);
|
||||
window.removeEventListener("keydown", onKey, true);
|
||||
};
|
||||
}, [picker?.kind]);
|
||||
|
||||
const entries = Object.entries(reactions ?? {}).sort(
|
||||
(a, b) => b[1].length - a[1].length || a[0].localeCompare(b[0]),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1">
|
||||
{entries.map(([emoji, slugs]) => (
|
||||
<button
|
||||
key={emoji}
|
||||
data-testid="comment-reaction-chip"
|
||||
data-emoji={emoji}
|
||||
title={slugs.join(", ")}
|
||||
onClick={() => onToggle(emoji)}
|
||||
className={`flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[11px] ring-1 ring-inset ${
|
||||
slugs.includes(currentUser)
|
||||
? "bg-sky-500/20 ring-sky-500/50 dark:bg-sky-500/25 dark:ring-sky-300/50"
|
||||
: "bg-black/5 ring-black/15 hover:bg-black/10 dark:bg-white/5 dark:ring-white/15 dark:hover:bg-white/10"
|
||||
}`}
|
||||
>
|
||||
<span>{emoji}</span>
|
||||
<span className="text-neutral-600 dark:text-white/70">{slugs.length}</span>
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
data-testid="comment-react"
|
||||
title="Add reaction"
|
||||
onClick={(e) => {
|
||||
const r = e.currentTarget.getBoundingClientRect();
|
||||
setPicker((p) => (p ? null : { kind: "quick", x: r.x, y: r.bottom }));
|
||||
}}
|
||||
className={`rounded-full p-1 text-neutral-400 ring-1 ring-inset ring-black/15 hover:bg-black/5 hover:text-neutral-900 dark:text-white/50 dark:ring-white/15 dark:hover:bg-white/10 dark:hover:text-white ${
|
||||
entries.length ? "" : "opacity-0 group-hover:opacity-100 focus-visible:opacity-100"
|
||||
}`}
|
||||
>
|
||||
<SmilePlus size={12} />
|
||||
</button>
|
||||
|
||||
{picker?.kind === "quick" &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={quickRef}
|
||||
data-testid="comment-quick-react"
|
||||
className="fixed z-[70] flex items-center gap-0.5 rounded-full bg-white px-1.5 py-1 shadow-xl ring-1 ring-black/10 dark:bg-neutral-900 dark:ring-white/15"
|
||||
style={{
|
||||
left: Math.max(8, Math.min(picker.x, window.innerWidth - 260)),
|
||||
top: Math.min(picker.y + 4, window.innerHeight - 44),
|
||||
}}
|
||||
>
|
||||
{quickEmojis().map((e) => (
|
||||
<button
|
||||
key={e}
|
||||
data-emoji={e}
|
||||
title={`React ${e}`}
|
||||
onClick={() => {
|
||||
onToggle(e);
|
||||
setPicker(null);
|
||||
}}
|
||||
className="rounded px-1 text-sm hover:bg-black/5 dark:hover:bg-white/10"
|
||||
>
|
||||
{e}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
data-testid="comment-react-more"
|
||||
title="All emoji…"
|
||||
onClick={() => setPicker((p) => (p ? { ...p, kind: "full" } : p))}
|
||||
className="rounded px-1 text-neutral-500 hover:bg-black/5 hover:text-neutral-900 dark:text-white/60 dark:hover:bg-white/10 dark:hover:text-white"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
|
||||
{picker?.kind === "full" && (
|
||||
<EmojiPickerPopover
|
||||
anchor={picker}
|
||||
onPick={(e) => {
|
||||
onToggle(e);
|
||||
setPicker(null);
|
||||
}}
|
||||
onClose={() => setPicker(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ThreadPopover({
|
||||
thread,
|
||||
css,
|
||||
currentUser,
|
||||
controller,
|
||||
getCandidates,
|
||||
onClose,
|
||||
}: {
|
||||
thread: ResolvedThread;
|
||||
css: { x: number; y: number };
|
||||
currentUser: string;
|
||||
controller: CommentsController;
|
||||
getCandidates: () => Promise<Collaborator[]>;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [reply, setReply] = React.useState("");
|
||||
const [editing, setEditing] = React.useState<{ id: string; body: string } | null>(null);
|
||||
const replyMentionsRef = React.useRef<Set<string>>(new Set());
|
||||
|
||||
const sendReply = () => {
|
||||
if (reply.trim()) {
|
||||
controller.reply(thread.id, reply.trim());
|
||||
controller.reply(thread.id, reply.trim(), presentMentions(reply, replyMentionsRef.current));
|
||||
setReply("");
|
||||
replyMentionsRef.current = new Set();
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -482,14 +1002,14 @@ function ThreadPopover({
|
|||
return (
|
||||
<div
|
||||
data-testid="comment-popover"
|
||||
className="absolute z-[60] w-72 rounded-lg bg-black/90 text-white shadow-lg ring-1 ring-inset ring-white/20"
|
||||
className="absolute z-[60] w-72 rounded-lg bg-white/95 text-neutral-900 shadow-lg ring-1 ring-inset ring-black/15 dark:bg-black/90 dark:text-white dark: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">
|
||||
<span className="text-xs text-neutral-500 dark:text-white/60">
|
||||
{thread.detached && "detached pin · "}
|
||||
{thread.resolved ? "resolved" : "open"}
|
||||
</span>
|
||||
|
|
@ -497,7 +1017,7 @@ function ThreadPopover({
|
|||
<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"
|
||||
className="rounded px-1.5 py-0.5 text-[11px] text-neutral-700 ring-1 ring-inset ring-black/20 hover:bg-black/5 dark:text-white/80 dark:ring-white/25 dark:hover:bg-white/10"
|
||||
>
|
||||
{thread.resolved ? "Reopen" : "Resolve"}
|
||||
</button>
|
||||
|
|
@ -514,13 +1034,13 @@ function ThreadPopover({
|
|||
Delete
|
||||
</button>
|
||||
)}
|
||||
<button onClick={onClose} title="Close" className="text-white/60 hover:text-white">
|
||||
<button onClick={onClose} title="Close" className="text-neutral-500 hover:text-neutral-900 dark:text-white/60 dark:hover:text-white">
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-56 overflow-y-auto border-t border-white/10">
|
||||
<div className="max-h-56 overflow-y-auto border-t border-black/10 dark: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">
|
||||
|
|
@ -531,7 +1051,7 @@ function ThreadPopover({
|
|||
>
|
||||
{authorLabel(m).text}
|
||||
</span>
|
||||
<span className="text-[10px] text-white/40">
|
||||
<span className="text-[10px] text-neutral-400 dark:text-white/40">
|
||||
{timeAgo(m.createdAt)} ago{m.editedAt ? " · edited" : ""}
|
||||
</span>
|
||||
{m.author === currentUser && (
|
||||
|
|
@ -539,7 +1059,7 @@ function ThreadPopover({
|
|||
<button
|
||||
data-testid="comment-edit"
|
||||
onClick={() => setEditing({ id: m.id, body: m.body })}
|
||||
className="text-[10px] text-white/60 hover:text-white"
|
||||
className="text-[10px] text-neutral-500 hover:text-neutral-900 dark:text-white/60 dark:hover:text-white"
|
||||
>
|
||||
edit
|
||||
</button>
|
||||
|
|
@ -568,26 +1088,34 @@ function ThreadPopover({
|
|||
setEditing(null);
|
||||
}
|
||||
}}
|
||||
className="h-12 w-full resize-none rounded bg-white/10 p-1.5 text-xs text-white outline-none"
|
||||
className="h-12 w-full resize-none rounded bg-black/5 p-1.5 text-xs text-neutral-900 outline-none dark:bg-white/10 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-0.5 whitespace-pre-wrap text-white/90">{m.body}</p>
|
||||
<MentionBody body={m.body} mentions={m.mentions} currentUser={currentUser} />
|
||||
)}
|
||||
<MessageReactions
|
||||
reactions={thread.reactions?.[m.id]}
|
||||
currentUser={currentUser}
|
||||
onToggle={(emoji) => {
|
||||
controller.toggleReaction(thread.id, m.id, emoji);
|
||||
noteEmojiUsed(emoji);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-white/10 p-2">
|
||||
<input
|
||||
data-testid="comment-reply"
|
||||
<div className="border-t border-black/10 p-2 dark:border-white/10">
|
||||
<MentionInput
|
||||
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"
|
||||
onChange={setReply}
|
||||
onMention={(slug) => replyMentionsRef.current.add(slug)}
|
||||
onSubmit={sendReply}
|
||||
getCandidates={getCandidates}
|
||||
placeholder="Reply… (@ to mention)"
|
||||
className="w-full rounded bg-black/5 px-2 py-1.5 text-xs text-neutral-900 placeholder-neutral-400 outline-none dark:bg-white/10 dark:text-white dark:placeholder-white/40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
108
web/standalone/src/components/EmojiPicker.tsx
Normal file
108
web/standalone/src/components/EmojiPicker.tsx
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import * as React from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useThemeValue } from "@/lib/theme";
|
||||
|
||||
/**
|
||||
* Full emoji picker for comment reactions (comments-ux 0001 D): emoji-mart —
|
||||
* categories, search, skin tones, frequently-used — LAZY-LOADED so the picker
|
||||
* component and its ~0.5 MB data JSON become their own chunk, fetched on the
|
||||
* first "+" click and never in the boot path. The data is bundled locally
|
||||
* (no CDN fetch): the standalone must work in offline/@local mode.
|
||||
*
|
||||
* Reaction CHIPS need none of this — they render native glyphs as plain text.
|
||||
*/
|
||||
|
||||
interface EmojiSelection {
|
||||
native?: string;
|
||||
}
|
||||
|
||||
const LazyPicker = React.lazy(async () => {
|
||||
const [{ default: data }, { default: Picker }] = await Promise.all([
|
||||
import("@emoji-mart/data"),
|
||||
import("@emoji-mart/react"),
|
||||
]);
|
||||
|
||||
function PickerWithData(props: { onPick: (emoji: string) => void; theme: "light" | "dark" }) {
|
||||
return (
|
||||
<Picker
|
||||
data={data}
|
||||
theme={props.theme}
|
||||
previewPosition="none"
|
||||
autoFocus
|
||||
onEmojiSelect={(e: EmojiSelection) => {
|
||||
if (e.native) props.onPick(e.native);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return { default: PickerWithData };
|
||||
});
|
||||
|
||||
// emoji-mart's rendered size — used only to clamp the popover into view.
|
||||
const PICKER_W = 352;
|
||||
const PICKER_H = 435;
|
||||
|
||||
/**
|
||||
* Click-away-dismissed full picker, PORTALED to the body at a fixed position
|
||||
* (anchor = the trigger's rect): the comment popover's scroll container would
|
||||
* clip an absolutely-positioned child, and the picker is bigger than the
|
||||
* popover anyway. z-[70]: above the thread popover's deliberate z-[60].
|
||||
*/
|
||||
export function EmojiPickerPopover({
|
||||
anchor,
|
||||
onPick,
|
||||
onClose,
|
||||
}: {
|
||||
anchor: { x: number; y: number };
|
||||
onPick: (emoji: string) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const ref = React.useRef<HTMLDivElement | null>(null);
|
||||
const theme = useThemeValue();
|
||||
|
||||
React.useEffect(() => {
|
||||
const onDown = (e: PointerEvent) => {
|
||||
if (!ref.current?.contains(e.target as Node)) onClose();
|
||||
};
|
||||
// Capture phase: emoji-mart's search input consumes Escape (clears the
|
||||
// query, stops propagation) before a bubble listener would see it.
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
// The picker is the focused surface: Esc closes IT, not the thread
|
||||
// popover underneath (CommentLayer's own window Esc handler).
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener("pointerdown", onDown, true);
|
||||
window.addEventListener("keydown", onKey, true);
|
||||
return () => {
|
||||
window.removeEventListener("pointerdown", onDown, true);
|
||||
window.removeEventListener("keydown", onKey, true);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={ref}
|
||||
data-testid="emoji-picker"
|
||||
className="fixed z-[70]"
|
||||
style={{
|
||||
left: Math.max(8, Math.min(anchor.x, window.innerWidth - PICKER_W - 8)),
|
||||
top: Math.max(8, Math.min(anchor.y + 4, window.innerHeight - PICKER_H - 8)),
|
||||
}}
|
||||
>
|
||||
<React.Suspense
|
||||
fallback={
|
||||
<div className="rounded-lg bg-white px-3 py-2 text-xs text-neutral-500 shadow-xl ring-1 ring-black/10 dark:bg-neutral-900 dark:text-white/60 dark:ring-white/15">
|
||||
Loading emoji…
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<LazyPicker onPick={onPick} theme={theme} />
|
||||
</React.Suspense>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
206
web/standalone/src/components/MentionInput.tsx
Normal file
206
web/standalone/src/components/MentionInput.tsx
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
import * as React from "react";
|
||||
import type { Collaborator } from "@pcbjam/shared";
|
||||
import { filterCandidates } from "@/lib/mentions";
|
||||
|
||||
/**
|
||||
* Text input/textarea with `@`-mention autocomplete (comments-ux 0001 E).
|
||||
* Typing `@` at a word start opens a combobox under the field (candidates are
|
||||
* fetched lazily on that first keystroke); ↑/↓ navigate, Enter/Tab accept,
|
||||
* Esc dismisses (without closing the surrounding popover). Accepting inserts
|
||||
* `@slug ` into the plain-text body and reports the slug via `onMention` —
|
||||
* no contentEditable, no rich text. Hand-typed `@slug` text is legal too; it
|
||||
* just isn't recorded in the message's `mentions` array.
|
||||
*/
|
||||
|
||||
interface ActiveToken {
|
||||
start: number; // index of the "@"
|
||||
query: string;
|
||||
}
|
||||
|
||||
function activeToken(value: string, caret: number): ActiveToken | null {
|
||||
const at = value.lastIndexOf("@", caret - 1);
|
||||
if (at < 0) return null;
|
||||
// The @ must start a word (start-of-text or after whitespace).
|
||||
if (at > 0 && !/\s/.test(value.charAt(at - 1))) return null;
|
||||
const between = value.slice(at + 1, caret);
|
||||
if (/[\s@]/.test(between)) return null;
|
||||
return { start: at, query: between };
|
||||
}
|
||||
|
||||
const MAX_ITEMS = 6;
|
||||
|
||||
export function MentionInput({
|
||||
value,
|
||||
onChange,
|
||||
onMention,
|
||||
onSubmit,
|
||||
getCandidates,
|
||||
multiline = false,
|
||||
placeholder,
|
||||
autoFocus = false,
|
||||
className,
|
||||
testId,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
/** An autocomplete completion was accepted. */
|
||||
onMention: (slug: string) => void;
|
||||
/** Enter (without Shift, combobox closed). */
|
||||
onSubmit?: () => void;
|
||||
/** Lazy candidate source — first `@` keystroke triggers it, then cached. */
|
||||
getCandidates: () => Promise<Collaborator[]>;
|
||||
multiline?: boolean;
|
||||
placeholder?: string;
|
||||
autoFocus?: boolean;
|
||||
className?: string;
|
||||
testId?: string;
|
||||
}) {
|
||||
const [open, setOpen] = React.useState<{
|
||||
token: ActiveToken;
|
||||
items: Collaborator[];
|
||||
sel: number;
|
||||
} | null>(null);
|
||||
const inputRef = React.useRef<HTMLTextAreaElement | HTMLInputElement | null>(null);
|
||||
const candidatesRef = React.useRef<Collaborator[] | null>(null);
|
||||
|
||||
const refresh = (val: string, caret: number) => {
|
||||
const token = activeToken(val, caret);
|
||||
if (!token) {
|
||||
setOpen(null);
|
||||
return;
|
||||
}
|
||||
const apply = (cands: Collaborator[]) => {
|
||||
const items = filterCandidates(cands, token.query).slice(0, MAX_ITEMS);
|
||||
setOpen(items.length ? { token, items, sel: 0 } : null);
|
||||
};
|
||||
if (candidatesRef.current) {
|
||||
apply(candidatesRef.current);
|
||||
} else {
|
||||
void getCandidates().then((c) => {
|
||||
candidatesRef.current = c;
|
||||
// Re-derive against the CURRENT field state — the user kept typing
|
||||
// while the roster loaded.
|
||||
const el = inputRef.current;
|
||||
if (!el) return;
|
||||
const now = activeToken(el.value, el.selectionStart ?? el.value.length);
|
||||
if (now) {
|
||||
const items = filterCandidates(c, now.query).slice(0, MAX_ITEMS);
|
||||
setOpen(items.length ? { token: now, items, sel: 0 } : null);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const accept = (c: Collaborator) => {
|
||||
if (!open) return;
|
||||
const el = inputRef.current;
|
||||
const caret = el?.selectionStart ?? value.length;
|
||||
const inserted = `@${c.slug} `;
|
||||
const next = value.slice(0, open.token.start) + inserted + value.slice(caret);
|
||||
onMention(c.slug);
|
||||
onChange(next);
|
||||
setOpen(null);
|
||||
const pos = open.token.start + inserted.length;
|
||||
requestAnimationFrame(() => {
|
||||
el?.focus();
|
||||
el?.setSelectionRange(pos, pos);
|
||||
});
|
||||
};
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (open) {
|
||||
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
const d = e.key === "ArrowDown" ? 1 : -1;
|
||||
setOpen({ ...open, sel: (open.sel + d + open.items.length) % open.items.length });
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter" || e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
const item = open.items[open.sel];
|
||||
if (item) accept(item);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
// Only dismiss the combobox — not the popover/mode Esc handlers.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setOpen(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (e.key === "Enter" && !e.shiftKey && onSubmit) {
|
||||
e.preventDefault();
|
||||
onSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
const onInput = (e: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => {
|
||||
onChange(e.target.value);
|
||||
refresh(e.target.value, e.target.selectionStart ?? e.target.value.length);
|
||||
};
|
||||
|
||||
const shared = {
|
||||
value,
|
||||
onChange: onInput,
|
||||
onKeyDown,
|
||||
placeholder,
|
||||
autoFocus,
|
||||
className,
|
||||
"data-testid": testId,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
{multiline ? (
|
||||
<textarea
|
||||
{...shared}
|
||||
ref={(el) => {
|
||||
inputRef.current = el;
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
{...shared}
|
||||
ref={(el) => {
|
||||
inputRef.current = el;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{open && (
|
||||
<div
|
||||
data-testid="mention-combobox"
|
||||
className="absolute left-0 right-0 top-full z-[80] mt-1 overflow-hidden rounded-md bg-white shadow-xl ring-1 ring-black/10 dark:bg-neutral-900 dark:ring-white/15"
|
||||
>
|
||||
{open.items.map((c, i) => (
|
||||
<button
|
||||
key={c.slug}
|
||||
data-testid="mention-option"
|
||||
data-slug={c.slug}
|
||||
// pointerdown, not click: the field keeps focus (no blur race).
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
accept(c);
|
||||
}}
|
||||
onPointerEnter={() => setOpen((o) => (o ? { ...o, sel: i } : o))}
|
||||
className={`flex w-full items-center gap-2 px-2 py-1.5 text-left text-xs text-neutral-900 dark:text-white ${
|
||||
i === open.sel ? "bg-sky-500/20 dark:bg-sky-600/40" : "hover:bg-black/5 dark:hover:bg-white/10"
|
||||
}`}
|
||||
>
|
||||
{c.image ? (
|
||||
<img src={c.image} alt="" className="h-4 w-4 shrink-0 rounded-full" />
|
||||
) : (
|
||||
<span className="flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-black/10 text-[9px] uppercase dark:bg-white/15">
|
||||
{(c.name || c.slug).slice(0, 1)}
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate">{c.name}</span>
|
||||
<span className="ml-auto truncate text-[10px] text-neutral-400 dark:text-white/40">@{c.slug}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import * as React from "react";
|
||||
import { Users } from "lucide-react";
|
||||
import { useDraggablePanel } from "@/components/useDraggablePanel";
|
||||
|
||||
/**
|
||||
* Unified overlay menu (collab-presence 0010): the single circular icon that
|
||||
|
|
@ -32,8 +33,9 @@ import { Users } from "lucide-react";
|
|||
* reads as a list; the hover/active states are the only affordance needed. */
|
||||
export const overlayRowClass =
|
||||
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs " +
|
||||
"text-white/90 transition-colors hover:bg-white/10 " +
|
||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-white/40";
|
||||
"text-neutral-800 transition-colors hover:bg-black/5 " +
|
||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-black/30 " +
|
||||
"dark:text-white/90 dark:hover:bg-white/10 dark:focus-visible:ring-white/40";
|
||||
|
||||
/** A labelled group. `label` is omitted for the first/unnamed group. */
|
||||
export function OverlayMenuSection({
|
||||
|
|
@ -44,9 +46,9 @@ export function OverlayMenuSection({
|
|||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-1 border-t border-white/10 pt-2 first:border-t-0 first:pt-0">
|
||||
<div className="flex w-full flex-col gap-1 border-t border-black/10 pt-2 first:border-t-0 first:pt-0 dark:border-white/10">
|
||||
{label && (
|
||||
<div className="px-2 text-[10px] font-semibold uppercase tracking-wide text-white/40">
|
||||
<div className="px-2 text-[10px] font-semibold uppercase tracking-wide text-neutral-400 dark:text-white/40">
|
||||
{label}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -57,50 +59,32 @@ export function OverlayMenuSection({
|
|||
|
||||
const POS_KEY = "pcbjam:overlay-menu-pos";
|
||||
const FAB_SIZE = 36;
|
||||
const DRAG_THRESHOLD_PX = 4;
|
||||
|
||||
type Pos = { x: number; y: number };
|
||||
|
||||
function loadPos(): Pos | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(POS_KEY);
|
||||
if (!raw) return null;
|
||||
const p = JSON.parse(raw) as Pos;
|
||||
return typeof p.x === "number" && typeof p.y === "number" ? p : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function clamp(p: Pos): Pos {
|
||||
return {
|
||||
x: Math.min(Math.max(p.x, 4), window.innerWidth - FAB_SIZE - 4),
|
||||
y: Math.min(Math.max(p.y, 4), window.innerHeight - FAB_SIZE - 4),
|
||||
};
|
||||
}
|
||||
|
||||
export function OverlayMenu({
|
||||
badge,
|
||||
unread = 0,
|
||||
unreadMention = false,
|
||||
children,
|
||||
}: {
|
||||
/** Peer count shown on the FAB (0 hides the badge). */
|
||||
badge: number;
|
||||
/** Unread comment threads (comments-ux 0001 C) — amber FAB badge, bottom
|
||||
* corner; rose when one of them mentions the current user. 0 hides it. */
|
||||
unread?: number;
|
||||
unreadMention?: boolean;
|
||||
/** Panel sections, rendered top-to-bottom. Falsy children collapse. */
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [pos, setPos] = React.useState<Pos | null>(() =>
|
||||
typeof window === "undefined" ? null : loadPos(),
|
||||
);
|
||||
const rootRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const dragRef = React.useRef<{
|
||||
pointerId: number;
|
||||
startX: number;
|
||||
startY: number;
|
||||
fabX: number;
|
||||
fabY: number;
|
||||
moved: boolean;
|
||||
} | null>(null);
|
||||
// Drag/clamp/persist behavior shared with the comments panel (0001 B) —
|
||||
// including the always-onscreen restore guarantee.
|
||||
const drag = useDraggablePanel({
|
||||
storageKey: POS_KEY,
|
||||
handleWidth: FAB_SIZE,
|
||||
handleHeight: FAB_SIZE,
|
||||
});
|
||||
const pos = drag.pos;
|
||||
|
||||
// Esc closes (bubble phase, same etiquette as the comment layer — wx also
|
||||
// sees the key, matching how every other overlay treats Escape).
|
||||
|
|
@ -125,57 +109,16 @@ export function OverlayMenu({
|
|||
}, [open]);
|
||||
|
||||
const onFabPointerDown = (e: React.PointerEvent) => {
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
const rect = rootRef.current!.getBoundingClientRect();
|
||||
dragRef.current = {
|
||||
pointerId: e.pointerId,
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
fabX: rect.x,
|
||||
fabY: rect.y,
|
||||
moved: false,
|
||||
};
|
||||
drag.onPointerDown(e, rootRef.current!.getBoundingClientRect());
|
||||
};
|
||||
|
||||
const onFabPointerMove = (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;
|
||||
setOpen(false); // dragging repositions; the click that follows reopens
|
||||
}
|
||||
setPos(
|
||||
clamp({
|
||||
x: d.fabX + (e.clientX - d.startX),
|
||||
y: d.fabY + (e.clientY - d.startY),
|
||||
}),
|
||||
);
|
||||
// Dragging repositions; the click that follows reopens.
|
||||
if (drag.onPointerMove(e)) setOpen(false);
|
||||
};
|
||||
|
||||
const onFabPointerUp = () => {
|
||||
const d = dragRef.current;
|
||||
dragRef.current = null;
|
||||
if (!d) return;
|
||||
if (d.moved) {
|
||||
setPos((p) => {
|
||||
if (p) {
|
||||
try {
|
||||
localStorage.setItem(POS_KEY, JSON.stringify(p));
|
||||
} catch {
|
||||
/* private mode — position just doesn't persist */
|
||||
}
|
||||
}
|
||||
return p;
|
||||
});
|
||||
} else {
|
||||
setOpen((o) => !o);
|
||||
}
|
||||
if (!drag.onPointerUp()) setOpen((o) => !o);
|
||||
};
|
||||
|
||||
// Default anchor: top-right (the old row's home). After a drag, explicit px.
|
||||
|
|
@ -196,10 +139,11 @@ export function OverlayMenu({
|
|||
onPointerDown={onFabPointerDown}
|
||||
onPointerMove={onFabPointerMove}
|
||||
onPointerUp={onFabPointerUp}
|
||||
className={`relative flex h-9 w-9 items-center justify-center rounded-full text-white shadow-lg ring-1 ring-inset transition-colors ${
|
||||
className={`relative flex h-9 w-9 items-center justify-center rounded-full shadow-lg ring-1 ring-inset transition-colors ${
|
||||
open
|
||||
? "bg-sky-600 ring-sky-300/40"
|
||||
: "bg-neutral-950/80 ring-white/15 backdrop-blur-sm hover:bg-neutral-900/90"
|
||||
? "bg-sky-600 text-white ring-sky-300/40"
|
||||
: "bg-white/90 text-neutral-700 ring-black/15 backdrop-blur-sm hover:bg-white " +
|
||||
"dark:bg-neutral-950/80 dark:text-white dark:ring-white/15 dark:hover:bg-neutral-900/90"
|
||||
}`}
|
||||
style={{ touchAction: "none" }}
|
||||
>
|
||||
|
|
@ -212,21 +156,32 @@ export function OverlayMenu({
|
|||
{badge}
|
||||
</span>
|
||||
)}
|
||||
{unread > 0 && (
|
||||
<span
|
||||
data-testid="overlay-menu-unread-badge"
|
||||
title={unreadMention ? "Unread comments — you were mentioned" : "Unread comments"}
|
||||
className={`absolute -bottom-1 -right-1 flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px] font-semibold text-white ${
|
||||
unreadMention ? "bg-rose-500" : "bg-amber-500"
|
||||
}`}
|
||||
>
|
||||
{unread}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
data-testid="overlay-menu-panel"
|
||||
className={`absolute flex w-72 flex-col gap-2 rounded-xl bg-neutral-950/90 p-2 shadow-2xl ring-1 ring-inset ring-white/15 backdrop-blur-sm ${
|
||||
className={`absolute flex w-72 flex-col gap-2 rounded-xl bg-white/95 p-2 shadow-2xl ring-1 ring-inset ring-black/10 backdrop-blur-sm dark:bg-neutral-950/90 dark:ring-white/15 ${
|
||||
onLeftHalf ? "left-0" : "right-0"
|
||||
} ${onTopHalf ? "top-11" : "bottom-11"}`}
|
||||
>
|
||||
<div className="flex items-center justify-between px-2 pt-0.5">
|
||||
<span className="text-[11px] font-semibold tracking-wide text-white/70">
|
||||
<span className="text-[11px] font-semibold tracking-wide text-neutral-600 dark:text-white/70">
|
||||
Session
|
||||
</span>
|
||||
{badge > 0 && (
|
||||
<span className="text-[10px] text-white/40">
|
||||
<span className="text-[10px] text-neutral-400 dark:text-white/40">
|
||||
{badge} {badge === 1 ? "other" : "others"} here
|
||||
</span>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ export function PresenceRoster({
|
|||
: `${p.user.name} — on ${elsewhere}`
|
||||
}
|
||||
className={`${overlayRowClass} ${
|
||||
followed ? "bg-white/10" : ""
|
||||
followed ? "bg-black/10 dark:bg-white/10" : ""
|
||||
} ${here ? "" : "cursor-default opacity-50 hover:bg-transparent"}`}
|
||||
>
|
||||
<span
|
||||
|
|
@ -101,17 +101,17 @@ export function PresenceRoster({
|
|||
/>
|
||||
<span className="truncate">{p.user.name}</span>
|
||||
{!here && (
|
||||
<span className="ml-auto shrink-0 truncate text-[10px] text-white/40">
|
||||
<span className="ml-auto shrink-0 truncate text-[10px] text-neutral-400 dark:text-white/40">
|
||||
on {elsewhere}
|
||||
</span>
|
||||
)}
|
||||
{here && followed && (
|
||||
<span className="ml-auto flex shrink-0 items-center gap-1 text-[10px] font-medium text-white/70">
|
||||
<span className="ml-auto flex shrink-0 items-center gap-1 text-[10px] font-medium text-neutral-600 dark:text-white/70">
|
||||
<Eye size={12} /> Stop
|
||||
</span>
|
||||
)}
|
||||
{here && !followed && followable(p) && (
|
||||
<span className="ml-auto shrink-0 text-[10px] text-white/35">
|
||||
<span className="ml-auto shrink-0 text-[10px] text-neutral-400 dark:text-white/35">
|
||||
Follow
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -119,7 +119,7 @@ export function PresenceRoster({
|
|||
);
|
||||
})}
|
||||
{peers.length > MAX_ROWS && (
|
||||
<span className="px-2 py-1 text-[10px] text-white/40">
|
||||
<span className="px-2 py-1 text-[10px] text-neutral-400 dark:text-white/40">
|
||||
+{peers.length - MAX_ROWS} more
|
||||
</span>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import * as React from "react";
|
||||
import { setPinRadiusPx } from "@/wasm/collab/pin-geometry";
|
||||
import { PRESENCE_COLORS } from "@pcbjam/shared";
|
||||
import { Palette, X } from "lucide-react";
|
||||
|
||||
|
|
@ -57,11 +58,13 @@ const DEFAULT_STYLE = {
|
|||
cursorLabelChip: true,
|
||||
fixedColor: "",
|
||||
palette: [] as string[],
|
||||
pinShape: 1,
|
||||
pinRadiusPx: 9,
|
||||
pinRingPx: 3,
|
||||
pinRingAlpha: 1,
|
||||
pinFillAlpha: 1,
|
||||
pinRingPx: 4,
|
||||
pinRingAlpha: 0.9,
|
||||
pinFillAlpha: 0.9,
|
||||
pinResolvedAlpha: 0.3,
|
||||
pinUnreadRingColor: "#ffb020",
|
||||
};
|
||||
|
||||
type Style = typeof DEFAULT_STYLE;
|
||||
|
|
@ -98,6 +101,7 @@ const SEL_SHAPES = [
|
|||
"exact outline (pcb)",
|
||||
];
|
||||
const CURSOR_SHAPES = ["cross", "pointer", "circle + dot"];
|
||||
const PIN_SHAPES = ["circle dot", "bubble (sharp corner)"];
|
||||
const VPOS = ["top", "bottom"];
|
||||
const HPOS = ["start", "end", "center"];
|
||||
|
||||
|
|
@ -118,6 +122,10 @@ export function PresenceTuner({ mod, tool }: { mod: TunerModule; tool: string })
|
|||
// Push on mount (restores a stored style after reload) + on every change.
|
||||
React.useEffect(() => {
|
||||
mod.kicadCollabSetStyle(JSON.stringify(style));
|
||||
// The DOM half of the comment pins (hit target / highlight / popover
|
||||
// offset in CommentLayer) mirrors the GAL radius — keep it in step with
|
||||
// the live re-style, or the highlight drifts off the drawn bubble.
|
||||
setPinRadiusPx(style.pinRadiusPx);
|
||||
try {
|
||||
localStorage.setItem(storeKey(tool), JSON.stringify(style));
|
||||
} catch {
|
||||
|
|
@ -298,9 +306,13 @@ export function PresenceTuner({ mod, tool }: { mod: TunerModule; tool: string })
|
|||
<ColorsSection style={style} set={set} />
|
||||
|
||||
<Section title="Comment pins">
|
||||
<Select label="shape" value={style.pinShape} options={PIN_SHAPES} onChange={(v) => set("pinShape", v)} />
|
||||
<Range label="radius px" v={style.pinRadiusPx} min={3} max={16} step={0.5} onChange={(v) => set("pinRadiusPx", v)} />
|
||||
<Range label="ring px" v={style.pinRingPx} min={0} max={5} step={0.25} onChange={(v) => set("pinRingPx", v)} />
|
||||
<Range label="ring α" v={style.pinRingAlpha} min={0} max={1} step={0.05} onChange={(v) => set("pinRingAlpha", v)} />
|
||||
{/* Bubble radius mirrors into the DOM hit target via pin-geometry.ts
|
||||
— tuner drift is dev-only, ship values must land in BOTH
|
||||
collab_presence_style.h and pin-geometry.ts. */}
|
||||
<Range label="fill α" v={style.pinFillAlpha} min={0.2} max={1} step={0.05} onChange={(v) => set("pinFillAlpha", v)} />
|
||||
</Section>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ export function SourceChip({
|
|||
return (
|
||||
<span
|
||||
title={descriptor.description}
|
||||
className={`inline-flex items-center gap-2 text-xs font-medium text-white/90 ${className}`}
|
||||
className={`inline-flex items-center gap-2 text-xs font-medium text-neutral-800 dark:text-white/90 ${className}`}
|
||||
>
|
||||
<Icon size={14} className={`shrink-0 ${MUTED_TONES[descriptor.kind]}`} />
|
||||
{descriptor.label}
|
||||
|
|
|
|||
23
web/standalone/src/components/ThemeToggle.tsx
Normal file
23
web/standalone/src/components/ThemeToggle.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { Moon, Sun } from "lucide-react";
|
||||
import { setTheme, useThemeValue } from "@/lib/theme";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
/** Sun/moon theme toggle (comments-ux 0002): flips `<html>.dark` + storage;
|
||||
* theme.ts subscribers (incl. the F4 canvas bridge) follow. */
|
||||
export function ThemeToggle({ className }: { className?: string }) {
|
||||
const theme = useThemeValue();
|
||||
const next = theme === "dark" ? "light" : "dark";
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-testid="theme-toggle"
|
||||
title={`Switch to ${next} mode`}
|
||||
aria-label={`Switch to ${next} mode`}
|
||||
className={className}
|
||||
onClick={() => setTheme(next)}
|
||||
>
|
||||
{theme === "dark" ? <Sun size={16} /> : <Moon size={16} />}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
|
@ -15,7 +15,7 @@ import {
|
|||
type KicadDoc,
|
||||
type Tool,
|
||||
} from "@pcbjam/shared";
|
||||
import { ChevronDown, ChevronUp, Eye, EyeOff, Loader2, PanelsTopLeft } from "lucide-react";
|
||||
import { ChevronDown, ChevronUp, Eye, EyeOff, Loader2, Moon, PanelsTopLeft, Sun } from "lucide-react";
|
||||
import {
|
||||
API_BASE_URL,
|
||||
APP_URL,
|
||||
|
|
@ -31,6 +31,7 @@ import {
|
|||
import { defaultFileName, newFileTemplate, withExtension } from "@/lib/new-file";
|
||||
import { redirectTargetFor } from "@/lib/redirect";
|
||||
import { loadSessionIdentity } from "@/lib/session-identity";
|
||||
import { setTheme, useThemeValue } from "@/lib/theme";
|
||||
import { bootKicadTool } from "@/wasm/boot";
|
||||
import { resolveWasmBase } from "@/wasm/wasm-assets";
|
||||
import {
|
||||
|
|
@ -905,6 +906,21 @@ export function WasmTool({
|
|||
const [commentsSlot, setCommentsSlot] = React.useState<HTMLDivElement | null>(null);
|
||||
const [viewportState, setViewportState] = React.useState<ViewportState | null>(null);
|
||||
const commentsRef = React.useRef<CommentsController | null>(null);
|
||||
// Unread-comments rollup for the FAB badge (comments-ux 0001 C).
|
||||
const [commentsUnread, setCommentsUnread] = React.useState({ threads: 0, mentioned: false });
|
||||
const onCommentsUnread = React.useCallback(
|
||||
(threads: number, mentioned: boolean) => setCommentsUnread({ threads, mentioned }),
|
||||
[],
|
||||
);
|
||||
// Live canvas theme (comments-ux 0002 F4): shell toggles drive the GAL color
|
||||
// theme through the bridge when the loaded wasm exposes it; older builds
|
||||
// just keep their boot-seeded theme.
|
||||
const theme = useThemeValue();
|
||||
React.useEffect(() => {
|
||||
if (!ready) return;
|
||||
const mod = (window as { Module?: { kicadSetColorTheme?: (name: string) => void } }).Module;
|
||||
mod?.kicadSetColorTheme?.(theme === "dark" ? "pcbjam-dark" : "_builtin_default");
|
||||
}, [theme, ready]);
|
||||
// Dev-time presence style tuner (VITE_PRESENCE_TUNER=1) — set once the wasm
|
||||
// exposes the style bridge, mounts the floating panel.
|
||||
const [tunerMod, setTunerMod] = React.useState<TunerModule | null>(null);
|
||||
|
|
@ -1688,7 +1704,11 @@ export function WasmTool({
|
|||
comments (portal slot filled by CommentLayer), chrome toggle. It is
|
||||
the one control that stays up in canvas-only (chrome-hidden) mode. */}
|
||||
{ready && (
|
||||
<OverlayMenu badge={peers.length}>
|
||||
<OverlayMenu
|
||||
badge={peers.length}
|
||||
unread={commentsUnread.threads}
|
||||
unreadMention={commentsUnread.mentioned}
|
||||
>
|
||||
{/* PEOPLE — who else is here, and whose view you're locked to. The
|
||||
follow state lives on each person's own row (PresenceRoster), so
|
||||
there is no separate "Following…" banner to keep in sync. */}
|
||||
|
|
@ -1722,9 +1742,9 @@ export function WasmTool({
|
|||
data-testid="view-only-pill"
|
||||
className={`${overlayRowClass} cursor-default`}
|
||||
>
|
||||
<EyeOff size={14} className="shrink-0 text-white/50" />
|
||||
<EyeOff size={14} className="shrink-0 text-neutral-400 dark:text-white/50" />
|
||||
<span>View only</span>
|
||||
<span className="ml-auto text-[10px] text-white/40">
|
||||
<span className="ml-auto text-[10px] text-neutral-400 dark:text-white/40">
|
||||
read-only
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -1742,8 +1762,8 @@ export function WasmTool({
|
|||
</OverlayMenuSection>
|
||||
)}
|
||||
|
||||
{setChromeFn !== null && !readOnly && (
|
||||
<OverlayMenuSection label="View">
|
||||
{setChromeFn !== null && !readOnly && (
|
||||
<button
|
||||
data-testid="chrome-toggle"
|
||||
aria-pressed={chromeHidden}
|
||||
|
|
@ -1752,17 +1772,34 @@ export function WasmTool({
|
|||
onClick={() => toggleChromeHidden()}
|
||||
>
|
||||
{chromeHidden ? (
|
||||
<PanelsTopLeft size={14} className="shrink-0 text-white/50" />
|
||||
<PanelsTopLeft size={14} className="shrink-0 text-neutral-400 dark:text-white/50" />
|
||||
) : (
|
||||
<EyeOff size={14} className="shrink-0 text-white/50" />
|
||||
<EyeOff size={14} className="shrink-0 text-neutral-400 dark:text-white/50" />
|
||||
)}
|
||||
<span>{chromeHidden ? "Show UI" : "Hide UI"}</span>
|
||||
<kbd className="ml-auto rounded bg-white/10 px-1.5 py-0.5 text-[10px] font-medium text-white/50">
|
||||
<kbd className="ml-auto rounded bg-black/10 px-1.5 py-0.5 text-[10px] font-medium text-neutral-500 dark:bg-white/10 dark:text-white/50">
|
||||
{CHROME_HOTKEY_LABEL}
|
||||
</kbd>
|
||||
</button>
|
||||
</OverlayMenuSection>
|
||||
)}
|
||||
{/* Light/dark toggle (comments-ux 0002): flips the shell theme;
|
||||
the F4 effect above re-themes the GAL canvas through the
|
||||
bridge. Available to viewers too — theming isn't editing. */}
|
||||
<button
|
||||
data-testid="overlay-theme-toggle"
|
||||
aria-pressed={theme === "dark"}
|
||||
className={overlayRowClass}
|
||||
title={`Switch to ${theme === "dark" ? "light" : "dark"} mode`}
|
||||
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
||||
>
|
||||
{theme === "dark" ? (
|
||||
<Sun size={14} className="shrink-0 text-neutral-400 dark:text-white/50" />
|
||||
) : (
|
||||
<Moon size={14} className="shrink-0 text-neutral-400 dark:text-white/50" />
|
||||
)}
|
||||
<span>{theme === "dark" ? "Light mode" : "Dark mode"}</span>
|
||||
</button>
|
||||
</OverlayMenuSection>
|
||||
</OverlayMenu>
|
||||
)}
|
||||
|
||||
|
|
@ -1775,6 +1812,11 @@ export function WasmTool({
|
|||
viewport={viewportState}
|
||||
currentUser={presenceUser().id}
|
||||
menuSlot={commentsSlot}
|
||||
onUnreadChange={onCommentsUnread}
|
||||
mentionPeers={peers.map((p) => ({
|
||||
slug: p.user.id,
|
||||
name: p.user.name || p.user.id,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
36
web/standalone/src/components/useDraggablePanel.test.ts
Normal file
36
web/standalone/src/components/useDraggablePanel.test.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { restorePosition } from "./useDraggablePanel";
|
||||
|
||||
const VP = { w: 1200, h: 800 };
|
||||
|
||||
describe("restorePosition (comments-ux 0001 B always-onscreen guarantee)", () => {
|
||||
it("restores fraction entries scaled to the current viewport", () => {
|
||||
const p = restorePosition(JSON.stringify({ fx: 0.5, fy: 0.5 }), VP, 36, 36, 4);
|
||||
expect(p).toEqual({ x: 4 + 0.5 * (1200 - 36 - 8), y: 4 + 0.5 * (800 - 36 - 8) });
|
||||
});
|
||||
|
||||
it("clamps out-of-range fractions onscreen instead of discarding", () => {
|
||||
const p = restorePosition(JSON.stringify({ fx: 7, fy: -3 }), VP, 36, 36, 4);
|
||||
expect(p).toEqual({ x: 4 + (1200 - 36 - 8), y: 4 });
|
||||
});
|
||||
|
||||
it("keeps a legacy px entry that is still fully visible", () => {
|
||||
expect(restorePosition(JSON.stringify({ x: 100, y: 100 }), VP, 36, 36)).toEqual({
|
||||
x: 100,
|
||||
y: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it("discards a legacy px entry outside the current viewport (secondary display gone)", () => {
|
||||
expect(restorePosition(JSON.stringify({ x: 2500, y: 100 }), VP, 36, 36)).toBeNull();
|
||||
expect(restorePosition(JSON.stringify({ x: 100, y: -50 }), VP, 36, 36)).toBeNull();
|
||||
// Partially visible but the handle would stick out — also reset.
|
||||
expect(restorePosition(JSON.stringify({ x: 1190, y: 100 }), VP, 36, 36)).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores garbage", () => {
|
||||
expect(restorePosition(null, VP, 36, 36)).toBeNull();
|
||||
expect(restorePosition("not json", VP, 36, 36)).toBeNull();
|
||||
expect(restorePosition(JSON.stringify({ nope: 1 }), VP, 36, 36)).toBeNull();
|
||||
});
|
||||
});
|
||||
172
web/standalone/src/components/useDraggablePanel.ts
Normal file
172
web/standalone/src/components/useDraggablePanel.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import * as React from "react";
|
||||
|
||||
/**
|
||||
* Shared draggable-overlay behavior (comments-ux 0001 B), extracted from the
|
||||
* 0010 OverlayMenu FAB and reused by the floating comments panel: pointer-
|
||||
* capture dragging with a click-vs-drag threshold, viewport clamping, and
|
||||
* per-panel position persistence with an ALWAYS-ONSCREEN restore guarantee.
|
||||
*
|
||||
* Persistence stores FRACTIONS of the viewport's free space, not absolute px:
|
||||
* an ordinary window resize keeps the element roughly where the user left it,
|
||||
* and a restore can never land offscreen (fractions clamp to [0,1]). Legacy
|
||||
* absolute-px entries (pre-fraction format, possibly from a bigger or
|
||||
* secondary display) are honored only if the drag handle would still be fully
|
||||
* visible — otherwise they are discarded and the caller's default CSS anchor
|
||||
* applies. Live window resizes re-clamp mid-session, so the handle can never
|
||||
* be stranded outside the viewport.
|
||||
*/
|
||||
|
||||
type Pos = { x: number; y: number };
|
||||
|
||||
const DRAG_THRESHOLD_PX = 4;
|
||||
|
||||
function freeSpace(handleW: number, handleH: number, margin: number) {
|
||||
return {
|
||||
w: Math.max(0, window.innerWidth - handleW - 2 * margin),
|
||||
h: Math.max(0, window.innerHeight - handleH - 2 * margin),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a stored position against the CURRENT viewport (pure — exported for
|
||||
* tests). Fraction entries always restore onscreen (clamped to [0,1] of the
|
||||
* free space); legacy absolute-px entries are honored only if the handle
|
||||
* would still be fully visible, else discarded (null = default anchor).
|
||||
*/
|
||||
export function restorePosition(
|
||||
raw: string | null,
|
||||
viewport: { w: number; h: number },
|
||||
handleW: number,
|
||||
handleH: number,
|
||||
margin = 4,
|
||||
): Pos | null {
|
||||
if (!raw) return null;
|
||||
let v: Partial<{ fx: number; fy: number; x: number; y: number }>;
|
||||
try {
|
||||
v = JSON.parse(raw) as typeof v;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (typeof v.fx === "number" && typeof v.fy === "number") {
|
||||
const w = Math.max(0, viewport.w - handleW - 2 * margin);
|
||||
const h = Math.max(0, viewport.h - handleH - 2 * margin);
|
||||
return {
|
||||
x: margin + Math.min(Math.max(v.fx, 0), 1) * w,
|
||||
y: margin + Math.min(Math.max(v.fy, 0), 1) * h,
|
||||
};
|
||||
}
|
||||
if (typeof v.x === "number" && typeof v.y === "number") {
|
||||
const onscreen =
|
||||
v.x >= 0 && v.y >= 0 && v.x + handleW <= viewport.w && v.y + handleH <= viewport.h;
|
||||
return onscreen ? { x: v.x, y: v.y } : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function useDraggablePanel(opts: {
|
||||
storageKey: string;
|
||||
/** Clamp box of the DRAG HANDLE (FAB / header bar) — what stays onscreen. */
|
||||
handleWidth: number;
|
||||
handleHeight: number;
|
||||
margin?: number;
|
||||
}) {
|
||||
const { storageKey, handleWidth, handleHeight, margin = 4 } = opts;
|
||||
|
||||
const clamp = React.useCallback(
|
||||
(p: Pos): Pos => {
|
||||
const f = freeSpace(handleWidth, handleHeight, margin);
|
||||
return {
|
||||
x: Math.min(Math.max(p.x, margin), margin + f.w),
|
||||
y: Math.min(Math.max(p.y, margin), margin + f.h),
|
||||
};
|
||||
},
|
||||
[handleWidth, handleHeight, margin],
|
||||
);
|
||||
|
||||
const [pos, setPos] = React.useState<Pos | null>(() => {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
return restorePosition(
|
||||
localStorage.getItem(storageKey),
|
||||
{ w: window.innerWidth, h: window.innerHeight },
|
||||
handleWidth,
|
||||
handleHeight,
|
||||
margin,
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const persist = React.useCallback(
|
||||
(p: Pos) => {
|
||||
try {
|
||||
const f = freeSpace(handleWidth, handleHeight, margin);
|
||||
localStorage.setItem(
|
||||
storageKey,
|
||||
JSON.stringify({
|
||||
fx: f.w > 0 ? (p.x - margin) / f.w : 0,
|
||||
fy: f.h > 0 ? (p.y - margin) / f.h : 0,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
/* private mode — position just doesn't persist */
|
||||
}
|
||||
},
|
||||
[storageKey, handleWidth, handleHeight, margin],
|
||||
);
|
||||
|
||||
// A live resize must not strand the handle outside the shrunken viewport.
|
||||
React.useEffect(() => {
|
||||
const onResize = () => setPos((p) => (p ? clamp(p) : p));
|
||||
window.addEventListener("resize", onResize);
|
||||
return () => window.removeEventListener("resize", onResize);
|
||||
}, [clamp]);
|
||||
|
||||
const dragRef = React.useRef<{
|
||||
startX: number;
|
||||
startY: number;
|
||||
baseX: number;
|
||||
baseY: number;
|
||||
moved: boolean;
|
||||
} | null>(null);
|
||||
|
||||
/** `handleRect` = the dragged element's current rect (measured by caller —
|
||||
* the handle may not be the element being repositioned). */
|
||||
const onPointerDown = (e: React.PointerEvent, handleRect: { x: number; y: number }) => {
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
dragRef.current = {
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
baseX: handleRect.x,
|
||||
baseY: handleRect.y,
|
||||
moved: false,
|
||||
};
|
||||
};
|
||||
|
||||
/** Returns true the moment the gesture crosses the drag threshold (callers
|
||||
* close popups / mark "this is a drag, not a click" on that edge). */
|
||||
const onPointerMove = (e: React.PointerEvent): boolean => {
|
||||
const d = dragRef.current;
|
||||
if (!d) return false;
|
||||
let crossed = false;
|
||||
if (!d.moved) {
|
||||
if (Math.hypot(e.clientX - d.startX, e.clientY - d.startY) < DRAG_THRESHOLD_PX) return false;
|
||||
d.moved = true;
|
||||
crossed = true;
|
||||
}
|
||||
setPos(clamp({ x: d.baseX + (e.clientX - d.startX), y: d.baseY + (e.clientY - d.startY) }));
|
||||
return crossed;
|
||||
};
|
||||
|
||||
/** Ends the gesture; returns whether it was a drag (persisted) or a click. */
|
||||
const onPointerUp = (): boolean => {
|
||||
const d = dragRef.current;
|
||||
dragRef.current = null;
|
||||
if (!d) return false;
|
||||
if (d.moved) setPos((p) => (p ? (persist(p), p) : p));
|
||||
return d.moved;
|
||||
};
|
||||
|
||||
return { pos, onPointerDown, onPointerMove, onPointerUp };
|
||||
}
|
||||
48
web/standalone/src/lib/emoji-quick.ts
Normal file
48
web/standalone/src/lib/emoji-quick.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
/**
|
||||
* Quick-react row (comments-ux 0001 D): the user's most-used reaction emoji,
|
||||
* seeded with a sensible default set. Usage counts persist per browser; the
|
||||
* fixed seed is a starting point, not a limit — anything picked from the full
|
||||
* emoji-mart picker joins the rotation.
|
||||
*/
|
||||
|
||||
const KEY = "pcbjam:comment-quick-emoji";
|
||||
const SEED = ["👍", "❤️", "😄", "🎉", "👀", "✅"];
|
||||
export const QUICK_ROW_SIZE = 6;
|
||||
|
||||
function counts(): Record<string, number> {
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY);
|
||||
const v = raw ? (JSON.parse(raw) as unknown) : null;
|
||||
if (v && typeof v === "object" && !Array.isArray(v)) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(v as Record<string, unknown>).filter(([, n]) => typeof n === "number"),
|
||||
) as Record<string, number>;
|
||||
}
|
||||
} catch {
|
||||
/* private mode / corrupt entry — fall through to seed */
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/** Top emoji by use, seed-filled up to QUICK_ROW_SIZE. */
|
||||
export function quickEmojis(): string[] {
|
||||
const used = Object.entries(counts())
|
||||
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
||||
.map(([e]) => e);
|
||||
const out = [...used];
|
||||
for (const s of SEED) {
|
||||
if (out.length >= QUICK_ROW_SIZE) break;
|
||||
if (!out.includes(s)) out.push(s);
|
||||
}
|
||||
return out.slice(0, QUICK_ROW_SIZE);
|
||||
}
|
||||
|
||||
export function noteEmojiUsed(emoji: string): void {
|
||||
try {
|
||||
const c = counts();
|
||||
c[emoji] = (c[emoji] ?? 0) + 1;
|
||||
localStorage.setItem(KEY, JSON.stringify(c));
|
||||
} catch {
|
||||
/* private mode — quick row just stays the seed */
|
||||
}
|
||||
}
|
||||
62
web/standalone/src/lib/mentions.ts
Normal file
62
web/standalone/src/lib/mentions.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import type { Collaborator } from "@pcbjam/shared";
|
||||
import { client } from "./contract-client";
|
||||
import { currentScope } from "./config";
|
||||
|
||||
/**
|
||||
* Mention-autocomplete roster (comments-ux 0001 E): the scope's collaborators
|
||||
* from the backend's display-only `listCollaborators` route — fetched lazily
|
||||
* on the first `@` keystroke, cached for the session. Backends without a
|
||||
* members model (example/demo/static/@local) 404 or reject → null, and the
|
||||
* caller falls back to presence peers ∪ existing comment authors.
|
||||
*/
|
||||
|
||||
let inflight: Promise<Collaborator[] | null> | null = null;
|
||||
let resolved: Collaborator[] | null = null;
|
||||
|
||||
export function collaborators(): Promise<Collaborator[] | null> {
|
||||
inflight ??= client
|
||||
.listCollaborators({ params: { scope: currentScope() } })
|
||||
.then((r) => {
|
||||
resolved = r.status === 200 ? r.body : null;
|
||||
return resolved;
|
||||
})
|
||||
.catch(() => null);
|
||||
return inflight;
|
||||
}
|
||||
|
||||
/** The already-fetched roster, for synchronous render paths (mention
|
||||
* highlighting of hand-typed `@slug`s); null before the first fetch lands. */
|
||||
export function cachedCollaborators(): Collaborator[] | null {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/** Test hook: forget the session cache. */
|
||||
export function resetCollaboratorsCache(): void {
|
||||
inflight = null;
|
||||
resolved = null;
|
||||
}
|
||||
|
||||
/** Dedupe/merge candidate lists (first occurrence of a slug wins). */
|
||||
export function mergeCandidates(...lists: Collaborator[][]): Collaborator[] {
|
||||
const seen = new Set<string>();
|
||||
const out: Collaborator[] = [];
|
||||
for (const list of lists) {
|
||||
for (const c of list) {
|
||||
if (!c.slug || seen.has(c.slug)) continue;
|
||||
seen.add(c.slug);
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Filter + rank candidates for a typed `@` query (prefix > substring). */
|
||||
export function filterCandidates(candidates: Collaborator[], query: string): Collaborator[] {
|
||||
const q = query.toLowerCase();
|
||||
if (!q) return candidates;
|
||||
const starts = (c: Collaborator) =>
|
||||
c.slug.toLowerCase().startsWith(q) || c.name.toLowerCase().startsWith(q);
|
||||
const contains = (c: Collaborator) =>
|
||||
c.slug.toLowerCase().includes(q) || c.name.toLowerCase().includes(q);
|
||||
return [...candidates.filter(starts), ...candidates.filter((c) => !starts(c) && contains(c))];
|
||||
}
|
||||
68
web/standalone/src/lib/theme.test.ts
Normal file
68
web/standalone/src/lib/theme.test.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { currentTheme, initTheme, setTheme } from "./theme";
|
||||
|
||||
/** Stub the browser surface theme.ts touches (node test environment). */
|
||||
function setup(opts: { search?: string; stored?: string; osDark?: boolean }) {
|
||||
const store = new Map<string, string>();
|
||||
if (opts.stored) store.set("pcbjam-theme", opts.stored);
|
||||
|
||||
const classes = new Set<string>();
|
||||
vi.stubGlobal("localStorage", {
|
||||
getItem: (k: string) => store.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => void store.set(k, v),
|
||||
});
|
||||
vi.stubGlobal("window", {
|
||||
location: { search: opts.search ?? "" },
|
||||
matchMedia: () => ({ matches: opts.osDark ?? false }),
|
||||
});
|
||||
vi.stubGlobal("document", {
|
||||
documentElement: {
|
||||
classList: {
|
||||
toggle: (name: string, on: boolean) => {
|
||||
if (on) classes.add(name);
|
||||
else classes.delete(name);
|
||||
},
|
||||
contains: (name: string) => classes.has(name),
|
||||
},
|
||||
},
|
||||
});
|
||||
return { store, classes };
|
||||
}
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe("theme resolution (comments-ux 0002): param > storage > OS", () => {
|
||||
it("uses the OS preference when nothing else is set", () => {
|
||||
setup({ osDark: true });
|
||||
expect(currentTheme()).toBe("dark");
|
||||
setup({ osDark: false });
|
||||
expect(currentTheme()).toBe("light");
|
||||
});
|
||||
|
||||
it("stored choice beats the OS preference", () => {
|
||||
setup({ stored: "light", osDark: true });
|
||||
expect(currentTheme()).toBe("light");
|
||||
});
|
||||
|
||||
it("?theme= beats storage and persists on init (platform hand-off)", () => {
|
||||
const { store, classes } = setup({ search: "?theme=dark", stored: "light" });
|
||||
expect(currentTheme()).toBe("dark");
|
||||
initTheme();
|
||||
expect(store.get("pcbjam-theme")).toBe("dark");
|
||||
expect(classes.has("dark")).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores an invalid ?theme= value", () => {
|
||||
setup({ search: "?theme=blue", stored: "dark" });
|
||||
expect(currentTheme()).toBe("dark");
|
||||
});
|
||||
|
||||
it("setTheme persists and applies <html>.dark", () => {
|
||||
const { store, classes } = setup({});
|
||||
setTheme("dark");
|
||||
expect(store.get("pcbjam-theme")).toBe("dark");
|
||||
expect(classes.has("dark")).toBe(true);
|
||||
setTheme("light");
|
||||
expect(classes.has("dark")).toBe(false);
|
||||
});
|
||||
});
|
||||
81
web/standalone/src/lib/theme.ts
Normal file
81
web/standalone/src/lib/theme.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import * as React from "react";
|
||||
|
||||
/**
|
||||
* Standalone light/dark theme (comments-ux 0002 F1), mirroring the platform's
|
||||
* module — same storage key, same `<html>.dark` single source of truth — plus
|
||||
* the `?theme=` hand-off: the platform appends the param to editor links (the
|
||||
* only cross-origin channel; no iframe/postMessage exists), the param wins and
|
||||
* re-persists on every navigation, then storage, then the OS preference.
|
||||
* index.html applies the same rule inline before first paint — keep the key
|
||||
* and precedence in sync with it.
|
||||
*
|
||||
* The KiCad canvas follows separately: boot seeding picks the color theme
|
||||
* (wasm/boot.ts), and the F4 bridge (`kicadSetColorTheme`) switches it live
|
||||
* when the loaded wasm exposes it (theme.ts notifies subscribers).
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = "pcbjam-theme";
|
||||
|
||||
export type Theme = "light" | "dark";
|
||||
|
||||
function themeParam(): Theme | null {
|
||||
try {
|
||||
const v = new URLSearchParams(window.location.search).get("theme");
|
||||
return v === "light" || v === "dark" ? v : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The theme in effect: `?theme=` > stored choice > OS preference. */
|
||||
export function currentTheme(): Theme {
|
||||
const param = themeParam();
|
||||
if (param) return param;
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored === "light" || stored === "dark") return stored;
|
||||
} catch {
|
||||
/* storage disabled — fall through to the OS preference */
|
||||
}
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
}
|
||||
|
||||
/** Reactive theme: tracks <html>.dark itself (the single source of truth all
|
||||
* appliers — toggles, the no-flash boot script — write to). */
|
||||
export function useThemeValue(): Theme {
|
||||
const subscribe = React.useCallback((onChange: () => void) => {
|
||||
const observer = new MutationObserver(onChange);
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class"],
|
||||
});
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
return React.useSyncExternalStore(subscribe, () =>
|
||||
document.documentElement.classList.contains("dark") ? "dark" : "light",
|
||||
);
|
||||
}
|
||||
|
||||
/** Persist and apply (tailwind `darkMode: ["class"]` keys off <html>.dark). */
|
||||
export function setTheme(theme: Theme): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, theme);
|
||||
} catch {
|
||||
/* storage disabled — still apply for this page view */
|
||||
}
|
||||
document.documentElement.classList.toggle("dark", theme === "dark");
|
||||
}
|
||||
|
||||
/** Apply the resolved theme at app start and persist a `?theme=` hand-off so
|
||||
* it survives in-app navigation (the param stays in the URL regardless). */
|
||||
export function initTheme(): void {
|
||||
const t = currentTheme();
|
||||
if (themeParam()) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, t);
|
||||
} catch {
|
||||
/* private mode */
|
||||
}
|
||||
}
|
||||
document.documentElement.classList.toggle("dark", t === "dark");
|
||||
}
|
||||
|
|
@ -3,11 +3,17 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|||
import { BrowserRouter } from "react-router-dom";
|
||||
import App from "./App";
|
||||
import { initAnalytics } from "./lib/analytics";
|
||||
import { initTheme } from "./lib/theme";
|
||||
import "./index.css";
|
||||
|
||||
// Privacy-friendly analytics (Plausible), only when VITE_PLAUSIBLE_SRC is set.
|
||||
initAnalytics();
|
||||
|
||||
// Re-assert the resolved theme (index.html applied it pre-paint; this keeps
|
||||
// SPA state consistent if that inline script is ever bypassed, e.g. tests
|
||||
// mounting the app directly).
|
||||
initTheme();
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import * as React from "react";
|
|||
import { useNavigate } from "react-router-dom";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { type Tool, libPath, projectPath } from "@pcbjam/shared";
|
||||
import { ThemeToggle } from "@/components/ThemeToggle";
|
||||
import { FolderOpen, Library, Loader2, Package } from "lucide-react";
|
||||
import { useLibs } from "@/lib/api";
|
||||
import {
|
||||
|
|
@ -230,6 +231,7 @@ export function HomePage() {
|
|||
<span className="rounded-full border border-amber-500/50 bg-amber-500/10 px-2.5 py-0.5 text-xs font-medium text-amber-600 dark:text-amber-400">
|
||||
Early-access alpha
|
||||
</span>
|
||||
<ThemeToggle className="ml-auto" />
|
||||
</h1>
|
||||
<p className="mb-8 text-sm text-muted-foreground">
|
||||
{staticMode
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ import {
|
|||
} from "./libs/source";
|
||||
import { libUri, PCBJAM_LIB_MOUNT } from "./libs/uri";
|
||||
import { installTouchGestures } from "./touch-gestures";
|
||||
import { currentTheme } from "@/lib/theme";
|
||||
import pcbjamDarkTheme from "./themes/pcbjam-dark.json";
|
||||
|
||||
/** The default user lib boot ensures exists, so there's a writable save target. */
|
||||
const DEFAULT_USER_LIB_NAME = "My Symbols";
|
||||
|
|
@ -431,6 +433,27 @@ async function doBoot(opts: BootOptions): Promise<void> {
|
|||
`${KICAD_CONFIG_DIR}/design-block-lib-table`,
|
||||
"(design_block_lib_table\n (version 7)\n)\n",
|
||||
);
|
||||
|
||||
// Theme (comments-ux 0002 F2): the dark color theme is seeded ALWAYS (so
|
||||
// the F4 live switch is a settings-only operation); the per-app settings
|
||||
// pick the boot theme. Only the schematic set is overridden — KiCad's
|
||||
// board canvas is dark by default, and `_builtin_default` keeps it.
|
||||
FS.mkdirTree(`${KICAD_CONFIG_DIR}/colors`);
|
||||
writeIfAbsent(
|
||||
`${KICAD_CONFIG_DIR}/colors/pcbjam-dark.json`,
|
||||
JSON.stringify(pcbjamDarkTheme, null, 2),
|
||||
);
|
||||
if (currentTheme() === "dark") {
|
||||
const appearance = JSON.stringify(
|
||||
{ appearance: { color_theme: "pcbjam-dark" } },
|
||||
null,
|
||||
2,
|
||||
);
|
||||
// writeIfAbsent by design: a same-session relaunch after the user
|
||||
// switched themes in-app (F4 persists into these files) must win.
|
||||
writeIfAbsent(`${KICAD_CONFIG_DIR}/eeschema.json`, appearance);
|
||||
writeIfAbsent(`${KICAD_CONFIG_DIR}/pcbnew.json`, appearance);
|
||||
}
|
||||
};
|
||||
|
||||
const preRun = [createCanvas, writeResources];
|
||||
|
|
@ -438,7 +461,16 @@ async function doBoot(opts: BootOptions): Promise<void> {
|
|||
|
||||
w.Module = {
|
||||
thisProgram: TOOL_ARGV0[tool], // argv[0] for KiCad's DEBUG check
|
||||
...(traceMask ? { ENV: { KICAD_TRACE: traceMask } } : {}),
|
||||
// PCBJAM_DARK_CHROME is a getenv fallback for the wx chrome appearance
|
||||
// (wxwidgets src/wasm/settings.cpp) — the authoritative seed is the
|
||||
// kicadSetDarkChrome call in onRuntimeInitialized below, which runs on
|
||||
// the browser main thread BEFORE main() spawns on the KiCad pthread
|
||||
// (pthreads build environ from their own worker's ENV, so this ENV may
|
||||
// never reach them).
|
||||
ENV: {
|
||||
...(traceMask ? { KICAD_TRACE: traceMask } : {}),
|
||||
...(currentTheme() === "dark" ? { PCBJAM_DARK_CHROME: "1" } : {}),
|
||||
},
|
||||
// Runtime frame selection: emscripten feeds these to main() as argv[1..], which
|
||||
// single_top.cpp parses ("--frame=<token>") to open the requested editor frame
|
||||
// from a shared bundle. Set in the Module literal so it's present before the
|
||||
|
|
@ -471,6 +503,12 @@ async function doBoot(opts: BootOptions): Promise<void> {
|
|||
monitorRunDependencies: () => {},
|
||||
onRuntimeInitialized: () => {
|
||||
log("[boot] runtime initialized");
|
||||
// Seed the wx chrome appearance BEFORE main() spawns on the KiCad
|
||||
// pthread (comments-ux 0002 F4) — this runs on the browser main
|
||||
// thread, and the flag lives in shared wasm memory. Older bundles
|
||||
// without the binding just skip it.
|
||||
const mod = w.Module as { kicadSetDarkChrome?: (dark: boolean) => void };
|
||||
mod.kicadSetDarkChrome?.(currentTheme() === "dark");
|
||||
const canvas = (w.Module as { canvas?: HTMLCanvasElement }).canvas;
|
||||
if (canvas) canvas.style.display = "block";
|
||||
onStatus("");
|
||||
|
|
|
|||
|
|
@ -10,11 +10,14 @@ import {
|
|||
field,
|
||||
kicadItemsMap,
|
||||
listThreads,
|
||||
markThreadSeen,
|
||||
observeComments,
|
||||
removeMessage,
|
||||
resolveAnchor,
|
||||
setThreadAnchor,
|
||||
setThreadResolved,
|
||||
threadUnreadCount,
|
||||
toggleReaction,
|
||||
yToItemUnchecked,
|
||||
type CommentAnchor,
|
||||
type CommentThread,
|
||||
|
|
@ -91,8 +94,13 @@ export interface CommentsController {
|
|||
/** 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;
|
||||
create(anchor: CommentAnchor, body: string, mentions?: string[]): string;
|
||||
reply(threadId: string, body: string, mentions?: string[]): void;
|
||||
/** Advance the bound user's seen watermark on a thread (0001 C) — pins and
|
||||
* badges refresh through the normal observe → subscribe cycle. */
|
||||
markSeen(threadId: string): void;
|
||||
/** Toggle the bound user's emoji reaction on a message (0001 D). */
|
||||
toggleReaction(threadId: string, messageId: string, emoji: 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;
|
||||
|
|
@ -165,6 +173,7 @@ export function createComments(opts: {
|
|||
y: t.world.y,
|
||||
color: colorFor(t.createdBy),
|
||||
resolved: t.resolved,
|
||||
unread: threadUnreadCount(t, user.id) > 0,
|
||||
})),
|
||||
}),
|
||||
);
|
||||
|
|
@ -226,23 +235,31 @@ export function createComments(opts: {
|
|||
|
||||
return { pos: { x: world.x, y: world.y } };
|
||||
},
|
||||
create(anchor, body) {
|
||||
create(anchor, body, mentions) {
|
||||
return createThread(doc, {
|
||||
anchor,
|
||||
author: user.id,
|
||||
authorName: user.name,
|
||||
authorEmail: user.email,
|
||||
body,
|
||||
mentions,
|
||||
});
|
||||
},
|
||||
reply(threadId, body) {
|
||||
reply(threadId, body, mentions) {
|
||||
addMessage(doc, threadId, {
|
||||
author: user.id,
|
||||
authorName: user.name,
|
||||
authorEmail: user.email,
|
||||
body,
|
||||
mentions,
|
||||
});
|
||||
},
|
||||
markSeen(threadId) {
|
||||
markThreadSeen(doc, threadId, user.id);
|
||||
},
|
||||
toggleReaction(threadId, messageId, emoji) {
|
||||
toggleReaction(doc, threadId, messageId, user.id, emoji);
|
||||
},
|
||||
edit(threadId, messageId, body) {
|
||||
return editMessage(doc, threadId, messageId, body);
|
||||
},
|
||||
|
|
|
|||
48
web/standalone/src/wasm/collab/pin-geometry.ts
Normal file
48
web/standalone/src/wasm/collab/pin-geometry.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
/**
|
||||
* Bubble-pin geometry (comments-ux 0001 A) — the TS mirror of the STYLE
|
||||
* values in pcbjam/wasm/bindings/collab_presence_style.h. GAL draws the
|
||||
* bubble; the DOM hit target, highlight and popover must sit on the drawn
|
||||
* body, so both sides need the same numbers.
|
||||
*
|
||||
* The radius is LIVE, not a constant: the PresenceTuner can re-style the GAL
|
||||
* pins at runtime (`kicadCollabSetStyle`), and the DOM must follow — it
|
||||
* pushes the current `pinRadiusPx` here, and CommentLayer subscribes. The
|
||||
* default matches the shipped C++ STYLE default; if the tuner-picked ship
|
||||
* value changes, change BOTH defaults together.
|
||||
*
|
||||
* Shape (figma-style): a round body whose bottom-left corner is squared off;
|
||||
* the SHARP CORNER is the anchored world point, so the body center sits at
|
||||
* anchor + (r, -r) in screen coords (y down).
|
||||
*/
|
||||
|
||||
export const DEFAULT_PIN_RADIUS_PX = 9;
|
||||
|
||||
let radiusPx = DEFAULT_PIN_RADIUS_PX;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
/** Current bubble radius in canvas device px (GAL screen px). */
|
||||
export function pinRadiusPx(): number {
|
||||
return radiusPx;
|
||||
}
|
||||
|
||||
/** Follow a live GAL re-style (PresenceTuner). No-ops on bogus/same values. */
|
||||
export function setPinRadiusPx(r: number): void {
|
||||
if (!Number.isFinite(r) || r <= 0 || r === radiusPx) return;
|
||||
radiusPx = r;
|
||||
for (const l of listeners) l();
|
||||
}
|
||||
|
||||
/** Subscribe to radius changes (React: useSyncExternalStore-compatible). */
|
||||
export function subscribePinRadius(cb: () => void): () => void {
|
||||
listeners.add(cb);
|
||||
return () => listeners.delete(cb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Offset from the anchor (sharp corner) to the bubble body center, in canvas
|
||||
* device px with CSS axis orientation (y grows downward) — multiply by the
|
||||
* canvas CSS ratio before positioning DOM.
|
||||
*/
|
||||
export function bubbleCenterOffsetPx(): { dx: number; dy: number } {
|
||||
return { dx: radiusPx, dy: -radiusPx };
|
||||
}
|
||||
55
web/standalone/src/wasm/themes/pcbjam-dark.json
Normal file
55
web/standalone/src/wasm/themes/pcbjam-dark.json
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
{
|
||||
"meta": {
|
||||
"name": "PCBJam Dark",
|
||||
"version": 5
|
||||
},
|
||||
"schematic": {
|
||||
"anchor": "rgb(77, 121, 255)",
|
||||
"aux_items": "rgb(220, 220, 230)",
|
||||
"background": "rgb(26, 26, 46)",
|
||||
"brightened": "rgb(255, 0, 255)",
|
||||
"bus": "rgb(98, 139, 255)",
|
||||
"bus_junction": "rgb(98, 139, 255)",
|
||||
"component_body": "rgb(42, 42, 58)",
|
||||
"component_outline": "rgb(224, 138, 106)",
|
||||
"cursor": "rgb(230, 230, 240)",
|
||||
"dnp_marker": "rgba(220, 9, 9, 0.7)",
|
||||
"erc_error": "rgb(255, 85, 85)",
|
||||
"erc_exclusion": "rgba(94, 194, 194, 0.8)",
|
||||
"erc_warning": "rgb(255, 184, 108)",
|
||||
"excluded_from_sim": "rgb(154, 154, 174)",
|
||||
"fields": "rgb(139, 233, 253)",
|
||||
"grid": "rgb(58, 58, 78)",
|
||||
"grid_axes": "rgb(84, 84, 114)",
|
||||
"hidden": "rgb(94, 100, 120)",
|
||||
"hovered": "rgb(77, 121, 255)",
|
||||
"junction": "rgb(80, 200, 90)",
|
||||
"label_global": "rgb(255, 184, 108)",
|
||||
"label_hier": "rgb(189, 147, 249)",
|
||||
"label_local": "rgb(230, 230, 240)",
|
||||
"netclass_flag": "rgb(114, 174, 224)",
|
||||
"no_connect": "rgb(98, 108, 128)",
|
||||
"note": "rgb(184, 192, 224)",
|
||||
"note_background": "rgba(0, 0, 0, 0)",
|
||||
"op_currents": "rgb(255, 122, 122)",
|
||||
"op_voltages": "rgb(122, 179, 255)",
|
||||
"override_item_colors": false,
|
||||
"page_limits": "rgb(84, 84, 104)",
|
||||
"pin": "rgb(224, 138, 106)",
|
||||
"pin_name": "rgb(94, 194, 194)",
|
||||
"pin_number": "rgb(94, 194, 194)",
|
||||
"private_note": "rgb(114, 142, 255)",
|
||||
"reference": "rgb(139, 233, 253)",
|
||||
"rule_area": "rgb(255, 121, 198)",
|
||||
"shadow": "rgba(120, 170, 255, 0.55)",
|
||||
"sheet": "rgb(189, 147, 249)",
|
||||
"sheet_background": "rgba(0, 0, 0, 0)",
|
||||
"sheet_fields": "rgb(139, 233, 253)",
|
||||
"sheet_filename": "rgb(184, 192, 224)",
|
||||
"sheet_label": "rgb(255, 184, 108)",
|
||||
"sheet_name": "rgb(94, 194, 194)",
|
||||
"value": "rgb(139, 233, 253)",
|
||||
"wire": "rgb(80, 200, 90)",
|
||||
"worksheet": "rgb(153, 85, 85)"
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +1 @@
|
|||
Subproject commit a61bcf4aa8684bf2d3afa9d30b3e0edfb7debec1
|
||||
Subproject commit 87336aa51d19942d5ac7fe1f18a326f3c3d1a1fb
|
||||
Loading…
Reference in a new issue