- 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 ReactElement, type ReactNode, useEffect, useRef, useState } from "react";
import {
type Doc,
type Layer,
type NodeInfo,
TYPE_LABEL,
boundsOf,
existing,
fmt,
frameOffset,
getIndex,
isLeaf,
normalizeAngle,
replaceNodes,
round2,
scaleNode,
translateNode,
} from "./model";
import { beginGesture, commit, endGesture, getState, live, useEditor } from "./store";
import {
deleteSelection,
duplicateSelection,
groupSelection,
reorderSelection,
ungroupSelection,
} from "./actions";
import { Icon, TypeIcon } from "./icons";
type Mixed = number | "mixed" | null;
function common<T>(vals: T[]): T | "mixed" | null {
if (!vals.length) return null;
return vals.every((v) => v === vals[0]) ? vals[0] : "mixed";
}
/** X, Y, W, H as shown in the panel (relative to the parent frame). */
function geom(doc: Doc, info: NodeInfo) {
const n = info.node;
if (n.type === "group") {
const b = boundsOf(doc, n.id)!;
const off = frameOffset(doc, n.id);
return { x: round2(b.x - off.x), y: round2(b.y - off.y), w: round2(b.w), h: round2(b.h) };
}
return { x: n.x, y: n.y, w: n.width, h: n.height };
}
type GeomKey = "x" | "y" | "w" | "h";
function setGeom(doc: Doc, info: NodeInfo, key: GeomKey, v: number): Layer {
const n = info.node;
if (n.type === "group") {
const g = geom(doc, info);
if (key === "x") return translateNode(n, v - g.x, 0);
if (key === "y") return translateNode(n, 0, v - g.y);
const b0 = boundsOf(doc, n.id)!;
const b1 = { ...b0, w: key === "w" ? v : b0.w, h: key === "h" ? v : b0.h };
return scaleNode(n, frameOffset(doc, n.id), b0, b1);
}
const prop = { x: "x", y: "y", w: "width", h: "height" }[key];
return { ...n, [prop]: round2(v) } as Layer;
}
function applyEach(ids: string[], fn: (doc: Doc, info: NodeInfo) => Layer | null) {
const doc = getState().doc;
const idx = getIndex(doc);
const repl = new Map<string, Layer>();
for (const id of ids) {
const info = idx.get(id);
if (!info) continue;
const r = fn(doc, info);
if (r) repl.set(id, r);
}
commit(replaceNodes(doc, repl));
}
const NUM_RE = /^[-+]?(\d+\.?\d*|\.\d+)$/;
function parseNum(raw: string): number | null {
const t = raw.trim().replace(/,/g, ".");
return NUM_RE.test(t) ? Number(t) : null;
}
export function normalizeHex(raw: string): string | null {
const t = raw.trim().replace(/^#/, "");
if (/^[0-9a-f]{3}$/i.test(t)) return `#${t.split("").map((c) => c + c).join("").toUpperCase()}`;
if (/^[0-9a-f]{6}$/i.test(t)) return `#${t.toUpperCase()}`;
return null;
}
// ---------------------------------------------------------------------------
interface NumFieldProps {
label: ReactNode;
name: string;
value: Mixed;
suffix?: string;
step?: number;
validate: (v: number) => string | null;
onCommit: (v: number) => void;
onError: (msg: string | null) => void;
}
function NumField({ label, name, value, suffix, step = 1, validate, onCommit, onError }: NumFieldProps) {
const [draft, setDraft] = useState<string | null>(null);
const shown = value === null ? "" : value === "mixed" ? "" : fmt(value);
const display = draft ?? shown;
const tryCommit = (raw: string) => {
setDraft(null);
const trimmed = raw.trim();
if (trimmed === shown || (value === "mixed" && trimmed === "")) return;
const v = parseNum(suffix ? trimmed.replace(new RegExp(`${suffix}$`), "") : trimmed);
if (v === null) {
onError(`“${trimmed}” isn’t a number. ${name} stays ${value === "mixed" ? "as it was" : `at ${shown}${suffix ?? ""}`}.`);
return;
}
const err = validate(v);
if (err) {
onError(`${err} ${name} stays ${value === "mixed" ? "as it was" : `at ${shown}${suffix ?? ""}`}.`);
return;
}
onError(null);
onCommit(v);
};
return (
<label className={`field${value === "mixed" ? " is-mixed" : ""}`}>
<span className="field-label" aria-hidden="true">
{label}
</span>
<input
type="text"
inputMode="decimal"
aria-label={name}
value={display}
placeholder={value === "mixed" ? "Mixed" : ""}
onFocus={(e) => e.currentTarget.select()}
onChange={(e) => setDraft(e.target.value)}
onBlur={(e) => {
if (draft !== null) tryCommit(e.currentTarget.value);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
tryCommit(e.currentTarget.value);
e.currentTarget.select();
} else if (e.key === "Escape") {
e.preventDefault();
setDraft(null);
onError(null);
e.currentTarget.blur();
} else if ((e.key === "ArrowUp" || e.key === "ArrowDown") && typeof value === "number") {
e.preventDefault();
const delta = (e.key === "ArrowUp" ? 1 : -1) * (e.shiftKey ? 10 : 1) * step;
const v = round2(value + delta);
const err = validate(v);
if (err) onError(err);
else {
onError(null);
setDraft(null);
onCommit(v);
}
}
}}
/>
{suffix && <span className="field-suffix" aria-hidden="true">{suffix}</span>}
</label>
);
}
function ColorField({ value, ids, onError }: { value: string | "mixed" | null; ids: string[]; onError: (m: string | null) => void }) {
const [draft, setDraft] = useState<string | null>(null);
const pickerRef = useRef<HTMLInputElement>(null);
const shown = value && value !== "mixed" ? value.toUpperCase() : "";
const applyColor = (hex: string, liveOnly: boolean) => {
const doc = getState().doc;
const idx = getIndex(doc);
const repl = new Map<string, Layer>();
for (const id of ids) {
const n = idx.get(id)?.node;
if (n && n.type !== "group") repl.set(id, { ...n, fill: hex } as Layer);
}
if (liveOnly) live(replaceNodes(doc, repl));
else commit(replaceNodes(doc, repl));
};
useEffect(() => {
const el = pickerRef.current;
if (!el) return;
const onChange = () => endGesture();
el.addEventListener("change", onChange);
return () => el.removeEventListener("change", onChange);
}, []);
const tryCommit = (raw: string) => {
setDraft(null);
const t = raw.trim();
if (t.toUpperCase() === shown || (value === "mixed" && t === "")) return;
const hex = normalizeHex(t);
if (!hex) {
onError(`“${t}” isn’t a hex color. Use 3 or 6 hex digits, like #C8553D. Fill stays ${shown || "as it was"}.`);
return;
}
onError(null);
applyColor(hex, false);
};
return (
<div className={`field color-field${value === "mixed" ? " is-mixed" : ""}`}>
<span className="swatch" style={{ background: value && value !== "mixed" ? value : undefined }}>
<input
ref={pickerRef}
type="color"
aria-label="Pick fill color"
value={shown ? shown.toLowerCase() : "#000000"}
onInput={(e) => {
beginGesture();
applyColor(e.currentTarget.value.toUpperCase(), true);
}}
onChange={() => undefined}
onBlur={() => endGesture()}
/>
</span>
<input
type="text"
aria-label="Fill color hex"
spellCheck={false}
value={draft ?? shown}
placeholder={value === "mixed" ? "Mixed" : ""}
onFocus={(e) => e.currentTarget.select()}
onChange={(e) => setDraft(e.target.value)}
onBlur={(e) => {
if (draft !== null) tryCommit(e.currentTarget.value);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
tryCommit(e.currentTarget.value);
e.currentTarget.select();
} else if (e.key === "Escape") {
e.preventDefault();
setDraft(null);
onError(null);
e.currentTarget.blur();
}
}}
/>
</div>
);
}
// ---------------------------------------------------------------------------
function ActionBar({ count, canGroup, canUngroup }: { count: number; canGroup: boolean; canUngroup: boolean }) {
const btn = (label: string, tip: string, icon: ReactElement, onClick: () => void, dim = false, danger = false) => (
<button
type="button"
className={`icon-btn${dim ? " is-dim" : ""}${danger ? " is-danger" : ""}`}
aria-label={label}
data-tip={tip}
onClick={onClick}
>
{icon}
</button>
);
return (
<div className="action-bar" role="toolbar" aria-label="Arrange selection">
{btn("Bring forward", "Bring forward Ctrl/⌘ ]", <Icon.forward />, () => reorderSelection(1))}
{btn("Send backward", "Send backward Ctrl/⌘ [", <Icon.backward />, () => reorderSelection(-1))}
<span className="sep" />
{btn("Group selection", canGroup ? "Group Ctrl/⌘ G" : "Group — needs two or more layers with the same parent", <Icon.groupAction />, groupSelection, !canGroup)}
{btn("Ungroup", canUngroup ? "Ungroup Ctrl/⌘ ⇧ G" : "Ungroup — select a group first", <Icon.ungroupAction />, ungroupSelection, !canUngroup)}
<span className="sep" />
{btn(count > 1 ? `Duplicate ${count} layers` : "Duplicate", "Duplicate Ctrl/⌘ D", <Icon.duplicate />, duplicateSelection)}
{btn(count > 1 ? `Delete ${count} layers` : "Delete", "Delete Del / ⌫", <Icon.trash />, deleteSelection, false, true)}
</div>
);
}
export function PropertiesPanel() {
const { doc, selection } = useEditor();
const [error, setError] = useState<{ key: string; msg: string } | null>(null);
const ids = existing(doc, selection);
const idx = getIndex(doc);
const infos = ids.map((id) => idx.get(id)!);
const selKey = ids.join("|");
useEffect(() => setError(null), [selKey]);
if (!infos.length) {
let count = 0;
idx.forEach(() => (count += 1));
const frames = doc.children.filter((n) => n.type === "frame").length;
return (
<section className="panel props-panel" aria-labelledby="props-title">
<header className="panel-head">
<h2 id="props-title">Properties</h2>
</header>
<div className="panel-scroll">
<div className="empty-state">
<p className="empty-title">Nothing selected</p>
<p>Click a layer on the canvas or pick a row in Layers to see and edit its properties here.</p>
<ul className="tips">
<li>
<span>Select inside a group</span>
<kbd>Double-click</kbd>
</li>
<li>
<span>Select several</span>
<kbd>⇧ Click</kbd>
</li>
<li>
<span>Box-select in a frame</span>
<kbd>Drag</kbd>
</li>
<li>
<span>Pan the canvas</span>
<kbd>Space + drag</kbd>
</li>
<li>
<span>Zoom at the pointer</span>
<kbd>Ctrl/⌘ + scroll</kbd>
</li>
<li>
<span>Fit everything</span>
<kbd>⇧ 1</kbd>
</li>
</ul>
<p className="doc-summary">
{doc.file} · {frames} frames · {count} layers
</p>
</div>
</div>
</section>
);
}
const single = infos.length === 1 ? infos[0] : null;
const nodes = infos.map((i) => i.node);
const allLeaves = nodes.every(isLeaf);
const hasFill = nodes.every((n) => n.type !== "group");
const allRects = nodes.every((n) => n.type === "rect");
const allText = nodes.every((n) => n.type === "text");
const g = infos.map((i) => geom(doc, i));
const errFor = (key: string) => (msg: string | null) => setError(msg ? { key, msg } : null);
const geomField = (key: GeomKey, label: string, name: string) => (
<NumField
label={label}
name={name}
value={common(g.map((v) => v[key]))}
validate={(v) => ((key === "w" || key === "h") && v < 1 ? `${key === "w" ? "Width" : "Height"} must be at least 1 px.` : null)}
onCommit={(v) => applyEach(ids, (d, info) => setGeom(d, info, key, v))}
onError={errFor("geom")}
/>
);
const leafVals = <K extends string>(k: K) => nodes.map((n) => (n as unknown as Record<K, number>)[k]);
const parents = new Set(infos.map((i) => i.parentId));
const canGroup = infos.length >= 2 && parents.size === 1 && nodes.every((n) => n.type !== "frame");
const canUngroup = nodes.some((n) => n.type === "group");
const anyLocked = infos.some((i) => i.locked);
const anyHidden = infos.some((i) => i.hidden);
const typeSet = new Set(nodes.map((n) => n.type));
const title = single ? single.node.name : `${infos.length} layers`;
const subtitle = single
? TYPE_LABEL[single.node.type] + (single.frameId && single.node.type !== "frame" ? ` in ${idx.get(single.frameId)!.node.name}` : "")
: typeSet.size === 1
? `All ${TYPE_LABEL[nodes[0].type].toLowerCase()}${nodes[0].type === "text" ? " layers" : "s"}`
: "Mixed types";
return (
<section className="panel props-panel" aria-labelledby="props-title">
<header className="panel-head">
<h2 id="props-title">Properties</h2>
</header>
<div className="panel-scroll">
<div className="sel-summary">
<span className="type-icon">{single ? <TypeIcon type={single.node.type} /> : <Icon.layers />}</span>
<div>
<p className="sel-title">{title}</p>
<p className="sel-sub">{subtitle}</p>
</div>
</div>
<ActionBar count={infos.length} canGroup={canGroup} canUngroup={canUngroup} />
{(anyLocked || anyHidden) && (
<p className="lock-notice">
{anyLocked
? "Locked on the canvas: it can’t be clicked, dragged, resized or nudged there. You can still edit it here, duplicate it, or delete it."
: "Hidden: it isn’t drawn on the canvas. Show it from the Layers panel."}
</p>
)}
<div className="section" role="group" aria-labelledby="sec-layout">
<h3 id="sec-layout">{single?.node.type === "group" ? "Bounds" : "Layout"}</h3>
<div className="grid2">
{geomField("x", "X", "X position")}
{geomField("y", "Y", "Y position")}
{geomField("w", "W", "Width")}
{geomField("h", "H", "Height")}
{allLeaves && (
<NumField
label={<Icon.rotate />}
name="Rotation"
suffix="°"
value={common(leafVals("rotation"))}
validate={() => null}
onCommit={(v) => applyEach(ids, (_d, info) => ({ ...info.node, rotation: normalizeAngle(v) }) as Layer)}
onError={errFor("geom")}
/>
)}
{allRects && (
<NumField
label={<Icon.radius />}
name="Corner radius"
value={common(leafVals("corner_radius"))}
validate={(v) => (v < 0 ? "Corner radius can’t be negative." : null)}
onCommit={(v) => applyEach(ids, (_d, info) => ({ ...info.node, corner_radius: round2(v) }) as Layer)}
onError={errFor("geom")}
/>
)}
</div>
{error?.key === "geom" && <FieldError msg={error.msg} />}
{single?.node.type === "group" && <p className="hint">Changing W or H scales every layer inside the group.</p>}
{single?.node.type === "frame" && <p className="hint">X and Y are canvas coordinates. Resizing a frame keeps its layers where they are.</p>}
</div>
{(allLeaves || hasFill) && (
<div className="section" role="group" aria-labelledby="sec-look">
<h3 id="sec-look">Appearance</h3>
{allLeaves && (
<div className="grid2">
<NumField
label={<Icon.opacity />}
name="Opacity"
suffix="%"
value={common(leafVals("opacity").map((o) => round2(o * 100)))}
validate={(v) => (v < 0 || v > 100 ? "Opacity must be between 0 and 100%." : null)}
onCommit={(v) => applyEach(ids, (_d, info) => ({ ...info.node, opacity: round2(v) / 100 }) as Layer)}
onError={errFor("look")}
/>
</div>
)}
{hasFill && (
<div className="fill-row">
<span className="row-label">{allText ? "Text color" : "Fill"}</span>
<ColorField
value={common(nodes.map((n) => ("fill" in n ? n.fill.toUpperCase() : "")))}
ids={ids}
onError={errFor("look")}
/>
</div>
)}
{error?.key === "look" && <FieldError msg={error.msg} />}
</div>
)}
{allText && (
<div className="section" role="group" aria-labelledby="sec-text">
<h3 id="sec-text">Text</h3>
<div className="grid2">
<NumField
label="Aa"
name="Font size"
value={common(leafVals("font_size"))}
validate={(v) => (v < 1 ? "Font size must be at least 1." : null)}
onCommit={(v) => applyEach(ids, (_d, info) => ({ ...info.node, font_size: round2(v) }) as Layer)}
onError={errFor("text")}
/>
</div>
{error?.key === "text" && <FieldError msg={error.msg} />}
{single && <p className="hint">Double-click the text on the canvas, or press Enter, to edit it.</p>}
</div>
)}
</div>
</section>
);
}
function FieldError({ msg }: { msg: string }) {
return (
<p className="field-error" role="alert">
{msg}
</p>
);
}