- Run record
- loading
Loading this Run…
Loading the Revisions of this Run…
A published source file of Revision 0 of Canvely — Spring Menu 1.0.0, shown as text in the browser.
Source is browser-viewable only. There is no download or archive endpoint, and no additional license is granted beyond inspection and research.
import { type KeyboardEvent, type PointerEvent, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { type Layer, TYPE_LABEL, childrenOf, getIndex, isContainer, setChildren } from "./model";
import { commit, getState, select, useEditor } from "./store";
import { rename, startTextEdit, toggleFlag } from "./actions";
import { Icon, TypeIcon } from "./icons";
interface Row {
node: Layer;
depth: number;
parentId: string | null;
hiddenByParent: boolean;
lockedByParent: boolean;
}
interface DragState {
id: string;
pointerId: number;
startY: number;
started: boolean;
order: string[] | null;
lineY: number;
indent: number;
}
export function LayersPanel() {
const { doc, selection } = useEditor();
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set());
const [activeId, setActiveId] = useState<string | null>(null);
const [renaming, setRenaming] = useState<string | null>(null);
const [drag, setDrag] = useState<DragState | null>(null);
const dragRef = useRef<DragState | null>(null);
const suppressClick = useRef(false);
const treeRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const rows = useMemo(() => {
const out: Row[] = [];
const walk = (list: Layer[], depth: number, parentId: string | null, hidden: boolean, locked: boolean) => {
for (let i = list.length - 1; i >= 0; i--) {
const n = list[i];
out.push({ node: n, depth, parentId, hiddenByParent: hidden, lockedByParent: locked });
if (isContainer(n) && !collapsed.has(n.id)) walk(n.children, depth + 1, n.id, hidden || !n.visible, locked || n.locked);
}
};
walk(doc.children, 0, null, false, false);
return out;
}, [doc, collapsed]);
// Reveal the selection: expand its ancestors and scroll its row into view.
useEffect(() => {
if (!selection.length) return;
const idx = getIndex(doc);
const need = new Set<string>();
for (const id of selection) idx.get(id)?.ancestorIds.forEach((a) => collapsed.has(a) && need.add(a));
if (need.size) {
setCollapsed((c) => new Set([...c].filter((x) => !need.has(x))));
return;
}
const last = selection[selection.length - 1];
setActiveId((cur) => (selection.includes(cur ?? "") ? cur : last));
const el = treeRef.current?.querySelector<HTMLElement>(`[data-row="${CSS.escape(last)}"]`);
el?.scrollIntoView({ block: "nearest" });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selection, collapsed]);
const tabStop = rows.some((r) => r.node.id === activeId) ? activeId : rows[0]?.node.id ?? null;
const focusRow = (id: string) => {
setActiveId(id);
requestAnimationFrame(() => treeRef.current?.querySelector<HTMLElement>(`[data-row="${CSS.escape(id)}"]`)?.focus());
};
const toggleCollapsed = (id: string) =>
setCollapsed((c) => {
const n = new Set(c);
if (n.has(id)) n.delete(id);
else n.add(id);
return n;
});
const onRowClick = (e: React.MouseEvent, id: string) => {
if (suppressClick.current) {
suppressClick.current = false;
return;
}
setActiveId(id);
const sel = getState().selection;
if (e.shiftKey || e.metaKey || e.ctrlKey) select(sel.includes(id) ? sel.filter((x) => x !== id) : [...sel, id]);
else select([id]);
};
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
const target = e.target as HTMLElement;
if (!target.hasAttribute("data-row")) return;
const id = target.getAttribute("data-row")!;
const i = rows.findIndex((r) => r.node.id === id);
if (i < 0) return;
const row = rows[i];
const go = (j: number) => {
const r = rows[Math.max(0, Math.min(rows.length - 1, j))];
if (!r) return;
select([r.node.id]);
focusRow(r.node.id);
};
switch (e.key) {
case "ArrowDown":
e.preventDefault();
e.stopPropagation();
go(i + 1);
return;
case "ArrowUp":
e.preventDefault();
e.stopPropagation();
go(i - 1);
return;
case "Home":
e.preventDefault();
go(0);
return;
case "End":
e.preventDefault();
go(rows.length - 1);
return;
case "ArrowRight":
e.preventDefault();
e.stopPropagation();
if (isContainer(row.node)) {
if (collapsed.has(id)) toggleCollapsed(id);
else if (row.node.children.length) go(i + 1);
}
return;
case "ArrowLeft":
e.preventDefault();
e.stopPropagation();
if (isContainer(row.node) && !collapsed.has(id)) toggleCollapsed(id);
else if (row.parentId) {
select([row.parentId]);
focusRow(row.parentId);
}
return;
case "F2":
e.preventDefault();
setRenaming(id);
return;
case "Enter":
e.preventDefault();
e.stopPropagation();
if (row.node.type === "text" && !getIndex(doc).get(id)?.locked) {
select([id]);
startTextEdit(id);
} else setRenaming(id);
return;
case " ":
e.preventDefault();
e.stopPropagation();
{
const sel = getState().selection;
if (e.shiftKey || e.metaKey || e.ctrlKey) select(sel.includes(id) ? sel.filter((x) => x !== id) : [...sel, id]);
else select([id]);
}
return;
}
};
// ------------------------------------------------------------------ drag
const onRowPointerDown = (e: PointerEvent<HTMLDivElement>, id: string) => {
if (e.button !== 0 || renaming) return;
if ((e.target as HTMLElement).closest("button, input")) return;
dragRef.current = { id, pointerId: e.pointerId, startY: e.clientY, started: false, order: null, lineY: 0, indent: 0 };
};
const computeDrop = (d: DragState, clientY: number): DragState => {
const idx = getIndex(doc);
const info = idx.get(d.id);
const tree = treeRef.current;
if (!info || !tree) return d;
const display = [...childrenOf(doc, info.parentId)].reverse().map((n) => n.id);
const others = display.filter((x) => x !== d.id);
const rowEl = (x: string) => tree.querySelector<HTMLElement>(`[data-row="${CSS.escape(x)}"]`);
let slot = 0;
for (const o of others) {
const r = rowEl(o)?.getBoundingClientRect();
if (r && clientY > r.top + r.height / 2) slot += 1;
}
const order = [...others.slice(0, slot), d.id, ...others.slice(slot)];
const treeTop = tree.getBoundingClientRect().top;
let lineY: number;
if (slot < others.length) {
lineY = (rowEl(others[slot])?.getBoundingClientRect().top ?? 0) - treeTop;
} else {
// after the last sibling, including its expanded descendants
const last = others[others.length - 1] ?? d.id;
const li = rows.findIndex((r) => r.node.id === last);
let k = li;
while (k + 1 < rows.length && rows[k + 1].depth > rows[li].depth) k++;
lineY = (rowEl(rows[k]?.node.id ?? last)?.getBoundingClientRect().bottom ?? 0) - treeTop;
}
return { ...d, order, lineY, indent: info.depth };
};
const onRowPointerMove = (e: PointerEvent<HTMLDivElement>) => {
const d = dragRef.current;
if (!d || d.pointerId !== e.pointerId) return;
if (!d.started) {
if (Math.abs(e.clientY - d.startY) < 5) return;
d.started = true;
try {
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
} catch {
// ignore
}
}
const next = computeDrop(d, e.clientY);
dragRef.current = next;
setDrag(next);
};
const onRowPointerUp = (e: PointerEvent<HTMLDivElement>) => {
const d = dragRef.current;
dragRef.current = null;
if (!d || d.pointerId !== e.pointerId) return;
setDrag(null);
if (!d.started || !d.order) return;
suppressClick.current = true;
setTimeout(() => (suppressClick.current = false), 0);
const info = getIndex(doc).get(d.id);
if (!info) return;
const list = childrenOf(doc, info.parentId);
const byId = new Map(list.map((n) => [n.id, n]));
const next = [...d.order].reverse().map((x) => byId.get(x)!);
if (next.every((n, i) => n === list[i])) return;
commit(setChildren(doc, info.parentId, next), getState().selection.includes(d.id) ? getState().selection : [d.id]);
};
const selectedSet = new Set(selection);
return (
<section className="panel layers-panel" aria-labelledby="layers-title" data-layers-panel="">
<header className="panel-head">
<h2 id="layers-title">Layers</h2>
<span className="panel-meta">{doc.children.length} top-level</span>
</header>
<div className="panel-scroll" ref={scrollRef}>
<div
ref={treeRef}
role="tree"
aria-label="Layers"
aria-multiselectable="true"
className="tree"
onKeyDown={onKeyDown}
>
{rows.length === 0 && (
<p className="empty-note">No layers yet. Draw a shape with R, O or T to get started.</p>
)}
{rows.map((r) => {
const n = r.node;
const id = n.id;
const isSel = selectedSet.has(id);
const effHidden = r.hiddenByParent || !n.visible;
const effLocked = r.lockedByParent || n.locked;
const container = isContainer(n);
const open = container && !collapsed.has(id);
const btnTab = id === tabStop ? 0 : -1;
return (
<div
key={id}
data-row={id}
role="treeitem"
aria-level={r.depth + 1}
aria-selected={isSel}
aria-expanded={container ? open : undefined}
aria-label={`${TYPE_LABEL[n.type]} ${n.name}${!n.visible ? ", hidden" : ""}${n.locked ? ", locked" : ""}`}
tabIndex={id === tabStop ? 0 : -1}
className={`row${isSel ? " is-selected" : ""}${effHidden ? " is-hidden" : ""}${effLocked ? " is-locked" : ""}${drag?.started && drag.id === id ? " is-dragging" : ""}${n.type === "frame" ? " is-frame" : ""}`}
style={{ paddingLeft: 6 + r.depth * 14 }}
onClick={(e) => onRowClick(e, id)}
onDoubleClick={(e) => {
if ((e.target as HTMLElement).closest("button")) return;
setRenaming(id);
}}
onFocus={(e) => e.target === e.currentTarget && setActiveId(id)}
onPointerDown={(e) => onRowPointerDown(e, id)}
onPointerMove={onRowPointerMove}
onPointerUp={onRowPointerUp}
onPointerCancel={() => {
dragRef.current = null;
setDrag(null);
}}
>
{container ? (
<button
type="button"
className={`disclosure${open ? " is-open" : ""}`}
tabIndex={-1}
aria-label={`${open ? "Collapse" : "Expand"} ${n.name}`}
onClick={(e) => {
e.stopPropagation();
toggleCollapsed(id);
}}
>
<Icon.chevron />
</button>
) : (
<span className="disclosure-spacer" />
)}
<span className={`type-icon type-${n.type}`} title={TYPE_LABEL[n.type]}>
<TypeIcon type={n.type} />
</span>
{renaming === id ? (
<RenameInput
initial={n.name}
label={`Rename ${n.name}`}
onDone={(value, viaKeyboard) => {
setRenaming(null);
if (value !== null) rename(id, value);
if (viaKeyboard) focusRow(id);
}}
/>
) : (
<span className="row-name">
{n.name}
<span className="sr-only">, {TYPE_LABEL[n.type]}</span>
</span>
)}
<span className="row-actions">
<button
type="button"
className={`row-btn${!n.visible ? " is-on" : ""}`}
tabIndex={btnTab}
aria-label={`${n.visible ? "Hide" : "Show"} ${n.name}`}
aria-pressed={!n.visible}
data-tip={n.visible ? "Hide layer" : "Show layer"}
onClick={(e) => {
e.stopPropagation();
toggleFlag(id, "visible");
}}
>
{n.visible ? <Icon.eye /> : <Icon.eyeOff />}
</button>
<button
type="button"
className={`row-btn${n.locked ? " is-on" : ""}`}
tabIndex={btnTab}
aria-label={`${n.locked ? "Unlock" : "Lock"} ${n.name}`}
aria-pressed={n.locked}
data-tip={n.locked ? "Unlock layer" : "Lock layer"}
onClick={(e) => {
e.stopPropagation();
toggleFlag(id, "locked");
}}
>
{n.locked ? <Icon.lock /> : <Icon.unlock />}
</button>
</span>
</div>
);
})}
{drag?.started && drag.order && (
<div className="drop-line" style={{ top: drag.lineY - 1, left: 6 + drag.indent * 14 + 16 }} aria-hidden="true" />
)}
</div>
</div>
<p className="panel-foot">Drag rows to restack · F2 renames</p>
</section>
);
}
function RenameInput({
initial,
label,
onDone,
}: {
initial: string;
label: string;
onDone: (v: string | null, viaKeyboard: boolean) => void;
}) {
const ref = useRef<HTMLInputElement>(null);
const done = useRef(false);
useLayoutEffect(() => {
ref.current?.focus();
ref.current?.select();
}, []);
const finish = (v: string | null, viaKeyboard: boolean) => {
if (done.current) return;
done.current = true;
onDone(v, viaKeyboard);
};
return (
<input
ref={ref}
className="rename-input"
defaultValue={initial}
aria-label={label}
onClick={(e) => e.stopPropagation()}
onDoubleClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === "Enter") finish(e.currentTarget.value, true);
if (e.key === "Escape") finish(null, true);
}}
onBlur={(e) => finish(e.currentTarget.value, false)}
/>
);
}