Compare commits

...
Author SHA1 Message Date
Tanish Manku 532bd2db4a Merge branch 'main' into moodboard-lib 2026-06-23 20:15:43 -07:00
bflatastic 3cb2e6de6a added missing tailwind color 2026-06-17 21:41:50 +07:00
bflatastic 8334e43886 Moodboard: webapp parity, lightbox redesign, editable notes & selection polish
Align the shared @storyteller/ui-moodboard surface with the webapp's design
language across both hosts (Tauri desktop + webapp), plus several UX fixes.

Theming & layout
- Use `var(--st-bg, hsl(0 0% 9%))` for the workspace/grid background so desktop
  follows the active theme while the webapp falls back to its --background token
  (replaces the desktop-only bg-ui-background class).
- Fix desktop overflow: the fixed TopBar is 56px, so the moodboard tab now uses
  h-screen + pt-[56px] + overflow-hidden, killing the stray vertical/horizontal
  scrollbars on an empty board. Grid view root clips too.

Empty state & toolbars
- Empty-state Upload / From-library CTAs now use the shared Button, matching the
  webapp create pages (h-12, pill, semibold; primary + secondary).
- Restyle SelectionBar to match the library multiselect bar (solid pill, filled
  bg-ui-controls buttons, red Delete, round clear); align the "Move to"
  menu/items/Kbd to the same idiom.
- Restyle SmartSearchBar (bg-ui-controls, rounded-xl) and tighten ToolbarShell
  padding.
- Add a tooltip to the toolbar's Add Color button to match the sibling buttons.

Notes
- Adding a note creates an empty, immediately-editable note (autofocusing inline
  textarea; commit on blur/Enter, Shift+Enter newline, Esc reverts); double-click
  re-enters edit mode — no more stuck "New note".
- Pad the note's top so the selection check no longer overlaps the first line.

Selection
- Clicks accumulate: clicking an item adds it to the selection (clicking again
  removes it), so multi-select builds up without a modifier; background click
  clears. "Use as reference" only shows when the selection has a
  reference-capable image.

Item inspector
- Redesign ItemInspector to mirror the shared LightboxModal: a bounded, centered,
  draggable/resizable Modal with media on a dark plate, hover nav arrows, and a
  fixed info/actions column using shared Buttons.

Webapp homepage
- Add a Moodboard card next to Edit Video on the webapp home page.
2026-06-17 21:37:21 +07:00
BFlat 43de77f2a6 Moodboard: styling updates 2026-06-17 14:39:40 +07:00
Tanish Manku 9abbbd686e Moodboard P3: canvas snapping, link previews, drag-from-library
2.6 — canvas drag snapping. A shared useNodeDrag hook snaps a dragged
node's near/center/far edges to nearby nodes (and the grid as a fallback)
and draws live alignment guides; a magnet toggle in the canvas toolbar
turns it on/off. Pure computeSnap helper, unit-tested.

2.7 — richer link cards. Show the site's own favicon (client-side, no
proxy; graceful link-glyph fallback) plus a domain line, in both the
grid card and the inspector.

2.8 — drag a library/gallery image onto the board. The grid gains a
drop listener mirroring the canvas's; both consume one event dispatched
by dispatchGalleryMoodboardDrop, which each app wires to gallery-modal's
onImageDrop. Bounds-checked against the mounted board surface.
2026-06-15 19:02:29 -07:00
Tanish Manku a7b887e11c Moodboard: section lanes in the grid
P2.3 from the UX change plan — organize a board into named sections:
- Grid groups items into section lanes within one virtualized scroll
  plane (computeSectionedLayout): header band + masonry + gap, repeated,
  with absolute positions so virtualization, marquee, and keyboard nav
  all keep working over the flat position list.
- Section headers: inline rename, count, collapse chevron (persists),
  delete (items fall back to the ungrouped lane). Ungrouped lane is
  shown first when populated and is non-editable.
- 'New section' in the toolbar; 'Move to' menu in the selection bar
  assigns the selection to a section / ungrouped / a fresh section.
- Store: createSection / renameSection / deleteSection /
  toggleSectionCollapsed / assignItemsToSection, all persisted.
- Tests for the sectioned layout and the section store actions.
2026-06-15 18:48:56 -07:00
Tanish Manku da4bde24e1 Moodboard: keyboard navigation, entry motion, focus & CTA polish
P1 + select P2 from the UX change plan:
- Keyboard-navigable grid cards: roving tabindex, geometry-aware
  arrow movement (nearestInDirection), Enter opens / Space selects /
  Delete removes, programmatic focus on arrow nav, ARIA labels.
- Staggered fade-up entry motion, virtualization-safe via a revealed
  Set so scrolling never re-triggers it; respects reduced-motion.
- Complete focus-visible coverage across grid chrome, inspector,
  selection bar, presentation, and empty state.
- Button-in-button trailing-icon CTAs on primary actions.
- Drop unsupported tooltipDelay prop that slipped through on a stale
  dist; add gridLayout spatial-nav tests.
2026-06-15 18:41:24 -07:00
Tanish Manku b68fe3c563 docs(moodboard): UX change plan from the design/UX doc (audit + prioritized) 2026-06-15 18:18:28 -07:00
Tanish Manku 747174b608 Gallery modal: show a Login button instead of Retry when signed out
The "not logged in" state of the gallery picker showed a small "Retry" link,
which doesn't tell the user what to do. Add an optional onLoginClick prop: when
provided, render a proper "Log in" button; otherwise keep the Retry fallback
(backward compatible). The moodboard wires it on both platforms — desktop opens
the login modal (useLoginModalStore), web navigates to /login.

Also adds docs/moodboard/DESIGN_AND_FLOW.md — the moodboard's design language +
user flows, written through the high-end design / UX lens.
2026-06-15 18:09:13 -07:00
Tanish Manku 1ab4627545 Moodboard: land shared lib on main + integrate into webapp and desktop
Re-bases the moodboard work onto current main (the old branch had diverged
~2 months / 315 commits, and main's "create pages redesign" had deleted the
PageEditor the old integration mounted into). The feature lives entirely in the
shared @storyteller/ui-moodboard lib (grid + Konva canvas + presentation,
adapter pattern); only thin per-app wiring is added.

New webapp (apps/artcraft-webapp):
- /moodboard route (ProtectedLayout) + sidebar entry (BETA).
- webappMoodboardAdapter: upload via the webapp uploader (media token), library
  picker via @storyteller/ui-gallery-modal, "use as reference" seeds
  useCreateImageStore + navigates to /create-image.

Desktop (apps/artcraft):
- MOODBOARD tab in MainApp/TabState + appMenu entry (BETA), routed via goToApp.
- desktopMoodboardAdapter: upload via @storyteller/api UploadImageMedia, library
  picker via gallery-modal, "use as reference" seeds usePromptImageStore +
  switches to the IMAGE tab.

Both apps share the lib via the same MoodboardAdapter seam (uploadImage /
sendToGeneration / renderLibraryPicker). Lib + both apps build green; lib tsc/
eslint clean; 15 lib unit tests pass. nx sync updated project references.
2026-06-15 17:32:12 -07:00
94 changed files with 9037 additions and 33 deletions
+227
View File
@@ -0,0 +1,227 @@
# Artcraft Moodboard — Design & Flow
> How the moodboard looks, feels, and flows. Written through a high-end product-design lens
> (Linear/Apple-tier restraint: haptic depth, spring-physics motion, calm surfaces) and grounded
> in the shipped implementation (`@storyteller/ui-moodboard`). Companion to `DESIGN_SYSTEM.md`
> (tokens/components), `FEATURE_PLAN.md` (roadmap), and `FEATURE_BACKLOG.md` (what's next).
---
## 1. Design philosophy
Three convictions drive every decision:
1. **The board is an input to generation, not a graveyard.** Generic moodboards (Pinterest,
Milanote) are dead ends — references go in, nothing comes out. Artcraft owns the engine, so the
board is one gesture away from a generation and vice-versa. Every surface keeps that loop in
reach without ever shouting about it.
2. **Native, then elevated.** The moodboard lives *inside* Artcraft, so it speaks the app's visual
language (the `--st-*` theme tokens, Fira Sans, the `glass` material, the brand blue). Elevation
comes from *composition* — nested "double-bezel" enclosures, floating glass islands, motion with
mass — never from a louder palette. The board should feel like it was always part of the app.
3. **The imagery is the interface.** Chrome recedes; references dominate. No like-counts, no
notification noise, no decorative gradients competing with the user's work. Calm by default
(the Cosmos/Are.na lesson), dense where it counts.
The felt quality we're after: **a precise, quiet instrument** — fast, tactile, and a little bit
joyful, where heavy operations feel weightless and nothing ever teleports.
---
## 2. Visual language (the short version)
The full token/component spec is in `DESIGN_SYSTEM.md`; the design *intent* behind it:
- **Double-bezel enclosures.** Cards, the inspector, and toolbars are never flat on the canvas.
A subtle outer shell (plane-1, hairline ring, `rounded-[18px]`, `p-1`) cradles an inner core
(plane-2, concentric `rounded-[14px]`, an inset top-highlight). It reads like a glass plate
seated in a machined tray — physical, considered, expensive.
- **Floating glass islands.** The toolbar and the view switch are detached pills, not edge-glued
bars (`glass` + hairline + ambient shadow, `backdrop-blur` only on these fixed elements — never
on the scrolling grid, where blur would torch scroll performance).
- **Spring-physics motion.** Custom cubic-beziers, never `linear`/`ease-in-out`. A hover lift, a
card landing on drop with a touch of overshoot, a palette fading up. Animate only `transform`,
`opacity`, `filter` — GPU-safe. `prefers-reduced-motion` collapses springs to fades.
- **Concentric squircle radii** and **ambient, layered shadows** (never a harsh `shadow-md`).
- **One display moment.** Body/UI is Fira Sans (the app font, for zero context-switch); the only
"display" flourish is the editorial empty-state heading and presentation mode.
---
## 3. Information architecture
```
Board (durable model, localStorage today → backend later)
├── items: image · video · text · link · color (each: tags, rating, deterministic aspect)
├── sections (model-ready; UI is a near-term add)
└── rendered three ways — ALL views are projections of the same board:
├── GRID virtualized masonry — the default; scan / search / triage at volume
├── CANVAS freeform Konva stage — arrange / compare / plan spatially
└── PRESENT full-bleed deck — review & share
```
**Why grid-first.** 90% of moodboard time is scanning, searching, and triaging — the grid wins
those high-frequency jobs and stays at 60fps (canvas-only tools like Milanote lag at a few hundred
items; we virtualize from item aspect ratios so only the viewport mounts). The freeform canvas is
the *delighter* for spatial thinking, not the foundation. **The board, not the view, is what plugs
into generation** — "use as reference" works regardless of how you're looking at it.
---
## 4. Core flows
Each flow below lists the **gesture**, then the **design rationale**.
### 4.1 Capture — get references in with zero friction
The fastest path wins, so we offer every path and make the empty state *say so*.
- **Drag images** from the desktop onto the board → a dashed primary-tinted drop overlay confirms
the target; files land at the cursor.
- **Paste** — an image from the clipboard drops instantly; a **URL** becomes a link card
(hostname title today; rich OG preview is a backlog item).
- **Upload** (toolbar `↑` / empty-state CTA) → the image appears immediately from a blob, then
uploads in the background to capture a durable media token (so it becomes reference-capable).
- **From library** → the shared gallery picker (Artcraft's own `GalleryModal`), so muscle memory
transfers from the rest of the app. Not signed in? The picker now shows a **Log in** button
(not a dead "Retry"), routed per-platform via the adapter.
- **Note / Color** → a sticky note or an OS-picked swatch, one click each.
*Rationale:* friction is the enemy of collection. The empty state is an **editorial hero**
("Start collecting ideas") that doubles as the drop affordance — it teaches the gestures while
looking like a finished product, not a blank slate.
### 4.2 Organize — keep a growing board legible
- **Density** — a segmented control (Compact / Cozy / Comfortable) re-flows the masonry; the
layout is target-width-driven, so it adapts to any container (Savee's adjustable-density bar).
- **Search + tag chips** — text query over captions/tags/links; click a chip to filter.
- **Multi-select** — click, shift/⌘-click, or marquee-drag on bare canvas; a floating glass
**selection bar** rises from the bottom with the count + actions.
### 4.3 Triage — sift keepers from a batch, fast
Lightroom is the gold standard for high-volume culling, so we borrow its muscle memory:
- With items selected, **`1`–`5`** rate, **`0`** clears, **`P`** picks (5 stars). A rating badge
persists on the tile.
- The toolbar **star filter** cycles "All → 1+ … → 5+ → All" to winnow to keepers.
- The selection bar surfaces the shortcuts inline (`1–5 rate · 0 clear`) so they're *discoverable*,
not hidden lore.
- **Delete is deliberate** — `Delete`/`Backspace` only (we removed `x` as a delete alias: it sits
in the number row and the board has no undo, so a stray press must not destroy work).
*Rationale:* triage is keyboard-first because it's repetitive and high-volume; every key saves a
trip to the mouse. Destructive actions get friction; constructive ones get none.
### 4.4 The board → generate loop — the differentiator
This is the move no generic moodboard can make.
- **"Use as reference"** — on a card's hover rail, in the selection bar, or in the inspector.
Selected board images are pushed into the image-generation prompt as style references (via their
media tokens) and the user lands on the Create-Image surface, refs pre-filled. Krea/Firefly
proved board→generate; Artcraft closes it natively.
- The seam is the **adapter** (§6): desktop seeds `usePromptImageStore` + switches to the Image
tab; web seeds `useCreateImageStore` + navigates to `/create-image`. Same gesture, same result,
two platforms.
*Rationale:* the highest-leverage feature is the one that turns a passive collection into momentum.
It's always one click from any view, and it never blocks — it sends and gets out of the way.
### 4.5 Inspect — the lightbox as a workbench
Double-click or the rail's expand icon opens a full-screen **inspector**:
- Large media on a glass-blurred backdrop (blur is fine here — it's a fixed overlay), a
double-bezel side panel, `←/→` to walk the board, `Esc` to close, background scroll locked,
`role="dialog"`.
- The panel is a small workbench: **rating** stars, a **tag editor**, an **extracted palette**
(client-side color quantization — click a swatch to copy, or "add swatches to board"), and
**Use as reference**. The palette is keyed on the image src, so editing tags never re-decodes
the image or flickers.
### 4.6 Canvas — think spatially
The freeform Konva stage for arranging, comparing, and planning:
- Pan/zoom, marquee + lasso select, 8-handle transform, grouping, undo/redo, a shortcut cheatsheet,
and auto-layout helpers (pack / fit-to-grid / cluster-by-proximity) that already exist and want
better surfacing. Alignment/snapping + a background grid are the next polish (backlog).
- Drop a generation onto the canvas, set images side by side, group a coherent set, then send that
set to generation. It's the planning room; the grid is the library.
### 4.7 Present — review & share
A distraction-free, full-bleed deck: one item at a time, `←/→/space` to navigate, `Esc` to exit,
launched from the view switch as a transient overlay (never persisted, so a reload never reopens
into a slideshow). For client/team review and screen-sharing.
---
## 5. Micro-UX niceties (the details that read as "expensive")
- **Hover action rail** — a glass pill on each card fades *up* on hover (translate + opacity), with
Open / Use-as-reference / Delete. Buttons stop propagation so they never double as a select.
- **Card states choreographed** — hover lifts the shell 2px and deepens the shadow; selected swaps
to a 2px brand ring; drag scales to 1.03; drop lands with a snap-overshoot.
- **Selection check + rating** — solid scrims (not blur) inside the scrolling grid for legibility
without GPU cost.
- **Focus-visible rings everywhere** — keyboard users see where they are, not just hover users.
- **The Login button** — the gallery picker now invites action ("Log in to browse your library" +
a real primary button) instead of a confusing "Retry" link.
- **Stagger on entry** — grid items and presentation reveals come in with a short staggered
fade-up, so the board feels assembled, not dumped.
---
## 6. Cross-platform via the adapter
One library, two hosts, identical experience. The lib owns everything platform-agnostic; each app
injects a `MoodboardAdapter` for its three seams:
```ts
interface MoodboardAdapter {
uploadImage?: (file) => Promise<mediaToken | null>; // capture a durable token
sendToGeneration: (refs) => void; // board → prompt
renderLibraryPicker?: (props) => ReactNode; // the gallery picker (render-prop)
}
```
- **Desktop (Tauri):** upload via `@storyteller/api`, picker via `GalleryModal`, send → prompt
store + tab switch, login → the login modal.
- **Web (artcraft-webapp):** upload via the webapp uploader, picker via the same `GalleryModal`,
send → create-image store + `/create-image`, login → navigate `/login`.
*Rationale:* the adapter is the seam where platform reality lives, so the *experience* stays a
single, consistent design across surfaces — and the same polish ships everywhere at once.
---
## 7. Motion & performance contract
- **Curves:** spring (`cubic-bezier(0.32,0.72,0,1)`) for settle/zoom; smooth
(`cubic-bezier(0.4,0,0.2,1)`, ~200ms) for hover/selection; snap with slight overshoot on drop.
- **Only `transform`/`opacity`/`filter`** animate — never layout properties.
- **`backdrop-blur` only on fixed chrome** (toolbar, view switch, inspector/present backdrops);
the virtualized scrolling grid uses solid scrims so scrolling stays at 60fps.
- **Virtualize aggressively** — deterministic masonry from item aspects means only viewport-visible
cards mount; the grid holds thousands of items without lag (the competitor failure mode).
- **Respect `prefers-reduced-motion`** — springs become instant, fades remain.
---
## 8. Accessibility & polish checklist
- [x] Theme-correct: consumes `--st-*` only, so light/dark/gray/black/aurora themes work for free.
- [x] Focus-visible rings on interactive controls; inspector is a labelled `role="dialog"` with
scroll-lock and keyboard nav.
- [x] Color is never the only signal (drop state pairs a tint with a dashed outline + icon).
- [x] Destructive delete is deliberate (no single-printable-key delete); no silent data loss.
- [ ] Cards are not yet keyboard-focusable/selectable (pointer-first) — a known a11y gap.
- [ ] Toolbar/inspector don't yet collapse gracefully below ~768px (desktop-first) — responsive
pass pending.
---
## 9. Where the design goes next
The research-backed backlog (`FEATURE_BACKLOG.md`) extends this flow without changing its spine —
client-side ML (in-browser captioning, auto-tagging, find-similar, palette/color search) makes the
board *understand itself*; canvas snapping/alignment and a region→palette tool deepen planning; and
the generate-into-a-board / remix loop tightens the differentiator. Collaboration & sharing are
deferred behind a flag (they need the backend). The throughline stays constant: **collect with no
friction, organize effortlessly, and turn the board into the next generation — calmly.**
+101
View File
@@ -0,0 +1,101 @@
# Moodboard — UX Change Plan
> Derived from the recommendations in `DESIGN_AND_FLOW.md` (the high-end design/UX doc), checked
> against the **actual implementation**. This is a *design-and-UX polish* plan — closing the gap
> between the experience the doc describes and what's shipped. (AI/ML features live separately in
> `FEATURE_BACKLOG.md`; backend-dependent work like sharing stays deferred.)
## Audit — doc says vs. reality
| Recommendation (DESIGN_AND_FLOW.md) | Status today | Gap |
|---|---|---|
| §5 "Stagger on entry — items fade-up assembled" | **Not built** — grid renders the virtualized slice with no entry motion | Yes |
| §8 "Focus-visible rings on interactive controls" | **Partial** — only card action-rail + toolbar add-buttons | Yes |
| §8 "Cards not yet keyboard-focusable/selectable" | **Not built** — pointer-only | Yes (acknowledged) |
| §8 "Toolbar/inspector don't collapse < ~768px" | **Not built** — fixed horizontal island; inspector is a fixed 2-pane | Yes (acknowledged) |
| §4.2 "Sections" | Model-ready, **no UI** | Yes |
| §4.1 "Paste URL → link card" | Hostname-only, **no OG preview** | Yes (needs proxy) |
| §4.6 "Auto-layout helpers want better surfacing" | Helpers exist (`pack`/`fitToGrid`/`cluster`), **under-exposed** | Yes |
| §5 double-bezel, glass islands, spring motion, hover rail, card states, scrims, login button, triage hint, inspector dialog | **Built** ✓ | — |
Everything in the bottom row is genuinely shipped; the rows above are the work.
---
## The plan (prioritized by impact ÷ effort)
Each item: **what + UX**, **why** (design rationale), **effort (S/M/L)**, **acceptance**.
### P1 — Motion & accessibility foundation (do first)
**1.1 Entry motion — staggered fade-up on the grid.** *(the doc claims it; it isn't there)*
- **What:** as cards mount into the viewport, fade-up from `translate-y-2 opacity-0 blur-[2px]` → rest over ~320ms on `--mb-ease-smooth`, with an index-based delay capped at ~12 items so a full screen feels *assembled*, not dumped. Must be virtualization-safe: animate only on first mount, key the animation so re-renders/scroll don't re-trigger; respect `prefers-reduced-motion`.
- **Why:** the single biggest "expensive feel" lever the doc names; the board currently pops in flatly.
- **Effort:** **S–M.** **Acceptance:** opening a board / switching density plays one clean stagger; scrolling does **not** re-animate; reduced-motion shows instant.
**1.2 Keyboard-navigable cards.**
- **What:** card root becomes focusable (`tabIndex=0`, `role="button"`, `aria-label`); `Enter`/`Space` opens the inspector, `X`/`Delete` deletes the focused card (with the same deliberate-delete rule), and arrow keys move focus across the masonry (geometry-aware: left/right within a row, up/down to the nearest card). Selection follows focus with `Space`; `Shift`+arrows extends.
- **Why:** the doc's headline a11y gap; also a power-user speed win (no mouse round-trips during triage).
- **Effort:** **M.** **Acceptance:** a keyboard-only user can tab into the grid, move with arrows, open/rate/delete, and select — with a visible focus ring on each card.
**1.3 Complete focus-visible coverage.**
- **What:** add focus-visible rings to every remaining interactive control — `SelectionBar` buttons, `SmartSearchBar` input + clear + tag chips, density segmented control, rating-filter toggle, the view switch, and all `ItemInspector` controls (rating stars, tag input, palette swatches, actions).
- **Why:** §8 says "everywhere"; today it's two components.
- **Effort:** **S.** **Acceptance:** tabbing through the whole moodboard always shows where focus is.
### P2 — Responsive & micro-UX completeness
**2.1 Responsive toolbar.**
- **What:** below `md`, the floating island stops being one wide row — it becomes a compact glass bar: board name + a `+`-menu (collapsing the 4 add-buttons), density, rating, and a search **icon** that expands into a sheet; the whole thing `overflow-x-auto` as a last resort so it never breaks the layout.
- **Why:** §8 gap; the webapp can be viewed narrow, and today the island overflows.
- **Effort:** **M.** **Acceptance:** at 375px wide the toolbar is usable and doesn't clip; at ≥`md` it's unchanged.
**2.2 Responsive inspector.**
- **What:** below `md`, the inspector stacks (media on top, the panel as a bottom sheet) instead of the fixed media-left / 320px-panel split; `h-[100dvh]`; nav arrows move to the corners.
- **Why:** §8 gap; the current side-by-side is unusable on phones.
- **Effort:** **S–M.** **Acceptance:** inspector is fully usable at 375px.
**2.3 Sections UI.**
- **What:** group items into named, collapsible sections in the grid (the model already supports `sections` + `sectionId`). A section header row with a name (inline-rename), count, collapse toggle, and "new section"; drag a selection into a section; an "Ungrouped" default lane.
- **Why:** §4.2 calls it a near-term add; big boards spiral without it (Pinterest Sections / MJ folder-groups).
- **Effort:** **M.** **Acceptance:** create/rename/collapse sections; assign items; persists.
**2.4 Surface canvas auto-layout helpers.**
- **What:** a one-click **Tidy / Arrange** menu on the canvas toolbar exposing the already-built `pack` (justified rows), `fitToGrid`, and `cluster-by-proximity`, plus "align selection."
- **Why:** §4.6 — the logic exists but is hidden; cheap, high-delight.
- **Effort:** **S.** **Acceptance:** the three layouts run from a visible menu; undoable.
**2.5 "Button-in-button" primary CTAs.**
- **What:** apply the doc's nested trailing-icon pattern (already in the empty-state CTA) to the primary actions — "Use as reference" and the inspector's primary button — with the magnetic hover physics (icon translates diagonally + scales on hover, button `active:scale-[0.98]`).
- **Why:** §5 haptic consistency; makes the differentiator action feel premium.
- **Effort:** **S.** **Acceptance:** primary CTAs share one polished button anatomy.
### P3 — Larger canvas & capture polish
**2.6 Canvas alignment + snapping + background grid.**
- **What:** snap a dragged node to other nodes' edges/centers with guide lines (DIY on Konva `dragmove`), plus a toggleable background grid + snap-to-grid.
- **Why:** §4.6/§9 — makes the freeform canvas feel professional (PureRef 2.1).
- **Effort:** **M.** (also tracked in `FEATURE_BACKLOG.md` 3.1/3.2.) **Acceptance:** dragging shows guides + snaps; grid toggles.
**2.7 Rich URL preview (link cards).**
- **What:** paste a URL → fetch OG title + image for a real preview card (vs hostname today).
- **Why:** §4.1 — link cards look unfinished without a thumbnail.
- **Effort:** **S (client) / M (proxy).** **Dependency:** OG fetch needs a CORS unfurl proxy → **flag as backend-dependent / defer the image; ship title now.**
**2.8 Drag-from-library onto the board.**
- **What:** let a gallery item be dragged directly onto the grid/canvas (not only picked via the modal), reusing Artcraft's existing gallery-drag event.
- **Why:** §4.1 — the lowest-friction capture path for already-owned media.
- **Effort:** **M.** **Acceptance:** drag a library thumbnail onto the board → it lands as a tokened item.
---
## Sequencing
- **Sprint 1 (feel + a11y):** 1.1 entry motion, 1.2 keyboard cards, 1.3 focus coverage. *One cohesive "the board feels finished and is fully keyboard-able" release.*
- **Sprint 2 (responsive + organize):** 2.1 toolbar, 2.2 inspector, 2.3 sections, 2.5 CTAs.
- **Sprint 3 (canvas + capture):** 2.4 surface auto-layout, 2.6 snapping/grid, 2.8 drag-from-library, 2.7 link previews (title now, image when a proxy exists).
## Recommended first move
**Sprint 1** — it's the highest "expensive feel ÷ effort" and closes the doc's two explicit a11y
gaps + the one motion claim that isn't yet true. All client-side, no backend, fits the shared lib
cleanly, and verifiable headlessly (focus order, reduced-motion, DOM-on-mount).
@@ -18,6 +18,7 @@ import CreateVFX from "../pages/create-vfx";
import PageScene from "../pages/pagescene";
import PageDraw from "../pages/pagedraw";
import VideoEditorPage from "../pages/video-editor";
import MoodboardPage from "../pages/moodboard";
import Pricing from "../pages/pricing";
import Support from "../pages/support/support";
import Login from "../pages/login";
@@ -91,7 +92,7 @@ function ProtectedContent() {
? "var(--sidebar-width)"
: "calc(var(--sidebar-width-icon) + 1.5rem)";
// The Edit 3D, Edit Image, and video editors host the header's actions
// The Edit 3D, video editor, and moodboard host the header's actions
// (pricing/credits/task queue/profile) inside their own toolbar/header to
// reclaim vertical space, so the global header is hidden there — desktop
// only, since the mobile routes show the global chrome (Edit Image keeps the
@@ -102,7 +103,8 @@ function ProtectedContent() {
pathname.startsWith("/edit-3d/") ||
pathname === "/edit-image" ||
pathname === "/video-editor" ||
pathname.startsWith("/video-editor/"));
pathname.startsWith("/video-editor/") ||
pathname === "/moodboard");
return (
<div
@@ -152,6 +154,7 @@ export function App() {
<Route path="/create-image" element={<CreateImage />} />
<Route path="/create-video" element={<CreateVideo />} />
<Route path="/background-change" element={<CreateVFX />} />
<Route path="/moodboard" element={<MoodboardPage />} />
<Route path="/edit-3d" element={<PageScene />} />
<Route path="/edit-3d/:sceneToken" element={<PageScene />} />
<Route path="/edit-image" element={<PageDraw />} />
@@ -8,6 +8,7 @@ import {
faImage,
faCube,
faFilm,
faObjectGroup,
faWandMagicSparkles,
faGraduationCap,
faNewspaper,
@@ -63,6 +64,12 @@ const CREATE_ITEMS_STATIC: NavItem[] = [
href: "/background-change",
icon: faWandMagicSparkles,
},
{
label: "Moodboard",
href: "/moodboard",
icon: faObjectGroup,
badge: "BETA",
},
];
// Rewrite the "Edit 3D" item's href to point at the user's last visited
@@ -7,6 +7,7 @@ import {
faWandMagicSparkles,
faCube,
faFilm,
faObjectGroup,
faArrowRight,
} from "@fortawesome/pro-solid-svg-icons";
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
@@ -74,6 +75,16 @@ const APPS: AppCard[] = [
iconColor: "text-rose-300",
badge: "BETA",
},
{
label: "Moodboard",
description: "Collect references and ideas to steer a generation.",
href: "/moodboard",
icon: faObjectGroup,
accent: "from-indigo-500/20 to-indigo-500/0",
iconBg: "bg-indigo-500/25 border-indigo-400/30",
iconColor: "text-indigo-300",
badge: "BETA",
},
];
export function Home() {
@@ -0,0 +1,54 @@
import { useEffect } from "react";
import { useNavigate } from "react-router-dom";
import {
MoodboardWorkspace,
dispatchGalleryMoodboardDrop,
} from "@storyteller/ui-moodboard";
import {
GalleryItem,
onImageDrop,
removeImageDropListener,
} from "@storyteller/ui-gallery-modal";
import {
webappMoodboardAdapter,
setMoodboardNavigate,
} from "./webappMoodboardAdapter";
import { TopBarActions } from "../../components/topbar/TopBarActions";
import { useSidebar } from "../../components/ui/sidebar";
// Web moodboard page. Renders the full shared workspace (Grid + freeform Konva
// Canvas + Presentation). Fills the SidebarInset content area below the TopBar.
export default function MoodboardPage() {
const navigate = useNavigate();
// On desktop the global TopBar is hidden for this route (see app.tsx), so we
// relocate its nav actions into the board. On mobile the TopBar stays, so we
// skip the in-page copy to avoid doubling the profile/credits chrome.
const { isMobile } = useSidebar();
// Let the (non-hook) generation bridge navigate via the SPA router.
useEffect(() => {
setMoodboardNavigate((path) => navigate(path));
}, [navigate]);
// Drag a library image onto the grid/canvas to add it to the board.
useEffect(() => {
const handler = onImageDrop(
(item: GalleryItem, position: { x: number; y: number }) => {
dispatchGalleryMoodboardDrop(item, position);
},
);
return () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (handler) removeImageDropListener(handler as any);
};
}, []);
return (
<div className="h-full w-full">
<MoodboardWorkspace
adapter={webappMoodboardAdapter}
topBarEndSlot={isMobile ? undefined : <TopBarActions />}
/>
</div>
);
}
@@ -0,0 +1 @@
export { default } from "./MoodboardPage";
@@ -0,0 +1,122 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import toast from "react-hot-toast";
import { UploaderStates } from "@storyteller/common";
import { GalleryModal, GalleryItem } from "@storyteller/ui-gallery-modal";
import type {
MoodboardAdapter,
MoodboardReference,
MoodboardLibraryPickerProps,
} from "@storyteller/ui-moodboard";
import { uploadImage as webappUploadImage } from "../../components/prompt-box/upload-image";
import { useCreateImageStore } from "../create-image/create-image-store";
import type { RefImage } from "../../components/prompt-box/types";
// Web (artcraft-webapp) implementation of the moodboard's platform seams.
// MoodboardPage registers the router navigate so this non-hook module can do
// SPA navigation without a full reload.
let navigateFn: ((path: string) => void) | null = null;
export const setMoodboardNavigate = (fn: (path: string) => void): void => {
navigateFn = fn;
};
// Reuses the webapp's uploader, which surfaces a durable media token via its
// success callback — so web-uploaded items are reference-capable.
const uploadImage = async (file: File): Promise<string | null> => {
let token: string | null = null;
try {
await webappUploadImage({
title: file.name || "Moodboard image",
assetFile: file,
progressCallback: (state) => {
if (state.status === UploaderStates.success && state.data) {
token = String(state.data);
}
},
});
} catch (err) {
console.error("[Moodboard] upload failed", err);
}
return token;
};
// The webapp keeps reference images in a shared store, so we can seed them
// directly and navigate to Create Image (no sessionStorage handoff needed).
const sendToGeneration = (refs: MoodboardReference[]): void => {
const store = useCreateImageStore.getState();
const newRefs: RefImage[] = refs.map((r) => ({
id: r.id,
url: r.url,
file: new File([], r.id),
mediaToken: r.mediaToken,
}));
store.setReferenceImages(dedupeByToken([...store.referenceImages, ...newRefs]));
toast.success(
`${refs.length} reference${refs.length > 1 ? "s" : ""} sent to Create Image`,
);
if (navigateFn) navigateFn("/create-image");
else window.location.assign("/create-image");
};
const WebappLibraryPicker = ({
open,
onClose,
onPick,
}: MoodboardLibraryPickerProps) => {
const navigate = useNavigate();
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const close = () => {
setSelectedIds([]);
onClose();
};
return (
<GalleryModal
isOpen={open}
onClose={close}
onLoginClick={() => {
close();
navigate("/login");
}}
mode="select"
selectedItemIds={selectedIds}
onSelectItem={(id: string) =>
setSelectedIds((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
)
}
onUseSelected={(items: GalleryItem[]) => {
onPick(
items.map((it) => ({
url: it.fullImage || it.thumbnail || "",
mediaToken: it.id ?? null,
kind: it.mediaClass === "video" ? "video" : "image",
})),
);
setSelectedIds([]);
}}
forceFilter="image"
/>
);
};
export const webappMoodboardAdapter: MoodboardAdapter = {
uploadImage,
sendToGeneration,
renderLibraryPicker: (props) => <WebappLibraryPicker {...props} />,
};
// Dedupe by token but keep tokenless refs (a manually-added prompt reference
// without a token shouldn't vanish when sending from a board).
const dedupeByToken = (refs: RefImage[]): RefImage[] => {
const seen = new Set<string>();
const out: RefImage[] = [];
for (const r of refs) {
if (r.mediaToken) {
if (seen.has(r.mediaToken)) continue;
seen.add(r.mediaToken);
}
out.push(r);
}
return out;
};
@@ -38,6 +38,7 @@ module.exports = {
gray: colors.gray,
black: colors.black,
rose: colors.rose,
indigo: colors.indigo,
red: { ...colors.red, DEFAULT: "#D33242" },
blue: { ...colors.blue, DEFAULT: "#2E70FF" },
orange: colors.orange,
@@ -46,6 +46,9 @@
{
"path": "../../libs/components/upload-modal/tsconfig.lib.json"
},
{
"path": "../../libs/components/moodboard/tsconfig.lib.json"
},
{
"path": "../../libs/components/pagedraw/tsconfig.lib.json"
},
@@ -11,6 +11,9 @@
{
"path": "../../libs/components/upload-modal"
},
{
"path": "../../libs/components/moodboard"
},
{
"path": "../../libs/components/pagedraw"
},
@@ -11,6 +11,7 @@ import {
faPenNib,
faCrosshairs,
faSparkles,
faObjectGroup,
} from "@fortawesome/pro-solid-svg-icons";
import { useMemo } from "react";
import {
@@ -34,7 +35,8 @@ export type AppId =
| "ANGLES"
| "STORYBOARD"
| "BACKGROUND_CHANGE"
| "VIDEO_EDITOR";
| "VIDEO_EDITOR"
| "MOODBOARD";
export interface AppDescriptor {
id: AppId;
@@ -290,6 +292,16 @@ export const ALL_APPS: FullAppItem[] = [
color: "bg-fuchsia-600/40",
badge: "NEW",
},
{
id: "moodboard",
label: "Moodboard",
description: "Collect references and steer generations from a board",
icon: faObjectGroup,
category: "generate",
action: "MOODBOARD",
color: "bg-orange-500/40",
badge: "BETA",
},
{
id: "background-change",
label: "Background Change",
@@ -398,6 +410,7 @@ export const goToApp = (action?: string) => {
"STORYBOARD",
"BACKGROUND_CHANGE",
"VIDEO_EDITOR",
"MOODBOARD",
].includes(action)
) {
if (action === "STORYBOARD") {
@@ -46,10 +46,7 @@ import {
} from "@storyteller/tauri-events";
import { SoundManager } from "@storyteller/soundboard";
import { useStoryboardPageEnabled } from "@storyteller/ui-settings-modal";
import {
DomLevels,
usePageSceneStore,
} from "@storyteller/ui-pagescene";
import { DomLevels, usePageSceneStore } from "@storyteller/ui-pagescene";
import { useActiveJobs } from "~/hooks/useActiveJobs";
import { useBackgroundLoadingMedia } from "~/hooks/useBackgroundLoadingMedia";
@@ -74,6 +71,7 @@ import { Storyboard } from "./PageStoryboard";
import { PageBackgroundChange } from "./PageBackgroundChange";
import { PageScene } from "./PageScene";
import { PageVideoEditor } from "./PageVideoEditor";
import { PageMoodboard } from "./PageMoodboard";
import {
topNavMediaId,
topNavMediaUrl,
@@ -223,9 +221,17 @@ const TabBody = ({ sceneToken }: { sceneToken?: string }) => {
case "3D":
return <PageScene sceneToken={sceneToken} />;
case "APPS":
return <div><AppsIndexPage /></div>;
return (
<div>
<AppsIndexPage />
</div>
);
case "2D":
return <div><PageDrawWithGalleryDrop /></div>;
return (
<div>
<PageDrawWithGalleryDrop />
</div>
);
case "IMAGE":
return (
<div>
@@ -236,27 +242,83 @@ const TabBody = ({ sceneToken }: { sceneToken?: string }) => {
</div>
);
case "VIDEO":
return <div><ImageToVideo /></div>;
return (
<div>
<ImageToVideo />
</div>
);
case "VIDEO_FRAME_EXTRACTOR":
return <div><VideoFrameExtractor /></div>;
return (
<div>
<VideoFrameExtractor />
</div>
);
case "VIDEO_WATERMARK_REMOVAL":
return <div><VideoWatermarkRemover /></div>;
return (
<div>
<VideoWatermarkRemover />
</div>
);
case "IMAGE_WATERMARK_REMOVAL":
return <div><ImageWatermarkRemover /></div>;
return (
<div>
<ImageWatermarkRemover />
</div>
);
case "IMAGE_TO_3D_OBJECT":
return <div><ImageTo3DObject /></div>;
return (
<div>
<ImageTo3DObject />
</div>
);
case "IMAGE_TO_3D_WORLD":
return <div><ImageTo3DWorld /></div>;
return (
<div>
<ImageTo3DWorld />
</div>
);
case "REMOVE_BACKGROUND":
return <div><RemoveBackground /></div>;
return (
<div>
<RemoveBackground />
</div>
);
case "ANGLES":
return <div><Angles /></div>;
return (
<div>
<Angles />
</div>
);
case "STORYBOARD":
return storyboardPageEnabled ? <div><Storyboard /></div> : null;
return storyboardPageEnabled ? (
<div>
<Storyboard />
</div>
) : null;
case "BACKGROUND_CHANGE":
return <div><PageBackgroundChange /></div>;
return (
<div>
<PageBackgroundChange />
</div>
);
case "VIDEO_EDITOR":
return <div className="h-[calc(100vh-3rem)] w-full"><PageVideoEditor /></div>;
return (
<div className="h-[calc(100vh-3rem)] w-full">
<PageVideoEditor />
</div>
);
case "MOODBOARD":
// The TopBar is fixed and 56px tall, so the board sits below it and fills
// the rest of the viewport. h-screen + pt-[56px] (border-box) makes the
// board area exactly viewport-minus-topbar, and overflow-hidden keeps
// sub-pixel 100vh rounding from spawning page scrollbars (which w-screen
// ancestors would turn into a horizontal scrollbar via the Windows
// scrollbar gutter).
return (
<div className="h-[calc(100vh-56px)] w-full overflow-hidden">
<PageMoodboard />
</div>
);
default:
return null;
}
@@ -0,0 +1,35 @@
import { useEffect } from "react";
import {
MoodboardWorkspace,
dispatchGalleryMoodboardDrop,
} from "@storyteller/ui-moodboard";
import {
GalleryItem,
onImageDrop,
removeImageDropListener,
} from "@storyteller/ui-gallery-modal";
import { desktopMoodboardAdapter } from "./desktopMoodboardAdapter";
// Desktop moodboard tab. The shared workspace fills its parent; MainApp's
// TabBody wraps this in a sized container.
export const PageMoodboard = () => {
useGalleryDropBridge();
return <MoodboardWorkspace adapter={desktopMoodboardAdapter} />;
};
// Bridges gallery-modal's onImageDrop into the moodboard's board-drop event, so
// dragging a library image onto the grid/canvas adds it. Mounted only while the
// moodboard tab is active, so it doesn't race the 2D/3D tab drop handlers.
const useGalleryDropBridge = () => {
useEffect(() => {
const handler = onImageDrop(
(item: GalleryItem, position: { x: number; y: number }) => {
dispatchGalleryMoodboardDrop(item, position);
},
);
return () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (handler) removeImageDropListener(handler as any);
};
}, []);
};
@@ -0,0 +1,107 @@
import { useState } from "react";
import toast from "react-hot-toast";
import { UploaderStates } from "@storyteller/common";
import { UploadImageMedia } from "@storyteller/api";
import { GalleryModal, GalleryItem } from "@storyteller/ui-gallery-modal";
import { useLoginModalStore } from "@storyteller/ui-login-modal";
import { usePromptImageStore, RefImage } from "@storyteller/ui-promptbox";
import type {
MoodboardAdapter,
MoodboardReference,
MoodboardLibraryPickerProps,
} from "@storyteller/ui-moodboard";
import { useTabStore } from "../Stores/TabState";
// Desktop (Tauri) implementation of the moodboard's platform seams.
const uploadImage = async (file: File): Promise<string | null> => {
let token: string | null = null;
try {
await UploadImageMedia({
title: file.name || "Moodboard image",
assetFile: file,
progressCallback: (state) => {
if (state.status === UploaderStates.success && state.data) {
token = String(state.data);
}
},
});
} catch (err) {
console.error("[Moodboard] upload failed", err);
}
return token;
};
const sendToGeneration = (refs: MoodboardReference[]): void => {
const newRefs: RefImage[] = refs.map((r) => ({
id: r.id,
url: r.url,
file: new File([], r.id),
mediaToken: r.mediaToken,
}));
const store = usePromptImageStore.getState();
store.setReferenceImages(dedupeByToken([...store.referenceImages, ...newRefs]));
void useTabStore.getState().setActiveTab("IMAGE");
toast.success(
`${refs.length} reference${refs.length > 1 ? "s" : ""} added to the prompt`,
);
};
const DesktopLibraryPicker = ({
open,
onClose,
onPick,
}: MoodboardLibraryPickerProps) => {
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const close = () => {
setSelectedIds([]);
onClose();
};
return (
<GalleryModal
isOpen={open}
onClose={close}
onLoginClick={() => {
close();
useLoginModalStore.getState().openModal();
}}
mode="select"
selectedItemIds={selectedIds}
onSelectItem={(id: string) =>
setSelectedIds((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
)
}
onUseSelected={(items: GalleryItem[]) => {
onPick(
items.map((it) => ({
url: it.fullImage || it.thumbnail || "",
mediaToken: it.id ?? null,
kind: it.mediaClass === "video" ? "video" : "image",
})),
);
setSelectedIds([]);
}}
forceFilter="image"
/>
);
};
export const desktopMoodboardAdapter: MoodboardAdapter = {
uploadImage,
sendToGeneration,
renderLibraryPicker: (props) => <DesktopLibraryPicker {...props} />,
};
const dedupeByToken = (refs: RefImage[]): RefImage[] => {
const seen = new Set<string>();
const out: RefImage[] = [];
for (const r of refs) {
if (r.mediaToken) {
if (seen.has(r.mediaToken)) continue;
seen.add(r.mediaToken);
}
out.push(r);
}
return out;
};
@@ -0,0 +1,2 @@
export { PageMoodboard } from "./PageMoodboard";
export { desktopMoodboardAdapter } from "./desktopMoodboardAdapter";
@@ -17,7 +17,8 @@ export type TabId =
| "ANGLES"
| "STORYBOARD"
| "BACKGROUND_CHANGE"
| "VIDEO_EDITOR";
| "VIDEO_EDITOR"
| "MOODBOARD";
const DEFAULT_TAB: TabId = "APPS";
+3
View File
@@ -31,6 +31,9 @@
{
"path": "../../libs/components/video-editor"
},
{
"path": "../../libs/components/moodboard"
},
{
"path": "../../libs/components/vfx"
},
@@ -252,6 +252,10 @@ interface GalleryModalProps {
maxSelections?: number;
onUseSelected?: (selectedItems: GalleryItem[]) => void;
useSelectedLoading?: boolean;
/** When set, the "not logged in" state shows a Login button that calls this
* (e.g. open the login modal on desktop, navigate to /login on web) instead
* of a Retry link. */
onLoginClick?: () => void;
onDownloadClicked?: (url: string, mediaClass?: string) => Promise<void>;
onAddToSceneClicked?: (
url: string,
@@ -472,6 +476,7 @@ export const GalleryModal = React.memo(
maxSelections = 4,
onUseSelected,
useSelectedLoading,
onLoginClick,
onDownloadClicked,
onAddToSceneClicked,
isOpen,
@@ -2699,20 +2704,28 @@ export const GalleryModal = React.memo(
<>
{usernameError && allItems.length === 0 ? (
<div className="flex h-full items-center justify-center">
<div className="flex flex-col items-center gap-3 text-sm">
<div className="flex max-w-xs flex-col items-center gap-4 text-center text-sm">
<div className="text-base-fg/60">
Unable to load gallery. Please ensure you are logged
in.
Log in to browse and use your library.
</div>
<button
className="text-xs text-blue-400 hover:text-blue-300 underline"
onClick={() => {
setUsernameError(false);
setUsernameRetryCount((c) => c + 1);
}}
>
Retry
</button>
{onLoginClick ? (
<button
className="rounded-full bg-primary px-5 py-2 text-sm font-semibold text-white transition-colors hover:bg-primary/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
onClick={() => onLoginClick()}
>
Log in
</button>
) : (
<button
className="text-xs text-blue-400 underline hover:text-blue-300"
onClick={() => {
setUsernameError(false);
setUsernameRetryCount((c) => c + 1);
}}
>
Retry
</button>
)}
</div>
</div>
) : itemsLoadError && allItems.length === 0 ? (
@@ -0,0 +1,17 @@
{
"name": "@storyteller/ui-moodboard",
"version": "0.0.1",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"development": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
}
}
}
@@ -0,0 +1,49 @@
// Public API for @storyteller/ui-moodboard
// Components — each platform supplies a MoodboardAdapter.
// MoodboardWorkspace is the full surface (Grid + freeform Konva Canvas + Present);
// the individual views are also exported for apps that want to compose their own.
export { MoodboardWorkspace } from "./lib/MoodboardWorkspace";
export { BoardGridView as MoodboardGrid } from "./lib/grid/BoardGridView";
export { PresentationView as MoodboardPresentation } from "./lib/grid/PresentationView";
// Stores + selectors. useMoodboardStore is the Konva canvas runtime state
// (separate from the durable board model in useBoardLibraryStore).
export { useBoardLibraryStore } from "./lib/boards/BoardLibraryStore";
export { useMoodboardStore } from "./lib/canvas/MoodboardStore";
export {
useActiveBoard,
filterItems,
collectTags,
} from "./lib/boards/boardSelectors";
// Adapter (platform seams)
export type {
MoodboardAdapter,
MoodboardReference,
MoodboardPickedItem,
MoodboardLibraryPickerProps,
} from "./lib/adapter";
// Gallery → board drop bridge. Host apps wire their gallery's drop callback to
// this; the grid + canvas views listen for the event it dispatches.
export {
dispatchGalleryMoodboardDrop,
GALLERY_MOODBOARD_DROP_EVENT,
} from "./lib/galleryDrop";
export type { DroppedGalleryItem } from "./lib/galleryDrop";
// Board model types
export type {
Board,
BoardItem,
BoardImageItem,
BoardVideoItem,
BoardTextItem,
BoardLinkItem,
BoardColorItem,
BoardSection,
BoardItemKind,
ViewMode,
GridDensity,
} from "./lib/boards/boardTypes";
@@ -0,0 +1,133 @@
import { ReactNode, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import {
faTableCells,
faVectorSquare,
faPlay,
} from "@fortawesome/pro-regular-svg-icons";
import { BoardGridView } from "./grid/BoardGridView";
import { PresentationView } from "./grid/PresentationView";
import { useBoardLibraryStore } from "./boards/BoardLibraryStore";
import { useActiveBoard } from "./boards/boardSelectors";
import type { ViewMode } from "./boards/boardTypes";
import type { MoodboardAdapter } from "./adapter";
import { Moodboard } from "./canvas/Moodboard";
interface Props {
adapter: MoodboardAdapter;
// Host chrome rendered in the top-right cluster, left of nothing — beside the
// Present button. Webapp hosts that hide the global topbar inject their nav
// actions (pricing/credits/task queue/profile) here, mirroring the 3D and
// video editors. Tauri leaves it unset.
topBarEndSlot?: ReactNode;
}
// Root moodboard surface. Switches how the board is rendered — Grid (virtualized
// masonry) vs Canvas (freeform Konva planning) — plus a transient Presentation
// overlay. Fills its parent; the host app sizes the container. Platform seams
// (upload, library picker, send-to-generation) arrive via the adapter.
export const MoodboardWorkspace = ({ adapter, topBarEndSlot }: Props) => {
const viewMode = useBoardLibraryStore((s) => s.viewMode);
const setViewMode = useBoardLibraryStore((s) => s.setViewMode);
const board = useActiveBoard();
// Presentation is a transient overlay, not a persisted view mode — so a
// reload never reopens straight into a slideshow.
const [presenting, setPresenting] = useState(false);
const presentItems = board
? board.itemOrder.map((id) => board.items[id]).filter((it) => Boolean(it))
: [];
return (
<div
data-moodboard-root
className="relative h-full w-full overflow-hidden"
// Desktop themes set --st-bg (gray/light/black/aurora) so the board follows
// the active theme. The webapp never defines --st-bg, so it falls back to
// its own --background token (0 0% 9% / #171717), matching the rest of the app.
style={{ background: "var(--st-bg, hsl(0 0% 9%))" }}
>
{viewMode === "grid" ? (
<BoardGridView active={!presenting} adapter={adapter} />
) : (
<Moodboard adapter={adapter} />
)}
<ViewSwitch mode={viewMode} onChange={setViewMode} />
<TopRightCluster
onPresent={() => setPresenting(true)}
endSlot={topBarEndSlot}
/>
{presenting && (
<PresentationView
items={presentItems}
onClose={() => setPresenting(false)}
/>
)}
</div>
);
};
const OPTIONS: Array<{ mode: ViewMode; label: string; icon: IconDefinition }> =
[
{ mode: "grid", label: "Grid", icon: faTableCells },
{ mode: "canvas", label: "Canvas", icon: faVectorSquare },
];
const ViewSwitch = ({
mode,
onChange,
}: {
mode: ViewMode;
onChange: (m: ViewMode) => void;
}) => (
<div className="glass absolute left-3 top-3 z-40 flex items-center gap-0.5 rounded-2xl border border-ui-divider p-1 shadow-[0_8px_24px_-12px_rgba(0,0,0,0.45)]">
{OPTIONS.map((opt) => {
const active = opt.mode === mode;
return (
<button
key={opt.mode}
type="button"
onClick={() => onChange(opt.mode)}
className={[
"flex items-center gap-2 rounded-xl px-3.5 py-1.5 text-sm font-medium",
"transition-colors duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary",
active
? "bg-base-fg/15 text-base-fg"
: "text-base-fg/55 hover:text-base-fg",
].join(" ")}
>
<FontAwesomeIcon icon={opt.icon} className="h-3.5 w-3.5" />
{opt.label}
</button>
);
})}
</div>
);
// Top-right chrome: the Present action (always present, including Tauri) and the
// host's relocated nav actions (webapp only, via endSlot). The wrapper is
// click-through so the gap between the two never blocks the board beneath.
const TopRightCluster = ({
onPresent,
endSlot,
}: {
onPresent: () => void;
endSlot?: ReactNode;
}) => (
<div className="pointer-events-none absolute right-3 top-3 z-40 flex items-center gap-2">
<div className="glass pointer-events-auto flex items-center rounded-xl border border-ui-divider p-0.5 shadow-[0_8px_24px_-12px_rgba(0,0,0,0.45)]">
<button
type="button"
onClick={onPresent}
title="Present"
aria-label="Present"
className="flex items-center gap-2 rounded-xl px-3.5 py-1.5 text-sm font-medium text-base-fg/55 transition-colors duration-150 hover:text-base-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
<FontAwesomeIcon icon={faPlay} className="h-3 w-3" />
Present
</button>
</div>
{endSlot && <div className="pointer-events-auto">{endSlot}</div>}
</div>
);
@@ -0,0 +1,37 @@
import type { ReactNode } from "react";
// Platform-specific seams the moodboard needs but can't own itself. Each app
// (desktop Tauri / web) supplies a concrete adapter; the lib stays portable.
export interface MoodboardReference {
id: string;
url: string;
mediaToken: string;
}
export interface MoodboardPickedItem {
url: string;
mediaToken: string | null;
kind: "image" | "video";
}
export interface MoodboardLibraryPickerProps {
open: boolean;
onClose: () => void;
onPick: (items: MoodboardPickedItem[]) => void;
}
export interface MoodboardAdapter {
/** Upload a file, resolving to a durable media token (or null if the platform
* can't provide one). When present, uploaded images become reference-capable
* and persist beyond the blob URL. */
uploadImage?: (file: File) => Promise<string | null>;
/** Push selected board images to the platform's generation surface
* (desktop: prompt store + tab switch; web: sessionStorage + route nav). */
sendToGeneration: (refs: MoodboardReference[]) => void;
/** Optional library/gallery picker as a render-prop. Omit to disable the
* "From library" action (e.g. when a platform's picker isn't wired yet). */
renderLibraryPicker?: (props: MoodboardLibraryPickerProps) => ReactNode;
}
@@ -0,0 +1,74 @@
import { beforeEach, describe, expect, it } from "vitest";
import { useBoardLibraryStore } from "./BoardLibraryStore";
// Fresh store per test — clear persisted boards and any selection.
const reset = () => {
useBoardLibraryStore.setState({
boards: {},
boardOrder: [],
activeBoardId: null,
selectedItemIds: new Set(),
});
};
const seedItem = (boardId: string): string =>
useBoardLibraryStore.getState().addTextItem(boardId, "note");
describe("BoardLibraryStore sections", () => {
beforeEach(reset);
it("creates, renames, and collapses a section", () => {
const store = useBoardLibraryStore.getState();
const boardId = store.createBoard("B");
const sectionId = store.createSection(boardId, "Refs");
let board = useBoardLibraryStore.getState().boards[boardId];
expect(board.sections).toEqual([{ id: sectionId, name: "Refs" }]);
store.renameSection(boardId, sectionId, "Hero shots");
store.toggleSectionCollapsed(boardId, sectionId);
board = useBoardLibraryStore.getState().boards[boardId];
expect(board.sections[0].name).toBe("Hero shots");
expect(board.sections[0].collapsed).toBe(true);
});
it("falls back to a default name for blank section names", () => {
const store = useBoardLibraryStore.getState();
const boardId = store.createBoard("B");
const sectionId = store.createSection(boardId, " ");
const board = useBoardLibraryStore.getState().boards[boardId];
expect(board.sections.find((s) => s.id === sectionId)?.name).toBe(
"New section",
);
});
it("assigns items into a section and back to ungrouped", () => {
const store = useBoardLibraryStore.getState();
const boardId = store.createBoard("B");
const itemId = seedItem(boardId);
const sectionId = store.createSection(boardId);
store.assignItemsToSection(boardId, [itemId], sectionId);
expect(
useBoardLibraryStore.getState().boards[boardId].items[itemId].sectionId,
).toBe(sectionId);
store.assignItemsToSection(boardId, [itemId], null);
expect(
useBoardLibraryStore.getState().boards[boardId].items[itemId].sectionId,
).toBeNull();
});
it("returns items to the ungrouped flow when their section is deleted", () => {
const store = useBoardLibraryStore.getState();
const boardId = store.createBoard("B");
const itemId = seedItem(boardId);
const sectionId = store.createSection(boardId);
store.assignItemsToSection(boardId, [itemId], sectionId);
store.deleteSection(boardId, sectionId);
const board = useBoardLibraryStore.getState().boards[boardId];
expect(board.sections).toHaveLength(0);
expect(board.items[itemId].sectionId).toBeNull();
});
});
@@ -0,0 +1,547 @@
import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
import { v4 as uuidv4 } from "uuid";
import {
Board,
BoardImageItem,
BoardItem,
BoardLinkItem,
BoardVideoItem,
COLOR_ITEM_ASPECT,
GridDensity,
LINK_ITEM_ASPECT,
TEXT_ITEM_ASPECT,
ViewMode,
} from "./boardTypes";
const STORAGE_KEY = "artcraft_moodboard_library";
const STORAGE_VERSION = 1;
// Persisted slice of the library (excludes transient UI: selection, search,
// tag filters). All mutations go through the actions below — that indirection
// is the seam a future backend sync layer slots into without touching views.
interface PersistedLibrary {
boards: Record<string, Board>;
boardOrder: string[];
activeBoardId: string | null;
viewMode: ViewMode;
density: GridDensity;
}
interface BoardLibraryState extends PersistedLibrary {
// Transient (not persisted)
searchQuery: string;
activeTagFilters: string[];
ratingFilter: number;
selectedItemIds: Set<string>;
// Board lifecycle
ensureActiveBoard: () => string;
createBoard: (name?: string) => string;
renameBoard: (id: string, name: string) => void;
deleteBoard: (id: string) => void;
setActiveBoard: (id: string) => void;
// Item entry — typed creators that fill base fields + aspect.
addImageItem: (
boardId: string,
data: Pick<BoardImageItem, "src" | "mediaId" | "naturalW" | "naturalH"> &
Partial<Pick<BoardImageItem, "caption">>,
) => string;
addVideoItem: (
boardId: string,
data: Pick<BoardVideoItem, "src" | "mediaId" | "naturalW" | "naturalH">,
) => string;
addTextItem: (boardId: string, text: string) => string;
addLinkItem: (
boardId: string,
data: Pick<BoardLinkItem, "url" | "title" | "description" | "image">,
) => string;
addColorItem: (boardId: string, color: string) => string;
updateItem: (boardId: string, id: string, patch: Partial<BoardItem>) => void;
removeItems: (boardId: string, ids: string[]) => void;
// Triage: set the same rating (0-5) on many items in one history step.
rateItems: (boardId: string, ids: string[], rating: number) => void;
// Sections — organize items into named lanes within a board.
createSection: (boardId: string, name?: string) => string;
renameSection: (boardId: string, sectionId: string, name: string) => void;
deleteSection: (boardId: string, sectionId: string) => void;
toggleSectionCollapsed: (boardId: string, sectionId: string) => void;
// Move items into a section (or back to the ungrouped flow with null).
assignItemsToSection: (
boardId: string,
ids: string[],
sectionId: string | null,
) => void;
// View + filters
setViewMode: (mode: ViewMode) => void;
setDensity: (density: GridDensity) => void;
setSearchQuery: (q: string) => void;
toggleTagFilter: (tag: string) => void;
setRatingFilter: (min: number) => void;
clearFilters: () => void;
// Selection (operates on the active board)
setSelection: (ids: Iterable<string>) => void;
// Clicks accumulate; `additive` is accepted for caller compatibility but no
// longer changes behavior (a plain click already toggles within the set).
toggleSelection: (id: string, additive?: boolean) => void;
clearSelection: () => void;
deleteSelected: () => void;
}
const DEFAULT_BOARD_NAME = "Untitled board";
const DEFAULT_SECTION_NAME = "New section";
// Monotonic counter for stable createdAt-like ordering. Date.now() is avoided
// so behaviour stays deterministic and testable; ordering is all we need.
let seq = 0;
const nextSeq = () => ++seq;
const mediaAspect = (naturalW: number, naturalH: number): number => {
if (naturalW > 0 && naturalH > 0) return naturalH / naturalW;
return 1;
};
export const useBoardLibraryStore = create<BoardLibraryState>()(
persist(
(set, get) => ({
boards: {},
boardOrder: [],
activeBoardId: null,
viewMode: "grid",
density: "comfortable",
searchQuery: "",
activeTagFilters: [],
ratingFilter: 0,
selectedItemIds: new Set(),
ensureActiveBoard: () => {
const { activeBoardId, boards } = get();
if (activeBoardId && boards[activeBoardId]) return activeBoardId;
const firstId = get().boardOrder[0];
if (firstId && boards[firstId]) {
set({ activeBoardId: firstId });
return firstId;
}
return get().createBoard();
},
createBoard: (name) => {
const id = uuidv4();
const order = nextSeq();
const board: Board = {
id,
name: name?.trim() || DEFAULT_BOARD_NAME,
createdAt: order,
updatedAt: order,
itemOrder: [],
items: {},
sections: [],
};
set((s) => ({
boards: { ...s.boards, [id]: board },
boardOrder: [...s.boardOrder, id],
activeBoardId: id,
}));
return id;
},
renameBoard: (id, name) => {
set((s) => {
const board = s.boards[id];
if (!board) return s;
return {
boards: {
...s.boards,
[id]: { ...board, name: name.trim() || DEFAULT_BOARD_NAME },
},
};
});
},
deleteBoard: (id) => {
set((s) => {
if (!s.boards[id]) return s;
Object.values(s.boards[id].items).forEach(revokeBlobSrc);
const boards = { ...s.boards };
delete boards[id];
const boardOrder = s.boardOrder.filter((b) => b !== id);
const activeBoardId =
s.activeBoardId === id ? boardOrder[0] ?? null : s.activeBoardId;
return { boards, boardOrder, activeBoardId };
});
},
setActiveBoard: (id) => {
if (!get().boards[id]) return;
set({ activeBoardId: id, selectedItemIds: new Set() });
},
addImageItem: (boardId, data) =>
addItemToBoard(set, get, boardId, (base) => ({
...base,
kind: "image",
src: data.src,
mediaId: data.mediaId,
naturalW: data.naturalW,
naturalH: data.naturalH,
caption: data.caption ?? "",
aspect: mediaAspect(data.naturalW, data.naturalH),
})),
addVideoItem: (boardId, data) =>
addItemToBoard(set, get, boardId, (base) => ({
...base,
kind: "video",
src: data.src,
mediaId: data.mediaId,
naturalW: data.naturalW,
naturalH: data.naturalH,
aspect: mediaAspect(data.naturalW, data.naturalH),
})),
addTextItem: (boardId, text) =>
addItemToBoard(set, get, boardId, (base) => ({
...base,
kind: "text",
text,
aspect: TEXT_ITEM_ASPECT,
})),
addLinkItem: (boardId, data) =>
addItemToBoard(set, get, boardId, (base) => ({
...base,
kind: "link",
url: data.url,
title: data.title,
description: data.description,
image: data.image,
aspect: data.image ? LINK_ITEM_ASPECT : TEXT_ITEM_ASPECT,
})),
addColorItem: (boardId, color) =>
addItemToBoard(set, get, boardId, (base) => ({
...base,
kind: "color",
color,
aspect: COLOR_ITEM_ASPECT,
})),
updateItem: (boardId, id, patch) => {
set((s) => {
const board = s.boards[boardId];
if (!board || !board.items[id]) return s;
const merged = { ...board.items[id], ...patch } as BoardItem;
return {
boards: {
...s.boards,
[boardId]: {
...board,
items: { ...board.items, [id]: merged },
updatedAt: nextSeq(),
},
},
};
});
},
removeItems: (boardId, ids) => {
if (ids.length === 0) return;
set((s) => {
const board = s.boards[boardId];
if (!board) return s;
const remove = new Set(ids);
const items = { ...board.items };
remove.forEach((id) => {
revokeBlobSrc(items[id]);
delete items[id];
});
const selectedItemIds = new Set(s.selectedItemIds);
remove.forEach((id) => selectedItemIds.delete(id));
return {
boards: {
...s.boards,
[boardId]: {
...board,
items,
itemOrder: board.itemOrder.filter((x) => !remove.has(x)),
updatedAt: nextSeq(),
},
},
selectedItemIds,
};
});
},
rateItems: (boardId, ids, rating) => {
if (ids.length === 0) return;
const clamped = Math.max(0, Math.min(5, Math.round(rating)));
set((s) => {
const board = s.boards[boardId];
if (!board) return s;
const items = { ...board.items };
ids.forEach((id) => {
const it = items[id];
if (it) items[id] = { ...it, rating: clamped };
});
return {
boards: {
...s.boards,
[boardId]: { ...board, items, updatedAt: nextSeq() },
},
};
});
},
createSection: (boardId, name) => {
const id = uuidv4();
set((s) => {
const board = s.boards[boardId];
if (!board) return s;
const section = { id, name: name?.trim() || DEFAULT_SECTION_NAME };
return {
boards: {
...s.boards,
[boardId]: {
...board,
sections: [...board.sections, section],
updatedAt: nextSeq(),
},
},
};
});
return id;
},
renameSection: (boardId, sectionId, name) => {
set((s) => {
const board = s.boards[boardId];
if (!board) return s;
return {
boards: {
...s.boards,
[boardId]: {
...board,
sections: board.sections.map((sec) =>
sec.id === sectionId
? { ...sec, name: name.trim() || DEFAULT_SECTION_NAME }
: sec,
),
},
},
};
});
},
deleteSection: (boardId, sectionId) => {
set((s) => {
const board = s.boards[boardId];
if (!board) return s;
// Items in the section fall back to the ungrouped flow.
const items = { ...board.items };
for (const [id, it] of Object.entries(items)) {
if (it.sectionId === sectionId) items[id] = { ...it, sectionId: null };
}
return {
boards: {
...s.boards,
[boardId]: {
...board,
items,
sections: board.sections.filter((sec) => sec.id !== sectionId),
updatedAt: nextSeq(),
},
},
};
});
},
toggleSectionCollapsed: (boardId, sectionId) => {
set((s) => {
const board = s.boards[boardId];
if (!board) return s;
return {
boards: {
...s.boards,
[boardId]: {
...board,
sections: board.sections.map((sec) =>
sec.id === sectionId
? { ...sec, collapsed: !sec.collapsed }
: sec,
),
},
},
};
});
},
assignItemsToSection: (boardId, ids, sectionId) => {
if (ids.length === 0) return;
set((s) => {
const board = s.boards[boardId];
if (!board) return s;
const target = new Set(ids);
const items = { ...board.items };
target.forEach((id) => {
const it = items[id];
if (it) items[id] = { ...it, sectionId };
});
return {
boards: {
...s.boards,
[boardId]: { ...board, items, updatedAt: nextSeq() },
},
};
});
},
setViewMode: (mode) => set({ viewMode: mode }),
setDensity: (density) => set({ density }),
setSearchQuery: (q) => set({ searchQuery: q }),
toggleTagFilter: (tag) =>
set((s) => ({
activeTagFilters: s.activeTagFilters.includes(tag)
? s.activeTagFilters.filter((t) => t !== tag)
: [...s.activeTagFilters, tag],
})),
setRatingFilter: (min) => set({ ratingFilter: Math.max(0, Math.min(5, min)) }),
clearFilters: () =>
set({ searchQuery: "", activeTagFilters: [], ratingFilter: 0 }),
setSelection: (ids) => set({ selectedItemIds: new Set(ids) }),
// Every click accumulates: a plain click adds the item to the current
// selection (or removes it if already selected), so clicking item after
// item builds up a multi-selection without needing a modifier. The
// `additive` flag (ctrl/cmd/shift-click) behaves identically here; the
// distinction is only meaningful for marquee/range selection.
toggleSelection: (id) =>
set((s) => {
const next = new Set(s.selectedItemIds);
if (next.has(id)) next.delete(id);
else next.add(id);
return { selectedItemIds: next };
}),
clearSelection: () => set({ selectedItemIds: new Set() }),
deleteSelected: () => {
const { activeBoardId, selectedItemIds } = get();
if (!activeBoardId || selectedItemIds.size === 0) return;
get().removeItems(activeBoardId, Array.from(selectedItemIds));
},
}),
{
name: STORAGE_KEY,
version: STORAGE_VERSION,
storage: createJSONStorage(() => localStorage),
// Only the library + view prefs persist; selection/search are per-session.
// Blob-backed items are dropped — their object URLs die with the document,
// so persisting them would only resurrect broken tiles after a reload.
partialize: (s): PersistedLibrary => ({
boards: stripEphemeralItems(s.boards),
boardOrder: s.boardOrder,
activeBoardId: s.activeBoardId,
viewMode: s.viewMode,
density: s.density,
}),
// Keep `seq` ahead of any restored ordering values so new items always
// sort after persisted ones.
onRehydrateStorage: () => (state) => {
if (!state) return;
let max = 0;
Object.values(state.boards).forEach((b) => {
max = Math.max(max, b.createdAt, b.updatedAt);
Object.values(b.items).forEach((it) => {
max = Math.max(max, it.createdAt);
});
});
seq = Math.max(seq, max);
},
},
),
);
// ---------- helpers ----------
// Returns an item's blob: object URL, or null if it isn't blob-backed. Narrows
// to image/video internally so callers get a plain string back.
const blobSrc = (item: BoardItem): string | null => {
if (item.kind !== "image" && item.kind !== "video") return null;
return item.src.startsWith("blob:") ? item.src : null;
};
// Release an item's object URL when it leaves the store, so dropped/uploaded
// blobs don't pin their backing File for the life of the document.
const revokeBlobSrc = (item: BoardItem | undefined): void => {
const src = item ? blobSrc(item) : null;
if (!src) return;
try {
URL.revokeObjectURL(src);
} catch {
// ignore
}
};
// Persistence view of the boards with blob-backed (ephemeral) items removed and
// their order entries pruned. Non-blob image/video (library picks) are kept.
const stripEphemeralItems = (
boards: Record<string, Board>,
): Record<string, Board> => {
const out: Record<string, Board> = {};
for (const [boardId, board] of Object.entries(boards)) {
const items: Record<string, BoardItem> = {};
for (const [itemId, item] of Object.entries(board.items)) {
if (!blobSrc(item)) items[itemId] = item;
}
out[boardId] = {
...board,
items,
itemOrder: board.itemOrder.filter((id) => items[id]),
};
}
return out;
};
// Shared item-insertion path used by every typed creator. The `build` callback
// receives the filled base fields and returns the full discriminated item.
const addItemToBoard = (
set: (fn: (s: BoardLibraryState) => Partial<BoardLibraryState>) => void,
get: () => BoardLibraryState,
boardId: string,
build: (base: {
id: string;
sectionId: null;
createdAt: number;
tags: string[];
rating: number;
}) => BoardItem,
): string => {
const id = uuidv4();
const item = build({
id,
sectionId: null,
createdAt: nextSeq(),
tags: [],
rating: 0,
});
set((s) => {
const board = s.boards[boardId];
if (!board) return s;
return {
boards: {
...s.boards,
[boardId]: {
...board,
items: { ...board.items, [id]: item },
itemOrder: [...board.itemOrder, id],
updatedAt: nextSeq(),
},
},
};
});
return id;
};
@@ -0,0 +1,53 @@
import { useBoardLibraryStore } from "./BoardLibraryStore";
import { Board, BoardItem } from "./boardTypes";
export const useActiveBoard = (): Board | null =>
useBoardLibraryStore((s) =>
s.activeBoardId ? s.boards[s.activeBoardId] ?? null : null,
);
// Items in display order, narrowed by search query, tag filters, and a minimum
// rating. Pure so the grid can memoize on (board, query, tagFilters, minRating).
export const filterItems = (
board: Board,
query: string,
tagFilters: string[],
minRating = 0,
): BoardItem[] => {
const q = query.trim().toLowerCase();
return board.itemOrder
.map((id) => board.items[id])
.filter((it): it is BoardItem => Boolean(it))
.filter((it) => {
if (minRating > 0 && it.rating < minRating) return false;
if (tagFilters.length && !tagFilters.every((t) => it.tags.includes(t))) {
return false;
}
return q ? itemMatchesText(it, q) : true;
});
};
// All distinct tags across a board, for the filter chips.
export const collectTags = (board: Board): string[] => {
const seen = new Set<string>();
board.itemOrder.forEach((id) => {
board.items[id]?.tags.forEach((t) => seen.add(t));
});
return Array.from(seen).sort();
};
const itemMatchesText = (it: BoardItem, q: string): boolean => {
if (it.tags.some((t) => t.toLowerCase().includes(q))) return true;
switch (it.kind) {
case "text":
return it.text.toLowerCase().includes(q);
case "link":
return `${it.title} ${it.description} ${it.url}`.toLowerCase().includes(q);
case "image":
return it.caption.toLowerCase().includes(q);
case "color":
return it.color.toLowerCase().includes(q);
default:
return false;
}
};
@@ -0,0 +1,90 @@
// The board model is the source of truth for the moodboard feature. Both the
// Grid view (virtualized masonry) and the Canvas view render from it. Items
// carry a deterministic `aspect` so the masonry positioner can lay out and
// virtualize without measuring the DOM.
export type BoardItemKind = "image" | "video" | "text" | "link" | "color";
export type ViewMode = "grid" | "canvas";
// Grid density maps to a target column width + gap (see gridLayout.ts). Named
// rather than numeric so it reads well in the UI and persists stably.
export type GridDensity = "comfortable" | "cozy" | "compact";
interface BoardItemBase {
id: string;
kind: BoardItemKind;
// null = ungrouped (lives in the board's default flow).
sectionId: string | null;
createdAt: number;
tags: string[];
// height / width ratio used by the masonry positioner.
aspect: number;
// 0 = unrated; 1-5 reserved for Lightroom-style triage (Phase 3).
rating: number;
}
export interface BoardImageItem extends BoardItemBase {
kind: "image";
src: string;
mediaId: string | null;
naturalW: number;
naturalH: number;
caption: string;
}
export interface BoardVideoItem extends BoardItemBase {
kind: "video";
src: string;
mediaId: string | null;
naturalW: number;
naturalH: number;
}
export interface BoardTextItem extends BoardItemBase {
kind: "text";
text: string;
}
export interface BoardLinkItem extends BoardItemBase {
kind: "link";
url: string;
title: string;
description: string;
image: string | null;
}
export interface BoardColorItem extends BoardItemBase {
kind: "color";
color: string;
}
export type BoardItem =
| BoardImageItem
| BoardVideoItem
| BoardTextItem
| BoardLinkItem
| BoardColorItem;
export interface BoardSection {
id: string;
name: string;
// Header-collapsed in the grid. Persists so a tidied board stays tidy.
collapsed?: boolean;
}
export interface Board {
id: string;
name: string;
createdAt: number;
updatedAt: number;
// Display / z order for items in the grid.
itemOrder: string[];
items: Record<string, BoardItem>;
sections: BoardSection[];
}
// Nominal aspect ratios for non-media items, so masonry stays deterministic.
export const TEXT_ITEM_ASPECT = 0.62;
export const LINK_ITEM_ASPECT = 0.92;
export const COLOR_ITEM_ASPECT = 1;
@@ -0,0 +1,21 @@
// Client-side link metadata — no proxy, no network beyond the favicon <img>.
// Bare display host: strips the scheme and a leading "www.".
export const hostnameOf = (url: string): string => {
try {
return new URL(url).hostname.replace(/^www\./, "");
} catch {
return url;
}
};
// The site's own favicon, served from its origin (no third-party proxy). Falls
// back to a link glyph at the render site when the <img> errors.
export const faviconOf = (url: string): string | null => {
try {
const u = new URL(url);
return `${u.protocol}//${u.hostname}/favicon.ico`;
} catch {
return null;
}
};
@@ -0,0 +1,30 @@
// Probe intrinsic dimensions so masonry can lay an item out before it paints.
// Both resolve (never reject) with a square fallback so a failed probe still
// yields a usable tile.
export const measureImage = (
url: string,
): Promise<{ w: number; h: number }> =>
new Promise((resolve) => {
const img = new window.Image();
// No crossOrigin: we only read naturalWidth/Height (which never taint a
// canvas), and forcing anonymous CORS makes non-CORS images fail to load —
// collapsing them to the 320x320 fallback. palette.ts sets crossOrigin
// itself, where getImageData actually needs it.
img.onload = () => resolve({ w: img.naturalWidth, h: img.naturalHeight });
img.onerror = () => resolve({ w: 320, h: 320 });
img.src = url;
});
export const measureVideo = (
url: string,
): Promise<{ w: number; h: number }> =>
new Promise((resolve) => {
const v = document.createElement("video");
v.preload = "metadata";
v.muted = true;
v.onloadedmetadata = () =>
resolve({ w: v.videoWidth || 320, h: v.videoHeight || 320 });
v.onerror = () => resolve({ w: 320, h: 320 });
v.src = url;
});
@@ -0,0 +1,77 @@
// Client-side color extraction. Downscales the image to a small sample and
// buckets colors (4 bits/channel) to find a representative palette + dominant
// color, with near-duplicate suppression. Returns [] on a cross-origin tainted
// canvas (getImageData throws) — callers degrade gracefully.
const SAMPLE = 40;
const MIN_DISTANCE = 48;
interface Rgb {
r: number;
g: number;
b: number;
}
export const extractPalette = (url: string, count = 5): Promise<string[]> =>
new Promise((resolve) => {
const img = new window.Image();
img.crossOrigin = "anonymous";
img.onload = () => {
try {
resolve(quantize(img, count));
} catch {
resolve([]);
}
};
img.onerror = () => resolve([]);
img.src = url;
});
const quantize = (img: HTMLImageElement, count: number): string[] => {
const canvas = document.createElement("canvas");
canvas.width = SAMPLE;
canvas.height = SAMPLE;
const ctx = canvas.getContext("2d");
if (!ctx) return [];
ctx.drawImage(img, 0, 0, SAMPLE, SAMPLE);
const { data } = ctx.getImageData(0, 0, SAMPLE, SAMPLE);
const buckets = new Map<number, Rgb & { n: number }>();
for (let i = 0; i < data.length; i += 4) {
if (data[i + 3] < 128) continue;
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
const key = ((r >> 4) << 8) | ((g >> 4) << 4) | (b >> 4);
const e = buckets.get(key);
if (e) {
e.r += r;
e.g += g;
e.b += b;
e.n += 1;
} else {
buckets.set(key, { r, g, b, n: 1 });
}
}
const sorted = Array.from(buckets.values()).sort((a, b) => b.n - a.n);
const chosen: Rgb[] = [];
for (const e of sorted) {
const c: Rgb = {
r: Math.round(e.r / e.n),
g: Math.round(e.g / e.n),
b: Math.round(e.b / e.n),
};
if (chosen.every((o) => distance(o, c) > MIN_DISTANCE)) chosen.push(c);
if (chosen.length >= count) break;
}
return chosen.map((c) => toHex(c));
};
const distance = (a: Rgb, b: Rgb): number =>
Math.abs(a.r - b.r) + Math.abs(a.g - b.g) + Math.abs(a.b - b.b);
const toHex = ({ r, g, b }: Rgb): string => {
const h = (r << 16) | (g << 8) | b;
return `#${h.toString(16).padStart(6, "0").toUpperCase()}`;
};
@@ -0,0 +1,188 @@
import { ReactNode, useCallback, useEffect, useRef, useState } from "react";
import toast from "react-hot-toast";
import { useBoardLibraryStore } from "./BoardLibraryStore";
import { BoardImageItem } from "./boardTypes";
import { measureImage, measureVideo } from "./measureMedia";
import type { MoodboardAdapter, MoodboardPickedItem } from "../adapter";
interface UseReturn {
triggerUpload: () => void;
triggerGallery: () => void;
/** Adds an empty note and returns its id so the grid can open it for editing. */
addNote: () => string;
modals: ReactNode;
}
// Generic add-flows: upload, library pick (via adapter render-prop), clipboard
// paste (image + URL), note. The platform-specific bits — how a file is
// uploaded and what the library picker is — come from the adapter. (Color entry
// lives in the toolbar's ColorPickerPopover, which commits via addColorItem.)
export const useBoardItemEntry = (
adapter: MoodboardAdapter,
enabled: boolean,
): UseReturn => {
const fileInputRef = useRef<HTMLInputElement>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const triggerUpload = useCallback(() => fileInputRef.current?.click(), []);
const triggerGallery = useCallback(() => {
if (adapter.renderLibraryPicker) setPickerOpen(true);
else
toast("Library picker isn't available here — upload or paste for now", {
icon: "🖼️",
});
}, [adapter]);
const addNote = useCallback(() => {
const store = useBoardLibraryStore.getState();
// Empty text → the card shows its "Note" placeholder and the grid drops
// straight into edit mode so the user types instead of clearing "New note".
const id = store.addTextItem(store.ensureActiveBoard(), "");
store.setSelection([id]);
return id;
}, []);
// Place the image immediately from a blob, then (if the platform supports it)
// upload to capture a durable mediaToken so it becomes reference-capable.
const addImageFile = useCallback(
async (file: File) => {
const store = useBoardLibraryStore.getState();
const boardId = store.ensureActiveBoard();
const blobUrl = URL.createObjectURL(file);
const dims = await measureImage(blobUrl);
const itemId = store.addImageItem(boardId, {
src: blobUrl,
mediaId: null,
naturalW: dims.w,
naturalH: dims.h,
});
if (!adapter.uploadImage) return;
try {
const token = await adapter.uploadImage(file);
if (token) {
const patch: Partial<BoardImageItem> = { mediaId: token };
useBoardLibraryStore.getState().updateItem(boardId, itemId, patch);
}
} catch (err) {
console.error("[Moodboard] upload failed", err);
}
},
[adapter],
);
const handleFiles = useCallback(
async (files: FileList) => {
for (const file of Array.from(files)) {
if (file.type.startsWith("image/")) await addImageFile(file);
}
},
[addImageFile],
);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files && files.length) void handleFiles(files);
if (fileInputRef.current) fileInputRef.current.value = "";
};
const handlePicked = useCallback(async (items: MoodboardPickedItem[]) => {
const store = useBoardLibraryStore.getState();
const boardId = store.ensureActiveBoard();
for (const it of items) {
if (!it.url) continue;
if (it.kind === "video") {
const dims = await measureVideo(it.url);
store.addVideoItem(boardId, {
src: it.url,
mediaId: it.mediaToken,
naturalW: dims.w,
naturalH: dims.h,
});
} else {
const dims = await measureImage(it.url);
store.addImageItem(boardId, {
src: it.url,
mediaId: it.mediaToken,
naturalW: dims.w,
naturalH: dims.h,
});
}
}
setPickerOpen(false);
}, []);
// ----- clipboard paste (images + URLs) -----
useEffect(() => {
if (!enabled) return undefined;
const onPaste = (e: ClipboardEvent) => {
const target = e.target as HTMLElement | null;
if (target && /input|textarea/i.test(target.tagName)) return;
const data = e.clipboardData;
if (!data) return;
const imageFile = Array.from(data.items)
.find((it) => it.type.startsWith("image/"))
?.getAsFile();
if (imageFile) {
e.preventDefault();
void addImageFile(imageFile);
return;
}
const text = data.getData("text/plain").trim();
if (isUrl(text)) {
e.preventDefault();
const store = useBoardLibraryStore.getState();
const boardId = store.ensureActiveBoard();
store.addLinkItem(boardId, {
url: text,
title: hostnameOf(text),
description: "",
image: null,
});
}
};
window.addEventListener("paste", onPaste);
return () => window.removeEventListener("paste", onPaste);
}, [enabled, addImageFile]);
const modals = (
<>
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={handleFileChange}
/>
{adapter.renderLibraryPicker?.({
open: pickerOpen,
onClose: () => setPickerOpen(false),
onPick: (items) => void handlePicked(items),
})}
</>
);
return { triggerUpload, triggerGallery, addNote, modals };
};
// ---------- helpers ----------
const isUrl = (text: string): boolean => {
if (!/^https?:\/\//i.test(text)) return false;
try {
new URL(text);
return true;
} catch {
return false;
}
};
const hostnameOf = (url: string): string => {
try {
return new URL(url).hostname.replace(/^www\./, "");
} catch {
return url;
}
};
@@ -0,0 +1,27 @@
import { ReactNode } from "react";
import {
ToolbarShell,
ToolbarIconButton,
ToolbarDivider,
} from "../ui/Toolbar";
interface FloatingToolbarProps {
children: ReactNode;
className?: string;
}
// Canvas top bar — the same glass island as the Grid toolbar (ToolbarShell),
// centered and detached from the top edge.
export const FloatingToolbar = ({
children,
className,
}: FloatingToolbarProps) => (
<div className="pointer-events-none flex w-full justify-center pt-3">
<ToolbarShell className={className}>{children}</ToolbarShell>
</div>
);
// Re-exported under the canvas names so existing callers stay unchanged while
// sharing one button/divider look with the Grid toolbar.
export const FloatingToolbarDivider = ToolbarDivider;
export const FloatingToolbarButton = ToolbarIconButton;
@@ -0,0 +1,83 @@
import { useEffect, useRef } from "react";
import Konva from "konva";
import { MoodboardToolbar } from "./MoodboardToolbar";
import { MoodboardStage } from "./MoodboardStage";
import { TextEditOverlay } from "./TextEditOverlay";
import { useMoodboardStore } from "./MoodboardStore";
import { useUndoRedo } from "./interactions/useUndoRedo";
import { usePasteHandler } from "./interactions/usePasteHandler";
import { useGalleryDropEvent } from "./interactions/useGalleryDropEvent";
import { useKeyboardShortcuts } from "./interactions/useKeyboardShortcuts";
import { useShortcutCheatsheet } from "./interactions/useShortcutCheatsheet";
import { useMoodboardImageEntry } from "./useMoodboardImageEntry";
import { RecenterIndicator } from "./overlays/RecenterIndicator";
import { ShortcutCheatsheet } from "./overlays/ShortcutCheatsheet";
import type { MoodboardAdapter } from "../adapter";
// import { EmptyMoodboardCTA } from "./EmptyMoodboardCTA";
interface Props {
adapter: MoodboardAdapter;
}
export const Moodboard = ({ adapter }: Props) => {
const containerRef = useRef<HTMLDivElement | null>(null);
const stageRef = useRef<Konva.Stage | null>(null);
const deleteSelected = useMoodboardStore((s) => s.deleteSelected);
const setSelection = useMoodboardStore((s) => s.setSelection);
const editingTextId = useMoodboardStore((s) => s.transient.editingTextId);
// const isEmpty = useMoodboardStore((s) => s.rootOrder.length === 0);
useUndoRedo(true);
usePasteHandler(adapter, true, stageRef);
useGalleryDropEvent(true, stageRef);
useKeyboardShortcuts(true);
const cheatsheetVisible = useShortcutCheatsheet();
const { triggerUpload, triggerGallery, modals } = useMoodboardImageEntry(
adapter,
stageRef,
);
// Delete / Backspace removes the current selection. Skip when typing in
// an input or while a text node is in edit mode.
useEffect(() => {
const handler = (e: KeyboardEvent) => {
const target = e.target as HTMLElement | null;
if (target && /input|textarea/i.test(target.tagName)) return;
if (target && target.isContentEditable) return;
if (editingTextId) return;
if (e.key === "Delete" || e.key === "Backspace") {
e.preventDefault();
deleteSelected();
} else if (e.key === "Escape") {
setSelection([]);
}
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [deleteSelected, setSelection, editingTextId]);
return (
<div
ref={containerRef}
className="relative h-full w-full overflow-hidden bg-ui-panel"
>
<MoodboardStage containerRef={containerRef} stageRef={stageRef} />
<TextEditOverlay containerRef={containerRef} />
<RecenterIndicator />
<ShortcutCheatsheet visible={cheatsheetVisible} />
{/* {isEmpty && (
<EmptyMoodboardCTA
onUploadClick={triggerUpload}
onGalleryClick={triggerGallery}
/>
)} */}
<div className="absolute left-0 right-0 top-0 z-10">
<MoodboardToolbar
onUploadClick={triggerUpload}
onGalleryClick={triggerGallery}
/>
</div>
{modals}
</div>
);
};
@@ -0,0 +1,66 @@
import { memo } from "react";
import { Shape } from "react-konva";
import Konva from "konva";
import { Rect } from "./types";
interface Props {
// Visible region in stage (document) coordinates. Driven by pan/zoom.
viewport: Rect;
spacing: number;
zoom: number;
dotColor?: string;
// On-screen radius in CSS pixels. Converted to stage units so dots stay
// the same visual size regardless of zoom.
dotRadiusPx?: number;
}
// Minimum on-screen gap between dots before we drop to a coarser grid.
// At zoom 1× with spacing 32, dots are 32px apart; as you zoom out, the
// LOD step doubles `spacing` so density never drops below this bound.
const MIN_SCREEN_SPACING = 16;
// Vector dotted grid that follows the visible viewport. Dots stay anchored
// at fixed stage coords (multiples of the effective spacing); zooming out
// coarsens the grid in powers of 2 so we never draw tens of thousands of
// arcs per frame.
const MoodboardBackgroundInner = ({
viewport,
spacing,
zoom,
dotColor = "rgba(255,255,255,0.18)",
dotRadiusPx = 1.2,
}: Props) => {
const baseOnScreen = spacing * zoom;
const lodStep =
baseOnScreen >= MIN_SCREEN_SPACING
? 0
: Math.ceil(Math.log2(MIN_SCREEN_SPACING / Math.max(baseOnScreen, 1e-6)));
const effectiveSpacing = spacing * Math.pow(2, lodStep);
const radius = dotRadiusPx / Math.max(zoom, 1e-6);
return (
<Shape
x={0}
y={0}
listening={false}
sceneFunc={(ctx: Konva.Context, shape) => {
const startX = Math.floor(viewport.x / effectiveSpacing) * effectiveSpacing;
const startY = Math.floor(viewport.y / effectiveSpacing) * effectiveSpacing;
const endX = viewport.x + viewport.width;
const endY = viewport.y + viewport.height;
ctx.fillStyle = dotColor;
ctx.beginPath();
for (let x = startX; x <= endX; x += effectiveSpacing) {
for (let y = startY; y <= endY; y += effectiveSpacing) {
ctx.moveTo(x + radius, y);
ctx.arc(x, y, radius, 0, Math.PI * 2);
}
}
ctx.fill();
ctx.fillStrokeShape(shape);
}}
/>
);
};
export const MoodboardBackground = memo(MoodboardBackgroundInner);
@@ -0,0 +1,47 @@
import { create } from "zustand";
import { MoodboardSnapshot } from "./types";
const MAX_HISTORY = 50;
interface MoodboardHistoryState {
past: MoodboardSnapshot[];
future: MoodboardSnapshot[];
push: (snapshot: MoodboardSnapshot) => void;
undo: (current: MoodboardSnapshot) => MoodboardSnapshot | null;
redo: (current: MoodboardSnapshot) => MoodboardSnapshot | null;
clear: () => void;
}
export const useMoodboardHistoryStore = create<MoodboardHistoryState>(
(set, get) => ({
past: [],
future: [],
push: (snapshot) => {
const past = get().past;
const next = [...past, snapshot];
if (next.length > MAX_HISTORY) next.shift();
set({ past: next, future: [] });
},
undo: (current) => {
const { past, future } = get();
if (past.length === 0) return null;
const prior = past[past.length - 1];
set({
past: past.slice(0, -1),
future: [...future, current],
});
return prior;
},
redo: (current) => {
const { past, future } = get();
if (future.length === 0) return null;
const next = future[future.length - 1];
set({
past: [...past, current],
future: future.slice(0, -1),
});
return next;
},
clear: () => set({ past: [], future: [] }),
}),
);
@@ -0,0 +1,301 @@
import { useCallback, useEffect, useMemo } from "react";
import Konva from "konva";
import { Stage, Layer, Rect, Line, Group, Transformer } from "react-konva";
import { useThemeFgRgb } from "./useThemeFgRgb";
import { useShallow } from "zustand/react/shallow";
import { useMoodboardStore } from "./MoodboardStore";
import { MoodboardBackground } from "./MoodboardBackground";
import { ImageNode } from "./nodes/ImageNode";
import { VideoNode } from "./nodes/VideoNode";
import { TextNode } from "./nodes/TextNode";
import { GroupNode } from "./nodes/GroupNode";
import { useSelection } from "./interactions/useSelection";
import { useTransformer } from "./interactions/useTransformer";
import { useViewportControls } from "./interactions/useViewportControls";
import { stagePointerPos } from "./interactions/useStagePointer";
interface Props {
containerRef: React.RefObject<HTMLDivElement | null>;
stageRef: React.MutableRefObject<Konva.Stage | null>;
}
export const MoodboardStage = ({ containerRef, stageRef }: Props) => {
const transformerRef = useTransformer(stageRef);
const nodes = useMoodboardStore((s) => s.nodes);
const rootOrder = useMoodboardStore((s) => s.rootOrder);
const selectedIds = useMoodboardStore((s) => s.selectedIds);
const tool = useMoodboardStore((s) => s.tool);
const gridSpacing = useMoodboardStore((s) => s.gridSpacing);
// Only subscribe to the transient fields this component actually renders
// (marquee + lasso). Skips re-renders on `editingTextId` and `isPanning`
// toggles, which don't affect Stage JSX.
const { marquee, lassoPath, guides } = useMoodboardStore(
useShallow((s) => ({
marquee: s.transient.marquee,
lassoPath: s.transient.lassoPath,
guides: s.transient.guides,
})),
);
const viewport = useMoodboardStore((s) => s.viewport);
const canvasSize = useMoodboardStore((s) => s.canvasSize);
const setLastDropPoint = useMoodboardStore((s) => s.setLastDropPoint);
const setCanvasSize = useMoodboardStore((s) => s.setCanvasSize);
const toggleInSelection = useMoodboardStore((s) => s.toggleInSelection);
const setEditingText = useMoodboardStore((s) => s.setEditingText);
const addText = useMoodboardStore((s) => s.addText);
const zoom = viewport.zoom;
const pan = viewport.pan;
const fgRgb = useThemeFgRgb();
// Track wrapper size with ResizeObserver so the Stage fills its container.
// The size lives in the store so overlays (recenter indicator, future
// minimap) can read it without drilling refs through the tree.
useEffect(() => {
const el = containerRef.current;
if (!el) return undefined;
const updateSize = () => {
setCanvasSize({ width: el.clientWidth, height: el.clientHeight });
};
updateSize();
const ro = new ResizeObserver(updateSize);
ro.observe(el);
return () => ro.disconnect();
}, [containerRef, setCanvasSize]);
useSelection(stageRef);
useViewportControls(stageRef);
// Update lastDropPoint as the cursor moves across the stage so external
// drops (uploads, paste fallbacks) land near the cursor.
useEffect(() => {
const stage = stageRef.current;
if (!stage) return undefined;
const handle = () => {
const pos = stagePointerPos(stage);
if (pos) setLastDropPoint(pos);
};
stage.on("mousemove.moodboardCursor", handle);
return () => {
stage.off("mousemove.moodboardCursor");
};
}, [setLastDropPoint, stageRef]);
// Stable across renders so memoized node children see an unchanged
// onSelect prop and skip re-render when only camera/selection state
// changes. Depends only on a Zustand action, which itself is stable.
const handleNodeSelect = useCallback(
(id: string, e: Konva.KonvaEventObject<MouseEvent | TouchEvent>) => {
const evt = e.evt as MouseEvent | TouchEvent;
const additive = "shiftKey" in evt && evt.shiftKey;
toggleInSelection(id, additive);
},
[toggleInSelection],
);
const handleStageClick = (e: Konva.KonvaEventObject<MouseEvent>) => {
if (e.target !== stageRef.current) return;
if (tool === "text") {
const pos = stagePointerPos(stageRef.current);
if (pos) addText(pos);
return;
}
// Don't setSelection([]) here: Konva fires `click` after any marquee
// drag that ends on bare canvas, which would stomp on the selection
// `useSelection` just committed. The zero-area branch in `useSelection`
// handles the "bare click with no drag" case.
setEditingText(null);
};
// Visible region in stage coords; drives the dotted background grid so
// it follows pan/zoom rather than rendering a fixed (0,0,w,h) block.
// Memoized so transient store updates (marquee, lasso, isPanning) don't
// churn its identity and force `MoodboardBackground` to re-run sceneFunc
// every frame of a marquee drag.
const visibleViewport = useMemo(
() => ({
x: -pan.x / zoom,
y: -pan.y / zoom,
width: canvasSize.width / zoom,
height: canvasSize.height / zoom,
}),
[pan.x, pan.y, zoom, canvasSize.width, canvasSize.height],
);
return (
<Stage
ref={(s) => {
stageRef.current = s;
}}
width={canvasSize.width}
height={canvasSize.height}
x={pan.x}
y={pan.y}
scaleX={zoom}
scaleY={zoom}
onClick={handleStageClick}
onTap={handleStageClick}
style={{ cursor: tool === "text" ? "text" : "default" }}
>
<Layer listening={false}>
<MoodboardBackground
viewport={visibleViewport}
spacing={gridSpacing}
zoom={zoom}
dotColor={`rgb(${fgRgb} / 0.10)`}
/>
</Layer>
<Layer>
{rootOrder.map((id) => {
const n = nodes[id];
if (!n) return null;
const selected = selectedIds.has(id);
const draggable = tool === "select";
if (n.kind === "image") {
return (
<ImageNode
key={id}
node={n}
draggable={draggable}
selected={selected}
onSelect={handleNodeSelect}
/>
);
}
if (n.kind === "video") {
return (
<VideoNode
key={id}
node={n}
draggable={draggable}
selected={selected}
onSelect={handleNodeSelect}
/>
);
}
if (n.kind === "text") {
return (
<TextNode
key={id}
node={n}
draggable={draggable}
selected={selected}
onSelect={handleNodeSelect}
/>
);
}
if (n.kind === "group") {
return (
<GroupNode
key={id}
node={n}
selected={selected}
onSelect={handleNodeSelect}
/>
);
}
return null;
})}
<Transformer
ref={(t) => {
transformerRef.current = t;
}}
rotateEnabled
keepRatio={false}
/>
</Layer>
<Layer listening={false}>
{Array.from(selectedIds).map((id) => {
const n = nodes[id];
if (!n || n.parentId !== null) return null;
if (n.kind === "group") {
// Union AABB of children in group-local coords, wrapped in a
// Konva Group so the outline rotates around the group's origin.
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const cid of n.childIds) {
const c = nodes[cid];
if (!c) continue;
minX = Math.min(minX, c.x);
minY = Math.min(minY, c.y);
maxX = Math.max(maxX, c.x + c.width);
maxY = Math.max(maxY, c.y + c.height);
}
if (!Number.isFinite(minX)) return null;
return (
<Group
key={`outline-${id}`}
x={n.x}
y={n.y}
rotation={n.rotation}
listening={false}
>
<Rect
x={minX}
y={minY}
width={maxX - minX}
height={maxY - minY}
stroke="#3b82f6"
strokeWidth={1 / zoom}
dash={[4 / zoom, 4 / zoom]}
listening={false}
/>
</Group>
);
}
return (
<Rect
key={`outline-${id}`}
x={n.x}
y={n.y}
width={n.width}
height={n.height}
rotation={n.rotation}
stroke="#3b82f6"
strokeWidth={1 / zoom}
dash={[4 / zoom, 4 / zoom]}
listening={false}
/>
);
})}
{marquee && (
<Rect
x={marquee.x}
y={marquee.y}
width={marquee.width}
height={marquee.height}
stroke="#3b82f6"
strokeWidth={1 / zoom}
dash={[4 / zoom, 4 / zoom]}
fill="rgba(59,130,246,0.08)"
/>
)}
{lassoPath && lassoPath.length >= 2 && (
<Line
points={lassoPath.flatMap((p) => [p.x, p.y])}
stroke="#3b82f6"
strokeWidth={1 / zoom}
dash={[4 / zoom, 4 / zoom]}
closed={false}
/>
)}
{guides.map((g, i) => (
<Line
key={`guide-${i}`}
points={
g.axis === "x"
? [g.pos, g.from, g.pos, g.to]
: [g.from, g.pos, g.to, g.pos]
}
stroke="#f43f5e"
strokeWidth={1 / zoom}
listening={false}
/>
))}
</Layer>
</Stage>
);
};
@@ -0,0 +1,706 @@
import { create } from "zustand";
import { v4 as uuidv4 } from "uuid";
import {
CardNode,
GroupNode,
ImageNode,
MoodboardNode,
MoodboardSnapshot,
Rect,
TextNode,
Tool,
Vec2,
VideoNode,
} from "./types";
import { useMoodboardHistoryStore } from "./MoodboardHistoryStore";
import {
DEFAULT_CARD_COLOR,
DEFAULT_CARD_PADDING,
DEFAULT_CARD_WIDTH,
computeCardHeight,
} from "./cardLayout";
import {
CanvasSize,
Viewport,
computeAABBById,
computeContentBounds,
fitViewportToBounds,
} from "./layout/geometry";
import { SnapGuide } from "./layout/snapping";
// Hover state published during a node drag so overlay UI can highlight the card
// (or to-be card) the dragged node would land in. `targetId` is null when the
// pointer is over bare canvas.
export interface DragPreview {
draggedId: string;
targetId: string | null;
}
interface Transient {
marquee: Rect | null;
lassoPath: Vec2[] | null;
editingTextId: string | null;
isPanning: boolean;
dragPreview: DragPreview | null;
// Alignment guides shown live during a snapped drag.
guides: SnapGuide[];
}
interface MoodboardState {
nodes: Record<string, MoodboardNode>;
rootOrder: string[];
selectedIds: Set<string>;
tool: Tool;
gridSpacing: number;
// Snap a dragged node to nearby node edges/centers + the grid.
snapEnabled: boolean;
lastDropPoint: Vec2;
viewport: Viewport;
canvasSize: CanvasSize;
transient: Transient;
setTool: (t: Tool) => void;
toggleSnap: () => void;
setGuides: (guides: SnapGuide[]) => void;
setLastDropPoint: (p: Vec2) => void;
setZoom: (z: number) => void;
setPan: (p: Vec2) => void;
setCanvasSize: (s: CanvasSize) => void;
resetViewport: () => void;
fitToContent: (padding?: number) => void;
selectAll: () => void;
addImage: (
src: string,
pos: Vec2,
naturalW: number,
naturalH: number,
mediaId?: string | null,
) => string;
addVideo: (
src: string,
pos: Vec2,
naturalW: number,
naturalH: number,
mediaId?: string | null,
) => string;
addText: (pos: Vec2) => string;
updateNode: (id: string, patch: Partial<MoodboardNode>) => void;
setCardColor: (id: string, color: string) => void;
deleteSelected: () => void;
applyLayoutPatches: (
patches: Array<{ id: string; patch: Partial<MoodboardNode> }>,
) => void;
setSelection: (ids: Iterable<string>) => void;
toggleInSelection: (id: string, additive: boolean) => void;
clearSelection: () => void;
group: () => string | null;
ungroup: () => void;
// Atomic multi-cluster grouping for "auto-group by proximity": creates one
// group per provided cluster of ids, with a single history entry covering
// all of them. Returns the new group ids.
groupClusters: (clusters: string[][]) => string[];
// Card stacking (drag-onto-node gesture). `createCardFromDrop` forms a new
// card from a bare target node plus the dragged node; `appendToCard` adds the
// dragged node into an existing card. Both remove the dragged node from the
// root and reflow the card's height.
createCardFromDrop: (draggedId: string, targetId: string) => string | null;
appendToCard: (cardId: string, draggedId: string) => void;
setMarquee: (r: Rect | null) => void;
setLassoPath: (p: Vec2[] | null) => void;
setEditingText: (id: string | null) => void;
setIsPanning: (v: boolean) => void;
setDragPreview: (p: DragPreview | null) => void;
// Snapshot helpers used by history wiring (do NOT push to history themselves).
snapshot: () => MoodboardSnapshot;
applySnapshot: (snap: MoodboardSnapshot) => void;
pushHistory: () => void;
undo: () => void;
redo: () => void;
reset: () => void;
}
const DEFAULT_TEXT_W = 200;
const DEFAULT_TEXT_H = 60;
const DEFAULT_FONT_SIZE = 24;
const DEFAULT_TEXT_COLOR = "#FFFFFF";
const MAX_IMAGE_DIMENSION = 480;
const fitWithinBox = (w: number, h: number, max: number): { w: number; h: number } => {
if (w <= max && h <= max) return { w, h };
const ratio = Math.min(max / w, max / h);
return { w: w * ratio, h: h * ratio };
};
export const useMoodboardStore = create<MoodboardState>((set, get) => ({
nodes: {},
rootOrder: [],
selectedIds: new Set(),
tool: "select",
gridSpacing: 32,
snapEnabled: true,
lastDropPoint: { x: 200, y: 200 },
viewport: { zoom: 1, pan: { x: 0, y: 0 } },
canvasSize: { width: 800, height: 600 },
transient: {
marquee: null,
lassoPath: null,
editingTextId: null,
isPanning: false,
dragPreview: null,
guides: [],
},
setTool: (t) => set({ tool: t }),
toggleSnap: () => set((s) => ({ snapEnabled: !s.snapEnabled })),
setGuides: (guides) =>
set((s) => ({ transient: { ...s.transient, guides } })),
setLastDropPoint: (p) => set({ lastDropPoint: p }),
setZoom: (z) => set((s) => ({ viewport: { ...s.viewport, zoom: z } })),
setPan: (p) => set((s) => ({ viewport: { ...s.viewport, pan: p } })),
setCanvasSize: (sz) => set({ canvasSize: sz }),
resetViewport: () =>
set({ viewport: { zoom: 1, pan: { x: 0, y: 0 } } }),
fitToContent: (padding = 48) => {
const { nodes, rootOrder, canvasSize } = get();
const bounds = computeContentBounds(nodes, rootOrder);
if (!bounds) return;
set({ viewport: fitViewportToBounds(bounds, canvasSize, padding) });
},
selectAll: () => {
const { rootOrder } = get();
if (rootOrder.length === 0) return;
set({ selectedIds: new Set(rootOrder) });
},
addImage: (src, pos, naturalW, naturalH, mediaId = null) => {
get().pushHistory();
const id = uuidv4();
const fit = fitWithinBox(naturalW || 320, naturalH || 320, MAX_IMAGE_DIMENSION);
const node: ImageNode = {
id,
kind: "image",
parentId: null,
x: pos.x - fit.w / 2,
y: pos.y - fit.h / 2,
width: fit.w,
height: fit.h,
rotation: 0,
zIndex: get().rootOrder.length,
src,
mediaId,
naturalW,
naturalH,
};
set((s) => ({
nodes: { ...s.nodes, [id]: node },
rootOrder: [...s.rootOrder, id],
selectedIds: new Set([id]),
}));
return id;
},
addVideo: (src, pos, naturalW, naturalH, mediaId = null) => {
get().pushHistory();
const id = uuidv4();
const fit = fitWithinBox(naturalW || 320, naturalH || 320, MAX_IMAGE_DIMENSION);
const node: VideoNode = {
id,
kind: "video",
parentId: null,
x: pos.x - fit.w / 2,
y: pos.y - fit.h / 2,
width: fit.w,
height: fit.h,
rotation: 0,
zIndex: get().rootOrder.length,
src,
mediaId,
naturalW,
naturalH,
// Silent looping autoplay is the Milanote-style default — browsers
// only permit autoplay when muted, so these three travel together.
muted: true,
loop: true,
autoplay: true,
};
set((s) => ({
nodes: { ...s.nodes, [id]: node },
rootOrder: [...s.rootOrder, id],
selectedIds: new Set([id]),
}));
return id;
},
addText: (pos) => {
get().pushHistory();
const id = uuidv4();
const node: TextNode = {
id,
kind: "text",
parentId: null,
x: pos.x - DEFAULT_TEXT_W / 2,
y: pos.y - DEFAULT_TEXT_H / 2,
width: DEFAULT_TEXT_W,
height: DEFAULT_TEXT_H,
rotation: 0,
zIndex: get().rootOrder.length,
text: "Note",
fontSize: DEFAULT_FONT_SIZE,
color: DEFAULT_TEXT_COLOR,
};
set((s) => ({
nodes: { ...s.nodes, [id]: node },
rootOrder: [...s.rootOrder, id],
selectedIds: new Set([id]),
transient: { ...s.transient, editingTextId: id },
}));
return id;
},
updateNode: (id, patch) => {
set((s) => {
const existing = s.nodes[id];
if (!existing) return s;
// Keep discriminant intact by spreading then overlaying with patch fields.
const merged = { ...existing, ...patch } as MoodboardNode;
return { nodes: { ...s.nodes, [id]: merged } };
});
},
setCardColor: (id, color) => {
// Live edit (called continuously while dragging the color picker), so it
// deliberately skips history — the undo stack would otherwise flood with a
// frame per pointermove.
set((s) => {
const n = s.nodes[id];
if (!n || n.kind !== "card") return s;
return { nodes: { ...s.nodes, [id]: { ...n, backgroundColor: color } } };
});
},
deleteSelected: () => {
const { selectedIds } = get();
if (selectedIds.size === 0) return;
get().pushHistory();
set((s) => {
const removed = new Set<string>();
const collect = (id: string) => {
const n = s.nodes[id];
if (!n) return;
removed.add(id);
if (n.kind === "group" || n.kind === "card") n.childIds.forEach(collect);
};
selectedIds.forEach(collect);
const nodes: Record<string, MoodboardNode> = {};
Object.entries(s.nodes).forEach(([id, n]) => {
if (removed.has(id)) return;
// Filter dangling child references in container nodes.
if (n.kind === "group" || n.kind === "card") {
nodes[id] = {
...n,
childIds: n.childIds.filter((c) => !removed.has(c)),
};
} else {
nodes[id] = n;
}
});
return {
nodes,
rootOrder: s.rootOrder.filter((id) => !removed.has(id)),
selectedIds: new Set(),
};
});
},
applyLayoutPatches: (patches) => {
if (patches.length === 0) return;
get().pushHistory();
set((s) => {
const nodes = { ...s.nodes };
patches.forEach(({ id, patch }) => {
const existing = nodes[id];
if (!existing) return;
nodes[id] = { ...existing, ...patch } as MoodboardNode;
});
return { nodes };
});
},
setSelection: (ids) => {
set({ selectedIds: stripAncestorsAndDescendants(get().nodes, new Set(ids)) });
},
toggleInSelection: (id, additive) => {
set((s) => {
const next = additive ? new Set(s.selectedIds) : new Set<string>();
if (additive && s.selectedIds.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return { selectedIds: stripAncestorsAndDescendants(s.nodes, next) };
});
},
clearSelection: () => set({ selectedIds: new Set() }),
group: () => {
const { selectedIds, nodes } = get();
const topLevelIds = Array.from(selectedIds).filter((id) => {
const n = nodes[id];
return n && n.parentId === null;
});
if (topLevelIds.length < 2) return null;
get().pushHistory();
const groupId = uuidv4();
const aabb = computeAABBById(nodes, topLevelIds);
if (!aabb) return null;
const groupNode: GroupNode = {
id: groupId,
kind: "group",
parentId: null,
x: aabb.x,
y: aabb.y,
width: aabb.width,
height: aabb.height,
rotation: 0,
zIndex: get().rootOrder.length,
childIds: topLevelIds,
};
set((s) => {
const newNodes: Record<string, MoodboardNode> = { ...s.nodes };
// Rebase each child position to be group-local.
topLevelIds.forEach((id) => {
const n = newNodes[id];
if (!n) return;
newNodes[id] = {
...n,
parentId: groupId,
x: n.x - aabb.x,
y: n.y - aabb.y,
} as MoodboardNode;
});
newNodes[groupId] = groupNode;
return {
nodes: newNodes,
rootOrder: [
...s.rootOrder.filter((id) => !topLevelIds.includes(id)),
groupId,
],
selectedIds: new Set([groupId]),
};
});
return groupId;
},
groupClusters: (clusters) => {
// Single-shot atomic version of group() that creates one group per
// cluster of ids, with one history entry covering all of them. Used by
// "Auto-group by proximity" so undo collapses to a single step.
const validClusters = clusters.filter((cluster) => cluster.length >= 2);
if (validClusters.length === 0) return [];
get().pushHistory();
const newGroupIds: string[] = [];
set((s) => {
const newNodes: Record<string, MoodboardNode> = { ...s.nodes };
let newRootOrder = [...s.rootOrder];
for (const cluster of validClusters) {
const topLevelIds = cluster.filter((id) => {
const n = newNodes[id];
return n && n.parentId === null;
});
if (topLevelIds.length < 2) continue;
const aabb = computeAABBById(newNodes, topLevelIds);
if (!aabb) continue;
const groupId = uuidv4();
const groupNode: GroupNode = {
id: groupId,
kind: "group",
parentId: null,
x: aabb.x,
y: aabb.y,
width: aabb.width,
height: aabb.height,
rotation: 0,
zIndex: newRootOrder.length,
childIds: topLevelIds,
};
topLevelIds.forEach((id) => {
const n = newNodes[id];
if (!n) return;
newNodes[id] = {
...n,
parentId: groupId,
x: n.x - aabb.x,
y: n.y - aabb.y,
} as MoodboardNode;
});
newNodes[groupId] = groupNode;
newRootOrder = [
...newRootOrder.filter((id) => !topLevelIds.includes(id)),
groupId,
];
newGroupIds.push(groupId);
}
return {
nodes: newNodes,
rootOrder: newRootOrder,
selectedIds: new Set(newGroupIds),
};
});
return newGroupIds;
},
createCardFromDrop: (draggedId, targetId) => {
const { nodes } = get();
const dragged = nodes[draggedId];
const target = nodes[targetId];
if (!dragged || !target || draggedId === targetId) return null;
// If the target is already a card, this is really an append.
if (target.kind === "card") {
get().appendToCard(targetId, draggedId);
return targetId;
}
// Only top-level bare nodes can seed a new card.
if (target.parentId !== null || dragged.parentId !== null) return null;
get().pushHistory();
const cardId = uuidv4();
// Target was there first, so it sits on top; the dragged node stacks below.
const childIds = [targetId, draggedId];
set((s) => {
const newNodes: Record<string, MoodboardNode> = { ...s.nodes };
childIds.forEach((cid) => {
const c = newNodes[cid];
if (!c) return;
// Children are reparented; their x/y are ignored by the card's flex
// layout, so we leave them as-is to make ungrouping cheap later.
newNodes[cid] = { ...c, parentId: cardId } as MoodboardNode;
});
const base: CardNode = {
id: cardId,
kind: "card",
parentId: null,
x: target.x,
y: target.y,
width: DEFAULT_CARD_WIDTH,
height: 0,
rotation: 0,
zIndex: s.rootOrder.length,
childIds,
padding: DEFAULT_CARD_PADDING,
backgroundColor: DEFAULT_CARD_COLOR,
};
newNodes[cardId] = { ...base, height: computeCardHeight(base, newNodes) };
return {
nodes: newNodes,
rootOrder: [
...s.rootOrder.filter((id) => id !== targetId && id !== draggedId),
cardId,
],
selectedIds: new Set([cardId]),
};
});
return cardId;
},
appendToCard: (cardId, draggedId) => {
const { nodes } = get();
const card = nodes[cardId];
const dragged = nodes[draggedId];
if (!card || card.kind !== "card" || !dragged) return;
if (draggedId === cardId || card.childIds.includes(draggedId)) return;
get().pushHistory();
set((s) => {
const current = s.nodes[cardId];
const child = s.nodes[draggedId];
if (!current || current.kind !== "card" || !child) return s;
const newNodes: Record<string, MoodboardNode> = { ...s.nodes };
newNodes[draggedId] = { ...child, parentId: cardId } as MoodboardNode;
const base: CardNode = {
...current,
childIds: [...current.childIds, draggedId],
};
newNodes[cardId] = { ...base, height: computeCardHeight(base, newNodes) };
return {
nodes: newNodes,
rootOrder: s.rootOrder.filter((id) => id !== draggedId),
selectedIds: new Set([cardId]),
};
});
},
ungroup: () => {
const { selectedIds, nodes } = get();
const groupIds = Array.from(selectedIds).filter(
(id) => nodes[id]?.kind === "group",
);
if (groupIds.length === 0) return;
get().pushHistory();
set((s) => {
const newNodes: Record<string, MoodboardNode> = { ...s.nodes };
const newRootOrder = [...s.rootOrder];
const releasedChildIds: string[] = [];
groupIds.forEach((gid) => {
const g = newNodes[gid];
if (!g || g.kind !== "group") return;
// Rebase children back to stage-local using group's transform.
g.childIds.forEach((cid) => {
const child = newNodes[cid];
if (!child) return;
const local = { x: child.x, y: child.y };
const stagePoint = applyGroupTransform(local, g);
newNodes[cid] = {
...child,
parentId: null,
x: stagePoint.x,
y: stagePoint.y,
// Carry the group's rotation onto the child so the visual stays put.
rotation: child.rotation + g.rotation,
} as MoodboardNode;
releasedChildIds.push(cid);
});
delete newNodes[gid];
const idx = newRootOrder.indexOf(gid);
if (idx >= 0) newRootOrder.splice(idx, 1);
});
releasedChildIds.forEach((cid) => newRootOrder.push(cid));
return {
nodes: newNodes,
rootOrder: newRootOrder,
selectedIds: new Set(releasedChildIds),
};
});
},
setMarquee: (r) =>
set((s) => ({ transient: { ...s.transient, marquee: r } })),
setLassoPath: (p) =>
set((s) => ({ transient: { ...s.transient, lassoPath: p } })),
setEditingText: (id) =>
set((s) => ({ transient: { ...s.transient, editingTextId: id } })),
setIsPanning: (v) =>
set((s) => ({ transient: { ...s.transient, isPanning: v } })),
setDragPreview: (p) =>
set((s) => ({ transient: { ...s.transient, dragPreview: p } })),
snapshot: () => {
const { nodes, rootOrder, selectedIds } = get();
return {
nodes: structuredClone(nodes),
rootOrder: [...rootOrder],
selectedIds: Array.from(selectedIds),
};
},
applySnapshot: (snap) => {
set({
nodes: structuredClone(snap.nodes),
rootOrder: [...snap.rootOrder],
selectedIds: new Set(snap.selectedIds),
});
},
pushHistory: () => {
useMoodboardHistoryStore.getState().push(get().snapshot());
},
undo: () => {
const current = get().snapshot();
const prior = useMoodboardHistoryStore.getState().undo(current);
if (prior) get().applySnapshot(prior);
},
redo: () => {
const current = get().snapshot();
const next = useMoodboardHistoryStore.getState().redo(current);
if (next) get().applySnapshot(next);
},
reset: () => {
useMoodboardHistoryStore.getState().clear();
set({
nodes: {},
rootOrder: [],
selectedIds: new Set(),
tool: "select",
lastDropPoint: { x: 200, y: 200 },
viewport: { zoom: 1, pan: { x: 0, y: 0 } },
transient: {
marquee: null,
lassoPath: null,
editingTextId: null,
isPanning: false,
dragPreview: null,
guides: [],
},
});
},
}));
// ---------- helpers ----------
const stripAncestorsAndDescendants = (
nodes: Record<string, MoodboardNode>,
ids: Set<string>,
): Set<string> => {
const result = new Set(ids);
// Drop any node whose ancestor (parent chain up to root) is in the set.
ids.forEach((id) => {
let cur = nodes[id];
while (cur && cur.parentId) {
if (result.has(cur.parentId)) {
result.delete(id);
break;
}
cur = nodes[cur.parentId];
}
});
// Drop any descendant whose ancestor container (group or card) is in the set.
ids.forEach((id) => {
const n = nodes[id];
if (n && (n.kind === "group" || n.kind === "card")) {
const stack = [...n.childIds];
while (stack.length) {
const c = stack.pop()!;
if (result.has(c)) result.delete(c);
const cn = nodes[c];
if (cn && (cn.kind === "group" || cn.kind === "card")) {
stack.push(...cn.childIds);
}
}
}
});
return result;
};
// Maps a group-local point to stage-local coords using the group's transform.
const applyGroupTransform = (local: Vec2, g: GroupNode): Vec2 => {
const rad = (g.rotation * Math.PI) / 180;
const cos = Math.cos(rad);
const sin = Math.sin(rad);
return {
x: g.x + local.x * cos - local.y * sin,
y: g.y + local.x * sin + local.y * cos,
};
};
@@ -0,0 +1,217 @@
import {
faMousePointer,
faDrawPolygon,
faObjectGroup,
faObjectUngroup,
faGripVertical,
faTableCells,
faDiagramProject,
faRotateLeft,
faRotateRight,
faTrash,
faPlus,
faImages,
faArrowUpFromBracket,
faText,
faMagnet,
} from "@fortawesome/pro-solid-svg-icons";
import { ButtonIconSelect } from "@storyteller/ui-button-icon-select";
import { PopoverMenu } from "@storyteller/ui-popover";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Tooltip } from "@storyteller/ui-tooltip";
import {
FloatingToolbar,
FloatingToolbarButton,
FloatingToolbarDivider,
} from "./FloatingToolbar";
import { useMoodboardStore } from "./MoodboardStore";
import { Tool } from "./types";
import { MOD, fmtShortcut } from "./overlays/shortcuts";
import { computeFitToGridPatches } from "./layout/fitToGrid";
import { computeAABB } from "./layout/geometry";
import { computePackPatches } from "./layout/packCollage";
import { clusterByProximity } from "./layout/clusterProximity";
const MODES: Array<{
value: Tool;
icon: typeof faMousePointer;
tooltip: string;
}> = [
{ value: "select", icon: faMousePointer, tooltip: "Select (V)" },
{ value: "lasso", icon: faDrawPolygon, tooltip: "Lasso (L)" },
{ value: "text", icon: faText, tooltip: "Text (T)" },
];
interface Props {
onUploadClick: () => void;
onGalleryClick: () => void;
}
export const MoodboardToolbar = ({ onUploadClick, onGalleryClick }: Props) => {
const tool = useMoodboardStore((s) => s.tool);
const setTool = useMoodboardStore((s) => s.setTool);
const group = useMoodboardStore((s) => s.group);
const groupClusters = useMoodboardStore((s) => s.groupClusters);
const ungroup = useMoodboardStore((s) => s.ungroup);
const deleteSelected = useMoodboardStore((s) => s.deleteSelected);
const undo = useMoodboardStore((s) => s.undo);
const redo = useMoodboardStore((s) => s.redo);
const applyLayoutPatches = useMoodboardStore((s) => s.applyLayoutPatches);
const selectedIds = useMoodboardStore((s) => s.selectedIds);
const nodes = useMoodboardStore((s) => s.nodes);
const rootOrder = useMoodboardStore((s) => s.rootOrder);
const gridSpacing = useMoodboardStore((s) => s.gridSpacing);
const snapEnabled = useMoodboardStore((s) => s.snapEnabled);
const toggleSnap = useMoodboardStore((s) => s.toggleSnap);
const handleFitToGrid = () => {
if (selectedIds.size === 0) return;
const targetNodes = Array.from(selectedIds)
.map((id) => nodes[id])
.filter(Boolean);
applyLayoutPatches(computeFitToGridPatches(targetNodes, gridSpacing));
};
const handlePackCollage = () => {
const ids = selectedIds.size > 0 ? Array.from(selectedIds) : rootOrder;
const targetNodes = ids
.map((id) => nodes[id])
.filter((n) => n && n.parentId === null);
if (targetNodes.length < 2) return;
const aabb = computeAABB(targetNodes);
if (!aabb) return;
applyLayoutPatches(computePackPatches(targetNodes, aabb));
};
const handleAutoGroup = () => {
const ids = selectedIds.size > 0 ? Array.from(selectedIds) : rootOrder;
const targetNodes = ids
.map((id) => nodes[id])
.filter((n) => n && n.parentId === null);
if (targetNodes.length < 2) return;
const clusters = clusterByProximity(targetNodes);
const clusterIds = clusters.map((c) => c.map((n) => n.id));
groupClusters(clusterIds);
};
const handleAddAction = (action: string) => {
if (action === "upload") onUploadClick();
else if (action === "library") onGalleryClick();
};
return (
<FloatingToolbar>
<Tooltip
content="Add an image"
position="bottom"
delay={300}
closeOnClick
>
<PopoverMenu
mode="button"
position="bottom"
panelTitle="Add an image"
items={[
{
label: "Upload Image",
selected: false,
icon: (
<FontAwesomeIcon
icon={faArrowUpFromBracket}
className="h-4 w-4"
/>
),
action: "upload",
},
{
label: "Pick from Library",
selected: false,
icon: <FontAwesomeIcon icon={faImages} className="h-4 w-4" />,
action: "library",
},
]}
onPanelAction={handleAddAction}
showIconsInList
buttonClassName="h-9 w-9 rounded-[10px] border-transparent bg-primary/90 text-lg text-white hover:bg-primary/70"
triggerIcon={<FontAwesomeIcon icon={faPlus} className="text-xl" />}
/>
</Tooltip>
<FloatingToolbarDivider />
<ButtonIconSelect
options={MODES}
onOptionChange={(value) => setTool(value as Tool)}
selectedOption={tool}
/>
<FloatingToolbarDivider />
<FloatingToolbarButton
icon={faObjectGroup}
label={`Group (${fmtShortcut([MOD, "G"])})`}
onClick={() => group()}
disabled={selectedIds.size < 2}
tooltipDelay={100}
/>
<FloatingToolbarButton
icon={faObjectUngroup}
label={`Ungroup (${fmtShortcut([MOD, "Shift", "G"])})`}
onClick={() => ungroup()}
disabled={selectedIds.size === 0}
tooltipDelay={100}
/>
<FloatingToolbarDivider />
<FloatingToolbarButton
icon={faTableCells}
label="Fit to grid"
onClick={handleFitToGrid}
disabled={selectedIds.size === 0}
tooltipDelay={100}
/>
<FloatingToolbarButton
icon={faGripVertical}
label="Pack collage"
onClick={handlePackCollage}
tooltipDelay={100}
/>
<FloatingToolbarButton
icon={faDiagramProject}
label="Auto-group by proximity"
onClick={handleAutoGroup}
tooltipDelay={100}
/>
<FloatingToolbarButton
icon={faMagnet}
label={snapEnabled ? "Snapping on" : "Snapping off"}
active={snapEnabled}
onClick={toggleSnap}
tooltipDelay={100}
/>
<FloatingToolbarDivider />
<FloatingToolbarButton
icon={faRotateLeft}
label={`Undo (${fmtShortcut([MOD, "Z"])})`}
onClick={undo}
tooltipDelay={100}
/>
<FloatingToolbarButton
icon={faRotateRight}
label={`Redo (${fmtShortcut([MOD, "Shift", "Z"])})`}
onClick={redo}
tooltipDelay={100}
/>
<FloatingToolbarButton
icon={faTrash}
label="Delete (Del)"
onClick={deleteSelected}
disabled={selectedIds.size === 0}
tooltipDelay={100}
/>
</FloatingToolbar>
);
};
@@ -0,0 +1,82 @@
import { useEffect, useRef } from "react";
import { useMoodboardStore } from "./MoodboardStore";
interface Props {
containerRef: React.RefObject<HTMLDivElement | null>;
}
// Floats a positioned <textarea> over the editing text node so the user can
// type directly. Konva Text has no native edit mode; this is the standard
// react-konva pattern. Phase 1 has no zoom/pan, so absolute positioning is
// simply (node.x, node.y) within the container.
export const TextEditOverlay = ({ containerRef }: Props) => {
const editingId = useMoodboardStore((s) => s.transient.editingTextId);
const node = useMoodboardStore((s) =>
editingId ? s.nodes[editingId] : null,
);
const updateNode = useMoodboardStore((s) => s.updateNode);
const pushHistory = useMoodboardStore((s) => s.pushHistory);
const setEditingText = useMoodboardStore((s) => s.setEditingText);
const taRef = useRef<HTMLTextAreaElement | null>(null);
const initialTextRef = useRef<string>("");
useEffect(() => {
if (editingId && node && node.kind === "text") {
initialTextRef.current = node.text;
requestAnimationFrame(() => {
taRef.current?.focus();
taRef.current?.select();
});
}
}, [editingId, node]);
if (!editingId || !node || node.kind !== "text") return null;
const commit = () => {
if (!taRef.current) return;
const newText = taRef.current.value;
if (newText !== initialTextRef.current) {
pushHistory();
updateNode(node.id, { text: newText });
}
setEditingText(null);
};
const cancel = () => {
setEditingText(null);
};
return (
<textarea
ref={taRef}
defaultValue={node.text}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault();
cancel();
} else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
commit();
}
}}
style={{
position: "absolute",
left: node.x,
top: node.y,
width: node.width,
height: node.height,
background: "rgba(15,15,18,0.85)",
color: node.color,
border: "1px dashed rgba(59,130,246,0.6)",
outline: "none",
padding: 6,
fontSize: node.fontSize,
fontFamily: "inherit",
resize: "none",
overflow: "hidden",
zIndex: 5,
}}
/>
);
};
@@ -0,0 +1,37 @@
import { CardNode, MoodboardNode } from "./types";
export const DEFAULT_CARD_WIDTH = 280;
export const DEFAULT_CARD_PADDING = 8;
export const DEFAULT_CARD_COLOR = "#FFFFFF";
// Height of one child when laid out inside a card: child's native aspect
// ratio applied to (cardWidth - 2*padding), falling back to the child's own
// height when aspect isn't available (text nodes, nested containers).
export const childRenderedHeight = (
child: MoodboardNode,
innerWidth: number,
): number => {
if (child.kind === "image" || child.kind === "video") {
const ratio =
child.naturalH > 0 && child.naturalW > 0
? child.naturalH / child.naturalW
: child.height / Math.max(child.width, 1);
return innerWidth * ratio;
}
if (child.kind === "text") return child.height;
return child.height;
};
export const computeCardHeight = (
card: CardNode,
nodes: Record<string, MoodboardNode>,
): number => {
const innerW = Math.max(card.width - card.padding * 2, 1);
let total = card.padding;
for (const cid of card.childIds) {
const c = nodes[cid];
if (!c) continue;
total += childRenderedHeight(c, innerW) + card.padding;
}
return Math.max(total, card.padding * 2);
};
@@ -0,0 +1,59 @@
import { useEffect } from "react";
import Konva from "konva";
import { useMoodboardStore } from "../MoodboardStore";
import {
DroppedGalleryItem,
GALLERY_MOODBOARD_DROP_EVENT,
} from "../../galleryDrop";
interface GalleryMoodboardDropDetail {
item: DroppedGalleryItem;
canvasPosition: { x: number; y: number };
}
const measure = (url: string): Promise<{ w: number; h: number }> =>
new Promise((resolve) => {
const i = new window.Image();
i.crossOrigin = "anonymous";
i.onload = () => resolve({ w: i.naturalWidth, h: i.naturalHeight });
i.onerror = () => resolve({ w: 320, h: 320 });
i.src = url;
});
// Listens for gallery-moodboard-drop CustomEvents dispatched by the
// PageEditor when the user drops a gallery item over the canvas. Mirrors
// PageDraw's gallery-2d-drop listener pattern but scoped to moodboard.
export const useGalleryDropEvent = (
active: boolean,
stageRef: React.RefObject<Konva.Stage | null>,
) => {
const addImage = useMoodboardStore((s) => s.addImage);
useEffect(() => {
if (!active) return undefined;
const handler = async (e: Event) => {
const ce = e as CustomEvent<GalleryMoodboardDropDetail>;
const detail = ce.detail;
if (!detail) return;
const { item, canvasPosition } = detail;
const url = item.fullImage || item.thumbnail;
if (!url) return;
const stage = stageRef.current;
const stagePoint = stage
? {
x: (canvasPosition.x - stage.x()) / stage.scaleX(),
y: (canvasPosition.y - stage.y()) / stage.scaleY(),
}
: canvasPosition;
const dims = await measure(url);
addImage(url, stagePoint, dims.w, dims.h, item.id ?? null);
};
window.addEventListener(GALLERY_MOODBOARD_DROP_EVENT, handler);
return () =>
window.removeEventListener(GALLERY_MOODBOARD_DROP_EVENT, handler);
}, [active, addImage, stageRef]);
};
@@ -0,0 +1,77 @@
import { useEffect } from "react";
import { useMoodboardStore } from "../MoodboardStore";
const isEditableTarget = (target: EventTarget | null): boolean => {
const el = target as HTMLElement | null;
if (!el) return false;
if (/input|textarea/i.test(el.tagName)) return true;
if (el.isContentEditable) return true;
return false;
};
// Figma-style shortcuts that aren't already wired elsewhere.
// Cmd+Z / Cmd+Shift+Z live in useUndoRedo; Cmd+0 + Space-pan live in
// useViewportControls; Delete / Escape live in Moodboard.tsx.
export const useKeyboardShortcuts = (active: boolean) => {
const setTool = useMoodboardStore((s) => s.setTool);
const selectAll = useMoodboardStore((s) => s.selectAll);
const group = useMoodboardStore((s) => s.group);
const ungroup = useMoodboardStore((s) => s.ungroup);
const fitToContent = useMoodboardStore((s) => s.fitToContent);
useEffect(() => {
if (!active) return undefined;
const isMac = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
const handler = (e: KeyboardEvent) => {
if (isEditableTarget(e.target)) return;
// Skip while typing in the text-edit overlay (covered by isEditableTarget
// too, but belt-and-suspenders in case the overlay loses target focus).
if (useMoodboardStore.getState().transient.editingTextId) return;
const mod = isMac ? e.metaKey : e.ctrlKey;
const k = e.key.toLowerCase();
// Modifier combos
if (mod && !e.shiftKey && k === "a") {
e.preventDefault();
selectAll();
return;
}
if (mod && !e.shiftKey && k === "g") {
e.preventDefault();
group();
return;
}
if (mod && e.shiftKey && k === "g") {
e.preventDefault();
ungroup();
return;
}
// Shift-only combos
if (e.shiftKey && !mod && !e.altKey && k === "1") {
e.preventDefault();
fitToContent();
return;
}
// Plain (no modifiers) tool switches — skip if any modifier is held
// so Cmd+V (paste) etc. never triggers the Text tool.
if (mod || e.shiftKey || e.altKey) return;
if (k === "v") {
e.preventDefault();
setTool("select");
} else if (k === "l") {
e.preventDefault();
setTool("lasso");
} else if (k === "t") {
e.preventDefault();
setTool("text");
}
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [active, setTool, selectAll, group, ungroup, fitToContent]);
};
@@ -0,0 +1,61 @@
import { useMemo } from "react";
import Konva from "konva";
import { useMoodboardStore } from "../MoodboardStore";
import { computeSnap, SnapBox } from "../layout/snapping";
// Screen-space snap radius; divided by zoom so the feel is constant at any zoom.
const SNAP_SCREEN_PX = 6;
export interface NodeDragHandlers {
onDragStart: () => void;
onDragMove: (e: Konva.KonvaEventObject<DragEvent>) => void;
onDragEnd: (e: Konva.KonvaEventObject<DragEvent>) => void;
}
// Shared drag wiring for every canvas node. onDragStart snapshots history;
// onDragMove snaps the live position to nearby node edges/centers + the grid and
// publishes alignment guides; onDragEnd commits the final position and clears
// the guides. Reads the store imperatively inside handlers so the hook itself
// doesn't re-render on every node move.
export const useNodeDrag = (nodeId: string): NodeDragHandlers => {
const updateNode = useMoodboardStore((s) => s.updateNode);
const pushHistory = useMoodboardStore((s) => s.pushHistory);
const setGuides = useMoodboardStore((s) => s.setGuides);
return useMemo(
() => ({
onDragStart: () => pushHistory(),
onDragMove: (e) => {
const target = e.target;
const state = useMoodboardStore.getState();
if (!state.snapEnabled) return;
const node = state.nodes[nodeId];
if (!node) return;
const box: SnapBox = {
x: target.x(),
y: target.y(),
width: node.width,
height: node.height,
};
const others: SnapBox[] = [];
for (const id of state.rootOrder) {
if (id === nodeId) continue;
const n = state.nodes[id];
if (n) others.push({ x: n.x, y: n.y, width: n.width, height: n.height });
}
const threshold = SNAP_SCREEN_PX / state.viewport.zoom;
const result = computeSnap(box, others, threshold, state.gridSpacing);
target.x(result.x);
target.y(result.y);
setGuides(result.guides);
},
onDragEnd: (e) => {
updateNode(nodeId, { x: e.target.x(), y: e.target.y() });
setGuides([]);
},
}),
[nodeId, updateNode, pushHistory, setGuides],
);
};
@@ -0,0 +1,72 @@
import { useEffect } from "react";
import Konva from "konva";
import { useMoodboardStore } from "../MoodboardStore";
import type { MoodboardAdapter } from "../../adapter";
// Loads natural dimensions for an image URL.
const measure = (url: string): Promise<{ w: number; h: number }> =>
new Promise((resolve, reject) => {
const i = new window.Image();
i.onload = () => resolve({ w: i.naturalWidth, h: i.naturalHeight });
i.onerror = reject;
i.src = url;
});
// Listens for paste events while the moodboard page is mounted. For each
// image entry on the clipboard, immediately drops a blob-URL image onto the
// canvas at the viewport center, then asynchronously uploads it to the
// library so it lives in the user's gallery.
export const usePasteHandler = (
adapter: MoodboardAdapter,
active: boolean,
stageRef: React.RefObject<Konva.Stage | null>,
) => {
const addImage = useMoodboardStore((s) => s.addImage);
useEffect(() => {
if (!active) return undefined;
const handler = async (e: ClipboardEvent) => {
const target = e.target as HTMLElement | null;
if (target && /input|textarea/i.test(target.tagName)) return;
if (target && target.isContentEditable) return;
const items = e.clipboardData?.items;
if (!items) return;
const fileItems: File[] = [];
for (let i = 0; i < items.length; i++) {
const it = items[i];
if (it.kind === "file" && it.type.startsWith("image/")) {
const f = it.getAsFile();
if (f) fileItems.push(f);
}
}
if (fileItems.length === 0) return;
e.preventDefault();
const stage = stageRef.current;
const center = stage
? { x: stage.width() / 2, y: stage.height() / 2 }
: { x: 400, y: 400 };
for (const file of fileItems) {
const blobUrl = URL.createObjectURL(file);
try {
const dims = await measure(blobUrl);
addImage(blobUrl, center, dims.w, dims.h, null);
} catch (err) {
console.error("[Moodboard] paste measure failed", err);
addImage(blobUrl, center, 320, 320, null);
}
// Fire-and-forget: also push to the platform's media library so the
// image persists in the user's gallery.
adapter.uploadImage?.(file).catch((err) => {
console.error("[Moodboard] background upload failed", err);
});
}
};
window.addEventListener("paste", handler);
return () => window.removeEventListener("paste", handler);
}, [adapter, active, addImage, stageRef]);
};
@@ -0,0 +1,141 @@
import { useEffect, useRef } from "react";
import Konva from "konva";
import { useMoodboardStore } from "../MoodboardStore";
import { Vec2 } from "../types";
import { stagePointerPos } from "./useStagePointer";
import { pointInPolygon } from "../layout/pointInPolygon";
import { polygonIntersectsRect } from "../layout/segmentsIntersect";
import { rectCorners, rectFromPoints, rectsIntersect } from "../layout/geometry";
const MIN_DRAG = 4; // screen px before a press becomes a drag
interface DragState {
origin: Vec2;
path: Vec2[];
active: boolean;
mode: "rect" | "lasso" | null;
}
// Selection state machine for rectangle marquee (Select tool) and free-form
// lasso (Lasso tool). Pan gestures (middle-mouse, spacebar+left) are owned
// by `useViewportControls` and stop the event at the capture phase, so
// they never reach Konva's mousedown and therefore never reach this hook.
export const useSelection = (
stageRef: React.RefObject<Konva.Stage | null>,
) => {
const drag = useRef<DragState>({
origin: { x: 0, y: 0 },
path: [],
active: false,
mode: null,
});
const tool = useMoodboardStore((s) => s.tool);
const setMarquee = useMoodboardStore((s) => s.setMarquee);
const setLassoPath = useMoodboardStore((s) => s.setLassoPath);
const setSelection = useMoodboardStore((s) => s.setSelection);
useEffect(() => {
if (tool !== "select" && tool !== "lasso") return;
const stage = stageRef.current;
if (!stage) return;
const handleMouseDown = (e: Konva.KonvaEventObject<MouseEvent>) => {
// Only on bare canvas — clicks on nodes are handled by the node's
// own onMouseDown for single-select.
if (e.target !== stage) return;
// Primary button only; middle-mouse is the pan hook's domain.
if (e.evt.button !== 0) return;
const pos = stagePointerPos(stage);
if (!pos) return;
drag.current = {
origin: pos,
path: tool === "lasso" ? [pos] : [],
active: true,
mode: tool === "lasso" ? "lasso" : "rect",
};
};
const handleMouseMove = () => {
if (!drag.current.active) return;
const pos = stagePointerPos(stage);
if (!pos) return;
if (drag.current.mode === "rect") {
// Suppress marquee until the drag exceeds a threshold so bare clicks
// don't leave a zero-area rect behind.
const dx = pos.x - drag.current.origin.x;
const dy = pos.y - drag.current.origin.y;
if (Math.abs(dx) < MIN_DRAG && Math.abs(dy) < MIN_DRAG) return;
setMarquee(rectFromPoints(drag.current.origin, pos));
} else if (drag.current.mode === "lasso") {
drag.current.path.push(pos);
// Slice copy so subscribers see a new identity.
setLassoPath([...drag.current.path]);
}
};
const commitSelection = () => {
const { nodes, rootOrder } = useMoodboardStore.getState();
const candidates = rootOrder
.map((id) => nodes[id])
.filter((n): n is NonNullable<typeof n> => Boolean(n));
if (drag.current.mode === "rect") {
const rect = useMoodboardStore.getState().transient.marquee;
if (!rect || (rect.width < MIN_DRAG && rect.height < MIN_DRAG)) {
setSelection([]);
} else {
const hit = candidates
.filter((n) =>
rectsIntersect(
{ x: n.x, y: n.y, width: n.width, height: n.height },
rect,
),
)
.map((n) => n.id);
setSelection(hit);
}
setMarquee(null);
} else if (drag.current.mode === "lasso") {
const path = drag.current.path;
if (path.length < 3) {
setSelection([]);
} else {
const closed = [...path, path[0]];
const hit = candidates
.filter((n) => {
const corners = rectCorners(n);
if (corners.some((c) => pointInPolygon(c, closed))) return true;
if (polygonIntersectsRect(closed, n)) return true;
return false;
})
.map((n) => n.id);
setSelection(hit);
}
setLassoPath(null);
}
drag.current.active = false;
drag.current.mode = null;
drag.current.path = [];
};
const handleMouseUp = () => {
if (!drag.current.active) return;
commitSelection();
};
stage.on("mousedown.moodboardSelection", handleMouseDown);
stage.on("mousemove.moodboardSelection", handleMouseMove);
stage.on("mouseup.moodboardSelection", handleMouseUp);
const onWindowUp = () => {
if (drag.current.active) commitSelection();
};
window.addEventListener("mouseup", onWindowUp);
return () => {
stage.off("mousedown.moodboardSelection");
stage.off("mousemove.moodboardSelection");
stage.off("mouseup.moodboardSelection");
window.removeEventListener("mouseup", onWindowUp);
};
}, [stageRef, tool, setMarquee, setLassoPath, setSelection]);
};
@@ -0,0 +1,74 @@
import { useEffect, useState } from "react";
const HOLD_MS = 3000;
// Shows the cheatsheet when the user holds Ctrl (or Cmd on Mac) alone for
// HOLD_MS with no other key pressed. Any other keydown cancels the timer,
// so a real `Ctrl+C` / `Cmd+X` never trips it. Releasing the modifier hides
// the cheatsheet immediately.
export const useShortcutCheatsheet = (): boolean => {
const [visible, setVisible] = useState(false);
useEffect(() => {
const isMac = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
const modKey = isMac ? "Meta" : "Control";
let timer: ReturnType<typeof setTimeout> | null = null;
let modHeld = false;
const clearTimer = () => {
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
};
const hide = () => {
setVisible((v) => (v ? false : v));
};
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === modKey) {
// Ignore auto-repeat keydown while already held.
if (modHeld) return;
modHeld = true;
clearTimer();
timer = setTimeout(() => {
timer = null;
setVisible(true);
}, HOLD_MS);
return;
}
// Any non-modifier key press while the modifier is held cancels the
// planned cheatsheet — the user is running a real combo.
clearTimer();
hide();
};
const onKeyUp = (e: KeyboardEvent) => {
if (e.key === modKey) {
modHeld = false;
clearTimer();
hide();
}
};
// Losing focus while holding the key should also cancel.
const onBlur = () => {
modHeld = false;
clearTimer();
hide();
};
document.addEventListener("keydown", onKeyDown);
document.addEventListener("keyup", onKeyUp);
window.addEventListener("blur", onBlur);
return () => {
document.removeEventListener("keydown", onKeyDown);
document.removeEventListener("keyup", onKeyUp);
window.removeEventListener("blur", onBlur);
clearTimer();
};
}, []);
return visible;
};
@@ -0,0 +1,17 @@
import Konva from "konva";
import { Vec2 } from "../types";
// Converts a raw KonvaEventObject pointer position to stage-local coordinates,
// accounting for stage offset and scale. Phase 1 has no zoom/pan so this is a
// thin wrapper, but encapsulating it keeps interaction hooks consistent.
export const stagePointerPos = (
stage: Konva.Stage | null,
): Vec2 | null => {
if (!stage) return null;
const pointer = stage.getPointerPosition();
if (!pointer) return null;
return {
x: (pointer.x - stage.x()) / stage.scaleX(),
y: (pointer.y - stage.y()) / stage.scaleY(),
};
};
@@ -0,0 +1,153 @@
import { useEffect, useRef } from "react";
import Konva from "konva";
import { useMoodboardStore } from "../MoodboardStore";
import { MoodboardNode } from "../types";
interface NodePatch {
id: string;
patch: Partial<MoodboardNode>;
}
// Owns a single Konva.Transformer ref. Re-attaches its `nodes()` whenever
// the selection OR the root structure changes (e.g. group/ungroup
// reparented the previously-selected ids). On transformend, bakes
// scaleX/scaleY into width/height — and for groups, propagates the scale
// into each child's position/size so the resize sticks (Figma/Excalidraw
// behavior) instead of snapping back when the Group's scale resets to 1.
export const useTransformer = (stageRef: React.RefObject<Konva.Stage | null>) => {
const transformerRef = useRef<Konva.Transformer | null>(null);
const selectedIds = useMoodboardStore((s) => s.selectedIds);
// rootOrder is included so a re-attach happens after group()/ungroup()
// reparents Konva nodes — without it, findOne can return stale results.
const rootOrder = useMoodboardStore((s) => s.rootOrder);
const editingTextId = useMoodboardStore((s) => s.transient.editingTextId);
useEffect(() => {
const stage = stageRef.current;
const transformer = transformerRef.current;
if (!stage || !transformer) return;
// Defer one frame so any pending Konva re-render commits before we
// findOne — otherwise the new Group node may not exist yet.
const raf = requestAnimationFrame(() => {
const selectedNodes: Konva.Node[] = [];
selectedIds.forEach((id) => {
// Plain `#id` lookup — Konva's selector parser strips all spaces, so
// a CSS.escape'd id (e.g. `\38 abc...` for a UUID starting with `8`)
// ends up being compared as `\38abc...` and never matches. Our ids
// are UUIDs (hex + dashes), which are already safe selectors.
const found = stage.findOne(`#${id}`);
if (found) selectedNodes.push(found);
});
transformer.nodes(selectedNodes);
transformer.forceUpdate();
transformer.getLayer()?.batchDraw();
});
return () => cancelAnimationFrame(raf);
}, [selectedIds, rootOrder, stageRef]);
useEffect(() => {
const transformer = transformerRef.current;
if (!transformer) return;
const handleTransformEnd = () => {
const store = useMoodboardStore.getState();
store.pushHistory();
const patches: NodePatch[] = [];
transformer.nodes().forEach((node) => {
const id = node.id();
if (!id) return;
const scaleX = node.scaleX();
const scaleY = node.scaleY();
const storeNode = store.nodes[id];
if (!storeNode) return;
// Groups: bake the scale into each child's position + size so the
// resize survives the Group's own scale reset. Konva's <Group>
// doesn't store width/height in a way that lets the children just
// inherit the scale — we have to push it down explicitly.
if (storeNode.kind === "group") {
const uniformScale = (Math.abs(scaleX) + Math.abs(scaleY)) / 2;
for (const childId of storeNode.childIds) {
const child = store.nodes[childId];
if (!child) continue;
const childPatch: Partial<MoodboardNode> = {
x: child.x * scaleX,
y: child.y * scaleY,
width: child.width * scaleX,
height: child.height * scaleY,
};
// Text inside a scaled group needs fontSize to track the scale,
// otherwise the text stays the same size and overflows / floats.
if (child.kind === "text") {
(childPatch as Partial<MoodboardNode> & { fontSize?: number })
.fontSize = child.fontSize * uniformScale;
}
patches.push({ id: childId, patch: childPatch });
}
}
// Bake the scale into the node's own width/height too, then reset
// the Konva scale so subsequent transforms compose cleanly.
const baseW = node.width() || 1;
const baseH = node.height() || 1;
const newWidth = baseW * scaleX;
const newHeight = baseH * scaleY;
node.scaleX(1);
node.scaleY(1);
node.width(newWidth);
node.height(newHeight);
patches.push({
id,
patch: {
x: node.x(),
y: node.y(),
width: newWidth,
height: newHeight,
rotation: node.rotation(),
},
});
});
// Apply all patches in one store mutation so the tree only renders
// once — keeps the visual transition from "scaled preview" to "baked
// result" tight, with no flicker.
if (patches.length === 0) return;
useMoodboardStore.setState((s) => {
const nodes = { ...s.nodes };
for (const { id, patch } of patches) {
const existing = nodes[id];
if (!existing) continue;
nodes[id] = { ...existing, ...patch } as MoodboardNode;
}
return { nodes };
});
// The Transformer caches its bbox from `getClientRect()` of the
// attached node. For a Konva.Group, that walks children — but the
// children haven't React-re-rendered to their new sizes yet, so a
// sync forceUpdate would read the OLD bounds and the box would snap
// back. Defer one frame so the children commit first.
requestAnimationFrame(() => {
transformer.forceUpdate();
transformer.getLayer()?.batchDraw();
});
};
transformer.on("transformend", handleTransformEnd);
return () => {
transformer.off("transformend", handleTransformEnd);
};
}, []);
// Hide transformer entirely while editing a text node (avoid handle conflicts).
useEffect(() => {
const transformer = transformerRef.current;
if (!transformer) return;
transformer.visible(editingTextId === null);
transformer.getLayer()?.batchDraw();
}, [editingTextId]);
return transformerRef;
};
@@ -0,0 +1,37 @@
import { useEffect } from "react";
import { useMoodboardStore } from "../MoodboardStore";
// Standard editor hotkeys:
// - Cmd+Z (Mac) / Ctrl+Z (Win) → undo
// - Cmd+Shift+Z (Mac), Ctrl+Y or Ctrl+Shift+Z (Win) → redo
export const useUndoRedo = (active: boolean) => {
const undo = useMoodboardStore((s) => s.undo);
const redo = useMoodboardStore((s) => s.redo);
useEffect(() => {
if (!active) return undefined;
const handler = (e: KeyboardEvent) => {
const target = e.target as HTMLElement | null;
if (target && /input|textarea/i.test(target.tagName)) return;
if (target && target.isContentEditable) return;
const isMac = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
const mod = isMac ? e.metaKey : e.ctrlKey;
if (!mod) return;
const k = e.key.toLowerCase();
if (k === "z" && !e.shiftKey) {
e.preventDefault();
undo();
} else if (
(isMac && k === "z" && e.shiftKey) ||
(!isMac && k === "y") ||
(!isMac && k === "z" && e.shiftKey)
) {
e.preventDefault();
redo();
}
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [active, undo, redo]);
};
@@ -0,0 +1,181 @@
import { useEffect, useRef } from "react";
import Konva from "konva";
import { useMoodboardStore } from "../MoodboardStore";
import { Vec2 } from "../types";
import { ZOOM_MAX, ZOOM_MIN, clamp } from "../layout/geometry";
const ZOOM_STEP = 1.1;
const isEditableTarget = (target: EventTarget | null): boolean => {
const el = target as HTMLElement | null;
if (!el) return false;
if (/input|textarea/i.test(el.tagName)) return true;
if (el.isContentEditable) return true;
return false;
};
// Centralizes wheel-zoom and three pan gestures (drag-empty, spacebar+drag,
// middle-click drag) for the moodboard canvas. Reads/writes viewport from
// the store directly — no more ref props, which keeps a future minimap or
// other camera-driving UI (recenter button, Shift+1 fit, etc.) able to
// co-exist with these gestures.
export const useViewportControls = (
stageRef: React.RefObject<Konva.Stage | null>,
) => {
const setZoom = useMoodboardStore((s) => s.setZoom);
const setPan = useMoodboardStore((s) => s.setPan);
const resetViewport = useMoodboardStore((s) => s.resetViewport);
const setIsPanning = useMoodboardStore((s) => s.setIsPanning);
// Track spacebar so any subsequent mousedown becomes a pan, not a select.
const spaceHeld = useRef(false);
// Whether a pan drag is currently in flight, plus its origin in screen px
// and the pan offset captured at drag start.
const panState = useRef<{
active: boolean;
startScreen: Vec2;
startPan: Vec2;
}>({
active: false,
startScreen: { x: 0, y: 0 },
startPan: { x: 0, y: 0 },
});
// Wheel: zoom around cursor.
useEffect(() => {
const stage = stageRef.current;
if (!stage) return undefined;
const container = stage.container();
const handleWheel = (e: WheelEvent) => {
// Ignore zoom if the user is typing in the text-edit overlay or an input.
if (isEditableTarget(document.activeElement)) return;
e.preventDefault();
const { viewport } = useMoodboardStore.getState();
const oldScale = viewport.zoom;
const pointer = stage.getPointerPosition();
if (!pointer) return;
const stagePos = viewport.pan;
const mousePointTo = {
x: (pointer.x - stagePos.x) / oldScale,
y: (pointer.y - stagePos.y) / oldScale,
};
const direction = e.deltaY > 0 ? -1 : 1;
const newScale = clamp(
direction > 0 ? oldScale * ZOOM_STEP : oldScale / ZOOM_STEP,
ZOOM_MIN,
ZOOM_MAX,
);
const newPan = {
x: pointer.x - mousePointTo.x * newScale,
y: pointer.y - mousePointTo.y * newScale,
};
setZoom(newScale);
setPan(newPan);
};
container.addEventListener("wheel", handleWheel, { passive: false });
return () => {
container.removeEventListener("wheel", handleWheel);
};
}, [stageRef, setZoom, setPan]);
// Pan gestures: middle-mouse drag, or spacebar+left-drag. Plain left
// click is reserved for selection (clicking nodes, lasso, future marquee)
// so it never triggers pan, even on empty canvas.
useEffect(() => {
const stage = stageRef.current;
if (!stage) return undefined;
const container = stage.container();
const beginPan = (clientX: number, clientY: number) => {
panState.current = {
active: true,
startScreen: { x: clientX, y: clientY },
startPan: { ...useMoodboardStore.getState().viewport.pan },
};
setIsPanning(true);
container.style.cursor = "grabbing";
};
const endPan = () => {
if (!panState.current.active) return;
panState.current.active = false;
setIsPanning(false);
container.style.cursor = spaceHeld.current ? "grab" : "";
};
const handleMouseDown = (e: MouseEvent) => {
// Middle-mouse always pans. stopPropagation so Konva's internal
// mousedown dispatcher never runs — otherwise `useSelection` would
// start a marquee underneath the pan.
if (e.button === 1) {
e.preventDefault();
e.stopPropagation();
beginPan(e.clientX, e.clientY);
return;
}
// Spacebar + left-click pans (Figma convention).
if (e.button === 0 && spaceHeld.current) {
e.preventDefault();
e.stopPropagation();
beginPan(e.clientX, e.clientY);
}
};
const handleMouseMove = (e: MouseEvent) => {
if (!panState.current.active) return;
const dx = e.clientX - panState.current.startScreen.x;
const dy = e.clientY - panState.current.startScreen.y;
setPan({
x: panState.current.startPan.x + dx,
y: panState.current.startPan.y + dy,
});
};
const handleMouseUp = () => endPan();
const handleKeyDown = (e: KeyboardEvent) => {
if (isEditableTarget(e.target)) return;
if (e.code === "Space" && !spaceHeld.current) {
spaceHeld.current = true;
if (!panState.current.active) container.style.cursor = "grab";
// Prevent page from scrolling on spacebar.
e.preventDefault();
}
// Cmd/Ctrl+0 → reset zoom + pan.
const isMac = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
const mod = isMac ? e.metaKey : e.ctrlKey;
if (mod && e.key === "0") {
e.preventDefault();
resetViewport();
}
};
const handleKeyUp = (e: KeyboardEvent) => {
if (e.code === "Space") {
spaceHeld.current = false;
if (!panState.current.active) container.style.cursor = "";
}
};
// Capture-phase so we run BEFORE Konva's internal mousedown dispatch.
// stopPropagation inside pan-claim branches then prevents Konva from
// forwarding the event to node/stage handlers.
container.addEventListener("mousedown", handleMouseDown, { capture: true });
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleMouseUp);
document.addEventListener("keydown", handleKeyDown);
document.addEventListener("keyup", handleKeyUp);
return () => {
container.removeEventListener("mousedown", handleMouseDown, {
capture: true,
} as EventListenerOptions);
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp);
document.removeEventListener("keydown", handleKeyDown);
document.removeEventListener("keyup", handleKeyUp);
container.style.cursor = "";
};
}, [stageRef, setPan, resetViewport, setIsPanning]);
};
@@ -0,0 +1,51 @@
import {
clusterByProximity,
defaultEpsForNodes,
} from "./clusterProximity";
import { MoodboardNode } from "../types";
const node = (id: string, x: number, y: number, size = 50): MoodboardNode => ({
id,
kind: "image",
parentId: null,
x,
y,
width: size,
height: size,
rotation: 0,
zIndex: 0,
src: "",
mediaId: null,
naturalW: size,
naturalH: size,
});
describe("clusterByProximity", () => {
it("returns no clusters for empty input", () => {
expect(clusterByProximity([])).toEqual([]);
});
it("groups close neighbors and separates far ones", () => {
const nodes = [
node("a", 0, 0, 50),
node("b", 30, 30, 50),
node("c", 1000, 1000, 50),
];
const clusters = clusterByProximity(nodes, 100);
expect(clusters.length).toBe(2);
const sizes = clusters.map((c) => c.length).sort();
expect(sizes).toEqual([1, 2]);
});
it("treats every input as a singleton when eps is tiny", () => {
const nodes = [node("a", 0, 0), node("b", 5, 5), node("c", 10, 10)];
const clusters = clusterByProximity(nodes, 0.0001);
expect(clusters.length).toBe(3);
expect(clusters.every((c) => c.length === 1)).toBe(true);
});
it("derives a positive default eps from median size", () => {
const eps = defaultEpsForNodes([node("a", 0, 0, 100), node("b", 0, 0, 200)]);
expect(eps).toBeGreaterThan(0);
});
});
@@ -0,0 +1,64 @@
import { MoodboardNode, Vec2 } from "../types";
const center = (n: MoodboardNode): Vec2 => ({
x: n.x + n.width / 2,
y: n.y + n.height / 2,
});
const median = (xs: number[]): number => {
if (xs.length === 0) return 0;
const sorted = [...xs].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 === 0
? (sorted[mid - 1] + sorted[mid]) / 2
: sorted[mid];
};
export const defaultEpsForNodes = (nodes: MoodboardNode[]): number => {
if (nodes.length === 0) return 0;
const dims = nodes.map((n) => Math.max(n.width, n.height));
return median(dims) * 1.5;
};
// DBSCAN with minPts = 1 → equivalent to connected-components on the
// eps-graph. Every input node ends up in some cluster (no noise points).
export const clusterByProximity = (
nodes: MoodboardNode[],
eps?: number,
): MoodboardNode[][] => {
if (nodes.length === 0) return [];
const e = eps ?? defaultEpsForNodes(nodes);
const e2 = e * e;
const centers = nodes.map(center);
const visited = new Array(nodes.length).fill(false);
const clusters: MoodboardNode[][] = [];
const neighbors = (i: number): number[] => {
const ci = centers[i];
const out: number[] = [];
for (let j = 0; j < centers.length; j++) {
if (j === i) continue;
const dx = centers[j].x - ci.x;
const dy = centers[j].y - ci.y;
if (dx * dx + dy * dy <= e2) out.push(j);
}
return out;
};
for (let i = 0; i < nodes.length; i++) {
if (visited[i]) continue;
const cluster: MoodboardNode[] = [];
const queue = [i];
while (queue.length) {
const idx = queue.shift()!;
if (visited[idx]) continue;
visited[idx] = true;
cluster.push(nodes[idx]);
for (const n of neighbors(idx)) {
if (!visited[n]) queue.push(n);
}
}
clusters.push(cluster);
}
return clusters;
};
@@ -0,0 +1,23 @@
import { MoodboardNode } from "../types";
export interface LayoutPatch {
id: string;
patch: Partial<MoodboardNode>;
}
export const computeFitToGridPatches = (
nodes: MoodboardNode[],
gridSpacing: number,
): LayoutPatch[] => {
if (gridSpacing <= 0) return [];
const patches: LayoutPatch[] = [];
for (const n of nodes) {
if (n.parentId !== null) continue; // only snap top-level nodes
const snappedX = Math.round(n.x / gridSpacing) * gridSpacing;
const snappedY = Math.round(n.y / gridSpacing) * gridSpacing;
if (snappedX !== n.x || snappedY !== n.y) {
patches.push({ id: n.id, patch: { x: snappedX, y: snappedY } });
}
}
return patches;
};
@@ -0,0 +1,174 @@
import { MoodboardNode, Rect, Vec2 } from "../types";
export interface Viewport {
zoom: number;
pan: Vec2;
}
export interface CanvasSize {
width: number;
height: number;
}
// Global zoom clamps — shared by gesture handlers, fit-to-content, and any
// future minimap so the camera can never land outside a consistent range.
export const ZOOM_MIN = 0.1;
export const ZOOM_MAX = 8;
// ---------- scalar + rect primitives ----------
export const clamp = (v: number, lo: number, hi: number): number =>
Math.max(lo, Math.min(hi, v));
export const rectFromPoints = (a: Vec2, b: Vec2): Rect => ({
x: Math.min(a.x, b.x),
y: Math.min(a.y, b.y),
width: Math.abs(b.x - a.x),
height: Math.abs(b.y - a.y),
});
export const rectCorners = (r: {
x: number;
y: number;
width: number;
height: number;
}): Vec2[] => [
{ x: r.x, y: r.y },
{ x: r.x + r.width, y: r.y },
{ x: r.x + r.width, y: r.y + r.height },
{ x: r.x, y: r.y + r.height },
];
export const rectsIntersect = (a: Rect, b: Rect): boolean =>
!(
a.x + a.width < b.x ||
a.x > b.x + b.width ||
a.y + a.height < b.y ||
a.y > b.y + b.height
);
// ---------- AABB over a set of nodes ----------
export const computeAABB = (nodes: MoodboardNode[]): Rect | null => {
if (nodes.length === 0) return null;
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const n of nodes) {
minX = Math.min(minX, n.x);
minY = Math.min(minY, n.y);
maxX = Math.max(maxX, n.x + n.width);
maxY = Math.max(maxY, n.y + n.height);
}
if (!Number.isFinite(minX)) return null;
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
};
export const computeAABBById = (
nodes: Record<string, MoodboardNode>,
ids: string[],
): Rect | null => {
const picked: MoodboardNode[] = [];
for (const id of ids) {
const n = nodes[id];
if (n) picked.push(n);
}
return computeAABB(picked);
};
// AABB of every top-level node. Treats rotated nodes as their own
// (x, y, w, h) rect for simplicity — fine for "is content on screen?"
// checks and minimap overviews.
export const computeContentBounds = (
nodes: Record<string, MoodboardNode>,
rootOrder: string[],
): Rect | null => computeAABBById(nodes, rootOrder);
// ---------- viewport math ----------
// Visible stage-coord rect = what the camera currently sees.
// canvas_px = pan + stage_coord * zoom, so the visible stage region maps to
// (-pan / zoom) .. (-pan / zoom + canvas / zoom).
export const computeVisibleRect = (
viewport: Viewport,
canvas: CanvasSize,
): Rect => ({
x: -viewport.pan.x / viewport.zoom,
y: -viewport.pan.y / viewport.zoom,
width: canvas.width / viewport.zoom,
height: canvas.height / viewport.zoom,
});
// True if any top-level node's AABB intersects the visible viewport.
// Used by the recenter indicator; a minimap can use this to style the
// viewport rectangle differently when off-content.
export const anyContentVisible = (
nodes: Record<string, MoodboardNode>,
rootOrder: string[],
viewport: Viewport,
canvas: CanvasSize,
): boolean => {
const visible = computeVisibleRect(viewport, canvas);
for (const id of rootOrder) {
const n = nodes[id];
if (!n) continue;
if (
rectsIntersect(
{ x: n.x, y: n.y, width: n.width, height: n.height },
visible,
)
) {
return true;
}
}
return false;
};
// Returns the viewport that frames `bounds` inside `canvas` with `padding`
// screen pixels of margin on all sides, clamped to [ZOOM_MIN, ZOOM_MAX].
// Degenerate bounds (zero width/height) fall back to zoom=1 centered on
// the point.
export const fitViewportToBounds = (
bounds: Rect,
canvas: CanvasSize,
padding: number,
): Viewport => {
if (bounds.width <= 0 || bounds.height <= 0) {
return {
zoom: 1,
pan: {
x: canvas.width / 2 - bounds.x,
y: canvas.height / 2 - bounds.y,
},
};
}
const availW = Math.max(canvas.width - 2 * padding, 1);
const availH = Math.max(canvas.height - 2 * padding, 1);
const zoom = clamp(
Math.min(availW / bounds.width, availH / bounds.height),
ZOOM_MIN,
ZOOM_MAX,
);
const centerX = bounds.x + bounds.width / 2;
const centerY = bounds.y + bounds.height / 2;
return {
zoom,
pan: {
x: canvas.width / 2 - centerX * zoom,
y: canvas.height / 2 - centerY * zoom,
},
};
};
// Returns the pan that centers the viewport on a given stage-coord point
// at the current zoom. Handy for minimap drag-to-navigate: stage point
// under the minimap cursor → pan such that it lands in the canvas center.
export const panToCenterOn = (
stagePoint: Vec2,
zoom: number,
canvas: CanvasSize,
): Vec2 => ({
x: canvas.width / 2 - stagePoint.x * zoom,
y: canvas.height / 2 - stagePoint.y * zoom,
});
@@ -0,0 +1,67 @@
import { computeAABB } from "./geometry";
import { computePackPatches } from "./packCollage";
import { MoodboardNode } from "../types";
const img = (
id: string,
x: number,
y: number,
width: number,
height: number,
): MoodboardNode => ({
id,
kind: "image",
parentId: null,
x,
y,
width,
height,
rotation: 0,
zIndex: 0,
src: "",
mediaId: null,
naturalW: width,
naturalH: height,
});
describe("packCollage", () => {
it("returns no patches for empty input", () => {
const patches = computePackPatches([], { x: 0, y: 0, width: 800, height: 600 });
expect(patches).toEqual([]);
});
it("packs three equal-aspect images into a single row that fills width with gaps", () => {
const nodes = [
img("a", 0, 0, 200, 200),
img("b", 0, 0, 200, 200),
img("c", 0, 0, 200, 200),
];
const bounds = { x: 0, y: 0, width: 600, height: 400 };
const patches = computePackPatches(nodes, bounds, { rowHeight: 180, gap: 8 });
expect(patches.length).toBe(3);
// All in one row → same y, identical heights, total widths + 2 gaps == bounds.width.
const ys = patches.map((p) => p.patch.y!);
expect(ys[0]).toBe(0);
expect(ys.every((y) => y === ys[0])).toBe(true);
const widths = patches.map((p) => p.patch.width!);
const totalW = widths.reduce((a, b) => a + b, 0) + 2 * 8;
expect(totalW).toBeCloseTo(600, 1);
const heights = patches.map((p) => p.patch.height!);
expect(heights.every((h) => Math.abs(h - heights[0]) < 1e-6)).toBe(true);
});
it("breaks into multiple rows when aspects sum exceeds target", () => {
const wide = (id: string) => img(id, 0, 0, 400, 100); // aspect 4
const nodes = [wide("a"), wide("b"), wide("c"), wide("d")];
const bounds = { x: 0, y: 0, width: 400, height: 600 };
const patches = computePackPatches(nodes, bounds, { rowHeight: 100, gap: 0 });
const distinctYs = new Set(patches.map((p) => p.patch.y!));
expect(distinctYs.size).toBeGreaterThan(1);
});
it("computeAABB returns null on empty and a tight box on inputs", () => {
expect(computeAABB([])).toBeNull();
const aabb = computeAABB([img("a", 10, 20, 100, 50), img("b", 60, 0, 80, 200)]);
expect(aabb).toEqual({ x: 10, y: 0, width: 130, height: 200 });
});
});
@@ -0,0 +1,72 @@
import { MoodboardNode, Rect } from "../types";
import { LayoutPatch } from "./fitToGrid";
export interface PackOptions {
rowHeight: number;
gap: number;
}
const DEFAULTS: PackOptions = { rowHeight: 180, gap: 8 };
export const computePackPatches = (
nodes: MoodboardNode[],
bounds: Rect,
opts: Partial<PackOptions> = {},
): LayoutPatch[] => {
const { rowHeight, gap } = { ...DEFAULTS, ...opts };
if (nodes.length === 0 || bounds.width <= 0) return [];
const aspects = nodes.map((n) =>
n.height > 0 ? n.width / n.height : 1,
);
// Greedy row break: stop adding when summed aspect (at target rowHeight)
// would overflow the bounds width including per-item gaps.
type Row = { indices: number[]; aspectSum: number };
const rows: Row[] = [];
let current: Row = { indices: [], aspectSum: 0 };
const targetAspect = bounds.width / rowHeight;
for (let i = 0; i < nodes.length; i++) {
const a = aspects[i];
const projected = current.aspectSum + a;
const projectedGapAspect =
(current.indices.length * gap) / rowHeight;
if (
current.indices.length > 0 &&
projected + projectedGapAspect >= targetAspect
) {
rows.push(current);
current = { indices: [], aspectSum: 0 };
}
current.indices.push(i);
current.aspectSum += a;
}
if (current.indices.length > 0) rows.push(current);
const patches: LayoutPatch[] = [];
let y = bounds.y;
rows.forEach((row, rowIdx) => {
const isLast = rowIdx === rows.length - 1;
const gapsTotal = (row.indices.length - 1) * gap;
const availW = Math.max(1, bounds.width - gapsTotal);
let rowH = availW / row.aspectSum;
if (isLast && rowH > rowHeight * 1.5) {
rowH = rowHeight;
}
let x = bounds.x;
for (const i of row.indices) {
const w = aspects[i] * rowH;
patches.push({
id: nodes[i].id,
patch: { x, y, width: w, height: rowH, rotation: 0 },
});
x += w + gap;
}
y += rowH + gap;
});
return patches;
};
@@ -0,0 +1,54 @@
import { pointInPolygon } from "./pointInPolygon";
import {
segmentsIntersect,
polygonIntersectsRect,
} from "./segmentsIntersect";
const square = [
{ x: 0, y: 0 },
{ x: 10, y: 0 },
{ x: 10, y: 10 },
{ x: 0, y: 10 },
];
describe("pointInPolygon", () => {
it("returns true for points strictly inside", () => {
expect(pointInPolygon({ x: 5, y: 5 }, square)).toBe(true);
});
it("returns false for points outside", () => {
expect(pointInPolygon({ x: 20, y: 5 }, square)).toBe(false);
});
it("returns false for degenerate polygons", () => {
expect(pointInPolygon({ x: 0, y: 0 }, [{ x: 0, y: 0 }, { x: 1, y: 1 }])).toBe(false);
});
});
describe("segmentsIntersect", () => {
it("detects crossing segments", () => {
expect(
segmentsIntersect({ x: 0, y: 0 }, { x: 10, y: 10 }, { x: 0, y: 10 }, { x: 10, y: 0 }),
).toBe(true);
});
it("rejects non-crossing segments", () => {
expect(
segmentsIntersect({ x: 0, y: 0 }, { x: 1, y: 1 }, { x: 5, y: 5 }, { x: 6, y: 6 }),
).toBe(false);
});
});
describe("polygonIntersectsRect", () => {
it("returns true when an edge crosses a rect side", () => {
const polyline = [
{ x: -5, y: 5 },
{ x: 15, y: 5 },
];
expect(polygonIntersectsRect(polyline, { x: 0, y: 0, width: 10, height: 10 })).toBe(true);
});
it("returns false when polygon is entirely outside and does not touch", () => {
const polyline = [
{ x: 100, y: 100 },
{ x: 200, y: 100 },
];
expect(polygonIntersectsRect(polyline, { x: 0, y: 0, width: 10, height: 10 })).toBe(false);
});
});
@@ -0,0 +1,17 @@
import { Vec2 } from "../types";
export const pointInPolygon = (point: Vec2, polygon: Vec2[]): boolean => {
if (polygon.length < 3) return false;
let inside = false;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const xi = polygon[i].x;
const yi = polygon[i].y;
const xj = polygon[j].x;
const yj = polygon[j].y;
const intersect =
yi > point.y !== yj > point.y &&
point.x < ((xj - xi) * (point.y - yi)) / (yj - yi + Number.EPSILON) + xi;
if (intersect) inside = !inside;
}
return inside;
};
@@ -0,0 +1,46 @@
import { Vec2 } from "../types";
const cross = (ax: number, ay: number, bx: number, by: number) =>
ax * by - ay * bx;
export const segmentsIntersect = (
p1: Vec2,
p2: Vec2,
p3: Vec2,
p4: Vec2,
): boolean => {
const d1x = p2.x - p1.x;
const d1y = p2.y - p1.y;
const d2x = p4.x - p3.x;
const d2y = p4.y - p3.y;
const denom = cross(d1x, d1y, d2x, d2y);
if (denom === 0) return false; // parallel or collinear
const dx = p3.x - p1.x;
const dy = p3.y - p1.y;
const t = cross(dx, dy, d2x, d2y) / denom;
const u = cross(dx, dy, d1x, d1y) / denom;
return t >= 0 && t <= 1 && u >= 0 && u <= 1;
};
export const polygonIntersectsRect = (
polygon: Vec2[],
rect: { x: number; y: number; width: number; height: number },
): boolean => {
if (polygon.length < 2) return false;
const corners: Vec2[] = [
{ x: rect.x, y: rect.y },
{ x: rect.x + rect.width, y: rect.y },
{ x: rect.x + rect.width, y: rect.y + rect.height },
{ x: rect.x, y: rect.y + rect.height },
];
for (let i = 0; i < polygon.length; i++) {
const a = polygon[i];
const b = polygon[(i + 1) % polygon.length];
for (let j = 0; j < 4; j++) {
const c = corners[j];
const d = corners[(j + 1) % 4];
if (segmentsIntersect(a, b, c, d)) return true;
}
}
return false;
};
@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import { SnapBox, computeSnap } from "./snapping";
const box = (x: number, y: number, w = 100, h = 100): SnapBox => ({
x,
y,
width: w,
height: h,
});
describe("computeSnap", () => {
it("snaps a near-aligned left edge onto another node's left edge", () => {
const moving = box(103, 400);
const result = computeSnap(moving, [box(100, 0)], 6, null);
expect(result.x).toBe(100);
expect(result.guides.some((g) => g.axis === "x" && g.pos === 100)).toBe(true);
});
it("snaps centers together", () => {
// Other center-x = 150. Moving (w=100) center aligns when x = 100.
const moving = box(98, 400);
const result = computeSnap(moving, [box(100, 0)], 6, null);
expect(result.x).toBe(100);
});
it("leaves position untouched beyond the threshold", () => {
const moving = box(120, 400);
const result = computeSnap(moving, [box(100, 0)], 6, null);
expect(result.x).toBe(120);
expect(result.guides).toHaveLength(0);
});
it("falls back to the grid when no node aligns", () => {
const moving = box(33, 65);
const result = computeSnap(moving, [], 6, 32);
expect(result.x).toBe(32);
expect(result.y).toBe(64);
});
it("prefers node alignment over the grid on a given axis", () => {
// x has a node edge at 100 (moving from 97) within threshold; grid=32 would
// pull to 96 — node alignment must win.
const moving = box(97, 400);
const result = computeSnap(moving, [box(100, 0)], 6, 32);
expect(result.x).toBe(100);
});
it("emits a guide spanning both boxes on the shared axis", () => {
const moving = box(100, 300);
const result = computeSnap(moving, [box(100, 0)], 6, null);
const guide = result.guides.find((g) => g.axis === "x");
expect(guide).toBeDefined();
expect(guide?.from).toBe(0);
expect(guide?.to).toBe(400);
});
});
@@ -0,0 +1,110 @@
// Drag snapping for the freeform canvas. A moving node's near/center/far edges
// align to the same edges of nearby nodes (and, as a fallback, the background
// grid). Pure + deterministic so it can be unit-tested without Konva.
export interface SnapBox {
x: number;
y: number;
width: number;
height: number;
}
export interface SnapGuide {
axis: "x" | "y";
// Stage coordinate of the guide line on its axis.
pos: number;
// Cross-axis extent the guide spans (so it visually connects the two nodes).
from: number;
to: number;
}
export interface SnapResult {
x: number;
y: number;
guides: SnapGuide[];
}
// near edge, center, far edge — the three alignment anchors per axis.
const xAnchors = (b: SnapBox): number[] => [b.x, b.x + b.width / 2, b.x + b.width];
const yAnchors = (b: SnapBox): number[] => [b.y, b.y + b.height / 2, b.y + b.height];
// Snaps `moving` against `others` within `threshold` (stage units). `grid`, when
// set, snaps the top-left to the grid on any axis that found no node alignment.
export const computeSnap = (
moving: SnapBox,
others: SnapBox[],
threshold: number,
grid: number | null,
): SnapResult => {
let bestDX = threshold + 1;
let snapX: number | null = null;
let alignX = 0;
let otherX: SnapBox | null = null;
let bestDY = threshold + 1;
let snapY: number | null = null;
let alignY = 0;
let otherY: SnapBox | null = null;
const mxs = xAnchors(moving);
const mys = yAnchors(moving);
for (const o of others) {
for (const ox of xAnchors(o)) {
for (const mx of mxs) {
const d = Math.abs(ox - mx);
if (d < bestDX) {
bestDX = d;
snapX = moving.x + (ox - mx);
alignX = ox;
otherX = o;
}
}
}
for (const oy of yAnchors(o)) {
for (const my of mys) {
const d = Math.abs(oy - my);
if (d < bestDY) {
bestDY = d;
snapY = moving.y + (oy - my);
alignY = oy;
otherY = o;
}
}
}
}
// Grid fallback per axis (no guide line — the dotted grid is the reference).
if (snapX === null && grid) {
const gx = Math.round(moving.x / grid) * grid;
if (Math.abs(gx - moving.x) <= threshold) snapX = gx;
}
if (snapY === null && grid) {
const gy = Math.round(moving.y / grid) * grid;
if (Math.abs(gy - moving.y) <= threshold) snapY = gy;
}
const finalX = snapX ?? moving.x;
const finalY = snapY ?? moving.y;
const snapped: SnapBox = { ...moving, x: finalX, y: finalY };
const guides: SnapGuide[] = [];
if (otherX) {
guides.push({
axis: "x",
pos: alignX,
from: Math.min(snapped.y, otherX.y),
to: Math.max(snapped.y + snapped.height, otherX.y + otherX.height),
});
}
if (otherY) {
guides.push({
axis: "y",
pos: alignY,
from: Math.min(snapped.x, otherY.x),
to: Math.max(snapped.x + snapped.width, otherY.x + otherY.width),
});
}
return { x: finalX, y: finalY, guides };
};
@@ -0,0 +1,112 @@
import { memo } from "react";
import Konva from "konva";
import { Group, Rect } from "react-konva";
import { useShallow } from "zustand/react/shallow";
import { GroupNode as GroupNodeData, MoodboardNode } from "../types";
import { useMoodboardStore } from "../MoodboardStore";
import { useNodeDrag } from "../interactions/useNodeDrag";
import { ImageNode } from "./ImageNode";
import { TextNode } from "./TextNode";
interface Props {
node: GroupNodeData;
selected: boolean;
onSelect: (
id: string,
e: Konva.KonvaEventObject<MouseEvent | TouchEvent>,
) => void;
}
// Shared no-op for child renders: the group absorbs all clicks, so child
// nodes never fire `onSelect`. Using a module-level constant keeps the
// reference stable across renders, which lets the memoized child nodes
// skip work when nothing else about them changed.
const NO_OP_SELECT = () => {};
const GroupNodeInner = ({ node, selected, onSelect }: Props) => {
const drag = useNodeDrag(node.id);
// Subscribe only to this group's own children, with a shallow compare on
// the returned array. Avoids re-rendering every group whenever any node
// anywhere in the board updates (which would otherwise mutate the whole
// `nodes` map reference).
const children = useMoodboardStore(
useShallow((s) =>
node.childIds
.map((id) => s.nodes[id])
.filter((n): n is MoodboardNode => Boolean(n)),
),
);
const handleSelect = (
e: Konva.KonvaEventObject<MouseEvent | TouchEvent>,
) => onSelect(node.id, e);
return (
<Group
id={node.id}
x={node.x}
y={node.y}
width={node.width}
height={node.height}
rotation={node.rotation}
draggable
onMouseDown={handleSelect}
onTouchStart={handleSelect}
onDragStart={drag.onDragStart}
onDragMove={drag.onDragMove}
onDragEnd={drag.onDragEnd}
>
{/* Transparent hit/transform target so the Transformer has stable bounds. */}
<Rect
x={0}
y={0}
width={node.width}
height={node.height}
fill="transparent"
stroke={selected ? "#3b82f6" : "transparent"}
strokeWidth={1}
dash={selected ? [4, 4] : undefined}
listening={false}
/>
{children.map((child) => {
if (child.kind === "image") {
return (
<ImageNode
key={child.id}
node={child}
draggable={false}
selected={false}
onSelect={NO_OP_SELECT}
/>
);
}
if (child.kind === "text") {
return (
<TextNode
key={child.id}
node={child}
draggable={false}
selected={false}
onSelect={NO_OP_SELECT}
/>
);
}
// Nested groups not exercised in phase 1, but render recursively for future.
if (child.kind === "group") {
return (
<GroupNode
key={child.id}
node={child}
selected={false}
onSelect={NO_OP_SELECT}
/>
);
}
return null;
})}
</Group>
);
};
export const GroupNode = memo(GroupNodeInner);
@@ -0,0 +1,74 @@
import { memo, useEffect, useRef, useState } from "react";
import Konva from "konva";
import { Image as KonvaImage, Rect } from "react-konva";
import { ImageNode as ImageNodeData } from "../types";
import { useNodeDrag } from "../interactions/useNodeDrag";
interface Props {
node: ImageNodeData;
draggable: boolean;
selected: boolean;
// Receives the node id so parents can pass one stable callback to every
// sibling rather than allocating a per-item closure on each render.
onSelect: (
id: string,
e: Konva.KonvaEventObject<MouseEvent | TouchEvent>,
) => void;
}
const ImageNodeInner = ({ node, draggable, selected, onSelect }: Props) => {
const drag = useNodeDrag(node.id);
const ref = useRef<Konva.Image | null>(null);
const [img, setImg] = useState<HTMLImageElement | null>(null);
useEffect(() => {
const i = new window.Image();
i.crossOrigin = "anonymous";
i.onload = () => setImg(i);
i.src = node.src;
return () => {
i.onload = null;
};
}, [node.src]);
const handleSelect = (
e: Konva.KonvaEventObject<MouseEvent | TouchEvent>,
) => onSelect(node.id, e);
return (
<>
{!img && (
<Rect
x={node.x}
y={node.y}
width={node.width}
height={node.height}
rotation={node.rotation}
fill="#222"
stroke={selected ? "#3b82f6" : "transparent"}
strokeWidth={1}
/>
)}
<KonvaImage
id={node.id}
ref={(n) => {
ref.current = n;
}}
image={img ?? undefined}
x={node.x}
y={node.y}
width={node.width}
height={node.height}
rotation={node.rotation}
draggable={draggable}
onMouseDown={handleSelect}
onTouchStart={handleSelect}
onDragStart={drag.onDragStart}
onDragMove={drag.onDragMove}
onDragEnd={drag.onDragEnd}
/>
</>
);
};
export const ImageNode = memo(ImageNodeInner);
@@ -0,0 +1,57 @@
import { memo } from "react";
import Konva from "konva";
import { Text as KonvaText } from "react-konva";
import { TextNode as TextNodeData } from "../types";
import { useMoodboardStore } from "../MoodboardStore";
import { useNodeDrag } from "../interactions/useNodeDrag";
interface Props {
node: TextNodeData;
draggable: boolean;
selected: boolean;
onSelect: (
id: string,
e: Konva.KonvaEventObject<MouseEvent | TouchEvent>,
) => void;
}
const TextNodeInner = ({ node, draggable, selected, onSelect }: Props) => {
const drag = useNodeDrag(node.id);
const setEditingText = useMoodboardStore((s) => s.setEditingText);
// Selector compares a single id — only the TextNode whose isEditing
// actually flips will re-render when someone enters/exits text edit.
const isEditing = useMoodboardStore(
(s) => s.transient.editingTextId === node.id,
);
const handleSelect = (
e: Konva.KonvaEventObject<MouseEvent | TouchEvent>,
) => onSelect(node.id, e);
return (
<KonvaText
id={node.id}
x={node.x}
y={node.y}
width={node.width}
height={node.height}
rotation={node.rotation}
text={isEditing ? "" : node.text || "Note"}
fontSize={node.fontSize}
fill={node.color}
padding={6}
draggable={draggable}
onMouseDown={handleSelect}
onTouchStart={handleSelect}
onDblClick={() => setEditingText(node.id)}
onDblTap={() => setEditingText(node.id)}
onDragStart={drag.onDragStart}
onDragMove={drag.onDragMove}
onDragEnd={drag.onDragEnd}
stroke={selected ? "#3b82f6" : undefined}
strokeWidth={selected ? 0.5 : 0}
/>
);
};
export const TextNode = memo(TextNodeInner);
@@ -0,0 +1,105 @@
import { memo, useEffect, useRef, useState } from "react";
import Konva from "konva";
import { Image as KonvaImage, Rect } from "react-konva";
import { VideoNode as VideoNodeData } from "../types";
import { useNodeDrag } from "../interactions/useNodeDrag";
interface Props {
node: VideoNodeData;
draggable: boolean;
selected: boolean;
// Receives the node id so parents can pass one stable callback to every
// sibling rather than allocating a per-item closure on each render.
onSelect: (
id: string,
e: Konva.KonvaEventObject<MouseEvent | TouchEvent>,
) => void;
}
// Konva can't render a <video> directly — it paints whatever HTMLImageElement
// or HTMLVideoElement you hand its Image node, but only on draw. So we own the
// element imperatively and run a Konva.Animation tied to the node's layer to
// repaint each frame while the video plays. This mirrors ImageNode otherwise.
const VideoNodeInner = ({ node, draggable, selected, onSelect }: Props) => {
const drag = useNodeDrag(node.id);
const ref = useRef<Konva.Image | null>(null);
const [video, setVideo] = useState<HTMLVideoElement | null>(null);
// Build the element once per src; autoplay only succeeds while muted, which
// is why addVideo() forces muted+loop+autoplay to travel together.
useEffect(() => {
const el = document.createElement("video");
el.crossOrigin = "anonymous";
el.src = node.src;
el.muted = node.muted;
el.loop = node.loop;
el.autoplay = node.autoplay;
el.playsInline = true;
el.preload = "metadata";
const onLoaded = () => setVideo(el);
el.addEventListener("loadedmetadata", onLoaded);
if (node.autoplay) void el.play().catch(() => undefined);
return () => {
el.removeEventListener("loadedmetadata", onLoaded);
el.pause();
el.removeAttribute("src");
el.load();
setVideo(null);
};
}, [node.src, node.muted, node.loop, node.autoplay]);
// Drive repaints off the layer the image node lives on, for as long as the
// element is actually playing. The animation auto-stops on unmount/cleanup.
useEffect(() => {
const imageNode = ref.current;
if (!video || !imageNode) return undefined;
const layer = imageNode.getLayer();
if (!layer) return undefined;
const anim = new Konva.Animation(() => undefined, layer);
anim.start();
return () => {
anim.stop();
};
}, [video]);
const handleSelect = (
e: Konva.KonvaEventObject<MouseEvent | TouchEvent>,
) => onSelect(node.id, e);
return (
<>
{!video && (
<Rect
x={node.x}
y={node.y}
width={node.width}
height={node.height}
rotation={node.rotation}
fill="#222"
stroke={selected ? "#3b82f6" : "transparent"}
strokeWidth={1}
/>
)}
<KonvaImage
id={node.id}
ref={(n) => {
ref.current = n;
}}
image={video ?? undefined}
x={node.x}
y={node.y}
width={node.width}
height={node.height}
rotation={node.rotation}
draggable={draggable}
onMouseDown={handleSelect}
onTouchStart={handleSelect}
onDragStart={drag.onDragStart}
onDragMove={drag.onDragMove}
onDragEnd={drag.onDragEnd}
/>
</>
);
};
export const VideoNode = memo(VideoNodeInner);
@@ -0,0 +1,32 @@
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCompress } from "@fortawesome/pro-solid-svg-icons";
import { useMoodboardStore } from "../MoodboardStore";
import { anyContentVisible } from "../layout/geometry";
// Small pill button in the bottom-right that appears when the user has
// panned or zoomed so far that no content is on screen. Clicking it calls
// `fitToContent` to frame all nodes back into view. Hidden on an empty
// board (nothing to recenter on) and whenever any node intersects the
// visible viewport.
export const RecenterIndicator = () => {
const nodes = useMoodboardStore((s) => s.nodes);
const rootOrder = useMoodboardStore((s) => s.rootOrder);
const viewport = useMoodboardStore((s) => s.viewport);
const canvasSize = useMoodboardStore((s) => s.canvasSize);
const fitToContent = useMoodboardStore((s) => s.fitToContent);
if (rootOrder.length === 0) return null;
if (anyContentVisible(nodes, rootOrder, viewport, canvasSize)) return null;
return (
<button
type="button"
onClick={() => fitToContent()}
title="Zoom to fit (Shift+1)"
className="pointer-events-auto absolute bottom-4 right-4 z-20 flex items-center gap-2 rounded-full border border-white/20 bg-black/70 px-3 py-2 text-sm text-white/90 shadow-lg backdrop-blur-sm transition-colors hover:bg-black/80"
>
<FontAwesomeIcon icon={faCompress} />
<span>Content off-screen — recenter</span>
</button>
);
};
@@ -0,0 +1,76 @@
import { SHORTCUTS, ShortcutRow } from "./shortcuts";
interface Props {
visible: boolean;
}
const SECTION_ORDER: ShortcutRow["section"][] = [
"Tools",
"Selection",
"Editing",
"Viewport",
"History",
];
// Translucent, non-interactive overlay that lists all keyboard shortcuts.
// Shown while the user holds Ctrl/Cmd alone for 3 s (see
// `useShortcutCheatsheet.ts`). `pointer-events: none` so it never steals
// focus — the user can keep panning / zooming / typing while it's visible.
export const ShortcutCheatsheet = ({ visible }: Props) => {
if (!visible) return null;
// Group rows by section, preserving declaration order within each.
const bySection = new Map<ShortcutRow["section"], ShortcutRow[]>();
for (const row of SHORTCUTS) {
const arr = bySection.get(row.section) ?? [];
arr.push(row);
bySection.set(row.section, arr);
}
return (
<div
aria-hidden
className="pointer-events-none absolute inset-0 z-30 flex items-center justify-center"
>
<div className="max-h-[80%] w-[min(640px,90%)] overflow-auto rounded-xl border border-white/15 bg-black/70 p-6 text-white/90 shadow-2xl backdrop-blur-sm">
<div className="mb-3 text-xs uppercase tracking-widest text-white/50">
Keyboard shortcuts
</div>
<div className="grid grid-cols-1 gap-x-8 gap-y-6 sm:grid-cols-2">
{SECTION_ORDER.map((section) => {
const rows = bySection.get(section);
if (!rows) return null;
return (
<div key={section}>
<div className="mb-2 text-xs font-semibold uppercase tracking-wider text-white/60">
{section}
</div>
<ul className="flex flex-col gap-1.5">
{rows.map((row) => (
<li
key={row.label}
className="flex items-center justify-between gap-3 text-sm"
>
<span className="text-white/80">{row.label}</span>
<span className="flex items-center gap-1">
{row.keys.map((k, i) => (
<Key key={`${row.label}-${i}`} label={k} />
))}
</span>
</li>
))}
</ul>
</div>
);
})}
</div>
</div>
</div>
);
};
const Key = ({ label }: { label: string }) => (
<kbd className="rounded border border-white/20 bg-white/10 px-1.5 py-0.5 font-mono text-[11px] text-white/90">
{label}
</kbd>
);
@@ -0,0 +1,54 @@
// Single source of truth for moodboard keyboard shortcuts. Consumed by
// `ShortcutCheatsheet.tsx` (rendering) and indirectly by
// `useKeyboardShortcuts.ts` (wiring). The `keys` array is the label shown
// to the user; the actual matching logic lives in the hook because some
// shortcuts are modifier-sensitive and platform-specific.
export interface ShortcutRow {
label: string;
keys: string[];
section: "Tools" | "Selection" | "Editing" | "Viewport" | "History";
}
const isMac =
typeof navigator !== "undefined" &&
/Mac|iPod|iPhone|iPad/.test(navigator.platform);
export const MOD = isMac ? "⌘" : "Ctrl";
// Renders a key combo as a compact tooltip label. Mac uses adjacent symbols
// (⌘⇧Z); other platforms use "+" separators (Ctrl+Shift+Z). Pass keys in
// order, e.g. [MOD, "Shift", "Z"].
export const fmtShortcut = (keys: string[]): string =>
isMac
? keys.map((k) => (k === "Shift" ? "⇧" : k)).join("")
: keys.join("+");
export const SHORTCUTS: ShortcutRow[] = [
// Tools
{ section: "Tools", label: "Select tool", keys: ["V"] },
{ section: "Tools", label: "Lasso tool", keys: ["L"] },
{ section: "Tools", label: "Text tool", keys: ["T"] },
// Selection
{ section: "Selection", label: "Select all", keys: [MOD, "A"] },
{ section: "Selection", label: "Clear selection", keys: ["Esc"] },
{ section: "Selection", label: "Add / remove from selection", keys: ["Shift", "Click"] },
// Editing
{ section: "Editing", label: "Delete selection", keys: ["Delete"] },
{ section: "Editing", label: "Group selection", keys: [MOD, "G"] },
{ section: "Editing", label: "Ungroup selection", keys: [MOD, "Shift", "G"] },
{ section: "Editing", label: "Paste image", keys: [MOD, "V"] },
// Viewport
{ section: "Viewport", label: "Pan", keys: ["Middle-click", "drag"] },
{ section: "Viewport", label: "Pan (alt)", keys: ["Space", "drag"] },
{ section: "Viewport", label: "Zoom", keys: ["Scroll"] },
{ section: "Viewport", label: "Reset zoom", keys: [MOD, "0"] },
{ section: "Viewport", label: "Zoom to fit", keys: ["Shift", "1"] },
// History
{ section: "History", label: "Undo", keys: [MOD, "Z"] },
{ section: "History", label: "Redo", keys: [MOD, "Shift", "Z"] },
];
@@ -0,0 +1,71 @@
export type Vec2 = { x: number; y: number };
export type Rect = { x: number; y: number; width: number; height: number };
export type Tool = "select" | "lasso" | "text";
interface BaseNode {
id: string;
parentId: string | null;
// Stage-local for top-level nodes; group-local for children of a group.
x: number;
y: number;
width: number;
height: number;
// Degrees, Konva convention.
rotation: number;
zIndex: number;
}
export type ImageNode = BaseNode & {
kind: "image";
src: string;
mediaId: string | null;
naturalW: number;
naturalH: number;
};
export type VideoNode = BaseNode & {
kind: "video";
src: string;
mediaId: string | null;
naturalW: number;
naturalH: number;
muted: boolean;
loop: boolean;
autoplay: boolean;
};
export type TextNode = BaseNode & {
kind: "text";
text: string;
fontSize: number;
color: string;
};
export type GroupNode = BaseNode & {
kind: "group";
childIds: string[];
};
// A card stacks its children in a vertical flex column (see CardNodeHtml). Unlike
// a group, children are laid out by the card (their own x/y are ignored) and the
// card owns a padding + background. `width` is authoritative; `height` is derived
// from the children via computeCardHeight whenever the child set changes.
export type CardNode = BaseNode & {
kind: "card";
childIds: string[];
padding: number;
backgroundColor: string;
};
export type MoodboardNode =
| ImageNode
| VideoNode
| TextNode
| GroupNode
| CardNode;
export interface MoodboardSnapshot {
nodes: Record<string, MoodboardNode>;
rootOrder: string[];
selectedIds: string[];
}
@@ -0,0 +1,105 @@
import { ReactNode, useCallback, useRef, useState } from "react";
import Konva from "konva";
import toast from "react-hot-toast";
import { useMoodboardStore } from "./MoodboardStore";
import { Vec2 } from "./types";
import { measureImage, measureVideo } from "../boards/measureMedia";
import type { MoodboardAdapter, MoodboardPickedItem } from "../adapter";
const stageCenter = (stage: Konva.Stage | null): Vec2 => {
if (!stage) return { x: 400, y: 400 };
return {
x: (stage.width() / 2 - stage.x()) / stage.scaleX(),
y: (stage.height() / 2 - stage.y()) / stage.scaleY(),
};
};
interface UseMoodboardImageEntryReturn {
triggerUpload: () => void;
triggerGallery: () => void;
modals: ReactNode;
}
// Owns the file input + library picker that feed the canvas. Upload and the
// library picker are platform seams provided by the adapter (the same one the
// grid uses), so the canvas no longer depends on any app-specific upload util
// or gallery modal.
export const useMoodboardImageEntry = (
adapter: MoodboardAdapter,
stageRef: React.RefObject<Konva.Stage | null>,
): UseMoodboardImageEntryReturn => {
const addImage = useMoodboardStore((s) => s.addImage);
const addVideo = useMoodboardStore((s) => s.addVideo);
const fileInputRef = useRef<HTMLInputElement>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const triggerUpload = useCallback(() => fileInputRef.current?.click(), []);
const triggerGallery = useCallback(() => {
if (adapter.renderLibraryPicker) setPickerOpen(true);
else
toast("Library picker isn't available here — upload or paste for now", {
icon: "🖼️",
});
}, [adapter]);
const handleFiles = useCallback(
async (files: FileList) => {
const center = stageCenter(stageRef.current);
for (const file of Array.from(files)) {
if (!file.type.startsWith("image/")) continue;
const blobUrl = URL.createObjectURL(file);
const dims = await measureImage(blobUrl);
addImage(blobUrl, center, dims.w, dims.h, null);
// Background upload so the image also lands in the user's library.
adapter.uploadImage?.(file).catch((err) => {
console.error("[Moodboard] background upload failed", err);
});
}
},
[addImage, stageRef, adapter],
);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files && files.length) void handleFiles(files);
if (fileInputRef.current) fileInputRef.current.value = "";
};
const handlePicked = useCallback(
async (items: MoodboardPickedItem[]) => {
const center = stageCenter(stageRef.current);
for (const it of items) {
if (!it.url) continue;
if (it.kind === "video") {
const dims = await measureVideo(it.url);
addVideo(it.url, center, dims.w, dims.h, it.mediaToken);
} else {
const dims = await measureImage(it.url);
addImage(it.url, center, dims.w, dims.h, it.mediaToken);
}
}
setPickerOpen(false);
},
[addImage, addVideo, stageRef],
);
const modals = (
<>
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={handleFileChange}
/>
{adapter.renderLibraryPicker?.({
open: pickerOpen,
onClose: () => setPickerOpen(false),
onPick: (items) => void handlePicked(items),
})}
</>
);
return { triggerUpload, triggerGallery, modals };
};
@@ -0,0 +1,33 @@
import { useEffect, useState } from "react";
// Reads the active theme's foreground color (`--st-fg-rgb`) as a "R G B" string,
// e.g. "255 255 255" for dark themes / "17 24 39" for light. Konva fills don't
// resolve `var()`, so we expand it once and re-read whenever the theme class on
// `<html>` changes.
const readFgRgb = (): string => {
if (typeof window === "undefined") return "255 255 255";
const v = getComputedStyle(document.documentElement)
.getPropertyValue("--st-fg-rgb")
.trim();
return v || "255 255 255";
};
export const useThemeFgRgb = (): string => {
const [fgRgb, setFgRgb] = useState(readFgRgb);
useEffect(() => {
const update = () => setFgRgb(readFgRgb());
const observer = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.attributeName === "class") {
update();
return;
}
}
});
observer.observe(document.documentElement, { attributes: true });
return () => observer.disconnect();
}, []);
return fgRgb;
};
@@ -0,0 +1,46 @@
// Bridge for "drag an item from the gallery/library onto the board". The host
// app owns the gallery (and its drag source), so it can't reach into this lib's
// views directly. Instead the app calls this on its gallery drop callback; the
// grid + canvas views listen for the dispatched event. Keeping the dispatch
// here (rather than in the app) means the event name + payload shape stay
// owned by the lib that consumes them.
export interface DroppedGalleryItem {
id?: string;
thumbnail?: string | null;
fullImage?: string | null;
}
export const GALLERY_MOODBOARD_DROP_EVENT = "gallery-moodboard-drop";
// Dispatches a board-drop for `item` if `screenPosition` (viewport coords) lands
// over the mounted moodboard surface. Returns whether it was accepted, so the
// caller can fall back to its own handling when the drop missed the board.
export const dispatchGalleryMoodboardDrop = (
item: DroppedGalleryItem,
screenPosition: { x: number; y: number },
): boolean => {
const root = document.querySelector("[data-moodboard-root]");
if (!root) return false;
const rect = root.getBoundingClientRect();
if (
screenPosition.x < rect.left ||
screenPosition.x > rect.right ||
screenPosition.y < rect.top ||
screenPosition.y > rect.bottom
) {
return false;
}
window.dispatchEvent(
new CustomEvent(GALLERY_MOODBOARD_DROP_EVENT, {
detail: {
item,
canvasPosition: {
x: screenPosition.x - rect.left,
y: screenPosition.y - rect.top,
},
},
}),
);
return true;
};
@@ -0,0 +1,377 @@
import { memo, useEffect, useRef, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faUpRightAndDownLeftFromCenter,
faTrashCan,
faCheck,
faWandMagicSparkles,
} from "@fortawesome/pro-regular-svg-icons";
import { faStar } from "@fortawesome/pro-solid-svg-icons";
import { BoardItem } from "../boards/boardTypes";
import { hostnameOf } from "../boards/linkMeta";
import { PositionedItem } from "./gridLayout";
import { Favicon } from "./Favicon";
interface Props {
item: BoardItem;
pos: PositionedItem;
selected: boolean;
/** Play a one-time fade-up the first time this card is revealed. */
animateIn?: boolean;
revealDelayMs?: number;
/** Roving tabindex: only the active card is keyboard-reachable via Tab. */
tabStop?: boolean;
/** Programmatically pull DOM focus here (set after arrow-key navigation). */
focused?: boolean;
onFocusCard?: (id: string) => void;
onKeyNav?: (id: string, e: React.KeyboardEvent) => void;
onSelect: (id: string, additive: boolean) => void;
onOpen: (id: string) => void;
onDelete: (id: string) => void;
onUseReference: (id: string) => void;
/** This text card is in inline-edit mode (renders an editable textarea). */
editing?: boolean;
/** Enter inline edit for a text card (double-click). */
onEditText?: (id: string) => void;
/** Commit edited note text (blur / Enter). */
onCommitText?: (id: string, text: string) => void;
}
const KIND_NOUN: Record<BoardItem["kind"], string> = {
image: "image",
video: "video",
text: "note",
link: "link",
color: "color swatch",
};
const prefersReducedMotion = (): boolean =>
typeof window !== "undefined" &&
typeof window.matchMedia === "function" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// One masonry tile. Absolutely positioned by the grid; the card owns only its
// visual treatment (double-bezel shell), per-kind content, and hover affordances.
const BoardCardInner = ({
item,
pos,
selected,
animateIn,
revealDelayMs = 0,
tabStop = false,
focused = false,
onFocusCard,
onKeyNav,
onSelect,
onOpen,
onDelete,
onUseReference,
editing = false,
onEditText,
onCommitText,
}: Props) => {
const rootRef = useRef<HTMLDivElement | null>(null);
const [entering, setEntering] = useState(
Boolean(animateIn) && !prefersReducedMotion(),
);
useEffect(() => {
if (!entering) return undefined;
const r = requestAnimationFrame(() => setEntering(false));
return () => cancelAnimationFrame(r);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Pull DOM focus here when the grid hands us focus via arrow navigation.
useEffect(() => {
if (focused && rootRef.current && document.activeElement !== rootRef.current) {
rootRef.current.focus();
}
}, [focused]);
const caption = "caption" in item ? item.caption : undefined;
const label = `${KIND_NOUN[item.kind]}${caption ? `: ${caption}` : ""}${
item.rating > 0 ? `, rated ${item.rating}` : ""
}${selected ? ", selected" : ""}`;
return (
<div
ref={rootRef}
role="button"
tabIndex={tabStop ? 0 : -1}
aria-label={label}
aria-pressed={selected}
className="group absolute rounded-[18px] transition-[opacity,transform] duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] focus:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-ui-panel motion-reduce:transition-none"
style={{
left: pos.x,
top: pos.y,
width: pos.width,
height: pos.height,
transitionDelay: `${revealDelayMs}ms`,
opacity: entering ? 0 : 1,
transform: entering ? "translateY(8px)" : undefined,
}}
onPointerDown={(e) => {
// Left-click selects; modifier-click extends. Action buttons stop
// propagation so they don't double as a select.
if (e.button !== 0) return;
onSelect(item.id, e.shiftKey || e.metaKey || e.ctrlKey);
}}
onDoubleClick={() =>
item.kind === "text" && onEditText
? onEditText(item.id)
: onOpen(item.id)
}
onFocus={() => onFocusCard?.(item.id)}
onKeyDown={(e) => onKeyNav?.(item.id, e)}
>
{/* Outer shell — double-bezel. Lifts slightly on hover; ring turns to the
brand blue when selected. */}
<div
className={[
"h-full w-full rounded-[18px] p-1 transition-all duration-200",
"ease-[cubic-bezier(0.4,0,0.2,1)]",
"ring-1 group-hover:-translate-y-0.5",
"bg-[var(--mb-plane-1,rgba(127,127,127,0.04))]",
selected
? "ring-2 ring-primary"
: "ring-[var(--mb-hairline,rgba(127,127,127,0.12))] group-hover:ring-[var(--mb-hairline,rgba(127,127,127,0.22))]",
selected
? "shadow-[0_8px_28px_-10px_rgba(0,0,0,0.45)]"
: "shadow-[0_1px_2px_rgba(0,0,0,0.10)] group-hover:shadow-[0_10px_30px_-12px_rgba(0,0,0,0.4)]",
].join(" ")}
>
{/* Inner core — concentric radius, holds the content. */}
<div className="relative h-full w-full overflow-hidden rounded-[14px] bg-ui-panel shadow-[inset_0_1px_1px_rgba(255,255,255,0.08)]">
<CardContent
item={item}
editing={editing}
onCommitText={onCommitText}
/>
{/* Rating — persistent when set (Lightroom-style triage marker).
No backdrop-blur: this tile lives in a virtualized scroll
container, where blur filters trigger GPU repaints on every
scroll frame. A solid scrim keeps the icons legible instead. */}
{item.rating > 0 && (
<div className="absolute bottom-2 left-2 flex items-center gap-0.5 rounded-full bg-black/65 px-2 py-1">
{Array.from({ length: item.rating }).map((_, i) => (
<FontAwesomeIcon
key={i}
icon={faStar}
className="h-2.5 w-2.5 text-yellow-400"
/>
))}
</div>
)}
{/* Selection check — visible when selected or on hover. */}
<div
className={[
"absolute left-2 top-2 flex h-6 w-6 items-center justify-center rounded-full",
"border transition-all duration-150",
selected
? "border-primary bg-primary text-white opacity-100"
: "border-white/40 bg-black/45 text-white/0 opacity-0 group-hover:opacity-100",
].join(" ")}
>
<FontAwesomeIcon icon={faCheck} className="h-3 w-3" />
</div>
{/* Action rail — solid scrim pill (no blur in the scroll container),
fades up on hover. */}
<div className="absolute right-2 top-2 flex translate-y-1 items-center gap-1 rounded-full border border-white/15 bg-black/65 p-0.5 opacity-0 transition-all duration-200 group-hover:translate-y-0 group-hover:opacity-100">
<RailButton
label="Open"
icon={faUpRightAndDownLeftFromCenter}
onClick={() => onOpen(item.id)}
/>
{item.kind === "image" && (
<RailButton
label="Use as reference"
icon={faWandMagicSparkles}
onClick={() => onUseReference(item.id)}
/>
)}
<RailButton
label="Delete"
icon={faTrashCan}
onClick={() => onDelete(item.id)}
/>
</div>
</div>
</div>
</div>
);
};
const RailButton = ({
label,
icon,
onClick,
}: {
label: string;
icon: typeof faCheck;
onClick: () => void;
}) => (
<button
type="button"
aria-label={label}
title={label}
className="flex h-7 w-7 items-center justify-center rounded-full text-white/85 transition-colors duration-150 hover:bg-white/15 hover:text-white focus:outline-none focus-visible:bg-white/15 focus-visible:text-white focus-visible:ring-2 focus-visible:ring-white/70"
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
onClick();
}}
>
<FontAwesomeIcon icon={icon} className="h-3.5 w-3.5" />
</button>
);
const CardContent = ({
item,
editing,
onCommitText,
}: {
item: BoardItem;
editing?: boolean;
onCommitText?: (id: string, text: string) => void;
}) => {
switch (item.kind) {
case "image":
return (
<img
src={item.src}
alt={item.caption || ""}
draggable={false}
className="h-full w-full select-none object-cover"
/>
);
case "video":
return (
<video
src={item.src}
autoPlay
muted
loop
playsInline
preload="metadata"
className="h-full w-full object-cover"
/>
);
case "color":
return (
<div className="h-full w-full" style={{ background: item.color }}>
<span className="absolute bottom-2 left-2 rounded-full bg-black/40 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider text-white backdrop-blur">
{item.color}
</span>
</div>
);
case "text":
return editing && onCommitText ? (
<NoteEditor
initial={item.text}
onCommit={(text) => onCommitText(item.id, text)}
/>
) : (
// Extra top padding clears the selection check (top-left) so it never
// overlaps the first line of the note.
<div className="h-full w-full overflow-hidden px-4 pb-4 pt-10">
<p
// Clicking the text itself shouldn't toggle the card's selection —
// stop the pointer-down from reaching the card root. Double-click
// (a separate event) still bubbles, so it opens the editor.
onPointerDown={(e) => e.stopPropagation()}
className="whitespace-pre-wrap text-sm leading-relaxed text-base-fg/90"
>
{item.text || "Note"}
</p>
</div>
);
case "link":
return (
<a
href={item.url}
target="_blank"
rel="noreferrer"
className="flex h-full w-full flex-col"
onPointerDown={(e) => e.stopPropagation()}
>
{item.image && (
<img
src={item.image}
alt=""
draggable={false}
className="min-h-0 flex-1 select-none object-cover"
/>
)}
<div className="flex items-center gap-2.5 px-3 py-2.5">
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-base-fg/8">
<Favicon url={item.url} className="h-4 w-4" />
</span>
<span className="flex min-w-0 flex-col">
<span className="truncate text-xs font-medium text-base-fg/90">
{item.title || hostnameOf(item.url)}
</span>
<span className="truncate text-[10px] text-base-fg/45">
{hostnameOf(item.url)}
</span>
</span>
</div>
</a>
);
default:
return null;
}
};
// Inline note editor. Autofocuses, commits on blur or Enter (Shift+Enter inserts
// a newline), reverts on Escape. Pointer events are stopped so typing/selecting
// inside the textarea never triggers card selection, drag, or marquee.
const NoteEditor = ({
initial,
onCommit,
}: {
initial: string;
onCommit: (text: string) => void;
}) => {
const ref = useRef<HTMLTextAreaElement | null>(null);
const [draft, setDraft] = useState(initial);
useEffect(() => {
const el = ref.current;
if (!el) return;
el.focus();
el.select();
}, []);
return (
<div
// Matches the static note: top padding clears the selection check.
className="h-full w-full px-4 pb-4 pt-10"
onPointerDown={(e) => e.stopPropagation()}
onDoubleClick={(e) => e.stopPropagation()}
>
<textarea
ref={ref}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={() => onCommit(draft.trim())}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
onCommit(draft.trim());
} else if (e.key === "Escape") {
e.preventDefault();
onCommit(initial.trim());
}
}}
placeholder="Write a note…"
className="h-full w-full resize-none bg-transparent text-sm leading-relaxed text-base-fg/90 placeholder:text-base-fg/35 focus:outline-none"
/>
</div>
);
};
export const BoardCard = memo(BoardCardInner);
@@ -0,0 +1,54 @@
import {
faArrowUpFromBracket,
faImages,
} from "@fortawesome/pro-regular-svg-icons";
import { Button } from "@storyteller/ui-button";
interface Props {
onUpload: () => void;
onLibrary: () => void;
}
// Shared sizing so both CTAs match the webapp create pages' empty-state buttons
// (h-12, pill, semibold). Centralized so the pair stays visually aligned.
const CTA_CLASS = "h-12 px-6 text-base font-semibold rounded-full";
// Editorial empty state — the board's first impression. Doubles as the
// drop / paste affordance (the whole grid accepts drops; this just says so).
export const BoardEmptyState = ({ onUpload, onLibrary }: Props) => {
return (
<div className="flex h-full w-full items-center justify-center px-6">
<div className="flex max-w-md flex-col items-center text-center">
<span className="mb-5 rounded-full border border-ui-divider px-3 py-1 text-[10px] font-medium uppercase tracking-[0.2em] text-base-fg/50">
Moodboard
</span>
<h2 className="text-3xl font-semibold tracking-[-0.02em] text-base-fg">
Start collecting ideas
</h2>
<p className="mt-3 text-sm leading-relaxed text-base-fg/55">
Drag images in, paste from anywhere, or pull from your library.
Everything you gather here can later steer a generation.
</p>
<div className="mt-7 flex items-center gap-3">
<Button
variant="primary"
onClick={onUpload}
icon={faArrowUpFromBracket}
className={CTA_CLASS}
>
Upload
</Button>
<Button
variant="secondary"
onClick={onLibrary}
icon={faImages}
className={CTA_CLASS}
>
From library
</Button>
</div>
</div>
</div>
);
};
@@ -0,0 +1,347 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { BoardItem, BoardSection, GridDensity } from "../boards/boardTypes";
import { BoardCard } from "./BoardCard";
import { SectionHeader } from "./SectionHeader";
import {
DENSITY_CONFIG,
NavDirection,
SectionGroup,
SectionedLayout,
computeMasonryLayout,
computeSectionedLayout,
nearestInDirection,
sliceVisible,
} from "./gridLayout";
const ARROW_DIRS: Record<string, NavDirection> = {
ArrowUp: "up",
ArrowDown: "down",
ArrowLeft: "left",
ArrowRight: "right",
};
interface Props {
items: BoardItem[];
density: GridDensity;
selectedIds: Set<string>;
onSelect: (id: string, additive: boolean) => void;
onSelectMany: (ids: string[], additive: boolean) => void;
onClearSelection: () => void;
onOpen: (id: string) => void;
onUseReference: (id: string) => void;
onDelete: (id: string) => void;
// Inline text editing: which text card is being edited, plus enter/commit.
editingTextId?: string | null;
onEditText?: (id: string) => void;
onCommitText?: (id: string, text: string) => void;
// When present (non-empty), the grid groups items into section lanes with
// header rows. Absent → flat masonry (the default).
sections?: BoardSection[];
onRenameSection?: (id: string, name: string) => void;
onDeleteSection?: (id: string) => void;
onToggleSectionCollapsed?: (id: string) => void;
}
const OUTER_PADDING = 24;
const VIRTUAL_BUFFER = 600;
const MARQUEE_THRESHOLD = 4;
interface Marquee {
x0: number;
y0: number;
x1: number;
y1: number;
}
// Virtualized masonry. The layout is precomputed from item aspects, so we only
// mount the cards intersecting the viewport (± buffer) — this is what keeps the
// grid at 60fps with thousands of items (the competitor failure mode).
export const BoardGrid = ({
items,
density,
selectedIds,
onSelect,
onSelectMany,
onClearSelection,
onOpen,
onUseReference,
onDelete,
editingTextId,
onEditText,
onCommitText,
sections,
onRenameSection,
onDeleteSection,
onToggleSectionCollapsed,
}: Props) => {
const scrollRef = useRef<HTMLDivElement | null>(null);
const revealedRef = useRef<Set<string>>(new Set());
const [width, setWidth] = useState(0);
const [scrollTop, setScrollTop] = useState(0);
const [viewportH, setViewportH] = useState(0);
const [marquee, setMarquee] = useState<Marquee | null>(null);
const [focusedId, setFocusedId] = useState<string | null>(null);
const marqueeRef = useRef<{ active: boolean; moved: boolean; additive: boolean }>(
{ active: false, moved: false, additive: false },
);
useLayoutEffect(() => {
const el = scrollRef.current;
if (!el) return undefined;
const measure = () => {
setWidth(el.clientWidth - OUTER_PADDING * 2);
setViewportH(el.clientHeight);
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, []);
// Group items into section lanes (ungrouped lane first) when sections exist.
// null = flat masonry.
const groups = useMemo<SectionGroup[] | null>(() => {
if (!sections || sections.length === 0) return null;
const known = new Set(sections.map((s) => s.id));
const bySection = new Map<string | null, Array<{ id: string; aspect: number }>>();
for (const it of items) {
const key = it.sectionId && known.has(it.sectionId) ? it.sectionId : null;
const entry = { id: it.id, aspect: it.aspect };
const arr = bySection.get(key);
if (arr) arr.push(entry);
else bySection.set(key, [entry]);
}
const named: SectionGroup[] = sections.map((s) => ({
id: s.id,
name: s.name,
collapsed: Boolean(s.collapsed),
items: bySection.get(s.id) ?? [],
}));
const ungrouped = bySection.get(null) ?? [];
return ungrouped.length > 0
? [{ id: null, name: "Ungrouped", collapsed: false, items: ungrouped }, ...named]
: named;
}, [items, sections]);
const layout = useMemo<SectionedLayout>(() => {
const w = Math.max(width, 1);
if (groups) {
return computeSectionedLayout(groups, w, DENSITY_CONFIG[density]);
}
const flat = computeMasonryLayout(
items.map((it) => ({ id: it.id, aspect: it.aspect })),
w,
DENSITY_CONFIG[density],
);
return { ...flat, bands: [] };
}, [groups, items, width, density]);
const itemsById = useMemo(() => {
const map: Record<string, BoardItem> = {};
items.forEach((it) => (map[it.id] = it));
return map;
}, [items]);
const visible = useMemo(
() => sliceVisible(layout.positions, scrollTop, viewportH, VIRTUAL_BUFFER),
[layout, scrollTop, viewportH],
);
// ----- keyboard navigation (roving tabindex) -----
// Exactly one card is a tab stop: the focused one, falling back to the first.
// Arrows move focus geometrically; Enter opens, Space selects, Delete removes.
const activeId = focusedId ?? layout.positions[0]?.id ?? null;
const scrollCardIntoView = (id: string) => {
const el = scrollRef.current;
const p = layout.byId[id];
if (!el || !p) return;
// Positions are content-relative; the scroll container adds OUTER_PADDING.
const top = p.y + OUTER_PADDING;
const bottom = top + p.height;
if (top < el.scrollTop) {
el.scrollTop = Math.max(0, top - OUTER_PADDING);
} else if (bottom > el.scrollTop + el.clientHeight) {
el.scrollTop = bottom - el.clientHeight + OUTER_PADDING;
}
};
const handleCardKeyDown = (id: string, e: React.KeyboardEvent) => {
const dir = ARROW_DIRS[e.key];
if (dir) {
const next = nearestInDirection(layout.byId, id, dir);
if (next) {
e.preventDefault();
setFocusedId(next);
scrollCardIntoView(next);
}
return;
}
if (e.key === "Enter") {
e.preventDefault();
onOpen(id);
} else if (e.key === " ") {
e.preventDefault();
onSelect(id, e.shiftKey || e.metaKey || e.ctrlKey);
} else if (e.key === "Delete" || e.key === "Backspace") {
e.preventDefault();
onDelete(id);
}
};
// ----- marquee selection on empty canvas -----
const pointToContent = (clientX: number, clientY: number) => {
const el = scrollRef.current;
if (!el) return { x: 0, y: 0 };
const rect = el.getBoundingClientRect();
// Positions are relative to the inner content div, which begins after the
// scroll container's padding — so subtract OUTER_PADDING on both axes.
return {
x: clientX - rect.left - OUTER_PADDING + el.scrollLeft,
y: clientY - rect.top - OUTER_PADDING + el.scrollTop,
};
};
const handlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
// Only start a marquee from bare canvas (not from a card).
if (e.target !== e.currentTarget) return;
if (e.button !== 0) return;
const p = pointToContent(e.clientX, e.clientY);
marqueeRef.current = {
active: true,
moved: false,
additive: e.shiftKey || e.metaKey || e.ctrlKey,
};
setMarquee({ x0: p.x, y0: p.y, x1: p.x, y1: p.y });
};
useEffect(() => {
const onMove = (e: PointerEvent) => {
if (!marqueeRef.current.active) return;
const p = pointToContent(e.clientX, e.clientY);
setMarquee((m) => {
if (!m) return m;
if (
!marqueeRef.current.moved &&
(Math.abs(p.x - m.x0) > MARQUEE_THRESHOLD ||
Math.abs(p.y - m.y0) > MARQUEE_THRESHOLD)
) {
marqueeRef.current.moved = true;
}
return { ...m, x1: p.x, y1: p.y };
});
};
const onUp = () => {
const st = marqueeRef.current;
if (!st.active) return;
marqueeRef.current.active = false;
setMarquee((m) => {
if (m && st.moved) {
const hit = intersectIds(layout, m);
onSelectMany(hit, st.additive);
} else if (!st.moved) {
onClearSelection();
}
return null;
});
};
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
return () => {
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
};
}, [layout, onSelectMany, onClearSelection]);
return (
<div
ref={scrollRef}
className="h-full w-full overflow-y-auto overflow-x-hidden"
style={{ padding: OUTER_PADDING }}
onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
onPointerDown={handlePointerDown}
>
<div className="relative" style={{ height: layout.totalHeight }}>
{layout.bands.map((band) => (
<div
key={band.id ?? "__ungrouped"}
className="group/section absolute inset-x-0"
style={{ top: band.headerY }}
>
<SectionHeader
name={band.name}
count={band.count}
collapsed={band.collapsed}
editable={band.id !== null}
onToggleCollapse={() =>
band.id && onToggleSectionCollapsed?.(band.id)
}
onRename={(name) => band.id && onRenameSection?.(band.id, name)}
onDelete={() => band.id && onDeleteSection?.(band.id)}
/>
</div>
))}
{visible.map((pos, i) => {
const item = itemsById[pos.id];
if (!item) return null;
// Animate each card in only the first time it's revealed this session,
// so scrolling (which mounts/unmounts virtualized cards) never
// re-triggers the entry motion.
const firstReveal = !revealedRef.current.has(pos.id);
if (firstReveal) revealedRef.current.add(pos.id);
return (
<BoardCard
key={pos.id}
item={item}
pos={pos}
selected={selectedIds.has(pos.id)}
animateIn={firstReveal}
revealDelayMs={firstReveal ? Math.min(i, 8) * 25 : 0}
tabStop={pos.id === activeId}
focused={pos.id === focusedId}
onFocusCard={setFocusedId}
onKeyNav={handleCardKeyDown}
onSelect={onSelect}
onOpen={onOpen}
onUseReference={onUseReference}
onDelete={onDelete}
editing={editingTextId === pos.id}
onEditText={onEditText}
onCommitText={onCommitText}
/>
);
})}
{marquee && marqueeRef.current.moved && (
<div
className="pointer-events-none absolute rounded-[4px] border border-primary bg-primary/10"
style={{
left: Math.min(marquee.x0, marquee.x1),
top: Math.min(marquee.y0, marquee.y1),
width: Math.abs(marquee.x1 - marquee.x0),
height: Math.abs(marquee.y1 - marquee.y0),
}}
/>
)}
</div>
</div>
);
};
const intersectIds = (
layout: ReturnType<typeof computeMasonryLayout>,
m: Marquee,
): string[] => {
const left = Math.min(m.x0, m.x1);
const right = Math.max(m.x0, m.x1);
const top = Math.min(m.y0, m.y1);
const bottom = Math.max(m.y0, m.y1);
return layout.positions
.filter(
(p) =>
p.x < right &&
p.x + p.width > left &&
p.y < bottom &&
p.y + p.height > top,
)
.map((p) => p.id);
};
@@ -0,0 +1,239 @@
import { useEffect, useRef, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import {
faArrowUpFromBracket,
faImages,
faNoteSticky,
faTableCells,
faTableCellsLarge,
faGrip,
faLayerGroup,
} from "@fortawesome/pro-regular-svg-icons";
import { faStar } from "@fortawesome/pro-solid-svg-icons";
import { GridDensity } from "../boards/boardTypes";
import { DENSITY_ORDER } from "./gridLayout";
import { SmartSearchBar } from "./SmartSearchBar";
import { ColorPickerPopover } from "./ColorPickerPopover";
import { ToolbarShell, ToolbarIconButton, ToolbarDivider } from "../ui/Toolbar";
interface Props {
boardName: string;
onRenameBoard: (name: string) => void;
itemCount: number;
density: GridDensity;
onDensityChange: (d: GridDensity) => void;
query: string;
onQueryChange: (q: string) => void;
tags: string[];
activeTags: string[];
onToggleTag: (tag: string) => void;
ratingFilter: number;
onCycleRatingFilter: () => void;
onUpload: () => void;
onLibrary: () => void;
canPickLibrary: boolean;
onAddNote: () => void;
onAddColor: (color: string) => void;
onNewSection: () => void;
}
const DENSITY_ICON: Record<GridDensity, IconDefinition> = {
compact: faGrip,
cozy: faTableCells,
comfortable: faTableCellsLarge,
};
const DENSITY_LABEL: Record<GridDensity, string> = {
compact: "Compact",
cozy: "Cozy",
comfortable: "Comfortable",
};
// Floating glass island — detached from the top edge, not glued to it. Shares
// its shell, buttons, and dividers with the Canvas toolbar (see ../ui/Toolbar).
export const BoardGridToolbar = ({
boardName,
onRenameBoard,
itemCount,
density,
onDensityChange,
query,
onQueryChange,
tags,
activeTags,
onToggleTag,
ratingFilter,
onCycleRatingFilter,
onUpload,
onLibrary,
canPickLibrary,
onAddNote,
onAddColor,
onNewSection,
}: Props) => {
return (
<div className="pointer-events-none absolute inset-x-0 top-0 z-20 flex justify-center px-3 pt-3">
<ToolbarShell className="max-w-full">
<div className="flex min-w-0 flex-col pl-1 pr-2">
<BoardTitle name={boardName} onRename={onRenameBoard} />
<span className="pl-1.5 text-[11px] leading-tight text-base-fg/45">
{itemCount} {itemCount === 1 ? "item" : "items"}
</span>
</div>
<ToolbarDivider />
{/* Add cluster */}
<div className="flex items-center gap-1">
<ToolbarIconButton
icon={faArrowUpFromBracket}
label="Upload"
onClick={onUpload}
/>
{canPickLibrary && (
<ToolbarIconButton
icon={faImages}
label="From library"
onClick={onLibrary}
/>
)}
<ToolbarIconButton
icon={faNoteSticky}
label="Add note"
onClick={onAddNote}
/>
<ColorPickerPopover onAdd={onAddColor} />
<ToolbarIconButton
icon={faLayerGroup}
label="New section"
onClick={onNewSection}
/>
</div>
<ToolbarDivider />
{/* Density segmented control */}
<div className="flex items-center gap-0.5 rounded-full bg-base-fg/5 p-0.5">
{DENSITY_ORDER.map((d) => {
const active = d === density;
return (
<button
key={d}
type="button"
title={DENSITY_LABEL[d]}
aria-label={`${DENSITY_LABEL[d]} density`}
onClick={() => onDensityChange(d)}
className={[
"flex h-7 w-7 items-center justify-center rounded-full transition-colors duration-150",
"focus:outline-none focus-visible:ring-2 focus-visible:ring-primary",
active
? "bg-base-fg/15 text-base-fg"
: "text-base-fg/50 hover:text-base-fg",
].join(" ")}
>
<FontAwesomeIcon
icon={DENSITY_ICON[d]}
className="h-3.5 w-3.5"
/>
</button>
);
})}
</div>
<ToolbarDivider />
{/* Rating filter — cycles off → ≥1 … ≥5 → off (Lightroom-style sift). */}
<button
type="button"
title={
ratingFilter > 0
? `Showing ${ratingFilter}+ stars`
: "Filter by rating"
}
aria-label="Filter by rating"
onClick={onCycleRatingFilter}
className={[
"flex h-8 items-center gap-1 rounded-full px-2.5 transition-colors duration-150",
"focus:outline-none focus-visible:ring-2 focus-visible:ring-primary",
ratingFilter > 0
? "bg-yellow-400/15 text-yellow-500"
: "text-base-fg/55 hover:bg-base-fg/10 hover:text-base-fg",
].join(" ")}
>
<FontAwesomeIcon icon={faStar} className="h-3.5 w-3.5" />
<span className="text-xs font-semibold tabular-nums">
{ratingFilter > 0 ? `${ratingFilter}+` : "All"}
</span>
</button>
<ToolbarDivider />
<SmartSearchBar
query={query}
onQueryChange={onQueryChange}
tags={tags}
activeTags={activeTags}
onToggleTag={onToggleTag}
/>
</ToolbarShell>
</div>
);
};
// Inline-editable board title — click to rename (mirrors SectionHeader). Commits
// on Enter / blur, Escape cancels; empty or unchanged input is ignored.
const BoardTitle = ({
name,
onRename,
}: {
name: string;
onRename: (name: string) => void;
}) => {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(name);
const inputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
if (editing) {
inputRef.current?.focus();
inputRef.current?.select();
}
}, [editing]);
const commit = () => {
const next = draft.trim();
if (next && next !== name) onRename(next);
setEditing(false);
};
if (editing) {
return (
<input
ref={inputRef}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") commit();
else if (e.key === "Escape") setEditing(false);
}}
onBlur={commit}
className="min-w-0 rounded-md bg-base-fg/5 px-1.5 py-0.5 text-sm font-semibold leading-tight text-base-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
/>
);
}
return (
<button
type="button"
title="Rename board"
onClick={() => {
setDraft(name);
setEditing(true);
}}
className="truncate rounded-md px-1.5 py-0.5 text-left text-sm font-semibold leading-tight text-base-fg transition-colors hover:bg-base-fg/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
{name}
</button>
);
};
@@ -0,0 +1,356 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useShallow } from "zustand/react/shallow";
import { useBoardLibraryStore } from "../boards/BoardLibraryStore";
import {
collectTags,
filterItems,
useActiveBoard,
} from "../boards/boardSelectors";
import toast from "react-hot-toast";
import { useBoardItemEntry } from "../boards/useBoardItemEntry";
import { measureImage } from "../boards/measureMedia";
import { useGalleryBoardDrop } from "./useGalleryBoardDrop";
import { BoardImageItem, BoardItem } from "../boards/boardTypes";
import type { MoodboardAdapter } from "../adapter";
import { BoardGrid } from "./BoardGrid";
import { BoardGridToolbar } from "./BoardGridToolbar";
import { BoardEmptyState } from "./BoardEmptyState";
import { SelectionBar } from "./SelectionBar";
import { ItemInspector } from "./ItemInspector";
interface Props {
active: boolean;
adapter: MoodboardAdapter;
}
// Grid mode — the default moodboard view. Owns add-flows, filtering, selection,
// and OS file drop; delegates layout/virtualization to <BoardGrid>. Platform
// seams (upload, library picker, "use as reference") arrive via the adapter.
export const BoardGridView = ({ active, adapter }: Props) => {
const ensureActiveBoard = useBoardLibraryStore((s) => s.ensureActiveBoard);
const board = useActiveBoard();
const { density, searchQuery, activeTagFilters, ratingFilter } =
useBoardLibraryStore(
useShallow((s) => ({
density: s.density,
searchQuery: s.searchQuery,
activeTagFilters: s.activeTagFilters,
ratingFilter: s.ratingFilter,
})),
);
const selectedItemIds = useBoardLibraryStore((s) => s.selectedItemIds);
const setDensity = useBoardLibraryStore((s) => s.setDensity);
const setSearchQuery = useBoardLibraryStore((s) => s.setSearchQuery);
const toggleTagFilter = useBoardLibraryStore((s) => s.toggleTagFilter);
const setRatingFilter = useBoardLibraryStore((s) => s.setRatingFilter);
const rateItems = useBoardLibraryStore((s) => s.rateItems);
const setSelection = useBoardLibraryStore((s) => s.setSelection);
const toggleSelection = useBoardLibraryStore((s) => s.toggleSelection);
const clearSelection = useBoardLibraryStore((s) => s.clearSelection);
const deleteSelected = useBoardLibraryStore((s) => s.deleteSelected);
const removeItems = useBoardLibraryStore((s) => s.removeItems);
const updateItem = useBoardLibraryStore((s) => s.updateItem);
const addColorItem = useBoardLibraryStore((s) => s.addColorItem);
const renameBoard = useBoardLibraryStore((s) => s.renameBoard);
const createSection = useBoardLibraryStore((s) => s.createSection);
const renameSection = useBoardLibraryStore((s) => s.renameSection);
const deleteSection = useBoardLibraryStore((s) => s.deleteSection);
const toggleSectionCollapsed = useBoardLibraryStore(
(s) => s.toggleSectionCollapsed,
);
const assignItemsToSection = useBoardLibraryStore(
(s) => s.assignItemsToSection,
);
const { triggerUpload, triggerGallery, addNote, modals } =
useBoardItemEntry(adapter, active);
const [dragOver, setDragOver] = useState(false);
const [openId, setOpenId] = useState<string | null>(null);
// Which text card is in inline-edit mode (e.g. a freshly-added note).
const [editingTextId, setEditingTextId] = useState<string | null>(null);
const handleAddNote = useCallback(
() => setEditingTextId(addNote()),
[addNote],
);
const commitText = useCallback(
(id: string, text: string) => {
if (board) updateItem(board.id, id, { text });
setEditingTextId((cur) => (cur === id ? null : cur));
},
[board, updateItem],
);
useEffect(() => {
if (active) ensureActiveBoard();
}, [active, ensureActiveBoard]);
// Accept gallery items dragged onto the grid (mirrors the canvas listener).
useGalleryBoardDrop(active);
const filtered = useMemo(
() =>
board
? filterItems(board, searchQuery, activeTagFilters, ratingFilter)
: [],
[board, searchQuery, activeTagFilters, ratingFilter],
);
const tags = useMemo(() => (board ? collectTags(board) : []), [board]);
const openIndex = openId
? filtered.findIndex((it) => it.id === openId)
: -1;
const openItem = openIndex >= 0 ? filtered[openIndex] : null;
const sendReference = (items: BoardItem[]) => {
const refs = items
.filter(
(it): it is BoardImageItem => it.kind === "image" && Boolean(it.mediaId),
)
.map((it) => ({ id: it.id, url: it.src, mediaToken: it.mediaId as string }));
if (refs.length === 0) {
toast.error("Add images with a media reference to use them in a generation");
return;
}
adapter.sendToGeneration(refs);
};
const referenceById = (id: string) => {
const it = board?.items[id];
if (it) sendReference([it]);
};
const referenceSelected = () => {
if (!board) return;
sendReference(
Array.from(selectedItemIds)
.map((id) => board.items[id])
.filter((it): it is BoardItem => Boolean(it)),
);
};
// "Use as reference" only applies to reference-capable items (images with a
// media token) — never notes/colors/links. Mirrors sendReference's filter.
const canUseReference = useMemo(
() =>
board
? Array.from(selectedItemIds).some((id) => {
const it = board.items[id];
return it?.kind === "image" && Boolean(it.mediaId);
})
: false,
[board, selectedItemIds],
);
const handleNewSection = () => {
if (board) createSection(board.id);
};
const handleAssignToSection = (sectionId: string | null) => {
if (board) assignItemsToSection(board.id, Array.from(selectedItemIds), sectionId);
};
const handleGroupSelectionIntoNewSection = () => {
if (!board) return;
const sectionId = createSection(board.id);
assignItemsToSection(board.id, Array.from(selectedItemIds), sectionId);
};
// Grid keyboard: delete / escape + Lightroom-style triage (0-5 rate, P pick)
// on the current selection. Inert while the inspector owns input. Delete is
// bound to Delete/Backspace only — `x` is deliberately NOT an alias, since the
// board has no undo and `x` sits among the number-key triage row, where an
// accidental press would silently destroy work.
useEffect(() => {
if (!active || openId) return undefined;
const handler = (e: KeyboardEvent) => {
const t = e.target as HTMLElement | null;
if (t && (/input|textarea/i.test(t.tagName) || t.isContentEditable)) return;
const ids = Array.from(selectedItemIds);
if (e.key === "Escape") {
clearSelection();
return;
}
if (ids.length === 0) return;
if (e.key === "Delete" || e.key === "Backspace") {
e.preventDefault();
deleteSelected();
} else if (e.key === "p") {
e.preventDefault();
if (board) rateItems(board.id, ids, 5);
} else if (/^[0-5]$/.test(e.key)) {
e.preventDefault();
if (board) rateItems(board.id, ids, Number(e.key));
}
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [
active,
openId,
board,
selectedItemIds,
deleteSelected,
clearSelection,
rateItems,
]);
const handleDrop = async (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
setDragOver(false);
const files = Array.from(e.dataTransfer.files).filter((f) =>
f.type.startsWith("image/"),
);
if (files.length === 0) return;
const store = useBoardLibraryStore.getState();
const boardId = store.ensureActiveBoard();
for (const file of files) {
const url = URL.createObjectURL(file);
const dims = await measureImage(url);
store.addImageItem(boardId, {
src: url,
mediaId: null,
naturalW: dims.w,
naturalH: dims.h,
});
}
};
const isEmpty = filtered.length === 0;
const noItemsAtAll = !board || board.itemOrder.length === 0;
return (
<div
className="relative h-full w-full overflow-hidden"
// Matches MoodboardWorkspace: desktop themes drive --st-bg, the webapp
// falls back to its --background token (#171717). overflow-hidden keeps
// the view root from ever scrolling — the grid scrolls internally.
style={{ background: "var(--st-bg, hsl(0 0% 9%))" }}
onDragOver={(e) => {
if (e.dataTransfer.types.includes("Files")) {
e.preventDefault();
setDragOver(true);
}
}}
onDragLeave={(e) => {
if (e.currentTarget === e.target) setDragOver(false);
}}
onDrop={handleDrop}
>
<BoardGridToolbar
boardName={board?.name ?? "Moodboard"}
onRenameBoard={(name) => renameBoard(ensureActiveBoard(), name)}
itemCount={board?.itemOrder.length ?? 0}
density={density}
onDensityChange={setDensity}
query={searchQuery}
onQueryChange={setSearchQuery}
tags={tags}
activeTags={activeTagFilters}
onToggleTag={toggleTagFilter}
ratingFilter={ratingFilter}
onCycleRatingFilter={() =>
setRatingFilter(ratingFilter >= 5 ? 0 : ratingFilter + 1)
}
onUpload={triggerUpload}
onLibrary={triggerGallery}
canPickLibrary={Boolean(adapter.renderLibraryPicker)}
onAddNote={handleAddNote}
onAddColor={(color) => addColorItem(ensureActiveBoard(), color)}
onNewSection={handleNewSection}
/>
<div className="h-full w-full pt-[68px]">
{isEmpty ? (
noItemsAtAll ? (
<BoardEmptyState
onUpload={triggerUpload}
onLibrary={triggerGallery}
/>
) : (
<div className="flex h-full items-center justify-center text-sm text-base-fg/45">
No items match your search.
</div>
)
) : (
<BoardGrid
items={filtered}
density={density}
selectedIds={selectedItemIds}
onSelect={toggleSelection}
onSelectMany={(ids, additive) => {
if (additive) {
const next = new Set(selectedItemIds);
ids.forEach((id) => next.add(id));
setSelection(next);
} else {
setSelection(ids);
}
}}
onClearSelection={clearSelection}
onOpen={(id) => setOpenId(id)}
onUseReference={referenceById}
onDelete={(id) => board && removeItems(board.id, [id])}
editingTextId={editingTextId}
onEditText={setEditingTextId}
onCommitText={commitText}
sections={board?.sections}
onRenameSection={(id, name) =>
board && renameSection(board.id, id, name)
}
onDeleteSection={(id) => board && deleteSection(board.id, id)}
onToggleSectionCollapsed={(id) =>
board && toggleSectionCollapsed(board.id, id)
}
/>
)}
</div>
{dragOver && (
<div className="pointer-events-none absolute inset-3 z-40 rounded-2xl border-2 border-dashed border-primary/70 bg-primary/5" />
)}
<SelectionBar
count={selectedItemIds.size}
canUseReference={canUseReference}
sections={board?.sections ?? []}
onUseReference={referenceSelected}
onAssignToSection={handleAssignToSection}
onCreateSectionWithSelection={handleGroupSelectionIntoNewSection}
onDelete={deleteSelected}
onClear={clearSelection}
/>
{openItem && board && (
<ItemInspector
item={openItem}
hasPrev={openIndex > 0}
hasNext={openIndex < filtered.length - 1}
onPrev={() => setOpenId(filtered[openIndex - 1]?.id ?? null)}
onNext={() => setOpenId(filtered[openIndex + 1]?.id ?? null)}
onClose={() => setOpenId(null)}
onAddTag={(tag) => {
if (openItem.tags.includes(tag)) return;
updateItem(board.id, openItem.id, {
tags: [...openItem.tags, tag],
});
}}
onRemoveTag={(tag) =>
updateItem(board.id, openItem.id, {
tags: openItem.tags.filter((t) => t !== tag),
})
}
onSetRating={(rating) => rateItems(board.id, [openItem.id], rating)}
onUseReference={() => sendReference([openItem])}
onAddPaletteToBoard={(colors) =>
colors.forEach((c) => addColorItem(board.id, c))
}
onDelete={() => {
removeItems(board.id, [openItem.id]);
setOpenId(null);
}}
/>
)}
{modals}
</div>
);
};
@@ -0,0 +1,80 @@
import { useState } from "react";
import { HexColorPicker, HexColorInput } from "react-colorful";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faPalette } from "@fortawesome/pro-regular-svg-icons";
import { PopoverMenu } from "@storyteller/ui-popover";
import { Button } from "@storyteller/ui-button";
import { Tooltip } from "@storyteller/ui-tooltip";
const DEFAULT_COLOR = "#6384F4"; // character-selected — seeds the picker
interface Props {
onAdd: (color: string) => void;
}
// "Add color" entry. Picking only updates a local preview; nothing lands on the
// board until the explicit "Add color" button — so dragging through the spectrum
// no longer spams the board with a swatch on every tick.
export const ColorPickerPopover = ({ onAdd }: Props) => (
// Tooltip matches the sibling ToolbarIconButtons (bottom, 300ms, close on click).
<Tooltip content="Add color" position="bottom" delay={300} closeOnClick>
<PopoverMenu
mode="default"
position="bottom"
panelTitle="Add a color"
panelClassName="w-60"
triggerIcon={
<FontAwesomeIcon
icon={faPalette}
className="h-4 w-4"
aria-label="Add color"
/>
}
buttonClassName="h-9 w-9 rounded-[10px] border-transparent bg-transparent p-0 text-base-fg/80 shadow-none hover:bg-base-fg/10 hover:text-base-fg"
>
{(close) => (
<ColorPickerPanel
onAdd={(color) => {
onAdd(color);
close();
}}
/>
)}
</PopoverMenu>
</Tooltip>
);
const ColorPickerPanel = ({ onAdd }: Props) => {
const [color, setColor] = useState(DEFAULT_COLOR);
const normalized = color.toUpperCase();
return (
<div className="flex flex-col gap-3 p-1">
<HexColorPicker
color={color}
onChange={setColor}
style={{ width: "100%", height: 168 }}
/>
<div className="flex items-center gap-2">
<span
className="h-9 w-9 shrink-0 rounded-lg border border-ui-divider"
style={{ backgroundColor: normalized }}
aria-hidden
/>
<HexColorInput
color={color}
onChange={setColor}
prefixed
aria-label="Hex color"
className="h-9 w-full rounded-lg border border-ui-controls-border bg-ui-controls px-2.5 text-sm uppercase text-base-fg outline-none focus:border-primary"
/>
</div>
<Button
variant="primary"
onClick={() => onAdd(normalized)}
className="w-full justify-center"
>
Add color
</Button>
</div>
);
};
@@ -0,0 +1,35 @@
import { useEffect, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faLink } from "@fortawesome/pro-regular-svg-icons";
import { faviconOf } from "../boards/linkMeta";
interface Props {
url: string;
className?: string;
}
// A site's own favicon with a graceful link-glyph fallback when it 404s or
// isn't an image. Reset on url change so a recycled card shows the right icon.
export const Favicon = ({ url, className = "h-4 w-4" }: Props) => {
const src = faviconOf(url);
const [failed, setFailed] = useState(false);
useEffect(() => setFailed(false), [url]);
if (!src || failed) {
return (
<FontAwesomeIcon icon={faLink} className={`${className} text-base-fg/45`} />
);
}
return (
<img
src={src}
alt=""
width={16}
height={16}
draggable={false}
onError={() => setFailed(true)}
className={`${className} select-none rounded-sm object-contain`}
/>
);
};
@@ -0,0 +1,344 @@
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faXmark,
faWandMagicSparkles,
faTrashCan,
faPlus,
faChevronLeft,
faChevronRight,
} from "@fortawesome/pro-regular-svg-icons";
import { faStar } from "@fortawesome/pro-solid-svg-icons";
import { Modal } from "@storyteller/ui-modal";
import { Button } from "@storyteller/ui-button";
import { BoardItem } from "../boards/boardTypes";
import { hostnameOf } from "../boards/linkMeta";
import { extractPalette } from "../boards/palette";
import { Favicon } from "./Favicon";
interface Props {
item: BoardItem;
hasPrev: boolean;
hasNext: boolean;
onPrev: () => void;
onNext: () => void;
onClose: () => void;
onAddTag: (tag: string) => void;
onRemoveTag: (tag: string) => void;
onSetRating: (rating: number) => void;
onUseReference: () => void;
onAddPaletteToBoard: (colors: string[]) => void;
onDelete: () => void;
}
const KIND_LABEL: Record<BoardItem["kind"], string> = {
image: "Image",
video: "Video",
text: "Note",
link: "Link",
color: "Color",
};
// Lightbox + inspector. Mirrors the shared LightboxModal design: a bounded,
// centered, draggable/resizable panel with the media on the left (object-contain
// on a dark plate) and a fixed-width info/actions column on the right. The shared
// Modal owns the backdrop, Esc, drag/resize and a11y; we own ←/→ navigation.
export const ItemInspector = ({
item,
hasPrev,
hasNext,
onPrev,
onNext,
onClose,
onAddTag,
onRemoveTag,
onSetRating,
onUseReference,
onAddPaletteToBoard,
onDelete,
}: Props) => {
const [palette, setPalette] = useState<string[]>([]);
const [tagDraft, setTagDraft] = useState("");
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "ArrowLeft" && hasPrev) onPrev();
else if (e.key === "ArrowRight" && hasNext) onNext();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [onPrev, onNext, hasPrev, hasNext]);
// Pull a palette for images, keyed on the image src — NOT the whole item, so
// editing tags/rating (which produce a new item object) doesn't re-decode the
// image and flicker the palette.
const imageSrc = item.kind === "image" ? item.src : null;
useEffect(() => {
setPalette([]);
if (!imageSrc) return undefined;
let alive = true;
void extractPalette(imageSrc, 6).then((c) => {
if (alive) setPalette(c);
});
return () => {
alive = false;
};
}, [imageSrc]);
const commitTag = () => {
const t = tagDraft.trim().toLowerCase();
if (t) onAddTag(t);
setTagDraft("");
};
return (
<Modal
isOpen
onClose={onClose}
accessibleTitle="Item details"
className="rounded-xl bg-ui-modal h-[760px] w-[1200px] max-w-screen min-w-[1000px] min-h-[600px] p-4"
draggable
resizable
expandable
showClose
>
{/* Invisible drag strip along the top, matching the shared lightbox. */}
<Modal.DragHandle>
<div className="absolute left-0 top-0 z-20 h-12 w-full cursor-move rounded-t-xl" />
</Modal.DragHandle>
<div className="flex h-full gap-4">
{/* Media panel — flexible width, dark plate, contained media. */}
<div className="group/nav relative flex h-full flex-1 items-center justify-center overflow-hidden rounded-l-xl bg-black/30">
<Media item={item} />
{hasPrev && <NavArrow side="left" onClick={onPrev} />}
{hasNext && <NavArrow side="right" onClick={onNext} />}
</div>
{/* Info + actions — fixed width, scrollable body, buttons pinned bottom. */}
<div className="flex h-full w-[280px] shrink-0 flex-col">
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto pb-2 text-base-fg">
<div className="flex items-center gap-2.5 border-b border-white/5 pb-3 pr-10">
<span className="rounded-full border border-ui-panel-border px-2.5 py-1 text-[10px] font-medium uppercase tracking-[0.18em] text-base-fg/55">
{KIND_LABEL[item.kind]}
</span>
</div>
<Section title="Rating">
<div className="flex items-center gap-1">
{[1, 2, 3, 4, 5].map((n) => (
<button
key={n}
type="button"
aria-label={`Rate ${n}`}
onClick={() => onSetRating(item.rating === n ? 0 : n)}
className="rounded p-0.5 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
<FontAwesomeIcon
icon={faStar}
className={
n <= item.rating
? "h-4 w-4 text-yellow-400"
: "h-4 w-4 text-base-fg/20 transition-colors hover:text-base-fg/40"
}
/>
</button>
))}
</div>
</Section>
<Section title="Tags">
<div className="flex flex-wrap gap-1.5">
{item.tags.map((tag) => (
<button
key={tag}
type="button"
onClick={() => onRemoveTag(tag)}
className="group/tag flex items-center gap-1 rounded-full bg-base-fg/10 px-2.5 py-1 text-[11px] font-medium text-base-fg/80 transition-colors hover:bg-red/15 hover:text-red focus:outline-none focus-visible:ring-2 focus-visible:ring-red"
>
{tag}
<FontAwesomeIcon icon={faXmark} className="h-2.5 w-2.5" />
</button>
))}
<input
value={tagDraft}
onChange={(e) => setTagDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") commitTag();
}}
onBlur={commitTag}
placeholder="Add tag"
className="w-20 rounded-full bg-base-fg/5 px-2.5 py-1 text-[11px] text-base-fg placeholder:text-base-fg/40 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
/>
</div>
</Section>
{item.kind === "image" && palette.length > 0 && (
<Section title="Palette">
<div className="flex flex-wrap gap-1.5">
{palette.map((hex) => (
<button
key={hex}
type="button"
title={`${hex} — copy`}
onClick={() => {
void navigator.clipboard?.writeText(hex);
toast.success(`Copied ${hex}`);
}}
className="h-7 w-7 rounded-md ring-1 ring-inset ring-black/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
style={{ background: hex }}
/>
))}
</div>
<button
type="button"
onClick={() => onAddPaletteToBoard(palette)}
className="mt-2.5 flex items-center gap-1.5 rounded text-xs font-medium text-base-fg/60 transition-colors hover:text-base-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
<FontAwesomeIcon icon={faPlus} className="h-3 w-3" />
Add swatches to board
</button>
</Section>
)}
</div>
{/* Actions — shared Button, lightbox-style 2-col grid. */}
<div className="mt-4 grid grid-cols-2 gap-1.5">
{item.kind === "image" && (
<Button
variant="primary"
icon={faWandMagicSparkles}
onClick={onUseReference}
className="w-full col-span-2 py-1.5 text-[13px]"
>
Use as reference
</Button>
)}
<Button
variant="destructive"
icon={faTrashCan}
onClick={onDelete}
className="w-full col-span-2 py-1.5 text-[13px]"
>
Delete
</Button>
</div>
</div>
</div>
</Modal>
);
};
const Section = ({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) => (
<div className="space-y-1.5">
<div className="text-sm font-medium text-base-fg/90">{title}</div>
{children}
</div>
);
const Media = ({ item }: { item: BoardItem }) => {
switch (item.kind) {
case "image":
return (
<img
src={item.src}
alt={item.caption || ""}
className="h-full w-full object-contain"
/>
);
case "video":
return (
<video
src={item.src}
autoPlay
muted
loop
controls
playsInline
className="h-full w-full object-contain"
/>
);
case "color":
return (
<div
className="flex h-72 w-72 items-end rounded-2xl p-4 shadow-2xl"
style={{ background: item.color }}
>
<span className="rounded-full bg-black/40 px-3 py-1 text-sm font-medium uppercase tracking-wider text-white backdrop-blur">
{item.color}
</span>
</div>
);
case "text":
return (
<div className="max-h-full max-w-2xl overflow-auto rounded-2xl bg-ui-panel p-8 shadow-2xl">
<p className="whitespace-pre-wrap text-lg leading-relaxed text-base-fg">
{item.text || "Note"}
</p>
</div>
);
case "link":
return (
<a
href={item.url}
target="_blank"
rel="noreferrer"
className="flex max-w-lg flex-col overflow-hidden rounded-2xl bg-ui-panel shadow-2xl"
>
{item.image && (
<img src={item.image} alt="" className="w-full object-cover" />
)}
<div className="flex items-start gap-3 p-5">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-base-fg/8">
<Favicon url={item.url} className="h-5 w-5" />
</span>
<div className="min-w-0">
<p className="text-base font-medium text-base-fg">
{item.title || hostnameOf(item.url)}
</p>
<p className="mt-1 truncate text-sm text-base-fg/55">{item.url}</p>
</div>
</div>
</a>
);
default:
return null;
}
};
// Hover-reveal navigation arrow, styled to match the shared LightboxModal.
const NavArrow = ({
side,
onClick,
}: {
side: "left" | "right";
onClick: () => void;
}) => (
<button
type="button"
aria-label={side === "left" ? "Previous" : "Next"}
onClick={(e) => {
e.stopPropagation();
onClick();
}}
className={[
"absolute top-1/2 z-30 flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full",
"bg-black/50 text-white/70 opacity-0 transition-opacity duration-200",
"hover:bg-black/70 hover:text-white group-hover/nav:opacity-100 focus:outline-none",
side === "left" ? "left-3" : "right-3",
].join(" ")}
>
<FontAwesomeIcon
icon={side === "left" ? faChevronLeft : faChevronRight}
className="text-lg"
/>
</button>
);
@@ -0,0 +1,163 @@
import { useEffect, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faXmark,
faChevronLeft,
faChevronRight,
} from "@fortawesome/pro-regular-svg-icons";
import { BoardItem } from "../boards/boardTypes";
interface Props {
items: BoardItem[];
onClose: () => void;
}
// Distraction-free, full-bleed review deck. One item at a time, keyboard-driven
// (←/→ navigate, Esc exits). For client/team review and sharing-by-screen.
export const PresentationView = ({ items, onClose }: Props) => {
const [index, setIndex] = useState(0);
const count = items.length;
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
else if (e.key === "ArrowLeft")
setIndex((i) => (i - 1 + count) % Math.max(count, 1));
else if (e.key === "ArrowRight" || e.key === " ")
setIndex((i) => (i + 1) % Math.max(count, 1));
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [onClose, count]);
const item = items[Math.min(index, count - 1)];
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black">
<button
type="button"
aria-label="Exit presentation"
onClick={onClose}
className="absolute right-5 top-5 z-20 flex h-10 w-10 items-center justify-center rounded-full border border-white/15 bg-white/5 text-white/80 backdrop-blur-md transition-colors hover:bg-white/15 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-white/70"
>
<FontAwesomeIcon icon={faXmark} className="h-4 w-4" />
</button>
{count === 0 ? (
<p className="text-sm text-white/50">This board is empty.</p>
) : (
<>
<div className="flex h-full w-full items-center justify-center p-12">
<Stage item={item} />
</div>
{count > 1 && (
<>
<Arrow
side="left"
onClick={() =>
setIndex((i) => (i - 1 + count) % count)
}
/>
<Arrow
side="right"
onClick={() => setIndex((i) => (i + 1) % count)}
/>
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 rounded-full bg-white/10 px-3 py-1 text-xs font-medium tabular-nums text-white/70 backdrop-blur">
{index + 1} / {count}
</div>
</>
)}
</>
)}
</div>
);
};
const Stage = ({ item }: { item: BoardItem }) => {
switch (item.kind) {
case "image":
return (
<img
src={item.src}
alt={item.caption || ""}
className="max-h-full max-w-full object-contain"
/>
);
case "video":
return (
<video
src={item.src}
autoPlay
muted
loop
controls
playsInline
className="max-h-full max-w-full"
/>
);
case "color":
return (
<div
className="flex h-[60vh] w-[60vh] items-end rounded-3xl p-6"
style={{ background: item.color }}
>
<span className="rounded-full bg-black/40 px-3 py-1 text-sm font-medium uppercase tracking-wider text-white backdrop-blur">
{item.color}
</span>
</div>
);
case "text":
return (
<p className="max-w-3xl whitespace-pre-wrap text-center text-3xl font-medium leading-relaxed text-white">
{item.text || "Note"}
</p>
);
case "link":
return (
<a
href={item.url}
target="_blank"
rel="noreferrer"
className="flex max-w-2xl flex-col items-center gap-4 text-center"
>
{item.image && (
<img
src={item.image}
alt=""
className="max-h-[60vh] rounded-2xl object-contain"
/>
)}
<span className="text-xl font-medium text-white">
{item.title || item.url}
</span>
</a>
);
default:
return null;
}
};
const Arrow = ({
side,
onClick,
}: {
side: "left" | "right";
onClick: () => void;
}) => (
<button
type="button"
aria-label={side === "left" ? "Previous" : "Next"}
onClick={onClick}
className={[
"absolute top-1/2 z-20 flex h-12 w-12 -translate-y-1/2 items-center justify-center rounded-full",
"border border-white/15 bg-white/5 text-white/80 backdrop-blur-md transition-colors hover:bg-white/15 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-white/70",
side === "left" ? "left-5" : "right-5",
].join(" ")}
>
<FontAwesomeIcon
icon={side === "left" ? faChevronLeft : faChevronRight}
className="h-5 w-5"
/>
</button>
);
@@ -0,0 +1,119 @@
import { useEffect, useRef, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faChevronDown, faTrashCan } from "@fortawesome/pro-regular-svg-icons";
interface Props {
name: string;
count: number;
collapsed: boolean;
// The ungrouped lane can't be renamed, collapsed, or deleted.
editable: boolean;
onToggleCollapse: () => void;
onRename: (name: string) => void;
onDelete: () => void;
}
// One section's header row. Click the title to rename inline; the chevron
// collapses the lane; the trash removes the section (items fall back to
// ungrouped). Absolutely positioned by the grid, so it spans the content width.
export const SectionHeader = ({
name,
count,
collapsed,
editable,
onToggleCollapse,
onRename,
onDelete,
}: Props) => {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(name);
const inputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
if (editing) {
inputRef.current?.focus();
inputRef.current?.select();
}
}, [editing]);
const commit = () => {
const next = draft.trim();
if (next && next !== name) onRename(next);
setEditing(false);
};
const startEdit = () => {
if (!editable) return;
setDraft(name);
setEditing(true);
};
return (
<div className="flex h-12 items-center gap-2 pb-2 pt-3">
{editable ? (
<button
type="button"
aria-label={collapsed ? "Expand section" : "Collapse section"}
onClick={onToggleCollapse}
className="flex h-6 w-6 items-center justify-center rounded-md text-base-fg/45 transition-colors duration-150 hover:bg-base-fg/10 hover:text-base-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
<FontAwesomeIcon
icon={faChevronDown}
className={[
"h-3 w-3 transition-transform duration-200 ease-[cubic-bezier(0.4,0,0.2,1)]",
collapsed ? "-rotate-90" : "rotate-0",
].join(" ")}
/>
</button>
) : (
<span className="h-6 w-1" />
)}
{editing ? (
<input
ref={inputRef}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") commit();
else if (e.key === "Escape") setEditing(false);
}}
onBlur={commit}
className="min-w-0 rounded-md bg-base-fg/5 px-2 py-1 text-sm font-semibold text-base-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
/>
) : (
<button
type="button"
onClick={startEdit}
disabled={!editable}
className={[
"truncate rounded-md px-1.5 py-0.5 text-sm font-semibold tracking-[-0.01em] text-base-fg transition-colors",
editable
? "hover:bg-base-fg/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
: "cursor-default text-base-fg/55",
].join(" ")}
>
{name}
</button>
)}
<span className="rounded-full bg-base-fg/8 px-2 py-0.5 text-[11px] font-medium tabular-nums text-base-fg/50">
{count}
</span>
<div className="h-px flex-1 bg-ui-divider/60" />
{editable && (
<button
type="button"
aria-label="Delete section"
title="Delete section (keeps items)"
onClick={onDelete}
className="flex h-7 w-7 items-center justify-center rounded-md text-base-fg/40 opacity-0 transition-all duration-150 hover:bg-danger/10 hover:text-danger focus:outline-none focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-danger group-hover/section:opacity-100"
>
<FontAwesomeIcon icon={faTrashCan} className="h-3 w-3" />
</button>
)}
</div>
);
};
@@ -0,0 +1,206 @@
import { ReactNode, useEffect, useRef, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faTrashCan,
faXmark,
faWandMagicSparkles,
faLayerGroup,
faPlus,
} from "@fortawesome/pro-regular-svg-icons";
import { BoardSection } from "../boards/boardTypes";
interface Props {
count: number;
// True when the selection has at least one reference-capable item (an image
// with a media token). Notes/colors/links can't be used as a reference.
canUseReference: boolean;
sections: BoardSection[];
onUseReference: () => void;
onAssignToSection: (sectionId: string | null) => void;
onCreateSectionWithSelection: () => void;
onDelete: () => void;
onClear: () => void;
}
// Floats above the grid when items are multi-selected. "Move to" assigns the
// selection into a section lane; "Use as reference" is the board→generate loop.
export const SelectionBar = ({
count,
canUseReference,
sections,
onUseReference,
onAssignToSection,
onCreateSectionWithSelection,
onDelete,
onClear,
}: Props) => {
if (count === 0) return null;
return (
<div className="pointer-events-none absolute inset-x-0 bottom-6 z-30 flex justify-center">
{/* Styled to match the webapp library's multiselect bar: solid pill, filled
control buttons, red delete, round clear. */}
<div className="pointer-events-auto flex items-center gap-2 rounded-full border border-ui-panel-border bg-ui-panel/95 px-2.5 py-2 shadow-xl backdrop-blur">
<span className="px-1 text-sm font-medium text-white/80">
{count} selected
</span>
{/* Triage shortcut discoverability — these keys act on the selection. */}
<span className="hidden items-center gap-1 text-[11px] text-white/40 sm:flex">
<Kbd>1–5</Kbd> rate
<Kbd>0</Kbd> clear
</span>
<MoveToMenu
sections={sections}
onAssignToSection={onAssignToSection}
onCreateSectionWithSelection={onCreateSectionWithSelection}
/>
{canUseReference && (
<button
type="button"
onClick={onUseReference}
className="flex items-center gap-2 rounded-full bg-ui-controls/60 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-ui-controls/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
<FontAwesomeIcon icon={faWandMagicSparkles} className="text-xs" />
Use as reference
</button>
)}
<button
type="button"
onClick={onDelete}
className="flex items-center gap-2 rounded-full bg-red/90 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-red focus:outline-none focus-visible:ring-2 focus-visible:ring-red"
>
<FontAwesomeIcon icon={faTrashCan} className="text-xs" />
Delete
</button>
<button
type="button"
aria-label="Clear selection"
onClick={onClear}
className="flex h-8 w-8 items-center justify-center rounded-full bg-ui-controls/60 text-white transition-colors hover:bg-ui-controls/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
<FontAwesomeIcon icon={faXmark} />
</button>
</div>
</div>
);
};
const MoveToMenu = ({
sections,
onAssignToSection,
onCreateSectionWithSelection,
}: {
sections: BoardSection[];
onAssignToSection: (sectionId: string | null) => void;
onCreateSectionWithSelection: () => void;
}) => {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!open) return undefined;
const onDown = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
window.addEventListener("mousedown", onDown);
window.addEventListener("keydown", onKey);
return () => {
window.removeEventListener("mousedown", onDown);
window.removeEventListener("keydown", onKey);
};
}, [open]);
const pick = (fn: () => void) => {
fn();
setOpen(false);
};
return (
<div ref={ref} className="relative">
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-haspopup="menu"
aria-expanded={open}
className="flex items-center gap-2 rounded-full bg-ui-controls/60 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-ui-controls/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
<FontAwesomeIcon icon={faLayerGroup} className="text-xs" />
Move to
</button>
{open && (
<div
role="menu"
className="absolute bottom-[calc(100%+8px)] left-1/2 z-40 max-h-72 w-52 -translate-x-1/2 overflow-y-auto rounded-lg border border-ui-panel-border bg-ui-panel p-2 shadow-xl"
>
<MenuItem
label="Ungrouped"
muted
onClick={() => pick(() => onAssignToSection(null))}
/>
{sections.length > 0 && (
<div className="mx-1.5 my-1 border-t border-ui-panel-border" />
)}
{sections.map((s) => (
<MenuItem
key={s.id}
label={s.name}
onClick={() => pick(() => onAssignToSection(s.id))}
/>
))}
<div className="mx-1.5 my-1 border-t border-ui-panel-border" />
<MenuItem
label="New section"
icon={faPlus}
accent
onClick={() => pick(onCreateSectionWithSelection)}
/>
</div>
)}
</div>
);
};
const MenuItem = ({
label,
icon,
muted,
accent,
onClick,
}: {
label: string;
icon?: typeof faPlus;
muted?: boolean;
accent?: boolean;
onClick: () => void;
}) => (
<button
type="button"
role="menuitem"
onClick={onClick}
className={[
"flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-left text-sm transition-colors",
"focus:outline-none focus-visible:ring-2 focus-visible:ring-primary",
accent
? "text-primary hover:bg-ui-controls/50"
: muted
? "text-white/50 hover:bg-ui-controls/50 hover:text-white"
: "text-white hover:bg-ui-controls/50",
].join(" ")}
>
{icon ? (
<FontAwesomeIcon icon={icon} className="h-3 w-3 shrink-0 opacity-70" />
) : (
<span className="h-3 w-3 shrink-0" />
)}
<span className="truncate">{label}</span>
</button>
);
const Kbd = ({ children }: { children: ReactNode }) => (
<kbd className="rounded border border-ui-panel-border bg-white/5 px-1 py-px font-sans text-[10px] text-white/60">
{children}
</kbd>
);
@@ -0,0 +1,71 @@
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faMagnifyingGlass, faXmark } from "@fortawesome/pro-regular-svg-icons";
interface Props {
query: string;
onQueryChange: (q: string) => void;
tags: string[];
activeTags: string[];
onToggleTag: (tag: string) => void;
}
// Text search + tag-chip filtering. Color / visual ("find similar") search is
// deferred to Phase 3; this is the table-stakes text layer.
export const SmartSearchBar = ({
query,
onQueryChange,
tags,
activeTags,
onToggleTag,
}: Props) => {
return (
<div className="flex min-w-0 items-center gap-2">
<div className="bg-ui-controls flex h-9 min-w-[200px] transition-all items-center gap-2 rounded-xl border border-ui-controls px-3 focus-within:ring-2 focus-within:ring-primary/50">
<FontAwesomeIcon
icon={faMagnifyingGlass}
className="h-3.5 w-3.5 text-base-fg/45"
/>
<input
value={query}
onChange={(e) => onQueryChange(e.target.value)}
placeholder="Search this board"
className="w-full bg-transparent text-sm text-base-fg placeholder:text-base-fg/40 focus:outline-none"
/>
{query && (
<button
type="button"
aria-label="Clear search"
onClick={() => onQueryChange("")}
className="rounded-full text-base-fg/45 transition-colors hover:text-base-fg focus:outline-none focus-visible:text-base-fg focus-visible:ring-2 focus-visible:ring-primary"
>
<FontAwesomeIcon icon={faXmark} className="h-3.5 w-3.5" />
</button>
)}
</div>
{tags.length > 0 && (
<div className="flex min-w-0 items-center gap-1.5 overflow-x-auto">
{tags.map((tag) => {
const active = activeTags.includes(tag);
return (
<button
key={tag}
type="button"
onClick={() => onToggleTag(tag)}
className={[
"shrink-0 rounded-full px-2.5 py-1 text-[11px] font-medium transition-colors duration-150",
"focus:outline-none focus-visible:ring-2 focus-visible:ring-primary",
active
? "bg-primary text-white"
: "bg-base-fg/8 text-base-fg/70 hover:bg-base-fg/15",
].join(" ")}
>
{tag}
</button>
);
})}
</div>
)}
</div>
);
};
@@ -0,0 +1,106 @@
import { describe, expect, it } from "vitest";
import {
PositionedItem,
SECTION_HEADER_HEIGHT,
computeMasonryLayout,
computeSectionedLayout,
nearestInDirection,
} from "./gridLayout";
const DENSITY = {
targetColumnWidth: 280,
gap: 20,
minColumns: 1,
maxColumns: 8,
};
// A 3x3 grid of 100x100 tiles on a 20px pitch — centers at 50/170/290.
const GRID: Record<string, PositionedItem> = {};
["a", "b", "c", "d", "e", "f", "g", "h", "i"].forEach((id, idx) => {
const col = idx % 3;
const row = Math.floor(idx / 3);
GRID[id] = { id, x: col * 120, y: row * 120, width: 100, height: 100 };
});
// Layout: a b c
// d e f
// g h i
describe("nearestInDirection", () => {
it("moves to the adjacent tile on each axis from the center", () => {
expect(nearestInDirection(GRID, "e", "left")).toBe("d");
expect(nearestInDirection(GRID, "e", "right")).toBe("f");
expect(nearestInDirection(GRID, "e", "up")).toBe("b");
expect(nearestInDirection(GRID, "e", "down")).toBe("h");
});
it("returns null when there is nothing in that direction", () => {
expect(nearestInDirection(GRID, "a", "left")).toBeNull();
expect(nearestInDirection(GRID, "a", "up")).toBeNull();
expect(nearestInDirection(GRID, "i", "right")).toBeNull();
expect(nearestInDirection(GRID, "i", "down")).toBeNull();
});
it("stays in the same row/column rather than jumping diagonally", () => {
// From the top-left, moving down should land on the tile directly below
// (same column), never the closer-by-raw-distance diagonal neighbor.
expect(nearestInDirection(GRID, "a", "down")).toBe("d");
expect(nearestInDirection(GRID, "a", "right")).toBe("b");
});
it("returns null for an unknown origin id", () => {
expect(nearestInDirection(GRID, "zzz", "down")).toBeNull();
});
});
describe("computeMasonryLayout", () => {
it("indexes every item in byId and keeps positions in sync", () => {
const layout = computeMasonryLayout(
[
{ id: "x", aspect: 1 },
{ id: "y", aspect: 1.5 },
],
600,
DENSITY,
);
expect(Object.keys(layout.byId)).toHaveLength(2);
expect(layout.byId.x).toBe(layout.positions[0]);
});
});
describe("computeSectionedLayout", () => {
it("emits a band per group and stacks them top-to-bottom", () => {
const layout = computeSectionedLayout(
[
{ id: null, name: "Ungrouped", collapsed: false, items: [{ id: "a", aspect: 1 }] },
{ id: "s1", name: "Refs", collapsed: false, items: [{ id: "b", aspect: 1 }] },
],
600,
DENSITY,
);
expect(layout.bands.map((b) => b.id)).toEqual([null, "s1"]);
expect(layout.bands[0].headerY).toBe(0);
// Second band starts below the first header + its single row of content.
expect(layout.bands[1].headerY).toBeGreaterThan(SECTION_HEADER_HEIGHT);
});
it("offsets a section's cards below its own header", () => {
const layout = computeSectionedLayout(
[{ id: "s1", name: "Refs", collapsed: false, items: [{ id: "b", aspect: 1 }] }],
600,
DENSITY,
);
expect(layout.byId.b.y).toBeGreaterThanOrEqual(SECTION_HEADER_HEIGHT);
});
it("renders a header but no cards for a collapsed section", () => {
const layout = computeSectionedLayout(
[{ id: "s1", name: "Refs", collapsed: true, items: [{ id: "b", aspect: 1 }] }],
600,
DENSITY,
);
expect(layout.bands).toHaveLength(1);
expect(layout.bands[0].count).toBe(1);
expect(layout.positions).toHaveLength(0);
expect(layout.byId.b).toBeUndefined();
});
});
@@ -0,0 +1,229 @@
import { GridDensity } from "../boards/boardTypes";
export interface DensityConfig {
targetColumnWidth: number;
gap: number;
minColumns: number;
maxColumns: number;
}
// Density presets map to a target column width + gap. The masonry resolves the
// actual column count + width from the container, so these are targets not hard
// sizes (Savee-style adjustable density).
export const DENSITY_CONFIG: Record<GridDensity, DensityConfig> = {
comfortable: { targetColumnWidth: 280, gap: 20, minColumns: 1, maxColumns: 8 },
cozy: { targetColumnWidth: 216, gap: 14, minColumns: 1, maxColumns: 10 },
compact: { targetColumnWidth: 164, gap: 10, minColumns: 2, maxColumns: 14 },
};
export const DENSITY_ORDER: GridDensity[] = ["compact", "cozy", "comfortable"];
export interface PositionedItem {
id: string;
x: number;
y: number;
width: number;
height: number;
}
export interface MasonryLayout {
positions: PositionedItem[];
byId: Record<string, PositionedItem>;
totalHeight: number;
columnWidth: number;
columnCount: number;
}
const MIN_ITEM_HEIGHT = 48;
// Classic shortest-column masonry. Heights come from each item's deterministic
// `aspect`, so the full layout is known without measuring the DOM — which is
// exactly what lets the grid virtualize by slicing on y.
export const computeMasonryLayout = (
items: Array<{ id: string; aspect: number }>,
containerWidth: number,
density: DensityConfig,
): MasonryLayout => {
const usable = Math.max(containerWidth, density.targetColumnWidth);
const rawColumns = Math.round(usable / (density.targetColumnWidth + density.gap));
const columnCount = Math.min(
density.maxColumns,
Math.max(density.minColumns, rawColumns || 1),
);
const totalGap = density.gap * (columnCount - 1);
const columnWidth = (usable - totalGap) / columnCount;
const columnHeights = new Array<number>(columnCount).fill(0);
const positions: PositionedItem[] = [];
const byId: Record<string, PositionedItem> = {};
for (const item of items) {
let col = 0;
for (let c = 1; c < columnCount; c++) {
if (columnHeights[c] < columnHeights[col]) col = c;
}
// Guard against a non-finite aspect (a single NaN would poison totalHeight
// and collapse the whole virtualized slice).
const aspect = Number.isFinite(item.aspect) && item.aspect > 0 ? item.aspect : 1;
const height = Math.max(columnWidth * aspect, MIN_ITEM_HEIGHT);
const pos: PositionedItem = {
id: item.id,
x: col * (columnWidth + density.gap),
y: columnHeights[col],
width: columnWidth,
height,
};
positions.push(pos);
byId[item.id] = pos;
columnHeights[col] = pos.y + height + density.gap;
}
const tallest = columnHeights.reduce((m, h) => Math.max(m, h), 0);
return {
positions,
byId,
totalHeight: Math.max(0, tallest - density.gap),
columnWidth,
columnCount,
};
};
// ---------- sectioned layout ----------
export const SECTION_HEADER_HEIGHT = 48;
export const SECTION_BOTTOM_GAP = 20;
export interface SectionGroup {
// null id = the ungrouped default flow.
id: string | null;
name: string;
collapsed: boolean;
items: Array<{ id: string; aspect: number }>;
}
export interface SectionBand {
id: string | null;
name: string;
count: number;
collapsed: boolean;
// Absolute y (content space) of the header row's top edge.
headerY: number;
}
export interface SectionedLayout extends MasonryLayout {
bands: SectionBand[];
}
// Stacks a masonry per section within one content plane: a header band, then the
// section's (non-collapsed) masonry, then a gap, repeated. Positions are absolute
// in the shared content space — so virtualization, marquee hit-testing, and
// spatial keyboard nav all keep working over the flat `positions`/`byId`.
export const computeSectionedLayout = (
groups: SectionGroup[],
containerWidth: number,
density: DensityConfig,
): SectionedLayout => {
let cursorY = 0;
const positions: PositionedItem[] = [];
const byId: Record<string, PositionedItem> = {};
const bands: SectionBand[] = [];
let columnWidth = 0;
let columnCount = 0;
for (const group of groups) {
bands.push({
id: group.id,
name: group.name,
count: group.items.length,
collapsed: group.collapsed,
headerY: cursorY,
});
cursorY += SECTION_HEADER_HEIGHT;
if (!group.collapsed && group.items.length > 0) {
const sub = computeMasonryLayout(group.items, containerWidth, density);
columnWidth = sub.columnWidth;
columnCount = sub.columnCount;
for (const p of sub.positions) {
const moved: PositionedItem = { ...p, y: p.y + cursorY };
positions.push(moved);
byId[p.id] = moved;
}
cursorY += sub.totalHeight;
}
cursorY += SECTION_BOTTOM_GAP;
}
return {
positions,
byId,
bands,
totalHeight: Math.max(0, cursorY - SECTION_BOTTOM_GAP),
columnWidth,
columnCount,
};
};
export type NavDirection = "up" | "down" | "left" | "right";
// Spatial navigation: the nearest card to `fromId` in a direction, biased toward
// staying in the same row/column (cross-axis distance weighted more). Used for
// arrow-key focus movement across the masonry.
export const nearestInDirection = (
byId: Record<string, PositionedItem>,
fromId: string,
dir: NavDirection,
): string | null => {
const from = byId[fromId];
if (!from) return null;
const fcx = from.x + from.width / 2;
const fcy = from.y + from.height / 2;
let best: string | null = null;
let bestScore = Infinity;
for (const p of Object.values(byId)) {
if (p.id === fromId) continue;
const cx = p.x + p.width / 2;
const cy = p.y + p.height / 2;
const dx = cx - fcx;
const dy = cy - fcy;
let primary: number;
let cross: number;
if (dir === "right") {
if (dx <= 1) continue;
primary = dx;
cross = Math.abs(dy);
} else if (dir === "left") {
if (dx >= -1) continue;
primary = -dx;
cross = Math.abs(dy);
} else if (dir === "down") {
if (dy <= 1) continue;
primary = dy;
cross = Math.abs(dx);
} else {
if (dy >= -1) continue;
primary = -dy;
cross = Math.abs(dx);
}
const score = primary + cross * 2;
if (score < bestScore) {
bestScore = score;
best = p.id;
}
}
return best;
};
// Returns only positions intersecting the scroll viewport (± buffer). Linear
// scan is fine to ~thousands of items; positions are already y-ordered enough
// for the predicate to be cheap.
export const sliceVisible = (
positions: PositionedItem[],
scrollTop: number,
viewportHeight: number,
buffer: number,
): PositionedItem[] => {
const top = scrollTop - buffer;
const bottom = scrollTop + viewportHeight + buffer;
return positions.filter((p) => p.y + p.height >= top && p.y <= bottom);
};
@@ -0,0 +1,41 @@
import { useEffect } from "react";
import { useBoardLibraryStore } from "../boards/BoardLibraryStore";
import { measureImage } from "../boards/measureMedia";
import {
DroppedGalleryItem,
GALLERY_MOODBOARD_DROP_EVENT,
} from "../galleryDrop";
interface GalleryMoodboardDropDetail {
item: DroppedGalleryItem;
}
// Grid counterpart to the canvas's useGalleryDropEvent: appends a dropped
// gallery item to the active board. The masonry is position-independent, so we
// ignore the drop coordinates the canvas uses for placement.
export const useGalleryBoardDrop = (active: boolean) => {
useEffect(() => {
if (!active) return undefined;
const handler = async (e: Event) => {
const ce = e as CustomEvent<GalleryMoodboardDropDetail>;
const item = ce.detail?.item;
const url = item?.fullImage || item?.thumbnail;
if (!url) return;
const store = useBoardLibraryStore.getState();
const boardId = store.ensureActiveBoard();
const dims = await measureImage(url);
store.addImageItem(boardId, {
src: url,
mediaId: item.id ?? null,
naturalW: dims.w,
naturalH: dims.h,
});
};
window.addEventListener(GALLERY_MOODBOARD_DROP_EVENT, handler);
return () =>
window.removeEventListener(GALLERY_MOODBOARD_DROP_EVENT, handler);
}, [active]);
};
@@ -0,0 +1,64 @@
import { ReactNode } from "react";
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import { Button } from "@storyteller/ui-button";
import { Tooltip } from "@storyteller/ui-tooltip";
import { twMerge } from "tailwind-merge";
// Shared chrome for the moodboard's floating top bars. The Grid and Canvas
// toolbars render inside the same glass island with the same buttons and
// dividers, so the two views read as one surface — in line with the
// create-image/video promptbox language (`.glass` + rounded-2xl).
interface ToolbarShellProps {
children: ReactNode;
className?: string;
}
export const ToolbarShell = ({ children, className }: ToolbarShellProps) => (
<div
className={twMerge(
"glass pointer-events-auto flex items-center gap-2 rounded-2xl border border-ui-divider p-2 text-base-fg shadow-[0_8px_28px_-12px_rgba(0,0,0,0.45)]",
className,
)}
>
{children}
</div>
);
export const ToolbarDivider = () => (
<span className="h-7 w-px shrink-0 bg-ui-divider" aria-hidden />
);
interface ToolbarIconButtonProps {
icon: IconDefinition;
label: string;
active?: boolean;
disabled?: boolean;
onClick: () => void;
tooltipDelay?: number;
}
export const ToolbarIconButton = ({
icon,
label,
active,
disabled,
onClick,
tooltipDelay = 300,
}: ToolbarIconButtonProps) => (
<Tooltip content={label} position="bottom" delay={tooltipDelay} closeOnClick>
<Button
variant="ghost"
icon={icon}
aria-label={label}
onClick={onClick}
disabled={disabled}
className={twMerge(
"h-9 w-9 rounded-[10px] border-transparent p-0 shadow-none",
active
? "bg-primary/30 text-base-fg hover:bg-primary/40"
: "bg-transparent text-base-fg/80 hover:bg-base-fg/10 hover:text-base-fg",
)}
/>
</Tooltip>
);
@@ -0,0 +1,25 @@
{
"files": [],
"include": [],
"references": [
{
"path": "../popover"
},
{
"path": "../button-icon-select"
},
{
"path": "../tooltip"
},
{
"path": "../button"
},
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
],
"extends": "../../../tsconfig.base.json"
}
@@ -0,0 +1,41 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"lib": ["dom", "esnext"],
"types": [
"node",
"@nx/react/typings/cssmodule.d.ts",
"@nx/react/typings/image.d.ts",
"vite/client"
],
"rootDir": "src",
"jsx": "react-jsx",
"tsBuildInfoFile": "dist/tsconfig.lib.tsbuildinfo"
},
"exclude": [
"out-tsc",
"dist",
"**/*.spec.ts",
"**/*.test.ts",
"**/*.spec.tsx",
"**/*.test.tsx",
"vite.config.ts",
"vite.config.mts"
],
"include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"],
"references": [
{
"path": "../popover/tsconfig.lib.json"
},
{
"path": "../button-icon-select/tsconfig.lib.json"
},
{
"path": "../tooltip/tsconfig.lib.json"
},
{
"path": "../button/tsconfig.lib.json"
}
]
}
@@ -0,0 +1,28 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./out-tsc/vitest",
"types": [
"vitest/globals",
"vitest/importMeta",
"vite/client",
"node",
"vitest"
],
"jsx": "react-jsx"
},
"include": [
"vite.config.ts",
"vite.config.mts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.d.ts"
],
"references": [
{
"path": "./tsconfig.lib.json"
}
]
}
@@ -0,0 +1,46 @@
/// <reference types='vitest' />
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import dts from "vite-plugin-dts";
import * as path from "path";
import { isExternal } from "../../shared-vite-config";
export default defineConfig(() => ({
root: __dirname,
cacheDir: "../../../node_modules/.vite/libs/components/moodboard",
plugins: [
react(),
dts({
entryRoot: "src",
tsconfigPath: path.join(__dirname, "tsconfig.lib.json"),
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
commonjsOptions: {
transformMixedEsModules: true,
},
lib: {
entry: "src/index.ts",
name: "@storyteller/ui-moodboard",
fileName: "index",
formats: ["es" as const],
},
rollupOptions: {
external: isExternal,
},
},
test: {
watch: false,
globals: true,
environment: "jsdom",
include: ["{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"],
reporters: ["default"],
coverage: {
reportsDirectory: "./test-output/vitest/coverage",
provider: "v8" as const,
},
},
}));
+8
View File
@@ -669,6 +669,10 @@
"name": "@storyteller/ui-model-selector",
"version": "0.0.1"
},
"libs/components/moodboard": {
"name": "@storyteller/ui-moodboard",
"version": "0.0.1"
},
"libs/components/pagedraw": {
"name": "@storyteller/ui-pagedraw",
"version": "0.0.1"
@@ -10969,6 +10973,10 @@
"resolved": "libs/components/model-selector",
"link": true
},
"node_modules/@storyteller/ui-moodboard": {
"resolved": "libs/components/moodboard",
"link": true
},
"node_modules/@storyteller/ui-pagedraw": {
"resolved": "libs/components/pagedraw",
"link": true
+3
View File
@@ -209,6 +209,9 @@
},
{
"path": "./libs/components/generation-list"
},
{
"path": "./libs/components/moodboard"
}
]
}