"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" }}>
{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 (