Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
334 changes: 334 additions & 0 deletions apps/web/src/features/canvas/AppMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,334 @@
import { useRef, useState, type ReactNode } from "react";
import { useNavigate } from "react-router-dom";
import { GlassPanel, Icon, Popover, useTheme, type IconName } from "@notux/ui";
import {
useAssetStore,
useCommandStore,
usePageStore,
useShapeStore,
useToolStore,
} from "@notux/canvas";

interface MenuItemProps {
icon?: IconName;
label: string;
shortcut?: string;
disabled?: boolean;
onClick(): void;
}

function MenuItem({ icon, label, shortcut, disabled, onClick }: MenuItemProps) {
return (
<button
type="button"
className="menu__item"
disabled={disabled}
onClick={onClick}
>
<span className="menu__item-icon">{icon && <Icon name={icon} size={18} />}</span>
<span className="menu__item-label">{label}</span>
{shortcut && <span className="menu__item-shortcut">{shortcut}</span>}
</button>
);
}

function MenuSection({ title, children }: { title: string; children: ReactNode }) {
return (
<div className="menu__section">
<div className="menu__section-title">{title}</div>
{children}
</div>
);
}

export function AppMenu() {
const navigate = useNavigate();
const { theme, toggle: toggleTheme } = useTheme();

const pages = usePageStore((s) => s.pages);
const activePageId = usePageStore((s) => s.activePageId);
usePageStore((s) => s.revision); // re-render on remote page changes
const setActivePage = usePageStore((s) => s.setActivePage);
const addPage = usePageStore((s) => s.addPage);
const deletePage = usePageStore((s) => s.deletePage);
const renamePage = usePageStore((s) => s.renamePage);
const reorderPage = usePageStore((s) => s.reorderPage);

const canImport = useAssetStore((s) => s.canImport);
const canUndo = useCommandStore((s) => s.canUndo);
const canRedo = useCommandStore((s) => s.canRedo);
const selection = useToolStore((s) => s.selection);
useShapeStore((s) => s.revision); // keep Object actions in sync

const [menuOpen, setMenuOpen] = useState(false);
const [pagesOpen, setPagesOpen] = useState(false);
const [dragIdx, setDragIdx] = useState<number | null>(null);
const [overIdx, setOverIdx] = useState<number | null>(null);

const menuBtnRef = useRef<HTMLButtonElement>(null);
const pagesBtnRef = useRef<HTMLButtonElement>(null);
const fileRef = useRef<HTMLInputElement>(null);

const activeTitle =
pages.find((p) => p.id === activePageId)?.title ?? "Untitled";
const hasSelection = selection.size > 0;

function run(fn: () => void) {
fn();
setMenuOpen(false);
}

// ----- Object / Edit actions on the current selection -------------------
function selectedIds(): string[] {
return Array.from(useToolStore.getState().selection);
}
function zOrder(op: "front" | "forward" | "backward" | "back") {
const ids = selectedIds();
if (ids.length === 0) return;
const store = useShapeStore.getState();
if (op === "front") store.bringToFront(activePageId, ids);
else if (op === "forward") store.bringForward(activePageId, ids);
else if (op === "backward") store.sendBackward(activePageId, ids);
else store.sendToBack(activePageId, ids);
}
function toggleLock() {
const ids = selectedIds();
if (ids.length === 0) return;
const store = useShapeStore.getState();
const allLocked = ids.every((id) => store.getShape(activePageId, id)?.locked);
store.transact(() => ids.forEach((id) => store.setLocked(activePageId, id, !allLocked)));
}
function deleteSelection() {
const ids = selectedIds();
if (ids.length === 0) return;
const store = useShapeStore.getState();
store.transact(() => ids.forEach((id) => store.deleteShape(activePageId, id)));
useToolStore.getState().clearSelection();
}
function selectAll() {
const ids = useShapeStore
.getState()
.listShapes(activePageId)
.map((s) => s.id);
useToolStore.getState().setTool("select");
useToolStore.getState().setSelection(ids);
}

function commitDrop() {
if (dragIdx !== null && overIdx !== null && dragIdx !== overIdx) {
reorderPage(dragIdx, overIdx);
}
setDragIdx(null);
setOverIdx(null);
}

// Commands (undo/redo/zoom) are registered by CanvasStage after mount, so
// fetch them fresh at click time rather than capturing a stale snapshot.
const cmd = () => useCommandStore.getState();

return (
<>
<GlassPanel className="app-menu" aria-label="Menu">
<button
ref={menuBtnRef}
type="button"
className="app-menu__btn app-menu__btn--menu"
onClick={() => setMenuOpen((o) => !o)}
aria-haspopup="menu"
aria-expanded={menuOpen}
title="Menu"
>
<Icon name="menu" size={20} />
<Icon name="chevron-down" size={12} className="app-menu__chevron" />
</button>
<button
type="button"
className="app-menu__title"
onClick={() => setPagesOpen((o) => !o)}
title="Pages"
>
{activeTitle}
</button>
<button
ref={pagesBtnRef}
type="button"
className="app-menu__btn"
onClick={() => setPagesOpen((o) => !o)}
aria-haspopup="menu"
aria-expanded={pagesOpen}
title="Pages"
>
<Icon name="pages" size={18} />
</button>
</GlassPanel>

{/* Main menu dropdown */}
<Popover
open={menuOpen}
onClose={() => setMenuOpen(false)}
anchorRef={menuBtnRef}
placement="bottom"
className="menu-popover"
>
<div className="menu">
<MenuItem
icon="chevron-left"
label="Back to files"
onClick={() => run(() => navigate("/"))}
/>

<MenuSection title="File">
<MenuItem
icon="plus"
label="New page"
onClick={() => run(() => setActivePage(addPage()))}
/>
<MenuItem
icon="upload"
label="Import image or PDF"
disabled={!canImport}
onClick={() => run(() => fileRef.current?.click())}
/>
</MenuSection>

<MenuSection title="Edit">
<MenuItem
icon="undo"
label="Undo"
shortcut="⌘Z"
disabled={!canUndo}
onClick={() => run(() => cmd().undo?.())}
/>
<MenuItem
icon="redo"
label="Redo"
shortcut="⌘⇧Z"
disabled={!canRedo}
onClick={() => run(() => cmd().redo?.())}
/>
<MenuItem
label="Select all"
shortcut="⌘A"
onClick={() => run(selectAll)}
/>
<MenuItem
icon="trash"
label="Delete selection"
disabled={!hasSelection}
onClick={() => run(deleteSelection)}
/>
</MenuSection>

<MenuSection title="View">
<MenuItem icon="zoom-in" label="Zoom in" onClick={() => run(() => cmd().zoomIn?.())} />
<MenuItem icon="zoom-out" label="Zoom out" onClick={() => run(() => cmd().zoomOut?.())} />
<MenuItem icon="zoom-reset" label="Reset zoom" onClick={() => run(() => cmd().zoomReset?.())} />
<MenuItem
icon={theme === "dark" ? "sun" : "moon"}
label={theme === "dark" ? "Light mode" : "Dark mode"}
onClick={() => run(toggleTheme)}
/>
</MenuSection>

<MenuSection title="Object">
<MenuItem icon="to-front" label="Bring to front" disabled={!hasSelection} onClick={() => run(() => zOrder("front"))} />
<MenuItem icon="forward" label="Bring forward" disabled={!hasSelection} onClick={() => run(() => zOrder("forward"))} />
<MenuItem icon="backward" label="Send backward" disabled={!hasSelection} onClick={() => run(() => zOrder("backward"))} />
<MenuItem icon="to-back" label="Send to back" disabled={!hasSelection} onClick={() => run(() => zOrder("back"))} />
<MenuItem icon="lock" label="Lock / unlock" disabled={!hasSelection} onClick={() => run(toggleLock)} />
<MenuItem icon="trash" label="Delete" disabled={!hasSelection} onClick={() => run(deleteSelection)} />
</MenuSection>
</div>
</Popover>

{/* Pages list popover */}
<Popover
open={pagesOpen}
onClose={() => setPagesOpen(false)}
anchorRef={pagesBtnRef}
placement="bottom"
>
<div className="page-tray">
<div className="page-tray__head">
<span>Pages</span>
<button
type="button"
className="page-tray__add"
onClick={() => setActivePage(addPage())}
title="Add page"
aria-label="Add page"
>
<Icon name="plus" size={16} />
</button>
</div>
{pages.map((p, i) => (
<div
key={p.id}
className={
"page-tray__item" +
(p.id === activePageId ? " page-tray__item--active" : "") +
(overIdx === i && dragIdx !== null ? " page-tray__item--dragover" : "")
}
draggable
onDragStart={() => setDragIdx(i)}
onDragOver={(e) => {
e.preventDefault();
setOverIdx(i);
}}
onDrop={commitDrop}
onDragEnd={() => {
setDragIdx(null);
setOverIdx(null);
}}
onClick={() => setActivePage(p.id)}
>
<span className="page-tray__num">{i + 1}</span>
<input
key={`${p.id}:${p.title}`}
className="page-tray__title"
defaultValue={p.title}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
}}
onBlur={(e) => {
const v = e.target.value.trim();
if (v && v !== p.title) renamePage(p.id, v);
}}
aria-label={`Rename ${p.title}`}
/>
<button
type="button"
className="page-tray__del"
onClick={(e) => {
e.stopPropagation();
deletePage(p.id);
}}
disabled={pages.length <= 1}
aria-label={`Delete ${p.title}`}
title="Delete page"
>
<Icon name="trash" size={15} />
</button>
</div>
))}
</div>
</Popover>

<input
ref={fileRef}
type="file"
accept="image/*,application/pdf"
multiple
style={{ display: "none" }}
onChange={(e) => {
const files = e.target.files;
if (files && files.length > 0) {
void useAssetStore.getState().importAtCenter(files);
}
e.target.value = "";
}}
/>
</>
);
}
8 changes: 4 additions & 4 deletions apps/web/src/features/canvas/ColorPicker.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useMemo, useState, type RefObject } from "react";
import { Segmented, Sheet, Slider, Swatch } from "@notux/ui";
import { Icon, Segmented, Sheet, Slider, Swatch } from "@notux/ui";
import { useDockStore } from "@notux/canvas";
import { useSavedSwatches } from "./useSavedSwatches";

Expand Down Expand Up @@ -106,7 +106,7 @@ export function ColorPicker({ open, onClose, anchorRef }: Props) {
aria-label="Pick color from screen"
title="Pick color from screen"
>
<Icon name="eyedropper" size={18} />
</button>
) : (
<span />
Expand All @@ -118,7 +118,7 @@ export function ColorPicker({ open, onClose, anchorRef }: Props) {
onClick={onClose}
aria-label="Close color picker"
>
<Icon name="close" size={16} />
</button>
</div>

Expand Down Expand Up @@ -219,7 +219,7 @@ export function ColorPicker({ open, onClose, anchorRef }: Props) {
aria-label="Save current color"
title="Save current color"
>
<Icon name="plus" size={16} />
</button>
</div>
</div>
Expand Down
Loading
Loading