diff --git a/apps/web/src/features/canvas/AppMenu.tsx b/apps/web/src/features/canvas/AppMenu.tsx new file mode 100644 index 0000000..4721600 --- /dev/null +++ b/apps/web/src/features/canvas/AppMenu.tsx @@ -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 ( + + ); +} + +function MenuSection({ title, children }: { title: string; children: ReactNode }) { + return ( +
+
{title}
+ {children} +
+ ); +} + +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(null); + const [overIdx, setOverIdx] = useState(null); + + const menuBtnRef = useRef(null); + const pagesBtnRef = useRef(null); + const fileRef = useRef(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 ( + <> + + + + + + + {/* Main menu dropdown */} + setMenuOpen(false)} + anchorRef={menuBtnRef} + placement="bottom" + className="menu-popover" + > +
+ run(() => navigate("/"))} + /> + + + run(() => setActivePage(addPage()))} + /> + run(() => fileRef.current?.click())} + /> + + + + run(() => cmd().undo?.())} + /> + run(() => cmd().redo?.())} + /> + run(selectAll)} + /> + run(deleteSelection)} + /> + + + + run(() => cmd().zoomIn?.())} /> + run(() => cmd().zoomOut?.())} /> + run(() => cmd().zoomReset?.())} /> + run(toggleTheme)} + /> + + + + run(() => zOrder("front"))} /> + run(() => zOrder("forward"))} /> + run(() => zOrder("backward"))} /> + run(() => zOrder("back"))} /> + run(toggleLock)} /> + run(deleteSelection)} /> + +
+
+ + {/* Pages list popover */} + setPagesOpen(false)} + anchorRef={pagesBtnRef} + placement="bottom" + > +
+
+ Pages + +
+ {pages.map((p, i) => ( +
setDragIdx(i)} + onDragOver={(e) => { + e.preventDefault(); + setOverIdx(i); + }} + onDrop={commitDrop} + onDragEnd={() => { + setDragIdx(null); + setOverIdx(null); + }} + onClick={() => setActivePage(p.id)} + > + {i + 1} + 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}`} + /> + +
+ ))} +
+
+ + { + const files = e.target.files; + if (files && files.length > 0) { + void useAssetStore.getState().importAtCenter(files); + } + e.target.value = ""; + }} + /> + + ); +} diff --git a/apps/web/src/features/canvas/ColorPicker.tsx b/apps/web/src/features/canvas/ColorPicker.tsx index bae196c..299a241 100644 --- a/apps/web/src/features/canvas/ColorPicker.tsx +++ b/apps/web/src/features/canvas/ColorPicker.tsx @@ -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"; @@ -106,7 +106,7 @@ export function ColorPicker({ open, onClose, anchorRef }: Props) { aria-label="Pick color from screen" title="Pick color from screen" > - ⊙ + ) : ( @@ -118,7 +118,7 @@ export function ColorPicker({ open, onClose, anchorRef }: Props) { onClick={onClose} aria-label="Close color picker" > - ✕ + @@ -219,7 +219,7 @@ export function ColorPicker({ open, onClose, anchorRef }: Props) { aria-label="Save current color" title="Save current color" > - + + diff --git a/apps/web/src/features/canvas/Dock.tsx b/apps/web/src/features/canvas/Dock.tsx index d62b151..fb8fe2f 100644 --- a/apps/web/src/features/canvas/Dock.tsx +++ b/apps/web/src/features/canvas/Dock.tsx @@ -1,35 +1,67 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useRef, type CSSProperties, type Ref } from "react"; import { - GlassButton, GlassPanel, - Instrument, + Icon, Popover, + Segmented, Slider, Swatch, + type IconName, } from "@notux/ui"; import type { ToolKind } from "@notux/types"; import { - INSTRUMENT_IDS, - INSTRUMENT_MAP, + PEN_STYLES, WIDTH_PRESETS, - useAssetStore, useDockStore, useToolStore, + type InstrumentId, } from "@notux/canvas"; import { ColorPicker } from "./ColorPicker"; -import { ThemeToggle } from "./ThemeToggle"; -const DRAWING_TOOLS: ReadonlySet = new Set([ - "pen", - "highlighter", - "eraser", +// Tools that count as "a shape" for the Shapes button's active state. +const SHAPE_TOOLS: ReadonlySet = new Set([ + "rect", + "ellipse", + "polygon", + "line", + "arrow", ]); -// A wavy stroke sample for the width-preset buttons (Frame 2). +const PEN_STYLE_LABELS: Record = { + pen: "Pen", + fineliner: "Fine", + pencil: "Pencil", + marker: "Marker", + highlighter: "Highlighter", + eraser: "Eraser", +}; + +const STICKY_COLORS = ["#ffe066", "#ff9eb1", "#9ad0ff", "#b9f6c5", "#d8b4fe"]; + +interface ShapeItem { + tool: ToolKind; + variant?: string; + icon: IconName; + label: string; +} + +const SHAPE_ITEMS: ShapeItem[] = [ + { tool: "rect", icon: "square", label: "Rectangle" }, + { tool: "rect", variant: "rounded", icon: "rounded", label: "Rounded rectangle" }, + { tool: "ellipse", icon: "circle", label: "Ellipse" }, + { tool: "polygon", variant: "diamond", icon: "diamond", label: "Diamond" }, + { tool: "polygon", variant: "triangle", icon: "triangle", label: "Triangle" }, + { tool: "line", icon: "line", label: "Line" }, + { tool: "arrow", variant: "straight", icon: "arrow", label: "Arrow" }, + { tool: "arrow", variant: "curved", icon: "arrow-curved", label: "Curved arrow" }, + { tool: "arrow", variant: "elbow", icon: "arrow-elbow", label: "Elbow arrow" }, +]; + +// A wavy stroke sample for the width-preset buttons. function WidthSquiggle({ width }: { width: number }) { const sw = Math.max(1.5, Math.min(11, width * 0.55)); return ( - + ; + chevron?: boolean; +} + +function ToolButton({ + icon, + label, + active, + filled, + onClick, + innerRef, + chevron, +}: ToolButtonProps) { + return ( + + ); +} export function Dock() { const instruments = useDockStore((s) => s.instruments); const activeId = useDockStore((s) => s.activeInstrumentId); - const trayOpen = useDockStore((s) => s.trayOpen); - const widthPopoverOpen = useDockStore((s) => s.widthPopoverOpen); + const penStyle = useDockStore((s) => s.penStyle); + const collapsed = useDockStore((s) => s.collapsed); + const position = useDockStore((s) => s.position); + const penPopoverOpen = useDockStore((s) => s.penPopoverOpen); + const eraserPopoverOpen = useDockStore((s) => s.eraserPopoverOpen); + const shapesFlyoutOpen = useDockStore((s) => s.shapesFlyoutOpen); + const stickyPopoverOpen = useDockStore((s) => s.stickyPopoverOpen); const colorPickerOpen = useDockStore((s) => s.colorPickerOpen); + const selectInstrument = useDockStore((s) => s.selectInstrument); + const setPenStyle = useDockStore((s) => s.setPenStyle); const setActiveWidth = useDockStore((s) => s.setActiveWidth); const setActiveOpacity = useDockStore((s) => s.setActiveOpacity); - const setTrayOpen = useDockStore((s) => s.setTrayOpen); - const setWidthPopoverOpen = useDockStore((s) => s.setWidthPopoverOpen); + const setCollapsed = useDockStore((s) => s.setCollapsed); + const setPosition = useDockStore((s) => s.setPosition); + const setPenPopoverOpen = useDockStore((s) => s.setPenPopoverOpen); + const setEraserPopoverOpen = useDockStore((s) => s.setEraserPopoverOpen); + const setShapesFlyoutOpen = useDockStore((s) => s.setShapesFlyoutOpen); + const setStickyPopoverOpen = useDockStore((s) => s.setStickyPopoverOpen); const setColorPickerOpen = useDockStore((s) => s.setColorPickerOpen); + const closeAllPopovers = useDockStore((s) => s.closeAllPopovers); const tool = useToolStore((s) => s.tool); - const canImport = useAssetStore((s) => s.canImport); + const eraserMode = useToolStore((s) => s.options.eraserMode); + const stickyColor = useToolStore((s) => s.options.stickyColor); + const setOptions = useToolStore((s) => s.setOptions); - const drawing = DRAWING_TOOLS.has(tool); const activeColor = instruments[activeId].color; - const activeInstrRef = useRef(null); + const penBtnRef = useRef(null); + const highlighterBtnRef = useRef(null); + const eraserBtnRef = useRef(null); + const shapesBtnRef = useRef(null); + const stickyBtnRef = useRef(null); const colorBtnRef = useRef(null); - const addBtnRef = useRef(null); - const fileRef = useRef(null); + const collapsedRef = useRef(null); + + // The brush popover (width + opacity, plus pen styles) anchors to whichever + // brush tool is active. + const brushAnchor = tool === "highlighter" ? highlighterBtnRef : penBtnRef; + const isPenFamily = activeId !== "highlighter" && activeId !== "eraser"; - // Sync toolStore with the default instrument once on mount so the canvas - // starts drawing with the pen's ink/width rather than raw tool defaults. + // Sync the default instrument into the tool store once on mount. useEffect(() => { selectInstrument(useDockStore.getState().activeInstrumentId); }, [selectInstrument]); - function onInstrumentClick(id: (typeof INSTRUMENT_IDS)[number]) { - if (drawing && id === activeId) { - setWidthPopoverOpen(!widthPopoverOpen); - } else { - selectInstrument(id); - } + function pickShape(item: ShapeItem) { + useToolStore.getState().setTool(item.tool); + setOptions({ shapeVariant: item.variant }); + closeAllPopovers(); + } + + // ----- dragging the dock around ----------------------------------------- + function onGripPointerDown(e: React.PointerEvent) { + e.preventDefault(); + const dock = (e.currentTarget as HTMLElement).closest(".dock") as HTMLElement; + if (!dock) return; + const rect = dock.getBoundingClientRect(); + const startX = e.clientX; + const startY = e.clientY; + const baseX = position?.x ?? rect.left; + const baseY = position?.y ?? rect.top; + const w = rect.width; + const h = rect.height; + const move = (ev: PointerEvent) => { + const x = Math.max(8, Math.min(baseX + (ev.clientX - startX), window.innerWidth - w - 8)); + const y = Math.max(8, Math.min(baseY + (ev.clientY - startY), window.innerHeight - h - 8)); + setPosition({ x, y }); + }; + const up = () => { + window.removeEventListener("pointermove", move); + window.removeEventListener("pointerup", up); + }; + window.addEventListener("pointermove", move); + window.addEventListener("pointerup", up); + } + + // When a custom position is set, override the centered default. + const dockStyle: CSSProperties | undefined = position + ? { left: position.x, top: position.y, bottom: "auto", transform: "none" } + : undefined; + + if (collapsed) { + return ( + + ); } return ( <> - - {INSTRUMENT_IDS.map((id) => { - const isActive = drawing && id === activeId; - return ( - - ); - })} + + setPosition(null)} + title="Drag to move · double-click to re-center" + role="separator" + aria-label="Move toolbar" + > + + + + { + useToolStore.getState().setTool("select"); + closeAllPopovers(); + }} + /> + { + useToolStore.getState().setTool("hand"); + closeAllPopovers(); + }} + /> + + + + { + if (tool === "pen") setPenPopoverOpen(!penPopoverOpen); + else selectInstrument(useDockStore.getState().penStyle); + }} + /> + { + if (tool === "eraser") setEraserPopoverOpen(!eraserPopoverOpen); + else selectInstrument("eraser"); + }} + /> + { + if (tool === "highlighter") setPenPopoverOpen(!penPopoverOpen); + else selectInstrument("highlighter"); + }} + /> + { + useToolStore.getState().setTool("text"); + closeAllPopovers(); + }} + /> + setShapesFlyoutOpen(!shapesFlyoutOpen)} + /> + { + if (tool === "sticky") setStickyPopoverOpen(!stickyPopoverOpen); + else { + useToolStore.getState().setTool("sticky"); + setOptions({ stickyColor: useToolStore.getState().options.stickyColor }); + closeAllPopovers(); + } + }} + /> @@ -124,34 +318,51 @@ export function Dock() { setColorPickerOpen(!colorPickerOpen)} title="Color" aria-label="Color picker" /> - - setTrayOpen(!trayOpen)} - aria-label="More tools" - title="More tools" - > - + - - + + + - {/* Width + opacity popover for the active instrument (Frame 2). */} + {/* Brush popover: pen styles (pen family) + width + opacity. */} setWidthPopoverOpen(false)} - anchorRef={activeInstrRef} + open={penPopoverOpen && (tool === "pen" || tool === "highlighter")} + onClose={() => setPenPopoverOpen(false)} + anchorRef={brushAnchor} placement="top" tail > + {isPenFamily && ( +
+ {PEN_STYLES.map((style) => ( + + ))} +
+ )}
{WIDTH_PRESETS[activeId].map((w) => (
- {activeId !== "eraser" && ( - +
+ + {/* Eraser popover: mode + size. */} + setEraserPopoverOpen(false)} + anchorRef={eraserBtnRef} + placement="top" + tail + > +
+ + options={["Object", "Area"]} + value={eraserMode === "area" ? "Area" : "Object"} + onChange={(v) => setOptions({ eraserMode: v === "Area" ? "area" : "object" })} /> - )} +
+ {WIDTH_PRESETS.eraser.map((w) => ( + + ))} +
+
- {/* "+" tray: the non-drawing tools + import + theme toggle. */} + {/* Shapes flyout. */} setTrayOpen(false)} - anchorRef={addBtnRef} + open={shapesFlyoutOpen} + onClose={() => setShapesFlyoutOpen(false)} + anchorRef={shapesBtnRef} placement="top" tail > -
- {TRAY_TOOLS.map((t) => ( +
+ {SHAPE_ITEMS.map((item) => ( ))} -
- -
- { - const files = e.target.files; - if (files && files.length > 0) { - void useAssetStore.getState().importAtCenter(files); - setTrayOpen(false); - } - e.target.value = ""; - }} - /> + {/* Sticky color popover. */} + setStickyPopoverOpen(false)} + anchorRef={stickyBtnRef} + placement="top" + tail + > +
+ {STICKY_COLORS.map((c) => ( + setOptions({ stickyColor: c })} + aria-label={`Sticky color ${c}`} + /> + ))} +
+
s.pages); - const activePageId = usePageStore((s) => s.activePageId); - // Subscribe to revision so remote list changes re-render the navigator. - usePageStore((s) => s.revision); - 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 [trayOpen, setTrayOpen] = useState(false); - const [dragIdx, setDragIdx] = useState(null); - const [overIdx, setOverIdx] = useState(null); - const labelRef = useRef(null); - - const index = Math.max( - 0, - pages.findIndex((p) => p.id === activePageId), - ); - const prevId = pages[index - 1]?.id; - const nextId = pages[index + 1]?.id; - - function commitDrop() { - if (dragIdx !== null && overIdx !== null && dragIdx !== overIdx) { - reorderPage(dragIdx, overIdx); - } - setDragIdx(null); - setOverIdx(null); - } - - return ( - <> - - - - - - - - setTrayOpen(false)} - anchorRef={labelRef} - placement="bottom" - > -
- {pages.map((p, i) => ( -
setDragIdx(i)} - onDragOver={(e) => { - e.preventDefault(); - setOverIdx(i); - }} - onDrop={commitDrop} - onDragEnd={() => { - setDragIdx(null); - setOverIdx(null); - }} - onClick={() => setActivePage(p.id)} - > - {i + 1} - 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}`} - /> - -
- ))} -
-
- - ); -} diff --git a/apps/web/src/features/canvas/SelectionInspector.tsx b/apps/web/src/features/canvas/SelectionInspector.tsx index 8f9ba2c..7569e64 100644 --- a/apps/web/src/features/canvas/SelectionInspector.tsx +++ b/apps/web/src/features/canvas/SelectionInspector.tsx @@ -12,11 +12,13 @@ function shapeColor(s: YShape): string | undefined { switch (s.kind) { case "rect": case "ellipse": + case "polygon": case "line": case "arrow": return s.stroke; case "text": case "stroke": + case "sticky": return s.color; case "asset": return undefined; @@ -27,11 +29,13 @@ function colorPatch(s: YShape, color: string): Partial | null { switch (s.kind) { case "rect": case "ellipse": + case "polygon": case "line": case "arrow": return { stroke: color }; case "text": case "stroke": + case "sticky": return { color }; case "asset": return null; @@ -84,7 +88,8 @@ export function SelectionInspector({ pageId }: Props) { }); } - const isFillKind = (s: YShape) => s.kind === "rect" || s.kind === "ellipse"; + const isFillKind = (s: YShape) => + s.kind === "rect" || s.kind === "ellipse" || s.kind === "polygon"; const hasColor = selected.some((s) => shapeColor(s) !== undefined); const fillShapes = selected.filter(isFillKind); const textShapes = selected.filter((s) => s.kind === "text"); diff --git a/apps/web/src/features/canvas/ThemeToggle.tsx b/apps/web/src/features/canvas/ThemeToggle.tsx deleted file mode 100644 index 565e0d0..0000000 --- a/apps/web/src/features/canvas/ThemeToggle.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { useTheme } from "@notux/ui"; - -/** Sun / moon button that flips the app between light and dark Liquid Glass. */ -export function ThemeToggle({ className }: { className?: string }) { - const { theme, toggle } = useTheme(); - return ( - - ); -} diff --git a/apps/web/src/routes/Board.tsx b/apps/web/src/routes/Board.tsx index dc5710a..4fd96d3 100644 --- a/apps/web/src/routes/Board.tsx +++ b/apps/web/src/routes/Board.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { Link, useParams } from "react-router-dom"; +import { useParams } from "react-router-dom"; import { CanvasStage, useAssetStore, @@ -7,8 +7,8 @@ import { useShapeStore, } from "@notux/canvas"; import { useTheme } from "@notux/ui"; +import { AppMenu } from "../features/canvas/AppMenu"; import { Dock } from "../features/canvas/Dock"; -import { PageNavigator } from "../features/canvas/PageNavigator"; import { SaveStatus } from "../features/canvas/SaveStatus"; import { SelectionInspector } from "../features/canvas/SelectionInspector"; import { useIdentity } from "../features/canvas/useIdentity"; @@ -71,13 +71,10 @@ export default function Board() { return (
- + - - ← Home -
); } diff --git a/packages/canvas/src/CanvasStage.tsx b/packages/canvas/src/CanvasStage.tsx index 9a9e4ed..f37d2dc 100644 --- a/packages/canvas/src/CanvasStage.tsx +++ b/packages/canvas/src/CanvasStage.tsx @@ -11,6 +11,7 @@ import { PresenceLayer } from "./layers/PresenceLayer"; import { ShapesLayer } from "./layers/ShapesLayer"; import { TransformLayer } from "./layers/TransformLayer"; import { useAssetStore } from "./store/assetStore"; +import { useCommandStore } from "./store/commandStore"; import { useDraftStore } from "./store/draftStore"; import { DEFAULT_PAGE_ID } from "./store/pageStore"; import { useShapeStore } from "./store/shapeStore"; @@ -37,6 +38,8 @@ function toolCursor(tool: ToolKind): string { switch (tool) { case "select": return "default"; + case "hand": + return "grab"; case "text": return "text"; case "eraser": @@ -66,9 +69,29 @@ export function CanvasStage({ const [viewport, setViewport] = useState({ x: 0, y: 0, scale: 1 }); const [spaceHeld, setSpaceHeld] = useState(false); - const { undo, redo } = useUndoManager(pageId); + const { undo, redo, canUndo, canRedo } = useUndoManager(pageId); const awareness = useAwareness(); + // Register undo/redo + zoom so the app menu (outside the canvas) can drive + // them. Re-registers when handlers or canvas size change. + const sizeRef = useRef(size); + sizeRef.current = size; + useEffect(() => { + const zoomBy = (factor: number) => + setViewport((v) => + zoomAt(v, sizeRef.current.w / 2, sizeRef.current.h / 2, v.scale * factor), + ); + useCommandStore.getState().register({ + undo, + redo, + canUndo, + canRedo, + zoomIn: () => zoomBy(1.2), + zoomOut: () => zoomBy(1 / 1.2), + zoomReset: () => setViewport((v) => ({ ...v, scale: 1 })), + }); + }, [undo, redo, canUndo, canRedo]); + // Publish the local cursor (world coords) to awareness, throttled to one // update per animation frame so rapid pointer moves don't flood the channel. const cursorRafRef = useRef(null); @@ -284,7 +307,7 @@ export function CanvasStage({ const onPointerDown = useCallback( (evt: React.PointerEvent) => { const native = evt.nativeEvent; - if (native.button === 1 || spaceHeld) { + if (native.button === 1 || spaceHeld || tool === "hand") { evt.preventDefault(); panRef.current = { active: true, lastX: native.clientX, lastY: native.clientY }; evt.currentTarget.setPointerCapture(native.pointerId); @@ -307,7 +330,7 @@ export function CanvasStage({ evt.currentTarget.setPointerCapture(native.pointerId); toolRef.current.onPointerDown(pointerToToolPoint(native), buildToolContext()); }, - [spaceHeld, viewport, buildToolContext], + [spaceHeld, tool, viewport, buildToolContext], ); const onPointerMove = useCallback( @@ -424,6 +447,18 @@ export function CanvasStage({ size: hit.size, color: hit.color, }); + } else if (hit && hit.kind === "sticky" && !hit.locked) { + const pad = 14; + useTextEditStore.getState().begin({ + editingId: hit.id, + worldX: hit.x + pad, + worldY: hit.y + pad, + width: Math.max(40, hit.w - pad * 2), + initial: hit.content, + font: "-apple-system, system-ui, sans-serif", + size: hit.fontSize ?? 18, + color: "#1c1c1e", + }); } }, [viewport, hitTestWorld], diff --git a/packages/canvas/src/index.ts b/packages/canvas/src/index.ts index f418d3c..45549ed 100644 --- a/packages/canvas/src/index.ts +++ b/packages/canvas/src/index.ts @@ -7,6 +7,7 @@ export { useShapeStore } from "./store/shapeStore"; export type { RealtimeConfig } from "./store/shapeStore"; export { useToolStore } from "./store/toolStore"; export type { ToolOptions } from "./store/toolStore"; +export { useCommandStore } from "./store/commandStore"; export { useAssetStore } from "./store/assetStore"; export { DEFAULT_PAGE_ID, usePageStore } from "./store/pageStore"; export type { PageMeta } from "./store/pageStore"; @@ -14,7 +15,8 @@ export { useDockStore, INSTRUMENT_IDS, INSTRUMENT_MAP, + PEN_STYLES, WIDTH_PRESETS, } from "./store/dockStore"; -export type { InstrumentId } from "./store/dockStore"; +export type { InstrumentId, DockPosition } from "./store/dockStore"; export type { ShapeStore } from "./store/shapeStore"; diff --git a/packages/canvas/src/layers/OverlayLayer.tsx b/packages/canvas/src/layers/OverlayLayer.tsx index ff017dc..38ba5c9 100644 --- a/packages/canvas/src/layers/OverlayLayer.tsx +++ b/packages/canvas/src/layers/OverlayLayer.tsx @@ -2,6 +2,8 @@ import type { YShape } from "@notux/types"; import { Arrow, Ellipse, Layer, Line, Rect } from "react-konva"; import type { DraftStore } from "../store/draftStore"; import { strokeOutline } from "../renderers/strokeGeometry"; +import { arrowPoints } from "../renderers/ArrowRenderer"; +import { polygonPoints } from "../renderers/PolygonRenderer"; import { shapeBounds } from "../tools/shapeOps"; import { cssVar } from "../theme/cssVar"; import type { ViewportState } from "../viewport/Viewport"; @@ -9,7 +11,7 @@ import type { ViewportState } from "../viewport/Viewport"; interface Props { draft: Pick< DraftStore, - "stroke" | "rect" | "ellipse" | "line" | "arrow" | "marquee" + "stroke" | "rect" | "ellipse" | "polygon" | "line" | "arrow" | "marquee" >; selectedShapes: YShape[]; viewport: ViewportState; @@ -50,6 +52,7 @@ export function OverlayLayer({ draft, selectedShapes, viewport }: Props) { y={draft.rect.y} width={draft.rect.w} height={draft.rect.h} + cornerRadius={draft.rect.radius ?? 0} stroke={draft.rect.stroke} fill={draft.rect.fill ?? undefined} strokeWidth={2} @@ -57,6 +60,24 @@ export function OverlayLayer({ draft, selectedShapes, viewport }: Props) { /> )} + {draft.polygon && ( + + )} + {draft.ellipse && ( )} - {draft.arrow && ( - - )} + {draft.arrow && (() => { + const { points, tension } = arrowPoints({ + kind: "arrow", + id: "draft", + author: "", + x1: draft.arrow.x1, + y1: draft.arrow.y1, + x2: draft.arrow.x2, + y2: draft.arrow.y2, + stroke: draft.arrow.stroke, + width: draft.arrow.width, + variant: draft.arrow.variant, + }); + return ( + + ); + })()} {draft.marquee && ( ; case "ellipse": return ; + case "polygon": + return ; case "line": return ; case "arrow": return ; case "text": return ; + case "sticky": + return ; case "asset": return ; } @@ -45,7 +51,9 @@ function groupTransform(shape: YShape): { switch (shape.kind) { case "rect": case "ellipse": + case "polygon": case "text": + case "sticky": case "asset": return { x: shape.x, y: shape.y, rotation: shape.rot ?? 0 }; default: diff --git a/packages/canvas/src/renderers/ArrowRenderer.tsx b/packages/canvas/src/renderers/ArrowRenderer.tsx index ca36b00..51e19e8 100644 --- a/packages/canvas/src/renderers/ArrowRenderer.tsx +++ b/packages/canvas/src/renderers/ArrowRenderer.tsx @@ -6,16 +6,40 @@ interface Props { selected?: boolean; } +// Resolve the polyline the arrow follows for each routing variant. Konva draws +// the arrowhead at the final point; `tension` smooths the curved variant. +export function arrowPoints(shape: YArrow): { points: number[]; tension: number } { + const { x1, y1, x2, y2, variant } = shape; + if (variant === "elbow") { + // Horizontal-then-vertical right-angle route. + return { points: [x1, y1, x2, y1, x2, y2], tension: 0 }; + } + if (variant === "curved") { + const dx = x2 - x1; + const dy = y2 - y1; + const len = Math.hypot(dx, dy) || 1; + const off = Math.min(len * 0.25, 120); + // Bow the midpoint out along the line's perpendicular. + const mx = (x1 + x2) / 2 + (-dy / len) * off; + const my = (y1 + y2) / 2 + (dx / len) * off; + return { points: [x1, y1, mx, my, x2, y2], tension: 0.5 }; + } + return { points: [x1, y1, x2, y2], tension: 0 }; +} + export function ArrowRenderer({ shape, selected }: Props) { + const { points, tension } = arrowPoints(shape); return ( + ); +} diff --git a/packages/canvas/src/renderers/RectRenderer.tsx b/packages/canvas/src/renderers/RectRenderer.tsx index c8c3b40..db12306 100644 --- a/packages/canvas/src/renderers/RectRenderer.tsx +++ b/packages/canvas/src/renderers/RectRenderer.tsx @@ -15,6 +15,7 @@ export function RectRenderer({ shape, selected }: Props) { y={0} width={shape.w} height={shape.h} + cornerRadius={shape.radius ?? 0} stroke={shape.stroke} strokeWidth={2} fill={shape.fill ?? undefined} diff --git a/packages/canvas/src/renderers/StickyRenderer.tsx b/packages/canvas/src/renderers/StickyRenderer.tsx new file mode 100644 index 0000000..06ff5f1 --- /dev/null +++ b/packages/canvas/src/renderers/StickyRenderer.tsx @@ -0,0 +1,58 @@ +import type { YSticky } from "@notux/types"; +import { Rect, Text } from "react-konva"; + +interface Props { + shape: YSticky; + selected?: boolean; +} + +const PAD = 14; + +// Choose readable text ink for a given note color (dark text on light notes). +function inkFor(color: string): string { + const m = /^#?([0-9a-f]{6})$/i.exec(color.trim()); + if (!m || !m[1]) return "#1c1c1e"; + const n = parseInt(m[1], 16); + const r = (n >> 16) & 255; + const g = (n >> 8) & 255; + const b = n & 255; + // Perceived luminance. + const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255; + return lum > 0.6 ? "#1c1c1e" : "#ffffff"; +} + +export function StickyRenderer({ shape, selected }: Props) { + // Local coords; the ShapesLayer Group carries x/y/rotation. + return ( + <> + + + + ); +} diff --git a/packages/canvas/src/store/commandStore.ts b/packages/canvas/src/store/commandStore.ts new file mode 100644 index 0000000..8cf41c3 --- /dev/null +++ b/packages/canvas/src/store/commandStore.ts @@ -0,0 +1,25 @@ +import { create } from "zustand"; + +// A tiny command registry so chrome outside the canvas (the app menu) can invoke +// actions that physically live inside CanvasStage — undo/redo (bound to the +// per-page Y.UndoManager) and viewport zoom (local CanvasStage state). The stage +// registers handlers on mount; consumers call them and read canUndo/canRedo for +// disabled states. All fields are optional until the stage mounts. +interface CommandStoreState { + undo?: () => void; + redo?: () => void; + zoomIn?: () => void; + zoomOut?: () => void; + zoomReset?: () => void; + canUndo: boolean; + canRedo: boolean; + register(patch: Partial>): void; +} + +export const useCommandStore = create((set) => ({ + canUndo: false, + canRedo: false, + register(patch) { + set(patch); + }, +})); diff --git a/packages/canvas/src/store/dockStore.ts b/packages/canvas/src/store/dockStore.ts index 16a3b88..fe3a327 100644 --- a/packages/canvas/src/store/dockStore.ts +++ b/packages/canvas/src/store/dockStore.ts @@ -2,9 +2,9 @@ import type { StrokeStyle, ToolKind } from "@notux/types"; import { create } from "zustand"; import { useToolStore } from "./toolStore"; -// The drawing instruments shown in the Liquid Glass dock (ruler omitted in -// M7). Structurally identical to @notux/ui's InstrumentKind, kept here so the -// canvas package stays free of a dependency on the UI package. +// The drawing instruments the dock can draw with. The Pen button cycles through +// the pen-family styles (pen/fineliner/pencil/marker) via its popover; the +// highlighter and eraser are their own buttons. export type InstrumentId = | "pen" | "fineliner" @@ -22,6 +22,14 @@ export const INSTRUMENT_IDS: readonly InstrumentId[] = [ "marker", ] as const; +// The pen-family styles offered in the Pen popover. +export const PEN_STYLES: readonly InstrumentId[] = [ + "pen", + "fineliner", + "pencil", + "marker", +] as const; + interface InstrumentDef { toolKind: ToolKind; // canvas tool that backs it style: StrokeStyle; // render variant @@ -70,7 +78,7 @@ export const INSTRUMENT_MAP: Record = { }, }; -// The five width samples shown in the dock popover (Frame 2), per instrument. +// The five width samples shown in the dock popover, per instrument. export const WIDTH_PRESETS: Record = { pen: [2, 4, 6, 9, 13], fineliner: [1, 2, 3, 4, 6], @@ -86,19 +94,42 @@ interface InstrumentState { opacity: number; } +export interface DockPosition { + x: number; + y: number; +} + interface DockStoreState { instruments: Record; + // The active drawing instrument (drives the color swatch + width popover). activeInstrumentId: InstrumentId; - trayOpen: boolean; - widthPopoverOpen: boolean; + // Which pen-family style the Pen button currently draws with. + penStyle: InstrumentId; + // Dock chrome. + collapsed: boolean; + position: DockPosition | null; // null = default bottom-center + // Popover toggles. + penPopoverOpen: boolean; + eraserPopoverOpen: boolean; + shapesFlyoutOpen: boolean; + stickyPopoverOpen: boolean; colorPickerOpen: boolean; + selectInstrument(id: InstrumentId): void; + setPenStyle(id: InstrumentId): void; setActiveColor(color: string): void; setActiveWidth(width: number): void; setActiveOpacity(opacity: number): void; - setTrayOpen(open: boolean): void; - setWidthPopoverOpen(open: boolean): void; + + setCollapsed(v: boolean): void; + setPosition(p: DockPosition | null): void; + + setPenPopoverOpen(open: boolean): void; + setEraserPopoverOpen(open: boolean): void; + setShapesFlyoutOpen(open: boolean): void; + setStickyPopoverOpen(open: boolean): void; setColorPickerOpen(open: boolean): void; + closeAllPopovers(): void; } function initialInstruments(): Record { @@ -110,9 +141,37 @@ function initialInstruments(): Record { return out; } -// Tools read from toolStore.options (see CanvasStage.buildToolContext), so the -// dock resolves the active instrument into toolStore on every change. -function pushToTool(id: InstrumentId, inst: InstrumentState) { +// ---- persistence (collapsed + dock position) ---------------------------- + +const PERSIST_KEY = "notux-dock"; + +function loadPersisted(): { collapsed: boolean; position: DockPosition | null } { + if (typeof window === "undefined") return { collapsed: false, position: null }; + try { + const raw = window.localStorage.getItem(PERSIST_KEY); + if (!raw) return { collapsed: false, position: null }; + const v = JSON.parse(raw) as { collapsed?: boolean; position?: DockPosition | null }; + return { collapsed: !!v.collapsed, position: v.position ?? null }; + } catch { + return { collapsed: false, position: null }; + } +} + +function persist(state: { collapsed: boolean; position: DockPosition | null }) { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem( + PERSIST_KEY, + JSON.stringify({ collapsed: state.collapsed, position: state.position }), + ); + } catch { + /* storage unavailable */ + } +} + +// Push a whole instrument preset (tool + ink/width/opacity/style) to the tool +// store. Used when *selecting* an instrument. +function selectInstrumentInTool(id: InstrumentId, inst: InstrumentState) { const def = INSTRUMENT_MAP[id]; const tool = useToolStore.getState(); tool.setTool(def.toolKind); @@ -124,51 +183,91 @@ function pushToTool(id: InstrumentId, inst: InstrumentState) { }); } -export const useDockStore = create((set, get) => ({ - instruments: initialInstruments(), - activeInstrumentId: "pen", - trayOpen: false, - widthPopoverOpen: false, - colorPickerOpen: false, - - selectInstrument(id) { - set({ - activeInstrumentId: id, - trayOpen: false, - widthPopoverOpen: false, - colorPickerOpen: false, - }); - pushToTool(id, get().instruments[id]); - }, +export const useDockStore = create((set, get) => { + const persisted = loadPersisted(); + return { + instruments: initialInstruments(), + activeInstrumentId: "pen", + penStyle: "pen", + collapsed: persisted.collapsed, + position: persisted.position, + penPopoverOpen: false, + eraserPopoverOpen: false, + shapesFlyoutOpen: false, + stickyPopoverOpen: false, + colorPickerOpen: false, - setActiveColor(color) { - const id = get().activeInstrumentId; - const inst = { ...get().instruments[id], color }; - set({ instruments: { ...get().instruments, [id]: inst } }); - pushToTool(id, inst); - }, + selectInstrument(id) { + set({ + activeInstrumentId: id, + penPopoverOpen: false, + eraserPopoverOpen: false, + shapesFlyoutOpen: false, + stickyPopoverOpen: false, + colorPickerOpen: false, + }); + selectInstrumentInTool(id, get().instruments[id]); + }, - setActiveWidth(width) { - const id = get().activeInstrumentId; - const inst = { ...get().instruments[id], width }; - set({ instruments: { ...get().instruments, [id]: inst } }); - pushToTool(id, inst); - }, + setPenStyle(id) { + set({ penStyle: id, activeInstrumentId: id }); + selectInstrumentInTool(id, get().instruments[id]); + }, - setActiveOpacity(opacity) { - const id = get().activeInstrumentId; - const inst = { ...get().instruments[id], opacity }; - set({ instruments: { ...get().instruments, [id]: inst } }); - pushToTool(id, inst); - }, + // Option tweaks update the active instrument's memory AND patch the current + // tool's options — but never call setTool, so they don't knock the user off + // a shape/sticky/text tool. + setActiveColor(color) { + const id = get().activeInstrumentId; + const inst = { ...get().instruments[id], color }; + set({ instruments: { ...get().instruments, [id]: inst } }); + useToolStore.getState().setOptions({ color }); + }, + setActiveWidth(width) { + const id = get().activeInstrumentId; + const inst = { ...get().instruments[id], width }; + set({ instruments: { ...get().instruments, [id]: inst } }); + useToolStore.getState().setOptions({ size: width }); + }, + setActiveOpacity(opacity) { + const id = get().activeInstrumentId; + const inst = { ...get().instruments[id], opacity }; + set({ instruments: { ...get().instruments, [id]: inst } }); + useToolStore.getState().setOptions({ opacity }); + }, - setTrayOpen(open) { - set({ trayOpen: open }); - }, - setWidthPopoverOpen(open) { - set({ widthPopoverOpen: open }); - }, - setColorPickerOpen(open) { - set({ colorPickerOpen: open }); - }, -})); + setCollapsed(v) { + set({ collapsed: v }); + persist({ collapsed: v, position: get().position }); + }, + setPosition(p) { + set({ position: p }); + persist({ collapsed: get().collapsed, position: p }); + }, + + setPenPopoverOpen(open) { + set({ penPopoverOpen: open }); + }, + setEraserPopoverOpen(open) { + set({ eraserPopoverOpen: open }); + }, + setShapesFlyoutOpen(open) { + set({ shapesFlyoutOpen: open }); + }, + setStickyPopoverOpen(open) { + set({ stickyPopoverOpen: open }); + }, + setColorPickerOpen(open) { + set({ colorPickerOpen: open }); + }, + closeAllPopovers() { + set({ + penPopoverOpen: false, + eraserPopoverOpen: false, + shapesFlyoutOpen: false, + stickyPopoverOpen: false, + colorPickerOpen: false, + }); + }, + }; +}); diff --git a/packages/canvas/src/store/draftStore.ts b/packages/canvas/src/store/draftStore.ts index 7c27084..2a9c79e 100644 --- a/packages/canvas/src/store/draftStore.ts +++ b/packages/canvas/src/store/draftStore.ts @@ -18,6 +18,18 @@ export interface DraftRect { h: number; stroke: string; fill: string | null; + // Corner radius for the rounded-square preview. + radius?: number; +} + +export interface DraftPolygon { + variant: "diamond" | "triangle"; + x: number; + y: number; + w: number; + h: number; + stroke: string; + fill: string | null; } export interface DraftLine { @@ -27,18 +39,22 @@ export interface DraftLine { y2: number; stroke: string; width: number; + // Arrow routing for the preview (matches YArrow.variant). + variant?: "straight" | "curved" | "elbow"; } interface DraftStoreState { stroke: DraftStroke | null; rect: DraftRect | null; ellipse: DraftRect | null; + polygon: DraftPolygon | null; line: DraftLine | null; arrow: DraftLine | null; marquee: { x: number; y: number; w: number; h: number } | null; setStroke(s: DraftStroke | null): void; setRect(r: DraftRect | null): void; setEllipse(r: DraftRect | null): void; + setPolygon(p: DraftPolygon | null): void; setLine(l: DraftLine | null): void; setArrow(l: DraftLine | null): void; setMarquee(m: { x: number; y: number; w: number; h: number } | null): void; @@ -52,6 +68,7 @@ export const useDraftStore = create((set) => ({ stroke: null, rect: null, ellipse: null, + polygon: null, line: null, arrow: null, marquee: null, @@ -64,6 +81,9 @@ export const useDraftStore = create((set) => ({ setEllipse(r) { set({ ellipse: r }); }, + setPolygon(p) { + set({ polygon: p }); + }, setLine(l) { set({ line: l }); }, @@ -78,6 +98,7 @@ export const useDraftStore = create((set) => ({ stroke: null, rect: null, ellipse: null, + polygon: null, line: null, arrow: null, marquee: null, diff --git a/packages/canvas/src/store/toolStore.ts b/packages/canvas/src/store/toolStore.ts index 8fbb425..8c82a40 100644 --- a/packages/canvas/src/store/toolStore.ts +++ b/packages/canvas/src/store/toolStore.ts @@ -9,6 +9,15 @@ export interface ToolOptions { opacity: number; // Instrument variant the active tool draws with (pen by default). style: StrokeStyle; + // Sub-variant for the shape family, interpreted per-tool: + // rect → "rounded" draws a rounded square + // polygon → "diamond" | "triangle" + // arrow → "straight" | "curved" | "elbow" + shapeVariant?: string; + // Eraser behaviour: delete whole objects, or trim/split strokes under the tip. + eraserMode: "object" | "area"; + // Fill color for new sticky notes (kept separate from the ink `color`). + stickyColor: string; } interface ToolStoreState { @@ -34,6 +43,9 @@ const DEFAULT_OPTIONS: ToolOptions = { fill: null, opacity: 1, style: "pen", + shapeVariant: undefined, + eraserMode: "object", + stickyColor: "#ffe066", }; export const useToolStore = create((set) => ({ diff --git a/packages/canvas/src/tools/ArrowTool.ts b/packages/canvas/src/tools/ArrowTool.ts index f53a3d8..6a018aa 100644 --- a/packages/canvas/src/tools/ArrowTool.ts +++ b/packages/canvas/src/tools/ArrowTool.ts @@ -9,9 +9,19 @@ export function makeArrowTool(): Tool { start: { x: 0, y: 0 }, }; + function variantOf(ctx: ToolContext): "straight" | "curved" | "elbow" { + const v = ctx.options.shapeVariant; + return v === "curved" || v === "elbow" ? v : "straight"; + } + function preview(end: { x: number; y: number }, shift: boolean, ctx: ToolContext) { const l = dragLine(state.start, end, shift); - ctx.draftStore.setArrow({ ...l, stroke: ctx.options.color, width: ctx.options.size }); + ctx.draftStore.setArrow({ + ...l, + stroke: ctx.options.color, + width: ctx.options.size, + variant: variantOf(ctx), + }); } return { @@ -37,6 +47,7 @@ export function makeArrowTool(): Tool { ...l, stroke: ctx.options.color, width: ctx.options.size, + variant: variantOf(ctx), }; ctx.store.transact(() => ctx.store.addShape(ctx.pageId, shape)); }, diff --git a/packages/canvas/src/tools/EraserTool.ts b/packages/canvas/src/tools/EraserTool.ts index 4d8bc34..b436200 100644 --- a/packages/canvas/src/tools/EraserTool.ts +++ b/packages/canvas/src/tools/EraserTool.ts @@ -1,21 +1,51 @@ +import { splitStrokeByEraser } from "./eraseGeom"; import type { Tool, ToolContext, ToolEventPoint } from "./types"; interface State { active: boolean; } -// Object eraser: while held, any shape under the pointer is deleted. -// Pixel eraser is post-M2. +// Eraser with two modes (set via options.eraserMode): +// "object" — any shape under the pointer is deleted whole. +// "area" — strokes are trimmed/split where the tip passes; other shapes +// touched by the tip are deleted whole (vectors can't partial-erase). export function makeEraserTool(): Tool { const state: State = { active: false }; - function eraseAt(p: ToolEventPoint, ctx: ToolContext) { + function eraseObjectAt(p: ToolEventPoint, ctx: ToolContext) { const hit = ctx.hitTest({ x: p.x, y: p.y }); if (hit && !hit.locked) { ctx.store.transact(() => ctx.store.deleteShape(ctx.pageId, hit.id)); } } + function eraseAreaAt(p: ToolEventPoint, ctx: ToolContext) { + const r = Math.max(2, ctx.options.size / 2); + const box = { x: p.x - r, y: p.y - r, w: r * 2, h: r * 2 }; + const candidates = ctx.rectIntersect(box).filter((s) => !s.locked); + if (candidates.length === 0) return; + ctx.store.transact(() => { + for (const shape of candidates) { + if (shape.kind === "stroke") { + const { changed, pieces } = splitStrokeByEraser(shape, p.x, p.y, r); + if (!changed) continue; + ctx.store.deleteShape(ctx.pageId, shape.id); + for (const piece of pieces) ctx.store.addShape(ctx.pageId, piece); + } else { + // Non-stroke vectors can't be partially erased — remove if the tip is + // actually over them (not just within the bbox). + const hit = ctx.hitTest({ x: p.x, y: p.y }); + if (hit && hit.id === shape.id) ctx.store.deleteShape(ctx.pageId, shape.id); + } + } + }); + } + + function eraseAt(p: ToolEventPoint, ctx: ToolContext) { + if (ctx.options.eraserMode === "area") eraseAreaAt(p, ctx); + else eraseObjectAt(p, ctx); + } + return { cursor: "cell", onPointerDown(p, ctx) { diff --git a/packages/canvas/src/tools/PolygonTool.ts b/packages/canvas/src/tools/PolygonTool.ts new file mode 100644 index 0000000..81fd419 --- /dev/null +++ b/packages/canvas/src/tools/PolygonTool.ts @@ -0,0 +1,67 @@ +import type { YPolygon } from "@notux/types"; +import { newShapeId } from "../ids"; +import { dragRect } from "./dragOutGeom"; +import type { Tool, ToolContext } from "./types"; + +function variantOf(ctx: ToolContext): YPolygon["variant"] { + return ctx.options.shapeVariant === "triangle" ? "triangle" : "diamond"; +} + +// Diamond / triangle drag-out tool. Shares the box geometry of RectTool; the +// variant selects which polygon PolygonRenderer inscribes in the x/y/w/h box. +export function makePolygonTool(): Tool { + const state: { active: boolean; start: { x: number; y: number } } = { + active: false, + start: { x: 0, y: 0 }, + }; + + function preview(end: { x: number; y: number }, shift: boolean, ctx: ToolContext) { + const r = dragRect(state.start, end, shift); + ctx.draftStore.setPolygon({ + variant: variantOf(ctx), + x: r.x, + y: r.y, + w: r.w, + h: r.h, + stroke: ctx.options.color, + fill: ctx.options.fill, + }); + } + + return { + cursor: "crosshair", + onPointerDown(p, _ctx) { + state.active = true; + state.start = { x: p.x, y: p.y }; + }, + onPointerMove(p, ctx) { + if (!state.active) return; + preview({ x: p.x, y: p.y }, p.shift, ctx); + }, + onPointerUp(p, ctx) { + if (!state.active) return; + const r = dragRect(state.start, { x: p.x, y: p.y }, p.shift); + ctx.draftStore.setPolygon(null); + state.active = false; + if (r.w < 1 || r.h < 1) return; + const shape: YPolygon = { + id: newShapeId(), + author: ctx.authorId, + kind: "polygon", + variant: variantOf(ctx), + x: r.x, + y: r.y, + w: r.w, + h: r.h, + rot: 0, + stroke: ctx.options.color, + fill: ctx.options.fill, + }; + ctx.store.transact(() => ctx.store.addShape(ctx.pageId, shape)); + }, + onCancel(ctx) { + state.active = false; + ctx.draftStore.setPolygon(null); + }, + }; +} diff --git a/packages/canvas/src/tools/RectTool.ts b/packages/canvas/src/tools/RectTool.ts index 90c13d3..530f684 100644 --- a/packages/canvas/src/tools/RectTool.ts +++ b/packages/canvas/src/tools/RectTool.ts @@ -3,6 +3,12 @@ import { newShapeId } from "../ids"; import { dragRect } from "./dragOutGeom"; import type { Tool, ToolContext } from "./types"; +// Corner radius for the "rounded square" variant, scaled to the box but capped. +function cornerRadius(variant: string | undefined, w: number, h: number): number { + if (variant !== "rounded") return 0; + return Math.min(24, Math.min(w, h) * 0.18); +} + export function makeRectTool(): Tool { const state: { active: boolean; start: { x: number; y: number } } = { active: false, @@ -16,6 +22,7 @@ export function makeRectTool(): Tool { y: r.y, w: r.w, h: r.h, + radius: cornerRadius(ctx.options.shapeVariant, r.w, r.h), stroke: ctx.options.color, fill: ctx.options.fill, }); @@ -46,6 +53,7 @@ export function makeRectTool(): Tool { w: r.w, h: r.h, rot: 0, + radius: cornerRadius(ctx.options.shapeVariant, r.w, r.h), stroke: ctx.options.color, fill: ctx.options.fill, }; diff --git a/packages/canvas/src/tools/StickyTool.ts b/packages/canvas/src/tools/StickyTool.ts new file mode 100644 index 0000000..8fc3a17 --- /dev/null +++ b/packages/canvas/src/tools/StickyTool.ts @@ -0,0 +1,79 @@ +import type { YSticky } from "@notux/types"; +import { newShapeId } from "../ids"; +import { useTextEditStore } from "../store/textEditStore"; +import { useToolStore } from "../store/toolStore"; +import { dragRect } from "./dragOutGeom"; +import type { Tool, ToolContext } from "./types"; + +const DEFAULT_SIZE = 180; +const MIN_SIZE = 60; +const PAD = 14; +const FONT = "-apple-system, system-ui, sans-serif"; +const FONT_SIZE = 18; + +// Sticky note: click to drop a default note, or drag to size one, then open the +// text editor on it (reusing the YText overlay, which patches `content`). +export function makeStickyTool(): Tool { + const state: { active: boolean; start: { x: number; y: number } } = { + active: false, + start: { x: 0, y: 0 }, + }; + + function place(end: { x: number; y: number }, ctx: ToolContext) { + const r = dragRect(state.start, end, false); + // A click (or tiny drag) drops a default-sized note centered on the point. + const sized = r.w >= MIN_SIZE || r.h >= MIN_SIZE; + const w = sized ? r.w : DEFAULT_SIZE; + const h = sized ? r.h : DEFAULT_SIZE; + const x = sized ? r.x : state.start.x - w / 2; + const y = sized ? r.y : state.start.y - h / 2; + + const shape: YSticky = { + id: newShapeId(), + author: ctx.authorId, + kind: "sticky", + x, + y, + w, + h, + rot: 0, + color: ctx.options.stickyColor, + content: "", + fontSize: FONT_SIZE, + }; + ctx.store.transact(() => ctx.store.addShape(ctx.pageId, shape)); + + // Open the text editor anchored to the note's text area. + useTextEditStore.getState().begin({ + editingId: shape.id, + worldX: x + PAD, + worldY: y + PAD, + width: Math.max(40, w - PAD * 2), + initial: "", + font: FONT, + size: FONT_SIZE, + color: "#1c1c1e", + }); + } + + return { + cursor: "crosshair", + onPointerDown(p, _ctx) { + state.active = true; + state.start = { x: p.x, y: p.y }; + }, + onPointerMove(_p, _ctx) { + // No live preview; the note appears on release. + }, + onPointerUp(p, ctx) { + if (!state.active) return; + state.active = false; + place({ x: p.x, y: p.y }, ctx); + // Return to select so the new note can be moved/edited immediately. + useToolStore.getState().setTool("select"); + }, + onCancel(_ctx) { + state.active = false; + }, + }; +} diff --git a/packages/canvas/src/tools/eraseGeom.ts b/packages/canvas/src/tools/eraseGeom.ts new file mode 100644 index 0000000..91b6a2c --- /dev/null +++ b/packages/canvas/src/tools/eraseGeom.ts @@ -0,0 +1,57 @@ +import type { YStroke } from "@notux/types"; +import { newShapeId } from "../ids"; + +export interface EraseResult { + // True when at least one point fell inside the eraser radius. + changed: boolean; + // The surviving sub-strokes (empty when the whole stroke was erased). + pieces: YStroke[]; +} + +// Point-based area erase: drops every point within `r` of (cx,cy) and splits the +// stroke into the contiguous runs that survive. Pen strokes are densely sampled, +// so a point test approximates trimming the line where the eraser passes. Each +// run of >= 2 points becomes a fresh stroke carrying the original's style. +export function splitStrokeByEraser( + stroke: YStroke, + cx: number, + cy: number, + r: number, +): EraseResult { + const r2 = r * r; + const runs: { points: number[]; pressure: number[] }[] = []; + let cur: { points: number[]; pressure: number[] } | null = null; + let changed = false; + + for (let i = 0; i < stroke.points.length; i += 2) { + const x = stroke.points[i] ?? 0; + const y = stroke.points[i + 1] ?? 0; + const dx = x - cx; + const dy = y - cy; + const inside = dx * dx + dy * dy <= r2; + if (inside) { + changed = true; + cur = null; // close the current run + continue; + } + if (!cur) { + cur = { points: [], pressure: [] }; + runs.push(cur); + } + cur.points.push(x, y); + cur.pressure.push(stroke.pressure[i / 2] ?? 0.5); + } + + if (!changed) return { changed: false, pieces: [stroke] }; + + const pieces: YStroke[] = runs + .filter((run) => run.points.length >= 4) // >= 2 points + .map((run) => ({ + ...stroke, + id: newShapeId(), + points: run.points, + pressure: run.pressure, + })); + + return { changed: true, pieces }; +} diff --git a/packages/canvas/src/tools/registry.ts b/packages/canvas/src/tools/registry.ts index cf95d2b..946495d 100644 --- a/packages/canvas/src/tools/registry.ts +++ b/packages/canvas/src/tools/registry.ts @@ -4,11 +4,25 @@ import { makeEllipseTool } from "./EllipseTool"; import { makeEraserTool } from "./EraserTool"; import { makeLineTool } from "./LineTool"; import { makePenTool } from "./PenTool"; +import { makePolygonTool } from "./PolygonTool"; import { makeRectTool } from "./RectTool"; import { makeSelectTool } from "./SelectTool"; +import { makeStickyTool } from "./StickyTool"; import { makeTextTool } from "./TextTool"; import type { Tool } from "./types"; +// The hand tool carries no drawing behaviour; CanvasStage routes pointer events +// to panning whenever the active tool is "hand". +function makeHandTool(): Tool { + return { + cursor: "grab", + onPointerDown() {}, + onPointerMove() {}, + onPointerUp() {}, + onCancel() {}, + }; +} + export function makeTool(kind: ToolKind): Tool { switch (kind) { case "pen": @@ -21,12 +35,18 @@ export function makeTool(kind: ToolKind): Tool { return makeRectTool(); case "ellipse": return makeEllipseTool(); + case "polygon": + return makePolygonTool(); case "line": return makeLineTool(); case "arrow": return makeArrowTool(); case "text": return makeTextTool(); + case "sticky": + return makeStickyTool(); + case "hand": + return makeHandTool(); case "select": return makeSelectTool(); } diff --git a/packages/canvas/src/tools/shapeOps.ts b/packages/canvas/src/tools/shapeOps.ts index aa24394..312d58a 100644 --- a/packages/canvas/src/tools/shapeOps.ts +++ b/packages/canvas/src/tools/shapeOps.ts @@ -33,7 +33,9 @@ export function shapeBounds(shape: YShape): Bounds { } case "rect": case "ellipse": + case "polygon": case "text": + case "sticky": case "asset": return { x: shape.x, y: shape.y, w: shape.w, h: shape.h }; case "line": @@ -66,7 +68,9 @@ export function translateShape(shape: YShape, dx: number, dy: number): YShape { } case "rect": case "ellipse": + case "polygon": case "text": + case "sticky": case "asset": return { ...shape, x: shape.x + dx, y: shape.y + dy }; case "line": diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 067d851..82061fc 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -6,9 +6,11 @@ export type { YStroke, YRect, YEllipse, + YPolygon, YLine, YArrow, YText, + YSticky, YAssetRef, YShape, YShapeKind, diff --git a/packages/types/src/yshape.ts b/packages/types/src/yshape.ts index 8acc0b7..1f515e3 100644 --- a/packages/types/src/yshape.ts +++ b/packages/types/src/yshape.ts @@ -4,9 +4,12 @@ export type ToolKind = | "eraser" | "rect" | "ellipse" + | "polygon" | "line" | "arrow" | "text" + | "sticky" + | "hand" | "select"; // Visual variant of a freehand stroke. The canvas/tool discriminator stays @@ -51,6 +54,23 @@ export interface YRect extends YShapeBase { rot: number; stroke: string; fill: string | null; + // Corner radius in world units. undefined / 0 renders a sharp rectangle; + // the "rounded square" shape sets this. + radius?: number; +} + +// Diamond and triangle share the drag-out box geometry of YRect; the `variant` +// picks which polygon is inscribed in the x/y/w/h box. +export interface YPolygon extends YShapeBase { + kind: "polygon"; + variant: "diamond" | "triangle"; + x: number; + y: number; + w: number; + h: number; + rot: number; + stroke: string; + fill: string | null; } export interface YEllipse extends YShapeBase { @@ -82,6 +102,8 @@ export interface YArrow extends YShapeBase { y2: number; stroke: string; width: number; + // Routing between the endpoints. undefined renders straight (back-compat). + variant?: "straight" | "curved" | "elbow"; } export interface YText extends YShapeBase { @@ -97,6 +119,18 @@ export interface YText extends YShapeBase { color: string; } +export interface YSticky extends YShapeBase { + kind: "sticky"; + x: number; + y: number; + w: number; + h: number; + rot?: number; + color: string; + content: string; + fontSize?: number; +} + export interface YAssetRef extends YShapeBase { kind: "asset"; assetId: string; @@ -112,9 +146,11 @@ export type YShape = | YStroke | YRect | YEllipse + | YPolygon | YLine | YArrow | YText + | YSticky | YAssetRef; export type YShapeKind = YShape["kind"]; diff --git a/packages/ui/src/components/Slider.tsx b/packages/ui/src/components/Slider.tsx index f713807..f8d9684 100644 --- a/packages/ui/src/components/Slider.tsx +++ b/packages/ui/src/components/Slider.tsx @@ -13,7 +13,12 @@ interface Props { "aria-label"?: string; } -/** Generic 0..1 slider; the "opacity" variant matches the iOS opacity track. */ +// Knob diameter — must match `.slider__knob` in styles.css. The knob travels +// inside a rail inset by its radius so it stays fully visible at 0% and 100% +// instead of being clipped by the track's rounded `overflow: hidden`. +const KNOB = 26; + +/** Generic 0..1 slider; the "opacity" variant draws a translucency track. */ export function Slider({ value, onChange, @@ -23,6 +28,7 @@ export function Slider({ ...rest }: Props) { const pct = Math.round(value * 100); + const clamped = Math.max(0, Math.min(1, value)); const fill: CSSProperties = trackStyle === "opacity" ? { @@ -34,22 +40,27 @@ export function Slider({ return (
-
-
-
- onChange(Number(e.target.value) / 100)} - aria-label={rest["aria-label"]} +
+
+
+ onChange(Number(e.target.value) / 100)} + aria-label={rest["aria-label"]} + /> +
+
{showValue &&
{pct}%
} diff --git a/packages/ui/src/icons/Icon.tsx b/packages/ui/src/icons/Icon.tsx new file mode 100644 index 0000000..5e53d1f --- /dev/null +++ b/packages/ui/src/icons/Icon.tsx @@ -0,0 +1,190 @@ +import type { ReactNode, SVGProps } from "react"; + +// A small, flat 24×24 stroke-icon set used across the dock, the app menu, the +// shapes flyout and the color picker. Icons inherit `currentColor` so they pick +// up the surrounding text color in either theme. +export type IconName = + | "select" + | "hand" + | "pen" + | "eraser" + | "highlighter" + | "text" + | "shapes" + | "sticky" + | "plus" + | "minus" + | "chevron-down" + | "chevron-up" + | "chevron-left" + | "chevron-right" + | "grip" + | "menu" + | "close" + | "undo" + | "redo" + | "zoom-in" + | "zoom-out" + | "zoom-reset" + | "trash" + | "lock" + | "unlock" + | "to-front" + | "to-back" + | "forward" + | "backward" + | "sun" + | "moon" + | "upload" + | "eyedropper" + | "pages" + | "square" + | "rounded" + | "circle" + | "diamond" + | "triangle" + | "line" + | "arrow" + | "arrow-curved" + | "arrow-elbow"; + +const PATHS: Record = { + select: , + hand: ( + + ), + pen: , + eraser: ( + <> + + + + ), + highlighter: ( + <> + + + + ), + text: , + shapes: ( + <> + + + + ), + sticky: ( + + ), + plus: , + minus: , + "chevron-down": , + "chevron-up": , + "chevron-left": , + "chevron-right": , + grip: ( + <> + + + + + + + + ), + menu: , + close: , + undo: , + redo: , + "zoom-in": ( + <> + + + + ), + "zoom-out": ( + <> + + + + ), + "zoom-reset": , + trash: , + lock: ( + <> + + + + ), + unlock: ( + <> + + + + ), + "to-front": ( + <> + + + + ), + "to-back": ( + <> + + + + ), + forward: , + backward: , + sun: ( + <> + + + + ), + moon: , + upload: , + eyedropper: , + pages: ( + <> + + + + ), + square: , + rounded: , + circle: , + diamond: , + triangle: , + line: , + arrow: , + "arrow-curved": , + "arrow-elbow": , +}; + +interface Props extends Omit, "name"> { + name: IconName; + size?: number; + /** Solid icons (sticky/diamond/etc.) read better filled in some contexts. */ + filled?: boolean; +} + +export function Icon({ name, size = 22, filled, ...rest }: Props) { + return ( + + {PATHS[name]} + + ); +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 8c7701c..7bba487 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -13,3 +13,6 @@ export { Swatch } from "./components/Swatch"; export { Instrument, INSTRUMENT_KINDS } from "./instruments/Instrument"; export type { InstrumentKind } from "./instruments/Instrument"; + +export { Icon } from "./icons/Icon"; +export type { IconName } from "./icons/Icon"; diff --git a/packages/ui/src/styles.css b/packages/ui/src/styles.css index 4047d69..ab6c470 100644 --- a/packages/ui/src/styles.css +++ b/packages/ui/src/styles.css @@ -237,6 +237,16 @@ gap: 12px; } +/* The rail is the positioning context. The colored track clips its gradient / + checkerboard, but the knob lives in the rail (not the track) so it is never + clipped, and its travel is inset by the knob radius (see Slider.tsx). */ +.slider__rail { + position: relative; + flex: 1; + display: flex; + align-items: center; +} + .slider__track { position: relative; flex: 1; @@ -355,14 +365,14 @@ .dock { position: fixed; left: 50%; - bottom: 18px; + bottom: 20px; transform: translateX(-50%); z-index: 30; display: flex; - align-items: flex-end; - gap: 4px; - padding: 6px 12px; - border-radius: 24px; + align-items: center; + gap: 2px; + padding: 6px 8px; + border-radius: 16px; background: var(--dock-bg); border: 1px solid var(--glass-stroke); backdrop-filter: blur(30px) saturate(180%); @@ -371,45 +381,191 @@ max-width: calc(100vw - 24px); } -.dock__instrument { +/* Drag handle. */ +.dock__grip { + display: grid; + place-items: center; + width: 20px; + height: 38px; + color: var(--fg-1); + cursor: grab; + border-radius: 8px; + touch-action: none; +} + +.dock__grip:hover { + background: var(--glass-hover); + color: var(--fg-0); +} + +.dock__grip:active { + cursor: grabbing; +} + +/* Flat icon tool button. */ +.dock__tool { + position: relative; appearance: none; border: 0; background: transparent; - padding: 0; + color: var(--fg-0); width: 40px; - height: 76px; - display: flex; - align-items: flex-end; - justify-content: center; + height: 40px; + border-radius: 10px; + display: grid; + place-items: center; cursor: pointer; - overflow: visible; - transition: transform 200ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 160ms ease; + transition: background 140ms ease, color 140ms ease; } -.dock__instrument--active { - transform: translateY(-14px); +.dock__tool:hover { + background: var(--glass-hover); } -.dock__instrument--dimmed { - opacity: 0.5; +.dock__tool--active { + background: var(--accent); + color: #fff; +} + +.dock__chevron { + position: absolute; + right: 3px; + bottom: 3px; + opacity: 0.75; } .dock__divider { - align-self: center; width: 1px; - height: 38px; - margin: 0 6px; + height: 26px; + margin: 0 4px; background: var(--glass-stroke); } .dock__color { - align-self: center; - width: 38px; - height: 38px; + display: grid; + place-items: center; + width: 40px; + height: 40px; +} + +.dock__chrome-btn { + appearance: none; + border: 0; + background: transparent; + color: var(--fg-1); + width: 30px; + height: 40px; + border-radius: 8px; + display: grid; + place-items: center; + cursor: pointer; +} + +.dock__chrome-btn:hover { + background: var(--glass-hover); + color: var(--fg-0); +} + +/* Collapsed handle shown in place of the dock. */ +.dock-collapsed { + position: fixed; + left: 50%; + bottom: 20px; + transform: translateX(-50%); + z-index: 30; + appearance: none; + width: 56px; + height: 36px; + border-radius: 14px; + border: 1px solid var(--glass-stroke); + background: var(--dock-bg); + color: var(--fg-0); + backdrop-filter: blur(30px) saturate(180%); + -webkit-backdrop-filter: blur(30px) saturate(180%); + box-shadow: var(--shadow-lg); + display: grid; + place-items: center; + cursor: pointer; +} + +.dock-collapsed:hover { + background: var(--glass-hover); +} + +/* Pen-style chooser in the brush popover. */ +.brush-styles { + display: flex; + gap: 4px; + margin-bottom: 10px; +} + +.brush-style { + appearance: none; + border: 1px solid var(--glass-stroke); + background: var(--segment-bg); + color: var(--fg-0); + font: inherit; + font-size: 13px; + font-weight: 600; + padding: 6px 10px; + border-radius: 8px; + cursor: pointer; + transition: background 140ms ease; } -.dock__add { - align-self: center; +.brush-style:hover { + background: var(--glass-hover); +} + +.brush-style--active { + background: var(--preset-active-bg); + color: var(--preset-active-fg); + border-color: transparent; +} + +.eraser-popover { + display: flex; + flex-direction: column; + gap: 12px; + min-width: 220px; +} + +.eraser-dot { + display: block; + border-radius: 50%; + background: currentColor; +} + +/* Shapes flyout grid. */ +.shapes-flyout { + display: grid; + grid-template-columns: repeat(3, 44px); + gap: 6px; +} + +.shape-item { + appearance: none; + width: 44px; + height: 44px; + border: 1px solid transparent; + border-radius: 10px; + background: var(--segment-bg); + color: var(--fg-0); + cursor: pointer; + display: grid; + place-items: center; + transition: background 140ms ease, border-color 140ms ease; +} + +.shape-item:hover { + background: var(--glass-hover); + border-color: var(--glass-stroke); +} + +.sticky-colors { + display: flex; + gap: 8px; + align-items: center; } /* Width-preset popover contents (Frame 2). */ @@ -571,46 +727,44 @@ flex-wrap: wrap; } -/* ----- Page navigator ---------------------------------------------------- */ +/* ----- App menu (top-left) ----------------------------------------------- */ -.page-nav { +.app-menu { position: fixed; top: 16px; - left: 50%; - transform: translateX(-50%); + left: 16px; z-index: 30; display: flex; align-items: center; - gap: 4px; - padding: 4px 6px; - border-radius: 999px; + gap: 2px; + padding: 4px; + border-radius: 12px; } -.page-nav__btn { +.app-menu__btn { appearance: none; border: 0; background: transparent; color: var(--fg-0); - width: 30px; - height: 30px; - border-radius: 50%; - font-size: 16px; + height: 34px; + padding: 0 8px; + border-radius: 8px; + display: flex; + align-items: center; + gap: 2px; cursor: pointer; - display: grid; - place-items: center; - transition: background 150ms ease; + transition: background 140ms ease; } -.page-nav__btn:hover { +.app-menu__btn:hover { background: var(--glass-hover); } -.page-nav__btn:disabled { - opacity: 0.35; - cursor: not-allowed; +.app-menu__chevron { + opacity: 0.6; } -.page-nav__label { +.app-menu__title { appearance: none; border: 0; background: transparent; @@ -618,13 +772,119 @@ font: inherit; font-size: 14px; font-weight: 600; - padding: 4px 10px; - border-radius: 999px; + height: 34px; + padding: 0 8px; + border-radius: 8px; cursor: pointer; + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; white-space: nowrap; + transition: background 140ms ease; +} + +.app-menu__title:hover { + background: var(--glass-hover); +} + +/* ----- Dropdown menu ----------------------------------------------------- */ + +.menu-popover { + padding: 6px; +} + +.menu { + display: flex; + flex-direction: column; + width: 248px; + max-height: 72vh; + overflow-y: auto; +} + +.menu__section { + display: flex; + flex-direction: column; + margin-top: 6px; + padding-top: 6px; + border-top: 1px solid var(--glass-stroke); +} + +.menu__section-title { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--fg-1); + padding: 2px 10px 4px; +} + +.menu__item { + appearance: none; + border: 0; + background: transparent; + color: var(--fg-0); + font: inherit; + font-size: 14px; + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + border-radius: 8px; + cursor: pointer; + text-align: left; + transition: background 120ms ease; +} + +.menu__item:hover { + background: var(--glass-hover); +} + +.menu__item:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.menu__item-icon { + width: 18px; + display: grid; + place-items: center; + color: var(--fg-1); + flex: none; +} + +.menu__item-label { + flex: 1; +} + +.menu__item-shortcut { + font-size: 12px; + color: var(--fg-1); +} + +.page-tray__head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 2px 6px 8px; + font-size: 13px; + font-weight: 700; + color: var(--fg-1); +} + +.page-tray__add { + appearance: none; + border: 0; + background: transparent; + color: var(--fg-0); + width: 24px; + height: 24px; + border-radius: 6px; + display: grid; + place-items: center; + cursor: pointer; } -.page-nav__label:hover { +.page-tray__add:hover { background: var(--glass-hover); } @@ -704,46 +964,3 @@ cursor: not-allowed; } -/* ----- "+" tray of non-drawing tools ------------------------------------ */ - -.tool-tray { - display: grid; - grid-template-columns: repeat(4, 44px); - gap: 6px; -} - -.tool-tray__btn { - appearance: none; - width: 44px; - height: 44px; - border: 1px solid transparent; - border-radius: 10px; - background: var(--segment-bg); - color: var(--fg-0); - font-size: 18px; - cursor: pointer; - display: grid; - place-items: center; - transition: background 150ms ease, border-color 150ms ease; -} - -.tool-tray__btn:hover { - background: var(--glass-hover); -} - -.tool-tray__btn--active { - background: color-mix(in srgb, var(--accent) 22%, transparent); - border-color: var(--accent); -} - -.tool-tray__btn:disabled { - opacity: 0.4; - cursor: not-allowed; -} - -.tool-tray__sep { - grid-column: 1 / -1; - height: 1px; - background: var(--glass-stroke); - margin: 2px 0; -} diff --git a/packages/ui/src/theme/useTheme.ts b/packages/ui/src/theme/useTheme.ts index c17153b..1076f1f 100644 --- a/packages/ui/src/theme/useTheme.ts +++ b/packages/ui/src/theme/useTheme.ts @@ -9,19 +9,12 @@ const STORAGE_KEY = "notux-theme"; let current: Theme = readInitial(); const listeners = new Set<() => void>(); -function systemPrefersDark(): boolean { - return ( - typeof window !== "undefined" && - typeof window.matchMedia === "function" && - window.matchMedia("(prefers-color-scheme: dark)").matches - ); -} - function readInitial(): Theme { - if (typeof window === "undefined") return "dark"; + if (typeof window === "undefined") return "light"; const saved = window.localStorage.getItem(STORAGE_KEY); if (saved === "light" || saved === "dark") return saved; - return systemPrefersDark() ? "dark" : "light"; + // Default to the light, FigJam-like look; users can switch via the menu. + return "light"; } /** Resolve the theme the app should boot with (stored pref → OS preference). */