- 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, useEffect, useRef, useState } from "react";
import {
type Tool,
fitAll,
resetDemo,
redo,
setTool,
showToast,
undo,
useEditor,
zoomStep,
zoomTo100,
} from "./store";
import { CanvelyMark, Icon } from "./icons";
const TOOLS: { id: Tool; label: string; key: string; icon: () => ReactElement }[] = [
{ id: "move", label: "Move", key: "V", icon: Icon.move },
{ id: "rect", label: "Rectangle", key: "R", icon: Icon.rect },
{ id: "ellipse", label: "Ellipse", key: "O", icon: Icon.ellipse },
{ id: "text", label: "Text", key: "T", icon: Icon.text },
];
export function TopBar({ onReset }: { onReset: () => void }) {
const { doc, tool, past, future, view } = useEditor();
const lastUndo = past.length;
return (
<header className="topbar">
<div className="brand">
<span className="logo" aria-label="Canvely" role="img">
<CanvelyMark />
<span className="wordmark" aria-hidden="true">
Canvely
</span>
</span>
<span className="crumb-sep" aria-hidden="true" />
<nav className="crumbs" aria-label="Current file">
<span className="workspace-name">{doc.workspace}</span>
<span className="slash" aria-hidden="true">
/
</span>
<span className="file-name" aria-current="page">
{doc.file}
</span>
</nav>
</div>
<div className="toolrow">
<div className="tools" role="toolbar" aria-label="Tools">
{TOOLS.map((t) => (
<button
key={t.id}
type="button"
className={`tool-btn${tool === t.id ? " is-active" : ""}`}
aria-pressed={tool === t.id}
aria-label={`${t.label} tool`}
aria-keyshortcuts={t.key}
data-tip={`${t.label} ${t.key}`}
onClick={() => setTool(t.id)}
>
<t.icon />
</button>
))}
<span className="sep" aria-hidden="true" />
<button
type="button"
className="tool-btn"
aria-label="Undo"
aria-keyshortcuts="Control+Z Meta+Z"
data-tip={lastUndo ? `Undo Ctrl/⌘ Z` : "Nothing to undo yet"}
disabled={!past.length}
onClick={undo}
>
<Icon.undo />
</button>
<button
type="button"
className="tool-btn"
aria-label="Redo"
aria-keyshortcuts="Control+Shift+Z Meta+Shift+Z"
data-tip={future.length ? "Redo Ctrl/⌘ ⇧ Z" : "Nothing to redo"}
disabled={!future.length}
onClick={redo}
>
<Icon.redo />
</button>
</div>
<div className="zoom" role="group" aria-label="Zoom">
<button type="button" className="tool-btn" aria-label="Zoom out" data-tip="Zoom out Ctrl/⌘ −" onClick={() => zoomStep(-1)} disabled={view.zoom <= 0.1001}>
<Icon.zoomOut />
</button>
<output className="zoom-level" aria-live="polite" aria-label={`Zoom ${Math.round(view.zoom * 100)} percent`}>
{Math.round(view.zoom * 100)}%
</output>
<button type="button" className="tool-btn" aria-label="Zoom in" data-tip="Zoom in Ctrl/⌘ +" onClick={() => zoomStep(1)} disabled={view.zoom >= 7.999}>
<Icon.zoomIn />
</button>
<button type="button" className="tool-btn" aria-label="Zoom to fit all content" data-tip="Fit all ⇧ 1" onClick={fitAll}>
<Icon.fit />
</button>
<button type="button" className="tool-btn text-btn" aria-label="Zoom to 100%" data-tip="Actual size ⇧ 0" onClick={zoomTo100}>
1:1
</button>
</div>
</div>
<button type="button" className="reset-btn" onClick={onReset} data-tip="Restore the original file and clear history">
<Icon.reset />
<span>Reset demo</span>
</button>
</header>
);
}
export function ResetDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
const ref = useRef<HTMLDialogElement>(null);
useEffect(() => {
const d = ref.current;
if (!d) return;
if (open && !d.open) d.showModal();
if (!open && d.open) d.close();
}, [open]);
return (
<dialog
ref={ref}
className="dialog"
aria-labelledby="reset-title"
aria-describedby="reset-desc"
onClose={onClose}
onCancel={onClose}
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div className="dialog-body">
<h2 id="reset-title">Reset the demo?</h2>
<p id="reset-desc">
This puts <strong>Hazel & Rye — Spring Menu</strong> back to how it started and clears undo history. Changes saved in this browser
will be lost.
</p>
<div className="dialog-actions">
<button type="button" className="btn" autoFocus onClick={onClose}>
Keep my changes
</button>
<button
type="button"
className="btn btn-danger"
onClick={() => {
onClose();
resetDemo();
showToast("Demo reset. The original Spring Menu is back and undo history is empty.");
}}
>
Reset demo
</button>
</div>
</div>
</dialog>
);
}
export function ToastView() {
const { toast } = useEditor();
return (
<div className="toast-region" role="status" aria-live="polite">
{toast && (
<div key={toast.id} className={`toast tone-${toast.tone}`}>
{toast.text}
</div>
)}
</div>
);
}
/** One floating tooltip for every element with data-tip; never clipped by scroll areas. */
export function TooltipLayer() {
const [tip, setTip] = useState<{ text: string; x: number; y: number; above: boolean } | null>(null);
useEffect(() => {
let timer: number | undefined;
let current: HTMLElement | null = null;
const hide = () => {
window.clearTimeout(timer);
current = null;
setTip(null);
};
const show = (el: HTMLElement, delay: number) => {
window.clearTimeout(timer);
current = el;
timer = window.setTimeout(() => {
if (!el.isConnected) return;
const r = el.getBoundingClientRect();
const above = r.bottom + 40 > window.innerHeight;
setTip({ text: el.dataset.tip ?? "", x: r.left + r.width / 2, y: above ? r.top - 8 : r.bottom + 8, above });
}, delay);
};
const over = (e: PointerEvent) => {
if (e.pointerType !== "mouse") return;
const el = (e.target as Element | null)?.closest?.<HTMLElement>("[data-tip]") ?? null;
if (el === current) return;
if (!el) hide();
else show(el, tipVisible() ? 60 : 500);
};
const tipVisible = () => !!document.querySelector(".tooltip");
const focusIn = (e: FocusEvent) => {
const el = (e.target as Element | null)?.closest?.<HTMLElement>("[data-tip]") ?? null;
if (el && el.matches(":focus-visible")) show(el, 250);
else hide();
};
window.addEventListener("pointerover", over);
window.addEventListener("pointerdown", hide);
window.addEventListener("focusin", focusIn);
window.addEventListener("focusout", hide);
window.addEventListener("keydown", hide);
window.addEventListener("scroll", hide, true);
return () => {
window.clearTimeout(timer);
window.removeEventListener("pointerover", over);
window.removeEventListener("pointerdown", hide);
window.removeEventListener("focusin", focusIn);
window.removeEventListener("focusout", hide);
window.removeEventListener("keydown", hide);
window.removeEventListener("scroll", hide, true);
};
}, []);
if (!tip || !tip.text) return null;
const left = Math.min(Math.max(tip.x, 90), window.innerWidth - 90);
return (
<div className={`tooltip${tip.above ? " is-above" : ""}`} role="tooltip" style={{ left, top: tip.y }}>
{tip.text}
</div>
);
}