mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-22 01:29:31 -04:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
532bd2db4a | ||
|
|
3cb2e6de6a | ||
|
|
8334e43886 | ||
|
|
43de77f2a6 | ||
|
|
9abbbd686e | ||
|
|
a7b887e11c | ||
|
|
da4bde24e1 | ||
|
|
b68fe3c563 | ||
|
|
747174b608 | ||
|
|
1ab4627545 |
@@ -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.**
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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]);
|
||||
};
|
||||
+77
@@ -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]);
|
||||
};
|
||||
+74
@@ -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]);
|
||||
};
|
||||
+181
@@ -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,
|
||||
},
|
||||
},
|
||||
}));
|
||||
Generated
+8
@@ -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
|
||||
|
||||
@@ -209,6 +209,9 @@
|
||||
},
|
||||
{
|
||||
"path": "./libs/components/generation-list"
|
||||
},
|
||||
{
|
||||
"path": "./libs/components/moodboard"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user