diff --git a/README.md b/README.md index bf32bf3..52dfc59 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,12 @@ A collaborative infinite whiteboard for teaching — pen, shapes, text, PDFs, re ## Status -Milestone 4 — Realtime collaboration. The canvas (M2), local Yjs persistence + undo/redo (M3), and now realtime multiplayer sync are in place: edits and live cursors sync between everyone on a board over Supabase Realtime, with the board falling back to local-only mode when Supabase isn't configured. PDF import and the Liquid Glass dock land in later milestones. +Realtime multiplayer, PDF/image/audio import, YouTube/Drive embeds, the Liquid +Glass dock, durable autosave, and the M-A redesign tier (universal resize + +smart alignment, contextual selection toolbar, two-layer color system, vector +SF Symbols, and the folder-based board library) are in place. The product-wide +UX redesign — audit, design system, interaction specs, benchmarks, and the +prioritised roadmap — lives in [`docs/redesign/`](docs/redesign/README.md). Realtime requires Supabase Realtime to be reachable by the `anon` role for the `notux-board-*` broadcast topics (the default, no-authorization Realtime mode works out of the box). Late-joiners get current board state from connected peers; persisting snapshots server-side for offline late-joiners is a later milestone. diff --git a/apps/web/package.json b/apps/web/package.json index 781faaf..dfdf061 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -20,7 +20,8 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-konva": "^18.2.10", - "react-router-dom": "^6.26.2" + "react-router-dom": "^6.26.2", + "zustand": "^4.5.5" }, "devDependencies": { "@types/react": "^18.3.10", diff --git a/apps/web/src/features/canvas/ColorPicker.tsx b/apps/web/src/features/canvas/ColorPicker.tsx index bf3f5ff..9ff9cc8 100644 --- a/apps/web/src/features/canvas/ColorPicker.tsx +++ b/apps/web/src/features/canvas/ColorPicker.tsx @@ -1,7 +1,8 @@ -import { useMemo, type RefObject } from "react"; +import { useState, type RefObject } from "react"; import { Icon, Sheet, Slider, Swatch } from "@notux/ui"; import { useDockStore } from "@notux/canvas"; import { useSavedSwatches } from "./useSavedSwatches"; +import { recordRecentColor, useRecentColors } from "./useRecentColors"; import { COLORS } from "./palette"; interface Props { @@ -14,7 +15,9 @@ interface EyeDropperCtor { new (): { open(): Promise<{ sRGBHex: string }> }; } -// ---- component ----------------------------------------------------------- +// Two-layer picker: the quick layer (curated palette, recents, favorites) is +// always one click away; hex / opacity / save-color live behind a disclosure +// so the common path stays a single tap. export function ColorPicker({ open, onClose, anchorRef }: Props) { const color = useDockStore((s) => s.instruments[s.activeInstrumentId].color); @@ -24,9 +27,16 @@ export function ColorPicker({ open, onClose, anchorRef }: Props) { const setColor = useDockStore((s) => s.setActiveColor); const setOpacity = useDockStore((s) => s.setActiveOpacity); const { swatches, addSwatch } = useSavedSwatches(); + const recents = useRecentColors(); + const [advancedOpen, setAdvancedOpen] = useState(false); const lower = color.toLowerCase(); + function pick(c: string) { + setColor(c); + recordRecentColor(c); + } + const hasEyeDropper = typeof window !== "undefined" && "EyeDropper" in window; @@ -36,12 +46,16 @@ export function ColorPicker({ open, onClose, anchorRef }: Props) { if (!Ctor) return; try { const res = await new Ctor().open(); - setColor(res.sRGBHex); + pick(res.sRGBHex); } catch { /* user dismissed the eyedropper */ } } + // Avoid echoing palette rows: recents that aren't already visible above. + const paletteSet = new Set(COLORS.map((c) => c.toLowerCase())); + const recentRow = recents.filter((c) => !paletteSet.has(c.toLowerCase())); + return (
@@ -70,7 +84,7 @@ export function ColorPicker({ open, onClose, anchorRef }: Props) {
- {/* Minimalist swatch palette */} + {/* Quick layer — curated palette */}
{COLORS.map((c) => ( setColor(c)} + onClick={() => pick(c)} aria-label={c} /> ))}
- {/* Hex input */} -
- # - { - 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
- + {recentRow.length > 0 && ( + <> +
Recent
+
+ {recentRow.map((c) => ( + pick(c)} + aria-label={c} + /> + ))} +
+ + )} {swatches.length > 0 && ( -
-
+ <> +
Saved
+
{swatches.map((c) => ( setColor(c)} + onClick={() => pick(c)} /> ))}
-
+ )} + {/* Advanced layer — hex, opacity, save */} + + {advancedOpen && ( +
+
+ # + { + const v = e.target.value; + if (/^#?[0-9a-f]{6}$/i.test(v)) { + pick(v.startsWith("#") ? v : `#${v}`); + } + }} + aria-label="Hex color" + spellCheck={false} + maxLength={7} + /> +
+
+ +
Opacity
+ + + +
+ )}
); diff --git a/apps/web/src/features/canvas/SelectionInspector.tsx b/apps/web/src/features/canvas/SelectionInspector.tsx deleted file mode 100644 index cd57898..0000000 --- a/apps/web/src/features/canvas/SelectionInspector.tsx +++ /dev/null @@ -1,386 +0,0 @@ -import { useMemo } from "react"; -import type { TextAlign, YShape } from "@notux/types"; -import { Icon, type IconName } from "@notux/ui"; -import { useShapeStore, useToolStore } from "@notux/canvas"; -import { Swatch } from "@notux/ui"; -import { COLORS, THICKNESS_PRESETS } from "./palette"; - -const ALIGN_OPTIONS: Array<{ id: TextAlign; icon: IconName; label: string }> = [ - { id: "left", icon: "align-left", label: "Align left" }, - { id: "center", icon: "align-center", label: "Align center" }, - { id: "right", icon: "align-right", label: "Align right" }, -]; - -interface Props { - pageId: string; -} - -// The stroke/outline color a shape exposes for editing (asset has none). -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": - case "embed": - return undefined; - } -} - -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": - case "embed": - return null; - } -} - -// Read the stroke thickness from any shape that has one. -function shapeThickness(s: YShape): number | undefined { - switch (s.kind) { - case "rect": - case "ellipse": - case "polygon": - return s.strokeWidth ?? 2; - case "line": - case "arrow": - return s.width; - default: - return undefined; - } -} - -// Build a patch to set stroke thickness for a given shape kind. -function thicknessPatch(s: YShape, thickness: number): Partial | null { - switch (s.kind) { - case "rect": - case "ellipse": - case "polygon": - return { strokeWidth: thickness }; - case "line": - case "arrow": - return { width: thickness }; - default: - return null; - } -} - -// The common value across the selection, or "mixed" when they differ. -function shared( - items: YShape[], - pick: (s: YShape) => T | undefined, -): T | "mixed" | undefined { - let acc: T | undefined; - let seen = false; - for (const s of items) { - const v = pick(s); - if (v === undefined) continue; - if (!seen) { - acc = v; - seen = true; - } else if (acc !== v) { - return "mixed"; - } - } - return seen ? acc : undefined; -} - -export function SelectionInspector({ pageId }: Props) { - const selection = useToolStore((s) => s.selection); - const revision = useShapeStore((s) => s.revision); - - const selected = useMemo(() => { - const store = useShapeStore.getState(); - return Array.from(selection) - .map((id) => store.getShape(pageId, id)) - .filter((s): s is YShape => !!s); - // revision so the panel reflects model edits / undo immediately. - }, [selection, revision, pageId]); - - if (selected.length === 0) return null; - - const store = useShapeStore.getState(); - const ids = selected.map((s) => s.id); - - function patchEach(make: (s: YShape) => Partial | null) { - store.transact(() => { - for (const s of selected) { - const p = make(s); - if (p) store.updateShape(pageId, s.id, p); - } - }); - } - - 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"); - const thicknessShapes = selected.filter( - (s) => shapeThickness(s) !== undefined, - ); - const alignShapes = selected.filter( - (s) => s.kind === "text" || s.kind === "sticky", - ); - - const currentColor = shared(selected, shapeColor); - const currentFill = shared(fillShapes, (s) => - isFillKind(s) ? (s as { fill: string | null }).fill : undefined, - ); - const currentOpacity = shared(selected, (s) => s.opacity ?? 1); - const currentSize = shared(textShapes, (s) => - s.kind === "text" ? s.size : undefined, - ); - const currentThickness = shared(thicknessShapes, shapeThickness); - const currentAlign = shared(alignShapes, (s) => - s.kind === "text" - ? (s.align ?? "left") - : s.kind === "sticky" - ? (s.align ?? "center") - : undefined, - ); - const allLocked = shared(selected, (s) => !!s.locked) === true; - - const opacityValue = - typeof currentOpacity === "number" ? Math.round(currentOpacity * 100) : 100; - - return ( -
- {/* Color row — minimalist swatch palette */} - {hasColor && ( -
- {COLORS.map((c) => ( - patchEach((s) => colorPatch(s, c))} - aria-label={`Stroke ${c}`} - /> - ))} -
- )} - - {/* Fill row */} - {fillShapes.length > 0 && ( -
- Fill -
- - patchEach((s) => (isFillKind(s) ? { fill: null } : null)) - } - aria-label="No fill" - /> - {COLORS.map((c) => ( - - patchEach((s) => (isFillKind(s) ? { fill: c } : null)) - } - aria-label={`Fill ${c}`} - /> - ))} -
-
- )} - - {/* Thickness row */} - {thicknessShapes.length > 0 && ( -
- Thickness -
- {THICKNESS_PRESETS.map((t) => ( - - ))} - { - const n = Number(e.target.value); - if (Number.isFinite(n) && n > 0) { - patchEach((s) => thicknessPatch(s, n)); - } - }} - aria-label="Stroke thickness" - /> -
-
- )} - - {/* Opacity row */} -
- Opacity - - patchEach(() => ({ opacity: Number(e.target.value) / 100 })) - } - aria-label="Opacity" - /> -
- - {alignShapes.length > 0 && ( -
- Align -
- {ALIGN_OPTIONS.map((a) => ( - - ))} -
-
- )} - - {/* Text size row */} - {textShapes.length > 0 && ( -
- Size - { - const n = Number(e.target.value); - if (Number.isFinite(n) && n > 0) { - patchEach((s) => (s.kind === "text" ? { size: n } : null)); - } - }} - aria-label="Font size" - /> -
- )} - - {/* Z-order actions */} -
- - - - -
- - -
- ); -} diff --git a/apps/web/src/features/canvas/SelectionToolbar.tsx b/apps/web/src/features/canvas/SelectionToolbar.tsx new file mode 100644 index 0000000..6292f7a --- /dev/null +++ b/apps/web/src/features/canvas/SelectionToolbar.tsx @@ -0,0 +1,709 @@ +import { + useLayoutEffect, + useMemo, + useRef, + useState, + type ReactNode, + type RefObject, +} from "react"; +import type { TextAlign, YShape } from "@notux/types"; +import { Icon, Popover, Slider, Swatch, type IconName } from "@notux/ui"; +import { + alignShapes, + distributeShapes, + newShapeId, + shapeBounds, + translateShape, + useShapeStore, + useTextEditStore, + useToolStore, + useViewportStore, + type AlignEdge, +} from "@notux/canvas"; +import { COLORS, THICKNESS_PRESETS } from "./palette"; +import { recordRecentColor, useRecentColors } from "./useRecentColors"; + +// Contextual selection toolbar (Freeform/FigJam-style): a compact glass pill +// that floats next to the selected objects, shows only the controls that +// apply, and tucks detail behind popovers. Replaces the old fixed right-side +// inspector panel, cutting cursor travel to near zero. + +const GAP = 12; +// Keep clear of the dock (top-center) when flipping above tall selections. +const TOP_SAFE = 72; +const EDGE = 8; + +const ALIGN_TEXT_OPTIONS: Array<{ id: TextAlign; icon: IconName; label: string }> = [ + { id: "left", icon: "align-left", label: "Align text left" }, + { id: "center", icon: "align-center", label: "Align text center" }, + { id: "right", icon: "align-right", label: "Align text right" }, +]; + +const ALIGN_OBJECT_OPTIONS: Array<{ id: AlignEdge; icon: IconName; label: string }> = [ + { id: "left", icon: "obj-align-left", label: "Align left edges" }, + { id: "hcenter", icon: "obj-align-center", label: "Align horizontal centers" }, + { id: "right", icon: "obj-align-right", label: "Align right edges" }, + { id: "top", icon: "obj-align-top", label: "Align top edges" }, + { id: "vcenter", icon: "obj-align-middle", label: "Align vertical centers" }, + { id: "bottom", icon: "obj-align-bottom", label: "Align bottom edges" }, +]; + +// The stroke/outline color a shape exposes for editing (asset has none). +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": + case "embed": + return undefined; + } +} + +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": + case "embed": + return null; + } +} + +function shapeThickness(s: YShape): number | undefined { + switch (s.kind) { + case "rect": + case "ellipse": + case "polygon": + return s.strokeWidth ?? 2; + case "line": + case "arrow": + return s.width; + default: + return undefined; + } +} + +function thicknessPatch(s: YShape, thickness: number): Partial | null { + switch (s.kind) { + case "rect": + case "ellipse": + case "polygon": + return { strokeWidth: thickness }; + case "line": + case "arrow": + return { width: thickness }; + default: + return null; + } +} + +// The common value across the selection, or "mixed" when they differ. +function shared( + items: YShape[], + pick: (s: YShape) => T | undefined, +): T | "mixed" | undefined { + let acc: T | undefined; + let seen = false; + for (const s of items) { + const v = pick(s); + if (v === undefined) continue; + if (!seen) { + acc = v; + seen = true; + } else if (acc !== v) { + return "mixed"; + } + } + return seen ? acc : undefined; +} + +interface BarButtonProps { + icon: IconName; + label: string; + active?: boolean; + danger?: boolean; + onClick(): void; + innerRef?: RefObject; +} + +function BarButton({ icon, label, active, danger, onClick, innerRef }: BarButtonProps) { + return ( + + ); +} + +type PanelId = "color" | "fill" | "stroke" | "text" | "opacity" | "arrange" | null; + +interface Props { + pageId: string; +} + +export function SelectionToolbar({ pageId }: Props) { + const selection = useToolStore((s) => s.selection); + const revision = useShapeStore((s) => s.revision); + const viewport = useViewportStore((s) => s.viewport); + const viewSize = useViewportStore((s) => s.size); + const editing = useTextEditStore((s) => s.session !== null); + + const [panel, setPanel] = useState(null); + const barRef = useRef(null); + const [barSize, setBarSize] = useState({ w: 0, h: 0 }); + + const colorBtnRef = useRef(null); + const fillBtnRef = useRef(null); + const strokeBtnRef = useRef(null); + const textBtnRef = useRef(null); + const opacityBtnRef = useRef(null); + const arrangeBtnRef = useRef(null); + + const recents = useRecentColors(); + + const selected = useMemo(() => { + const store = useShapeStore.getState(); + return Array.from(selection) + .map((id) => store.getShape(pageId, id)) + .filter((s): s is YShape => !!s); + // revision so the toolbar reflects model edits / undo immediately. + }, [selection, revision, pageId]); + + // Close any open panel when the selection itself changes. + const selectionKey = useMemo( + () => Array.from(selection).sort().join(","), + [selection], + ); + const prevKeyRef = useRef(selectionKey); + if (prevKeyRef.current !== selectionKey) { + prevKeyRef.current = selectionKey; + if (panel !== null) setPanel(null); + } + + // World bounds of the selection → screen-space anchor for the bar. + const bounds = useMemo(() => { + if (selected.length === 0) return null; + let x1 = Infinity; + let y1 = Infinity; + let x2 = -Infinity; + let y2 = -Infinity; + for (const s of selected) { + const b = shapeBounds(s); + x1 = Math.min(x1, b.x); + y1 = Math.min(y1, b.y); + x2 = Math.max(x2, b.x + b.w); + y2 = Math.max(y2, b.y + b.h); + } + return { x1, y1, x2, y2 }; + }, [selected]); + + useLayoutEffect(() => { + const el = barRef.current; + if (!el) return; + const r = el.getBoundingClientRect(); + if (r.width !== barSize.w || r.height !== barSize.h) { + setBarSize({ w: r.width, h: r.height }); + } + }); + + if (selected.length === 0 || !bounds || editing) return null; + + const store = useShapeStore.getState(); + const ids = selected.map((s) => s.id); + + // ---- position -------------------------------------------------------- + const sx1 = bounds.x1 * viewport.scale + viewport.x; + const sy1 = bounds.y1 * viewport.scale + viewport.y; + const sx2 = bounds.x2 * viewport.scale + viewport.x; + const sy2 = bounds.y2 * viewport.scale + viewport.y; + const cx = (sx1 + sx2) / 2; + let left = cx - barSize.w / 2; + left = Math.max(EDGE, Math.min(left, viewSize.w - barSize.w - EDGE)); + // Prefer above the selection; flip below when it would collide with the + // dock or the viewport edge. + let top = sy1 - GAP - barSize.h; + let placement: "top" | "bottom" = "top"; + if (top < TOP_SAFE) { + top = sy2 + GAP; + placement = "bottom"; + } + top = Math.max(EDGE, Math.min(top, viewSize.h - barSize.h - EDGE)); + + // ---- selection facts --------------------------------------------------- + function patchEach(make: (s: YShape) => Partial | null) { + store.transact(() => { + for (const s of selected) { + const p = make(s); + if (p) store.updateShape(pageId, s.id, p); + } + }); + } + + 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"); + const thicknessShapes = selected.filter((s) => shapeThickness(s) !== undefined); + const alignTextShapes = selected.filter( + (s) => s.kind === "text" || s.kind === "sticky", + ); + + const currentColor = shared(selected, shapeColor); + const currentFill = shared(fillShapes, (s) => + isFillKind(s) ? (s as { fill: string | null }).fill : undefined, + ); + const currentOpacity = shared(selected, (s) => s.opacity ?? 1); + const currentSize = shared(textShapes, (s) => + s.kind === "text" ? s.size : undefined, + ); + const currentThickness = shared(thicknessShapes, shapeThickness); + const currentAlign = shared(alignTextShapes, (s) => + s.kind === "text" + ? (s.align ?? "left") + : s.kind === "sticky" + ? (s.align ?? "center") + : undefined, + ); + const allLocked = shared(selected, (s) => !!s.locked) === true; + const opacityValue = typeof currentOpacity === "number" ? currentOpacity : 1; + + function pickColor(c: string) { + patchEach((s) => colorPatch(s, c)); + recordRecentColor(c); + } + + function duplicate() { + const offset = 24 / Math.max(0.25, viewport.scale); + const clones: YShape[] = selected + .filter((s) => !s.locked) + .map((s) => ({ + ...translateShape(s, offset, offset), + id: newShapeId(), + z: undefined, + })); + if (clones.length === 0) return; + store.transact(() => { + for (const c of clones) store.addShape(pageId, c); + }); + useToolStore.getState().setSelection(clones.map((c) => c.id)); + } + + function deleteSelection() { + const deletable = ids.filter((id) => !store.getShape(pageId, id)?.locked); + if (deletable.length === 0) return; + store.transact(() => store.deleteShapes(pageId, deletable)); + useToolStore.getState().clearSelection(); + } + + function toggle(p: PanelId) { + setPanel((cur) => (cur === p ? null : p)); + } + + const barStyle = { left, top } as const; + + // Locked selections expose exactly one action: Unlock (Freeform behaviour). + if (allLocked) { + return ( +
+ +
+ ); + } + + const paletteSet = new Set(COLORS.map((c) => c.toLowerCase())); + const recentRow = recents.filter((c) => !paletteSet.has(c.toLowerCase())); + + function swatchGrid( + current: string | "mixed" | null | undefined, + onPick: (c: string) => void, + withNone?: boolean, + ): ReactNode { + return ( +
+ {withNone && ( + patchEach((s) => (isFillKind(s) ? { fill: null } : null))} + aria-label="No fill" + /> + )} + {COLORS.map((c) => ( + onPick(c)} + aria-label={c} + /> + ))} + {recentRow.map((c) => ( + onPick(c)} + aria-label={c} + /> + ))} +
+ ); + } + + return ( + <> +
+ {hasColor && ( + + )} + + {fillShapes.length > 0 && ( + + )} + + {thicknessShapes.length > 0 && ( + toggle("stroke")} + /> + )} + + {alignTextShapes.length > 0 && ( + toggle("text")} + /> + )} + + toggle("opacity")} + /> + + + + + toggle("arrange")} + /> + patchEach(() => ({ locked: true }))} + /> + +
+ + {/* Color */} + setPanel(null)} + anchorRef={colorBtnRef} + placement="auto" + tail + > + {swatchGrid(currentColor, pickColor)} + + + {/* Fill */} + setPanel(null)} + anchorRef={fillBtnRef} + placement="auto" + tail + > + {swatchGrid( + currentFill, + (c) => { + patchEach((s) => (isFillKind(s) ? { fill: c } : null)); + recordRecentColor(c); + }, + true, + )} + + + {/* Thickness */} + setPanel(null)} + anchorRef={strokeBtnRef} + placement="auto" + tail + > +
+ {THICKNESS_PRESETS.map((t) => ( + + ))} + { + const n = Number(e.target.value); + if (Number.isFinite(n) && n > 0) { + patchEach((s) => thicknessPatch(s, n)); + } + }} + aria-label="Stroke thickness" + /> +
+
+ + {/* Text style */} + setPanel(null)} + anchorRef={textBtnRef} + placement="auto" + tail + > +
+
+ {ALIGN_TEXT_OPTIONS.map((a) => ( + + ))} + {textShapes.length > 0 && ( + { + const n = Number(e.target.value); + if (Number.isFinite(n) && n > 0) { + patchEach((s) => (s.kind === "text" ? { size: n } : null)); + } + }} + aria-label="Font size" + /> + )} +
+
+
+ + {/* Opacity */} + setPanel(null)} + anchorRef={opacityBtnRef} + placement="auto" + tail + className="sel-toolbar__opacity-popover" + > + patchEach(() => ({ opacity: v }))} + trackStyle="opacity" + color={typeof currentColor === "string" ? currentColor : "#1c1c1e"} + aria-label="Opacity" + /> + + + {/* Arrange: z-order, plus object alignment for multi-selections */} + setPanel(null)} + anchorRef={arrangeBtnRef} + placement="auto" + tail + > +
+
+ store.bringToFront(pageId, ids)} + /> + store.bringForward(pageId, ids)} + /> + store.sendBackward(pageId, ids)} + /> + store.sendToBack(pageId, ids)} + /> +
+ {selected.length >= 2 && ( +
+ {ALIGN_OBJECT_OPTIONS.map((a) => ( + alignShapes(store, pageId, ids, a.id)} + /> + ))} +
+ )} + {selected.length >= 3 && ( +
+ distributeShapes(store, pageId, ids, "h")} + /> + distributeShapes(store, pageId, ids, "v")} + /> +
+ )} +
+
+ + ); +} diff --git a/apps/web/src/features/canvas/palette.ts b/apps/web/src/features/canvas/palette.ts index f37dfdd..7958b4f 100644 --- a/apps/web/src/features/canvas/palette.ts +++ b/apps/web/src/features/canvas/palette.ts @@ -1,5 +1,5 @@ // Shared swatch + stroke-size presets, used by both the ColorPicker and the -// SelectionInspector so every color surface stays visually consistent. +// SelectionToolbar so every color surface stays visually consistent. // Minimalist palette — 6 grayscale + 8 curated hues. export const COLORS = [ @@ -21,5 +21,5 @@ export const COLORS = [ "#bf5af2", ]; -// Thickness presets shown in the SelectionInspector for shapes/lines/arrows. +// Thickness presets shown in the SelectionToolbar for shapes/lines/arrows. export const THICKNESS_PRESETS = [1, 2, 4, 6, 8]; diff --git a/apps/web/src/features/canvas/useRecentColors.ts b/apps/web/src/features/canvas/useRecentColors.ts new file mode 100644 index 0000000..f1b6467 --- /dev/null +++ b/apps/web/src/features/canvas/useRecentColors.ts @@ -0,0 +1,57 @@ +import { useSyncExternalStore } from "react"; + +// Recently used ink colors, shared across every color surface (dock picker, +// selection toolbar) and persisted per device. Most-recent-first, deduped. +const KEY = "notux-recent-colors"; +const MAX = 8; + +let cache: string[] | null = null; +const listeners = new Set<() => void>(); + +function load(): string[] { + if (cache) return cache; + try { + const raw = localStorage.getItem(KEY); + if (raw) { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed) && parsed.every((s) => typeof s === "string")) { + cache = parsed; + return cache; + } + } + } catch { + /* ignore malformed storage */ + } + cache = []; + return cache; +} + +function emit() { + for (const l of listeners) l(); +} + +export function recordRecentColor(color: string) { + const hex = color.toLowerCase(); + const next = [color, ...load().filter((c) => c.toLowerCase() !== hex)].slice( + 0, + MAX, + ); + cache = next; + try { + localStorage.setItem(KEY, JSON.stringify(next)); + } catch { + /* ignore quota / privacy-mode errors */ + } + emit(); +} + +export function useRecentColors(): string[] { + return useSyncExternalStore( + (cb) => { + listeners.add(cb); + return () => listeners.delete(cb); + }, + load, + () => [], + ); +} diff --git a/apps/web/src/features/library/libraryStore.ts b/apps/web/src/features/library/libraryStore.ts new file mode 100644 index 0000000..ee683a1 --- /dev/null +++ b/apps/web/src/features/library/libraryStore.ts @@ -0,0 +1,162 @@ +import { create } from "zustand"; + +function newFolderId(): string { + if (typeof crypto !== "undefined" && "randomUUID" in crypto) { + return "folder-" + crypto.randomUUID().slice(0, 8); + } + return "folder-" + Math.random().toString(36).slice(2, 10); +} + +// Local-first board library: Workspace → Folders → Boards. Every board you +// create or open is indexed here (localStorage), with favorites, recents and +// folder assignment. Works identically in local-only and Supabase modes; +// syncing the library server-side is a roadmap item (see docs/redesign). + +export interface BoardMeta { + id: string; + title: string; + createdAt: number; + lastOpenedAt: number; + starred: boolean; + folderId: string | null; +} + +export interface FolderMeta { + id: string; + name: string; + createdAt: number; +} + +interface LibraryState { + boards: Record; + folders: FolderMeta[]; + + /** Record a visit (called when a board opens); creates the entry if new. */ + touchBoard(id: string): void; + renameBoard(id: string, title: string): void; + toggleStar(id: string): void; + moveBoard(id: string, folderId: string | null): void; + /** Forget a board (does not delete its data, just the library entry). */ + removeBoard(id: string): void; + + createFolder(name: string): string; + renameFolder(id: string, name: string): void; + /** Delete a folder; its boards move back to the workspace root. */ + deleteFolder(id: string): void; +} + +const KEY = "notux-library"; + +interface Persisted { + boards?: Record; + folders?: FolderMeta[]; +} + +function load(): { boards: Record; folders: FolderMeta[] } { + if (typeof window === "undefined") return { boards: {}, folders: [] }; + try { + const raw = window.localStorage.getItem(KEY); + if (!raw) return { boards: {}, folders: [] }; + const v = JSON.parse(raw) as Persisted; + return { boards: v.boards ?? {}, folders: v.folders ?? [] }; + } catch { + return { boards: {}, folders: [] }; + } +} + +function persist(state: { boards: Record; folders: FolderMeta[] }) { + try { + window.localStorage.setItem( + KEY, + JSON.stringify({ boards: state.boards, folders: state.folders }), + ); + } catch { + /* storage unavailable */ + } +} + +export const useLibraryStore = create((set, get) => ({ + ...load(), + + touchBoard(id) { + const now = Date.now(); + const existing = get().boards[id]; + const meta: BoardMeta = existing + ? { ...existing, lastOpenedAt: now } + : { + id, + title: "Untitled board", + createdAt: now, + lastOpenedAt: now, + starred: false, + folderId: null, + }; + const boards = { ...get().boards, [id]: meta }; + set({ boards }); + persist({ boards, folders: get().folders }); + }, + + renameBoard(id, title) { + const b = get().boards[id]; + if (!b) return; + const t = title.trim(); + if (!t) return; + const boards = { ...get().boards, [id]: { ...b, title: t } }; + set({ boards }); + persist({ boards, folders: get().folders }); + }, + + toggleStar(id) { + const b = get().boards[id]; + if (!b) return; + const boards = { ...get().boards, [id]: { ...b, starred: !b.starred } }; + set({ boards }); + persist({ boards, folders: get().folders }); + }, + + moveBoard(id, folderId) { + const b = get().boards[id]; + if (!b) return; + const boards = { ...get().boards, [id]: { ...b, folderId } }; + set({ boards }); + persist({ boards, folders: get().folders }); + }, + + removeBoard(id) { + const boards = { ...get().boards }; + delete boards[id]; + set({ boards }); + persist({ boards, folders: get().folders }); + }, + + createFolder(name) { + const id = newFolderId(); + const folders = [ + ...get().folders, + { id, name: name.trim() || "New folder", createdAt: Date.now() }, + ]; + set({ folders }); + persist({ boards: get().boards, folders }); + return id; + }, + + renameFolder(id, name) { + const t = name.trim(); + if (!t) return; + const folders = get().folders.map((f) => (f.id === id ? { ...f, name: t } : f)); + set({ folders }); + persist({ boards: get().boards, folders }); + }, + + deleteFolder(id) { + const folders = get().folders.filter((f) => f.id !== id); + const boards = Object.fromEntries( + Object.entries(get().boards).map(([bid, b]) => [ + bid, + b.folderId === id ? { ...b, folderId: null } : b, + ]), + ); + set({ folders, boards }); + persist({ boards, folders }); + }, +})); diff --git a/apps/web/src/routes/Board.tsx b/apps/web/src/routes/Board.tsx index 51464e4..9917fc5 100644 --- a/apps/web/src/routes/Board.tsx +++ b/apps/web/src/routes/Board.tsx @@ -13,9 +13,10 @@ import { CollabBar } from "../features/canvas/CollabBar"; import { Dock } from "../features/canvas/Dock"; import { FollowPill } from "../features/canvas/FollowPill"; import { SaveStatus } from "../features/canvas/SaveStatus"; -import { SelectionInspector } from "../features/canvas/SelectionInspector"; +import { SelectionToolbar } from "../features/canvas/SelectionToolbar"; import { useIdentity } from "../features/canvas/useIdentity"; import { ensureBoardOwnership } from "../features/board/boardOwnership"; +import { useLibraryStore } from "../features/library/libraryStore"; import { getSupabase } from "../lib/supabase"; export default function Board() { @@ -28,6 +29,11 @@ export default function Board() { const activePageId = usePageStore((s) => s.activePageId); const { theme } = useTheme(); + // Index the visit so the board shows up in Home's library/recents. + useEffect(() => { + if (boardId) useLibraryStore.getState().touchBoard(boardId); + }, [boardId]); + useEffect(() => { if (!boardId) return; setReady(false); @@ -94,7 +100,7 @@ export default function Board() { /> - +
); diff --git a/apps/web/src/routes/Home.tsx b/apps/web/src/routes/Home.tsx index 1ab60b2..fd7c47d 100644 --- a/apps/web/src/routes/Home.tsx +++ b/apps/web/src/routes/Home.tsx @@ -1,6 +1,12 @@ -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; +import { Icon, useTheme } from "@notux/ui"; import { useAuth } from "../features/auth/useAuth"; +import { + useLibraryStore, + type BoardMeta, + type FolderMeta, +} from "../features/library/libraryStore"; import { supabaseConfigured } from "../env"; function newBoardId(): string { @@ -8,90 +14,536 @@ function newBoardId(): string { return Math.random().toString(36).slice(2); } +const RECENTS_LIMIT = 6; + export default function Home() { - const [email, setEmail] = useState(""); - const [showMagicLink, setShowMagicLink] = useState(false); - const { status, error, session, signInWithGoogle, sendMagicLink, signOut } = useAuth(); const navigate = useNavigate(); + const { theme, toggle: toggleTheme } = useTheme(); + + const boards = useLibraryStore((s) => s.boards); + const folders = useLibraryStore((s) => s.folders); + const touchBoard = useLibraryStore((s) => s.touchBoard); + const createFolder = useLibraryStore((s) => s.createFolder); + + const [query, setQuery] = useState(""); + const [openFolderId, setOpenFolderId] = useState(null); + + const all = useMemo( + () => Object.values(boards).sort((a, b) => b.lastOpenedAt - a.lastOpenedAt), + [boards], + ); + + const searching = query.trim().length > 0; + const matches = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return []; + return all.filter((b) => b.title.toLowerCase().includes(q)); + }, [all, query]); + + const favorites = all.filter((b) => b.starred); + const recents = all.slice(0, RECENTS_LIMIT); + const rootBoards = all.filter((b) => b.folderId === null); + const openFolder = folders.find((f) => f.id === openFolderId) ?? null; + const folderBoards = openFolder + ? all.filter((b) => b.folderId === openFolder.id) + : []; function onCreateBoard() { - navigate(`/board/${newBoardId()}`); + const id = newBoardId(); + touchBoard(id); + if (openFolder) useLibraryStore.getState().moveBoard(id, openFolder.id); + navigate(`/board/${id}`); + } + + function onCreateFolder() { + const id = createFolder("New folder"); + setOpenFolderId(id); } + const hasLibrary = all.length > 0 || folders.length > 0; + return ( -
-
-

NotUX

-

A collaborative infinite whiteboard for teaching.

-
+
+
+
+ {openFolder ? ( + + ) : ( + + N + + )} +

+ {openFolder ? openFolder.name : "NotUX"} +

+
-
- +
+ + setQuery(e.target.value)} + placeholder="Search boards" + aria-label="Search boards" + /> + {searching && ( + + )} +
- {!supabaseConfigured ? ( -

- Running in local-only mode. Set VITE_SUPABASE_URL and{" "} - VITE_SUPABASE_ANON_KEY to enable sync and sign-in. -

- ) : session ? ( -
- Signed in as {session.user.email} - + {!openFolder && ( + -
+ )} + +
+ + +
+ {searching ? ( +
+ {matches.length === 0 ? ( +

No boards match your search.

+ ) : ( +
+ {matches.map((b) => ( + + ))} +
+ )} +
+ ) : openFolder ? ( +
+ {folderBoards.length === 0 ? ( +

+ This folder is empty. Create a board here, or drag boards onto the + folder from the workspace. +

+ ) : ( +
+ {folderBoards.map((b) => ( + + ))} +
+ )} + setOpenFolderId(null)} /> +
+ ) : !hasLibrary ? ( + + ) : ( + <> + {favorites.length > 0 && ( +
+
+ {favorites.map((b) => ( + + ))} +
+
+ )} + + {recents.length > 0 && ( +
+
+ {recents.map((b) => ( + + ))} +
+
+ )} + + {folders.length > 0 && ( +
+
+ {folders.map((f) => ( + b.folderId === f.id).length} + onOpen={() => setOpenFolderId(f.id)} + /> + ))} +
+
+ )} + + {rootBoards.length > 0 && ( +
+
+ {rootBoards.map((b) => ( + + ))} +
+
+ )} + + )} + + +
+ + ); +} + +function Section({ + title, + icon, + children, +}: { + title: string; + icon?: Parameters[0]["name"]; + children: React.ReactNode; +}) { + return ( +
+ {title && ( +

+ {icon && } + {title} +

+ )} + {children} +
+ ); +} + +function BoardCard({ + board, + folders, +}: { + board: BoardMeta; + folders: FolderMeta[]; +}) { + const navigate = useNavigate(); + const renameBoard = useLibraryStore((s) => s.renameBoard); + const toggleStar = useLibraryStore((s) => s.toggleStar); + const moveBoard = useLibraryStore((s) => s.moveBoard); + const removeBoard = useLibraryStore((s) => s.removeBoard); + const [renaming, setRenaming] = useState(false); + const [menuOpen, setMenuOpen] = useState(false); + + return ( +
{ + e.dataTransfer.setData("application/x-notux-board", board.id); + e.dataTransfer.effectAllowed = "move"; + }} + onClick={() => { + if (!renaming && !menuOpen) navigate(`/board/${board.id}`); + }} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === "Enter" && !renaming) navigate(`/board/${board.id}`); + }} + aria-label={`Open ${board.title}`} + > +
+ +
+
+ {renaming ? ( + e.stopPropagation()} + onKeyDown={(e) => { + if (e.key === "Enter") (e.target as HTMLInputElement).blur(); + if (e.key === "Escape") setRenaming(false); + }} + onBlur={(e) => { + renameBoard(board.id, e.target.value); + setRenaming(false); + }} + aria-label="Board title" + /> ) : ( -
- + + {board.title} + + )} + + {new Date(board.lastOpenedAt).toLocaleDateString()} + +
+
e.stopPropagation()}> + + + {menuOpen && ( +
setMenuOpen(false)}> - - {showMagicLink ? ( -
{ - e.preventDefault(); - if (email) void sendMagicLink(email); + {folders.map((f) => ( + -
- - ) : ( + Move to {f.name} + + ))} + {board.folderId !== null && ( )} - - {status === "sent" && ( -

Check your email to finish signing in.

- )} - {status === "error" && error &&

{error}

} +
)} - - +
+
+ ); +} + +function FolderCard({ + folder, + count, + onOpen, +}: { + folder: FolderMeta; + count: number; + onOpen(): void; +}) { + const moveBoard = useLibraryStore((s) => s.moveBoard); + const [over, setOver] = useState(false); + + return ( + + ); +} + +function FolderActions({ + folder, + onDeleted, +}: { + folder: FolderMeta; + onDeleted(): void; +}) { + const renameFolder = useLibraryStore((s) => s.renameFolder); + const deleteFolder = useLibraryStore((s) => s.deleteFolder); + return ( +
+ { + if (e.key === "Enter") (e.target as HTMLInputElement).blur(); + }} + onBlur={(e) => renameFolder(folder.id, e.target.value)} + aria-label="Folder name" + /> + +
+ ); +} + +function EmptyState({ onCreate }: { onCreate(): void }) { + return ( +
+

A collaborative infinite whiteboard for teaching.

+

+ Draw, annotate PDFs, and teach live — your boards appear here as you + create them. +

+ +
+ ); +} + +function AuthFooter() { + const [email, setEmail] = useState(""); + const [showMagicLink, setShowMagicLink] = useState(false); + const { status, error, session, signInWithGoogle, sendMagicLink, signOut } = + useAuth(); + + if (!supabaseConfigured) { + return ( +

+ Running in local-only mode. Set VITE_SUPABASE_URL and{" "} + VITE_SUPABASE_ANON_KEY to enable sync and sign-in. +

+ ); + } + + if (session) { + return ( +
+ Signed in as {session.user.email} + +
+ ); + } + + return ( +
+ Sign in to keep your boards private & sync across devices +
+ + {!showMagicLink && ( + + )} +
+ {showMagicLink && ( +
{ + e.preventDefault(); + if (email) void sendMagicLink(email); + }} + > + setEmail(e.target.value)} + aria-label="Email address" + /> + +
+ )} + {status === "sent" && ( +

Check your email to finish signing in.

+ )} + {status === "error" && error &&

{error}

} +
); } diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index d59cb9c..13a1f6b 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -53,129 +53,473 @@ a { border-color: rgba(90, 200, 250, 0.5); } -.home { +.lg-button--google { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 10px; + background: #fff; + color: #1f1f1f; + border-color: rgba(0, 0, 0, 0.12); + font-weight: 500; +} + +.lg-button--google:hover { + background: #f5f5f5; +} + +/* ----- Board library (Home): Workspace → Folders → Boards ---------------- */ + +.library { display: flex; flex-direction: column; - justify-content: center; - align-items: center; - gap: 40px; height: 100%; - padding: 24px; } -.home__hero h1 { - font-size: clamp(48px, 8vw, 96px); +.library__bar { + display: flex; + align-items: center; + gap: 16px; + padding: 16px 24px; + flex: none; +} + +.library__brand { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.library__logo { + display: grid; + place-items: center; + width: 34px; + height: 34px; + border-radius: 9px; + background: linear-gradient(160deg, var(--accent), #5856d6); + color: #fff; + font-size: 17px; + font-weight: 800; + flex: none; +} + +.library__back { + appearance: none; + border: 1px solid var(--glass-stroke); + background: var(--glass-tint); + color: var(--fg-0); + width: 34px; + height: 34px; + border-radius: 50%; + display: grid; + place-items: center; + cursor: pointer; + flex: none; +} + +.library__back:hover { + background: var(--glass-hover); +} + +.library__title { margin: 0; - letter-spacing: -0.04em; - background: linear-gradient(180deg, var(--fg-0) 0%, var(--accent) 100%); - -webkit-background-clip: text; - background-clip: text; - color: transparent; + font-size: 22px; + letter-spacing: -0.02em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } -.home__tagline { - margin: 8px 0 0; +.library__search { + position: relative; + display: flex; + align-items: center; + flex: 1; + max-width: 380px; + margin-left: auto; +} + +.library__search input { + width: 100%; + background: var(--field-bg); + border: 1px solid var(--glass-stroke); + color: var(--fg-0); + border-radius: 999px; + padding: 9px 32px 9px 34px; + font: inherit; + font-size: 14px; +} + +.library__search input:focus { + outline: 2px solid var(--accent); + outline-offset: 1px; +} + +.library__search-icon { + position: absolute; + left: 12px; color: var(--fg-1); - text-align: center; + pointer-events: none; +} + +.library__search-clear { + position: absolute; + right: 8px; + appearance: none; + border: 0; + background: transparent; + color: var(--fg-1); + width: 22px; + height: 22px; + border-radius: 50%; + display: grid; + place-items: center; + cursor: pointer; } -.home__panel { +.library__actions { display: flex; - flex-direction: column; - align-items: stretch; - gap: 18px; - width: min(440px, 100%); - padding: 24px; - border-radius: 24px; - background: var(--glass-tint); + align-items: center; + gap: 8px; + flex: none; +} + +.library__icon-btn { + appearance: none; border: 1px solid var(--glass-stroke); - backdrop-filter: blur(28px) saturate(180%); - -webkit-backdrop-filter: blur(28px) saturate(180%); - box-shadow: var(--shadow-lg); + background: var(--glass-tint); + color: var(--fg-0); + width: 36px; + height: 36px; + border-radius: 50%; + display: grid; + place-items: center; + cursor: pointer; + transition: background 150ms ease; +} + +.library__icon-btn:hover { + background: var(--glass-hover); +} + +.library__new { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.library__scroll { + flex: 1; + overflow-y: auto; + padding: 8px 24px 40px; +} + +.library__section { + margin-top: 18px; +} + +.library__section-title { + display: flex; + align-items: center; + gap: 6px; + margin: 0 0 10px; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--fg-1); +} + +.library__grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 14px; } -.home__panel > .lg-button--primary { - align-self: stretch; +.library__empty { + color: var(--fg-1); + font-size: 14px; } -.home__auth { +.library__hero { display: flex; flex-direction: column; - gap: 8px; + align-items: center; + gap: 14px; + text-align: center; + padding: 80px 24px; + color: var(--fg-1); +} + +.library__hero h2 { + margin: 0; + font-size: clamp(24px, 4vw, 38px); + letter-spacing: -0.02em; + color: var(--fg-0); +} + +.library__hero p { + margin: 0; + max-width: 420px; +} + +.library__folder-actions { + display: flex; + align-items: center; + gap: 10px; + margin-top: 24px; +} + +.library__folder-rename { + background: var(--field-bg); + border: 1px solid var(--glass-stroke); + color: var(--fg-0); + border-radius: 10px; + padding: 8px 12px; + font: inherit; + font-size: 14px; +} + +.library__note { + margin: 28px 0 0; + font-size: 12px; + color: var(--fg-1); +} + +.library__error { + margin: 8px 0 0; + font-size: 12px; + color: #ff8a8a; +} + +.library__auth, +.library__auth-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; + margin-top: 28px; font-size: 13px; color: var(--fg-1); } -.home__auth-row { +.library__auth { + flex-direction: column; + align-items: flex-start; +} + +.library__auth-buttons { display: flex; + flex-wrap: wrap; gap: 8px; } -.home__auth-row input { - flex: 1; +.library__auth-buttons input { background: var(--field-bg); border: 1px solid var(--glass-stroke); color: var(--fg-0); border-radius: 999px; padding: 10px 14px; font-size: 14px; + min-width: 220px; } -.home__auth-row input:focus { - outline: 2px solid var(--accent); - outline-offset: 1px; +/* Board card */ + +.board-card { + position: relative; + display: flex; + flex-direction: column; + border-radius: 16px; + border: 1px solid var(--glass-stroke); + background: var(--glass-tint); + backdrop-filter: blur(20px) saturate(180%); + -webkit-backdrop-filter: blur(20px) saturate(180%); + cursor: pointer; + overflow: visible; + transition: transform 150ms ease, box-shadow 150ms ease; +} + +.board-card:hover { + transform: translateY(-2px); + box-shadow: 0 10px 28px rgba(0, 0, 0, 0.18); +} + +.board-card__preview { + display: grid; + place-items: center; + height: 110px; + border-radius: 15px 15px 0 0; + background: var(--canvas-bg); + background-image: radial-gradient(var(--canvas-dot) 1px, transparent 1px); + background-size: 16px 16px; + color: var(--fg-1); } -.home__signed-in { +.board-card__meta { display: flex; - justify-content: space-between; - align-items: center; - font-size: 13px; + flex-direction: column; + gap: 2px; + padding: 10px 12px 12px; + min-width: 0; +} + +.board-card__title { + font-size: 14px; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.board-card__rename { + font: inherit; + font-size: 14px; + font-weight: 600; + color: var(--fg-0); + background: var(--field-bg); + border: 1px solid var(--accent); + border-radius: 6px; + padding: 2px 6px; +} + +.board-card__time { + font-size: 11px; color: var(--fg-1); } -.home__note { - margin: 0; - font-size: 12px; +.board-card__tools { + position: absolute; + top: 8px; + right: 8px; + display: flex; + gap: 4px; +} + +.board-card__tool { + appearance: none; + border: 1px solid var(--glass-stroke); + background: var(--popover-bg); color: var(--fg-1); + width: 26px; + height: 26px; + border-radius: 50%; + display: grid; + place-items: center; + cursor: pointer; + opacity: 0; + transition: opacity 140ms ease, color 140ms ease; } -.home__error { - margin: 0; - font-size: 12px; - color: #ff8a8a; +.board-card:hover .board-card__tool, +.board-card__tool--starred, +.board-card__tool[aria-expanded="true"] { + opacity: 1; } -.lg-button--google { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 10px; - background: #fff; - color: #1f1f1f; - border-color: rgba(0, 0, 0, 0.12); - font-weight: 500; +.board-card__tool:hover { + color: var(--fg-0); } -.lg-button--google:hover { - background: #f5f5f5; +.board-card__tool--starred { + color: #ffd60a; } -.home__magic { +.board-card__menu { + position: absolute; + top: 30px; + right: 0; + z-index: 10; display: flex; flex-direction: column; + min-width: 180px; + padding: 6px; + border-radius: 12px; + background: var(--popover-bg); + border: 1px solid var(--glass-stroke); + backdrop-filter: blur(28px) saturate(180%); + -webkit-backdrop-filter: blur(28px) saturate(180%); + box-shadow: var(--shadow-lg); +} + +.board-card__menu button { + appearance: none; + border: 0; + background: transparent; + color: var(--fg-0); + font: inherit; + font-size: 13px; + display: flex; + align-items: center; gap: 8px; + padding: 7px 9px; + border-radius: 8px; + cursor: pointer; + text-align: left; } -.home__link-button { - align-self: flex-start; - background: none; - border: none; - padding: 0; +.board-card__menu button:hover { + background: var(--glass-hover); +} + +.board-card__menu button:disabled { + opacity: 0.4; + cursor: default; +} + +/* Folder card */ + +.folder-card { + appearance: none; + display: flex; + align-items: center; + gap: 12px; + padding: 16px; + border-radius: 16px; + border: 1px solid var(--glass-stroke); + background: var(--glass-tint); + color: var(--fg-0); + font: inherit; + text-align: left; + cursor: pointer; + backdrop-filter: blur(20px) saturate(180%); + -webkit-backdrop-filter: blur(20px) saturate(180%); + transition: transform 150ms ease, box-shadow 150ms ease, outline-color 150ms ease; +} + +.folder-card:hover { + transform: translateY(-2px); + box-shadow: 0 10px 28px rgba(0, 0, 0, 0.18); +} + +.folder-card--over { + outline: 2px dashed var(--accent); + outline-offset: 2px; +} + +.folder-card__icon { color: var(--accent); + flex: none; +} + +.folder-card__meta { + display: flex; + flex-direction: column; + min-width: 0; +} + +.folder-card__name { + font-size: 14px; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.folder-card__count { font-size: 12px; - cursor: pointer; - text-decoration: underline; + color: var(--fg-1); } /* ----- Collaboration bar (top-right) ------------------------------------- */ @@ -343,53 +687,76 @@ a { background: var(--canvas-bg); } -/* The left ToolPalette was replaced by the bottom Liquid Glass Dock in M7. */ +/* ----- Contextual selection toolbar -------------------------------------- */ +/* Floats adjacent to the selected objects (Freeform-style) instead of a + fixed side panel; detail controls live in popovers off each button. */ -.selection-inspector { - position: absolute; - right: 16px; - top: 50%; - transform: translateY(-50%); +.sel-toolbar { + position: fixed; + z-index: 25; display: flex; - flex-direction: column; - gap: 12px; - padding: 12px; - width: 200px; - border-radius: 18px; - background: var(--glass-tint); + align-items: center; + gap: 2px; + padding: 5px 6px; + border-radius: 14px; + background: var(--popover-bg); 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; + animation: sel-toolbar-in 160ms cubic-bezier(0.2, 0.8, 0.2, 1); } -.selection-inspector__row { - display: flex; - align-items: center; - gap: 8px; +@keyframes sel-toolbar-in { + from { + opacity: 0; + transform: translateY(4px); + } } -.selection-inspector__row--swatches { - flex-wrap: wrap; - gap: 6px; - justify-content: space-between; +@media (prefers-reduced-motion: reduce) { + .sel-toolbar { + animation: none; + } } -.selection-inspector__label { - font-size: 12px; - color: var(--fg-1); - width: 52px; - flex: none; +.sel-toolbar__btn { + appearance: none; + border: 0; + background: transparent; + color: var(--fg-0); + width: 34px; + height: 34px; + border-radius: 9px; + display: grid; + place-items: center; + cursor: pointer; + transition: background 140ms ease, color 140ms ease; } -.selection-inspector__swatches { - display: flex; - flex-wrap: wrap; - gap: 6px; +.sel-toolbar__btn:hover { + background: var(--glass-hover); } -.selection-inspector__none { +.sel-toolbar__btn--active, +.sel-toolbar__btn--active:hover { + background: color-mix(in srgb, var(--accent) 24%, transparent); + color: var(--fg-0); +} + +.sel-toolbar__btn--danger:hover { + background: rgba(255, 69, 58, 0.16); + color: #ff453a; +} + +.sel-toolbar__chip { + width: 20px; + height: 20px; + border-radius: 50%; + border: 1px solid var(--swatch-stroke); +} + +.sel-toolbar__chip--none { background: linear-gradient( 135deg, @@ -401,42 +768,58 @@ a { var(--field-bg); } -.selection-inspector__range { - flex: 1; - accent-color: var(--accent); +.sel-toolbar__divider { + width: 1px; + height: 20px; + margin: 0 3px; + background: var(--glass-stroke); } -.selection-inspector__num { - width: 64px; - background: var(--field-bg); - border: 1px solid var(--glass-stroke); +.sel-toolbar__unlock { + appearance: none; + border: 0; + background: transparent; color: var(--fg-0); - border-radius: 8px; - padding: 4px 8px; + font: inherit; font-size: 13px; + font-weight: 600; + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border-radius: 9px; + cursor: pointer; } -.selection-inspector__actions { - display: grid; - grid-template-columns: repeat(4, 1fr); +.sel-toolbar__unlock:hover { + background: var(--glass-hover); +} + +.sel-toolbar__palette { + display: flex; + flex-wrap: wrap; gap: 6px; - padding-top: 8px; - border-top: 1px solid var(--glass-stroke); + max-width: 240px; } -.selection-inspector__thickness { +.sel-toolbar__stack { + display: flex; + flex-direction: column; + gap: 8px; +} + +.sel-toolbar__row { display: flex; - gap: 4px; align-items: center; - flex: 1; + gap: 4px; } -.selection-inspector__thickness-btn { +.sel-toolbar__preset { appearance: none; - width: 28px; - height: 28px; + width: 32px; + height: 32px; border: 0; - border-radius: 6px; + border-radius: 8px; background: var(--segment-bg); color: var(--fg-0); cursor: pointer; @@ -445,66 +828,29 @@ a { transition: background 140ms ease; } -.selection-inspector__thickness-btn:hover { +.sel-toolbar__preset:hover { background: var(--glass-hover); } -.selection-inspector__thickness-btn--active { +.sel-toolbar__preset--active, +.sel-toolbar__preset--active:hover { background: var(--preset-active-bg); color: var(--preset-active-fg); } -.selection-inspector__btn { - appearance: none; - height: 30px; - border-radius: 8px; - border: 1px solid transparent; +.sel-toolbar__num { + width: 56px; background: var(--field-bg); - color: var(--fg-0); - font-size: 15px; - cursor: pointer; - display: grid; - place-items: center; - transition: background 150ms ease; -} - -.selection-inspector__btn:hover { - background: var(--glass-hover); -} - -.selection-inspector__btn--active { - background: color-mix(in srgb, var(--accent) 24%, transparent); - border-color: var(--accent); -} - -/* Inline segmented button group (e.g. text alignment). */ -.selection-inspector__seg { - display: grid; - grid-auto-flow: column; - grid-auto-columns: 1fr; - gap: 6px; - flex: 1; -} - -.selection-inspector__lock { - appearance: none; border: 1px solid var(--glass-stroke); - background: var(--field-bg); color: var(--fg-0); - border-radius: 999px; - padding: 8px 12px; + border-radius: 8px; + padding: 5px 8px; + font: inherit; font-size: 13px; - cursor: pointer; - transition: background 150ms ease, border-color 150ms ease; -} - -.selection-inspector__lock:hover { - background: var(--glass-hover); } -.selection-inspector__lock--on { - background: rgba(255, 214, 10, 0.18); - border-color: rgba(255, 214, 10, 0.5); +.sel-toolbar__opacity-popover { + width: min(260px, calc(100vw - 24px)); } .board__home-link { diff --git a/docs/redesign/01-ux-audit.md b/docs/redesign/01-ux-audit.md new file mode 100644 index 0000000..1ebff16 --- /dev/null +++ b/docs/redesign/01-ux-audit.md @@ -0,0 +1,183 @@ +# 1 · UX Audit & Redundancy Analysis + +Audit of the product as found at commit `b358df2`, file-level evidence +included. Items marked ✅ are fixed in the redesign PR that adds this +document; the rest are sequenced in [the roadmap](07-roadmap.md). + +## 1. Object manipulation + +### 1.1 No resize handles anywhere ✅ +`TransformLayer` existed (`packages/canvas/src/layers/TransformLayer.tsx`) +but was **imported and never mounted** in `CanvasStage.tsx`; the computed +`overlayShapes` list was also unused, and the dashed selection box was drawn +for the *entire* selection. Net effect: no object on the board — image, PDF, +video, sticky, shape, or text — could be resized or rotated at all. This is +the single largest capability gap versus every benchmarked product. + +Even when mounted, the old transformer covered only `rect/ellipse/text/ +asset/embed`. Polygon, sticky, and stroke were move-only, and line/arrow had +no way to edit endpoints after creation. + +**Fix shipped:** transformer mounted; all kinds transformable (strokes bake +the node's affine transform into their points and scale brush size; stickies +scale their type with the note; media is aspect-locked); lines/arrows get +Freeform-style draggable endpoint dots with 45° shift-constrain. + +### 1.2 No alignment assistance ✅ +`SelectTool.ts` translated shapes raw — no snapping, no guides, no align or +distribute commands anywhere in the product. For a teaching tool whose boards +are full of labelled diagrams and worksheet layouts, this made tidy layout +nearly impossible. + +**Fix shipped:** magnetic edge/center snapping with guide lines +(`tools/snapping.ts`), align/distribute commands (`tools/alignOps.ts`) +surfaced in the selection toolbar's Arrange popover. Equal-gap (spacing +match) snapping is roadmap M-C. + +## 2. Global vs. contextual controls + +### 2.1 The fixed right-edge inspector ✅ +`SelectionInspector` rendered as a 200 px panel pinned to the right edge, +vertically centered (`apps/web/src/styles.css .selection-inspector`), +regardless of where the selection was. On an iPad in landscape the cursor/ +finger travel from a selected sticky at the left edge to its color control +was the full width of the screen. It also occluded canvas content and +appeared even for single tiny objects. + +**Fix shipped:** contextual `SelectionToolbar` floating 12 px above the +selection (below when the dock would occlude it), horizontally clamped, +icon-first with popovers for detail. Locked selections collapse to a single +"Unlock" affordance, as in Freeform. + +### 2.2 Hidden commands +Z-order, lock, delete, select-all live only in the `AppMenu` text menus +(`File/Edit/View/Arrange`) and keyboard shortcuts. The Arrange menu +duplicates the inspector's z-order buttons — two homes for the same four +commands, neither discoverable on touch. The selection toolbar now carries +these; the desktop-style text menu bar itself is queued for consolidation +into a single board menu (roadmap M-E) since persistent `File Edit View +Arrange` reads as a desktop app, not a touch-first canvas. + +## 3. Hover states (light mode) ✅ + +The defect class: generic `:hover` rules carry specificity (0,2,0) while +`--active` modifier classes carry (0,1,0), so **hover always overrode +active backgrounds**. In dark mode `--glass-hover` is translucent white over +a dark dock — the white active glyph survives. In light mode `--glass-hover` +is `rgba(255,255,255,0.8)`: hovering the active Pen painted a near-white pill +under the active state's *white* icon → the icon disappeared (the reported +bug, reproduced from `packages/ui/src/styles.css`). + +The same collision existed for `.glass-button--active`, `.width-preset--active` +(white squiggle on near-white), `.brush-style--active`, `.menu__grid-btn--active`, +`.text-toolbar__btn--active`, and `.collab-bar__btn--active`. + +**Fix shipped:** an `--accent-hover` token plus explicit `--active:hover` +rules so active surfaces brighten *within* the accent ramp instead of +falling back to the glass hover wash. Hover is now additive (it never +reduces contrast), satisfying WCAG 1.4.11 for these states in both themes. + +## 4. Opacity controls ✅ + +- `Slider` had no `min-width: 0` and a 64 px value box at 17 px type: inside + the 280 px mini color picker (or a 320 px phone), the row overflowed its + popover. +- The selection inspector used a **native ``** while the + dock used the custom `Slider` — two different opacity controls with + different geometry and theming. + +**Fix shipped:** slider flexes within any container (`min-width: 0`, compact +tabular-numeral value box); both surfaces now use the same `Slider` in the +checkerboard "opacity" variant. + +## 5. Color system redundancies ✅ + +Three color surfaces, three behaviours: + +| Surface | Palette | Recents | Saved | Hex | Opacity | Eyedropper | +|---|---|---|---|---|---|---| +| Dock `ColorPicker` | 14 | — | ✓ (flat row) | always visible | always visible | ✓ | +| `SelectionInspector` color row | 14 | — | — | — | native range | — | +| Sticky color popover | 5 | — | — | — | — | — | + +Findings: + +- **Is the color wheel necessary? No.** There was no wheel in the shipped + code (a grid + sliders variant existed as dead CSS, `.color-grid` / + `.color-sliders`); benchmarks (Notes, Freeform quick palette, FigJam) + show curated palettes + recents + eyedropper + hex cover the real + distribution of teacher color choices. A wheel optimises for precision + nobody asked for and costs a full extra surface. Dead picker CSS should be + pruned with the menu consolidation (M-E). +- **Recents were missing entirely** — the highest-leverage one-click layer. +- Hex/opacity were *always* expanded, pushing swatches up and making the + common path (tap a color) compete with the rare path. + +**Fix shipped:** one two-layer model used by both the dock picker and the +selection toolbar: quick layer = curated 14 + Recent (auto-tracked, +device-local) + Saved; advanced layer behind a single "More" disclosure = +hex, opacity, save-color. Eyedropper stays in the header (it *is* a quick +action). Sticky colors stay a 5-chip popover — task-specific palettes are a +feature, not a redundancy. + +## 6. Drawing tools ✅ + +`PEN_STYLES` exposed four pen-family styles — pen, fineliner, pencil, marker +— next to five width presets and an opacity slider (`dockStore.ts`). +Fineliner = pen with `width: 2`; it differed by no other rendering property +(`strokeGeometry.ts` treats them identically). Marker and pen differ in cap +and default width; pencil has texture. So exactly one style was pure +redundancy. + +**Fix shipped:** Fineliner removed from the picker (kept as a valid +`StrokeStyle` so old boards render). The model going forward: **one pen +tool · three appearances (Pen/Pencil/Marker) · width presets · opacity** — +appearance × geometry are orthogonal axes, never combined into new "tools". +The highlighter remains a separate dock instrument deliberately: it has +distinct blend semantics (multiply), persistent per-instrument memory, and in +classrooms it is a *mode* (mark the text) rather than a pen look. + +## 7. Iconography ✅ + +- Icons were monochrome **PNG masks** (`sfIcons.ts` → `@2x.png`): blurry at + 3x displays and large sizes, with per-glyph "optical scale" fudge factors + to compensate for inconsistent crops. +- Several names fell back to hand-drawn paths (shapes, sticky, grip, grids, + elbow arrow) that didn't match SF stroke weight. +- Text labels persisted where icons + tooltips would do (inspector rows, + z-order glyph buttons used unicode arrows `⤒↑↓⤓`). + +**Fix shipped:** the supplied SF Symbols SVG set is now the source: 78 +vector glyphs normalized onto a shared 72-unit optical canvas (preserving +SF's relative sizing) and tinted via `currentColor` CSS masks. New glyphs +cover align/distribute/duplicate/opacity/search/star/folder/board. Hand-drawn +fallbacks remain only for `sticky` and `grip` (no SF counterpart). Every +icon-only control keeps `title` + `aria-label`. + +## 8. Information architecture + +Home was a single "New board" button — no list of your boards, no way back +to yesterday's lesson except browser history, no grouping, no search +(`routes/Home.tsx`). For a teacher running parallel classes this is the +second-largest gap after resize. The `AppMenu` title slot also shows the +active *page* title where users expect the *board* title. + +**Fix shipped:** the board library (workspace → folders → boards) with +favorites, recents, search, drag-to-file, inline rename — local-first. +Server-side sync of the library (Supabase `folders` table + board metadata) +and board-title unification are roadmap M-B. Full IA in +[02-information-architecture.md](02-information-architecture.md). + +## 9. Other findings (sequenced, not shipped) + +| Finding | Evidence | Disposition | +|---|---|---| +| Eraser width slider absent (presets only) while pen has both | `Dock.tsx` eraser popover | Fold into M-E control polish | +| `EmbedDialog` is reached from two different menus with different anchors | `Dock.tsx`, `AppMenu.tsx` | One insert surface in M-E | +| Snapshots, export, share buried in File menu | `AppMenu.tsx` | Board menu redesign M-E | +| No PDF page navigation beyond board pages | `assets/pdf.ts` | PDF workflow M-D | +| No text style presets (title/body) — only free font size | `TextToolbar.tsx` | M-E | +| Spotlight/follow exist but no laser pointer, timer, or focus mode | `CollabBar.tsx`, `followStore.ts` | Classroom kit M-F | +| `?` No keyboard shortcut overlay | — | M-E | +| Transformer resize lacks snapping (move-only snapping shipped) | `TransformLayer.tsx` | M-C | +| Marquee/endpoint drags don't auto-pan at viewport edges | `CanvasStage.tsx` | M-C | diff --git a/docs/redesign/02-information-architecture.md b/docs/redesign/02-information-architecture.md new file mode 100644 index 0000000..d809928 --- /dev/null +++ b/docs/redesign/02-information-architecture.md @@ -0,0 +1,76 @@ +# 2 · Information Architecture + +## The hierarchy + +``` +Workspace (you) +├── Favorites (pinned cross-cutting view) +├── Recents (automatic, last-opened order) +├── Folders ("Year 9 English", "ESL Content", …) +│ └── Boards +├── Boards (unfiled, workspace root) +└── Archive (roadmap M-B: out of sight, never deleted) + +Board +├── Pages (ordered, renameable — the existing page tray) +│ └── Objects (ink, text, stickies, shapes, media, PDF pages) +└── Board settings (background, grid, sharing, snapshots) +``` + +This matches the model teachers already hold from GoodNotes (folders → +notebooks → pages) and Freeform (boards list → board), with one deliberate +difference: **Favorites and Recents are views, not places**. A board lives in +exactly one folder (or the root); starring or opening it never moves it. + +## Home (the workspace) — shipped + +`apps/web/src/routes/Home.tsx` + `features/library/libraryStore.ts`: + +- **Top bar**: workspace identity, search (filters all boards by title, + flat results), theme toggle, New folder, New board. +- **Sections** in order: Favorites (only if any), Recents (last 6), Folders + (cards with board counts), Boards (unfiled). +- **Folder view**: tap a folder card → its boards + rename/delete controls; + "New board" inside a folder files the new board there. +- **Card affordances**: open on tap; star toggle; ⋯ menu (rename, move to + folder, move to workspace, remove from library); **drag a board card onto + a folder card to file it** — same gesture as iPadOS Files. +- **Empty state**: a single clear call to action; the library teaches itself + as entries appear automatically when boards are created or visited. + +### Local-first by design + +The library indexes in `localStorage` (`notux-library`), so it works +identically in local-only mode and Supabase mode, including for boards you +visited via a shared link but don't own. Roadmap **M-B** adds the +authoritative server layer for signed-in users: + +- `folders(id, owner_id, name, created_at)` + `boards.folder_id`, + `boards.starred`, `boards.last_opened_at`, `boards.archived_at` +- Home merges: server boards (owned) ∪ local visits (shared/anonymous), + server wins for titles; the board's title becomes the `boards.title` + column everywhere (fixing the page-title/board-title conflation in + `AppMenu`). +- Shared-with-me section sourced from boards the user opened that are owned + by someone else. + +## Inside a board + +Persistent chrome is capped at **three regions** (plus the save status pill): + +| Region | Contents | Rationale | +|---|---|---| +| Top-left `AppMenu` | board menu, page switcher | navigation + rare commands | +| Top-center `Dock` | tools, color, insert | the only always-needed surface | +| Top-right `CollabBar` | presence, share, spotlight | collaboration is glanceable | + +Everything else is **contextual**: selection toolbar by the selection, text +toolbar by the text caret, popovers off their buttons. Nothing else may be +permanently pinned to an edge — that rule is what keeps the canvas feeling +infinite on an 11″ iPad. + +Pages stay a flat ordered list per board (the current model). Sections +within pages are intentionally **not** added: for lesson flows, multiple +pages + folders cover the organisational need with one less concept; the +benchmark products that added section trees (Miro frames-as-pages, OneNote) +pay for it with a second navigation surface. diff --git a/docs/redesign/03-design-system.md b/docs/redesign/03-design-system.md new file mode 100644 index 0000000..aa29a58 --- /dev/null +++ b/docs/redesign/03-design-system.md @@ -0,0 +1,118 @@ +# 3 · Design System & Visual Language + +The system already speaks Apple (`@notux/ui`, "Liquid Glass"); this redesign +hardens it into rules, fixes its contrast defects, and inventories every +component so new UI composes instead of improvising. + +## 3.1 Material: Liquid Glass + +One glass recipe, three intensities. All values are tokens in +`packages/ui/src/styles.css`; **components must consume tokens, never raw +colors.** + +| Material | Use | Recipe | +|---|---|---| +| **Chrome glass** | dock, app menu, collab bar | `--dock-bg` tint · `blur(30px) saturate(180%)` · 1 px `--glass-stroke` · `--shadow-lg` | +| **Surface glass** | popovers, sheets, selection toolbar, cards | `--popover-bg` (more opaque: content sits *on* it) · `blur(28px)` | +| **Inset glass** | fields, segmented tracks, preset chips | `--field-bg` / `--segment-bg`, no blur (they sit on glass already) | + +Physicality rules: + +- Glass **stacks at most twice** (canvas → panel → popover). A popover over a + panel is the depth ceiling; never blur-on-blur-on-blur. +- Light comes from above: panels carry the soft drop (`--shadow-lg`); inset + elements carry none. +- Translucency must never cost legibility: anything textual sits on surface + glass, not chrome glass. +- **Liquid Glass refraction tier** (roadmap M-E): specular top edge + (`inset 0 1px 0 rgba(255,255,255,…)` — already on `.lg-button`) promoted + to a `--glass-bevel` token on dock + popovers, plus a barely-there inner + gradient for curvature. Subtle > showy; no animated refraction. + +## 3.2 Color tokens + +| Token | Dark | Light | Notes | +|---|---|---|---| +| `--accent` | `#5ac8fa` | `#0a84ff` | selection, active states | +| `--accent-hover` | `#74d2fb` | `#2e95ff` | **new** — hover stays inside the accent ramp | +| `--snap-guide` | `#ffd60a` | `#ff9500` | **new** — alignment guides (Keynote yellow/amber) | +| `--selection` / `--selection-fill` | blue pair | blue pair | marquee + handles | +| `--fg-0` / `--fg-1` | 92% / 62% white | 92% / 55% ink | two text tiers only | + +**State law (the light-mode-hover fix, now a rule):** + +> *Hover is additive.* A hover state may brighten or tint a control but may +> never replace an active/accent surface with a neutral one. Concretely: +> every `--active` class ships a paired `--active:hover` rule, and active +> surfaces hover toward `--accent-hover`. Glyphs on accent are always white; +> glyphs on glass are always `--fg-0/1`. Minimum 3:1 contrast for icons +> against their effective backdrop in both themes. + +### Ink palette (canvas content) + +14 curated colors (`apps/web/.../palette.ts`): 6 grays + 8 hues from the +Apple system family — chosen to survive both paper colors via the existing +adaptive-ink mapping (`theme/adaptiveInk.ts`). User-extensible through +Recent + Saved, not by widening the grid. + +## 3.3 Type, radius, spacing, motion + +- **Type**: system stack (SF on Apple hardware). Tiers: 11 section labels · + 13 controls · 14 menu/body · 18 panel titles · 22 page titles. Two + weights (500/600–700). Tabular numerals for live values. +- **Radius grammar**: 999 pills/swatches · 16–18 panels & cards · 8–10 + buttons/fields · 6 inner chips. Nothing square. +- **Spacing**: 4-px base grid; popover padding 12; section gap 14–18. +- **Hit targets**: ≥ 34 px pointer, ≥ 40 px primary touch, 44 px dock tools. +- **Motion**: 120–220 ms, `cubic-bezier(0.2, 0.8, 0.2, 1)`; popovers scale + from their anchor (`--tail-x` transform-origin — already shipped); every + animation gated by `prefers-reduced-motion` (shipped). No springs longer + than 300 ms — this is a tool, not a toy. + +## 3.4 Iconography + +- **Source**: Apple SF Symbols, as **vector SVGs** from project assets + (`packages/ui/src/icons/assets/`), normalized onto a shared 72-unit square + canvas so glyphs keep SF's relative optical sizing, rendered as + `currentColor` CSS masks (`Icon.tsx`). +- Icon-only controls are the default; **every one carries `title` + + `aria-label`** (tooltip = discoverability, label = accessibility). +- Text labels are reserved for: destructive confirmations, empty states, + menu rows, and the pen-style presets (Pen/Pencil/Marker — names *are* the + mental model there). +- One metaphor per concept across the app: e.g. `square.and.arrow.up` is + share/export everywhere; layers-3d glyphs are z-order everywhere. + +## 3.5 Component inventory + +### `@notux/ui` (primitives) +| Component | Status | Notes | +|---|---|---| +| `GlassPanel` | shipped | chrome/surface glass container | +| `GlassButton` | shipped | + contrast-safe active-hover (this PR) | +| `Popover` | shipped | anchored, auto-flip, tail, esc/outside-close | +| `Sheet` | shipped | popover→bottom-sheet at ≤640 px | +| `Segmented` | shipped | | +| `Slider` | shipped | container-safe + compact value (this PR) | +| `Swatch` | shipped | plain/none/rainbow variants | +| `Icon` | shipped | vector SF masks (this PR) | +| `Instrument` | shipped | pen illustrations (dock future) | +| `Tooltip` (rich) | M-E | today: native `title` | +| `Menu` (roving-focus) | M-E | extracted from AppMenu patterns | +| `Dialog` (modal confirm) | M-E | destructive actions | +| `Toast` | M-E | export done, link copied | + +### App / canvas components +| Component | Status | Notes | +|---|---|---| +| `Dock` | shipped | tool instruments + popovers | +| `ColorPicker` | **reworked** | two-layer quick/advanced | +| `SelectionToolbar` | **new** | contextual; replaces `SelectionInspector` (deleted) | +| `TransformLayer` | **reworked** | universal resize/rotate, aspect-locked media | +| `OverlayLayer` | **reworked** | + snap guides, endpoint handles | +| `SelectTool` | **reworked** | + snapping, endpoint drag | +| `alignOps` / `snapping` | **new** | align, distribute, magnetic snap | +| `viewportStore` | **new** | world→screen projection for DOM chrome | +| Library (`Home`, `libraryStore`) | **new** | folders/favorites/recents/search | +| `AppMenu` | unchanged | consolidation queued (M-E) | +| `CollabBar`, `FollowPill`, `SaveStatus`, `SnapshotsPanel`, `TextToolbar`, `EmbedDialog` | unchanged | M-E/M-F touchpoints | diff --git a/docs/redesign/04-interaction-specs.md b/docs/redesign/04-interaction-specs.md new file mode 100644 index 0000000..09f1837 --- /dev/null +++ b/docs/redesign/04-interaction-specs.md @@ -0,0 +1,105 @@ +# 4 · Interaction Design Specifications + +Normative specs for the interactions shipped in this PR plus the queued +tier. World-space values scale with zoom unless marked *screen*. + +## 4.1 Selection & transform + +**Select** (pointer/finger, Select tool): +- Tap object → select. Shift-tap → toggle membership. Tap empty → clear + + marquee. Locked objects select (amber dash) but never drag. +- Drag selected object(s) → translate, snapshot-based (no drift), single + undo entry per gesture. + +**Transform handles** (all kinds except line/arrow): +- 8 resize anchors + rotate handle (Konva Transformer), min size 5 px, + `ignoreStroke`. +- **Aspect lock**: any selection containing an image/PDF/embed resizes + proportionally; text scales its font with height; stickies scale type by + √(sx·sy); strokes bake the affine transform into points and scale brush + size by mean |scale|. +- Transform end commits one transaction → one undo step. + +**Line/arrow endpoints** (single selection): +- Two ⌀14 *screen*-px hit targets (7 px visual dots, white fill, accent + ring) at each endpoint; endpoint hit-test wins over body hit-test. +- Drag endpoint → reshape; **Shift** constrains to 45° steps around the + fixed endpoint; endpoints participate in snapping. +- In multi-selections lines show the dashed box and translate only. + +## 4.2 Smart alignment + +- **Targets**: edges + centers (x: left/cx/right, y: top/cy/bottom) of every + unselected shape on the page, collected once per gesture. +- **Tolerance**: 8 *screen* px ÷ zoom (zoom-independent feel). +- **Resolution**: nearest candidate per axis wins; both axes snap + independently and simultaneously. +- **Guides**: 1.5 px lines in `--snap-guide`, spanning the union of the + matched target and the moving bounds, cleared on pointer-up/cancel. +- **Bypass**: hold **Alt/Option** to drag friction-free. +- Applies to: selection drags and endpoint drags (point-snap). *Queued + (M-C)*: resize-snap, equal-gap suggestions, auto-pan at viewport edges, + drag-start guides for sticky grids. + +## 4.3 Contextual selection toolbar + +- **Placement**: horizontally centered on the selection bounds, 12 px above; + flips below when within 72 px of the top (dock safe area); clamped 8 px + from viewport edges; tracks live during drag/transform/viewport changes. +- **Visibility**: any selection in Select mode, hidden during text editing + (the text toolbar owns that moment). Locked-only selection → single + **Unlock** pill. +- **Content** (only applicable controls render): + `[ink chip] [fill chip] [thickness] [text style] [opacity] | [duplicate] [arrange] [lock] [delete]` + - Chips open swatch popovers (palette + recents; fill adds None). + - Thickness: 5 presets + numeric field. Text style: alignment segmented + + font size. Opacity: checkerboard slider. + - Arrange popover: z-order (4); align edges/centers (6) at ≥2 selected; + distribute H/V at ≥3. + - Duplicate: clones +24 px ÷ zoom diagonal, selects the clones. +- All buttons 34 px, icon 17 px, tooltip + `aria-label`; popovers reuse the + standard `Popover` (esc/outside-tap close, auto-flip, tail). + +## 4.4 Color + +- **Quick layer** (default): 14-swatch palette, Recent row (auto-tracked on + every pick, device-local, deduped, max 8), Saved row. One tap = applied. +- **Advanced layer** (one "More" disclosure): hex field (live-validated), + opacity slider, Save color. Eyedropper stays in the header when + `EyeDropper` API exists. +- Identical model in dock picker and selection toolbar; sticky tool keeps + its 5 paper colors. + +## 4.5 Drawing tools + +- **One pen tool.** Styles Pen/Pencil/Marker are *appearances* of it; width + (5 presets/instrument) and opacity are orthogonal. Highlighter and eraser + are separate instruments (different semantics, not different looks). +- Each instrument remembers its own color/width/opacity (shipped behaviour, + unchanged) — switching Pen→Highlighter→Pen restores your pen exactly. +- Active tool tap re-opens its options popover (shipped pattern). +- *Queued (M-C/M11)*: pressure tuning, palm rejection, wet-ink layer. + +## 4.6 Canvas navigation + +Unchanged and confirmed good: wheel = pan, ⌘/Ctrl+wheel = zoom-at-cursor, +space/hand/middle-drag = pan, pinch on touch. Manual gestures break follow +mode (`breakFollow`) — correct for student independence. *Queued*: zoom +pill with fit-to-content (M-E), two-finger double-tap zoom-out (M-G). + +## 4.7 Keyboard map (current + shipped) + +| Keys | Action | +|---|---| +| ⌘Z / ⌘⇧Z | undo / redo | +| ⌘A | select all | +| Delete | delete selection (locked skipped) | +| ⌘] / ⌘[ (+⇧) | forward/backward (front/back) | +| ⌘⇧L | lock toggle | +| Space-drag | pan | +| Shift (drag endpoint) | 45° constrain | +| **Alt (drag)** | **bypass snapping** *(new)* | +| Esc | clear selection / close popover / end text edit | + +*Queued (M-E)*: tool keys (V P H E T S N), ⌘D duplicate, arrow-key nudge +(1 px / 10 px with Shift), `?` shortcut overlay. diff --git a/docs/redesign/05-workflows.md b/docs/redesign/05-workflows.md new file mode 100644 index 0000000..41fbbcb --- /dev/null +++ b/docs/redesign/05-workflows.md @@ -0,0 +1,86 @@ +# 5 · Teacher & Student Workflows + +The product's identity: **a tutoring whiteboard, not a generic canvas**. The +benchmark products optimise for workshops (Miro/FigJam) or solo notes +(GoodNotes/Notability); NotUX's differentiation is the 1:1 / small-group +live lesson built around documents and ink. + +## 5.1 Lesson lifecycle + +**Prepare** (teacher, before class) +- Library folders per class/course (shipped); duplicate last week's board as + a starting point (object duplicate shipped; board duplicate M-B). +- Import textbook PDF / worksheet images / audio (shipped); lay out with + snapping + align/distribute (shipped); lock the "worksheet" layer of + objects so students can't move it (lock shipped — *bulk "lock background" + action* M-D). + +**Teach** (live) +- Spotlight mode: students auto-follow the teacher's viewport (shipped); + any student gesture breaks follow for independent exploration, with the + Following pill to re-engage (shipped). +- Annotate over content with pen/highlighter; the contextual toolbar keeps + object fixes (resize, recolor, move) at the selection (shipped). +- **M-F classroom kit** (queued): laser pointer (transient awareness-only + stroke, auto-fade ~1.5 s); presentation mode (chrome hides except dock + collapse pill); page-fit "slide" navigation (page = slide); focus mode + (dim everything except the spotlighted region); lesson timer pill. + +**Assign & review** (homework) +- Today: share link + student annotates; snapshots give before/after. +- **M-F homework mode** (queued): teacher marks a board "assignment" → + each student gets a private copy under the source board; the teacher's + review view lists copies; a **feedback ink layer** (teacher color locked, + toggleable) rides on top of student work. This builds on the existing + snapshot + ownership schema (`boards.owner_id`, `snapshots`). + +## 5.2 PDF-centric learning + +Current state: PDFs import as per-page raster `asset` shapes (good bones: +lazy rasterisation, storage-backed). Queued design (M-D), synthesising +GoodNotes/Notability/LiquidText: + +1. **Annotate-on-document**: imported PDF pages get *Lock to background* by + default (one tap to undo) so ink lands *over* the page and the page never + drifts mid-lesson. Ink remains board ink — no PDF mutation — preserving + multiplayer semantics. +2. **Write-around margin**: the infinite canvas *is* the margin — guidance + plus snap targets at page edges make "notes beside the page, arrows into + it" the natural layout (LiquidText's key insight, free on our canvas). +3. **Document navigator**: a popover thumbnail rail per imported document + (asset pages already know their `pageIndex`) → jump-to-page recenters the + viewport; large documents stay navigable without scrubbing the canvas. +4. **Anchored notes**: a sticky/text dropped on a page records its parent + asset id and ships with it when the page is moved/resized (lightweight + grouping, not a layout engine). +5. **Export round-trip**: existing PDF export already composites ink over + pages; M-D adds "export this document's pages only". + +## 5.3 Multimedia teaching + +Shipped: image/PDF/audio import (drag-drop or dock), YouTube/Drive embeds, +all now resizable/movable/lockable with aspect-locked handles; audio/video +play in-place via the HTML overlay. Queued: URL bookmark cards via an +unfurl edge function (M14 in the legacy roadmap), embed registry +generalisation (M15), and "find media" — a board-level media list popover +(M-E) so a teacher can jump to the listening exercise instantly. + +## 5.4 Student usage + +- **Homework annotation**: open shared board → pen defaults, contextual + toolbar, snapping — same vocabulary as the teacher's (no separate "student + UI" to learn). +- **Collaborative notes**: presence cursors + selections (shipped); + per-author attribution exists on every shape (`author`) → M-F adds an + author filter ("show only Maria's ink") for review. +- **Revision**: favorites + recents (shipped) make "the board from last + week" two taps; M-D's document navigator makes "page 34 of the textbook" + two taps more. + +## 5.5 Accessibility & inclusivity (cross-cutting) + +- Icon controls all carry labels/tooltips (shipped); contrast-safe states in + both themes (shipped); reduced-motion honoured (shipped). +- Queued: full keyboard traversal of menus (M-E `Menu` primitive), 44 px + touch audit on dense popovers (M-G), dyslexia-friendly font option for + text objects (M-G), captions field on audio embeds (M-G). diff --git a/docs/redesign/06-benchmarks.md b/docs/redesign/06-benchmarks.md new file mode 100644 index 0000000..fc4a061 --- /dev/null +++ b/docs/redesign/06-benchmarks.md @@ -0,0 +1,75 @@ +# 6 · Benchmark Analysis + +Nine products evaluated on the axes that matter for a teaching whiteboard. +"→ NotUX" = what we adopt (or deliberately reject). + +## Apple Freeform +- **Toolbar**: single bottom dock, ~7 items; everything else contextual. +- **Objects**: universal resize handles, aspect-locked media, alignment + guides, inline contextual menu on selection. +- **IA**: flat board list + favorites/recents/shared smart views. +- → **Adopted heavily**: contextual selection toolbar, universal transform, + guide behaviour, "views not places" library, locked→Unlock-only + affordance. Rejected: Freeform's shape *library* breadth (hundreds of + clipart shapes) — out of scope for v1; 9 shape primitives suffice. + +## Apple Notes (iPadOS) +- **Tool palette**: instruments with per-instrument memory, width via + preset dots, tap-active-tool-for-options. Quick 6-color row + wheel + behind it. +- → **Adopted**: instrument memory (already shipped in dockStore), tap-again + for options, quick palette + disclosure model. Rejected: the color wheel + (audit §5) and the ruler (snapping + shift-constrain cover the classroom + cases). + +## GoodNotes / Notability +- **PDF**: page-anchored ink, document thumbnails, lasso everything; + Notability's audio-synced notes are beloved for revision. +- **IA**: folders → notebooks with visual covers; favorites. +- → **Adopted**: folder library with counts (shipped), document navigator + + page-anchored annotation design (M-D), audio-on-board as first-class + (already shipped via embeds; sync-to-ink is a far-future idea, not + roadmapped — heavy and patent-adjacent). + +## LiquidText +- **Killer pattern**: document + infinite workspace beside it; excerpts + linked back to source pages. +- → **Adopted as stance**: NotUX's PDF pages live *on* the canvas, so + "write around the document" is native. Excerpt-link-back is M-D's + anchored-notes lite; full citation linking rejected (research tool, not + lesson tool). + +## FigJam +- **Contextual property bar above selection** — the closest analogue to our + new SelectionToolbar; snap + smart gap matching; cursor chat & stamps. +- → **Adopted**: toolbar placement/flip behaviour, snap feel (8 px, + zoom-independent), align/distribute set. Queued: gap matching (M-C). + Stamps/reactions considered for M-F (lightweight feedback in lessons). + +## Miro +- Deep facilitation kit (voting, timers, frames, presentation paths). +- → **Adopted selectively**: timer + presentation flow land in M-F as + lesson-shaped features. Rejected: frames-as-sections, template + marketplace, app ecosystem — complexity NotUX exists to avoid. + +## Excalidraw +- Minimal tool count, keyboard-first, hand-drawn aesthetic, local-first. +- → **Adopted**: tool minimalism bar (every dock item must defend itself), + Alt-to-bypass-snap, local-first library ethos (shipped). Rejected: the + sketchy rendering style (wrong tone for textbook annotation). + +## Concepts +- Infinite *vector* ink with per-stroke editability, tool wheel. +- → **Adopted**: stroke transformability (shipped — strokes scale/rotate as + real geometry). Rejected: the radial tool wheel (expert-niche, poor + discoverability for students). + +## Synthesis — where NotUX positions +| Axis | Position | +|---|---| +| Tool count | Excalidraw-minimal (10 dock items incl. chrome) | +| Object manipulation | Freeform-grade handles + FigJam-grade snapping | +| Color | Notes-style quick layer, no wheel | +| IA | GoodNotes folders × Freeform smart views | +| Collaboration | spotlight/follow now; lesson kit (M-F) is the moat | +| PDF | GoodNotes annotation comfort on a LiquidText-style canvas (M-D) | diff --git a/docs/redesign/07-roadmap.md b/docs/redesign/07-roadmap.md new file mode 100644 index 0000000..4b7aac8 --- /dev/null +++ b/docs/redesign/07-roadmap.md @@ -0,0 +1,78 @@ +# 7 · Implementation Roadmap + +Sequenced by **impact ÷ complexity** for the teaching use case. M-A shipped +with this PR; M11/M12/M14–M16 from `docs/ROADMAP.md` remain valid and are +slotted where they pay off most. Each tier is independently shippable. + +## M-A — Foundation tier ✅ (this PR) + +| Item | Impact | Complexity | +|---|---|---| +| Mount + universalise transform (all kinds, aspect-locked media, stroke baking, endpoint handles) | ★★★★★ | M | +| Magnetic snapping + guides + align/distribute | ★★★★★ | M | +| Contextual selection toolbar (replaces fixed inspector) | ★★★★★ | M | +| Light-mode hover/active contrast system (`--accent-hover`, paired rules) | ★★★★ | S | +| Opacity slider containment + unification | ★★★ | S | +| Two-layer color picker + recents | ★★★★ | S | +| Pen style dedup (retire Fineliner from picker) | ★★ | S | +| Vector SF Symbols (normalized SVG masks) + new glyphs | ★★★ | S | +| Board library: folders/favorites/recents/search/drag-to-file (local-first) | ★★★★★ | M | + +## M-B — Library sync & board identity (next) + +Server-side `folders` table, `boards.{folder_id,starred,last_opened_at,archived_at}`, +RLS owner-scoped; Home merges server boards with local visits; board title +unified onto `boards.title` (AppMenu shows board, page tray shows pages); +board duplicate; archive view. *Impact ★★★★ · Complexity M (migration + +merge logic).* + +## M-C — Manipulation polish + +Resize-time snapping; equal-gap (spacing-match) suggestions; auto-pan while +dragging at viewport edges; ⌘D duplicate + arrow-key nudge; multi-line +endpoint editing. Fold in legacy **M11 native ink feel** (coalesced events, +wet-ink layer, palm rejection) and **M12 rbush culling** — manipulation and +ink quality are one perceived feature. *Impact ★★★★ · Complexity M–L.* + +## M-D — PDF-centric learning + +Lock-to-background default for imported pages; document navigator popover +(per-asset page thumbnails → recenter); anchored notes (sticky/text records +parent asset); bulk "lock background"; export-this-document. Verify lazy +per-page rasterisation (legacy M14 item). *Impact ★★★★★ for the audience · +Complexity L.* + +## M-E — Chrome consolidation & input breadth + +Single board menu replacing File/Edit/View/Arrange text bar (icon trigger, +sections: Board · Insert · View · Share); one insert surface (dock + menu +merge); zoom pill with fit-to-content; tool shortcuts + `?` overlay; `Menu` +/`Dialog`/`Toast`/rich `Tooltip` primitives; Liquid Glass bevel tier; prune +dead picker CSS; media list popover. *Impact ★★★ · Complexity M.* + +## M-F — Classroom kit (the moat) + +Laser pointer (awareness-only fading stroke); presentation mode + page-fit +navigation; focus mode; lesson timer; homework mode (per-student board +copies + teacher review list); teacher feedback layer; author ink filter; +stamps/reactions. Builds on existing spotlight/follow/awareness plumbing. +*Impact ★★★★★ differentiation · Complexity L (homework mode is the long +pole).* + +## M-G — Platform & inclusivity + +PWA manifest + offline (legacy M16), Capacitor wrappers; embed registry +(legacy M15) + URL unfurl cards (legacy M14); 44 px touch audit; dyslexia- +friendly font option; audio captions; keyboard-traversable menus. +*Impact ★★★ · Complexity M–L.* + +## Sequencing logic + +1. **M-A before everything**: no amount of feature work matters while + objects can't be resized and controls fight their own hover states. +2. **M-B second**: the library is only half-real until it survives device + changes for signed-in teachers. +3. **M-C/M-D interleave** by team shape (canvas-engine vs. product work). +4. **M-F after M-D**: lesson tooling presumes documents behave. +5. **M-G continuous**: platform items ride along where convenient (the PWA + manifest is one afternoon and should not wait for its tier). diff --git a/docs/redesign/README.md b/docs/redesign/README.md new file mode 100644 index 0000000..9f031e8 --- /dev/null +++ b/docs/redesign/README.md @@ -0,0 +1,74 @@ +# NotUX Product Redesign + +A product-wide UX, UI, and interaction redesign of NotUX — the collaborative +infinite whiteboard for teaching — benchmarked against Apple Freeform, Apple +Notes, GoodNotes, FigJam, Excalidraw, Miro, Notability, LiquidText, and +Concepts, and designed for the platform's real audience: teachers and students +in live online lessons. + +This is both a **document set** and a **shipping change set**: the first +implementation tier (foundation fixes + contextual UI + smart alignment + +color/tool simplification + the board library) lands in the same PR that adds +these documents. Everything else is sequenced in the roadmap. + +## Documents + +| # | Document | Covers deliverable(s) | +|---|----------|------------------------| +| 1 | [UX audit & redundancies](01-ux-audit.md) | UX audit of the current platform; list of redundancies and simplification opportunities | +| 2 | [Information architecture](02-information-architecture.md) | Redesigned information architecture | +| 3 | [Design system](03-design-system.md) | New design system proposal; Apple-inspired visual language guidelines; component inventory | +| 4 | [Interaction specifications](04-interaction-specs.md) | Interaction design specifications | +| 5 | [Teacher & student workflows](05-workflows.md) | Teacher/student workflow improvements; PDF-centric learning; collaboration & online classes | +| 6 | [Benchmark analysis](06-benchmarks.md) | Feature benchmark analysis across nine canvas products | +| 7 | [Implementation roadmap](07-roadmap.md) | Roadmap prioritised by impact and complexity, with what already shipped | + +## Design principles (applied throughout) + +1. **Minimalist but powerful** — fewer tools, each one deeper. One pen with + styles beats four pens. One color surface with layers beats three pickers. +2. **Contextual over persistent** — controls appear next to the thing being + edited and disappear when it isn't. The only persistent chrome is the dock, + the app menu, and the collaboration bar. +3. **One-click common path** — the colors, widths, and actions used in the + first minute of a lesson are never more than one tap away; everything else + sits exactly one disclosure deeper. +4. **Touch- and Pencil-first** — 34–44 px targets, drag affordances, no + hover-dependent functionality (hover only *adds* affordance). +5. **Physical glass** — translucency, depth, and motion follow one material + system (`@notux/ui`), not per-component improvisation. +6. **Accessible by default** — contrast-safe hover/active states in both + themes, focus rings, `prefers-reduced-motion`, ARIA labels and tooltips on + every icon-only control. + +## What shipped with this PR + +- **Universal resize/rotate** — every object kind (strokes, stickies, + polygons, text, images, PDFs, audio/video embeds) gets transform handles; + lines/arrows get draggable endpoints; media resizes aspect-locked. The + Transformer had regressed out of the canvas entirely — it is now mounted + and extended. +- **Smart alignment** — magnetic edge/center snapping with Keynote-style + guides while dragging, zoom-independent tolerance, Alt to bypass; align and + distribute commands for multi-selections. +- **Contextual selection toolbar** — replaces the fixed right-edge inspector; + floats adjacent to the selection, repositions/flips intelligently, shows + only applicable controls, adds one-click Duplicate / Lock / Delete and an + Arrange popover. +- **Light-mode hover fix** — active controls no longer vanish on hover; the + hover/active state system is now contrast-safe by construction (see audit + §3). +- **Opacity slider fixes** — sliders are bound to their containers, compact, + and consistent across surfaces. +- **Two-layer color system** — quick layer (curated palette, recents, saved) + one click away; hex/opacity/eyedropper behind a single disclosure. The + color wheel is retired (rationale in audit §5). +- **Drawing tool simplification** — Fineliner retired as a separate style + (it duplicated Pen at a thin width); Pen/Pencil/Marker remain as styles of + the single pen tool, with back-compat rendering for existing boards. +- **Vector SF Symbols** — the PNG icon masks are replaced by the SVG + SF Symbols set (normalized onto a uniform optical canvas), with new glyphs + for align/distribute/duplicate/opacity/library actions. +- **Board library** — Home is now a workspace: folders, favorites, recents, + search, drag-and-drop filing, inline rename (local-first; server sync is + roadmap M-B). diff --git a/packages/canvas/src/CanvasStage.tsx b/packages/canvas/src/CanvasStage.tsx index 2a38335..2209aee 100644 --- a/packages/canvas/src/CanvasStage.tsx +++ b/packages/canvas/src/CanvasStage.tsx @@ -17,6 +17,7 @@ import { useCommandStore } from "./store/commandStore"; import { useDraftStore } from "./store/draftStore"; import { usePrefsStore } from "./store/prefsStore"; import { useSettingsStore } from "./store/settingsStore"; +import { useViewportStore } from "./store/viewportStore"; import { DEFAULT_PAGE_ID } from "./store/pageStore"; import { resolveInkColor } from "./theme/adaptiveInk"; import { @@ -204,6 +205,12 @@ export function CanvasStage({ }); }, [viewport, size, pageId]); + // Publish the live viewport so DOM chrome (contextual selection toolbar) + // can project world geometry into screen space. + useEffect(() => { + useViewportStore.getState().publish(viewport, size); + }, [viewport, size]); + // Hit test — converts world point to container coords and asks Konva. const hitTestWorld = useCallback( (p: { x: number; y: number }): YShape | undefined => { @@ -365,6 +372,7 @@ export function CanvasStage({ y: w.y, pressure: evt.pressure > 0 ? evt.pressure : 0.5, shift: evt.shiftKey, + alt: evt.altKey, }; } @@ -543,17 +551,23 @@ export function CanvasStage({ [shapes, selection], ); - // The Transformer draws handles for transformable, unlocked shapes; the - // dashed OverlayLayer box covers the rest (strokes, lines, arrows) plus any - // locked shape (which the Transformer skips, and which renders amber). + // A single selected line/arrow gets draggable endpoint dots instead of a + // box; everything else transformable gets the Transformer's handles. The + // dashed OverlayLayer box covers locked shapes (amber) and lines/arrows + // inside a multi-selection. + const endpointShapes = useMemo(() => { + if (selectedShapes.length !== 1) return []; + const s = selectedShapes[0]!; + return (s.kind === "line" || s.kind === "arrow") && !s.locked ? [s] : []; + }, [selectedShapes]); + const overlayShapes = useMemo( () => selectedShapes.filter( (s) => s.locked || - s.kind === "stroke" || - s.kind === "line" || - s.kind === "arrow", + ((s.kind === "line" || s.kind === "arrow") && + selectedShapes.length > 1), ), [selectedShapes], ); @@ -606,10 +620,19 @@ export function CanvasStage({ /> + {tool === "select" && ( + + )} {showRemoteCursors && ( )} diff --git a/packages/canvas/src/index.ts b/packages/canvas/src/index.ts index 570a879..a259034 100644 --- a/packages/canvas/src/index.ts +++ b/packages/canvas/src/index.ts @@ -1,4 +1,10 @@ export { CanvasStage } from "./CanvasStage"; +export { useViewportStore } from "./store/viewportStore"; +export { shapeBounds, translateShape } from "./tools/shapeOps"; +export type { Bounds } from "./tools/shapeOps"; +export { alignShapes, distributeShapes } from "./tools/alignOps"; +export type { AlignEdge } from "./tools/alignOps"; +export { newShapeId } from "./ids"; export { useAwareness } from "./hooks/useAwareness"; export { useRemoteCursors } from "./hooks/useRemoteCursors"; export type { RemoteCursor } from "./hooks/useRemoteCursors"; diff --git a/packages/canvas/src/layers/OverlayLayer.tsx b/packages/canvas/src/layers/OverlayLayer.tsx index 78cbdf5..13f76e7 100644 --- a/packages/canvas/src/layers/OverlayLayer.tsx +++ b/packages/canvas/src/layers/OverlayLayer.tsx @@ -1,5 +1,5 @@ -import type { YShape } from "@notux/types"; -import { Arrow, Ellipse, Layer, Line, Rect } from "react-konva"; +import type { YArrow, YLine, YShape } from "@notux/types"; +import { Arrow, Circle, Ellipse, Layer, Line, Rect } from "react-konva"; import type { DraftStore } from "../store/draftStore"; import { strokeOutline } from "../renderers/strokeGeometry"; import { arrowPoints } from "../renderers/ArrowRenderer"; @@ -12,9 +12,18 @@ import type { ViewportState } from "../viewport/Viewport"; interface Props { draft: Pick< DraftStore, - "stroke" | "rect" | "ellipse" | "polygon" | "line" | "arrow" | "marquee" + | "stroke" + | "rect" + | "ellipse" + | "polygon" + | "line" + | "arrow" + | "marquee" + | "guides" >; selectedShapes: YShape[]; + // Single-selected line/arrow: draws draggable endpoint dots instead of a box. + endpointShapes?: Array; viewport: ViewportState; darkCanvas?: boolean; } @@ -26,12 +35,14 @@ interface Props { export function OverlayLayer({ draft, selectedShapes, + endpointShapes = [], viewport, darkCanvas = false, }: Props) { const handleSize = 6 / viewport.scale; const selection = cssVar("--selection", "#5ac8fa"); const selectionFill = cssVar("--selection-fill", "rgba(90, 200, 250, 0.10)"); + const guideColor = cssVar("--snap-guide", "#ffcc00"); const ink = (c: string) => resolveInkColor(c, darkCanvas); return ( @@ -167,6 +178,44 @@ export function OverlayLayer({ /> ); })} + + {/* Endpoint dots for a single selected line/arrow (drag to reshape). */} + {endpointShapes.map((s) => + ( + [ + [s.x1, s.y1, 1], + [s.x2, s.y2, 2], + ] as const + ).map(([x, y, i]) => ( + + )), + )} + + {/* Smart-alignment guides (Keynote-style) while dragging. */} + {draft.guides.map((g, i) => ( + + ))} ); } diff --git a/packages/canvas/src/layers/TransformLayer.tsx b/packages/canvas/src/layers/TransformLayer.tsx index cc8181f..f07f5f9 100644 --- a/packages/canvas/src/layers/TransformLayer.tsx +++ b/packages/canvas/src/layers/TransformLayer.tsx @@ -1,5 +1,5 @@ import type { RefObject } from "react"; -import { useEffect, useRef } from "react"; +import { useEffect, useMemo, useRef } from "react"; import { Layer, Transformer } from "react-konva"; import type Konva from "konva"; import type { YShape } from "@notux/types"; @@ -13,17 +13,17 @@ interface Props { shapesLayerRef: RefObject; } -// v0: only box kinds get resize/rotate handles. Strokes are freehand point -// clouds and line/arrow are endpoint-shaped — both keep move-only (drag) and -// show the dashed OverlayLayer box instead. +// Every kind except line/arrow gets resize/rotate handles. Lines and arrows +// are endpoint-shaped: SelectTool drags their endpoint dots instead (see +// OverlayLayer), which is the better interaction for connectors. function isTransformable(kind: YShape["kind"]): boolean { - return ( - kind === "rect" || - kind === "ellipse" || - kind === "text" || - kind === "asset" || - kind === "embed" - ); + return kind !== "line" && kind !== "arrow"; +} + +// Media keeps its aspect ratio under the corner handles (Freeform-style); +// boxes and text resize freely. +function keepsRatio(kind: YShape["kind"]): boolean { + return kind === "asset" || kind === "embed"; } export function TransformLayer({ @@ -34,6 +34,18 @@ export function TransformLayer({ }: Props) { const trRef = useRef(null); + // Lock aspect when any selected shape is media, so a mixed selection never + // distorts an image/PDF/video. + const keepRatio = useMemo(() => { + const store = useShapeStore.getState(); + for (const id of selection) { + const s = store.getShape(pageId, id); + if (s && !s.locked && keepsRatio(s.kind)) return true; + } + return false; + // revision: re-evaluate when shapes change under a stable selection. + }, [selection, pageId, revision]); + useEffect(() => { const tr = trRef.current; const layer = shapesLayerRef.current; @@ -50,10 +62,10 @@ export function TransformLayer({ tr.getLayer()?.batchDraw(); }, [selection, revision, pageId, shapesLayerRef]); - // Bake the live Konva transform back into the model, then reset node scale so - // the next gesture starts clean. Nodes live in world space (the Stage carries - // the viewport), so no coordinate conversion is needed. One transact => one - // undo entry for a multi-shape transform. + // Bake the live Konva transform back into the model, then reset node + // transform so the next gesture starts clean. Nodes live in world space + // (the Stage carries the viewport), so no coordinate conversion is needed. + // One transact => one undo entry for a multi-shape transform. function onTransformEnd() { const tr = trRef.current; if (!tr) return; @@ -62,30 +74,58 @@ export function TransformLayer({ for (const node of tr.nodes()) { const id = node.name(); const shape = store.getShape(pageId, id); - if (!shape || !isTransformable(shape.kind)) continue; - // Narrowed: rect | ellipse | text | asset — all carry w/h, and the - // Group is anchored at the shape's top-left, so node.x()/y() is the - // new top-left for every kind (ellipse included). - if ( - shape.kind === "rect" || - shape.kind === "ellipse" || - shape.kind === "text" || - shape.kind === "asset" || - shape.kind === "embed" - ) { - const w = Math.max(1, shape.w * node.scaleX()); - const h = Math.max(1, shape.h * node.scaleY()); + if (!shape || shape.kind === "line" || shape.kind === "arrow") continue; + + if (shape.kind === "stroke") { + // Freehand points are absolute world coords inside an origin Group: + // run them through the node's local transform, scale the brush size + // by the mean scale factor, then reset the node to identity. + const m = node.getTransform().copy(); + const points = shape.points.slice(); + for (let i = 0; i < points.length; i += 2) { + const p = m.point({ x: points[i] ?? 0, y: points[i + 1] ?? 0 }); + points[i] = p.x; + points[i + 1] = p.y; + } + const meanScale = + (Math.abs(node.scaleX()) + Math.abs(node.scaleY())) / 2; store.updateShape(pageId, id, { - x: node.x(), - y: node.y(), - rot: node.rotation(), - w, - h, - ...(shape.kind === "text" - ? { size: Math.max(8, shape.size * node.scaleY()) } - : {}), + points, + size: Math.max(0.5, shape.size * meanScale), }); + node.position({ x: 0, y: 0 }); + node.rotation(0); + node.scaleX(1); + node.scaleY(1); + continue; } + + // Box kinds all carry x/y/w/h(+rot); the Group is anchored at the + // shape's top-left, so node.x()/y() is the new top-left for every + // kind (ellipse included). + const w = Math.max(1, shape.w * node.scaleX()); + const h = Math.max(1, shape.h * node.scaleY()); + store.updateShape(pageId, id, { + x: node.x(), + y: node.y(), + rot: node.rotation(), + w, + h, + // Text scales with its box; sticky text follows the note size so + // resizing feels like Freeform, not a reflow surprise. + ...(shape.kind === "text" + ? { size: Math.max(8, shape.size * node.scaleY()) } + : {}), + ...(shape.kind === "sticky" + ? { + fontSize: Math.max( + 10, + (shape.fontSize ?? 18) * + Math.sqrt(Math.abs(node.scaleX() * node.scaleY())), + ), + } + : {}), + }); node.scaleX(1); node.scaleY(1); } @@ -97,7 +137,7 @@ export function TransformLayer({ diff --git a/packages/canvas/src/store/dockStore.ts b/packages/canvas/src/store/dockStore.ts index 9184929..257ffde 100644 --- a/packages/canvas/src/store/dockStore.ts +++ b/packages/canvas/src/store/dockStore.ts @@ -22,10 +22,11 @@ export const INSTRUMENT_IDS: readonly InstrumentId[] = [ "marker", ] as const; -// The pen-family styles offered in the Pen popover. +// The pen-family styles offered in the Pen popover. "Fineliner" was retired +// from the picker — it duplicated Pen at a thin width preset — but stays a +// valid InstrumentId/StrokeStyle so existing boards keep rendering. export const PEN_STYLES: readonly InstrumentId[] = [ "pen", - "fineliner", "pencil", "marker", ] as const; diff --git a/packages/canvas/src/store/draftStore.ts b/packages/canvas/src/store/draftStore.ts index 2a9c79e..531531b 100644 --- a/packages/canvas/src/store/draftStore.ts +++ b/packages/canvas/src/store/draftStore.ts @@ -1,5 +1,6 @@ import type { StrokeStyle } from "@notux/types"; import { create } from "zustand"; +import type { SnapGuide } from "../tools/snapping"; export interface DraftStroke { tool: "pen" | "highlighter"; @@ -51,6 +52,8 @@ interface DraftStoreState { line: DraftLine | null; arrow: DraftLine | null; marquee: { x: number; y: number; w: number; h: number } | null; + // Live smart-alignment guides while dragging (SelectTool writes). + guides: SnapGuide[]; setStroke(s: DraftStroke | null): void; setRect(r: DraftRect | null): void; setEllipse(r: DraftRect | null): void; @@ -58,6 +61,7 @@ interface DraftStoreState { setLine(l: DraftLine | null): void; setArrow(l: DraftLine | null): void; setMarquee(m: { x: number; y: number; w: number; h: number } | null): void; + setGuides(guides: SnapGuide[]): void; clear(): void; } @@ -72,6 +76,7 @@ export const useDraftStore = create((set) => ({ line: null, arrow: null, marquee: null, + guides: [], setStroke(s) { set({ stroke: s }); }, @@ -93,6 +98,9 @@ export const useDraftStore = create((set) => ({ setMarquee(m) { set({ marquee: m }); }, + setGuides(guides) { + set({ guides }); + }, clear() { set({ stroke: null, @@ -102,6 +110,7 @@ export const useDraftStore = create((set) => ({ line: null, arrow: null, marquee: null, + guides: [], }); }, })); diff --git a/packages/canvas/src/store/viewportStore.ts b/packages/canvas/src/store/viewportStore.ts new file mode 100644 index 0000000..4dbdcbd --- /dev/null +++ b/packages/canvas/src/store/viewportStore.ts @@ -0,0 +1,19 @@ +import { create } from "zustand"; +import type { ViewportState } from "../viewport/Viewport"; + +// CanvasStage publishes its live viewport + container size here so DOM-side +// chrome (the contextual selection toolbar, future minimap, …) can project +// world-space geometry into screen space without living inside the stage. +interface ViewportStoreState { + viewport: ViewportState; + size: { w: number; h: number }; + publish(viewport: ViewportState, size: { w: number; h: number }): void; +} + +export const useViewportStore = create((set) => ({ + viewport: { x: 0, y: 0, scale: 1 }, + size: { w: 0, h: 0 }, + publish(viewport, size) { + set({ viewport, size }); + }, +})); diff --git a/packages/canvas/src/tools/SelectTool.ts b/packages/canvas/src/tools/SelectTool.ts index 434883f..f212603 100644 --- a/packages/canvas/src/tools/SelectTool.ts +++ b/packages/canvas/src/tools/SelectTool.ts @@ -1,9 +1,21 @@ -import type { YShape } from "@notux/types"; +import type { YArrow, YLine, YShape } from "@notux/types"; import { useTextEditStore } from "../store/textEditStore"; +import type { Bounds } from "./shapeOps"; import { boundsIntersect, shapeBounds, translateShape } from "./shapeOps"; +import { + collectSnapTargets, + snapMovedBounds, + snapPoint, + type SnapTargets, +} from "./snapping"; import type { Tool, ToolContext, ToolEventPoint } from "./types"; -type Mode = "idle" | "drag" | "marquee"; +type Mode = "idle" | "drag" | "marquee" | "endpoint"; + +// Screen-pixel budgets, divided by the viewport scale per gesture so feel is +// zoom-independent. +const SNAP_TOLERANCE_PX = 8; +const ENDPOINT_HIT_PX = 14; interface State { mode: Mode; @@ -12,6 +24,39 @@ interface State { // Snapshot of shapes at drag start, so translation is from the original // position rather than compounding rounding error each pointermove. dragSnapshot: Map; + // Union bounds of the snapshot, for magnetic snapping. + dragBounds: Bounds | null; + // Alignment targets collected once per gesture (everything not being moved). + snapTargets: SnapTargets | null; + // Endpoint editing (line/arrow): which end is being dragged. + endpointShape: YLine | YArrow | null; + endpointIndex: 1 | 2; +} + +function unionBounds(shapes: Iterable): Bounds | null { + let out: Bounds | null = null; + for (const s of shapes) { + const b = shapeBounds(s); + if (!out) { + out = { ...b }; + } else { + const x = Math.min(out.x, b.x); + const y = Math.min(out.y, b.y); + out.w = Math.max(out.x + out.w, b.x + b.w) - x; + out.h = Math.max(out.y + out.h, b.y + b.h) - y; + out.x = x; + out.y = y; + } + } + return out; +} + +// World-units-per-screen-pixel, derived from the world→container projection. +function worldPerPixel(ctx: ToolContext): number { + const a = ctx.worldToContainer({ x: 0, y: 0 }); + const b = ctx.worldToContainer({ x: 1, y: 0 }); + const scale = Math.abs(b.x - a.x); + return scale > 0 ? 1 / scale : 1; } export function makeSelectTool(): Tool { @@ -20,6 +65,10 @@ export function makeSelectTool(): Tool { start: { x: 0, y: 0 }, last: { x: 0, y: 0 }, dragSnapshot: new Map(), + dragBounds: null, + snapTargets: null, + endpointShape: null, + endpointIndex: 1, }; function snapshotSelection(ctx: ToolContext) { @@ -29,11 +78,31 @@ export function makeSelectTool(): Tool { // Locked shapes are never dragged, even within a mixed selection. if (s && !s.locked) state.dragSnapshot.set(id, s); } + state.dragBounds = unionBounds(state.dragSnapshot.values()); + state.snapTargets = collectSnapTargets( + ctx.store.listShapes(ctx.pageId), + new Set(state.dragSnapshot.keys()), + ); } function applyDrag(p: ToolEventPoint, ctx: ToolContext) { - const dx = p.x - state.start.x; - const dy = p.y - state.start.y; + let dx = p.x - state.start.x; + let dy = p.y - state.start.y; + if (!p.alt && state.dragBounds && state.snapTargets) { + const moved: Bounds = { + x: state.dragBounds.x + dx, + y: state.dragBounds.y + dy, + w: state.dragBounds.w, + h: state.dragBounds.h, + }; + const tolerance = SNAP_TOLERANCE_PX * worldPerPixel(ctx); + const snap = snapMovedBounds(moved, state.snapTargets, tolerance); + dx += snap.dx; + dy += snap.dy; + ctx.draftStore.setGuides(snap.guides); + } else { + ctx.draftStore.setGuides([]); + } ctx.store.transact(() => { for (const [id, original] of state.dragSnapshot) { const moved = translateShape(original, dx, dy); @@ -42,11 +111,84 @@ export function makeSelectTool(): Tool { }); } + // The endpoint hit-test runs before the shape hit-test so the handles win + // over the (thin) line body they sit on. + function endpointAt( + p: ToolEventPoint, + ctx: ToolContext, + ): { shape: YLine | YArrow; index: 1 | 2 } | null { + const selection = ctx.getSelection(); + if (selection.size !== 1) return null; + const id = selection.values().next().value as string; + const s = ctx.store.getShape(ctx.pageId, id); + if (!s || s.locked || (s.kind !== "line" && s.kind !== "arrow")) return null; + const r = ENDPOINT_HIT_PX * worldPerPixel(ctx); + const d1 = Math.hypot(p.x - s.x1, p.y - s.y1); + const d2 = Math.hypot(p.x - s.x2, p.y - s.y2); + if (d1 > r && d2 > r) return null; + return { shape: s, index: d1 <= d2 ? 1 : 2 }; + } + + function applyEndpointDrag(p: ToolEventPoint, ctx: ToolContext) { + const shape = state.endpointShape; + if (!shape) return; + const fixed = + state.endpointIndex === 1 + ? { x: shape.x2, y: shape.y2 } + : { x: shape.x1, y: shape.y1 }; + let nx = p.x; + let ny = p.y; + if (p.shift) { + // Constrain to 45° steps around the fixed endpoint (Keynote-style). + const angle = Math.atan2(ny - fixed.y, nx - fixed.x); + const dist = Math.hypot(nx - fixed.x, ny - fixed.y); + const snapped = Math.round(angle / (Math.PI / 4)) * (Math.PI / 4); + nx = fixed.x + Math.cos(snapped) * dist; + ny = fixed.y + Math.sin(snapped) * dist; + ctx.draftStore.setGuides([]); + } else if (!p.alt && state.snapTargets) { + const tolerance = SNAP_TOLERANCE_PX * worldPerPixel(ctx); + const snap = snapPoint({ x: nx, y: ny }, state.snapTargets, tolerance); + nx += snap.dx; + ny += snap.dy; + ctx.draftStore.setGuides(snap.guides); + } else { + ctx.draftStore.setGuides([]); + } + const patch = + state.endpointIndex === 1 + ? { x1: nx, y1: ny } + : { x2: nx, y2: ny }; + ctx.store.updateShape(ctx.pageId, shape.id, patch); + } + + function endGesture(ctx: ToolContext) { + state.mode = "idle"; + state.dragSnapshot.clear(); + state.dragBounds = null; + state.snapTargets = null; + state.endpointShape = null; + ctx.draftStore.setGuides([]); + } + return { cursor: "default", onPointerDown(p, ctx) { state.start = { x: p.x, y: p.y }; state.last = { x: p.x, y: p.y }; + + const endpoint = endpointAt(p, ctx); + if (endpoint) { + state.mode = "endpoint"; + state.endpointShape = endpoint.shape; + state.endpointIndex = endpoint.index; + state.snapTargets = collectSnapTargets( + ctx.store.listShapes(ctx.pageId), + new Set([endpoint.shape.id]), + ); + return; + } + const hit = ctx.hitTest({ x: p.x, y: p.y }); if (hit) { const selection = ctx.getSelection(); @@ -75,6 +217,8 @@ export function makeSelectTool(): Tool { onPointerMove(p, ctx) { if (state.mode === "drag") { applyDrag(p, ctx); + } else if (state.mode === "endpoint") { + applyEndpointDrag(p, ctx); } else if (state.mode === "marquee") { const x = Math.min(state.start.x, p.x); const y = Math.min(state.start.y, p.y); @@ -102,12 +246,10 @@ export function makeSelectTool(): Tool { ctx.setSelection(next); } } - state.mode = "idle"; - state.dragSnapshot.clear(); + endGesture(ctx); }, onCancel(ctx) { - state.mode = "idle"; - state.dragSnapshot.clear(); + endGesture(ctx); ctx.draftStore.setMarquee(null); }, onKeyDown(e, ctx) { diff --git a/packages/canvas/src/tools/alignOps.ts b/packages/canvas/src/tools/alignOps.ts new file mode 100644 index 0000000..f93e12b --- /dev/null +++ b/packages/canvas/src/tools/alignOps.ts @@ -0,0 +1,115 @@ +import type { YShape } from "@notux/types"; +import type { ShapeStore } from "../store/shapeStore"; +import { shapeBounds, translateShape } from "./shapeOps"; + +// Keynote/Freeform-style alignment and distribution. Both operate purely via +// bounds + translation so they work for every shape kind, strokes included. + +export type AlignEdge = + | "left" + | "hcenter" + | "right" + | "top" + | "vcenter" + | "bottom"; + +function liveShapes( + store: ShapeStore, + pageId: string, + ids: Iterable, +): YShape[] { + const out: YShape[] = []; + for (const id of ids) { + const s = store.getShape(pageId, id); + if (s && !s.locked) out.push(s); + } + return out; +} + +export function alignShapes( + store: ShapeStore, + pageId: string, + ids: Iterable, + edge: AlignEdge, +): void { + const shapes = liveShapes(store, pageId, ids); + if (shapes.length < 2) return; + const boxes = shapes.map(shapeBounds); + const minX = Math.min(...boxes.map((b) => b.x)); + const maxX = Math.max(...boxes.map((b) => b.x + b.w)); + const minY = Math.min(...boxes.map((b) => b.y)); + const maxY = Math.max(...boxes.map((b) => b.y + b.h)); + store.transact(() => { + shapes.forEach((s, i) => { + const b = boxes[i]!; + let dx = 0; + let dy = 0; + switch (edge) { + case "left": + dx = minX - b.x; + break; + case "hcenter": + dx = (minX + maxX) / 2 - (b.x + b.w / 2); + break; + case "right": + dx = maxX - (b.x + b.w); + break; + case "top": + dy = minY - b.y; + break; + case "vcenter": + dy = (minY + maxY) / 2 - (b.y + b.h / 2); + break; + case "bottom": + dy = maxY - (b.y + b.h); + break; + } + if (dx !== 0 || dy !== 0) { + store.updateShape(pageId, s.id, translateShape(s, dx, dy)); + } + }); + }); +} + +/** Even out the gaps between three or more shapes along an axis. */ +export function distributeShapes( + store: ShapeStore, + pageId: string, + ids: Iterable, + axis: "h" | "v", +): void { + const shapes = liveShapes(store, pageId, ids); + if (shapes.length < 3) return; + const items = shapes + .map((s) => ({ s, b: shapeBounds(s) })) + .sort((a, z) => + axis === "h" ? a.b.x - z.b.x : a.b.y - z.b.y, + ); + const first = items[0]!; + const last = items[items.length - 1]!; + const span = + axis === "h" + ? last.b.x + last.b.w - first.b.x + : last.b.y + last.b.h - first.b.y; + const total = items.reduce( + (acc, it) => acc + (axis === "h" ? it.b.w : it.b.h), + 0, + ); + const gap = (span - total) / (items.length - 1); + store.transact(() => { + let cursor = + (axis === "h" ? first.b.x + first.b.w : first.b.y + first.b.h) + gap; + for (let i = 1; i < items.length - 1; i++) { + const it = items[i]!; + const delta = axis === "h" ? cursor - it.b.x : cursor - it.b.y; + if (delta !== 0) { + store.updateShape( + pageId, + it.s.id, + translateShape(it.s, axis === "h" ? delta : 0, axis === "h" ? 0 : delta), + ); + } + cursor += (axis === "h" ? it.b.w : it.b.h) + gap; + } + }); +} diff --git a/packages/canvas/src/tools/snapping.ts b/packages/canvas/src/tools/snapping.ts new file mode 100644 index 0000000..b0e7db3 --- /dev/null +++ b/packages/canvas/src/tools/snapping.ts @@ -0,0 +1,152 @@ +import type { YShape } from "@notux/types"; +import type { Bounds } from "./shapeOps"; +import { shapeBounds } from "./shapeOps"; + +// Freeform/Figma-style magnetic alignment: while dragging, the moved bounds' +// edges and centers snap to the edges/centers of every other shape on the +// page, and the matched alignment is drawn as a guide line spanning both +// shapes. Tolerance is given in world units (callers divide a screen-pixel +// budget by the viewport scale so the feel is zoom-independent). + +export interface SnapGuide { + orientation: "v" | "h"; + // World coordinate of the alignment line (x for vertical, y for horizontal). + at: number; + // Extent of the line along the other axis, spanning snapped + target shape. + from: number; + to: number; +} + +interface SnapLine { + at: number; + // The target shape's extent perpendicular to the line, so guides can span + // from the target to the moving selection. + lo: number; + hi: number; +} + +export interface SnapTargets { + v: SnapLine[]; // vertical lines (x positions): left / centerX / right + h: SnapLine[]; // horizontal lines (y positions): top / centerY / bottom +} + +export function collectSnapTargets( + shapes: YShape[], + exclude: ReadonlySet, +): SnapTargets { + const v: SnapLine[] = []; + const h: SnapLine[] = []; + for (const s of shapes) { + if (exclude.has(s.id)) continue; + const b = shapeBounds(s); + if (b.w <= 0 && b.h <= 0) continue; + const x2 = b.x + b.w; + const y2 = b.y + b.h; + v.push( + { at: b.x, lo: b.y, hi: y2 }, + { at: b.x + b.w / 2, lo: b.y, hi: y2 }, + { at: x2, lo: b.y, hi: y2 }, + ); + h.push( + { at: b.y, lo: b.x, hi: x2 }, + { at: b.y + b.h / 2, lo: b.x, hi: x2 }, + { at: y2, lo: b.x, hi: x2 }, + ); + } + return { v, h }; +} + +interface AxisSnap { + delta: number; + line: SnapLine; + // Which of the moving bounds' lines matched (its perpendicular extent is + // merged with the target's for the guide length). + atMoving: number; +} + +function snapAxis( + candidates: number[], + lines: SnapLine[], + tolerance: number, +): AxisSnap | null { + let best: AxisSnap | null = null; + for (const c of candidates) { + for (const line of lines) { + const delta = line.at - c; + if (Math.abs(delta) > tolerance) continue; + if (!best || Math.abs(delta) < Math.abs(best.delta)) { + best = { delta, line, atMoving: c }; + } + } + } + return best; +} + +export interface SnapResult { + dx: number; + dy: number; + guides: SnapGuide[]; +} + +const NO_SNAP: SnapResult = { dx: 0, dy: 0, guides: [] }; + +/** Snap a moving bounds box against the targets. Returns the extra translation + * to apply on top of the gesture delta, plus the guides to draw. */ +export function snapMovedBounds( + moved: Bounds, + targets: SnapTargets, + tolerance: number, +): SnapResult { + if (tolerance <= 0) return NO_SNAP; + const xs = [moved.x, moved.x + moved.w / 2, moved.x + moved.w]; + const ys = [moved.y, moved.y + moved.h / 2, moved.y + moved.h]; + const sx = snapAxis(xs, targets.v, tolerance); + const sy = snapAxis(ys, targets.h, tolerance); + const guides: SnapGuide[] = []; + if (sx) { + guides.push({ + orientation: "v", + at: sx.line.at, + from: Math.min(sx.line.lo, moved.y + (sy?.delta ?? 0)), + to: Math.max(sx.line.hi, moved.y + moved.h + (sy?.delta ?? 0)), + }); + } + if (sy) { + guides.push({ + orientation: "h", + at: sy.line.at, + from: Math.min(sy.line.lo, moved.x + (sx?.delta ?? 0)), + to: Math.max(sy.line.hi, moved.x + moved.w + (sx?.delta ?? 0)), + }); + } + return { dx: sx?.delta ?? 0, dy: sy?.delta ?? 0, guides }; +} + +/** Snap a single point (line/arrow endpoint) against the targets. */ +export function snapPoint( + p: { x: number; y: number }, + targets: SnapTargets, + tolerance: number, +): SnapResult { + if (tolerance <= 0) return NO_SNAP; + const sx = snapAxis([p.x], targets.v, tolerance); + const sy = snapAxis([p.y], targets.h, tolerance); + const guides: SnapGuide[] = []; + if (sx) { + guides.push({ + orientation: "v", + at: sx.line.at, + from: Math.min(sx.line.lo, p.y + (sy?.delta ?? 0)), + to: Math.max(sx.line.hi, p.y + (sy?.delta ?? 0)), + }); + } + if (sy) { + guides.push({ + orientation: "h", + at: sy.line.at, + from: Math.min(sy.line.lo, p.x + (sx?.delta ?? 0)), + to: Math.max(sy.line.hi, p.x + (sx?.delta ?? 0)), + }); + } + return { dx: sx?.delta ?? 0, dy: sy?.delta ?? 0, guides }; +} diff --git a/packages/canvas/src/tools/types.ts b/packages/canvas/src/tools/types.ts index d3c730b..cea3820 100644 --- a/packages/canvas/src/tools/types.ts +++ b/packages/canvas/src/tools/types.ts @@ -8,6 +8,8 @@ export interface ToolEventPoint { y: number; pressure: number; shift: boolean; + // Alt/Option temporarily disables magnetic snapping while dragging. + alt?: boolean; } export interface ToolContext { diff --git a/packages/ui/src/icons/Icon.tsx b/packages/ui/src/icons/Icon.tsx index ea38899..dff13b9 100644 --- a/packages/ui/src/icons/Icon.tsx +++ b/packages/ui/src/icons/Icon.tsx @@ -24,6 +24,16 @@ export type IconName = | "menu" | "close" | "check" + | "ellipsis" + | "search" + | "star" + | "clock" + | "archive" + | "rename" + | "board" + | "duplicate" + | "sliders" + | "opacity" | "undo" | "redo" | "zoom-in" @@ -36,6 +46,14 @@ export type IconName = | "to-back" | "forward" | "backward" + | "obj-align-left" + | "obj-align-center" + | "obj-align-right" + | "obj-align-top" + | "obj-align-middle" + | "obj-align-bottom" + | "distribute-h" + | "distribute-v" | "sun" | "moon" | "upload" @@ -73,12 +91,6 @@ export type IconName = // Hand-drawn fallbacks for names without SF artwork. const PATHS: Partial> = { - shapes: ( - <> - - - - ), sticky: , grip: ( <> @@ -90,29 +102,6 @@ const PATHS: Partial> = { ), - rounded: , - "arrow-elbow": , - "grid-dots": ( - <> - - - - - - - - - - - ), - "grid-lines": ( - <> - - - - ), - "grid-ruled": , - "grid-plain": , }; interface Props { diff --git a/packages/ui/src/icons/assets/align.horizontal.center.svg b/packages/ui/src/icons/assets/align.horizontal.center.svg new file mode 100644 index 0000000..332d065 --- /dev/null +++ b/packages/ui/src/icons/assets/align.horizontal.center.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/align.horizontal.left.svg b/packages/ui/src/icons/assets/align.horizontal.left.svg new file mode 100644 index 0000000..56f9974 --- /dev/null +++ b/packages/ui/src/icons/assets/align.horizontal.left.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/align.horizontal.right.svg b/packages/ui/src/icons/assets/align.horizontal.right.svg new file mode 100644 index 0000000..481d434 --- /dev/null +++ b/packages/ui/src/icons/assets/align.horizontal.right.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/align.vertical.bottom.svg b/packages/ui/src/icons/assets/align.vertical.bottom.svg new file mode 100644 index 0000000..24a7a6b --- /dev/null +++ b/packages/ui/src/icons/assets/align.vertical.bottom.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/align.vertical.center.svg b/packages/ui/src/icons/assets/align.vertical.center.svg new file mode 100644 index 0000000..cdc7b1e --- /dev/null +++ b/packages/ui/src/icons/assets/align.vertical.center.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/align.vertical.top.svg b/packages/ui/src/icons/assets/align.vertical.top.svg new file mode 100644 index 0000000..a72221e --- /dev/null +++ b/packages/ui/src/icons/assets/align.vertical.top.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/app.svg b/packages/ui/src/icons/assets/app.svg new file mode 100644 index 0000000..60afedc --- /dev/null +++ b/packages/ui/src/icons/assets/app.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/archivebox.svg b/packages/ui/src/icons/assets/archivebox.svg new file mode 100644 index 0000000..533cb0c --- /dev/null +++ b/packages/ui/src/icons/assets/archivebox.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/arrow.clockwise.svg b/packages/ui/src/icons/assets/arrow.clockwise.svg new file mode 100644 index 0000000..93227dd --- /dev/null +++ b/packages/ui/src/icons/assets/arrow.clockwise.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/arrow.clockwise@2x.png b/packages/ui/src/icons/assets/arrow.clockwise@2x.png deleted file mode 100644 index ccad90a..0000000 Binary files a/packages/ui/src/icons/assets/arrow.clockwise@2x.png and /dev/null differ diff --git a/packages/ui/src/icons/assets/arrow.counterclockwise.svg b/packages/ui/src/icons/assets/arrow.counterclockwise.svg new file mode 100644 index 0000000..0b139e0 --- /dev/null +++ b/packages/ui/src/icons/assets/arrow.counterclockwise.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/arrow.counterclockwise@2x.png b/packages/ui/src/icons/assets/arrow.counterclockwise@2x.png deleted file mode 100644 index 48a1e2e..0000000 Binary files a/packages/ui/src/icons/assets/arrow.counterclockwise@2x.png and /dev/null differ diff --git a/packages/ui/src/icons/assets/arrow.turn.right.up.svg b/packages/ui/src/icons/assets/arrow.turn.right.up.svg new file mode 100644 index 0000000..4d3ca53 --- /dev/null +++ b/packages/ui/src/icons/assets/arrow.turn.right.up.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/arrow.up.right.svg b/packages/ui/src/icons/assets/arrow.up.right.svg new file mode 100644 index 0000000..2948f19 --- /dev/null +++ b/packages/ui/src/icons/assets/arrow.up.right.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/book.pages.svg b/packages/ui/src/icons/assets/book.pages.svg new file mode 100644 index 0000000..9de9d23 --- /dev/null +++ b/packages/ui/src/icons/assets/book.pages.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/book.pages@2x.png b/packages/ui/src/icons/assets/book.pages@2x.png deleted file mode 100644 index f77ab70..0000000 Binary files a/packages/ui/src/icons/assets/book.pages@2x.png and /dev/null differ diff --git a/packages/ui/src/icons/assets/checkmark.svg b/packages/ui/src/icons/assets/checkmark.svg new file mode 100644 index 0000000..24602c6 --- /dev/null +++ b/packages/ui/src/icons/assets/checkmark.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/checkmark@2x.png b/packages/ui/src/icons/assets/checkmark@2x.png deleted file mode 100644 index a4835d5..0000000 Binary files a/packages/ui/src/icons/assets/checkmark@2x.png and /dev/null differ diff --git a/packages/ui/src/icons/assets/chevron.down.svg b/packages/ui/src/icons/assets/chevron.down.svg new file mode 100644 index 0000000..ae9cfdc --- /dev/null +++ b/packages/ui/src/icons/assets/chevron.down.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/chevron.down@2x.png b/packages/ui/src/icons/assets/chevron.down@2x.png deleted file mode 100644 index 749c317..0000000 Binary files a/packages/ui/src/icons/assets/chevron.down@2x.png and /dev/null differ diff --git a/packages/ui/src/icons/assets/chevron.left.svg b/packages/ui/src/icons/assets/chevron.left.svg new file mode 100644 index 0000000..b77a41f --- /dev/null +++ b/packages/ui/src/icons/assets/chevron.left.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/chevron.left@2x.png b/packages/ui/src/icons/assets/chevron.left@2x.png deleted file mode 100644 index 5386ac7..0000000 Binary files a/packages/ui/src/icons/assets/chevron.left@2x.png and /dev/null differ diff --git a/packages/ui/src/icons/assets/chevron.right.svg b/packages/ui/src/icons/assets/chevron.right.svg new file mode 100644 index 0000000..73552c6 --- /dev/null +++ b/packages/ui/src/icons/assets/chevron.right.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/chevron.right@2x.png b/packages/ui/src/icons/assets/chevron.right@2x.png deleted file mode 100644 index de59592..0000000 Binary files a/packages/ui/src/icons/assets/chevron.right@2x.png and /dev/null differ diff --git a/packages/ui/src/icons/assets/chevron.up.svg b/packages/ui/src/icons/assets/chevron.up.svg new file mode 100644 index 0000000..e04232e --- /dev/null +++ b/packages/ui/src/icons/assets/chevron.up.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/chevron.up@2x.png b/packages/ui/src/icons/assets/chevron.up@2x.png deleted file mode 100644 index 83b3941..0000000 Binary files a/packages/ui/src/icons/assets/chevron.up@2x.png and /dev/null differ diff --git a/packages/ui/src/icons/assets/circle.grid.3x3.svg b/packages/ui/src/icons/assets/circle.grid.3x3.svg new file mode 100644 index 0000000..46209c2 --- /dev/null +++ b/packages/ui/src/icons/assets/circle.grid.3x3.svg @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/circle.lefthalf.filled.svg b/packages/ui/src/icons/assets/circle.lefthalf.filled.svg new file mode 100644 index 0000000..88bcfcf --- /dev/null +++ b/packages/ui/src/icons/assets/circle.lefthalf.filled.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/circle.svg b/packages/ui/src/icons/assets/circle.svg new file mode 100644 index 0000000..b4c9ae4 --- /dev/null +++ b/packages/ui/src/icons/assets/circle.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/circle@2x.png b/packages/ui/src/icons/assets/circle@2x.png deleted file mode 100644 index 55ea6f7..0000000 Binary files a/packages/ui/src/icons/assets/circle@2x.png and /dev/null differ diff --git a/packages/ui/src/icons/assets/clock.arrow.trianglehead.clockwise.rotate.90.path.dotted.svg b/packages/ui/src/icons/assets/clock.arrow.trianglehead.clockwise.rotate.90.path.dotted.svg new file mode 100644 index 0000000..40aa030 --- /dev/null +++ b/packages/ui/src/icons/assets/clock.arrow.trianglehead.clockwise.rotate.90.path.dotted.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/clock.arrow.trianglehead.clockwise.rotate.90.path.dotted@2x.png b/packages/ui/src/icons/assets/clock.arrow.trianglehead.clockwise.rotate.90.path.dotted@2x.png deleted file mode 100644 index 1c9190b..0000000 Binary files a/packages/ui/src/icons/assets/clock.arrow.trianglehead.clockwise.rotate.90.path.dotted@2x.png and /dev/null differ diff --git a/packages/ui/src/icons/assets/clock.svg b/packages/ui/src/icons/assets/clock.svg new file mode 100644 index 0000000..888bd0d --- /dev/null +++ b/packages/ui/src/icons/assets/clock.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/diamond.svg b/packages/ui/src/icons/assets/diamond.svg new file mode 100644 index 0000000..91f1551 --- /dev/null +++ b/packages/ui/src/icons/assets/diamond.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/diamond@2x.png b/packages/ui/src/icons/assets/diamond@2x.png deleted file mode 100644 index 9b3d4f7..0000000 Binary files a/packages/ui/src/icons/assets/diamond@2x.png and /dev/null differ diff --git a/packages/ui/src/icons/assets/distribute.horizontal.svg b/packages/ui/src/icons/assets/distribute.horizontal.svg new file mode 100644 index 0000000..2498667 --- /dev/null +++ b/packages/ui/src/icons/assets/distribute.horizontal.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/distribute.vertical.svg b/packages/ui/src/icons/assets/distribute.vertical.svg new file mode 100644 index 0000000..cff642b --- /dev/null +++ b/packages/ui/src/icons/assets/distribute.vertical.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/ellipsis.svg b/packages/ui/src/icons/assets/ellipsis.svg new file mode 100644 index 0000000..d1217f7 --- /dev/null +++ b/packages/ui/src/icons/assets/ellipsis.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/eraser.svg b/packages/ui/src/icons/assets/eraser.svg new file mode 100644 index 0000000..440c6c5 --- /dev/null +++ b/packages/ui/src/icons/assets/eraser.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/eraser@2x.png b/packages/ui/src/icons/assets/eraser@2x.png deleted file mode 100644 index 464cb5c..0000000 Binary files a/packages/ui/src/icons/assets/eraser@2x.png and /dev/null differ diff --git a/packages/ui/src/icons/assets/eyedropper.svg b/packages/ui/src/icons/assets/eyedropper.svg new file mode 100644 index 0000000..32f3df0 --- /dev/null +++ b/packages/ui/src/icons/assets/eyedropper.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/eyedropper@2x.png b/packages/ui/src/icons/assets/eyedropper@2x.png deleted file mode 100644 index 5b8eca3..0000000 Binary files a/packages/ui/src/icons/assets/eyedropper@2x.png and /dev/null differ diff --git a/packages/ui/src/icons/assets/folder.svg b/packages/ui/src/icons/assets/folder.svg new file mode 100644 index 0000000..97acf1a --- /dev/null +++ b/packages/ui/src/icons/assets/folder.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/folder@2x.png b/packages/ui/src/icons/assets/folder@2x.png deleted file mode 100644 index 19373f8..0000000 Binary files a/packages/ui/src/icons/assets/folder@2x.png and /dev/null differ diff --git a/packages/ui/src/icons/assets/grid.svg b/packages/ui/src/icons/assets/grid.svg new file mode 100644 index 0000000..c3d6d5b --- /dev/null +++ b/packages/ui/src/icons/assets/grid.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/hand.point.up.svg b/packages/ui/src/icons/assets/hand.point.up.svg new file mode 100644 index 0000000..3a91e44 --- /dev/null +++ b/packages/ui/src/icons/assets/hand.point.up.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/hand.point.up@2x.png b/packages/ui/src/icons/assets/hand.point.up@2x.png deleted file mode 100644 index 1431368..0000000 Binary files a/packages/ui/src/icons/assets/hand.point.up@2x.png and /dev/null differ diff --git a/packages/ui/src/icons/assets/highlighter.svg b/packages/ui/src/icons/assets/highlighter.svg new file mode 100644 index 0000000..786e760 --- /dev/null +++ b/packages/ui/src/icons/assets/highlighter.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/highlighter@2x.png b/packages/ui/src/icons/assets/highlighter@2x.png deleted file mode 100644 index 039d726..0000000 Binary files a/packages/ui/src/icons/assets/highlighter@2x.png and /dev/null differ diff --git a/packages/ui/src/icons/assets/inset.filled.rectangle.and.pointer.arrow.svg b/packages/ui/src/icons/assets/inset.filled.rectangle.and.pointer.arrow.svg new file mode 100644 index 0000000..df93c2c --- /dev/null +++ b/packages/ui/src/icons/assets/inset.filled.rectangle.and.pointer.arrow.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/inset.filled.rectangle.and.pointer.arrow@2x.png b/packages/ui/src/icons/assets/inset.filled.rectangle.and.pointer.arrow@2x.png deleted file mode 100644 index de62096..0000000 Binary files a/packages/ui/src/icons/assets/inset.filled.rectangle.and.pointer.arrow@2x.png and /dev/null differ diff --git a/packages/ui/src/icons/assets/line.3.horizontal.svg b/packages/ui/src/icons/assets/line.3.horizontal.svg new file mode 100644 index 0000000..c6c375f --- /dev/null +++ b/packages/ui/src/icons/assets/line.3.horizontal.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/line.diagonal.svg b/packages/ui/src/icons/assets/line.diagonal.svg new file mode 100644 index 0000000..dd6dff4 --- /dev/null +++ b/packages/ui/src/icons/assets/line.diagonal.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/line.diagonal.trianglehead.up.right@2x.png b/packages/ui/src/icons/assets/line.diagonal.trianglehead.up.right@2x.png deleted file mode 100644 index 8bcdf71..0000000 Binary files a/packages/ui/src/icons/assets/line.diagonal.trianglehead.up.right@2x.png and /dev/null differ diff --git a/packages/ui/src/icons/assets/line.diagonal@2x.png b/packages/ui/src/icons/assets/line.diagonal@2x.png deleted file mode 100644 index 5472028..0000000 Binary files a/packages/ui/src/icons/assets/line.diagonal@2x.png and /dev/null differ diff --git a/packages/ui/src/icons/assets/link.svg b/packages/ui/src/icons/assets/link.svg new file mode 100644 index 0000000..bd0c912 --- /dev/null +++ b/packages/ui/src/icons/assets/link.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/link@2x.png b/packages/ui/src/icons/assets/link@2x.png deleted file mode 100644 index 291eee9..0000000 Binary files a/packages/ui/src/icons/assets/link@2x.png and /dev/null differ diff --git a/packages/ui/src/icons/assets/lock.open.svg b/packages/ui/src/icons/assets/lock.open.svg new file mode 100644 index 0000000..8734522 --- /dev/null +++ b/packages/ui/src/icons/assets/lock.open.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/lock.open@2x.png b/packages/ui/src/icons/assets/lock.open@2x.png deleted file mode 100644 index a66566b..0000000 Binary files a/packages/ui/src/icons/assets/lock.open@2x.png and /dev/null differ diff --git a/packages/ui/src/icons/assets/lock.svg b/packages/ui/src/icons/assets/lock.svg new file mode 100644 index 0000000..5314f9c --- /dev/null +++ b/packages/ui/src/icons/assets/lock.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/lock@2x.png b/packages/ui/src/icons/assets/lock@2x.png deleted file mode 100644 index f599be6..0000000 Binary files a/packages/ui/src/icons/assets/lock@2x.png and /dev/null differ diff --git a/packages/ui/src/icons/assets/magnifyingglass.svg b/packages/ui/src/icons/assets/magnifyingglass.svg new file mode 100644 index 0000000..50cd5da --- /dev/null +++ b/packages/ui/src/icons/assets/magnifyingglass.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/megaphone.svg b/packages/ui/src/icons/assets/megaphone.svg new file mode 100644 index 0000000..256af5b --- /dev/null +++ b/packages/ui/src/icons/assets/megaphone.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/megaphone@2x.png b/packages/ui/src/icons/assets/megaphone@2x.png deleted file mode 100644 index b8f5d17..0000000 Binary files a/packages/ui/src/icons/assets/megaphone@2x.png and /dev/null differ diff --git a/packages/ui/src/icons/assets/minus.svg b/packages/ui/src/icons/assets/minus.svg new file mode 100644 index 0000000..b661b4b --- /dev/null +++ b/packages/ui/src/icons/assets/minus.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/minus@2x.png b/packages/ui/src/icons/assets/minus@2x.png deleted file mode 100644 index 1fcd907..0000000 Binary files a/packages/ui/src/icons/assets/minus@2x.png and /dev/null differ diff --git a/packages/ui/src/icons/assets/moon.svg b/packages/ui/src/icons/assets/moon.svg new file mode 100644 index 0000000..b8da395 --- /dev/null +++ b/packages/ui/src/icons/assets/moon.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/moon@2x.png b/packages/ui/src/icons/assets/moon@2x.png deleted file mode 100644 index 1ed0201..0000000 Binary files a/packages/ui/src/icons/assets/moon@2x.png and /dev/null differ diff --git a/packages/ui/src/icons/assets/pencil.line.svg b/packages/ui/src/icons/assets/pencil.line.svg new file mode 100644 index 0000000..15b9577 --- /dev/null +++ b/packages/ui/src/icons/assets/pencil.line.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/pencil.line@2x.png b/packages/ui/src/icons/assets/pencil.line@2x.png deleted file mode 100644 index ee1328b..0000000 Binary files a/packages/ui/src/icons/assets/pencil.line@2x.png and /dev/null differ diff --git a/packages/ui/src/icons/assets/pencil.svg b/packages/ui/src/icons/assets/pencil.svg new file mode 100644 index 0000000..eb93261 --- /dev/null +++ b/packages/ui/src/icons/assets/pencil.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/person.2.svg b/packages/ui/src/icons/assets/person.2.svg new file mode 100644 index 0000000..75ccc40 --- /dev/null +++ b/packages/ui/src/icons/assets/person.2.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/person.3.sequence@2x.png b/packages/ui/src/icons/assets/person.3.sequence@2x.png deleted file mode 100644 index 6bcab61..0000000 Binary files a/packages/ui/src/icons/assets/person.3.sequence@2x.png and /dev/null differ diff --git a/packages/ui/src/icons/assets/photo.on.rectangle.svg b/packages/ui/src/icons/assets/photo.on.rectangle.svg new file mode 100644 index 0000000..0b5139b --- /dev/null +++ b/packages/ui/src/icons/assets/photo.on.rectangle.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/photo.on.rectangle@2x.png b/packages/ui/src/icons/assets/photo.on.rectangle@2x.png deleted file mode 100644 index afbae90..0000000 Binary files a/packages/ui/src/icons/assets/photo.on.rectangle@2x.png and /dev/null differ diff --git a/packages/ui/src/icons/assets/photo.svg b/packages/ui/src/icons/assets/photo.svg new file mode 100644 index 0000000..715e1fc --- /dev/null +++ b/packages/ui/src/icons/assets/photo.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/photo@2x.png b/packages/ui/src/icons/assets/photo@2x.png deleted file mode 100644 index 0e20a19..0000000 Binary files a/packages/ui/src/icons/assets/photo@2x.png and /dev/null differ diff --git a/packages/ui/src/icons/assets/play.display.svg b/packages/ui/src/icons/assets/play.display.svg new file mode 100644 index 0000000..17ad75b --- /dev/null +++ b/packages/ui/src/icons/assets/play.display.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/play.display@2x.png b/packages/ui/src/icons/assets/play.display@2x.png deleted file mode 100644 index 99a92d4..0000000 Binary files a/packages/ui/src/icons/assets/play.display@2x.png and /dev/null differ diff --git a/packages/ui/src/icons/assets/plus.svg b/packages/ui/src/icons/assets/plus.svg new file mode 100644 index 0000000..d4be5f6 --- /dev/null +++ b/packages/ui/src/icons/assets/plus.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/plus@2x.png b/packages/ui/src/icons/assets/plus@2x.png deleted file mode 100644 index f10f247..0000000 Binary files a/packages/ui/src/icons/assets/plus@2x.png and /dev/null differ diff --git a/packages/ui/src/icons/assets/point.bottomleft.forward.to.point.topright.scurvepath.svg b/packages/ui/src/icons/assets/point.bottomleft.forward.to.point.topright.scurvepath.svg new file mode 100644 index 0000000..39e1444 --- /dev/null +++ b/packages/ui/src/icons/assets/point.bottomleft.forward.to.point.topright.scurvepath.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/point.topleft.down.to.point.bottomright.curvepath@2x.png b/packages/ui/src/icons/assets/point.topleft.down.to.point.bottomright.curvepath@2x.png deleted file mode 100644 index af46f06..0000000 Binary files a/packages/ui/src/icons/assets/point.topleft.down.to.point.bottomright.curvepath@2x.png and /dev/null differ diff --git a/packages/ui/src/icons/assets/pointer.arrow.ipad.svg b/packages/ui/src/icons/assets/pointer.arrow.ipad.svg new file mode 100644 index 0000000..a5fd480 --- /dev/null +++ b/packages/ui/src/icons/assets/pointer.arrow.ipad.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/pointer.arrow.ipad@2x.png b/packages/ui/src/icons/assets/pointer.arrow.ipad@2x.png deleted file mode 100644 index 95250ed..0000000 Binary files a/packages/ui/src/icons/assets/pointer.arrow.ipad@2x.png and /dev/null differ diff --git a/packages/ui/src/icons/assets/rectangle.on.rectangle.svg b/packages/ui/src/icons/assets/rectangle.on.rectangle.svg new file mode 100644 index 0000000..85c408f --- /dev/null +++ b/packages/ui/src/icons/assets/rectangle.on.rectangle.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/rectangle.svg b/packages/ui/src/icons/assets/rectangle.svg new file mode 100644 index 0000000..f616ee2 --- /dev/null +++ b/packages/ui/src/icons/assets/rectangle.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/slider.horizontal.3.svg b/packages/ui/src/icons/assets/slider.horizontal.3.svg new file mode 100644 index 0000000..788a1f8 --- /dev/null +++ b/packages/ui/src/icons/assets/slider.horizontal.3.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/speaker.wave.2.svg b/packages/ui/src/icons/assets/speaker.wave.2.svg new file mode 100644 index 0000000..d96c728 --- /dev/null +++ b/packages/ui/src/icons/assets/speaker.wave.2.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/speaker.wave.2@2x.png b/packages/ui/src/icons/assets/speaker.wave.2@2x.png deleted file mode 100644 index aa01742..0000000 Binary files a/packages/ui/src/icons/assets/speaker.wave.2@2x.png and /dev/null differ diff --git a/packages/ui/src/icons/assets/square.3.layers.3d.bottom.filled.svg b/packages/ui/src/icons/assets/square.3.layers.3d.bottom.filled.svg new file mode 100644 index 0000000..1cd9338 --- /dev/null +++ b/packages/ui/src/icons/assets/square.3.layers.3d.bottom.filled.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/square.3.layers.3d.bottom.filled@2x.png b/packages/ui/src/icons/assets/square.3.layers.3d.bottom.filled@2x.png deleted file mode 100644 index 0a9f759..0000000 Binary files a/packages/ui/src/icons/assets/square.3.layers.3d.bottom.filled@2x.png and /dev/null differ diff --git a/packages/ui/src/icons/assets/square.3.layers.3d.middle.filled.svg b/packages/ui/src/icons/assets/square.3.layers.3d.middle.filled.svg new file mode 100644 index 0000000..88e2ca1 --- /dev/null +++ b/packages/ui/src/icons/assets/square.3.layers.3d.middle.filled.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/square.3.layers.3d.middle.filled@2x.png b/packages/ui/src/icons/assets/square.3.layers.3d.middle.filled@2x.png deleted file mode 100644 index 67959f0..0000000 Binary files a/packages/ui/src/icons/assets/square.3.layers.3d.middle.filled@2x.png and /dev/null differ diff --git a/packages/ui/src/icons/assets/square.3.layers.3d.top.filled.svg b/packages/ui/src/icons/assets/square.3.layers.3d.top.filled.svg new file mode 100644 index 0000000..d5f22dd --- /dev/null +++ b/packages/ui/src/icons/assets/square.3.layers.3d.top.filled.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/square.3.layers.3d.top.filled@2x.png b/packages/ui/src/icons/assets/square.3.layers.3d.top.filled@2x.png deleted file mode 100644 index ae9f32c..0000000 Binary files a/packages/ui/src/icons/assets/square.3.layers.3d.top.filled@2x.png and /dev/null differ diff --git a/packages/ui/src/icons/assets/square.and.arrow.down.svg b/packages/ui/src/icons/assets/square.and.arrow.down.svg new file mode 100644 index 0000000..d82ba98 --- /dev/null +++ b/packages/ui/src/icons/assets/square.and.arrow.down.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/square.and.arrow.down@2x.png b/packages/ui/src/icons/assets/square.and.arrow.down@2x.png deleted file mode 100644 index c2a41d0..0000000 Binary files a/packages/ui/src/icons/assets/square.and.arrow.down@2x.png and /dev/null differ diff --git a/packages/ui/src/icons/assets/square.and.arrow.up.svg b/packages/ui/src/icons/assets/square.and.arrow.up.svg new file mode 100644 index 0000000..e0ddbc7 --- /dev/null +++ b/packages/ui/src/icons/assets/square.and.arrow.up.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/square.and.arrow.up@2x.png b/packages/ui/src/icons/assets/square.and.arrow.up@2x.png deleted file mode 100644 index ea31f0b..0000000 Binary files a/packages/ui/src/icons/assets/square.and.arrow.up@2x.png and /dev/null differ diff --git a/packages/ui/src/icons/assets/square.on.circle.svg b/packages/ui/src/icons/assets/square.on.circle.svg new file mode 100644 index 0000000..acf557d --- /dev/null +++ b/packages/ui/src/icons/assets/square.on.circle.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/square.on.square.svg b/packages/ui/src/icons/assets/square.on.square.svg new file mode 100644 index 0000000..acf6aa7 --- /dev/null +++ b/packages/ui/src/icons/assets/square.on.square.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/square.stack.3d.up.svg b/packages/ui/src/icons/assets/square.stack.3d.up.svg new file mode 100644 index 0000000..108312d --- /dev/null +++ b/packages/ui/src/icons/assets/square.stack.3d.up.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/square.stack.3d.up@2x.png b/packages/ui/src/icons/assets/square.stack.3d.up@2x.png deleted file mode 100644 index 8ab9c1e..0000000 Binary files a/packages/ui/src/icons/assets/square.stack.3d.up@2x.png and /dev/null differ diff --git a/packages/ui/src/icons/assets/square.svg b/packages/ui/src/icons/assets/square.svg new file mode 100644 index 0000000..6454ee2 --- /dev/null +++ b/packages/ui/src/icons/assets/square.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/square@2x.png b/packages/ui/src/icons/assets/square@2x.png deleted file mode 100644 index bcbdbef..0000000 Binary files a/packages/ui/src/icons/assets/square@2x.png and /dev/null differ diff --git a/packages/ui/src/icons/assets/star.svg b/packages/ui/src/icons/assets/star.svg new file mode 100644 index 0000000..f015e7f --- /dev/null +++ b/packages/ui/src/icons/assets/star.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/sun.max.svg b/packages/ui/src/icons/assets/sun.max.svg new file mode 100644 index 0000000..360a315 --- /dev/null +++ b/packages/ui/src/icons/assets/sun.max.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/sun.max@2x.png b/packages/ui/src/icons/assets/sun.max@2x.png deleted file mode 100644 index 754afda..0000000 Binary files a/packages/ui/src/icons/assets/sun.max@2x.png and /dev/null differ diff --git a/packages/ui/src/icons/assets/swatchpalette.svg b/packages/ui/src/icons/assets/swatchpalette.svg new file mode 100644 index 0000000..dfb745a --- /dev/null +++ b/packages/ui/src/icons/assets/swatchpalette.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/swatchpalette@2x.png b/packages/ui/src/icons/assets/swatchpalette@2x.png deleted file mode 100644 index 1805381..0000000 Binary files a/packages/ui/src/icons/assets/swatchpalette@2x.png and /dev/null differ diff --git a/packages/ui/src/icons/assets/text.aligncenter.svg b/packages/ui/src/icons/assets/text.aligncenter.svg new file mode 100644 index 0000000..7dd1f47 --- /dev/null +++ b/packages/ui/src/icons/assets/text.aligncenter.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/text.aligncenter@2x.png b/packages/ui/src/icons/assets/text.aligncenter@2x.png deleted file mode 100644 index ba9c1e6..0000000 Binary files a/packages/ui/src/icons/assets/text.aligncenter@2x.png and /dev/null differ diff --git a/packages/ui/src/icons/assets/text.alignleft.svg b/packages/ui/src/icons/assets/text.alignleft.svg new file mode 100644 index 0000000..f2b5c51 --- /dev/null +++ b/packages/ui/src/icons/assets/text.alignleft.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/text.alignleft@2x.png b/packages/ui/src/icons/assets/text.alignleft@2x.png deleted file mode 100644 index 47a32fe..0000000 Binary files a/packages/ui/src/icons/assets/text.alignleft@2x.png and /dev/null differ diff --git a/packages/ui/src/icons/assets/text.alignright.svg b/packages/ui/src/icons/assets/text.alignright.svg new file mode 100644 index 0000000..8fa0096 --- /dev/null +++ b/packages/ui/src/icons/assets/text.alignright.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/text.alignright@2x.png b/packages/ui/src/icons/assets/text.alignright@2x.png deleted file mode 100644 index 5251425..0000000 Binary files a/packages/ui/src/icons/assets/text.alignright@2x.png and /dev/null differ diff --git a/packages/ui/src/icons/assets/text.document.svg b/packages/ui/src/icons/assets/text.document.svg new file mode 100644 index 0000000..804855d --- /dev/null +++ b/packages/ui/src/icons/assets/text.document.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/text.document@2x.png b/packages/ui/src/icons/assets/text.document@2x.png deleted file mode 100644 index fd2a4de..0000000 Binary files a/packages/ui/src/icons/assets/text.document@2x.png and /dev/null differ diff --git a/packages/ui/src/icons/assets/text.justify.svg b/packages/ui/src/icons/assets/text.justify.svg new file mode 100644 index 0000000..bab8f9f --- /dev/null +++ b/packages/ui/src/icons/assets/text.justify.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/text.justify@2x.png b/packages/ui/src/icons/assets/text.justify@2x.png deleted file mode 100644 index c833d0a..0000000 Binary files a/packages/ui/src/icons/assets/text.justify@2x.png and /dev/null differ diff --git a/packages/ui/src/icons/assets/textformat.svg b/packages/ui/src/icons/assets/textformat.svg new file mode 100644 index 0000000..a08c7f7 --- /dev/null +++ b/packages/ui/src/icons/assets/textformat.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/textformat@2x.png b/packages/ui/src/icons/assets/textformat@2x.png deleted file mode 100644 index 64dc30e..0000000 Binary files a/packages/ui/src/icons/assets/textformat@2x.png and /dev/null differ diff --git a/packages/ui/src/icons/assets/trash.svg b/packages/ui/src/icons/assets/trash.svg new file mode 100644 index 0000000..bee2c4d --- /dev/null +++ b/packages/ui/src/icons/assets/trash.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/trash@2x.png b/packages/ui/src/icons/assets/trash@2x.png deleted file mode 100644 index 04c879d..0000000 Binary files a/packages/ui/src/icons/assets/trash@2x.png and /dev/null differ diff --git a/packages/ui/src/icons/assets/triangle.svg b/packages/ui/src/icons/assets/triangle.svg new file mode 100644 index 0000000..fc9740c --- /dev/null +++ b/packages/ui/src/icons/assets/triangle.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/triangle@2x.png b/packages/ui/src/icons/assets/triangle@2x.png deleted file mode 100644 index a6b723b..0000000 Binary files a/packages/ui/src/icons/assets/triangle@2x.png and /dev/null differ diff --git a/packages/ui/src/icons/assets/viewfinder.svg b/packages/ui/src/icons/assets/viewfinder.svg new file mode 100644 index 0000000..b683cee --- /dev/null +++ b/packages/ui/src/icons/assets/viewfinder.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/viewfinder@2x.png b/packages/ui/src/icons/assets/viewfinder@2x.png deleted file mode 100644 index 8c14d13..0000000 Binary files a/packages/ui/src/icons/assets/viewfinder@2x.png and /dev/null differ diff --git a/packages/ui/src/icons/assets/xmark.svg b/packages/ui/src/icons/assets/xmark.svg new file mode 100644 index 0000000..e0353b7 --- /dev/null +++ b/packages/ui/src/icons/assets/xmark.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/icons/assets/xmark@2x.png b/packages/ui/src/icons/assets/xmark@2x.png deleted file mode 100644 index bbe4d85..0000000 Binary files a/packages/ui/src/icons/assets/xmark@2x.png and /dev/null differ diff --git a/packages/ui/src/icons/sfIcons.ts b/packages/ui/src/icons/sfIcons.ts index f99069f..d9b1c59 100644 --- a/packages/ui/src/icons/sfIcons.ts +++ b/packages/ui/src/icons/sfIcons.ts @@ -1,122 +1,116 @@ import type { IconName } from "./Icon"; -// SF Symbol artwork (monochrome @2x PNGs, black-on-transparent) rendered via -// CSS mask so glyphs tint with `currentColor` in either theme. Names without -// an entry here fall back to the hand-drawn stroke SVGs in Icon.tsx. +// SF Symbol artwork (vector SVGs, black-on-transparent) rendered via CSS mask +// so glyphs tint with `currentColor` in either theme and stay crisp at any +// size. Names without an entry here fall back to the hand-drawn stroke SVGs +// in Icon.tsx. +const u = (file: string) => + new URL(`./assets/${file}.svg`, import.meta.url).href; + export const SF_ICON_URLS: Partial> = { // Dock tools - select: new URL("./assets/pointer.arrow.ipad@2x.png", import.meta.url).href, - hand: new URL("./assets/hand.point.up@2x.png", import.meta.url).href, - pen: new URL("./assets/pencil.line@2x.png", import.meta.url).href, - eraser: new URL("./assets/eraser@2x.png", import.meta.url).href, - highlighter: new URL("./assets/highlighter@2x.png", import.meta.url).href, - text: new URL("./assets/textformat@2x.png", import.meta.url).href, - media: new URL("./assets/photo.on.rectangle@2x.png", import.meta.url).href, + select: u("pointer.arrow.ipad"), + hand: u("hand.point.up"), + pen: u("pencil.line"), + eraser: u("eraser"), + highlighter: u("highlighter"), + text: u("textformat"), + media: u("photo.on.rectangle"), + shapes: u("square.on.circle"), // Chrome / navigation - plus: new URL("./assets/plus@2x.png", import.meta.url).href, - minus: new URL("./assets/minus@2x.png", import.meta.url).href, - "chevron-down": new URL("./assets/chevron.down@2x.png", import.meta.url).href, - "chevron-up": new URL("./assets/chevron.up@2x.png", import.meta.url).href, - "chevron-left": new URL("./assets/chevron.left@2x.png", import.meta.url).href, - "chevron-right": new URL("./assets/chevron.right@2x.png", import.meta.url) - .href, - menu: new URL("./assets/text.justify@2x.png", import.meta.url).href, - close: new URL("./assets/xmark@2x.png", import.meta.url).href, - check: new URL("./assets/checkmark@2x.png", import.meta.url).href, + plus: u("plus"), + minus: u("minus"), + "chevron-down": u("chevron.down"), + "chevron-up": u("chevron.up"), + "chevron-left": u("chevron.left"), + "chevron-right": u("chevron.right"), + menu: u("text.justify"), + close: u("xmark"), + check: u("checkmark"), + ellipsis: u("ellipsis"), + search: u("magnifyingglass"), + star: u("star"), + clock: u("clock"), + archive: u("archivebox"), + rename: u("pencil"), + board: u("rectangle.on.rectangle"), + duplicate: u("square.on.square"), + sliders: u("slider.horizontal.3"), + opacity: u("circle.lefthalf.filled"), // Edit / view - undo: new URL("./assets/arrow.counterclockwise@2x.png", import.meta.url) - .href, - redo: new URL("./assets/arrow.clockwise@2x.png", import.meta.url).href, - "zoom-in": new URL("./assets/plus@2x.png", import.meta.url).href, - "zoom-out": new URL("./assets/minus@2x.png", import.meta.url).href, - "zoom-reset": new URL("./assets/viewfinder@2x.png", import.meta.url).href, - history: new URL( - "./assets/clock.arrow.trianglehead.clockwise.rotate.90.path.dotted@2x.png", - import.meta.url, - ).href, - trash: new URL("./assets/trash@2x.png", import.meta.url).href, - lock: new URL("./assets/lock@2x.png", import.meta.url).href, - unlock: new URL("./assets/lock.open@2x.png", import.meta.url).href, - sun: new URL("./assets/sun.max@2x.png", import.meta.url).href, - moon: new URL("./assets/moon@2x.png", import.meta.url).href, - cursors: new URL( - "./assets/inset.filled.rectangle.and.pointer.arrow@2x.png", - import.meta.url, - ).href, - background: new URL("./assets/swatchpalette@2x.png", import.meta.url).href, + undo: u("arrow.counterclockwise"), + redo: u("arrow.clockwise"), + "zoom-in": u("plus"), + "zoom-out": u("minus"), + "zoom-reset": u("viewfinder"), + history: u("clock.arrow.trianglehead.clockwise.rotate.90.path.dotted"), + trash: u("trash"), + lock: u("lock"), + unlock: u("lock.open"), + sun: u("sun.max"), + moon: u("moon"), + cursors: u("inset.filled.rectangle.and.pointer.arrow"), + background: u("swatchpalette"), // Arrange (z-order) - "to-front": new URL( - "./assets/square.3.layers.3d.top.filled@2x.png", - import.meta.url, - ).href, - forward: new URL("./assets/square.stack.3d.up@2x.png", import.meta.url).href, - backward: new URL( - "./assets/square.3.layers.3d.middle.filled@2x.png", - import.meta.url, - ).href, - "to-back": new URL( - "./assets/square.3.layers.3d.bottom.filled@2x.png", - import.meta.url, - ).href, + "to-front": u("square.3.layers.3d.top.filled"), + forward: u("square.stack.3d.up"), + backward: u("square.3.layers.3d.middle.filled"), + "to-back": u("square.3.layers.3d.bottom.filled"), + + // Object alignment / distribution + "obj-align-left": u("align.horizontal.left"), + "obj-align-center": u("align.horizontal.center"), + "obj-align-right": u("align.horizontal.right"), + "obj-align-top": u("align.vertical.top"), + "obj-align-middle": u("align.vertical.center"), + "obj-align-bottom": u("align.vertical.bottom"), + "distribute-h": u("distribute.horizontal"), + "distribute-v": u("distribute.vertical"), // Transfer / files - upload: new URL("./assets/square.and.arrow.up@2x.png", import.meta.url).href, - download: new URL("./assets/square.and.arrow.down@2x.png", import.meta.url) - .href, - share: new URL("./assets/square.and.arrow.up@2x.png", import.meta.url).href, - link: new URL("./assets/link@2x.png", import.meta.url).href, - pages: new URL("./assets/book.pages@2x.png", import.meta.url).href, - photo: new URL("./assets/photo@2x.png", import.meta.url).href, - audio: new URL("./assets/speaker.wave.2@2x.png", import.meta.url).href, - file: new URL("./assets/text.document@2x.png", import.meta.url).href, - video: new URL("./assets/play.display@2x.png", import.meta.url).href, - folder: new URL("./assets/folder@2x.png", import.meta.url).href, - eyedropper: new URL("./assets/eyedropper@2x.png", import.meta.url).href, + upload: u("square.and.arrow.up"), + download: u("square.and.arrow.down"), + share: u("square.and.arrow.up"), + link: u("link"), + pages: u("book.pages"), + photo: u("photo"), + audio: u("speaker.wave.2"), + file: u("text.document"), + video: u("play.display"), + folder: u("folder"), + eyedropper: u("eyedropper"), // Collaboration - spotlight: new URL("./assets/megaphone@2x.png", import.meta.url).href, - people: new URL("./assets/person.3.sequence@2x.png", import.meta.url).href, + spotlight: u("megaphone"), + people: u("person.2"), - // Shapes - square: new URL("./assets/square@2x.png", import.meta.url).href, - circle: new URL("./assets/circle@2x.png", import.meta.url).href, - diamond: new URL("./assets/diamond@2x.png", import.meta.url).href, - triangle: new URL("./assets/triangle@2x.png", import.meta.url).href, - line: new URL("./assets/line.diagonal@2x.png", import.meta.url).href, - arrow: new URL( - "./assets/line.diagonal.trianglehead.up.right@2x.png", - import.meta.url, - ).href, - "arrow-curved": new URL( - "./assets/point.topleft.down.to.point.bottomright.curvepath@2x.png", - import.meta.url, - ).href, + // Shape library + square: u("square"), + rounded: u("app"), + circle: u("circle"), + diamond: u("diamond"), + triangle: u("triangle"), + line: u("line.diagonal"), + arrow: u("arrow.up.right"), + "arrow-curved": u("point.bottomleft.forward.to.point.topright.scurvepath"), + "arrow-elbow": u("arrow.turn.right.up"), // Text alignment - "align-left": new URL("./assets/text.alignleft@2x.png", import.meta.url) - .href, - "align-center": new URL("./assets/text.aligncenter@2x.png", import.meta.url) - .href, - "align-right": new URL("./assets/text.alignright@2x.png", import.meta.url) - .href, -}; + "align-left": u("text.alignleft"), + "align-center": u("text.aligncenter"), + "align-right": u("text.alignright"), -// SF exports carry uneven intrinsic padding; a few glyphs need a small optical -// correction so they sit at the same visual weight as their neighbours. -export const SF_ICON_SCALE: Partial> = { - plus: 0.86, - minus: 0.86, - "zoom-in": 0.86, - "zoom-out": 0.86, - close: 0.86, - check: 0.88, - "chevron-down": 0.82, - "chevron-up": 0.82, - "chevron-left": 0.82, - "chevron-right": 0.82, - line: 0.88, - menu: 0.92, + // Canvas grid styles + "grid-dots": u("circle.grid.3x3"), + "grid-lines": u("grid"), + "grid-ruled": u("line.3.horizontal"), + "grid-plain": u("rectangle"), }; + +// Optional per-glyph mask scale. The SVG assets are normalized onto a shared +// square canvas (see assets), so glyphs already sit at consistent optical +// weight; entries here are only for deliberate one-off corrections. +export const SF_ICON_SCALE: Partial> = {}; diff --git a/packages/ui/src/styles.css b/packages/ui/src/styles.css index 3a63196..38ea668 100644 --- a/packages/ui/src/styles.css +++ b/packages/ui/src/styles.css @@ -12,6 +12,7 @@ --fg-0: rgba(255, 255, 255, 0.92); --fg-1: rgba(255, 255, 255, 0.62); --accent: #5ac8fa; + --accent-hover: #74d2fb; --glass-tint: rgba(255, 255, 255, 0.1); --glass-stroke: rgba(255, 255, 255, 0.22); @@ -22,6 +23,7 @@ --canvas-dot: rgba(255, 255, 255, 0.1); --selection: #5ac8fa; --selection-fill: rgba(90, 200, 250, 0.1); + --snap-guide: #ffd60a; --dock-bg: rgba(22, 26, 34, 0.62); --popover-bg: rgba(30, 34, 42, 0.92); @@ -44,6 +46,7 @@ --fg-0: rgba(20, 22, 28, 0.92); --fg-1: rgba(20, 22, 28, 0.55); --accent: #0a84ff; + --accent-hover: #2e95ff; --glass-tint: rgba(255, 255, 255, 0.55); --glass-stroke: rgba(20, 22, 28, 0.1); @@ -54,6 +57,7 @@ --canvas-dot: rgba(20, 22, 28, 0.1); --selection: #0a84ff; --selection-fill: rgba(10, 132, 255, 0.1); + --snap-guide: #ff9500; --dock-bg: rgba(238, 240, 243, 0.72); --popover-bg: rgba(255, 255, 255, 0.94); @@ -116,6 +120,14 @@ background: var(--glass-hover); } +/* Active (accent) states must survive hover: the generic :hover rules above + outrank single-class --active modifiers, which previously swapped the + accent background for --glass-hover — near-white in light mode — under a + white glyph, making hovered active controls invisible. */ +.glass-button--active:hover { + background: color-mix(in srgb, var(--accent) 32%, transparent); +} + .glass-button:active { transform: scale(0.96); } @@ -287,7 +299,11 @@ .slider { display: flex; align-items: center; - gap: 12px; + gap: 10px; + /* Never push past the popover/sheet that hosts it: the rail flexes, the + value box stays content-sized, and nothing overflows on narrow screens. */ + min-width: 0; + max-width: 100%; } /* The rail is the positioning context. The colored track clips its gradient / @@ -298,6 +314,7 @@ flex: 1; display: flex; align-items: center; + min-width: 0; } .slider__track { @@ -350,12 +367,13 @@ .slider__value { flex: none; - min-width: 64px; + min-width: 48px; text-align: center; - font-size: 17px; + font-size: 14px; font-weight: 600; - padding: 8px 10px; - border-radius: 10px; + font-variant-numeric: tabular-nums; + padding: 6px 8px; + border-radius: 8px; background: var(--segment-active-bg); border: 1px solid var(--glass-stroke); } @@ -488,6 +506,13 @@ color: #fff; } +/* Keep the accent surface (slightly lifted) when hovering the active tool so + the white glyph never lands on a light hover background. */ +.dock__tool--active:hover { + background: var(--accent-hover); + color: #fff; +} + .dock__chevron { position: absolute; right: 3px; @@ -605,7 +630,8 @@ input:focus-visible { background: var(--glass-hover); } -.brush-style--active { +.brush-style--active, +.brush-style--active:hover { background: var(--preset-active-bg); color: var(--preset-active-fg); border-color: transparent; @@ -681,7 +707,8 @@ input:focus-visible { background: var(--segment-bg); } -.width-preset--active { +.width-preset--active, +.width-preset--active:hover { background: var(--preset-active-bg); color: var(--preset-active-fg); } @@ -820,7 +847,39 @@ input:focus-visible { width: min(280px, calc(100vw - 24px)); } -/* Compact swatch palette row (shared by ColorPicker + SelectionInspector). */ +/* Disclosure row that reveals the advanced layer (hex / opacity / save). */ +.color-picker__more { + appearance: none; + border: 0; + background: transparent; + color: var(--fg-1); + font: inherit; + font-size: 13px; + font-weight: 600; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 6px; + border-radius: 8px; + cursor: pointer; + transition: background 140ms ease, color 140ms ease; +} + +.color-picker__more:hover { + background: var(--glass-hover); + color: var(--fg-0); +} + +.color-picker__advanced { + display: flex; + flex-direction: column; + gap: 12px; + padding-top: 10px; + border-top: 1px solid var(--glass-stroke); +} + +/* Compact swatch palette row (shared by ColorPicker + SelectionToolbar). */ .color-palette-mini { display: flex; flex-wrap: wrap; @@ -917,7 +976,8 @@ input:focus-visible { background: var(--glass-hover); } -.text-toolbar__btn--active { +.text-toolbar__btn--active, +.text-toolbar__btn--active:hover { background: var(--preset-active-bg); color: var(--preset-active-fg); } @@ -1174,7 +1234,8 @@ input:focus-visible { color: var(--fg-0); } -.menu__grid-btn--active { +.menu__grid-btn--active, +.menu__grid-btn--active:hover { background: var(--segment-active-bg); color: var(--fg-0); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4043711..95766c4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,6 +47,9 @@ importers: react-router-dom: specifier: ^6.26.2 version: 6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + zustand: + specifier: ^4.5.5 + version: 4.5.7(@types/react@18.3.29)(react@18.3.1) devDependencies: '@types/react': specifier: ^18.3.10