From 4d12e8d1330ff1501897c9eb71ddd8c19f1abd6c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 17:20:16 +0000 Subject: [PATCH] feat(M7): pages, Liquid Glass dock + UI library MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build out Milestone 7 against the supplied PencilKit-style design frames: a Liquid Glass component library, the bottom drawing dock, the iOS color picker, multi-page boards, and a light/dark theme toggle. packages/ui (Liquid Glass library) - Theme system (useTheme/applyTheme/getInitialTheme): light + dark token sets on [data-theme], persisted to localStorage, defaulting to OS preference. - Glass primitives: GlassPanel, GlassButton, Popover (portal + speech-bubble tail), Sheet (bottom sheet on narrow screens), Segmented, Slider (opacity track), Swatch (rainbow ring variant). - Faithful SVG drawing instruments (pen, fineliner, highlighter, eraser, pencil, marker) that lift when active. - Single design-system stylesheet exported as @notux/ui/styles.css. Dock (Frames 1 & 2) - Bottom-centered glass dock replaces the old left ToolPalette: instruments + rainbow color swatch + "+" tray (select/shapes/text/import/theme toggle). - Per-instrument settings (dockStore) — each instrument remembers its color, width and opacity and pushes resolved values into toolStore so the canvas tools pick them up. Active instrument lifts; tapping it opens a width-preset + opacity popover. Color picker (Frame 3) - Grid + Sliders tabs, opacity slider, saved swatches (persisted), and the EyeDropper API where supported. Pages - Synced ordered page list in the Yjs doc (sync/pageList.ts) with seeding that migrates existing single-page boards. pageStore mirrors shapeStore's Yjs-binding/revision pattern; active page is local per-user. Board threads the active pageId into CanvasStage/SelectionInspector; navigator pill + tray for add/switch/rename/delete/reorder. Stroke pipeline - Add opacity + style to ToolOptions/YStroke/DraftStroke; PenTool writes them (the old highlighter x4 size hack is removed); StrokeRenderer/OverlayLayer apply style-specific paint (highlighter = multiply) without double-applying the Group opacity already handled in ShapesLayer. Theming - CSS tokens moved into the UI library; canvas Konva layers read theme colors via cssVar() and re-render on toggle. Inspector/home restyled to tokens. --- apps/web/src/features/canvas/ColorPicker.tsx | 229 ++++++ apps/web/src/features/canvas/Dock.tsx | 248 ++++++ .../web/src/features/canvas/PageNavigator.tsx | 140 ++++ apps/web/src/features/canvas/ThemeToggle.tsx | 17 + apps/web/src/features/canvas/ToolPalette.tsx | 113 --- .../src/features/canvas/useSavedSwatches.ts | 41 + apps/web/src/main.tsx | 6 + apps/web/src/routes/Board.tsx | 21 +- apps/web/src/styles.css | 133 +--- packages/canvas/src/CanvasStage.tsx | 9 +- packages/canvas/src/ids.ts | 4 + packages/canvas/src/index.ts | 10 +- .../canvas/src/layers/BackgroundLayer.tsx | 5 +- packages/canvas/src/layers/OverlayLayer.tsx | 15 +- .../canvas/src/renderers/StrokeRenderer.tsx | 9 +- packages/canvas/src/store/dockStore.ts | 174 ++++ packages/canvas/src/store/draftStore.ts | 3 + packages/canvas/src/store/pageStore.ts | 122 +++ packages/canvas/src/store/toolStore.ts | 24 +- packages/canvas/src/theme/cssVar.ts | 12 + packages/canvas/src/tools/PenTool.ts | 48 +- packages/sync/src/index.ts | 10 + packages/sync/src/pageList.ts | 99 +++ packages/types/src/index.ts | 1 + packages/types/src/yshape.ts | 14 + packages/ui/package.json | 14 +- packages/ui/src/components/GlassButton.tsx | 32 + packages/ui/src/components/GlassPanel.tsx | 22 + packages/ui/src/components/Popover.tsx | 118 +++ packages/ui/src/components/Segmented.tsx | 37 + packages/ui/src/components/Sheet.tsx | 58 ++ packages/ui/src/components/Slider.tsx | 58 ++ packages/ui/src/components/Swatch.tsx | 53 ++ packages/ui/src/index.ts | 14 + packages/ui/src/instruments/Instrument.tsx | 166 ++++ packages/ui/src/styles.css | 749 ++++++++++++++++++ packages/ui/src/theme/useTheme.ts | 70 ++ packages/ui/tsconfig.json | 6 + pnpm-lock.yaml | 16 + 39 files changed, 2653 insertions(+), 267 deletions(-) create mode 100644 apps/web/src/features/canvas/ColorPicker.tsx create mode 100644 apps/web/src/features/canvas/Dock.tsx create mode 100644 apps/web/src/features/canvas/PageNavigator.tsx create mode 100644 apps/web/src/features/canvas/ThemeToggle.tsx delete mode 100644 apps/web/src/features/canvas/ToolPalette.tsx create mode 100644 apps/web/src/features/canvas/useSavedSwatches.ts create mode 100644 packages/canvas/src/store/dockStore.ts create mode 100644 packages/canvas/src/theme/cssVar.ts create mode 100644 packages/sync/src/pageList.ts create mode 100644 packages/ui/src/components/GlassButton.tsx create mode 100644 packages/ui/src/components/GlassPanel.tsx create mode 100644 packages/ui/src/components/Popover.tsx create mode 100644 packages/ui/src/components/Segmented.tsx create mode 100644 packages/ui/src/components/Sheet.tsx create mode 100644 packages/ui/src/components/Slider.tsx create mode 100644 packages/ui/src/components/Swatch.tsx create mode 100644 packages/ui/src/instruments/Instrument.tsx create mode 100644 packages/ui/src/styles.css create mode 100644 packages/ui/src/theme/useTheme.ts diff --git a/apps/web/src/features/canvas/ColorPicker.tsx b/apps/web/src/features/canvas/ColorPicker.tsx new file mode 100644 index 0000000..bae196c --- /dev/null +++ b/apps/web/src/features/canvas/ColorPicker.tsx @@ -0,0 +1,229 @@ +import { useMemo, useState, type RefObject } from "react"; +import { Segmented, Sheet, Slider, Swatch } from "@notux/ui"; +import { useDockStore } from "@notux/canvas"; +import { useSavedSwatches } from "./useSavedSwatches"; + +interface Props { + open: boolean; + onClose(): void; + anchorRef: RefObject; +} + +interface EyeDropperCtor { + new (): { open(): Promise<{ sRGBHex: string }> }; +} + +// ---- color helpers ------------------------------------------------------- + +function clamp255(n: number): number { + return Math.max(0, Math.min(255, Math.round(n))); +} + +function toHex(r: number, g: number, b: number): string { + return ( + "#" + + [r, g, b] + .map((c) => clamp255(c).toString(16).padStart(2, "0")) + .join("") + ); +} + +function hexToRgb(hex: string): { r: number; g: number; b: number } { + const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim()); + if (!m || !m[1]) return { r: 0, g: 0, b: 0 }; + const n = parseInt(m[1], 16); + return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 }; +} + +function hslToHex(h: number, s: number, l: number): string { + const a = s * Math.min(l, 1 - l); + const f = (n: number) => { + const k = (n + h / 30) % 12; + return l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1)); + }; + return toHex(f(0) * 255, f(8) * 255, f(4) * 255); +} + +const COLS = 12; +const COLOR_ROWS = 10; + +// iOS-style matrix: a grayscale top row, then hue columns over shade rows. +function buildColorGrid(): string[] { + const out: string[] = []; + for (let c = 0; c < COLS; c++) { + out.push(hslToHex(0, 0, 1 - c / (COLS - 1))); + } + for (let r = 0; r < COLOR_ROWS; r++) { + const l = (22 + (r * (88 - 22)) / (COLOR_ROWS - 1)) / 100; + for (let c = 0; c < COLS; c++) { + const h = (210 + c * 30) % 360; + out.push(hslToHex(h, 0.85, l)); + } + } + return out; +} + +// ---- component ----------------------------------------------------------- + +export function ColorPicker({ open, onClose, anchorRef }: Props) { + const color = useDockStore((s) => s.instruments[s.activeInstrumentId].color); + const opacity = useDockStore( + (s) => s.instruments[s.activeInstrumentId].opacity, + ); + const setColor = useDockStore((s) => s.setActiveColor); + const setOpacity = useDockStore((s) => s.setActiveOpacity); + const { swatches, addSwatch } = useSavedSwatches(); + const [tab, setTab] = useState<"Grid" | "Sliders">("Grid"); + + const grid = useMemo(buildColorGrid, []); + const rgb = useMemo(() => hexToRgb(color), [color]); + const lower = color.toLowerCase(); + + const hasEyeDropper = + typeof window !== "undefined" && "EyeDropper" in window; + + async function pickFromScreen() { + const Ctor = (window as unknown as { EyeDropper?: EyeDropperCtor }) + .EyeDropper; + if (!Ctor) return; + try { + const res = await new Ctor().open(); + setColor(res.sRGBHex); + } catch { + /* user dismissed the eyedropper */ + } + } + + return ( + +
+
+ {hasEyeDropper ? ( + + ) : ( + + )} +
Colors
+ +
+ + + options={["Grid", "Sliders"]} + value={tab} + onChange={setTab} + /> + + {tab === "Grid" ? ( +
+ {grid.map((c, i) => ( +
+ ) : ( +
+ {(["r", "g", "b"] as const).map((ch) => ( +
+ {ch.toUpperCase()} + + setColor(toHex( + ch === "r" ? Number(e.target.value) : rgb.r, + ch === "g" ? Number(e.target.value) : rgb.g, + ch === "b" ? Number(e.target.value) : rgb.b, + )) + } + aria-label={`${ch.toUpperCase()} channel`} + /> +
+ ))} +
+ # + { + const v = e.target.value; + if (/^#?[0-9a-f]{6}$/i.test(v)) { + setColor(v.startsWith("#") ? v : `#${v}`); + } + }} + aria-label="Hex color" + spellCheck={false} + maxLength={7} + /> +
+
+ )} + +
Opacity
+ + +
+
+
+ {swatches.map((c) => ( + setColor(c)} + /> + ))} + +
+
+
+ + ); +} diff --git a/apps/web/src/features/canvas/Dock.tsx b/apps/web/src/features/canvas/Dock.tsx new file mode 100644 index 0000000..d62b151 --- /dev/null +++ b/apps/web/src/features/canvas/Dock.tsx @@ -0,0 +1,248 @@ +import { useEffect, useRef } from "react"; +import { + GlassButton, + GlassPanel, + Instrument, + Popover, + Slider, + Swatch, +} from "@notux/ui"; +import type { ToolKind } from "@notux/types"; +import { + INSTRUMENT_IDS, + INSTRUMENT_MAP, + WIDTH_PRESETS, + useAssetStore, + useDockStore, + useToolStore, +} from "@notux/canvas"; +import { ColorPicker } from "./ColorPicker"; +import { ThemeToggle } from "./ThemeToggle"; + +const DRAWING_TOOLS: ReadonlySet = new Set([ + "pen", + "highlighter", + "eraser", +]); + +// A wavy stroke sample for the width-preset buttons (Frame 2). +function WidthSquiggle({ width }: { width: number }) { + const sw = Math.max(1.5, Math.min(11, width * 0.55)); + return ( + + + + ); +} + +const TRAY_TOOLS: { kind: ToolKind; glyph: string; label: string }[] = [ + { kind: "select", glyph: "↖", label: "Select" }, + { kind: "text", glyph: "T", label: "Text" }, + { kind: "rect", glyph: "▭", label: "Rectangle" }, + { kind: "ellipse", glyph: "◯", label: "Ellipse" }, + { kind: "line", glyph: "╱", label: "Line" }, + { kind: "arrow", glyph: "→", label: "Arrow" }, +]; + +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 colorPickerOpen = useDockStore((s) => s.colorPickerOpen); + const selectInstrument = useDockStore((s) => s.selectInstrument); + 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 setColorPickerOpen = useDockStore((s) => s.setColorPickerOpen); + + const tool = useToolStore((s) => s.tool); + const canImport = useAssetStore((s) => s.canImport); + + const drawing = DRAWING_TOOLS.has(tool); + const activeColor = instruments[activeId].color; + + const activeInstrRef = useRef(null); + const colorBtnRef = useRef(null); + const addBtnRef = useRef(null); + const fileRef = useRef(null); + + // 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. + useEffect(() => { + selectInstrument(useDockStore.getState().activeInstrumentId); + }, [selectInstrument]); + + function onInstrumentClick(id: (typeof INSTRUMENT_IDS)[number]) { + if (drawing && id === activeId) { + setWidthPopoverOpen(!widthPopoverOpen); + } else { + selectInstrument(id); + } + } + + return ( + <> + + {INSTRUMENT_IDS.map((id) => { + const isActive = drawing && id === activeId; + return ( + + ); + })} + + + + + 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). */} + setWidthPopoverOpen(false)} + anchorRef={activeInstrRef} + placement="top" + tail + > +
+ {WIDTH_PRESETS[activeId].map((w) => ( + + ))} +
+ {activeId !== "eraser" && ( + + )} +
+ + {/* "+" tray: the non-drawing tools + import + theme toggle. */} + setTrayOpen(false)} + anchorRef={addBtnRef} + placement="top" + tail + > +
+ {TRAY_TOOLS.map((t) => ( + + ))} +
+ + +
+ + + { + const files = e.target.files; + if (files && files.length > 0) { + void useAssetStore.getState().importAtCenter(files); + setTrayOpen(false); + } + e.target.value = ""; + }} + /> + + setColorPickerOpen(false)} + anchorRef={colorBtnRef} + /> + + ); +} diff --git a/apps/web/src/features/canvas/PageNavigator.tsx b/apps/web/src/features/canvas/PageNavigator.tsx new file mode 100644 index 0000000..e1dd6a0 --- /dev/null +++ b/apps/web/src/features/canvas/PageNavigator.tsx @@ -0,0 +1,140 @@ +import { useRef, useState } from "react"; +import { GlassPanel, Popover } from "@notux/ui"; +import { usePageStore } from "@notux/canvas"; + +export function PageNavigator() { + const pages = usePageStore((s) => 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/ThemeToggle.tsx b/apps/web/src/features/canvas/ThemeToggle.tsx new file mode 100644 index 0000000..565e0d0 --- /dev/null +++ b/apps/web/src/features/canvas/ThemeToggle.tsx @@ -0,0 +1,17 @@ +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/features/canvas/ToolPalette.tsx b/apps/web/src/features/canvas/ToolPalette.tsx deleted file mode 100644 index 28e0c49..0000000 --- a/apps/web/src/features/canvas/ToolPalette.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import { useRef } from "react"; -import type { ToolKind } from "@notux/types"; -import { useAssetStore, useToolStore } from "@notux/canvas"; -import { COLORS, SIZES } from "./palette"; - -interface ToolDef { - kind: ToolKind; - label: string; - glyph: string; -} - -const TOOLS: ToolDef[] = [ - { kind: "select", label: "Select", glyph: "↖" }, - { kind: "pen", label: "Pen", glyph: "✎" }, - { kind: "highlighter", label: "Highlighter", glyph: "▮" }, - { kind: "eraser", label: "Eraser", glyph: "⌫" }, - { kind: "rect", label: "Rectangle", glyph: "▭" }, - { kind: "ellipse", label: "Ellipse", glyph: "◯" }, - { kind: "line", label: "Line", glyph: "╱" }, - { kind: "arrow", label: "Arrow", glyph: "→" }, - { kind: "text", label: "Text", glyph: "T" }, -]; - -// Temporary palette — the real Liquid Glass dock lands in M7. -export function ToolPalette() { - const tool = useToolStore((s) => s.tool); - const options = useToolStore((s) => s.options); - const setTool = useToolStore((s) => s.setTool); - const setColor = useToolStore((s) => s.setColor); - const setSize = useToolStore((s) => s.setSize); - const canImport = useAssetStore((s) => s.canImport); - const fileRef = useRef(null); - - return ( -
-
- {TOOLS.map((t) => ( - - ))} - - { - const files = e.target.files; - if (files && files.length > 0) { - void useAssetStore.getState().importAtCenter(files); - } - e.target.value = ""; - }} - /> -
-
- {COLORS.map((c) => ( -
-
- {SIZES.map((s) => ( - - ))} -
-
- ); -} diff --git a/apps/web/src/features/canvas/useSavedSwatches.ts b/apps/web/src/features/canvas/useSavedSwatches.ts new file mode 100644 index 0000000..0ee6a26 --- /dev/null +++ b/apps/web/src/features/canvas/useSavedSwatches.ts @@ -0,0 +1,41 @@ +import { useCallback, useEffect, useState } from "react"; + +const KEY = "notux-swatches"; +const DEFAULTS = ["#000000", "#0a84ff", "#34c759", "#ffd60a", "#ff453a"]; +const MAX = 12; + +/** Persisted recently-saved color swatches (localStorage). */ +export function useSavedSwatches() { + const [swatches, setSwatches] = useState(() => { + try { + const raw = localStorage.getItem(KEY); + if (raw) { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed) && parsed.every((s) => typeof s === "string")) { + return parsed; + } + } + } catch { + /* ignore malformed storage */ + } + return DEFAULTS; + }); + + useEffect(() => { + try { + localStorage.setItem(KEY, JSON.stringify(swatches)); + } catch { + /* ignore quota / privacy-mode errors */ + } + }, [swatches]); + + const addSwatch = useCallback((color: string) => { + setSwatches((prev) => { + const hex = color.toLowerCase(); + if (prev.some((s) => s.toLowerCase() === hex)) return prev; + return [...prev, color].slice(-MAX); + }); + }, []); + + return { swatches, addSwatch }; +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 4912e5f..dc80df5 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,9 +1,15 @@ import React from "react"; import ReactDOM from "react-dom/client"; import { BrowserRouter } from "react-router-dom"; +import { applyTheme, getInitialTheme } from "@notux/ui"; import App from "./App"; +import "@notux/ui/styles.css"; import "./styles.css"; +// Set before the first paint so themed tokens apply with no +// flash (stored preference → OS preference). +applyTheme(getInitialTheme()); + const root = document.getElementById("root"); if (!root) throw new Error("Root element missing"); diff --git a/apps/web/src/routes/Board.tsx b/apps/web/src/routes/Board.tsx index ffbcbb5..dc5710a 100644 --- a/apps/web/src/routes/Board.tsx +++ b/apps/web/src/routes/Board.tsx @@ -2,13 +2,15 @@ import { useEffect, useState } from "react"; import { Link, useParams } from "react-router-dom"; import { CanvasStage, - DEFAULT_PAGE_ID, useAssetStore, + usePageStore, useShapeStore, } from "@notux/canvas"; +import { useTheme } from "@notux/ui"; +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 { ToolPalette } from "../features/canvas/ToolPalette"; import { useIdentity } from "../features/canvas/useIdentity"; import { getSupabase } from "../lib/supabase"; @@ -16,6 +18,8 @@ export default function Board() { const { boardId } = useParams<{ boardId: string }>(); const [ready, setReady] = useState(false); const identity = useIdentity(); + const activePageId = usePageStore((s) => s.activePageId); + const { theme } = useTheme(); useEffect(() => { if (!boardId) return; @@ -31,7 +35,11 @@ export default function Board() { useShapeStore .getState() .initBoard(boardId) - .then(() => setReady(true)); + .then(() => { + // Seed/migrate the page list against the IndexedDB-hydrated doc. + usePageStore.getState().initPages(boardId); + setReady(true); + }); }, [boardId, identity]); if (!ready) { @@ -62,9 +70,10 @@ export default function Board() { return (
- - - + + + + ← Home diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 3bfe97d..4a401c2 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -1,15 +1,8 @@ +/* Theme tokens live in @notux/ui's styles.css (imported first); this :root + only carries the shared font. */ :root { - --bg-0: #07090d; - --bg-1: #0e1218; - --fg-0: rgba(255, 255, 255, 0.92); - --fg-1: rgba(255, 255, 255, 0.62); - --accent: #5ac8fa; - --glass-tint: rgba(255, 255, 255, 0.10); - --glass-stroke: rgba(255, 255, 255, 0.22); - --shadow-lg: 0 24px 60px rgba(0, 0, 0, 0.45); font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Inter", system-ui, sans-serif; - color-scheme: dark; } * { @@ -19,7 +12,7 @@ html, body, #root { margin: 0; height: 100%; - background: radial-gradient(120% 80% at 20% 0%, #1a2540 0%, var(--bg-0) 60%); + background: var(--bg-0); color: var(--fg-0); overflow: hidden; } @@ -74,7 +67,7 @@ a { font-size: clamp(48px, 8vw, 96px); margin: 0; letter-spacing: -0.04em; - background: linear-gradient(180deg, #ffffff 0%, #b4c2d6 100%); + background: linear-gradient(180deg, var(--fg-0) 0%, var(--accent) 100%); -webkit-background-clip: text; background-clip: text; color: transparent; @@ -120,7 +113,7 @@ a { .home__auth-row input { flex: 1; - background: rgba(0, 0, 0, 0.25); + background: var(--field-bg); border: 1px solid var(--glass-stroke); color: var(--fg-0); border-radius: 999px; @@ -163,106 +156,12 @@ a { .board__canvas { position: absolute; inset: 0; - background: - radial-gradient(1px 1px at 24px 24px, rgba(255, 255, 255, 0.06) 1px, transparent 1px) 0 0 / 48px 48px, - var(--bg-1); + /* The world-space dot grid is drawn by the Konva BackgroundLayer; this is + just the solid whiteboard / dark surface behind it. */ + background: var(--canvas-bg); } -.tool-palette { - position: absolute; - left: 16px; - top: 50%; - transform: translateY(-50%); - display: flex; - flex-direction: column; - gap: 12px; - padding: 12px; - border-radius: 18px; - background: var(--glass-tint); - border: 1px solid var(--glass-stroke); - backdrop-filter: blur(28px) saturate(180%); - -webkit-backdrop-filter: blur(28px) saturate(180%); - box-shadow: var(--shadow-lg); - z-index: 5; -} - -.tool-palette__row { - display: grid; - grid-template-columns: repeat(3, 36px); - gap: 6px; -} - -.tool-palette__row--swatches, -.tool-palette__row--sizes { - grid-template-columns: repeat(6, 1fr); - padding-top: 8px; - border-top: 1px solid rgba(255, 255, 255, 0.10); -} - -.tool-palette__row--sizes { - grid-template-columns: repeat(4, 1fr); -} - -.tool-palette__chip { - appearance: none; - width: 36px; - height: 36px; - border-radius: 10px; - border: 1px solid transparent; - background: rgba(255, 255, 255, 0.05); - color: var(--fg-0); - font-size: 16px; - cursor: pointer; - display: grid; - place-items: center; - transition: background 150ms ease, border-color 150ms ease; -} - -.tool-palette__chip:hover { - background: rgba(255, 255, 255, 0.10); -} - -.tool-palette__chip--active { - background: rgba(90, 200, 250, 0.22); - border-color: rgba(90, 200, 250, 0.55); -} - -.tool-palette__swatch { - appearance: none; - width: 22px; - height: 22px; - border-radius: 50%; - border: 1px solid rgba(255, 255, 255, 0.25); - cursor: pointer; - padding: 0; -} - -.tool-palette__swatch--active { - outline: 2px solid var(--accent); - outline-offset: 1px; -} - -.tool-palette__size { - appearance: none; - display: grid; - place-items: center; - height: 28px; - border-radius: 8px; - border: 1px solid transparent; - background: rgba(255, 255, 255, 0.05); - cursor: pointer; -} - -.tool-palette__size span { - display: block; - background: var(--fg-0); - border-radius: 999px; -} - -.tool-palette__size--active { - background: rgba(90, 200, 250, 0.22); - border-color: rgba(90, 200, 250, 0.55); -} +/* The left ToolPalette was replaced by the bottom Liquid Glass Dock in M7. */ .selection-inspector { position: absolute; @@ -317,7 +216,7 @@ a { #ff453a 56%, transparent 56% ), - rgba(0, 0, 0, 0.25); + var(--field-bg); } .selection-inspector__range { @@ -327,7 +226,7 @@ a { .selection-inspector__num { width: 64px; - background: rgba(0, 0, 0, 0.25); + background: var(--field-bg); border: 1px solid var(--glass-stroke); color: var(--fg-0); border-radius: 8px; @@ -340,7 +239,7 @@ a { grid-template-columns: repeat(4, 1fr); gap: 6px; padding-top: 8px; - border-top: 1px solid rgba(255, 255, 255, 0.1); + border-top: 1px solid var(--glass-stroke); } .selection-inspector__btn { @@ -348,7 +247,7 @@ a { height: 30px; border-radius: 8px; border: 1px solid transparent; - background: rgba(255, 255, 255, 0.05); + background: var(--field-bg); color: var(--fg-0); font-size: 15px; cursor: pointer; @@ -358,13 +257,13 @@ a { } .selection-inspector__btn:hover { - background: rgba(255, 255, 255, 0.12); + background: var(--glass-hover); } .selection-inspector__lock { appearance: none; border: 1px solid var(--glass-stroke); - background: rgba(255, 255, 255, 0.05); + background: var(--field-bg); color: var(--fg-0); border-radius: 999px; padding: 8px 12px; @@ -374,7 +273,7 @@ a { } .selection-inspector__lock:hover { - background: rgba(255, 255, 255, 0.12); + background: var(--glass-hover); } .selection-inspector__lock--on { diff --git a/packages/canvas/src/CanvasStage.tsx b/packages/canvas/src/CanvasStage.tsx index 0221ddd..9a9e4ed 100644 --- a/packages/canvas/src/CanvasStage.tsx +++ b/packages/canvas/src/CanvasStage.tsx @@ -25,6 +25,9 @@ import { screenToWorld, zoomAt } from "./viewport/Viewport"; interface Props { boardId: string; pageId?: string; + // Changing this (the app's active theme) re-renders the Konva layers so they + // re-read CSS-variable colors via cssVar(). The value itself is unused. + theme?: string; } const ZOOM_PER_WHEEL_PIXEL = 0.0015; @@ -49,7 +52,11 @@ function isTypingTarget(t: EventTarget | null): boolean { return tag === "INPUT" || tag === "TEXTAREA" || t.isContentEditable; } -export function CanvasStage({ boardId: _boardId, pageId = DEFAULT_PAGE_ID }: Props) { +export function CanvasStage({ + boardId: _boardId, + pageId = DEFAULT_PAGE_ID, + theme: _theme, +}: Props) { const containerRef = useRef(null); const stageRef = useRef(null); const shapesLayerRef = useRef(null); diff --git a/packages/canvas/src/ids.ts b/packages/canvas/src/ids.ts index 09eaf38..1504873 100644 --- a/packages/canvas/src/ids.ts +++ b/packages/canvas/src/ids.ts @@ -11,3 +11,7 @@ export function newAuthorId(): string { export function newAssetId(): string { return nanoid(12); } + +export function newPageId(): string { + return "page-" + nanoid(10); +} diff --git a/packages/canvas/src/index.ts b/packages/canvas/src/index.ts index dc2b99d..f418d3c 100644 --- a/packages/canvas/src/index.ts +++ b/packages/canvas/src/index.ts @@ -8,5 +8,13 @@ export type { RealtimeConfig } from "./store/shapeStore"; export { useToolStore } from "./store/toolStore"; export type { ToolOptions } from "./store/toolStore"; export { useAssetStore } from "./store/assetStore"; -export { DEFAULT_PAGE_ID } from "./store/pageStore"; +export { DEFAULT_PAGE_ID, usePageStore } from "./store/pageStore"; +export type { PageMeta } from "./store/pageStore"; +export { + useDockStore, + INSTRUMENT_IDS, + INSTRUMENT_MAP, + WIDTH_PRESETS, +} from "./store/dockStore"; +export type { InstrumentId } from "./store/dockStore"; export type { ShapeStore } from "./store/shapeStore"; diff --git a/packages/canvas/src/layers/BackgroundLayer.tsx b/packages/canvas/src/layers/BackgroundLayer.tsx index 2b49789..4b5a63d 100644 --- a/packages/canvas/src/layers/BackgroundLayer.tsx +++ b/packages/canvas/src/layers/BackgroundLayer.tsx @@ -1,5 +1,6 @@ import { useMemo } from "react"; import { Circle, Layer } from "react-konva"; +import { cssVar } from "../theme/cssVar"; import type { ViewportState } from "../viewport/Viewport"; interface Props { @@ -34,6 +35,8 @@ export function BackgroundLayer({ viewport, width, height }: Props) { return out; }, [viewport.x, viewport.y, viewport.scale, width, height]); + const dotFill = cssVar("--canvas-dot", "rgba(255, 255, 255, 0.10)"); + return ( {dots.map((d, i) => ( @@ -42,7 +45,7 @@ export function BackgroundLayer({ viewport, width, height }: Props) { x={d.x} y={d.y} radius={1 / viewport.scale} - fill="rgba(255, 255, 255, 0.10)" + fill={dotFill} /> ))} diff --git a/packages/canvas/src/layers/OverlayLayer.tsx b/packages/canvas/src/layers/OverlayLayer.tsx index 1369d10..ff017dc 100644 --- a/packages/canvas/src/layers/OverlayLayer.tsx +++ b/packages/canvas/src/layers/OverlayLayer.tsx @@ -3,6 +3,7 @@ import { Arrow, Ellipse, Layer, Line, Rect } from "react-konva"; import type { DraftStore } from "../store/draftStore"; import { strokeOutline } from "../renderers/strokeGeometry"; import { shapeBounds } from "../tools/shapeOps"; +import { cssVar } from "../theme/cssVar"; import type { ViewportState } from "../viewport/Viewport"; interface Props { @@ -18,6 +19,8 @@ interface Props { // between gestures by the tool that produced the draft. export function OverlayLayer({ draft, selectedShapes, viewport }: Props) { const handleSize = 6 / viewport.scale; + const selection = cssVar("--selection", "#5ac8fa"); + const selectionFill = cssVar("--selection-fill", "rgba(90, 200, 250, 0.10)"); return ( @@ -25,12 +28,16 @@ export function OverlayLayer({ draft, selectedShapes, viewport }: Props) { const flat = strokeOutline(draft.stroke.points, draft.stroke.pressure, { size: draft.stroke.size, }); + const isHighlighter = + draft.stroke.tool === "highlighter" || + draft.stroke.style === "highlighter"; return ( @@ -90,8 +97,8 @@ export function OverlayLayer({ draft, selectedShapes, viewport }: Props) { y={draft.marquee.y} width={draft.marquee.w} height={draft.marquee.h} - stroke="#5ac8fa" - fill="rgba(90, 200, 250, 0.10)" + stroke={selection} + fill={selectionFill} strokeWidth={1 / viewport.scale} dash={[6 / viewport.scale, 4 / viewport.scale]} /> @@ -107,7 +114,7 @@ export function OverlayLayer({ draft, selectedShapes, viewport }: Props) { y={b.y - handleSize} width={b.w + handleSize * 2} height={b.h + handleSize * 2} - stroke={shape.locked ? "#ffd60a" : "#5ac8fa"} + stroke={shape.locked ? "#ffd60a" : selection} strokeWidth={1.5 / viewport.scale} dash={[5 / viewport.scale, 3 / viewport.scale]} /> diff --git a/packages/canvas/src/renderers/StrokeRenderer.tsx b/packages/canvas/src/renderers/StrokeRenderer.tsx index b0380e2..7251514 100644 --- a/packages/canvas/src/renderers/StrokeRenderer.tsx +++ b/packages/canvas/src/renderers/StrokeRenderer.tsx @@ -1,16 +1,22 @@ import type { YStroke } from "@notux/types"; import { Line } from "react-konva"; import { strokeOutline } from "./strokeGeometry"; +import { cssVar } from "../theme/cssVar"; interface Props { shape: YStroke; selected?: boolean; } +// Opacity is applied one level up on the wrapping (ShapesLayer), so it +// is NOT set here to avoid double-application. This renderer only varies the +// style-specific paint (highlighters multiply so overlaps deepen). export function StrokeRenderer({ shape, selected }: Props) { const flat = strokeOutline(shape.points, shape.pressure, { size: shape.size, }); + const isHighlighter = + shape.tool === "highlighter" || shape.style === "highlighter"; return ( = { + pen: { toolKind: "pen", style: "pen", color: "#1c1c1e", width: 4, opacity: 1 }, + fineliner: { + toolKind: "pen", + style: "fineliner", + color: "#1c1c1e", + width: 2, + opacity: 1, + }, + highlighter: { + toolKind: "highlighter", + style: "highlighter", + color: "#ffd60a", + width: 16, + opacity: 0.35, + }, + eraser: { + toolKind: "eraser", + style: "pen", + color: "#1c1c1e", + width: 16, + opacity: 1, + }, + pencil: { + toolKind: "pen", + style: "pencil", + color: "#1c1c1e", + width: 3, + opacity: 0.9, + }, + marker: { + toolKind: "pen", + style: "marker", + color: "#1c1c1e", + width: 10, + opacity: 1, + }, +}; + +// The five width samples shown in the dock popover (Frame 2), per instrument. +export const WIDTH_PRESETS: Record = { + pen: [2, 4, 6, 9, 13], + fineliner: [1, 2, 3, 4, 6], + highlighter: [10, 16, 22, 30, 40], + eraser: [8, 16, 24, 36, 50], + pencil: [2, 3, 5, 7, 10], + marker: [6, 10, 14, 20, 28], +}; + +interface InstrumentState { + color: string; + width: number; + opacity: number; +} + +interface DockStoreState { + instruments: Record; + activeInstrumentId: InstrumentId; + trayOpen: boolean; + widthPopoverOpen: boolean; + colorPickerOpen: boolean; + selectInstrument(id: InstrumentId): void; + setActiveColor(color: string): void; + setActiveWidth(width: number): void; + setActiveOpacity(opacity: number): void; + setTrayOpen(open: boolean): void; + setWidthPopoverOpen(open: boolean): void; + setColorPickerOpen(open: boolean): void; +} + +function initialInstruments(): Record { + const out = {} as Record; + for (const id of INSTRUMENT_IDS) { + const d = INSTRUMENT_MAP[id]; + out[id] = { color: d.color, width: d.width, opacity: d.opacity }; + } + 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) { + const def = INSTRUMENT_MAP[id]; + const tool = useToolStore.getState(); + tool.setTool(def.toolKind); + tool.setOptions({ + color: inst.color, + size: inst.width, + opacity: inst.opacity, + style: def.style, + }); +} + +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]); + }, + + setActiveColor(color) { + const id = get().activeInstrumentId; + const inst = { ...get().instruments[id], color }; + set({ instruments: { ...get().instruments, [id]: inst } }); + pushToTool(id, inst); + }, + + setActiveWidth(width) { + const id = get().activeInstrumentId; + const inst = { ...get().instruments[id], width }; + set({ instruments: { ...get().instruments, [id]: inst } }); + pushToTool(id, inst); + }, + + setActiveOpacity(opacity) { + const id = get().activeInstrumentId; + const inst = { ...get().instruments[id], opacity }; + set({ instruments: { ...get().instruments, [id]: inst } }); + pushToTool(id, inst); + }, + + setTrayOpen(open) { + set({ trayOpen: open }); + }, + setWidthPopoverOpen(open) { + set({ widthPopoverOpen: open }); + }, + setColorPickerOpen(open) { + set({ colorPickerOpen: open }); + }, +})); diff --git a/packages/canvas/src/store/draftStore.ts b/packages/canvas/src/store/draftStore.ts index 66b9ce6..7c27084 100644 --- a/packages/canvas/src/store/draftStore.ts +++ b/packages/canvas/src/store/draftStore.ts @@ -1,9 +1,12 @@ +import type { StrokeStyle } from "@notux/types"; import { create } from "zustand"; export interface DraftStroke { tool: "pen" | "highlighter"; + style: StrokeStyle; color: string; size: number; + opacity: number; points: number[]; pressure: number[]; } diff --git a/packages/canvas/src/store/pageStore.ts b/packages/canvas/src/store/pageStore.ts index 5c9cc68..dccbab7 100644 --- a/packages/canvas/src/store/pageStore.ts +++ b/packages/canvas/src/store/pageStore.ts @@ -1 +1,123 @@ +import type * as Y from "yjs"; +import { create } from "zustand"; +import { + getBoardDoc, + getPageList, + readPageList, + ensureSeedPage, + addPageEntry, + removePageEntry, + renamePageEntry, + movePageEntry, +} from "@notux/sync"; +import { newPageId } from "../ids"; + export const DEFAULT_PAGE_ID = "page-0"; + +export interface PageMeta { + id: string; + title: string; +} + +interface PageStoreState { + // Bumps on every page-list mutation so React selectors re-render. + revision: number; + _doc: Y.Doc | null; + _unobserve: (() => void) | null; + // The ordered, synced list of pages. + pages: PageMeta[]; + // The page this client is viewing. LOCAL per-user — never written to Yjs. + activePageId: string; + _bump(): void; + + // Bind to a board's Yjs doc and seed/migrate the page list. Idempotent per + // board (handles StrictMode double-invoke). Call after shapeStore.initBoard + // has resolved so the doc is hydrated from IndexedDB first. + initPages(boardId: string): void; + + addPage(): string; + deletePage(id: string): void; + renamePage(id: string, title: string): void; + reorderPage(fromIdx: number, toIdx: number): void; + setActivePage(id: string): void; +} + +export const usePageStore = create((set, get) => ({ + revision: 0, + _doc: null, + _unobserve: null, + pages: [{ id: DEFAULT_PAGE_ID, title: "Page 1" }], + activePageId: DEFAULT_PAGE_ID, + + _bump() { + set((s) => ({ revision: s.revision + 1 })); + }, + + initPages(boardId) { + const doc = getBoardDoc(boardId); + // Already bound to this board's doc — nothing to do. + if (get()._doc === doc) return; + get()._unobserve?.(); + + ensureSeedPage(doc, DEFAULT_PAGE_ID); + const list = getPageList(doc); + + const refresh = () => { + const pages = readPageList(doc); + set((s) => { + const valid = pages.some((p) => p.id === s.activePageId); + return { + pages, + activePageId: valid + ? s.activePageId + : (pages[0]?.id ?? DEFAULT_PAGE_ID), + }; + }); + get()._bump(); + }; + + const handler = () => refresh(); + list.observeDeep(handler); + set({ _doc: doc, _unobserve: () => list.unobserveDeep(handler) }); + refresh(); + }, + + addPage() { + const doc = get()._doc; + if (!doc) return DEFAULT_PAGE_ID; + const id = newPageId(); + addPageEntry(doc, id, `Page ${get().pages.length + 1}`); + return id; + }, + + deletePage(id) { + const doc = get()._doc; + if (!doc) return; + const pages = get().pages; + if (pages.length <= 1) return; // keep at least one page + const idx = pages.findIndex((p) => p.id === id); + const neighbor = + pages[idx + 1]?.id ?? + pages[idx - 1]?.id ?? + pages[0]?.id ?? + DEFAULT_PAGE_ID; + removePageEntry(doc, id); + if (get().activePageId === id) set({ activePageId: neighbor }); + }, + + renamePage(id, title) { + const doc = get()._doc; + if (!doc) return; + renamePageEntry(doc, id, title || "Page"); + }, + + reorderPage(fromIdx, toIdx) { + const doc = get()._doc; + if (!doc) return; + movePageEntry(doc, fromIdx, toIdx); + }, + + setActivePage(id) { + set({ activePageId: id }); + }, +})); diff --git a/packages/canvas/src/store/toolStore.ts b/packages/canvas/src/store/toolStore.ts index f6ca75e..8fbb425 100644 --- a/packages/canvas/src/store/toolStore.ts +++ b/packages/canvas/src/store/toolStore.ts @@ -1,10 +1,14 @@ -import type { ToolKind } from "@notux/types"; +import type { StrokeStyle, ToolKind } from "@notux/types"; import { create } from "zustand"; export interface ToolOptions { color: string; size: number; fill: string | null; + // 0..1. Applied to new strokes (M7 dock). 1 = fully opaque. + opacity: number; + // Instrument variant the active tool draws with (pen by default). + style: StrokeStyle; } interface ToolStoreState { @@ -15,14 +19,21 @@ interface ToolStoreState { setColor(color: string): void; setSize(size: number): void; setFill(fill: string | null): void; + setOpacity(opacity: number): void; + setStyle(style: StrokeStyle): void; + // Atomic multi-field update — used by the dock to push a whole instrument + // preset (color + width + opacity + style) in one shot. + setOptions(patch: Partial): void; setSelection(ids: Iterable): void; clearSelection(): void; } const DEFAULT_OPTIONS: ToolOptions = { - color: "#ffffff", + color: "#1c1c1e", size: 4, fill: null, + opacity: 1, + style: "pen", }; export const useToolStore = create((set) => ({ @@ -47,6 +58,15 @@ export const useToolStore = create((set) => ({ setFill(fill) { set((s) => ({ options: { ...s.options, fill } })); }, + setOpacity(opacity) { + set((s) => ({ options: { ...s.options, opacity } })); + }, + setStyle(style) { + set((s) => ({ options: { ...s.options, style } })); + }, + setOptions(patch) { + set((s) => ({ options: { ...s.options, ...patch } })); + }, setSelection(ids) { set({ selection: new Set(ids) }); }, diff --git a/packages/canvas/src/theme/cssVar.ts b/packages/canvas/src/theme/cssVar.ts new file mode 100644 index 0000000..16a6244 --- /dev/null +++ b/packages/canvas/src/theme/cssVar.ts @@ -0,0 +1,12 @@ +// Konva can't read CSS variables, so layers/renderers resolve theme tokens at +// render time. Components re-read this whenever they re-render (CanvasStage is +// given a changing `theme` prop on toggle, which cascades a re-render). +export function cssVar(name: string, fallback: string): string { + if (typeof window === "undefined" || typeof document === "undefined") { + return fallback; + } + const v = getComputedStyle(document.documentElement) + .getPropertyValue(name) + .trim(); + return v || fallback; +} diff --git a/packages/canvas/src/tools/PenTool.ts b/packages/canvas/src/tools/PenTool.ts index be4416e..75c6b3c 100644 --- a/packages/canvas/src/tools/PenTool.ts +++ b/packages/canvas/src/tools/PenTool.ts @@ -1,4 +1,4 @@ -import type { YStroke } from "@notux/types"; +import type { StrokeStyle, YStroke } from "@notux/types"; import { newShapeId } from "../ids"; import type { Tool, ToolContext, ToolEventPoint } from "./types"; @@ -8,9 +8,11 @@ interface State { pressure: number[]; } -// PenTool also drives the highlighter — the only difference is opacity, which -// the renderer picks up from YStroke.tool. Passing tool: "highlighter" lets one -// implementation back both ToolKinds. +// PenTool also drives the highlighter — the discriminator is `tool`, while the +// finer `style` (pen / fineliner / pencil / marker) comes from the dock via +// ToolOptions and only affects rendering. Opacity is written per-stroke so the +// dock's opacity control is honored (highlighter still defaults to 0.35 when no +// explicit opacity flows through). export function makePenTool(tool: "pen" | "highlighter"): Tool { const state: State = { active: false, points: [], pressure: [] }; @@ -26,30 +28,34 @@ export function makePenTool(tool: "pen" | "highlighter"): Tool { state.pressure.push(p.pressure); } + function styleFor(ctx: ToolContext): StrokeStyle { + return tool === "highlighter" ? "highlighter" : ctx.options.style; + } + + function paintDraft(ctx: ToolContext) { + ctx.draftStore.setStroke({ + tool, + style: styleFor(ctx), + color: ctx.options.color, + size: ctx.options.size, + opacity: ctx.options.opacity, + points: state.points, + pressure: state.pressure, + }); + } + return { - cursor: tool === "highlighter" ? "crosshair" : "crosshair", + cursor: "crosshair", onPointerDown(p, ctx) { state.active = true; state.points = [p.x, p.y]; state.pressure = [p.pressure]; - ctx.draftStore.setStroke({ - tool, - color: ctx.options.color, - size: ctx.options.size * (tool === "highlighter" ? 4 : 1), - points: state.points, - pressure: state.pressure, - }); + paintDraft(ctx); }, onPointerMove(p, ctx) { if (!state.active) return; pushPoint(p); - ctx.draftStore.setStroke({ - tool, - color: ctx.options.color, - size: ctx.options.size * (tool === "highlighter" ? 4 : 1), - points: state.points, - pressure: state.pressure, - }); + paintDraft(ctx); }, onPointerUp(p, ctx) { if (!state.active) return; @@ -59,8 +65,10 @@ export function makePenTool(tool: "pen" | "highlighter"): Tool { author: ctx.authorId, kind: "stroke", tool, + style: styleFor(ctx), color: ctx.options.color, - size: ctx.options.size * (tool === "highlighter" ? 4 : 1), + size: ctx.options.size, + opacity: ctx.options.opacity, points: state.points, pressure: state.pressure, }; diff --git a/packages/sync/src/index.ts b/packages/sync/src/index.ts index d635701..2d35809 100644 --- a/packages/sync/src/index.ts +++ b/packages/sync/src/index.ts @@ -1,6 +1,16 @@ export { getBoardDoc } from "./boardDoc"; export { getIndexedDbProvider } from "./indexedDbProvider"; export { findPageMap, getPageMap } from "./pageMap"; +export { + getPageList, + readPageList, + ensureSeedPage, + addPageEntry, + removePageEntry, + renamePageEntry, + movePageEntry, +} from "./pageList"; +export type { PageEntry } from "./pageList"; export { SupabaseProvider, getSupabaseProvider, diff --git a/packages/sync/src/pageList.ts b/packages/sync/src/pageList.ts new file mode 100644 index 0000000..b85e15d --- /dev/null +++ b/packages/sync/src/pageList.ts @@ -0,0 +1,99 @@ +import * as Y from "yjs"; + +// The ordered list of pages lives in a Y.Array("pageList") of small Y.Maps +// ({ id, title }), separate from the per-page shape maps in getMap("pages"). +// Array order == page order. Shapes for a page id keep living under +// getMap("pages").get(id) (see pageMap.ts) — this list just names and orders +// them so create / rename / delete / reorder sync across clients. + +export interface PageEntry { + id: string; + title: string; +} + +export function getPageList(doc: Y.Doc): Y.Array> { + return doc.getArray>("pageList"); +} + +function makeEntry(id: string, title: string): Y.Map { + const m = new Y.Map(); + m.set("id", id); + m.set("title", title); + return m; +} + +export function readPageList(doc: Y.Doc): PageEntry[] { + return getPageList(doc) + .toArray() + .map((m) => ({ id: m.get("id") ?? "", title: m.get("title") ?? "Page" })) + .filter((p) => p.id !== ""); +} + +/** + * Seed the page list on first run. Idempotent: a no-op once the list is + * populated. Migrates existing boards (whose shapes already live under one or + * more page ids in getMap("pages")) by listing those ids so their content + * stays selectable; otherwise creates a single fresh page with `seedId`. + */ +export function ensureSeedPage( + doc: Y.Doc, + seedId: string, + seedTitle = "Page 1", +): void { + const list = getPageList(doc); + if (list.length > 0) return; + doc.transact(() => { + if (list.length > 0) return; // guard against a racing transaction + const existingIds = Array.from( + doc.getMap("pages").keys(), + ); + if (existingIds.length === 0) { + list.push([makeEntry(seedId, seedTitle)]); + return; + } + const ordered = existingIds.includes(seedId) + ? [seedId, ...existingIds.filter((id) => id !== seedId)] + : existingIds; + list.push(ordered.map((id, i) => makeEntry(id, `Page ${i + 1}`))); + }); +} + +export function addPageEntry(doc: Y.Doc, id: string, title: string): void { + doc.transact(() => getPageList(doc).push([makeEntry(id, title)])); +} + +export function removePageEntry(doc: Y.Doc, id: string): void { + doc.transact(() => { + const list = getPageList(doc); + const idx = list.toArray().findIndex((m) => m.get("id") === id); + if (idx >= 0) list.delete(idx, 1); + // Drop the page's shapes too. + doc.getMap("pages").delete(id); + }); +} + +export function renamePageEntry(doc: Y.Doc, id: string, title: string): void { + doc.transact(() => { + const list = getPageList(doc); + for (let i = 0; i < list.length; i++) { + const m = list.get(i); + if (m.get("id") === id) { + m.set("title", title); + return; + } + } + }); +} + +export function movePageEntry(doc: Y.Doc, fromIdx: number, toIdx: number): void { + doc.transact(() => { + const list = getPageList(doc); + if (fromIdx < 0 || fromIdx >= list.length) return; + const m = list.get(fromIdx); + const id = m.get("id") ?? ""; + const title = m.get("title") ?? "Page"; + list.delete(fromIdx, 1); + const insertAt = Math.max(0, Math.min(toIdx, list.length)); + list.insert(insertAt, [makeEntry(id, title)]); + }); +} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index d127021..067d851 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -13,4 +13,5 @@ export type { YShape, YShapeKind, ToolKind, + StrokeStyle, } from "./yshape"; diff --git a/packages/types/src/yshape.ts b/packages/types/src/yshape.ts index 6fcaf52..8acc0b7 100644 --- a/packages/types/src/yshape.ts +++ b/packages/types/src/yshape.ts @@ -9,6 +9,17 @@ export type ToolKind = | "text" | "select"; +// Visual variant of a freehand stroke. The canvas/tool discriminator stays +// `tool` ("pen" | "highlighter"); `style` is the finer instrument look the +// Liquid Glass dock writes (M7). Optional → strokes from before M7 render as +// plain pens. +export type StrokeStyle = + | "pen" + | "fineliner" + | "pencil" + | "marker" + | "highlighter"; + export interface YShapeBase { id: string; author: string; @@ -22,6 +33,9 @@ export interface YShapeBase { export interface YStroke extends YShapeBase { kind: "stroke"; tool: "pen" | "highlighter"; + // Instrument variant for rendering (caps, blend, texture). Optional for + // back-compat with strokes authored before M7. + style?: StrokeStyle; color: string; size: number; points: number[]; diff --git a/packages/ui/package.json b/packages/ui/package.json index 7663b3c..59ee6e8 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -6,12 +6,24 @@ "main": "./src/index.ts", "types": "./src/index.ts", "exports": { - ".": "./src/index.ts" + ".": "./src/index.ts", + "./styles.css": "./src/styles.css" }, "scripts": { "typecheck": "tsc -p tsconfig.json --noEmit" }, + "dependencies": { + "@notux/types": "workspace:*" + }, + "peerDependencies": { + "react": "^18.3.0", + "react-dom": "^18.3.0" + }, "devDependencies": { + "@types/react": "^18.3.10", + "@types/react-dom": "^18.3.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", "typescript": "^5.6.2" } } diff --git a/packages/ui/src/components/GlassButton.tsx b/packages/ui/src/components/GlassButton.tsx new file mode 100644 index 0000000..1b2f55d --- /dev/null +++ b/packages/ui/src/components/GlassButton.tsx @@ -0,0 +1,32 @@ +import type { ButtonHTMLAttributes, CSSProperties, ReactNode } from "react"; + +interface Props extends ButtonHTMLAttributes { + children?: ReactNode; + /** Circular icon button. */ + round?: boolean; + /** Accent highlight (e.g. an active tool). */ + active?: boolean; + className?: string; + style?: CSSProperties; +} + +/** Pill / round Liquid Glass button. */ +export function GlassButton({ + children, + round, + active, + className, + type = "button", + ...rest +}: Props) { + const cls = + "glass-button" + + (round ? " glass-button--round" : "") + + (active ? " glass-button--active" : "") + + (className ? ` ${className}` : ""); + return ( + + ); +} diff --git a/packages/ui/src/components/GlassPanel.tsx b/packages/ui/src/components/GlassPanel.tsx new file mode 100644 index 0000000..7bbc3bb --- /dev/null +++ b/packages/ui/src/components/GlassPanel.tsx @@ -0,0 +1,22 @@ +import type { CSSProperties, ReactNode } from "react"; + +interface Props { + children?: ReactNode; + className?: string; + style?: CSSProperties; + role?: string; + "aria-label"?: string; +} + +/** Frosted translucent container — the base material for docks and panels. */ +export function GlassPanel({ children, className, style, ...rest }: Props) { + return ( +
+ {children} +
+ ); +} diff --git a/packages/ui/src/components/Popover.tsx b/packages/ui/src/components/Popover.tsx new file mode 100644 index 0000000..31fb2f7 --- /dev/null +++ b/packages/ui/src/components/Popover.tsx @@ -0,0 +1,118 @@ +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, + type ReactNode, + type RefObject, +} from "react"; +import { createPortal } from "react-dom"; + +interface Props { + open: boolean; + onClose(): void; + /** Element the popover floats above (or below). */ + anchorRef: RefObject; + placement?: "top" | "bottom"; + /** Draw a speech-bubble tail pointing at the anchor (top placement only). */ + tail?: boolean; + className?: string; + children?: ReactNode; +} + +const MARGIN = 8; + +/** Floating panel anchored to an element, portalled above the canvas. */ +export function Popover({ + open, + onClose, + anchorRef, + placement = "top", + tail, + className, + children, +}: Props) { + const ref = useRef(null); + const [pos, setPos] = useState<{ left: number; top: number; tailX: number }>({ + left: -9999, + top: -9999, + tailX: 0, + }); + + const reposition = useCallback(() => { + const anchor = anchorRef.current; + const el = ref.current; + if (!anchor || !el) return; + const a = anchor.getBoundingClientRect(); + const r = el.getBoundingClientRect(); + const anchorCenter = a.left + a.width / 2; + let left = anchorCenter - r.width / 2; + left = Math.max(MARGIN, Math.min(left, window.innerWidth - r.width - MARGIN)); + const top = + placement === "top" ? a.top - r.height - 12 : a.bottom + 12; + setPos({ left, top, tailX: anchorCenter - left }); + }, [anchorRef, placement]); + + useLayoutEffect(() => { + if (!open) return; + reposition(); + // A second pass next frame once children (and fonts) have settled. + const id = requestAnimationFrame(reposition); + return () => cancelAnimationFrame(id); + }, [open, reposition, children]); + + useEffect(() => { + if (!open) return; + const onScrollResize = () => reposition(); + window.addEventListener("resize", onScrollResize); + window.addEventListener("scroll", onScrollResize, true); + function onKey(e: KeyboardEvent) { + if (e.key === "Escape") onClose(); + } + function onDown(e: MouseEvent) { + const t = e.target as Node; + if (ref.current?.contains(t)) return; + if (anchorRef.current?.contains(t)) return; + onClose(); + } + window.addEventListener("keydown", onKey); + // Defer so the click that opened the popover doesn't immediately close it. + const id = window.setTimeout( + () => window.addEventListener("mousedown", onDown), + 0, + ); + return () => { + window.removeEventListener("resize", onScrollResize); + window.removeEventListener("scroll", onScrollResize, true); + window.removeEventListener("keydown", onKey); + window.removeEventListener("mousedown", onDown); + window.clearTimeout(id); + }; + }, [open, reposition, onClose, anchorRef]); + + if (!open) return null; + + const cls = + "glass-popover" + + (tail && placement === "top" ? " glass-popover--tail-bottom" : "") + + (className ? ` ${className}` : ""); + + return createPortal( +
+ {children} +
, + document.body, + ); +} diff --git a/packages/ui/src/components/Segmented.tsx b/packages/ui/src/components/Segmented.tsx new file mode 100644 index 0000000..b518faf --- /dev/null +++ b/packages/ui/src/components/Segmented.tsx @@ -0,0 +1,37 @@ +interface Props { + options: readonly T[]; + value: T; + onChange(value: T): void; + className?: string; +} + +/** iOS-style segmented control. */ +export function Segmented({ + options, + value, + onChange, + className, +}: Props) { + return ( +
+ {options.map((opt) => ( + + ))} +
+ ); +} diff --git a/packages/ui/src/components/Sheet.tsx b/packages/ui/src/components/Sheet.tsx new file mode 100644 index 0000000..24febc6 --- /dev/null +++ b/packages/ui/src/components/Sheet.tsx @@ -0,0 +1,58 @@ +import { + useEffect, + useState, + type ReactNode, + type RefObject, +} from "react"; +import { createPortal } from "react-dom"; +import { Popover } from "./Popover"; + +interface Props { + open: boolean; + onClose(): void; + anchorRef: RefObject; + className?: string; + children?: ReactNode; +} + +function useIsNarrow(): boolean { + const [narrow, setNarrow] = useState( + () => + typeof window !== "undefined" && + window.matchMedia("(max-width: 640px)").matches, + ); + useEffect(() => { + const mq = window.matchMedia("(max-width: 640px)"); + const on = () => setNarrow(mq.matches); + mq.addEventListener("change", on); + return () => mq.removeEventListener("change", on); + }, []); + return narrow; +} + +/** + * Responsive container: a bottom sheet on narrow screens, an anchored + * Popover on wide ones. Used for the color picker. + */ +export function Sheet({ open, onClose, anchorRef, className, children }: Props) { + const narrow = useIsNarrow(); + + if (narrow) { + if (!open) return null; + return createPortal( + <> +
+
+ {children} +
+ , + document.body, + ); + } + + return ( + + {children} + + ); +} diff --git a/packages/ui/src/components/Slider.tsx b/packages/ui/src/components/Slider.tsx new file mode 100644 index 0000000..f713807 --- /dev/null +++ b/packages/ui/src/components/Slider.tsx @@ -0,0 +1,58 @@ +import type { CSSProperties } from "react"; + +interface Props { + /** 0..1 */ + value: number; + onChange(value: number): void; + /** "opacity" draws a checkerboard + colored gradient track and a value box. */ + trackStyle?: "plain" | "opacity"; + /** The color the opacity gradient fades toward (the active ink color). */ + color?: string; + /** Show a percentage value box on the right (opacity track). */ + showValue?: boolean; + "aria-label"?: string; +} + +/** Generic 0..1 slider; the "opacity" variant matches the iOS opacity track. */ +export function Slider({ + value, + onChange, + trackStyle = "plain", + color = "#000000", + showValue = trackStyle === "opacity", + ...rest +}: Props) { + const pct = Math.round(value * 100); + const fill: CSSProperties = + trackStyle === "opacity" + ? { + background: `linear-gradient(90deg, transparent, ${color})`, + } + : { + background: `linear-gradient(90deg, var(--accent) ${pct}%, var(--field-bg) ${pct}%)`, + }; + + return ( +
+
+
+
+ onChange(Number(e.target.value) / 100)} + aria-label={rest["aria-label"]} + /> +
+ {showValue &&
{pct}%
} +
+ ); +} diff --git a/packages/ui/src/components/Swatch.tsx b/packages/ui/src/components/Swatch.tsx new file mode 100644 index 0000000..7a50ad2 --- /dev/null +++ b/packages/ui/src/components/Swatch.tsx @@ -0,0 +1,53 @@ +import type { CSSProperties } from "react"; + +interface Props { + /** A CSS color, or "none" for the diagonal no-fill chip. */ + color: string; + selected?: boolean; + /** Rainbow ring with the color in the center (the dock's color button). */ + rainbow?: boolean; + size?: number; + onClick?(): void; + title?: string; + "aria-label"?: string; + className?: string; +} + +/** Round color tile used in palettes, saved-swatch rows and the dock. */ +export function Swatch({ + color, + selected, + rainbow, + size = 24, + onClick, + title, + className, + ...rest +}: Props) { + const cls = + "swatch" + + (rainbow ? " swatch--rainbow" : "") + + (color === "none" ? " swatch--none" : "") + + (selected ? " swatch--selected" : "") + + (className ? ` ${className}` : ""); + const style: CSSProperties = { + width: size, + height: size, + ...(rainbow + ? ({ ["--swatch-color" as string]: color } as CSSProperties) + : color === "none" + ? {} + : { background: color }), + }; + return ( +