- 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 { useEffect, useState } from "react";
import { Canvas, canvasPointer } from "./Canvas";
import { LayersPanel } from "./LayersPanel";
import { PropertiesPanel } from "./PropertiesPanel";
import { ResetDialog, ToastView, TooltipLayer, TopBar } from "./Chrome";
import { Icon } from "./icons";
import { childrenOf, getIndex } from "./model";
import {
fitAll,
fitSelection,
gestureActive,
getState,
redo,
select,
setState,
setTool,
undo,
useEditor,
zoomStep,
zoomTo100,
} from "./store";
import {
deleteSelection,
duplicateSelection,
groupSelection,
nudge,
reorderSelection,
selectParent,
startTextEdit,
ungroupSelection,
} from "./actions";
export const FICTIONAL_NOTICE = "Canvely is a fictional product created for UIBenchmark.";
function isTypingTarget(t: EventTarget | null) {
const el = t as HTMLElement | null;
if (!el || !el.tagName) return false;
if (el.isContentEditable) return true;
if (el.tagName === "TEXTAREA" || el.tagName === "SELECT") return true;
if (el.tagName === "INPUT") {
const type = (el as HTMLInputElement).type;
return !["button", "checkbox", "radio", "color", "range", "submit", "reset"].includes(type);
}
return false;
}
function selectAll() {
const { doc, selection } = getState();
const idx = getIndex(doc);
const parentId = selection.length ? idx.get(selection[0])?.parentId ?? null : null;
select(childrenOf(doc, parentId).filter((n) => n.visible && !n.locked).map((n) => n.id));
}
function useShortcuts() {
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.defaultPrevented) return;
const t = e.target as HTMLElement;
if (isTypingTarget(t) || getState().editing) return;
if (document.querySelector("dialog[open]")) return;
const st = getState();
if (e.code === "Space") {
const onControl = !!t.closest?.("button, [role=treeitem], a");
if (canvasPointer.over || !onControl) {
e.preventDefault();
if (!st.spaceDown) setState({ spaceDown: true });
}
return;
}
if (gestureActive()) return;
const mod = e.ctrlKey || e.metaKey;
const k = e.key.toLowerCase();
if (mod && !e.altKey) {
let handled = true;
if (k === "z") e.shiftKey ? redo() : undo();
else if (k === "y") redo();
else if (k === "d") duplicateSelection();
else if (k === "g") e.shiftKey ? ungroupSelection() : groupSelection();
else if (e.code === "BracketRight" || k === "]") reorderSelection(1);
else if (e.code === "BracketLeft" || k === "[") reorderSelection(-1);
else if (k === "=" || k === "+") zoomStep(1);
else if (k === "-" || k === "_") zoomStep(-1);
else if (k === "0") zoomTo100();
else if (k === "a" && !t.closest?.("input, textarea")) selectAll();
else handled = false;
if (handled) e.preventDefault();
return;
}
if (e.altKey) return;
if (e.shiftKey && e.code === "Digit1") {
e.preventDefault();
fitAll();
return;
}
if (e.shiftKey && e.code === "Digit0") {
e.preventDefault();
zoomTo100();
return;
}
if (e.shiftKey && e.code === "Digit2") {
e.preventDefault();
fitSelection();
return;
}
const onControl = !!t.closest?.("button, a, summary");
const inLayers = !!t.closest?.("[data-layers-panel]");
if (!e.shiftKey) {
const tool = ({ v: "move", r: "rect", o: "ellipse", t: "text" } as const)[k as "v" | "r" | "o" | "t"];
if (tool) {
e.preventDefault();
setTool(tool);
return;
}
}
switch (e.key) {
case "Delete":
case "Backspace":
e.preventDefault();
if (st.selection.length) deleteSelection();
return;
case "Enter": {
if (onControl || inLayers) return;
const id = st.selection.length === 1 ? st.selection[0] : null;
if (id && getIndex(st.doc).get(id)?.node.type === "text") {
e.preventDefault();
startTextEdit(id);
}
return;
}
case "Escape":
if (st.tool !== "move") setTool("move");
else selectParent();
return;
case "ArrowUp":
case "ArrowDown":
case "ArrowLeft":
case "ArrowRight": {
if (inLayers || !st.selection.length) return;
e.preventDefault();
const step = e.shiftKey ? 10 : 1;
const dx = e.key === "ArrowLeft" ? -step : e.key === "ArrowRight" ? step : 0;
const dy = e.key === "ArrowUp" ? -step : e.key === "ArrowDown" ? step : 0;
nudge(dx, dy);
return;
}
}
};
const onKeyUp = (e: KeyboardEvent) => {
if (e.code === "Space" && getState().spaceDown) setState({ spaceDown: false });
};
const onBlur = () => getState().spaceDown && setState({ spaceDown: false });
// Ctrl/Cmd + wheel and trackpad pinch must never zoom the page itself.
const onWheel = (e: WheelEvent) => {
if (e.ctrlKey || e.metaKey) e.preventDefault();
};
const onGesture = (e: Event) => e.preventDefault();
window.addEventListener("keydown", onKeyDown);
window.addEventListener("keyup", onKeyUp);
window.addEventListener("blur", onBlur);
window.addEventListener("wheel", onWheel, { passive: false });
document.addEventListener("gesturestart", onGesture);
document.addEventListener("gesturechange", onGesture);
return () => {
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("keyup", onKeyUp);
window.removeEventListener("blur", onBlur);
window.removeEventListener("wheel", onWheel);
document.removeEventListener("gesturestart", onGesture);
document.removeEventListener("gesturechange", onGesture);
};
}, []);
}
export function App() {
useShortcuts();
const { selection, doc } = useEditor();
const [resetOpen, setResetOpen] = useState(false);
const [tab, setTab] = useState<"layers" | "props">("layers");
const idx = getIndex(doc);
const selLabel =
selection.length === 0
? "No selection"
: selection.length === 1
? `Selected: ${idx.get(selection[0])?.node.name ?? ""}`
: `${selection.length} layers selected`;
return (
<div className="app">
<TopBar onReset={() => setResetOpen(true)} />
<div className="workspace" data-tab={tab}>
<aside className="side side-left" id="panel-layers">
<LayersPanel />
</aside>
<main className="stage" aria-label="Canvas area">
<Canvas />
<ToastView />
</main>
<div className="mobile-tabs" role="tablist" aria-label="Panels">
<button type="button" role="tab" id="tab-layers" aria-selected={tab === "layers"} aria-controls="panel-layers" onClick={() => setTab("layers")}>
<Icon.layers />
Layers
</button>
<button type="button" role="tab" id="tab-props" aria-selected={tab === "props"} aria-controls="panel-props" onClick={() => setTab("props")}>
<Icon.sliders />
Properties
{selection.length > 0 && <span className="tab-badge">{selection.length}</span>}
</button>
</div>
<aside className="side side-right" id="panel-props">
<PropertiesPanel />
</aside>
</div>
<footer className="statusbar">
<p className="notice">{FICTIONAL_NOTICE}</p>
<p className="status-sel" aria-live="off">
{selLabel}
</p>
</footer>
<ResetDialog open={resetOpen} onClose={() => setResetOpen(false)} />
<TooltipLayer />
</div>
);
}