collab-presence 0010: unified draggable overlay menu
One circular FAB (peer-count badge) replaces the top-right overlay row: draggable anywhere (pointer capture, 4px click-vs-drag threshold — the comment-pin pattern), position persisted in localStorage, panel opens toward screen center and stacks composable sections — roster, source chip, view-only pill, follow row, comments, chrome toggle. Renders at z-50 above everything (decided: covers wx dialogs; trivially dismissed via click-away/ Esc/drag). CommentLayer portals its bar + list panel into the menu's comments slot; pins/popovers/composer/click-catcher stay canvas-anchored. The FAB is the chrome-hidden survivor. e2e specs updated with an openOverlayMenu helper (panel is closed by default, click-away closes it). NOTE: Playwright suites updated but not executed here (no local WASM artifacts) — verify on CI / next session. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A42xfPFNdfsUt9eowkC9eM
This commit is contained in:
parent
250dc3dbae
commit
f4d3c3f146
11 changed files with 410 additions and 141 deletions
|
|
@ -1,4 +1,5 @@
|
|||
import { test, expect, type Browser, type Page } from '@playwright/test';
|
||||
import { openOverlayMenu } from './overlay-menu';
|
||||
|
||||
/**
|
||||
* Figma-like "hide UI" toggle e2e (desktop): Cmd/Ctrl+\ and the floating
|
||||
|
|
@ -85,7 +86,9 @@ test('Ctrl+\\ hides every non-canvas UI element; the canvas reclaims the viewpor
|
|||
);
|
||||
// shell overlays follow the toggle…
|
||||
await expect(page.getByText(/console \(/)).toHaveCount(0);
|
||||
// …but the toggle button itself stays reachable
|
||||
// …but the toggle stays reachable inside the overlay menu (0010) — the
|
||||
// FAB is the canvas-only survivor.
|
||||
await openOverlayMenu(page);
|
||||
await expect(page.locator('[data-testid="chrome-toggle"]')).toBeVisible();
|
||||
|
||||
await page.screenshot({ path: 'test-results/web-chrome-hidden.png', scale: 'css' });
|
||||
|
|
@ -94,7 +97,8 @@ test('Ctrl+\\ hides every non-canvas UI element; the canvas reclaims the viewpor
|
|||
test('the floating button restores EXACTLY the pre-hide chrome (no over-shown panes)', async () => {
|
||||
test.setTimeout(120_000);
|
||||
|
||||
// still hidden from the previous test — restore via the button
|
||||
// still hidden from the previous test — restore via the button (in the menu)
|
||||
await openOverlayMenu(page);
|
||||
await page.locator('[data-testid="chrome-toggle"]').click();
|
||||
|
||||
await expect.poll(() => visibleMenuTitles(page), { timeout: 15000 }).toBeGreaterThan(0);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { test, expect, type Page } from '@playwright/test';
|
||||
import { openOverlayMenu } from './overlay-menu';
|
||||
|
||||
/**
|
||||
* Comment pins vs. canvas resize (collab-presence 0005 regression): the DOM
|
||||
|
|
@ -28,6 +29,7 @@ async function bootAs(page: Page, user: string): Promise<void> {
|
|||
intervals: [1000],
|
||||
})
|
||||
.toMatch(TITLE);
|
||||
await openOverlayMenu(page); // the comment bar lives in the overlay menu (0010)
|
||||
await expect(page.getByTestId('comment-bar-toggle')).toBeVisible({ timeout: 30000 });
|
||||
await page.getByTestId('comment-bar-toggle').click();
|
||||
await expect(page.getByTestId('comment-mode-toggle')).toBeVisible();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { test, expect, type Page } from '@playwright/test';
|
||||
import { openOverlayMenu } from './overlay-menu';
|
||||
|
||||
/**
|
||||
* Figma-like comments e2e (collab-presence 0005): two tabs of the real React
|
||||
|
|
@ -28,6 +29,7 @@ async function bootAs(page: Page, user: string): Promise<void> {
|
|||
.toMatch(TITLE);
|
||||
// The comment controls mount once the collab session + bridge are up; the
|
||||
// action buttons live inside the expandable bar — open it for the test.
|
||||
await openOverlayMenu(page); // the comment bar lives in the overlay menu (0010)
|
||||
await expect(page.getByTestId('comment-bar-toggle')).toBeVisible({ timeout: 30000 });
|
||||
await page.getByTestId('comment-bar-toggle').click();
|
||||
await expect(page.getByTestId('comment-mode-toggle')).toBeVisible();
|
||||
|
|
@ -96,7 +98,9 @@ test('comment lifecycle across two tabs: create → reply → resolve → delete
|
|||
|
||||
// Resolved pins hide from the default (unresolved) pin set in both tabs…
|
||||
await expect(page.getByTestId('comment-pin')).toHaveCount(0, { timeout: 20000 });
|
||||
// …but stay reachable via the panel's "resolved" filter.
|
||||
// …but stay reachable via the panel's "resolved" filter. (The popover
|
||||
// click above closed the overlay menu — click-away — so re-open it.)
|
||||
await openOverlayMenu(page);
|
||||
await page.getByTestId('comment-panel-toggle').click();
|
||||
await page.getByTestId('comment-show-resolved').check();
|
||||
await expect(page.getByTestId('comment-panel-item')).toHaveCount(1);
|
||||
|
|
@ -106,6 +110,7 @@ test('comment lifecycle across two tabs: create → reply → resolve → delete
|
|||
await expect(page.getByTestId('comment-popover')).toBeVisible();
|
||||
await page.getByTestId('comment-delete-thread').click();
|
||||
|
||||
await openOverlayMenu(page); // the delete click closed the menu (click-away)
|
||||
await expect(page.getByTestId('comment-panel-item')).toHaveCount(0, { timeout: 20000 });
|
||||
await expect(pageB.getByTestId('comment-pin')).toHaveCount(0, { timeout: 20000 });
|
||||
await pageB.close();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { test, expect, type Page } from '@playwright/test';
|
||||
import { openOverlayMenu } from './overlay-menu';
|
||||
|
||||
/**
|
||||
* Follow-user e2e (collab-presence 0008): two tabs on the demo BOARD share
|
||||
|
|
@ -81,7 +82,9 @@ test('B follows A: viewport mirrors, then local input breaks the follow', async
|
|||
})
|
||||
.toBe(true);
|
||||
|
||||
// B sees alice in the roster and clicks her avatar → follow.
|
||||
// B sees alice in the roster (inside the overlay menu, 0010) and clicks
|
||||
// her avatar → follow.
|
||||
await openOverlayMenu(b);
|
||||
const avatar = b.locator('[data-presence-user="alice"]');
|
||||
await expect(avatar).toBeVisible({ timeout: 30000 });
|
||||
await avatar.click();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { test, expect, devices, type Page, type Browser } from '@playwright/test';
|
||||
import { openOverlayMenu } from './overlay-menu';
|
||||
|
||||
/**
|
||||
* Mobile canvas-only mode e2e (features/mobile) — runs under the
|
||||
|
|
@ -310,7 +311,9 @@ test('editor is canvas-only: no menubar, GL canvas fills the viewport', async ()
|
|||
});
|
||||
|
||||
test('the floating toggle brings the full UI up and back off (Figma-like hide-UI)', async () => {
|
||||
// Canvas-only is the mobile DEFAULT, but the toggle must stay reachable.
|
||||
// Canvas-only is the mobile DEFAULT, but the toggle must stay reachable —
|
||||
// inside the overlay menu (0010); the FAB is the persistent control.
|
||||
await openOverlayMenu(page);
|
||||
const toggle = page.locator('[data-testid="chrome-toggle"]');
|
||||
await expect(toggle, 'toggle button present in canvas-only mode').toBeVisible();
|
||||
|
||||
|
|
|
|||
16
tests/web/overlay-menu.ts
Normal file
16
tests/web/overlay-menu.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { expect, type Page } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Open the overlay menu (collab-presence 0010) if its panel isn't already
|
||||
* open. The roster, source chip, view-only pill, comment bar and chrome
|
||||
* toggle live inside the panel; the FAB (with the peer-count badge) is the
|
||||
* only overlay control always on screen. Any canvas click closes the panel
|
||||
* (click-away), so specs re-open it before each in-panel interaction.
|
||||
*/
|
||||
export async function openOverlayMenu(page: Page): Promise<void> {
|
||||
const fab = page.getByTestId('overlay-menu-fab');
|
||||
await expect(fab).toBeVisible({ timeout: 30000 });
|
||||
if (await page.getByTestId('overlay-menu-panel').count()) return;
|
||||
await fab.click();
|
||||
await expect(page.getByTestId('overlay-menu-panel')).toBeVisible();
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { test, expect, type Page } from '@playwright/test';
|
||||
import { openOverlayMenu } from './overlay-menu';
|
||||
|
||||
/**
|
||||
* Presence roster e2e (collab-presence 0001): two tabs of the real React app on
|
||||
|
|
@ -42,11 +43,14 @@ test('two tabs on one file see each other in the roster; leaving removes promptl
|
|||
|
||||
await bootAs(page, 'alice');
|
||||
|
||||
// Before anyone else joins, alice has no roster chip at all.
|
||||
// Before anyone else joins, alice has no roster section in the overlay
|
||||
// menu (0010) at all.
|
||||
await openOverlayMenu(page);
|
||||
await expect(page.getByTestId('presence-roster')).toHaveCount(0);
|
||||
|
||||
const pageB = await context.newPage();
|
||||
await bootAs(pageB, 'bob');
|
||||
await openOverlayMenu(pageB);
|
||||
|
||||
// Each side shows the other user (avatar keyed by slug), not itself.
|
||||
await expect(page.locator('[data-presence-user="bob"]')).toBeVisible({ timeout: 30000 });
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { test, expect, type Browser, type Page } from '@playwright/test';
|
||||
import { openOverlayMenu } from './overlay-menu';
|
||||
|
||||
/**
|
||||
* Read-only viewer e2e (read-only-viewer): `?readonly=1` boots the pcbnew
|
||||
|
|
@ -118,6 +119,8 @@ test('viewer boots locked: chrome-less, nothing selectable, hotkey edits inert,
|
|||
// "View only" pill is the one read-only affordance; Ctrl+\ stays inert.
|
||||
await expect.poll(() => visibleMenuTitles(viewer), { timeout: 15000 }).toBe(0);
|
||||
await expect(viewer.getByText(/console \(/)).toHaveCount(0);
|
||||
// The pill + (absent) chrome toggle live inside the overlay menu (0010).
|
||||
await openOverlayMenu(viewer);
|
||||
await expect(viewer.locator('[data-testid="chrome-toggle"]')).toHaveCount(0);
|
||||
await expect(viewer.getByTestId('view-only-pill')).toBeVisible();
|
||||
await viewer.keyboard.press('Control+\\');
|
||||
|
|
@ -136,6 +139,7 @@ test('viewer boots locked: chrome-less, nothing selectable, hotkey edits inert,
|
|||
expect(await visibleMenuTitles(viewer)).toBe(0);
|
||||
|
||||
// The writer beside it keeps the full UI (positive control for the above).
|
||||
await openOverlayMenu(writer);
|
||||
await expect(writer.locator('[data-testid="chrome-toggle"]')).toBeVisible();
|
||||
await expect(writer.getByTestId('view-only-pill')).toHaveCount(0);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import * as React from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import type { CommentAnchor } from "@pcbjam/shared";
|
||||
import { Eye, EyeOff, List, MessageSquarePlus, MessageSquareText, X } from "lucide-react";
|
||||
import {
|
||||
|
|
@ -54,10 +55,15 @@ export function CommentLayer({
|
|||
controller,
|
||||
viewport,
|
||||
currentUser,
|
||||
menuSlot,
|
||||
}: {
|
||||
controller: CommentsController;
|
||||
viewport: ViewportState | null;
|
||||
currentUser: string;
|
||||
/** The overlay menu's comments section (0010): the bar + list panel portal
|
||||
* 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;
|
||||
}) {
|
||||
const [threads, setThreads] = React.useState<ResolvedThread[]>(controller.threads());
|
||||
const [barOpen, setBarOpen] = React.useState(false);
|
||||
|
|
@ -200,58 +206,116 @@ export function CommentLayer({
|
|||
const visibleThreads = threads.filter((t) => showResolved || !t.resolved);
|
||||
const pinThreads = hidden ? [] : visibleThreads;
|
||||
|
||||
// Comment toolbar + list panel: portaled into the overlay menu's comments
|
||||
// slot (0010) while the menu is open — in-flow there, not absolute.
|
||||
const menuUi = menuSlot
|
||||
? createPortal(
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
data-testid="comment-bar-toggle"
|
||||
title="Comments"
|
||||
onClick={() => setBarOpen((o) => !o)}
|
||||
className={`flex h-8 min-w-8 items-center justify-center gap-1 rounded-full px-2 text-xs shadow-sm ring-1 ring-inset ring-white/20 ${
|
||||
barOpen ? "bg-sky-600 text-white" : "bg-black/70 text-white hover:bg-black/85"
|
||||
}`}
|
||||
>
|
||||
<MessageSquareText size={15} />
|
||||
{threads.length > 0 && <span>{threads.length}</span>}
|
||||
</button>
|
||||
{barOpen && (
|
||||
<div className="flex items-center gap-1 rounded-full bg-black/70 p-1 shadow-sm ring-1 ring-inset ring-white/20">
|
||||
<button
|
||||
data-testid="comment-mode-toggle"
|
||||
title={mode ? "Cancel comment (Esc)" : "New comment"}
|
||||
onClick={() => {
|
||||
if (hidden) toggleHidden();
|
||||
setMode((m) => !m);
|
||||
setDraft(null);
|
||||
}}
|
||||
className={`flex h-6 w-6 items-center justify-center rounded-full ${
|
||||
mode ? "bg-amber-500 text-black" : "text-white hover:bg-white/15"
|
||||
}`}
|
||||
>
|
||||
<MessageSquarePlus size={14} />
|
||||
</button>
|
||||
<button
|
||||
data-testid="comment-panel-toggle"
|
||||
title="Comment list"
|
||||
onClick={() => setPanel((p) => !p)}
|
||||
className={`flex h-6 w-6 items-center justify-center rounded-full ${
|
||||
panel ? "bg-white/25 text-white" : "text-white hover:bg-white/15"
|
||||
}`}
|
||||
>
|
||||
<List size={14} />
|
||||
</button>
|
||||
<button
|
||||
data-testid="comment-visibility-toggle"
|
||||
title={hidden ? "Show comments" : "Hide comments"}
|
||||
onClick={toggleHidden}
|
||||
className="flex h-6 w-6 items-center justify-center rounded-full text-white hover:bg-white/15"
|
||||
>
|
||||
{hidden ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</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) }}
|
||||
>
|
||||
{t.createdBy}
|
||||
</span>{" "}
|
||||
<span className="text-white/50">
|
||||
{timeAgo(t.createdAt)} ago{t.resolved ? " · resolved" : ""}
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-white/90">
|
||||
{t.messages[0]?.body ?? ""}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>,
|
||||
menuSlot,
|
||||
)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Comment toolbar: one icon expanding into a small horizontal bar. */}
|
||||
<div className="absolute right-3 top-12 z-30 flex flex-row-reverse items-center gap-2">
|
||||
<button
|
||||
data-testid="comment-bar-toggle"
|
||||
title="Comments"
|
||||
onClick={() => setBarOpen((o) => !o)}
|
||||
className={`flex h-8 min-w-8 items-center justify-center gap-1 rounded-full px-2 text-xs shadow-sm ring-1 ring-inset ring-white/20 ${
|
||||
barOpen ? "bg-sky-600 text-white" : "bg-black/70 text-white hover:bg-black/85"
|
||||
}`}
|
||||
>
|
||||
<MessageSquareText size={15} />
|
||||
{threads.length > 0 && <span>{threads.length}</span>}
|
||||
</button>
|
||||
{barOpen && (
|
||||
<div className="flex items-center gap-1 rounded-full bg-black/70 p-1 shadow-sm ring-1 ring-inset ring-white/20">
|
||||
<button
|
||||
data-testid="comment-mode-toggle"
|
||||
title={mode ? "Cancel comment (Esc)" : "New comment"}
|
||||
onClick={() => {
|
||||
if (hidden) toggleHidden();
|
||||
setMode((m) => !m);
|
||||
setDraft(null);
|
||||
}}
|
||||
className={`flex h-6 w-6 items-center justify-center rounded-full ${
|
||||
mode ? "bg-amber-500 text-black" : "text-white hover:bg-white/15"
|
||||
}`}
|
||||
>
|
||||
<MessageSquarePlus size={14} />
|
||||
</button>
|
||||
<button
|
||||
data-testid="comment-panel-toggle"
|
||||
title="Comment list"
|
||||
onClick={() => setPanel((p) => !p)}
|
||||
className={`flex h-6 w-6 items-center justify-center rounded-full ${
|
||||
panel ? "bg-white/25 text-white" : "text-white hover:bg-white/15"
|
||||
}`}
|
||||
>
|
||||
<List size={14} />
|
||||
</button>
|
||||
<button
|
||||
data-testid="comment-visibility-toggle"
|
||||
title={hidden ? "Show comments" : "Hide comments"}
|
||||
onClick={toggleHidden}
|
||||
className="flex h-6 w-6 items-center justify-center rounded-full text-white hover:bg-white/15"
|
||||
>
|
||||
{hidden ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{menuUi}
|
||||
|
||||
{/* Comment-mode click catcher over the drawing area only. */}
|
||||
{mode && glRect && (
|
||||
|
|
@ -308,50 +372,6 @@ export function CommentLayer({
|
|||
/>
|
||||
)}
|
||||
|
||||
{/* Threads panel (filter + jump-to). */}
|
||||
{panel && (
|
||||
<div className="absolute right-3 top-24 z-30 flex max-h-[60vh] w-72 flex-col overflow-hidden rounded-lg bg-black/85 text-white shadow-lg ring-1 ring-inset ring-white/20">
|
||||
<div className="flex items-center justify-between px-3 py-2 text-xs font-semibold">
|
||||
<span>Comments ({visibleThreads.length})</span>
|
||||
<label className="flex items-center gap-1 font-normal text-white/70">
|
||||
<input
|
||||
data-testid="comment-show-resolved"
|
||||
type="checkbox"
|
||||
checked={showResolved}
|
||||
onChange={(e) => setShowResolved(e.target.checked)}
|
||||
/>
|
||||
resolved
|
||||
</label>
|
||||
</div>
|
||||
<div className="overflow-y-auto">
|
||||
{visibleThreads.length === 0 && (
|
||||
<p className="px-3 pb-3 text-xs text-white/50">No comments yet.</p>
|
||||
)}
|
||||
{visibleThreads.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
data-testid="comment-panel-item"
|
||||
onClick={() => {
|
||||
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) }}>
|
||||
{t.createdBy}
|
||||
</span>{" "}
|
||||
<span className="text-white/50">
|
||||
{timeAgo(t.createdAt)} ago{t.resolved ? " · resolved" : ""}
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-white/90">
|
||||
{t.messages[0]?.body ?? ""}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
187
web/standalone/src/components/OverlayMenu.tsx
Normal file
187
web/standalone/src/components/OverlayMenu.tsx
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
import * as React from "react";
|
||||
import { Users } from "lucide-react";
|
||||
|
||||
/**
|
||||
* Unified overlay menu (collab-presence 0010): the single circular icon that
|
||||
* replaces the old top-right overlay row. The FAB is draggable anywhere over
|
||||
* the canvas (pointer capture, 4px click-vs-drag threshold — the comment-pin
|
||||
* pattern), its position persists per browser, and its badge shows how many
|
||||
* OTHER users are in the session. Clicking opens a panel that stacks the
|
||||
* sections WasmTool composes as children (roster, source chip, view-only
|
||||
* pill, follow row, comments, chrome toggle, …) — adding a future section is
|
||||
* one more child. Renders above everything (z-50, including wx dialogs and
|
||||
* toasts) by decision: it is trivially dismissed (click-away, Esc, the FAB)
|
||||
* and can be dragged out of the way. Stays up in chrome-hidden mode — it is
|
||||
* the canvas-only survivor the chrome toggle used to be.
|
||||
*/
|
||||
|
||||
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,
|
||||
children,
|
||||
}: {
|
||||
/** Peer count shown on the FAB (0 hides the badge). */
|
||||
badge: number;
|
||||
/** 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);
|
||||
|
||||
// Esc closes (bubble phase, same etiquette as the comment layer — wx also
|
||||
// sees the key, matching how every other overlay treats Escape).
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open]);
|
||||
|
||||
// Click-away: any pointerdown outside the menu closes it (canvas included —
|
||||
// wx pointer handlers bind to #canvas, so this listener still fires).
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDown = (e: PointerEvent) => {
|
||||
if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
window.addEventListener("pointerdown", onDown);
|
||||
return () => window.removeEventListener("pointerdown", onDown);
|
||||
}, [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,
|
||||
};
|
||||
};
|
||||
|
||||
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),
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
// Default anchor: top-right (the old row's home). After a drag, explicit px.
|
||||
const style: React.CSSProperties = pos
|
||||
? { left: pos.x, top: pos.y }
|
||||
: { right: 12, top: 12 };
|
||||
// The panel opens toward the screen's center from wherever the FAB sits.
|
||||
const onLeftHalf = pos ? pos.x < window.innerWidth / 2 : false;
|
||||
const onTopHalf = pos ? pos.y < window.innerHeight / 2 : true;
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="absolute z-50" style={style}>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="overlay-menu-fab"
|
||||
aria-expanded={open}
|
||||
title="Session menu — drag to move"
|
||||
onPointerDown={onFabPointerDown}
|
||||
onPointerMove={onFabPointerMove}
|
||||
onPointerUp={onFabPointerUp}
|
||||
className={`relative flex h-9 w-9 items-center justify-center rounded-full shadow-md ring-1 ring-inset ring-white/25 ${
|
||||
open ? "bg-sky-600 text-white" : "bg-black/75 text-white hover:bg-black/90"
|
||||
}`}
|
||||
style={{ touchAction: "none" }}
|
||||
>
|
||||
<Users size={16} />
|
||||
{badge > 0 && (
|
||||
<span
|
||||
data-testid="overlay-menu-badge"
|
||||
className="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-sky-500 px-1 text-[10px] font-semibold text-white"
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
data-testid="overlay-menu-panel"
|
||||
className={`absolute flex w-72 flex-col items-start gap-2 rounded-lg bg-black/85 p-2 shadow-xl ring-1 ring-inset ring-white/20 ${
|
||||
onLeftHalf ? "left-0" : "right-0"
|
||||
} ${onTopHalf ? "top-11" : "bottom-11"}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -80,6 +80,7 @@ import {
|
|||
} from "@/wasm/collab/comments";
|
||||
import { PresenceRoster } from "@/components/PresenceRoster";
|
||||
import { CommentLayer } from "@/components/CommentLayer";
|
||||
import { OverlayMenu } from "@/components/OverlayMenu";
|
||||
import { hasTunerBridge, PresenceTuner, type TunerModule } from "@/components/PresenceTuner";
|
||||
import {
|
||||
createSheetCollabManager,
|
||||
|
|
@ -895,6 +896,9 @@ export function WasmTool({
|
|||
// the DOM layer maps world→CSS with. Both rebind with the collab session
|
||||
// (per sheet in eeschema).
|
||||
const [commentsCtl, setCommentsCtl] = React.useState<CommentsController | null>(null);
|
||||
// The overlay menu's comments section (0010): a ref-callback slot the
|
||||
// CommentLayer portals its bar/panel into; null while the menu is closed.
|
||||
const [commentsSlot, setCommentsSlot] = React.useState<HTMLDivElement | null>(null);
|
||||
const [viewportState, setViewportState] = React.useState<ViewportState | null>(null);
|
||||
const commentsRef = React.useRef<CommentsController | null>(null);
|
||||
// Dev-time presence style tuner (VITE_PRESENCE_TUNER=1) — set once the wasm
|
||||
|
|
@ -1653,61 +1657,78 @@ export function WasmTool({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Top-right overlay row: who else is in this file (awareness roster),
|
||||
where this project lives / whether Save persists (chip hidden while
|
||||
the UI is hidden), and the Figma-like hide/show-UI toggle — the one
|
||||
control that stays up in canvas-only mode. Read-only sessions swap
|
||||
the toggle for a "View only" pill (chrome stays force-hidden). */}
|
||||
{ready &&
|
||||
(readOnly ||
|
||||
setChromeFn !== null ||
|
||||
peers.length > 0 ||
|
||||
(sourceDescriptor && !effectiveChromeHidden)) && (
|
||||
<div className="absolute right-3 top-3 z-20 flex items-center gap-2">
|
||||
{peers.length > 0 && (
|
||||
<PresenceRoster
|
||||
peers={peers}
|
||||
activeSheetPath={activeSheetPath}
|
||||
following={followingTarget}
|
||||
onFollow={(t) => {
|
||||
if (t) followRef.current?.follow(t);
|
||||
else followRef.current?.unfollow();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{sourceDescriptor && !effectiveChromeHidden && (
|
||||
<SourceChip descriptor={sourceDescriptor} />
|
||||
)}
|
||||
{readOnly && (
|
||||
<span
|
||||
data-testid="view-only-pill"
|
||||
className="flex h-8 items-center rounded-full bg-black/70 px-3 text-xs font-medium text-white shadow-sm ring-1 ring-inset ring-white/20"
|
||||
>
|
||||
View only
|
||||
{/* Overlay menu (0010): the single draggable circular icon replacing the
|
||||
old top-right row. Its badge is the peer count; the panel stacks the
|
||||
session sections — roster, source chip, view-only pill, follow row,
|
||||
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}>
|
||||
{peers.length > 0 && (
|
||||
<PresenceRoster
|
||||
peers={peers}
|
||||
activeSheetPath={activeSheetPath}
|
||||
following={followingTarget}
|
||||
onFollow={(t) => {
|
||||
if (t) followRef.current?.follow(t);
|
||||
else followRef.current?.unfollow();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{sourceDescriptor && <SourceChip descriptor={sourceDescriptor} />}
|
||||
{readOnly && (
|
||||
<span
|
||||
data-testid="view-only-pill"
|
||||
className="flex h-8 items-center rounded-full bg-black/70 px-3 text-xs font-medium text-white shadow-sm ring-1 ring-inset ring-white/20"
|
||||
>
|
||||
View only
|
||||
</span>
|
||||
)}
|
||||
{followingTarget && (
|
||||
<div className="flex items-center gap-2 text-xs text-white">
|
||||
<span>
|
||||
Following <span className="font-semibold">{followingTarget.name}</span>
|
||||
</span>
|
||||
)}
|
||||
{setChromeFn !== null && !readOnly && (
|
||||
<button
|
||||
data-testid="chrome-toggle"
|
||||
aria-pressed={chromeHidden}
|
||||
// same pill design as the comment-bar toggle below it
|
||||
className="flex h-8 min-w-8 items-center justify-center rounded-full bg-black/70 text-white shadow-sm ring-1 ring-inset ring-white/20 hover:bg-black/85"
|
||||
title={`${chromeHidden ? "Show" : "Hide"} UI (${CHROME_HOTKEY_LABEL})`}
|
||||
onClick={() => toggleChromeHidden()}
|
||||
type="button"
|
||||
className="rounded-full bg-white/15 px-2 py-0.5 font-medium hover:bg-white/25"
|
||||
onClick={() => followRef.current?.unfollow()}
|
||||
>
|
||||
{chromeHidden ? <PanelsTopLeft size={15} /> : <EyeOff size={15} />}
|
||||
Stop
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{commentsCtl && (
|
||||
<div
|
||||
data-testid="overlay-menu-comments"
|
||||
ref={setCommentsSlot}
|
||||
className="flex w-full flex-col items-start gap-2"
|
||||
/>
|
||||
)}
|
||||
{setChromeFn !== null && !readOnly && (
|
||||
<button
|
||||
data-testid="chrome-toggle"
|
||||
aria-pressed={chromeHidden}
|
||||
className="flex h-8 items-center gap-2 rounded-full bg-black/70 px-3 text-xs text-white shadow-sm ring-1 ring-inset ring-white/20 hover:bg-black/85"
|
||||
title={`${chromeHidden ? "Show" : "Hide"} UI (${CHROME_HOTKEY_LABEL})`}
|
||||
onClick={() => toggleChromeHidden()}
|
||||
>
|
||||
{chromeHidden ? <PanelsTopLeft size={15} /> : <EyeOff size={15} />}
|
||||
{chromeHidden ? "Show UI" : "Hide UI"}
|
||||
</button>
|
||||
)}
|
||||
</OverlayMenu>
|
||||
)}
|
||||
|
||||
{/* Figma-like comments (0005): GAL pin dots + this DOM layer (hit targets,
|
||||
thread popovers, comment mode, panel). */}
|
||||
thread popovers, comment mode, panel). The bar + list panel render
|
||||
into the overlay menu's comments slot (0010). */}
|
||||
{ready && commentsCtl && (
|
||||
<CommentLayer
|
||||
controller={commentsCtl}
|
||||
viewport={viewportState}
|
||||
currentUser={presenceUser().id}
|
||||
menuSlot={commentsSlot}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue