fix(editor): real comment authors + overlay menu redesign
Comments showed the author's SLUG — which doubles as their personal scope — instead of their name, because WasmTool passed presenceUser().id where .name already existed. - denormalize authorName/authorEmail onto each comment message + thread at write time, so a comment still reads correctly when its author is offline, renamed, or gone. `author` stays the SLUG: colorFor() and the "is this mine?" ownership checks compare it. Legacy comments fall back. - session-identity keeps the email it was already fetching and discarding. Overlay menu: restyle + reorganise into labelled sections (People / Document / Comments / View) over one shared row shape, so sections can't drift apart again. - comments: four bare icons -> labelled rows (Add comment / Show list with count / Hide pins). The nested expand toggle is gone: the section IS the group, so it was a collapsible inside a collapsible. - presence: facepile of initials -> one row per person with an explicit Follow/Stop. The separate "Following X" banner is deleted; that state now lives on the person's own row, so there is nothing to keep in sync. - SourceChip gains a `muted` tone for known-dark surfaces; its solid variant is untouched for the light project pages that share it. Fixes a regression the redesign introduced: the taller panel (z-50) covered the comment popover (z-40), and since a popover can be opened FROM the menu's thread list, the menu swallowed clicks on the popover it had just spawned — delete was unreachable. Popover now z-[60]. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016p9kjdGBdcpwUSjJ3q5xg2
This commit is contained in:
parent
f92266fcee
commit
fc2efeff2c
11 changed files with 387 additions and 178 deletions
|
|
@ -1,7 +1,8 @@
|
|||
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, MessageSquareText, X } from "lucide-react";
|
||||
import { Eye, EyeOff, List, MessageSquarePlus, X } from "lucide-react";
|
||||
import {
|
||||
screenToWorld,
|
||||
worldToScreen,
|
||||
|
|
@ -29,6 +30,28 @@ interface CssRect {
|
|||
height: number;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* What to show for a comment author, and what to reveal on hover.
|
||||
*
|
||||
* `author`/`createdBy` is the SLUG — the identity key used for colors and
|
||||
* ownership. It is what used to be rendered, which is why comments showed a
|
||||
* scope-looking string instead of a person. Prefer the denormalized display
|
||||
* name; legacy messages (written before authorName existed) have none and fall
|
||||
* back to the slug, so old threads keep working.
|
||||
*/
|
||||
function authorLabel(a: { author?: string; createdBy?: string; authorName?: string; authorEmail?: string }): {
|
||||
text: string;
|
||||
title: string;
|
||||
} {
|
||||
const slug = a.author ?? a.createdBy ?? "";
|
||||
const text = a.authorName || slug;
|
||||
// Tooltip: email when we captured one, otherwise the slug — always something
|
||||
// more identifying than the label itself.
|
||||
const title = a.authorEmail ? `${text} <${a.authorEmail}>` : slug;
|
||||
return { text, title };
|
||||
}
|
||||
|
||||
function glCanvasRect(): CssRect | null {
|
||||
const el = Array.from(document.querySelectorAll('[id^="glcanvas-"]')).find((c) => {
|
||||
const r = (c as HTMLElement).getBoundingClientRect();
|
||||
|
|
@ -66,7 +89,6 @@ export function CommentLayer({
|
|||
menuSlot: HTMLElement | null;
|
||||
}) {
|
||||
const [threads, setThreads] = React.useState<ResolvedThread[]>(controller.threads());
|
||||
const [barOpen, setBarOpen] = React.useState(false);
|
||||
const [mode, setMode] = React.useState(false);
|
||||
const [openId, setOpenId] = React.useState<string | null>(null);
|
||||
const [panel, setPanel] = React.useState(false);
|
||||
|
|
@ -211,54 +233,57 @@ export function CommentLayer({
|
|||
const menuUi = menuSlot
|
||||
? createPortal(
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* The overlay menu's "Comments" section IS the group, so there is no
|
||||
nested open/close toggle here any more — that was a collapsible
|
||||
inside a collapsible. And every action is a LABELLED row: the old
|
||||
bar was four bare icons whose meanings you had to hover to learn. */}
|
||||
<div className="flex w-full flex-col">
|
||||
<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"
|
||||
}`}
|
||||
data-testid="comment-mode-toggle"
|
||||
aria-pressed={mode}
|
||||
title={mode ? "Cancel (Esc)" : "Click the canvas to place a pin"}
|
||||
onClick={() => {
|
||||
if (hidden) toggleHidden();
|
||||
setMode((m) => !m);
|
||||
setDraft(null);
|
||||
}}
|
||||
className={`${overlayRowClass} ${mode ? "bg-amber-500/20 text-amber-200" : ""}`}
|
||||
>
|
||||
<MessageSquareText size={15} />
|
||||
{threads.length > 0 && <span>{threads.length}</span>}
|
||||
<MessageSquarePlus size={14} className="shrink-0 text-white/50" />
|
||||
<span>{mode ? "Placing comment…" : "Add comment"}</span>
|
||||
<span className="ml-auto text-[10px] text-white/40">
|
||||
{mode ? "Esc" : ""}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
data-testid="comment-panel-toggle"
|
||||
aria-pressed={panel}
|
||||
title="Show every comment in this file"
|
||||
onClick={() => setPanel((p) => !p)}
|
||||
className={`${overlayRowClass} ${panel ? "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">
|
||||
{threads.length}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
data-testid="comment-visibility-toggle"
|
||||
aria-pressed={!hidden}
|
||||
title={hidden ? "Show the pins on the canvas" : "Hide the pins on the canvas"}
|
||||
onClick={toggleHidden}
|
||||
className={overlayRowClass}
|
||||
>
|
||||
{hidden ? (
|
||||
<EyeOff size={14} className="shrink-0 text-white/50" />
|
||||
) : (
|
||||
<Eye size={14} className="shrink-0 text-white/50" />
|
||||
)}
|
||||
<span>{hidden ? "Show pins" : "Hide pins"}</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). */}
|
||||
|
|
@ -294,8 +319,9 @@ export function CommentLayer({
|
|||
<span
|
||||
className="font-semibold"
|
||||
style={{ color: controller.colorFor(t.createdBy) }}
|
||||
title={authorLabel(t).title}
|
||||
>
|
||||
{t.createdBy}
|
||||
{authorLabel(t).text}
|
||||
</span>{" "}
|
||||
<span className="text-white/50">
|
||||
{timeAgo(t.createdAt)} ago{t.resolved ? " · resolved" : ""}
|
||||
|
|
@ -336,7 +362,7 @@ export function CommentLayer({
|
|||
key={t.id}
|
||||
data-testid="comment-pin"
|
||||
data-thread-id={t.id}
|
||||
title={`${t.createdBy}: ${t.messages[0]?.body ?? ""}${t.detached ? " (detached)" : ""} — drag to move`}
|
||||
title={`${authorLabel(t).text}: ${t.messages[0]?.body ?? ""}${t.detached ? " (detached)" : ""} — drag to move`}
|
||||
onPointerDown={onPinPointerDown(t)}
|
||||
onPointerMove={onPinPointerMove}
|
||||
onPointerUp={onPinPointerUp(t)}
|
||||
|
|
@ -448,10 +474,15 @@ function ThreadPopover({
|
|||
}
|
||||
};
|
||||
|
||||
// z-[60] beats the overlay menu's z-50 ON PURPOSE: the menu panel is tall
|
||||
// enough to cover a pin popover, and a popover can be opened FROM the menu
|
||||
// (the thread list's jump-to), so the menu would otherwise swallow clicks on
|
||||
// the popover it just spawned. The focused surface wins; the menu stays one
|
||||
// click-away from dismissal.
|
||||
return (
|
||||
<div
|
||||
data-testid="comment-popover"
|
||||
className="absolute z-40 w-72 rounded-lg bg-black/90 text-white shadow-lg ring-1 ring-inset ring-white/20"
|
||||
className="absolute z-[60] w-72 rounded-lg bg-black/90 text-white shadow-lg ring-1 ring-inset ring-white/20"
|
||||
style={{
|
||||
left: Math.min(css.x + 16, window.innerWidth - 300),
|
||||
top: Math.min(css.y - 8, window.innerHeight - 260),
|
||||
|
|
@ -493,8 +524,12 @@ function ThreadPopover({
|
|||
{thread.messages.map((m) => (
|
||||
<div key={m.id} data-testid="comment-message" className="group px-3 py-2 text-xs">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="font-semibold" style={{ color: controller.colorFor(m.author) }}>
|
||||
{m.author}
|
||||
<span
|
||||
className="font-semibold"
|
||||
style={{ color: controller.colorFor(m.author) }}
|
||||
title={authorLabel(m).title}
|
||||
>
|
||||
{authorLabel(m).text}
|
||||
</span>
|
||||
<span className="text-[10px] text-white/40">
|
||||
{timeAgo(m.createdAt)} ago{m.editedAt ? " · edited" : ""}
|
||||
|
|
|
|||
|
|
@ -13,8 +13,48 @@ import { Users } from "lucide-react";
|
|||
* 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.
|
||||
*
|
||||
* VISUAL SYSTEM. The panel previously stacked whatever its children happened to
|
||||
* look like — free-floating pills of different heights, radii and surfaces, all
|
||||
* left-aligned with a gap. It read as debris rather than a menu. The primitives
|
||||
* below are the fix and the contract:
|
||||
*
|
||||
* OverlayMenuSection — a labelled group, separated from its neighbours.
|
||||
* overlayRowClass — the shared row shape for anything interactive.
|
||||
*
|
||||
* Children compose these instead of inventing their own chrome. Two deliberate
|
||||
* exceptions stay self-styled because they are shared with light-background
|
||||
* pages (SourceChip) or own a nontrivial internal layout (PresenceRoster) —
|
||||
* those are wrapped in a section rather than restyled.
|
||||
*/
|
||||
|
||||
/** Row shape for any interactive item in the panel. Full-width so the panel
|
||||
* 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";
|
||||
|
||||
/** A labelled group. `label` is omitted for the first/unnamed group. */
|
||||
export function OverlayMenuSection({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label?: string;
|
||||
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">
|
||||
{label && (
|
||||
<div className="px-2 text-[10px] font-semibold uppercase tracking-wide text-white/40">
|
||||
{label}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const POS_KEY = "pcbjam:overlay-menu-pos";
|
||||
const FAB_SIZE = 36;
|
||||
const DRAG_THRESHOLD_PX = 4;
|
||||
|
|
@ -156,8 +196,10 @@ export function OverlayMenu({
|
|||
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"
|
||||
className={`relative flex h-9 w-9 items-center justify-center rounded-full text-white 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"
|
||||
}`}
|
||||
style={{ touchAction: "none" }}
|
||||
>
|
||||
|
|
@ -175,10 +217,20 @@ export function OverlayMenu({
|
|||
{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 ${
|
||||
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 ${
|
||||
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">
|
||||
Session
|
||||
</span>
|
||||
{badge > 0 && (
|
||||
<span className="text-[10px] text-white/40">
|
||||
{badge} {badge === 1 ? "other" : "others"} here
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,28 @@
|
|||
import { Eye } from "lucide-react";
|
||||
import type { PresencePeer } from "@/wasm/collab/presence";
|
||||
import type { FollowTarget } from "@/wasm/collab/follow-user";
|
||||
import { overlayRowClass } from "@/components/OverlayMenu";
|
||||
|
||||
/**
|
||||
* "Who else is in this file" (collab-presence 0001/0003): a compact facepile of
|
||||
* the room's OTHER users, one colored initial-avatar per person, fed by the
|
||||
* collab session's awareness. For eeschema (per-sheet rooms + warm-pool
|
||||
* skeleton states) peers on a DIFFERENT sheet render dimmed, with the sheet
|
||||
* they're on in the tooltip. Rendered in the editor's top-right overlay stack
|
||||
* next to SourceChip; the parent hides it when there are no peers. Chip styling
|
||||
* mirrors SourceChip (solid fill + inset ring) so it is legible on any backdrop.
|
||||
* "Who else is in this file" (collab-presence 0001/0003), as a readable LIST.
|
||||
*
|
||||
* Follow-user (0008): when `onFollow` is provided, clicking a same-sheet
|
||||
* peer's avatar follows their viewport (click again to stop); the followed
|
||||
* peer gets a ring in their color. The parent renders the "Following…"
|
||||
* banner — the roster only toggles.
|
||||
* This used to be a facepile — overlapping 20px circles showing one initial
|
||||
* each. That is a fine density trade in a cramped toolbar, but it lives in the
|
||||
* overlay menu now (0010), which is 288px wide: there is room for names, and a
|
||||
* row of colored initials told you neither who was here nor that clicking one
|
||||
* followed them. One row per person instead: color dot, name, where they are,
|
||||
* and an explicit follow affordance.
|
||||
*
|
||||
* For eeschema (per-sheet rooms) peers on a DIFFERENT sheet are dimmed, say
|
||||
* which sheet they're on, and are not followable — following someone you cannot
|
||||
* see would just teleport you.
|
||||
*
|
||||
* Follow-user (0008): clicking a same-sheet peer follows their viewport; the
|
||||
* followed peer's row stays highlighted with a "Stop" action, so the state and
|
||||
* its exit live on the same row (the parent no longer renders a separate
|
||||
* "Following…" banner).
|
||||
*/
|
||||
const MAX_AVATARS = 5;
|
||||
const MAX_ROWS = 6;
|
||||
|
||||
function sheetLabel(sheetPath?: string): string {
|
||||
if (!sheetPath) return "";
|
||||
|
|
@ -42,13 +49,10 @@ export function PresenceRoster({
|
|||
const sameSheet = (p: PresencePeer) =>
|
||||
(p.sheetPath ?? undefined) === (activeSheetPath ?? undefined);
|
||||
// Same-sheet peers first, then elsewhere (dimmed) — stable within each group.
|
||||
const ordered = [...peers].sort((a, b) => Number(sameSheet(b)) - Number(sameSheet(a)));
|
||||
const names = ordered
|
||||
.map((p) =>
|
||||
sameSheet(p) ? p.user.name : `${p.user.name} (on ${sheetLabel(p.sheetPath) || "another sheet"})`,
|
||||
)
|
||||
.join(", ");
|
||||
const shown = ordered.slice(0, MAX_AVATARS);
|
||||
const ordered = [...peers].sort(
|
||||
(a, b) => Number(sameSheet(b)) - Number(sameSheet(a)),
|
||||
);
|
||||
const shown = ordered.slice(0, MAX_ROWS);
|
||||
|
||||
const followable = (p: PresencePeer) => !!onFollow && sameSheet(p);
|
||||
const isFollowed = (p: PresencePeer) => following?.clientId === p.clientId;
|
||||
|
|
@ -56,46 +60,69 @@ export function PresenceRoster({
|
|||
const toggleFollow = (p: PresencePeer) => {
|
||||
if (!onFollow) return;
|
||||
onFollow(
|
||||
isFollowed(p) ? null : { clientId: p.clientId, userId: p.user.id, name: p.user.name },
|
||||
isFollowed(p)
|
||||
? null
|
||||
: { clientId: p.clientId, userId: p.user.id, name: p.user.name },
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
data-testid="presence-roster"
|
||||
title={`Also here: ${names}`}
|
||||
className="inline-flex items-center rounded-full bg-black/70 py-0.5 pl-1 pr-1.5 shadow-sm ring-1 ring-inset ring-white/20"
|
||||
>
|
||||
{shown.map((p) => (
|
||||
<button
|
||||
key={p.user.id}
|
||||
type="button"
|
||||
data-presence-user={p.user.id}
|
||||
data-presence-elsewhere={sameSheet(p) ? undefined : "1"}
|
||||
data-presence-following={isFollowed(p) ? "1" : undefined}
|
||||
disabled={!followable(p)}
|
||||
onClick={() => toggleFollow(p)}
|
||||
title={
|
||||
sameSheet(p)
|
||||
? `${p.user.name}${onFollow ? (isFollowed(p) ? " — click to stop following" : " — click to follow") : ""}`
|
||||
: `${p.user.name} — on ${sheetLabel(p.sheetPath) || "another sheet"}`
|
||||
}
|
||||
style={{
|
||||
backgroundColor: p.user.color,
|
||||
...(isFollowed(p) ? { boxShadow: `0 0 0 2px ${p.user.color}` } : {}),
|
||||
}}
|
||||
className={`-ml-1.5 flex h-5 w-5 items-center justify-center rounded-full text-[10px] font-semibold text-white ring-2 ring-black/50 first:ml-0 ${
|
||||
sameSheet(p) ? "cursor-pointer" : "opacity-40"
|
||||
}`}
|
||||
>
|
||||
{p.user.name.charAt(0).toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
{peers.length > MAX_AVATARS && (
|
||||
<span className="ml-1 text-[10px] font-medium text-white/80">
|
||||
+{peers.length - MAX_AVATARS}
|
||||
<div data-testid="presence-roster" className="flex w-full flex-col">
|
||||
{shown.map((p) => {
|
||||
const here = sameSheet(p);
|
||||
const followed = isFollowed(p);
|
||||
const elsewhere = sheetLabel(p.sheetPath) || "another sheet";
|
||||
return (
|
||||
<button
|
||||
key={p.user.id}
|
||||
type="button"
|
||||
data-presence-user={p.user.id}
|
||||
data-presence-elsewhere={here ? undefined : "1"}
|
||||
data-presence-following={followed ? "1" : undefined}
|
||||
disabled={!followable(p)}
|
||||
onClick={() => toggleFollow(p)}
|
||||
title={
|
||||
here
|
||||
? onFollow
|
||||
? followed
|
||||
? `${p.user.name} — click to stop following`
|
||||
: `${p.user.name} — click to follow their view`
|
||||
: p.user.name
|
||||
: `${p.user.name} — on ${elsewhere}`
|
||||
}
|
||||
className={`${overlayRowClass} ${
|
||||
followed ? "bg-white/10" : ""
|
||||
} ${here ? "" : "cursor-default opacity-50 hover:bg-transparent"}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="h-2.5 w-2.5 shrink-0 rounded-full ring-1 ring-inset ring-black/40"
|
||||
style={{ backgroundColor: p.user.color }}
|
||||
/>
|
||||
<span className="truncate">{p.user.name}</span>
|
||||
{!here && (
|
||||
<span className="ml-auto shrink-0 truncate text-[10px] 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">
|
||||
<Eye size={12} /> Stop
|
||||
</span>
|
||||
)}
|
||||
{here && !followed && followable(p) && (
|
||||
<span className="ml-auto shrink-0 text-[10px] text-white/35">
|
||||
Follow
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{peers.length > MAX_ROWS && (
|
||||
<span className="px-2 py-1 text-[10px] text-white/40">
|
||||
+{peers.length - MAX_ROWS} more
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,14 +23,41 @@ const TONES: Record<SourceKind, string> = {
|
|||
"remote-rw": "bg-sky-600 text-white ring-sky-300/30",
|
||||
};
|
||||
|
||||
// MUTED variant, for use on a known-dark surface (the editor's overlay menu).
|
||||
// The saturated fills above exist because the chip must survive an unknown
|
||||
// backdrop; inside the menu the backdrop IS known, and a solid sky/emerald pill
|
||||
// just shouts. Here the colour drops to a small leading dot and the chip itself
|
||||
// becomes another neutral row.
|
||||
const MUTED_TONES: Record<SourceKind, string> = {
|
||||
local: "text-emerald-300/90",
|
||||
"remote-ro": "text-amber-300/90",
|
||||
"remote-rw": "text-sky-300/90",
|
||||
};
|
||||
|
||||
export function SourceChip({
|
||||
descriptor,
|
||||
className = "",
|
||||
tone = "solid",
|
||||
}: {
|
||||
descriptor: SourceDescriptor;
|
||||
className?: string;
|
||||
/** `muted` for known-dark surfaces (see MUTED_TONES). */
|
||||
tone?: "solid" | "muted";
|
||||
}) {
|
||||
const Icon = ICONS[descriptor.kind];
|
||||
|
||||
if (tone === "muted") {
|
||||
return (
|
||||
<span
|
||||
title={descriptor.description}
|
||||
className={`inline-flex items-center gap-2 text-xs font-medium text-white/90 ${className}`}
|
||||
>
|
||||
<Icon size={14} className={`shrink-0 ${MUTED_TONES[descriptor.kind]}`} />
|
||||
{descriptor.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
title={descriptor.description}
|
||||
|
|
|
|||
|
|
@ -15,10 +15,11 @@ import {
|
|||
type KicadDoc,
|
||||
type Tool,
|
||||
} from "@pcbjam/shared";
|
||||
import { ChevronDown, ChevronUp, EyeOff, Loader2, PanelsTopLeft } from "lucide-react";
|
||||
import { ChevronDown, ChevronUp, Eye, EyeOff, Loader2, PanelsTopLeft } from "lucide-react";
|
||||
import {
|
||||
API_BASE_URL,
|
||||
APP_URL,
|
||||
commentAuthor,
|
||||
currentScope,
|
||||
libsSourceConfig,
|
||||
modelsSourceConfig,
|
||||
|
|
@ -86,7 +87,11 @@ import {
|
|||
} from "@/wasm/collab/comments";
|
||||
import { PresenceRoster } from "@/components/PresenceRoster";
|
||||
import { CommentLayer } from "@/components/CommentLayer";
|
||||
import { OverlayMenu } from "@/components/OverlayMenu";
|
||||
import {
|
||||
OverlayMenu,
|
||||
OverlayMenuSection,
|
||||
overlayRowClass,
|
||||
} from "@/components/OverlayMenu";
|
||||
import { hasTunerBridge, PresenceTuner, type TunerModule } from "@/components/PresenceTuner";
|
||||
import {
|
||||
createSheetCollabManager,
|
||||
|
|
@ -1164,7 +1169,7 @@ export function WasmTool({
|
|||
const ctl = createComments({
|
||||
doc,
|
||||
mod: win.Module,
|
||||
user: presenceUser().id,
|
||||
user: commentAuthor(),
|
||||
tool,
|
||||
// Author colors follow the live nth-in-room assignment when the
|
||||
// author is present; offline authors fall back to the name hash.
|
||||
|
|
@ -1684,58 +1689,79 @@ export function WasmTool({
|
|||
the one control that stays up in canvas-only (chrome-hidden) mode. */}
|
||||
{ready && (
|
||||
<OverlayMenu badge={peers.length}>
|
||||
{/* 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. */}
|
||||
{peers.length > 0 && (
|
||||
<PresenceRoster
|
||||
peers={peers}
|
||||
activeSheetPath={activeSheetPath}
|
||||
following={followingTarget}
|
||||
onFollow={(t) => {
|
||||
if (t) followRef.current?.follow(t);
|
||||
else followRef.current?.unfollow();
|
||||
}}
|
||||
/>
|
||||
<OverlayMenuSection label="People">
|
||||
<PresenceRoster
|
||||
peers={peers}
|
||||
activeSheetPath={activeSheetPath}
|
||||
following={followingTarget}
|
||||
onFollow={(t) => {
|
||||
if (t) followRef.current?.follow(t);
|
||||
else followRef.current?.unfollow();
|
||||
}}
|
||||
/>
|
||||
</OverlayMenuSection>
|
||||
)}
|
||||
{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>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full bg-white/15 px-2 py-0.5 font-medium hover:bg-white/25"
|
||||
onClick={() => followRef.current?.unfollow()}
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* DOCUMENT — where this file came from and whether you may edit it.
|
||||
SourceChip is shared with the light project pages, so instead of
|
||||
restyling it we ask for its `muted` tone: colour drops to a dot,
|
||||
and the chip sits in a normal row like everything else. */}
|
||||
{(sourceDescriptor || readOnly) && (
|
||||
<OverlayMenuSection label="Document">
|
||||
{sourceDescriptor && (
|
||||
<div className={`${overlayRowClass} cursor-default`}>
|
||||
<SourceChip descriptor={sourceDescriptor} tone="muted" />
|
||||
</div>
|
||||
)}
|
||||
{readOnly && (
|
||||
<div
|
||||
data-testid="view-only-pill"
|
||||
className={`${overlayRowClass} cursor-default`}
|
||||
>
|
||||
<EyeOff size={14} className="shrink-0 text-white/50" />
|
||||
<span>View only</span>
|
||||
<span className="ml-auto text-[10px] text-white/40">
|
||||
read-only
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</OverlayMenuSection>
|
||||
)}
|
||||
|
||||
{commentsCtl && (
|
||||
<div
|
||||
data-testid="overlay-menu-comments"
|
||||
ref={setCommentsSlot}
|
||||
className="flex w-full flex-col items-start gap-2"
|
||||
/>
|
||||
<OverlayMenuSection label="Comments">
|
||||
<div
|
||||
data-testid="overlay-menu-comments"
|
||||
ref={setCommentsSlot}
|
||||
className="flex w-full flex-col items-start gap-2"
|
||||
/>
|
||||
</OverlayMenuSection>
|
||||
)}
|
||||
|
||||
{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>
|
||||
<OverlayMenuSection label="View">
|
||||
<button
|
||||
data-testid="chrome-toggle"
|
||||
aria-pressed={chromeHidden}
|
||||
className={overlayRowClass}
|
||||
title={`${chromeHidden ? "Show" : "Hide"} UI (${CHROME_HOTKEY_LABEL})`}
|
||||
onClick={() => toggleChromeHidden()}
|
||||
>
|
||||
{chromeHidden ? (
|
||||
<PanelsTopLeft size={14} className="shrink-0 text-white/50" />
|
||||
) : (
|
||||
<EyeOff size={14} className="shrink-0 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">
|
||||
{CHROME_HOTKEY_LABEL}
|
||||
</kbd>
|
||||
</button>
|
||||
</OverlayMenuSection>
|
||||
)}
|
||||
</OverlayMenu>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -214,6 +214,28 @@ export function presenceUser(): PresenceUser {
|
|||
return { id: slug, name, color: colorForUser(slug) };
|
||||
}
|
||||
|
||||
/** Author identity stamped onto comments. */
|
||||
export interface CommentAuthor {
|
||||
/** Slug — the IDENTITY key (colors, "is this mine?"). Never a display name. */
|
||||
id: string;
|
||||
/** Display name; falls back to the slug when no session identity is loaded. */
|
||||
name: string;
|
||||
/** Only present for a real authenticated session. */
|
||||
email?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Who to attribute a new comment to. Same identity as `presenceUser()`, plus the
|
||||
* email, and kept separate because comments DENORMALIZE these at write time
|
||||
* (comments-wire.ts) whereas presence re-broadcasts them live.
|
||||
*/
|
||||
export function commentAuthor(): CommentAuthor {
|
||||
const slug = userSlug();
|
||||
const session = sessionIdentity();
|
||||
const mine = session && session.slug === slug ? session : null;
|
||||
return { id: slug, name: mine?.name ?? slug, email: mine?.email };
|
||||
}
|
||||
|
||||
/**
|
||||
* DEV-TIME presence style tuner (collab-presence): VITE_PRESENCE_TUNER=1 mounts
|
||||
* a floating panel that live-patches the wasm overlay style
|
||||
|
|
|
|||
|
|
@ -7,7 +7,12 @@
|
|||
* without the endpoint (example backend, demo/static) simply yield null and
|
||||
* the pre-auth slug fallback in config.ts stays in effect.
|
||||
*/
|
||||
export type SessionIdentity = { slug: string; name: string };
|
||||
export type SessionIdentity = {
|
||||
slug: string;
|
||||
name: string;
|
||||
/** Undefined for backends that don't return one (example/demo/static). */
|
||||
email?: string;
|
||||
};
|
||||
|
||||
let identity: SessionIdentity | null = null;
|
||||
let pending: Promise<SessionIdentity | null> | null = null;
|
||||
|
|
@ -35,12 +40,11 @@ export function loadSessionIdentity(
|
|||
} | null
|
||||
)?.user;
|
||||
if (u && typeof u.slug === "string" && u.slug) {
|
||||
const email = typeof u.email === "string" && u.email ? u.email : undefined;
|
||||
identity = {
|
||||
slug: u.slug,
|
||||
name:
|
||||
(typeof u.name === "string" && u.name) ||
|
||||
(typeof u.email === "string" && u.email) ||
|
||||
u.slug,
|
||||
name: (typeof u.name === "string" && u.name) || email || u.slug,
|
||||
email,
|
||||
};
|
||||
}
|
||||
return identity;
|
||||
|
|
|
|||
|
|
@ -115,8 +115,12 @@ const PUSH_THROTTLE_MS = 30;
|
|||
export function createComments(opts: {
|
||||
doc: Y.Doc;
|
||||
mod: CommentPinsModule;
|
||||
/** Author slug for new messages (presence identity). */
|
||||
user: string;
|
||||
/**
|
||||
* Author for new messages. `id` is the slug (identity key); `name`/`email`
|
||||
* are denormalized onto each message at write time so a comment still shows
|
||||
* a real author when they are offline, renamed, or gone (comments-wire.ts).
|
||||
*/
|
||||
user: { id: string; name?: string; email?: string };
|
||||
tool: string;
|
||||
/** Presence color resolver (nth-in-room); undefined falls back to the hash. */
|
||||
colorFor?: (userId: string) => string | undefined;
|
||||
|
|
@ -223,10 +227,21 @@ export function createComments(opts: {
|
|||
return { pos: { x: world.x, y: world.y } };
|
||||
},
|
||||
create(anchor, body) {
|
||||
return createThread(doc, { anchor, author: user, body });
|
||||
return createThread(doc, {
|
||||
anchor,
|
||||
author: user.id,
|
||||
authorName: user.name,
|
||||
authorEmail: user.email,
|
||||
body,
|
||||
});
|
||||
},
|
||||
reply(threadId, body) {
|
||||
addMessage(doc, threadId, { author: user, body });
|
||||
addMessage(doc, threadId, {
|
||||
author: user.id,
|
||||
authorName: user.name,
|
||||
authorEmail: user.email,
|
||||
body,
|
||||
});
|
||||
},
|
||||
edit(threadId, messageId, body) {
|
||||
return editMessage(doc, threadId, messageId, body);
|
||||
|
|
|
|||
Loading…
Reference in a new issue