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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
3 changes: 2 additions & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
138 changes: 93 additions & 45 deletions apps/web/src/features/canvas/ColorPicker.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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);
Expand All @@ -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;

Expand All @@ -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 (
<Sheet open={open} onClose={onClose} anchorRef={anchorRef}>
<div className="color-picker color-picker--mini">
Expand Down Expand Up @@ -70,77 +84,111 @@ export function ColorPicker({ open, onClose, anchorRef }: Props) {
</button>
</div>

{/* Minimalist swatch palette */}
{/* Quick layer — curated palette */}
<div className="color-palette-mini">
{COLORS.map((c) => (
<Swatch
key={c}
color={c}
size={26}
selected={c.toLowerCase() === lower}
onClick={() => setColor(c)}
onClick={() => pick(c)}
aria-label={c}
/>
))}
</div>

{/* Hex input */}
<div className="color-picker__hex-row">
<span className="color-picker__hex-label">#</span>
<input
className="color-picker__hex-input"
value={color.replace(/^#/, "")}
onChange={(e) => {
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}
/>
<div
className="color-picker__preview"
style={{ background: color }}
/>
</div>

<div className="color-picker__section-label">Opacity</div>
<Slider
value={opacity}
onChange={setOpacity}
trackStyle="opacity"
color={color}
aria-label="Opacity"
/>
{recentRow.length > 0 && (
<>
<div className="color-picker__section-label">Recent</div>
<div className="color-palette-mini">
{recentRow.map((c) => (
<Swatch
key={c}
color={c}
size={26}
selected={c.toLowerCase() === lower}
onClick={() => pick(c)}
aria-label={c}
/>
))}
</div>
</>
)}

{swatches.length > 0 && (
<div className="color-picker__swatches">
<div className="color-picker__saved">
<>
<div className="color-picker__section-label">Saved</div>
<div className="color-palette-mini">
{swatches.map((c) => (
<Swatch
key={c}
color={c}
size={26}
selected={c.toLowerCase() === lower}
onClick={() => setColor(c)}
onClick={() => pick(c)}
/>
))}
</div>
</div>
</>
)}

{/* Advanced layer — hex, opacity, save */}
<button
type="button"
className="color-picker__save-btn"
onClick={() => addSwatch(color)}
aria-label="Save current color"
title="Save current color"
className="color-picker__more"
onClick={() => setAdvancedOpen(!advancedOpen)}
aria-expanded={advancedOpen}
>
<Icon name="plus" size={14} />
<span>Save color</span>
<Icon name={advancedOpen ? "chevron-up" : "chevron-down"} size={13} />
<span>{advancedOpen ? "Less" : "More"}</span>
</button>

{advancedOpen && (
<div className="color-picker__advanced">
<div className="color-picker__hex-row">
<span className="color-picker__hex-label">#</span>
<input
className="color-picker__hex-input"
defaultValue={color.replace(/^#/, "")}
key={lower}
onChange={(e) => {
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}
/>
<div
className="color-picker__preview"
style={{ background: color }}
/>
</div>

<div className="color-picker__section-label">Opacity</div>
<Slider
value={opacity}
onChange={setOpacity}
trackStyle="opacity"
color={color}
aria-label="Opacity"
/>

<button
type="button"
className="color-picker__save-btn"
onClick={() => addSwatch(color)}
aria-label="Save current color"
title="Save current color"
>
<Icon name="plus" size={14} />
<span>Save color</span>
</button>
</div>
)}
</div>
</Sheet>
);
Expand Down
Loading
Loading