- Read-only sessions boot BOTH panels open as collapsed headers (a stored per-browser choice wins); inspector's default anchor moves to the top-left, layers keep the top-right stack under the FAB. - Layer rows follow KiCad's Appearance-pane order: color swatch, eye toggle, layer name (name click = set active). - read-only spec: boot-default assertions (open + collapsed + top-left anchor) in the lock test, chevron expansion in the panels test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011gJ3M1RpeZLeNUUj8jKC4h
251 lines
9.1 KiB
TypeScript
251 lines
9.1 KiB
TypeScript
import * as React from "react";
|
|
import { ChevronDown, ChevronRight, Eye, EyeOff, X } from "lucide-react";
|
|
import { useDraggablePanel } from "@/components/useDraggablePanel";
|
|
|
|
/**
|
|
* Floating layer panel (viewer-panels): the canvas-only replacement for the
|
|
* wx Appearance pane that kicadSetChrome(false) hides. Reads/toggles per-layer
|
|
* visibility and the active layer through the layer bridge (pcbnew builds
|
|
* only — kicadLayersGetState/SetVisible/SetActive), updating event-driven from
|
|
* the C++ `window.kicadCollab.onLayersState` push that follows every apply.
|
|
*
|
|
* Same draggable-panel conventions as the comments panel (comments-ux 0001 B):
|
|
* header = drag handle, collapse-to-header, position/collapse persisted,
|
|
* always-onscreen restore via useDraggablePanel.
|
|
*/
|
|
|
|
const PANEL_POS_KEY = "pcbjam:layers-panel-pos";
|
|
const PANEL_COLLAPSED_KEY = "pcbjam:layers-panel-collapsed";
|
|
const PANEL_W = 256; // w-64
|
|
const PANEL_HEADER_H = 36;
|
|
|
|
export interface LayerRow {
|
|
id: number;
|
|
name: string;
|
|
canonical: string;
|
|
copper: boolean;
|
|
visible: boolean;
|
|
color: string;
|
|
}
|
|
|
|
export interface LayersState {
|
|
active: number;
|
|
layers: LayerRow[];
|
|
}
|
|
|
|
export interface LayersModule {
|
|
kicadLayersGetState(): string;
|
|
kicadLayersSetVisible(id: number, visible: boolean): boolean | Promise<boolean>;
|
|
kicadLayersSetActive(id: number): boolean | Promise<boolean>;
|
|
}
|
|
|
|
interface LayersWindow {
|
|
kicadCollab?: { onLayersState?: (json: string) => void };
|
|
}
|
|
|
|
/** True when the loaded wasm exposes the layer bridge (pcbnew builds). */
|
|
export function hasLayersBridge(mod: unknown): mod is LayersModule {
|
|
const m = mod as Partial<LayersModule> | undefined;
|
|
return (
|
|
typeof m?.kicadLayersGetState === "function" &&
|
|
typeof m?.kicadLayersSetVisible === "function" &&
|
|
typeof m?.kicadLayersSetActive === "function"
|
|
);
|
|
}
|
|
|
|
export function parseLayersState(json: string): LayersState | null {
|
|
try {
|
|
const v: unknown = JSON.parse(json);
|
|
if (!v || typeof v !== "object") return null;
|
|
const o = v as { active?: unknown; layers?: unknown };
|
|
if (typeof o.active !== "number" || !Array.isArray(o.layers)) return null;
|
|
const layers: LayerRow[] = [];
|
|
for (const l of o.layers) {
|
|
const r = l as Partial<LayerRow> | null;
|
|
if (!r || typeof r.id !== "number" || typeof r.name !== "string") continue;
|
|
layers.push({
|
|
id: r.id,
|
|
name: r.name,
|
|
canonical: typeof r.canonical === "string" ? r.canonical : r.name,
|
|
copper: r.copper === true,
|
|
visible: r.visible !== false,
|
|
color: typeof r.color === "string" ? r.color : "",
|
|
});
|
|
}
|
|
return { active: o.active, layers };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function LayerPanel({
|
|
mod,
|
|
defaultCollapsed,
|
|
onClose,
|
|
}: {
|
|
mod: LayersModule;
|
|
/** Boot state when no per-browser choice is stored (read-only sessions
|
|
* start as a collapsed header — viewer-panels). */
|
|
defaultCollapsed?: boolean;
|
|
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 {
|
|
const stored = localStorage.getItem(PANEL_COLLAPSED_KEY);
|
|
if (stored !== null) return stored === "1";
|
|
} catch {
|
|
/* private mode */
|
|
}
|
|
return defaultCollapsed === true;
|
|
});
|
|
const setCollapsed = (v: boolean) => {
|
|
setCollapsedState(v);
|
|
try {
|
|
localStorage.setItem(PANEL_COLLAPSED_KEY, v ? "1" : "0");
|
|
} catch {
|
|
/* private mode */
|
|
}
|
|
};
|
|
|
|
const [state, setState] = React.useState<LayersState | null>(() =>
|
|
parseLayersState(mod.kicadLayersGetState() || "null"),
|
|
);
|
|
|
|
// Event-driven refresh: the C++ side pushes the fresh state after every
|
|
// apply (both setters run on the coroutine — a synchronous re-read right
|
|
// after the call would still see old state). Spread-preserving install,
|
|
// same etiquette as the presence bridge's handlers.
|
|
React.useEffect(() => {
|
|
const win = window as LayersWindow;
|
|
win.kicadCollab = {
|
|
...win.kicadCollab,
|
|
onLayersState: (json) => {
|
|
const s = parseLayersState(json);
|
|
if (s) setState(s);
|
|
},
|
|
};
|
|
return () => {
|
|
if (win.kicadCollab) delete win.kicadCollab.onLayersState;
|
|
};
|
|
}, []);
|
|
|
|
const setVisible = (id: number, visible: boolean) => {
|
|
// Optimistic flip for a snappy checkbox; the onLayersState push corrects.
|
|
setState((s) =>
|
|
s
|
|
? { ...s, layers: s.layers.map((l) => (l.id === id ? { ...l, visible } : l)) }
|
|
: s,
|
|
);
|
|
void mod.kicadLayersSetVisible(id, visible);
|
|
};
|
|
|
|
const setActive = (id: number) => {
|
|
setState((s) => (s ? { ...s, active: id } : s));
|
|
void mod.kicadLayersSetActive(id);
|
|
};
|
|
|
|
// Default anchor: below the overlay-menu FAB (right-anchored, top 12 + 36 + gap).
|
|
const style: React.CSSProperties = drag.pos
|
|
? { left: drag.pos.x, top: drag.pos.y }
|
|
: { right: 12, top: 56 };
|
|
|
|
return (
|
|
<div
|
|
ref={rootRef}
|
|
data-testid="layers-panel"
|
|
className="absolute z-40 flex w-64 flex-col overflow-hidden rounded-xl bg-white/95 text-neutral-900 shadow-2xl ring-1 ring-inset ring-black/10 backdrop-blur-sm dark:bg-neutral-950/90 dark:text-white dark:ring-white/15"
|
|
style={style}
|
|
>
|
|
{/* Header = drag handle. Interactive children stop pointerdown so they
|
|
don't start a drag. */}
|
|
<div
|
|
data-testid="layers-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="Layers — drag to move"
|
|
onPointerDown={(e) => drag.onPointerDown(e, rootRef.current!.getBoundingClientRect())}
|
|
onPointerMove={(e) => void drag.onPointerMove(e)}
|
|
onPointerUp={() => void drag.onPointerUp()}
|
|
>
|
|
<button
|
|
data-testid="layers-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>Layers</span>
|
|
<span className="ml-auto flex items-center gap-0.5" onPointerDown={(e) => e.stopPropagation()}>
|
|
<button
|
|
data-testid="layers-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 && (
|
|
<div data-testid="layers-panel-list" className="max-h-[60vh] overflow-y-auto pb-1">
|
|
{!state && (
|
|
<p className="px-3 pb-3 text-xs text-neutral-500 dark:text-white/50">
|
|
Layer state isn't available yet.
|
|
</p>
|
|
)}
|
|
{state?.layers.map((l) => (
|
|
<div
|
|
key={l.id}
|
|
data-testid="layer-row"
|
|
data-layer-id={l.id}
|
|
data-active={state.active === l.id || undefined}
|
|
className={`flex w-full items-center gap-2 border-t border-black/5 px-3 py-1 text-xs dark:border-white/5 ${
|
|
state.active === l.id ? "bg-sky-500/10 dark:bg-sky-400/10" : ""
|
|
}`}
|
|
>
|
|
{/* KiCad Appearance-pane row order: color swatch, eye, name.
|
|
The name sets the ACTIVE layer; the eye toggles visibility. */}
|
|
<span
|
|
className="h-3 w-3 shrink-0 rounded-sm ring-1 ring-inset ring-black/20 dark:ring-white/25"
|
|
style={l.color ? { backgroundColor: l.color } : undefined}
|
|
/>
|
|
<button
|
|
data-testid="layer-visibility"
|
|
aria-pressed={l.visible}
|
|
title={l.visible ? `Hide ${l.name}` : `Show ${l.name}`}
|
|
onClick={() => setVisible(l.id, !l.visible)}
|
|
className={`rounded p-0.5 hover:bg-black/5 dark:hover:bg-white/10 ${
|
|
l.visible
|
|
? "text-neutral-600 dark:text-white/70"
|
|
: "text-neutral-300 dark:text-white/25"
|
|
}`}
|
|
>
|
|
{l.visible ? <Eye size={13} /> : <EyeOff size={13} />}
|
|
</button>
|
|
<button
|
|
data-testid="layer-activate"
|
|
className="min-w-0 flex-1 text-left hover:text-sky-600 dark:hover:text-sky-300"
|
|
title={`Make ${l.name} the active layer`}
|
|
onClick={() => setActive(l.id)}
|
|
>
|
|
<span className={`block truncate ${state.active === l.id ? "font-semibold" : ""}`}>
|
|
{l.name}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|