"use client"; import { ReactNode, useMemo, useState } from "react"; import { Reorder, useDragControls } from "motion/react"; import { Frame, Group, Item, KIND_SPEC, Palette, explodeGroup, isPhoneFrame } from "../lib/tokens"; import { Icon } from "./M3Node"; import { Lang, KIND_TEXT, t, useLang } from "../lib/i18n"; /* Rows never animate their size: opening a row only adds rows under it, so * nothing stretches. Only the drag itself moves. * * The panel is a tree with two kinds of level. A z level lists what is drawn on * top of what, front first: the runs of a screen, and the runs hidden inside a * free group. A run level lists the parts of one connected run in reading order * (left to right, top to bottom). Every level is reordered by dragging the * handle; the page turns the new order back into a group. */ function nameOf(it: Item, lang: Lang) { const spec = KIND_SPEC[it.kind]; const noun = KIND_TEXT[lang][it.kind]?.noun ?? spec.label; return it.label.trim() || (it.kind === "iconButton" || it.kind === "fab" ? (it.icon ?? noun) : noun); } function runLabel(g: Group, lang: Lang) { const first = g.items[0]; const noun = KIND_TEXT[lang][first.kind]?.noun ?? KIND_SPEC[first.kind].label; return g.free ? `${t("group", lang)} × ${g.items.length}` : g.items.length > 1 ? `${noun} × ${g.items.length}` : nameOf(first, lang); } function Row({ id, p, depth, icon, label, on, onSelect, open, onToggle, onDragging, children, }: { id: string; p: Palette; depth: number; icon: ReactNode; label: string; on: boolean; onSelect: (add: boolean) => void; /** set when the row can open to show what it holds */ open?: boolean; onToggle?: () => void; onDragging: (dragging: boolean) => void; children?: ReactNode; }) { const lang = useLang(); const controls = useDragControls(); const h = depth === 0 ? 40 : 36; return ( onDragging(true)} onDragEnd={() => onDragging(false)} style={{ listStyle: "none", display: "flex", flexDirection: "column", gap: 4, position: "relative" }}>
{ e.preventDefault(); controls.start(e); }} style={{ cursor: "grab", color: p.outline, display: "grid", placeItems: "center", width: depth === 0 ? 24 : 20, height: h, touchAction: "none" }} > {onToggle && ( )}
{open && children}
); } /** One reorderable level: drag the handles; `values` is the shown order. */ function Level({ values, onReorder, children }: { values: string[]; onReorder: (next: string[]) => void; children: ReactNode }) { return ( {children} ); } /** the parts of one connected run, in reading order */ function RunParts({ run, p, depth, sel, onSelect, onReorder, onDragging, }: { run: Group; p: Palette; depth: number; sel: Set; onSelect: (itemIds: string[], add: boolean) => void; /** the run's parts in a new reading order */ onReorder: (ids: string[]) => void; onDragging: (dragging: boolean) => void; }) { const lang = useLang(); const ids = run.items.map((it) => it.id); return ( {run.items.map((it) => ( } label={nameOf(it, lang)} on={sel.has(it.id)} onSelect={(add) => onSelect([it.id], add)} onDragging={onDragging} /> ))} ); } /** The z-order of one screen, top layer first, reordered by dragging the handles. * A connected run opens to its parts in reading order; a free group opens to the * runs it holds, front first, and each of those opens in turn. */ export function LayersPanel({ p, frames, frameId, onFrame, groups, widths, selectedIds, onSelect, onReorder, onReorderItems, onDragging, }: { p: Palette; frames: Frame[]; frameId: string | null; onFrame: (id: string) => void; /** the runs on the chosen frame, in canvas order (bottom first) */ groups: Group[]; widths: Record; selectedIds: string[]; /** `add` is set when Shift was held, to extend the selection */ onSelect: (itemIds: string[], add: boolean) => void; /** new order, top layer first */ onReorder: (topFirst: string[]) => void; /** a group's parts in a new order: back to front for a free group, reading order for a run */ onReorderItems: (groupId: string, ids: string[]) => void; /** a drag on any level starting or ending, so the page can record one undo step for the whole drag */ onDragging: (dragging: boolean) => void; }) { const lang = useLang(); const topFirst = useMemo(() => [...groups].reverse(), [groups]); const ids = topFirst.map((g) => g.id); const sel = new Set(selectedIds); const [openIds, setOpenIds] = useState>(() => new Set()); const toggle = (id: string) => setOpenIds((cur) => { const next = new Set(cur); if (next.has(id)) next.delete(id); else next.add(id); return next; }); /** the runs hidden in a free group, front first, and the flat back-to-front list they make */ const freeRuns = (g: Group) => [...explodeGroup(g, widths)].reverse(); const flatten = (runsTopFirst: Group[]) => [...runsTopFirst].reverse().flatMap((r) => r.items.map((it) => it.id)); const groupBody = (g: Group, depth: number) => { if (!g.free) { return onReorderItems(g.id, order)} onDragging={onDragging} />; } const runs = freeRuns(g); const runIds = runs.map((r) => r.id); const byId = new Map(runs.map((r) => [r.id, r])); const reorderRuns = (next: string[]) => onReorderItems(g.id, flatten(next.map((id) => byId.get(id)).filter((r): r is Group => !!r))); return ( {runs.map((r) => { const many = r.items.length > 1; const open = many && openIds.has(r.id); return ( )} label={runLabel(r, lang)} on={r.items.some((it) => sel.has(it.id))} onSelect={(add) => onSelect(r.items.map((it) => it.id), add)} open={many ? open : undefined} onToggle={many ? () => toggle(r.id) : undefined} onDragging={onDragging} > { /* the run's members take each other's places in the list; every other part keeps its own */ const members = new Set(r.items.map((it) => it.id)); let k = 0; onReorderItems( g.id, g.items.map((it) => (members.has(it.id) ? order[k++] : it.id)), ); }} /> ); })} ); }; return (
{frames.length > 1 && (
{frames.map((f) => { const on = f.id === frameId; return ( ); })}
)}
{topFirst.length === 0 ? (
{t("noLayers", lang)}
) : ( {topFirst.map((g) => { const canOpen = g.free || g.items.length > 1; const open = canOpen && openIds.has(g.id); return ( : g.items.slice(0, 3).map((it, k) => )} label={runLabel(g, lang)} on={g.items.some((it) => sel.has(it.id))} onSelect={(add) => onSelect(g.items.map((it) => it.id), add)} open={canOpen ? open : undefined} onToggle={canOpen ? () => toggle(g.id) : undefined} onDragging={onDragging} > {groupBody(g, 1)} ); })} )}
); }