- 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 Layer,
type TextLayer,
duplicate,
getIndex,
groupLayers,
removeNodes,
shiftOrder,
topSelection,
translateMany,
ungroupLayers,
updateNode,
TYPE_LABEL,
} from "./model";
import {
beginGesture,
cancelGesture,
commit,
endGesture,
getState,
live,
setState,
showToast,
} from "./store";
function describe(ids: string[]) {
const idx = getIndex(getState().doc);
if (ids.length === 1) return `“${idx.get(ids[0])?.node.name ?? "Layer"}”`;
return `${ids.length} layers`;
}
export function deleteSelection() {
const { doc, selection } = getState();
const ids = topSelection(doc, selection);
if (!ids.length) {
showToast("Nothing to delete. Select a layer first.");
return;
}
const label = describe(ids);
commit(removeNodes(doc, ids), []);
showToast(`Deleted ${label}. Press Ctrl/⌘+Z to bring it back.`);
}
export function duplicateSelection() {
const { doc, selection } = getState();
if (!selection.length) {
showToast("Nothing to duplicate. Select a layer first.");
return;
}
const res = duplicate(doc, selection);
commit(res.doc, res.ids);
}
export function groupSelection() {
const { doc, selection } = getState();
const res = groupLayers(doc, selection);
if ("error" in res) {
showToast(res.error, "warn");
return;
}
commit(res.doc, res.selection);
}
export function ungroupSelection() {
const { doc, selection } = getState();
const res = ungroupLayers(doc, selection);
if ("error" in res) {
showToast(res.error, "warn");
return;
}
commit(res.doc, res.selection);
}
export function reorderSelection(dir: 1 | -1) {
const { doc, selection } = getState();
if (!selection.length) {
showToast("Select a layer to change its stacking order.");
return;
}
const next = shiftOrder(doc, selection, dir);
if (next === doc) {
showToast(dir === 1 ? "Already at the front of its parent." : "Already at the back of its parent.");
return;
}
commit(next);
}
export function nudge(dx: number, dy: number) {
const { doc, selection } = getState();
if (!selection.length) return;
const idx = getIndex(doc);
const ids = topSelection(doc, selection).filter((id) => !idx.get(id)!.locked);
if (!ids.length) {
showToast("Locked layers can’t be nudged. Unlock it in the Layers panel, or type new X/Y values in Properties.", "warn");
return;
}
commit(translateMany(doc, ids, dx, dy));
}
export function selectParent() {
const { doc, selection } = getState();
if (!selection.length) return;
const idx = getIndex(doc);
const parents = new Set(selection.map((id) => idx.get(id)?.parentId ?? null));
if (parents.size === 1) {
const p = [...parents][0];
if (p && idx.get(p)?.node.type === "group") {
setState({ selection: [p] });
return;
}
}
setState({ selection: [] });
}
export function toggleFlag(id: string, flag: "visible" | "locked") {
const { doc } = getState();
const n = getIndex(doc).get(id)?.node;
if (!n) return;
commit(updateNode(doc, id, { [flag]: !n[flag] } as Partial<Layer>));
}
export function rename(id: string, name: string): boolean {
const { doc } = getState();
const n = getIndex(doc).get(id)?.node;
if (!n) return false;
const trimmed = name.trim();
if (!trimmed) {
showToast(`Layer names can’t be empty, so it’s still called “${n.name}”.`, "warn");
return false;
}
if (trimmed !== n.name) commit(updateNode(doc, id, { name: trimmed }));
return true;
}
// ---------------------------------------------------------------------------
// Text editing
export function startTextEdit(id: string, isNew = false): boolean {
const { doc, editing } = getState();
if (editing) return false;
const info = getIndex(doc).get(id);
if (!info || info.node.type !== "text") return false;
if (!isNew && (info.locked || info.hidden)) {
showToast(
info.locked
? "This text is locked. Unlock it in the Layers panel to edit it on the canvas."
: "This text is hidden. Show it in the Layers panel to edit it on the canvas.",
"warn",
);
return false;
}
beginGesture();
setState({ editing: { id, isNew }, selection: [id], tool: "move" });
return true;
}
export function commitTextEdit(text: string) {
const { editing, doc } = getState();
if (!editing) return;
setState({ editing: null });
const node = getIndex(doc).get(editing.id)?.node as TextLayer | undefined;
if (!node) {
cancelGesture();
return;
}
if (text.trim() === "") {
if (editing.isNew) {
cancelGesture();
setState({ selection: [] });
showToast("Empty text layers are removed automatically.");
} else {
live(removeNodes(doc, [node.id]), []);
endGesture([]);
showToast(`Removed “${node.name}” because its text was empty.`);
}
return;
}
if (text !== node.text) live(updateNode(doc, node.id, { text }));
endGesture([node.id]);
}
export function layerTypeLabel(n: Layer) {
return TYPE_LABEL[n.type];
}