Compare commits

...
Author SHA1 Message Date
Brandon Thomas 8e3f6628c1 docs 2026-06-24 00:23:52 -04:00
Brandon Thomas 214eda6818 update docs 2026-06-24 00:21:07 -04:00
Brandon Thomas 47648b19ae remove fields, document 2026-06-23 23:46:18 -04:00
Brandon Thomas 379e528e0a new endpoints 2026-06-23 23:36:35 -04:00
Brandon Thomas 9965e2544d cleanup docs 2026-06-23 23:19:55 -04:00
Tanish MankuandBFlat 61a0880c80 Moodboard: Extensive feature support (#1661)
Co-authored-by: BFlat <bombayapirat@gmail.com>
2026-06-23 20:15:52 -07:00
Tanish Manku c5c68d301a Webapp: Premium motion pass across app (#1662)
Motion foundation + expressive entrance/interaction polish for the
webapp, following a "balanced" direction (restrained chrome, expressive
canvas).

Foundation
- Motion tokens: --ease-*/--dur-* CSS vars (styles.css) mirrored as JS
constants/presets in lib/motion.ts; prefers-reduced-motion respected.
- Shared Reveal/RevealGroup primitive (components/motion) — fade-up +
stagger.

Chrome & navigation
- Global route fade (components/motion/page-transition) keyed by
section.
- Sidebar active-indicator slide via framer layoutId + icon hover.

Pages
- Home: hero/eyebrow reveal, staggered launcher cards, spring lift/press
hover, double-bezel cards, nested arrow chip.
- Auth: AuthHeader staggers in (login+signup); form blocks cascade
after.
- Pricing: wire the orphaned data-reveal hero through the real Reveal.
- Library: sliding active indicator on tab/filter switchers (layoutId),
shimmer-sweep loading skeleton.

Pagescene entrance animations (user-drop only)
- EntranceAnimator: meshes get a transform-based pop/rise/fall drop with
a rubbery squash overshoot; splats get a per-gaussian radial settle
(Spark dyno) with center-out fade + height drop. Dev-only speed slider.

Gallery + asset drag-under UX
- On drag-start the gallery/asset modal becomes pointer-transparent so
the drag passes under onto the canvas; steps back to translucent (reopen
on) or eases fully out (reopen off), all interpolated. Shared DragGhost
(lift/tilt/follow) feeds both DnD systems. Media fades to placeholders
so thumbnails don't block the canvas. Asset modal always reopens fully
visible.

PageDraw: entrance fade on add + workspace vignette.

Dev-only (local testing): drop setDevelopment() API host pin; add /__cdn
proxy + maybeProxyCdnUrl so CDN splat/GLB assets dodge CORS.
2026-06-23 20:14:06 -07:00
144 changed files with 11254 additions and 341 deletions
+232 -32
View File
@@ -1,5 +1,10 @@
# ArtCraft Omni API
> # 📚 Full API reference: **<https://storyteller-docs.netlify.app/>**
>
> **Every ArtCraft API endpoint is documented there.** This guide covers only the Omni API video
> endpoints — for everything else, start at the link above.
The **Omni API** is the API-key–authenticated surface for ArtCraft generation. It mirrors the
in-app "Omni Gen" endpoints but is designed for programmatic use: you authenticate with an API key
in the `Authorization` header instead of a browser session cookie.
@@ -25,43 +30,21 @@ All paths below are relative to the base URL (e.g. `POST /v1/omni_api/generate/v
The examples in this guide use the **development** base URL. To run against production, swap
`http://localhost:12345` for `https://api.storyteller.ai`.
Note: Our APIs are hosted on "`api.storyteller.ai`", not "`api.getartcraft.com`". This is not a
typo. Our Cloudflare CDN URLs are `cdn-2.fakeyou.com`.
---
## Getting an API key
API keys are created from a logged-in session. You only need to do this once; store the key
somewhere safe.
Create and manage API keys from the ArtCraft web app:
1. **Log in** to obtain a session (browser, or the `/v1/login` endpoint).
2. **Create a key** by calling `POST /v1/api_keys/create` with your session:
1. Go to **<https://app.getartcraft.com/>**
2. Click **Account → Settings → API Keys** (your account must have API keys enabled from our staff)
3. Create a key and copy it. The secret is shown **once** — store it somewhere safe.
```bash
curl -s -c /tmp/cookies.txt -X POST http://localhost:12345/v1/login \
-H "Content-Type: application/json" \
-d '{"username_or_email":"YOUR_USERNAME","password":"YOUR_PASSWORD"}'
curl -s -b /tmp/cookies.txt -X POST http://localhost:12345/v1/api_keys/create \
-H "Content-Type: application/json" \
-d '{"name":"my-integration","maybe_description":"server-to-server video gen"}'
```
The response returns the secret **once** — it can never be retrieved again:
```json
{
"success": true,
"api_key_token": "api_key_xxxxxxxxxxxxxxxxxxxxxxxxx",
"api_key": "artcraft_api_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
}
```
- `api_key` — the **secret**. Store it now; treat it like a password. This is the value you put
in the `Authorization` header.
- `api_key_token` — a non-secret handle (prefix `api_key_`) used to manage the key later
(get / update / delete). Safe to log and store.
> **403 Forbidden when creating a key?** API-key creation is gated per account. Contact the
> Storyteller team to enable API access for your account.
> **Don't see the API Keys section?** API access is gated per account. Contact the Artcraft team
> to enable it.
### What an API key looks like
@@ -69,7 +52,7 @@ somewhere safe.
- Followed by 40 random lowercase Crockford-base32 characters (`0-9 a-z`, excluding `i l o u`)
- Total length: **53 characters**, e.g. `artcraft_api_3k7q9w0xv2hs5n8m4d6r1t8y0p2s4f6h8j0k2m4n6`
Never commit a key to source control or paste it into logs.
Treat the key like a password: never commit it to source control or paste it into logs.
---
@@ -412,6 +395,223 @@ console.log(resp.status, await resp.json());
---
## Checking job status
Generation is asynchronous. The generate call returns an `inference_job_token`; poll the job-status
endpoint until the job reaches a terminal state, then read the result URL.
### Endpoints
- **Single job:** `GET /v1/omni_api/job_status/job/{token}`
- **Batch (many jobs):** `GET /v1/omni_api/job_status/batch?tokens=jinf_aaa&tokens=jinf_bbb`
Both require the same `Authorization: Bearer …` header (no cookies).
### Response shape
```json
{
"success": true,
"state": {
"job_token": "jinf_xxxxxxxxxxxxxxxxxxxxxxxxx",
"request": {
"inference_category": "video_generation",
"maybe_prompt_token": "prompt_xxxxxxxxxxxxxxxxxxxxxxxxx",
"maybe_model_type": "seedance_2p0",
"maybe_model_token": null
},
"status": {
"status": "pending",
"maybe_first_started_at": null,
"maybe_failure_category": null,
"progress_percentage": 0
},
"maybe_result": null,
"created_at": "2026-06-24T04:06:14Z",
"updated_at": "2026-06-24T04:06:14Z"
}
}
```
- `state.status.status` — the job state. Keep polling while it's `pending`, `started`, or
`attempt_failed`. **Terminal** states are `complete_success`, `complete_failure`, `dead`,
`cancelled_by_user`, and `cancelled_by_system`.
- `state.status.progress_percentage` — an integer 0–100.
- `state.maybe_result` — `null` until the job succeeds. On `complete_success` it contains the
finished media; the playable/downloadable URL is **`state.maybe_result.media_links.cdn_url`**.
For video, `media_links.maybe_video_previews` also carries still/animated preview URLs.
The batch endpoint returns `{ "success": true, "job_states": [ … ] }` where each entry is the same
`state` payload as above.
### Where the result video / image lives
When `state.status.status` is `complete_success`, the finished media is under `state.maybe_result`:
- **`state.maybe_result.media_links.cdn_url`** — the primary, playable/downloadable URL of the
finished asset. **This is the field you want.** For a video job it's the `.mp4`; for an image job
it's the image file (`.jpg` / `.png` / etc.).
- `state.maybe_result.entity_token` — the `m_…` media-file token for the result.
- `state.maybe_result.media_links.maybe_video_previews` — **video jobs only**. Contains a `still`
(poster `.jpg`) and an `animated` (`.gif`) preview URL, plus `{WIDTH}` thumbnail templates. This
is `null` for image jobs.
- `state.maybe_result.media_links.maybe_thumbnail_template` — **image jobs only**. A URL template;
replace `{WIDTH}` with a pixel width to fetch a resized thumbnail. `null` for video jobs.
**Example — completed video job:**
```json
{
"success": true,
"state": {
"job_token": "jinf_9fcn2qv4dv3vvbrrxbnmfgen06v",
"request": {
"inference_category": "video_generation",
"maybe_prompt_token": "prompt_q53tp4rh97qh8q159p21tazzt",
"maybe_model_type": "seedance_2p0",
"maybe_model_token": null
},
"status": {
"status": "complete_success",
"maybe_first_started_at": "2026-06-24T04:07:11Z",
"maybe_failure_category": null,
"progress_percentage": 100
},
"maybe_result": {
"entity_type": "media_file",
"entity_token": "m_ss682134tx19yhqs5y0nbnd0bd59a6",
"media_links": {
"cdn_url": "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/media/8/p/n/0/4/8pn04v7q3vfhf60wy173f6awcwkre1qj/artcraft_8pn04v7q3vfhf60wy173f6awcwkre1qj.mp4",
"maybe_thumbnail_template": null,
"maybe_video_previews": {
"still": "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/media/8/p/n/0/4/8pn04v7q3vfhf60wy173f6awcwkre1qj/artcraft_8pn04v7q3vfhf60wy173f6awcwkre1qj.mp4-thumb.jpg",
"animated": "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/media/8/p/n/0/4/8pn04v7q3vfhf60wy173f6awcwkre1qj/artcraft_8pn04v7q3vfhf60wy173f6awcwkre1qj.mp4-thumb.gif",
"still_thumbnail_template": "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/media/8/p/n/0/4/8pn04v7q3vfhf60wy173f6awcwkre1qj/artcraft_8pn04v7q3vfhf60wy173f6awcwkre1qj.mp4-thumb-{WIDTH}.jpg",
"animated_thumbnail_template": "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/media/8/p/n/0/4/8pn04v7q3vfhf60wy173f6awcwkre1qj/artcraft_8pn04v7q3vfhf60wy173f6awcwkre1qj.mp4-thumb-{WIDTH}.gif"
}
},
"maybe_successfully_completed_at": "2026-06-24T04:17:30Z"
},
"created_at": "2026-06-24T04:06:14Z",
"updated_at": "2026-06-24T04:17:30Z"
}
}
```
→ The video is at
`https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/media/8/p/n/0/4/8pn04v7q3vfhf60wy173f6awcwkre1qj/artcraft_8pn04v7q3vfhf60wy173f6awcwkre1qj.mp4`
(example, not a live URL).
**Example — completed image job** (from `POST /v1/omni_api/generate/image`):
```json
{
"maybe_result": {
"entity_type": "media_file",
"entity_token": "m_3kq9w0xv2hs5n8m4d6r1t8y0p2s4f6",
"media_links": {
"cdn_url": "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/media/3/k/q/9/w/3kq9w0xv2hs5n8m4d6r1t8y0p2s4f6/artcraft_3kq9w0xv2hs5n8m4d6r1t8y0p2s4f6.jpg",
"maybe_thumbnail_template": "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/media/3/k/q/9/w/3kq9w0xv2hs5n8m4d6r1t8y0p2s4f6/artcraft_3kq9w0xv2hs5n8m4d6r1t8y0p2s4f6.jpg-thumb-{WIDTH}.jpg",
"maybe_video_previews": null
},
"maybe_successfully_completed_at": "2026-06-24T04:09:02Z"
}
}
```
→ The image is at
`https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/media/3/k/q/9/w/3kq9w0xv2hs5n8m4d6r1t8y0p2s4f6/artcraft_3kq9w0xv2hs5n8m4d6r1t8y0p2s4f6.jpg`
(example, not a live URL).
### Poll until complete
#### bash
```bash
JOB_TOKEN="jinf_xxxxxxxxxxxxxxxxxxxxxxxxx"
while :; do
resp=$(curl -s "http://localhost:12345/v1/omni_api/job_status/job/$JOB_TOKEN" \
-H "Authorization: Bearer YOUR_API_KEY")
status=$(echo "$resp" | python3 -c "import sys,json;print(json.load(sys.stdin)['state']['status']['status'])")
echo "status=$status"
case "$status" in
complete_success)
echo "$resp" | python3 -c "import sys,json;print(json.load(sys.stdin)['state']['maybe_result']['media_links']['cdn_url'])"
break ;;
complete_failure|dead|cancelled_by_user|cancelled_by_system)
echo "job ended without a result: $status"; break ;;
esac
sleep 5
done
```
#### python
```python
import time
import requests
BASE_URL = "http://localhost:12345" # production: https://api.storyteller.ai
API_KEY = "YOUR_API_KEY"
JOB_TOKEN = "jinf_xxxxxxxxxxxxxxxxxxxxxxxxx"
TERMINAL_FAILURES = {"complete_failure", "dead", "cancelled_by_user", "cancelled_by_system"}
while True:
resp = requests.get(
f"{BASE_URL}/v1/omni_api/job_status/job/{JOB_TOKEN}",
headers={"Authorization": f"Bearer {API_KEY}"},
timeout=30,
)
resp.raise_for_status()
state = resp.json()["state"]
status = state["status"]["status"]
print("status:", status, state["status"]["progress_percentage"], "%")
if status == "complete_success":
print("result URL:", state["maybe_result"]["media_links"]["cdn_url"])
break
if status in TERMINAL_FAILURES:
print("job ended without a result:", status)
break
time.sleep(5)
```
#### javascript
```javascript
const BASE_URL = "http://localhost:12345"; // production: https://api.storyteller.ai
const API_KEY = "YOUR_API_KEY";
const JOB_TOKEN = "jinf_xxxxxxxxxxxxxxxxxxxxxxxxx";
const TERMINAL_FAILURES = new Set([
"complete_failure", "dead", "cancelled_by_user", "cancelled_by_system",
]);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
while (true) {
const resp = await fetch(`${BASE_URL}/v1/omni_api/job_status/job/${JOB_TOKEN}`, {
headers: { Authorization: `Bearer ${API_KEY}` },
});
const { state } = await resp.json();
const status = state.status.status;
console.log("status:", status, state.status.progress_percentage, "%");
if (status === "complete_success") {
console.log("result URL:", state.maybe_result.media_links.cdn_url);
break;
}
if (TERMINAL_FAILURES.has(status)) {
console.log("job ended without a result:", status);
break;
}
await sleep(5000);
}
```
---
## Notes & troubleshooting
- **Always send a fresh `idempotency_token`** (UUID). Reusing one is rejected as a duplicate.
@@ -0,0 +1,3 @@
pub mod omni_api_batch_get_job_status;
pub mod omni_api_get_job_status;
pub mod omni_api_job_status_payload;
@@ -0,0 +1,25 @@
use std::collections::HashSet;
use serde_derive::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use crate::omni_api::job_status::omni_api_job_status_payload::OmniApiJobStatusPayload;
/// Query params for `GET /v1/omni_api/job_status/batch`.
#[derive(Deserialize, ToSchema, IntoParams)]
pub struct OmniApiBatchGetJobStatusQueryParams {
/// A grab bag of job tokens. Each token family generally has its own prefix,
/// so a flat set is sufficient.
///
/// NB: handlers parse this with `actix_web_lab`'s `Query<T>` because the
/// default actix-web `Query<T>` doesn't decode repeated/sequence params yet.
/// See https://github.com/actix/actix-web/issues/1301
pub tokens: HashSet<String>,
}
/// Success body for `GET /v1/omni_api/job_status/batch`.
#[derive(Serialize, Deserialize, ToSchema)]
pub struct OmniApiBatchGetJobStatusSuccessResponse {
pub success: bool,
pub job_states: Vec<OmniApiJobStatusPayload>,
}
@@ -0,0 +1,19 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use crate::omni_api::job_status::omni_api_job_status_payload::OmniApiJobStatusPayload;
/// Path params for `GET /v1/omni_api/job_status/job/{token}`.
#[derive(Deserialize, ToSchema)]
pub struct OmniApiGetJobStatusPathInfo {
pub token: InferenceJobToken,
}
/// Success body for `GET /v1/omni_api/job_status/job/{token}`.
#[derive(Serialize, Deserialize, ToSchema)]
pub struct OmniApiGetJobStatusSuccessResponse {
pub success: bool,
pub state: OmniApiJobStatusPayload,
}
@@ -0,0 +1,69 @@
use chrono::{DateTime, Utc};
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use enums::by_table::generic_inference_jobs::frontend_failure_category::FrontendFailureCategory;
use enums::by_table::generic_inference_jobs::inference_category::InferenceCategory;
use enums::common::job_status_plus::JobStatusPlus;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::prompts::PromptToken;
use crate::common::responses::media_links::MediaLinks;
/// Full status payload for a single inference job.
///
/// Shared by both the single (`/v1/omni_api/job_status/job/{token}`) and batch
/// (`/v1/omni_api/job_status/batch`) Omni API status endpoints. Forked from the
/// cookie-authenticated `/v1/jobs/*` response types so the Omni API surface can
/// evolve independently.
#[derive(Serialize, Deserialize, ToSchema)]
pub struct OmniApiJobStatusPayload {
pub job_token: InferenceJobToken,
pub request: OmniApiJobRequestDetails,
pub status: OmniApiJobStatusDetails,
pub maybe_result: Option<OmniApiJobResultDetails>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
/// Details about what the user requested for generation.
#[derive(Serialize, Deserialize, ToSchema)]
pub struct OmniApiJobRequestDetails {
pub inference_category: InferenceCategory,
pub maybe_prompt_token: Option<PromptToken>,
pub maybe_model_type: Option<String>,
pub maybe_model_token: Option<String>,
}
/// Details about the ongoing job status.
#[derive(Serialize, Deserialize, ToSchema)]
pub struct OmniApiJobStatusDetails {
/// Primary status from the database (a state machine).
pub status: JobStatusPlus,
pub maybe_first_started_at: Option<DateTime<Utc>>,
/// An enum the frontend can use to display localized/I18N error
/// messages. These pertain to both transient and permanent failures.
pub maybe_failure_category: Option<FrontendFailureCategory>,
/// This is an integer number between 0 and 100 (both inclusive) that
/// reports the completeness.
pub progress_percentage: u8,
}
/// Details about the completed result (if any).
#[derive(Serialize, Deserialize, ToSchema)]
pub struct OmniApiJobResultDetails {
pub entity_type: String,
pub entity_token: String,
/// Rich CDN links to the media, including thumbnails, previews, and more.
pub media_links: MediaLinks,
pub maybe_successfully_completed_at: Option<DateTime<Utc>>,
}
@@ -1,4 +1,5 @@
pub mod generate_requests;
pub mod job_status;
pub mod omni_upload_audio;
pub mod omni_upload_image;
pub mod omni_upload_video;
@@ -349,6 +349,9 @@ use artcraft_api_defs::omni_gen::generate_response::omni_gen_image_generate_resp
use artcraft_api_defs::omni_gen::generate_response::omni_gen_video_generate_response::*;
use artcraft_api_defs::omni_gen::models::omni_gen_image_models::*;
use artcraft_api_defs::omni_gen::models::omni_gen_video_models::*;
use artcraft_api_defs::omni_api::job_status::omni_api_batch_get_job_status::*;
use artcraft_api_defs::omni_api::job_status::omni_api_get_job_status::*;
use artcraft_api_defs::omni_api::job_status::omni_api_job_status_payload::*;
use artcraft_api_defs::omni_api::omni_upload_audio::*;
use artcraft_api_defs::omni_api::omni_upload_image::*;
use artcraft_api_defs::omni_api::omni_upload_video::*;
@@ -573,6 +576,8 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
crate::http_server::endpoints::omni_api::upload::omni_upload_audio_media_file_handler::omni_upload_audio_media_file_handler,
crate::http_server::endpoints::omni_api::upload::omni_upload_image_media_file_handler::omni_upload_image_media_file_handler,
crate::http_server::endpoints::omni_api::upload::omni_upload_video_media_file_handler::omni_upload_video_media_file_handler,
crate::http_server::endpoints::omni_api::job_status::get_job_status_handler::omni_api_get_job_status_handler,
crate::http_server::endpoints::omni_api::job_status::batch_get_job_status_handler::omni_api_batch_get_job_status_handler,
// Moderation
crate::http_server::endpoints::moderation::alerts::moderation_send_alert_handler::moderation_send_alert_handler,
crate::http_server::endpoints::moderation::info::moderator_token_info_handler::moderator_get_token_info_handler,
@@ -826,7 +831,13 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
GetCharacterResponse,
ListCharactersEntry,
ListCharactersResponse,
OmniApiBatchGetJobStatusSuccessResponse,
OmniApiGetJobStatusSuccessResponse,
OmniApiImageGenerateRequest,
OmniApiJobRequestDetails,
OmniApiJobResultDetails,
OmniApiJobStatusDetails,
OmniApiJobStatusPayload,
OmniApiVideoGenerateRequest,
OmniGenImageCostAndGenerateRequest,
OmniGenImageCostResponse,
@@ -0,0 +1,127 @@
use std::sync::Arc;
use actix_web::web::Json;
use actix_web::{web, HttpRequest};
use actix_web_lab::extract::Query;
use log::{error, warn};
use redis::Commands;
use artcraft_api_defs::omni_api::job_status::omni_api_batch_get_job_status::{OmniApiBatchGetJobStatusQueryParams, OmniApiBatchGetJobStatusSuccessResponse};
use mysql_queries::queries::generic_inference::web::batch_get_inference_job_status::batch_get_inference_job_status;
use mysql_queries::queries::generic_inference::web::job_status::GenericInferenceJobStatus;
use redis_common::redis_keys::RedisKeys;
use server_environment::ServerEnvironment;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::common_responses::media::media_domain::MediaDomain;
use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain;
use crate::http_server::endpoints::omni_api::job_status::record_to_payload::record_to_payload;
use crate::http_server::user_lookup::api_keys::require_api_key_user::require_api_key_user;
use crate::state::server_state::ServerState;
/// For certain jobs or job classes (eg. non-premium), we kill the jobs if the user hasn't
/// maintained a keepalive. This prevents wasted work when users who are unlikely to return
/// navigate away. Premium users have accounts and can always return to the site, so they
/// typically do not require keepalive.
const JOB_KEEPALIVE_TTL_SECONDS: u64 = 60 * 3;
/// Get job statuses for a batch of multiple job tokens (Omni API, API-key authenticated).
///
/// Mirrors `/v1/jobs/batch` but reads the caller's identity from the
/// `Authorization` header API key instead of a session cookie.
#[utoipa::path(
get,
tag = "Omni API",
path = "/v1/omni_api/job_status/batch",
responses(
(status = 200, body = OmniApiBatchGetJobStatusSuccessResponse),
(status = 401, description = "Unauthorized"),
(status = 500),
),
params(
OmniApiBatchGetJobStatusQueryParams
)
)]
pub async fn omni_api_batch_get_job_status_handler(
http_request: HttpRequest,
query: Query<OmniApiBatchGetJobStatusQueryParams>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<OmniApiBatchGetJobStatusSuccessResponse>, CommonWebError> {
// ==================== API KEY USER ==================== //
// API-key authentication (Authorization header) instead of a session cookie. Never cached, and a
// banned owner is rejected inside `require_api_key_user`. Drop the connection before the job
// lookups below so we don't hold a pool slot idle.
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
require_api_key_user(&http_request, &mut *mysql_connection).await?;
drop(mysql_connection);
let tokens = query.tokens.iter()
.map(|token| token.trim())
// NB: A bunch of Python clients use our API and can fail in this manner.
.filter(|token| !token.is_empty() && *token != "None")
.map(|token| InferenceJobToken::new_from_str(token))
.collect::<Vec<_>>();
if tokens.is_empty() {
// NB: MediaDomain doesn't matter since it's an empty list.
return Ok(records_to_response(
Vec::new(),
server_state.server_environment,
MediaDomain::Storyteller,
));
}
let records = batch_get_inference_job_status(
&tokens,
&server_state.mysql_pool,
).await
.map_err(|err| {
warn!("Batch job query error: {:?}", err);
CommonWebError::from_anyhow_error(err)
})?;
let mut redis = server_state.redis_pool
.get()
.map_err(|e| {
warn!("Redis pool error: {:?}", e);
CommonWebError::from_error(e)
})?;
// TODO(bt,2024-04-22): Look up the extra redis statuses per item.
let keepalive_keys = records.iter()
.filter(|record| record.is_keepalive_required)
.map(|record| RedisKeys::generic_inference_keepalive(record.job_token.as_str()))
.collect::<Vec<_>>();
for key in keepalive_keys.iter() {
// TODO(bt,2024-04-22): There is no msetex. We'll need to run a Redis pipeline here.
let _: Option<String> = match redis.set_ex(key, "1", JOB_KEEPALIVE_TTL_SECONDS) {
Ok(Some(status)) => Some(status),
Ok(None) => None,
Err(e) => {
error!("redis error setting job keepalive: {:?}", e);
None // Fail open (which in this case is bad! it will kill jobs if cluster has many jobs / is slow!)
}
};
}
let media_domain = get_media_domain(&http_request);
Ok(records_to_response(records, server_state.server_environment, media_domain))
}
fn records_to_response(
records: Vec<GenericInferenceJobStatus>,
server_environment: ServerEnvironment,
media_domain: MediaDomain,
) -> Json<OmniApiBatchGetJobStatusSuccessResponse> {
Json(OmniApiBatchGetJobStatusSuccessResponse {
success: true,
job_states: records.into_iter()
.map(|record| record_to_payload(record, server_environment, media_domain))
.collect::<Vec<_>>(),
})
}
@@ -0,0 +1,107 @@
use std::sync::Arc;
use actix_web::web::{Json, Path};
use actix_web::{web, HttpRequest};
use log::{error, warn};
use redis::Commands;
use artcraft_api_defs::omni_api::job_status::omni_api_get_job_status::{OmniApiGetJobStatusPathInfo, OmniApiGetJobStatusSuccessResponse};
use mysql_queries::queries::generic_inference::web::get_inference_job_status::get_inference_job_status;
use redis_common::redis_keys::RedisKeys;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain;
use crate::http_server::endpoints::omni_api::job_status::record_to_payload::record_to_payload;
use crate::http_server::user_lookup::api_keys::require_api_key_user::require_api_key_user;
use crate::state::server_state::ServerState;
/// For certain jobs or job classes (eg. non-premium), we kill the jobs if the user hasn't
/// maintained a keepalive. This prevents wasted work when users who are unlikely to return
/// navigate away. Premium users have accounts and can always return to the site, so they
/// typically do not require keepalive.
const JOB_KEEPALIVE_TTL_SECONDS: u64 = 60 * 3;
/// Get the status for a single job (Omni API, API-key authenticated).
///
/// Mirrors `/v1/jobs/job/{token}` but reads the caller's identity from the
/// `Authorization` header API key instead of a session cookie.
#[utoipa::path(
get,
tag = "Omni API",
path = "/v1/omni_api/job_status/job/{token}",
params(
("path" = OmniApiGetJobStatusPathInfo, description = "Path params for Request")
),
responses(
(status = 200, body = OmniApiGetJobStatusSuccessResponse),
(status = 401, description = "Unauthorized"),
(status = 404),
(status = 500),
),
)]
pub async fn omni_api_get_job_status_handler(
http_request: HttpRequest,
path: Path<OmniApiGetJobStatusPathInfo>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<OmniApiGetJobStatusSuccessResponse>, CommonWebError> {
// ==================== API KEY USER ==================== //
// API-key authentication (Authorization header) instead of a session cookie. Never cached, and a
// banned owner is rejected inside `require_api_key_user`. Drop the connection before the job
// lookups below so we don't hold a pool slot idle.
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
require_api_key_user(&http_request, &mut *mysql_connection).await?;
drop(mysql_connection);
if path.token.as_str().trim() == "None" {
// NB: A bunch of Python clients use our API and can fail in this manner.
return Err(CommonWebError::NotFound);
}
let maybe_status = get_inference_job_status(
&path.token,
&server_state.mysql_pool,
).await;
let record = match maybe_status {
Ok(Some(record)) => record,
Ok(None) => return Err(CommonWebError::NotFound),
Err(err) => {
warn!("Job query error for token {}: {:?}", path.token.as_str(), err);
return Err(CommonWebError::from_anyhow_error(err));
}
};
let mut redis = server_state.redis_pool
.get()
.map_err(|e| {
warn!("Redis pool error: {:?}", e);
CommonWebError::from_error(e)
})?;
if record.is_keepalive_required {
// TODO(bt,2023-05-21): Make async.
let extra_status_key = RedisKeys::generic_inference_extra_status_info(path.token.as_str());
let _: Option<String> = match redis.set_ex(&extra_status_key, "1", JOB_KEEPALIVE_TTL_SECONDS) {
Ok(Some(status)) => Some(status),
Ok(None) => None,
Err(e) => {
error!("redis error setting job keepalive: {:?}", e);
None // Fail open (which in this case is bad! it will kill jobs if cluster has many jobs / is slow!)
}
};
}
let media_domain = get_media_domain(&http_request);
let state = record_to_payload(
record,
server_state.server_environment,
media_domain,
);
Ok(Json(OmniApiGetJobStatusSuccessResponse {
success: true,
state,
}))
}
@@ -0,0 +1,3 @@
pub mod batch_get_job_status_handler;
pub mod get_job_status_handler;
pub mod record_to_payload;
@@ -0,0 +1,127 @@
use artcraft_api_defs::omni_api::job_status::omni_api_job_status_payload::{
OmniApiJobRequestDetails, OmniApiJobResultDetails, OmniApiJobStatusDetails, OmniApiJobStatusPayload,
};
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
use bucket_paths::legacy::typified_paths::public::voice_conversion_results::bucket_file_path::VoiceConversionResultOriginalFilePath;
use enums::by_table::generic_inference_jobs::inference_category::InferenceCategory;
use mysql_queries::queries::generic_inference::web::job_status::GenericInferenceJobStatus;
use server_environment::ServerEnvironment;
use crate::http_server::common_responses::media::media_domain::MediaDomain;
use crate::http_server::common_responses::media::media_links_builder::MediaLinksBuilder;
use crate::http_server::endpoints::inference_job::utils::estimates::estimate_job_progress::estimate_job_progress;
use crate::http_server::endpoints::inference_job::utils::extractors::extract_polymorphic_inference_args::extract_polymorphic_inference_args;
use crate::http_server::web_utils::filter_model_name::maybe_filter_model_name;
/// Convert a DB job-status record into the Omni API response payload.
///
/// This is the same mapping the cookie-authenticated `/v1/jobs/*` handlers use;
/// it's shared by both the single and batch Omni API status handlers.
#[allow(deprecated)] // We match deprecated `InferenceCategory` variants in the result-path mapping.
pub fn record_to_payload(
record: GenericInferenceJobStatus,
server_environment: ServerEnvironment,
media_domain: MediaDomain,
) -> OmniApiJobStatusPayload {
let inference_category = record.request_details.inference_category;
// NB: Fail open. We don't want to fail the request if we can't extract the args.
let maybe_polymorphic_args = extract_polymorphic_inference_args(&record)
.ok()
.flatten();
let progress_percentage = estimate_job_progress(&record, maybe_polymorphic_args.as_ref());
OmniApiJobStatusPayload {
job_token: record.job_token,
request: OmniApiJobRequestDetails {
inference_category: record.request_details.inference_category,
maybe_prompt_token: record.request_details.maybe_prompt_token,
maybe_model_type: maybe_filter_model_name(record.request_details.maybe_model_type.as_deref()),
maybe_model_token: record.request_details.maybe_model_token,
},
status: OmniApiJobStatusDetails {
status: record.status,
maybe_first_started_at: record.maybe_first_started_at,
maybe_failure_category: record.maybe_frontend_failure_category,
progress_percentage,
},
maybe_result: record.maybe_result_details.map(|result_details| {
// NB: Be careful here, because this varies based on the type of inference result.
let public_bucket_media_path = match inference_category {
// NB: TTS has to be special cased due to legacy behavior
InferenceCategory::TextToSpeech
| InferenceCategory::F5TTS => {
match result_details.entity_type.as_str() {
"media_file" => {
// NB: We're migrating TTS to media_files.
// Zero shot TTS uses media files.
// Legacy TT2 uses old pathing.
MediaFileBucketPath::from_object_hash(
&result_details.public_bucket_location_or_hash,
result_details.maybe_media_file_public_bucket_prefix.as_deref(),
result_details.maybe_media_file_public_bucket_extension.as_deref())
.get_full_object_path_str()
.to_string()
}
_ => {
// NB: TTS results receive the legacy treatment where their table only reports the full bucket path
result_details.public_bucket_location_or_hash
}
}
}
// NB: Voice conversion has to be special cased due to legacy behavior
InferenceCategory::VoiceConversion | InferenceCategory::SeedVc => {
match result_details.entity_type.as_str() {
"media_file" => {
// NB: We're migrating voice conversion to media_files.
MediaFileBucketPath::from_object_hash(
&result_details.public_bucket_location_or_hash,
result_details.maybe_media_file_public_bucket_prefix.as_deref(),
result_details.maybe_media_file_public_bucket_extension.as_deref())
.get_full_object_path_str()
.to_string()
}
_ => {
// NB: This is the old voice conversion result pathing.
VoiceConversionResultOriginalFilePath::from_object_hash(&result_details.public_bucket_location_or_hash)
.get_full_object_path_str()
.to_string()
}
}
}
// Unsupported media files.
InferenceCategory::FormatConversion |
InferenceCategory::ConvertBvhToWorkflow => {
"".to_string()
}
// Deprecated
InferenceCategory::DeprecatedField => {
"".to_string() // TODO(bt,2024-07-16): Read job type instead
}
// The blessed path that modern media files use.
_ => {
MediaFileBucketPath::from_object_hash(
&result_details.public_bucket_location_or_hash,
result_details.maybe_media_file_public_bucket_prefix.as_deref(),
result_details.maybe_media_file_public_bucket_extension.as_deref())
.get_full_object_path_str()
.to_string()
}
};
OmniApiJobResultDetails {
entity_type: result_details.entity_type,
entity_token: result_details.entity_token,
media_links: MediaLinksBuilder::from_rooted_path_and_env(
media_domain,
server_environment,
&public_bucket_media_path,
),
maybe_successfully_completed_at: result_details.maybe_successfully_completed_at,
}
}),
created_at: record.created_at,
updated_at: record.updated_at,
}
}
@@ -1,3 +1,4 @@
pub mod generate;
pub mod job_status;
pub mod shared_utils;
pub mod upload;
@@ -5,6 +5,8 @@ use actix_web::{web, App, Error, HttpResponse};
use crate::http_server::endpoints::omni_api::generate::image::omni_api_image_generate_handler::omni_api_image_generate_handler;
use crate::http_server::endpoints::omni_api::generate::video::omni_api_video_generate_handler::omni_api_video_generate_handler;
use crate::http_server::endpoints::omni_api::job_status::batch_get_job_status_handler::omni_api_batch_get_job_status_handler;
use crate::http_server::endpoints::omni_api::job_status::get_job_status_handler::omni_api_get_job_status_handler;
use crate::http_server::endpoints::omni_api::upload::omni_upload_audio_media_file_handler::omni_upload_audio_media_file_handler;
use crate::http_server::endpoints::omni_api::upload::omni_upload_image_media_file_handler::omni_upload_image_media_file_handler;
use crate::http_server::endpoints::omni_api::upload::omni_upload_video_media_file_handler::omni_upload_video_media_file_handler;
@@ -49,5 +51,15 @@ where
.route(web::head().to(|| HttpResponse::Ok()))
)
)
.service(web::scope("/job_status")
.service(web::resource("/job/{token}")
.route(web::get().to(omni_api_get_job_status_handler))
.route(web::head().to(|| HttpResponse::Ok()))
)
.service(web::resource("/batch")
.route(web::get().to(omni_api_batch_get_job_status_handler))
.route(web::head().to(|| HttpResponse::Ok()))
)
)
)
}
+227
View File
@@ -0,0 +1,227 @@
# Artcraft Moodboard — Design & Flow
> How the moodboard looks, feels, and flows. Written through a high-end product-design lens
> (Linear/Apple-tier restraint: haptic depth, spring-physics motion, calm surfaces) and grounded
> in the shipped implementation (`@storyteller/ui-moodboard`). Companion to `DESIGN_SYSTEM.md`
> (tokens/components), `FEATURE_PLAN.md` (roadmap), and `FEATURE_BACKLOG.md` (what's next).
---
## 1. Design philosophy
Three convictions drive every decision:
1. **The board is an input to generation, not a graveyard.** Generic moodboards (Pinterest,
Milanote) are dead ends — references go in, nothing comes out. Artcraft owns the engine, so the
board is one gesture away from a generation and vice-versa. Every surface keeps that loop in
reach without ever shouting about it.
2. **Native, then elevated.** The moodboard lives *inside* Artcraft, so it speaks the app's visual
language (the `--st-*` theme tokens, Fira Sans, the `glass` material, the brand blue). Elevation
comes from *composition* — nested "double-bezel" enclosures, floating glass islands, motion with
mass — never from a louder palette. The board should feel like it was always part of the app.
3. **The imagery is the interface.** Chrome recedes; references dominate. No like-counts, no
notification noise, no decorative gradients competing with the user's work. Calm by default
(the Cosmos/Are.na lesson), dense where it counts.
The felt quality we're after: **a precise, quiet instrument** — fast, tactile, and a little bit
joyful, where heavy operations feel weightless and nothing ever teleports.
---
## 2. Visual language (the short version)
The full token/component spec is in `DESIGN_SYSTEM.md`; the design *intent* behind it:
- **Double-bezel enclosures.** Cards, the inspector, and toolbars are never flat on the canvas.
A subtle outer shell (plane-1, hairline ring, `rounded-[18px]`, `p-1`) cradles an inner core
(plane-2, concentric `rounded-[14px]`, an inset top-highlight). It reads like a glass plate
seated in a machined tray — physical, considered, expensive.
- **Floating glass islands.** The toolbar and the view switch are detached pills, not edge-glued
bars (`glass` + hairline + ambient shadow, `backdrop-blur` only on these fixed elements — never
on the scrolling grid, where blur would torch scroll performance).
- **Spring-physics motion.** Custom cubic-beziers, never `linear`/`ease-in-out`. A hover lift, a
card landing on drop with a touch of overshoot, a palette fading up. Animate only `transform`,
`opacity`, `filter` — GPU-safe. `prefers-reduced-motion` collapses springs to fades.
- **Concentric squircle radii** and **ambient, layered shadows** (never a harsh `shadow-md`).
- **One display moment.** Body/UI is Fira Sans (the app font, for zero context-switch); the only
"display" flourish is the editorial empty-state heading and presentation mode.
---
## 3. Information architecture
```
Board (durable model, localStorage today → backend later)
├── items: image · video · text · link · color (each: tags, rating, deterministic aspect)
├── sections (model-ready; UI is a near-term add)
└── rendered three ways — ALL views are projections of the same board:
├── GRID virtualized masonry — the default; scan / search / triage at volume
├── CANVAS freeform Konva stage — arrange / compare / plan spatially
└── PRESENT full-bleed deck — review & share
```
**Why grid-first.** 90% of moodboard time is scanning, searching, and triaging — the grid wins
those high-frequency jobs and stays at 60fps (canvas-only tools like Milanote lag at a few hundred
items; we virtualize from item aspect ratios so only the viewport mounts). The freeform canvas is
the *delighter* for spatial thinking, not the foundation. **The board, not the view, is what plugs
into generation** — "use as reference" works regardless of how you're looking at it.
---
## 4. Core flows
Each flow below lists the **gesture**, then the **design rationale**.
### 4.1 Capture — get references in with zero friction
The fastest path wins, so we offer every path and make the empty state *say so*.
- **Drag images** from the desktop onto the board → a dashed primary-tinted drop overlay confirms
the target; files land at the cursor.
- **Paste** — an image from the clipboard drops instantly; a **URL** becomes a link card
(hostname title today; rich OG preview is a backlog item).
- **Upload** (toolbar `↑` / empty-state CTA) → the image appears immediately from a blob, then
uploads in the background to capture a durable media token (so it becomes reference-capable).
- **From library** → the shared gallery picker (Artcraft's own `GalleryModal`), so muscle memory
transfers from the rest of the app. Not signed in? The picker now shows a **Log in** button
(not a dead "Retry"), routed per-platform via the adapter.
- **Note / Color** → a sticky note or an OS-picked swatch, one click each.
*Rationale:* friction is the enemy of collection. The empty state is an **editorial hero**
("Start collecting ideas") that doubles as the drop affordance — it teaches the gestures while
looking like a finished product, not a blank slate.
### 4.2 Organize — keep a growing board legible
- **Density** — a segmented control (Compact / Cozy / Comfortable) re-flows the masonry; the
layout is target-width-driven, so it adapts to any container (Savee's adjustable-density bar).
- **Search + tag chips** — text query over captions/tags/links; click a chip to filter.
- **Multi-select** — click, shift/⌘-click, or marquee-drag on bare canvas; a floating glass
**selection bar** rises from the bottom with the count + actions.
### 4.3 Triage — sift keepers from a batch, fast
Lightroom is the gold standard for high-volume culling, so we borrow its muscle memory:
- With items selected, **`1`–`5`** rate, **`0`** clears, **`P`** picks (5 stars). A rating badge
persists on the tile.
- The toolbar **star filter** cycles "All → 1+ … → 5+ → All" to winnow to keepers.
- The selection bar surfaces the shortcuts inline (`1–5 rate · 0 clear`) so they're *discoverable*,
not hidden lore.
- **Delete is deliberate** — `Delete`/`Backspace` only (we removed `x` as a delete alias: it sits
in the number row and the board has no undo, so a stray press must not destroy work).
*Rationale:* triage is keyboard-first because it's repetitive and high-volume; every key saves a
trip to the mouse. Destructive actions get friction; constructive ones get none.
### 4.4 The board → generate loop — the differentiator
This is the move no generic moodboard can make.
- **"Use as reference"** — on a card's hover rail, in the selection bar, or in the inspector.
Selected board images are pushed into the image-generation prompt as style references (via their
media tokens) and the user lands on the Create-Image surface, refs pre-filled. Krea/Firefly
proved board→generate; Artcraft closes it natively.
- The seam is the **adapter** (§6): desktop seeds `usePromptImageStore` + switches to the Image
tab; web seeds `useCreateImageStore` + navigates to `/create-image`. Same gesture, same result,
two platforms.
*Rationale:* the highest-leverage feature is the one that turns a passive collection into momentum.
It's always one click from any view, and it never blocks — it sends and gets out of the way.
### 4.5 Inspect — the lightbox as a workbench
Double-click or the rail's expand icon opens a full-screen **inspector**:
- Large media on a glass-blurred backdrop (blur is fine here — it's a fixed overlay), a
double-bezel side panel, `←/→` to walk the board, `Esc` to close, background scroll locked,
`role="dialog"`.
- The panel is a small workbench: **rating** stars, a **tag editor**, an **extracted palette**
(client-side color quantization — click a swatch to copy, or "add swatches to board"), and
**Use as reference**. The palette is keyed on the image src, so editing tags never re-decodes
the image or flickers.
### 4.6 Canvas — think spatially
The freeform Konva stage for arranging, comparing, and planning:
- Pan/zoom, marquee + lasso select, 8-handle transform, grouping, undo/redo, a shortcut cheatsheet,
and auto-layout helpers (pack / fit-to-grid / cluster-by-proximity) that already exist and want
better surfacing. Alignment/snapping + a background grid are the next polish (backlog).
- Drop a generation onto the canvas, set images side by side, group a coherent set, then send that
set to generation. It's the planning room; the grid is the library.
### 4.7 Present — review & share
A distraction-free, full-bleed deck: one item at a time, `←/→/space` to navigate, `Esc` to exit,
launched from the view switch as a transient overlay (never persisted, so a reload never reopens
into a slideshow). For client/team review and screen-sharing.
---
## 5. Micro-UX niceties (the details that read as "expensive")
- **Hover action rail** — a glass pill on each card fades *up* on hover (translate + opacity), with
Open / Use-as-reference / Delete. Buttons stop propagation so they never double as a select.
- **Card states choreographed** — hover lifts the shell 2px and deepens the shadow; selected swaps
to a 2px brand ring; drag scales to 1.03; drop lands with a snap-overshoot.
- **Selection check + rating** — solid scrims (not blur) inside the scrolling grid for legibility
without GPU cost.
- **Focus-visible rings everywhere** — keyboard users see where they are, not just hover users.
- **The Login button** — the gallery picker now invites action ("Log in to browse your library" +
a real primary button) instead of a confusing "Retry" link.
- **Stagger on entry** — grid items and presentation reveals come in with a short staggered
fade-up, so the board feels assembled, not dumped.
---
## 6. Cross-platform via the adapter
One library, two hosts, identical experience. The lib owns everything platform-agnostic; each app
injects a `MoodboardAdapter` for its three seams:
```ts
interface MoodboardAdapter {
uploadImage?: (file) => Promise<mediaToken | null>; // capture a durable token
sendToGeneration: (refs) => void; // board → prompt
renderLibraryPicker?: (props) => ReactNode; // the gallery picker (render-prop)
}
```
- **Desktop (Tauri):** upload via `@storyteller/api`, picker via `GalleryModal`, send → prompt
store + tab switch, login → the login modal.
- **Web (artcraft-webapp):** upload via the webapp uploader, picker via the same `GalleryModal`,
send → create-image store + `/create-image`, login → navigate `/login`.
*Rationale:* the adapter is the seam where platform reality lives, so the *experience* stays a
single, consistent design across surfaces — and the same polish ships everywhere at once.
---
## 7. Motion & performance contract
- **Curves:** spring (`cubic-bezier(0.32,0.72,0,1)`) for settle/zoom; smooth
(`cubic-bezier(0.4,0,0.2,1)`, ~200ms) for hover/selection; snap with slight overshoot on drop.
- **Only `transform`/`opacity`/`filter`** animate — never layout properties.
- **`backdrop-blur` only on fixed chrome** (toolbar, view switch, inspector/present backdrops);
the virtualized scrolling grid uses solid scrims so scrolling stays at 60fps.
- **Virtualize aggressively** — deterministic masonry from item aspects means only viewport-visible
cards mount; the grid holds thousands of items without lag (the competitor failure mode).
- **Respect `prefers-reduced-motion`** — springs become instant, fades remain.
---
## 8. Accessibility & polish checklist
- [x] Theme-correct: consumes `--st-*` only, so light/dark/gray/black/aurora themes work for free.
- [x] Focus-visible rings on interactive controls; inspector is a labelled `role="dialog"` with
scroll-lock and keyboard nav.
- [x] Color is never the only signal (drop state pairs a tint with a dashed outline + icon).
- [x] Destructive delete is deliberate (no single-printable-key delete); no silent data loss.
- [ ] Cards are not yet keyboard-focusable/selectable (pointer-first) — a known a11y gap.
- [ ] Toolbar/inspector don't yet collapse gracefully below ~768px (desktop-first) — responsive
pass pending.
---
## 9. Where the design goes next
The research-backed backlog (`FEATURE_BACKLOG.md`) extends this flow without changing its spine —
client-side ML (in-browser captioning, auto-tagging, find-similar, palette/color search) makes the
board *understand itself*; canvas snapping/alignment and a region→palette tool deepen planning; and
the generate-into-a-board / remix loop tightens the differentiator. Collaboration & sharing are
deferred behind a flag (they need the backend). The throughline stays constant: **collect with no
friction, organize effortlessly, and turn the board into the next generation — calmly.**
+101
View File
@@ -0,0 +1,101 @@
# Moodboard — UX Change Plan
> Derived from the recommendations in `DESIGN_AND_FLOW.md` (the high-end design/UX doc), checked
> against the **actual implementation**. This is a *design-and-UX polish* plan — closing the gap
> between the experience the doc describes and what's shipped. (AI/ML features live separately in
> `FEATURE_BACKLOG.md`; backend-dependent work like sharing stays deferred.)
## Audit — doc says vs. reality
| Recommendation (DESIGN_AND_FLOW.md) | Status today | Gap |
|---|---|---|
| §5 "Stagger on entry — items fade-up assembled" | **Not built** — grid renders the virtualized slice with no entry motion | Yes |
| §8 "Focus-visible rings on interactive controls" | **Partial** — only card action-rail + toolbar add-buttons | Yes |
| §8 "Cards not yet keyboard-focusable/selectable" | **Not built** — pointer-only | Yes (acknowledged) |
| §8 "Toolbar/inspector don't collapse < ~768px" | **Not built** — fixed horizontal island; inspector is a fixed 2-pane | Yes (acknowledged) |
| §4.2 "Sections" | Model-ready, **no UI** | Yes |
| §4.1 "Paste URL → link card" | Hostname-only, **no OG preview** | Yes (needs proxy) |
| §4.6 "Auto-layout helpers want better surfacing" | Helpers exist (`pack`/`fitToGrid`/`cluster`), **under-exposed** | Yes |
| §5 double-bezel, glass islands, spring motion, hover rail, card states, scrims, login button, triage hint, inspector dialog | **Built** ✓ | — |
Everything in the bottom row is genuinely shipped; the rows above are the work.
---
## The plan (prioritized by impact ÷ effort)
Each item: **what + UX**, **why** (design rationale), **effort (S/M/L)**, **acceptance**.
### P1 — Motion & accessibility foundation (do first)
**1.1 Entry motion — staggered fade-up on the grid.** *(the doc claims it; it isn't there)*
- **What:** as cards mount into the viewport, fade-up from `translate-y-2 opacity-0 blur-[2px]` → rest over ~320ms on `--mb-ease-smooth`, with an index-based delay capped at ~12 items so a full screen feels *assembled*, not dumped. Must be virtualization-safe: animate only on first mount, key the animation so re-renders/scroll don't re-trigger; respect `prefers-reduced-motion`.
- **Why:** the single biggest "expensive feel" lever the doc names; the board currently pops in flatly.
- **Effort:** **S–M.** **Acceptance:** opening a board / switching density plays one clean stagger; scrolling does **not** re-animate; reduced-motion shows instant.
**1.2 Keyboard-navigable cards.**
- **What:** card root becomes focusable (`tabIndex=0`, `role="button"`, `aria-label`); `Enter`/`Space` opens the inspector, `X`/`Delete` deletes the focused card (with the same deliberate-delete rule), and arrow keys move focus across the masonry (geometry-aware: left/right within a row, up/down to the nearest card). Selection follows focus with `Space`; `Shift`+arrows extends.
- **Why:** the doc's headline a11y gap; also a power-user speed win (no mouse round-trips during triage).
- **Effort:** **M.** **Acceptance:** a keyboard-only user can tab into the grid, move with arrows, open/rate/delete, and select — with a visible focus ring on each card.
**1.3 Complete focus-visible coverage.**
- **What:** add focus-visible rings to every remaining interactive control — `SelectionBar` buttons, `SmartSearchBar` input + clear + tag chips, density segmented control, rating-filter toggle, the view switch, and all `ItemInspector` controls (rating stars, tag input, palette swatches, actions).
- **Why:** §8 says "everywhere"; today it's two components.
- **Effort:** **S.** **Acceptance:** tabbing through the whole moodboard always shows where focus is.
### P2 — Responsive & micro-UX completeness
**2.1 Responsive toolbar.**
- **What:** below `md`, the floating island stops being one wide row — it becomes a compact glass bar: board name + a `+`-menu (collapsing the 4 add-buttons), density, rating, and a search **icon** that expands into a sheet; the whole thing `overflow-x-auto` as a last resort so it never breaks the layout.
- **Why:** §8 gap; the webapp can be viewed narrow, and today the island overflows.
- **Effort:** **M.** **Acceptance:** at 375px wide the toolbar is usable and doesn't clip; at ≥`md` it's unchanged.
**2.2 Responsive inspector.**
- **What:** below `md`, the inspector stacks (media on top, the panel as a bottom sheet) instead of the fixed media-left / 320px-panel split; `h-[100dvh]`; nav arrows move to the corners.
- **Why:** §8 gap; the current side-by-side is unusable on phones.
- **Effort:** **S–M.** **Acceptance:** inspector is fully usable at 375px.
**2.3 Sections UI.**
- **What:** group items into named, collapsible sections in the grid (the model already supports `sections` + `sectionId`). A section header row with a name (inline-rename), count, collapse toggle, and "new section"; drag a selection into a section; an "Ungrouped" default lane.
- **Why:** §4.2 calls it a near-term add; big boards spiral without it (Pinterest Sections / MJ folder-groups).
- **Effort:** **M.** **Acceptance:** create/rename/collapse sections; assign items; persists.
**2.4 Surface canvas auto-layout helpers.**
- **What:** a one-click **Tidy / Arrange** menu on the canvas toolbar exposing the already-built `pack` (justified rows), `fitToGrid`, and `cluster-by-proximity`, plus "align selection."
- **Why:** §4.6 — the logic exists but is hidden; cheap, high-delight.
- **Effort:** **S.** **Acceptance:** the three layouts run from a visible menu; undoable.
**2.5 "Button-in-button" primary CTAs.**
- **What:** apply the doc's nested trailing-icon pattern (already in the empty-state CTA) to the primary actions — "Use as reference" and the inspector's primary button — with the magnetic hover physics (icon translates diagonally + scales on hover, button `active:scale-[0.98]`).
- **Why:** §5 haptic consistency; makes the differentiator action feel premium.
- **Effort:** **S.** **Acceptance:** primary CTAs share one polished button anatomy.
### P3 — Larger canvas & capture polish
**2.6 Canvas alignment + snapping + background grid.**
- **What:** snap a dragged node to other nodes' edges/centers with guide lines (DIY on Konva `dragmove`), plus a toggleable background grid + snap-to-grid.
- **Why:** §4.6/§9 — makes the freeform canvas feel professional (PureRef 2.1).
- **Effort:** **M.** (also tracked in `FEATURE_BACKLOG.md` 3.1/3.2.) **Acceptance:** dragging shows guides + snaps; grid toggles.
**2.7 Rich URL preview (link cards).**
- **What:** paste a URL → fetch OG title + image for a real preview card (vs hostname today).
- **Why:** §4.1 — link cards look unfinished without a thumbnail.
- **Effort:** **S (client) / M (proxy).** **Dependency:** OG fetch needs a CORS unfurl proxy → **flag as backend-dependent / defer the image; ship title now.**
**2.8 Drag-from-library onto the board.**
- **What:** let a gallery item be dragged directly onto the grid/canvas (not only picked via the modal), reusing Artcraft's existing gallery-drag event.
- **Why:** §4.1 — the lowest-friction capture path for already-owned media.
- **Effort:** **M.** **Acceptance:** drag a library thumbnail onto the board → it lands as a tokened item.
---
## Sequencing
- **Sprint 1 (feel + a11y):** 1.1 entry motion, 1.2 keyboard cards, 1.3 focus coverage. *One cohesive "the board feels finished and is fully keyboard-able" release.*
- **Sprint 2 (responsive + organize):** 2.1 toolbar, 2.2 inspector, 2.3 sections, 2.5 CTAs.
- **Sprint 3 (canvas + capture):** 2.4 surface auto-layout, 2.6 snapping/grid, 2.8 drag-from-library, 2.7 link previews (title now, image when a proxy exists).
## Recommended first move
**Sprint 1** — it's the highest "expensive feel ÷ effort" and closes the doc's two explicit a11y
gaps + the one motion claim that isn't yet true. All client-side, no backend, fits the shared lib
cleanly, and verifiable headlessly (focus order, reduced-motion, DOM-on-mount).
+10 -11
View File
@@ -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";
@@ -36,6 +37,7 @@ import {
useSidebar,
} from "../components/ui/sidebar";
import { AppSidebar } from "../components/sidebar/app-sidebar";
import { PageTransition } from "../components/motion/page-transition";
import { MobileBottomNav } from "../components/sidebar/mobile-bottom-nav";
import { TopBar } from "../components/topbar/topbar";
import { SignupCtaModal } from "../components/signup-cta-modal";
@@ -90,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
@@ -101,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
@@ -109,15 +112,10 @@ function ProtectedContent() {
style={{ "--ac-sidebar-offset": sidebarOffset } as React.CSSProperties}
>
{!hideTopBar && <TopBar />}
{/* The immersive editors (hideTopBar routes) paint their own #101014
canvas backdrop; match the inset to it so no lighter strip shows at
the edges. Other pages keep the standard #121212 surface. */}
<SidebarInset
className={`flex-1 min-h-0 overflow-y-auto ${
hideTopBar ? "bg-[#101014]" : "bg-[#121212]"
}`}
>
<Outlet />
<SidebarInset className="flex-1 min-h-0 overflow-y-auto bg-[#121212]">
<PageTransition>
<Outlet />
</PageTransition>
</SidebarInset>
{isMobile && <MobileBottomNav />}
</div>
@@ -156,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 />} />
@@ -6,6 +6,7 @@ import { TruchetPattern } from "@storyteller/ui-vfx";
import { AuthShowcase } from "./auth-showcase";
import { useMediaQuery } from "../ui/use-media-query";
import { useSession } from "../../lib/session";
import { Reveal, RevealGroup } from "../motion/reveal";
/**
* Persistent shell for the auth pages. Rendered as a layout route so the
@@ -108,16 +109,22 @@ interface AuthHeaderProps {
}
export const AuthHeader = ({ title, subtitle }: AuthHeaderProps) => (
<div className="mb-8 text-center">
<img
src="/images/artcraft-icon.png"
alt="ArtCraft"
className="mx-auto mb-6 h-12 w-auto select-none pointer-events-none"
draggable={false}
/>
<h1 className="mb-2 text-2xl font-semibold">{title}</h1>
<p className="text-sm text-white/60">{subtitle}</p>
</div>
<RevealGroup inView={false} stagger={0.08} className="mb-8 text-center">
<Reveal y={10}>
<img
src="/images/artcraft-icon.png"
alt="ArtCraft"
className="mx-auto mb-6 h-12 w-auto select-none pointer-events-none"
draggable={false}
/>
</Reveal>
<Reveal as="h1" y={10} className="mb-2 text-2xl font-semibold">
{title}
</Reveal>
<Reveal as="p" y={10} className="text-sm text-white/60">
{subtitle}
</Reveal>
</RevealGroup>
);
export const AuthFooter = ({ children }: { children: ReactNode }) => (
@@ -0,0 +1,50 @@
import { motion } from "framer-motion";
import { useLocation } from "react-router-dom";
import { pageFadeVariants } from "../../lib/motion";
// Routes whose dynamic sub-segments should NOT replay the page transition.
// Switching scenes (`/edit-3d/:token`), library filters (`/library/:slug`) or
// media items (`/media/:id`) stays *within* a section — re-fading the whole
// shell on every param change would feel laggy and would needlessly remount
// heavy editors. Section changes (Home → Create Image) still animate.
const SECTION_PREFIXES = [
"/edit-3d",
"/video-editor",
"/library",
"/media",
] as const;
/** Collapse a pathname to a stable key for its app section. */
export function routeSectionKey(pathname: string): string {
const section = SECTION_PREFIXES.find(
(prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`),
);
return section ?? pathname;
}
/**
* Wraps the routed `<Outlet />` so navigating between app sections fades the
* new page in. Enter-only (no exit) keeps navigation feeling instant — the old
* page is replaced immediately and the new one fades up over it.
*
* The fade is opacity-only by design; see `pageFadeVariants` for why a
* transform here would break `position: fixed` page chrome.
*
* The wrapper mirrors `SidebarInset`'s flex slot (`flex-1 flex-col min-h-0`) so
* pages that rely on `h-full` (the 3D/video editors) or `min-h-full` (scrolling
* pages like Home) keep resolving their heights exactly as before.
*/
export function PageTransition({ children }: { children: React.ReactNode }) {
const { pathname } = useLocation();
return (
<motion.div
key={routeSectionKey(pathname)}
variants={pageFadeVariants}
initial="initial"
animate="animate"
className="flex w-full flex-1 flex-col min-h-0"
>
{children}
</motion.div>
);
}
@@ -0,0 +1,119 @@
// Reveal — the webapp's reusable entrance primitive.
//
// Wraps content in a fade-and-lift that plays once when the element scrolls
// into view (or immediately on mount for above-the-fold content). It reuses the
// shared motion tokens so every page enters with the same restrained, premium
// cadence rather than each surface hand-rolling its own.
//
// Two pieces:
// <RevealGroup> — a stagger container; its <Reveal> children cascade in.
// <Reveal> — a single fade-up element (works standalone or as a child).
//
// Both respect `prefers-reduced-motion`: motion collapses to a plain, instant
// presence so the layout never moves for users who opt out.
import type { ElementType, ReactNode } from "react";
import { motion, useReducedMotion, type Variants } from "framer-motion";
import { DUR_SLOW, EASE_OUT } from "../../lib/motion";
interface RevealProps {
children: ReactNode;
/** Render as a different element/component (e.g. "section", "li", Link). */
as?: ElementType;
/** Extra delay (seconds) before this element animates. */
delay?: number;
/** Travel distance for the lift, in px. Defaults to a gentle 16. */
y?: number;
/** Play once when scrolled into view (default) vs. immediately on mount. */
inView?: boolean;
className?: string;
}
/**
* Stagger container. Place <Reveal> children inside and they fade up in
* sequence. Use for grids, card rows, and stacked content blocks.
*/
export function RevealGroup({
children,
as = "div",
stagger = 0.07,
delayChildren = 0.04,
inView = true,
className,
}: {
children: ReactNode;
as?: ElementType;
stagger?: number;
delayChildren?: number;
inView?: boolean;
className?: string;
}) {
const reduceMotion = useReducedMotion();
const MotionTag = motion(as);
const variants: Variants = {
initial: {},
animate: {
transition: reduceMotion
? {}
: { staggerChildren: stagger, delayChildren },
},
};
return (
<MotionTag
className={className}
variants={variants}
initial="initial"
{...(inView
? { whileInView: "animate", viewport: { once: true, margin: "-10%" } }
: { animate: "animate" })}
>
{children}
</MotionTag>
);
}
export function Reveal({
children,
as = "div",
delay = 0,
y = 16,
inView,
className,
}: RevealProps) {
const reduceMotion = useReducedMotion();
const MotionTag = motion(as);
const variants: Variants = {
initial: reduceMotion ? { opacity: 0 } : { opacity: 0, y },
animate: {
opacity: 1,
y: 0,
transition: { duration: DUR_SLOW, ease: EASE_OUT, delay },
},
};
// When `inView` is left undefined the element behaves as a stagger child:
// it inherits the parent RevealGroup's orchestration and must NOT drive its
// own initial/animate props (that would override the cascade).
const standalone = inView !== undefined;
return (
<MotionTag
className={className}
variants={variants}
{...(standalone
? inView
? {
initial: "initial",
whileInView: "animate",
viewport: { once: true, margin: "-10%" },
}
: { initial: "initial", animate: "animate" }
: {})}
>
{children}
</MotionTag>
);
}
@@ -1,5 +1,6 @@
import { useMemo } from "react";
import { Link, useLocation } from "react-router-dom";
import { motion, useReducedMotion } from "framer-motion";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faHouse,
@@ -7,6 +8,7 @@ import {
faImage,
faCube,
faFilm,
faObjectGroup,
faWandMagicSparkles,
faGraduationCap,
faNewspaper,
@@ -62,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
@@ -137,9 +145,16 @@ function NavMenuItem({
pathname: string;
onClick: () => void;
}) {
const reduceMotion = useReducedMotion();
const active = !item.external && isActive(pathname, item.href);
const inner = (
<>
<FontAwesomeIcon icon={item.icon} />
{/* Icon nudges up in scale on hover — a small tactile cue that the row is
interactive, kept subtle to stay within the "restrained chrome" lane. */}
<FontAwesomeIcon
icon={item.icon}
className="transition-transform duration-200 ease-out group-hover/menu-item:scale-110"
/>
<span className="group-data-[collapsible=icon]:hidden">{item.label}</span>
{item.badge && (
<span className="ml-auto bg-amber-600 px-1.5 py-0.5 text-[10px] font-semibold uppercase leading-none text-white group-data-[collapsible=icon]:hidden rounded-full">
@@ -150,9 +165,24 @@ function NavMenuItem({
);
return (
<SidebarMenuItem>
{/* Brand accent bar that glides between nav rows as the active route
changes (shared-element layout animation via `layoutId`). One is
mounted at a time, so framer-motion tweens it from the old row to the
new one. */}
{active && (
<motion.span
layoutId="sidebar-active-indicator"
className="pointer-events-none absolute inset-y-1.5 left-0 z-10 w-1 rounded-full bg-primary"
transition={
reduceMotion
? { duration: 0 }
: { type: "spring", stiffness: 520, damping: 40, mass: 0.7 }
}
/>
)}
<SidebarMenuButton
asChild
isActive={!item.external && isActive(pathname, item.href)}
isActive={active}
tooltip={item.label}
>
{item.external ? (
@@ -0,0 +1,80 @@
// Shared motion tokens for the webapp.
//
// These mirror the CSS custom properties defined in `src/styles.css` (`--ease-*`,
// `--dur-*`) so framer-motion animations and plain CSS transitions stay in sync.
// The "balanced" motion direction: chrome and navigation are restrained and fast;
// the creative canvas (3D drops, gallery, pagedraw) is where motion gets to be
// expressive. Reach for these instead of hand-writing curves and durations.
import type { Transition, Variants } from "framer-motion";
// ── Easing curves (cubic-bezier control points) ───────────────────────────────
// Tuples are typed as 4-number arrays so framer-motion accepts them as `ease`.
/** Decelerate-to-rest. The default for elements entering the screen. */
export const EASE_OUT: [number, number, number, number] = [0.22, 1, 0.36, 1];
/** Symmetric ease for elements that move and settle in place. */
export const EASE_IN_OUT: [number, number, number, number] = [0.65, 0, 0.35, 1];
/** Gentle overshoot for playful, tactile moments (pills, badges, drops). */
export const EASE_SPRING: [number, number, number, number] = [0.34, 1.56, 0.64, 1];
/** Apple/Linear "expo" curve for confident, premium slides. */
export const EASE_EMPHASIS: [number, number, number, number] = [0.32, 0.72, 0, 1];
// ── Durations (seconds, to match framer-motion's unit) ────────────────────────
export const DUR_FAST = 0.14;
export const DUR_BASE = 0.22;
export const DUR_SLOW = 0.34;
export const DUR_PAGE = 0.26;
// ── Ready-made transition presets ─────────────────────────────────────────────
/** Quick, restrained transition for chrome (nav, hovers, toggles). */
export const TRANSITION_CHROME: Transition = {
duration: DUR_BASE,
ease: EASE_OUT,
};
/** The global page/route transition — fast and unobtrusive. */
export const TRANSITION_PAGE: Transition = {
duration: DUR_PAGE,
ease: EASE_OUT,
};
/** Tactile spring for expressive bits that should feel physical. */
export const TRANSITION_SPRING: Transition = {
type: "spring",
stiffness: 420,
damping: 30,
mass: 0.9,
};
// ── Reusable variants ─────────────────────────────────────────────────────────
/**
* Opacity-only page enter. Deliberately avoids `transform`/`filter`: a lingering
* transform on a route wrapper would establish a containing block and re-scope
* `position: fixed` page chrome (promptboxes, editor toolbars) to the wrapper
* instead of the viewport. Fading is the safe, premium choice here — the
* expressive vertical motion lives inside individual pages, not the shell.
*/
export const pageFadeVariants: Variants = {
initial: { opacity: 0 },
animate: { opacity: 1, transition: TRANSITION_PAGE },
exit: { opacity: 0, transition: { duration: DUR_FAST, ease: EASE_OUT } },
};
/** Fade-and-lift for content that is safe to translate (no fixed children). */
export const fadeUpVariants: Variants = {
initial: { opacity: 0, y: 12 },
animate: { opacity: 1, y: 0, transition: TRANSITION_CHROME },
exit: { opacity: 0, y: 6, transition: { duration: DUR_FAST, ease: EASE_OUT } },
};
/** Parent variant that staggers children using `fadeUpVariants` (or similar). */
export const staggerContainerVariants: Variants = {
initial: {},
animate: {
transition: { staggerChildren: 0.05, delayChildren: 0.02 },
},
};
@@ -1,4 +1,5 @@
import { Link } from "react-router-dom";
import { motion, useReducedMotion } from "framer-motion";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faImage,
@@ -6,10 +7,15 @@ import {
faWandMagicSparkles,
faCube,
faFilm,
faObjectGroup,
faArrowRight,
} from "@fortawesome/pro-solid-svg-icons";
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import Seo from "../../components/seo";
import { Reveal, RevealGroup } from "../../components/motion/reveal";
import { TRANSITION_SPRING } from "../../lib/motion";
const MotionLink = motion(Link);
type AppCard = {
label: string;
@@ -69,63 +75,84 @@ 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() {
const reduceMotion = useReducedMotion();
return (
<div className="min-h-full px-6 sm:px-10 py-10 sm:py-16 max-w-6xl mx-auto w-full">
<Seo
title="ArtCraft - Create AI Images and Video"
description="Generate AI images and video with ArtCraft."
/>
<h1 className="text-center font-display text-4xl sm:text-6xl mx-auto font-semibold tracking-tight">
<Reveal as="h1" inView={false} y={20} className="text-center font-display text-4xl sm:text-6xl mx-auto font-semibold tracking-tight">
What will you <span className="text-primary">craft</span> today?
</h1>
</Reveal>
<section className="py-12">
<h2 className="text-sm font-semibold text-white/85 mb-4">Create</h2>
<div className="grid gap-3 auto-rows-fr sm:grid-cols-2 lg:grid-cols-3">
<Reveal as="h2" inView={false} delay={0.08} className="text-sm font-semibold text-white/85 mb-4">
Create
</Reveal>
<RevealGroup
inView={false}
delayChildren={0.12}
stagger={0.06}
className="grid gap-3 auto-rows-fr sm:grid-cols-2 lg:grid-cols-3"
>
{APPS.map((app) => (
<Link
key={app.href}
to={app.href}
className="bg-ui-controls/50 group relative h-full overflow-hidden rounded-2xl p-5 hover:border-white/20 hover:bg-ui-controls"
>
<div
className={`absolute -inset-px rounded-2xl bg-gradient-to-br ${app.accent} opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none`}
aria-hidden
/>
<div className="relative flex items-start gap-4">
<Reveal key={app.href} y={20}>
<MotionLink
to={app.href}
className="bg-ui-controls/50 group relative flex h-full overflow-hidden rounded-2xl p-5 ring-1 ring-white/[0.06] shadow-[inset_0_1px_0_0_rgba(255,255,255,0.04)] hover:bg-ui-controls hover:ring-white/15"
whileHover={reduceMotion ? undefined : { y: -4 }}
whileTap={reduceMotion ? undefined : { scale: 0.985 }}
transition={TRANSITION_SPRING}
>
<div
className={`flex h-11 w-11 shrink-0 items-center justify-center rounded-xl border ${app.iconBg} ${app.iconColor}`}
>
<FontAwesomeIcon icon={app.icon} className="text-base" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<h3 className="text-base font-semibold text-white truncate">
{app.label}
</h3>
{app.badge && (
<span className="shrink-0 rounded-full bg-amber-600 px-1.5 py-0.5 text-[9px] font-semibold uppercase leading-none text-white">
{app.badge}
</span>
)}
className={`absolute -inset-px rounded-2xl bg-gradient-to-br ${app.accent} opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none`}
aria-hidden
/>
<div className="relative flex w-full items-start gap-4">
<div
className={`flex h-11 w-11 shrink-0 items-center justify-center rounded-xl border ${app.iconBg} ${app.iconColor} shadow-[inset_0_1px_1px_rgba(255,255,255,0.12)] transition-transform duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] group-hover:scale-105`}
>
<FontAwesomeIcon icon={app.icon} className="text-base" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<h3 className="text-base font-semibold text-white truncate">
{app.label}
</h3>
{app.badge && (
<span className="shrink-0 rounded-full bg-amber-600 px-1.5 py-0.5 text-[9px] font-semibold uppercase leading-none text-white">
{app.badge}
</span>
)}
</div>
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-white/[0.04] text-white/40 transition-all duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] group-hover:bg-white/10 group-hover:text-white/80 group-hover:translate-x-0.5">
<FontAwesomeIcon icon={faArrowRight} className="text-xs" />
</span>
</div>
<FontAwesomeIcon
icon={faArrowRight}
className="text-sm text-white/40 group-hover:text-white/70 group-hover:translate-x-0.5 transition-all"
/>
<p className="mt-1 text-sm text-white/55 leading-snug">
{app.description}
</p>
</div>
<p className="mt-1 text-sm text-white/55 leading-snug">
{app.description}
</p>
</div>
</div>
</Link>
</MotionLink>
</Reveal>
))}
</div>
</RevealGroup>
</section>
</div>
);
@@ -1,6 +1,8 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Link, useParams, useNavigate, useLocation } from "react-router-dom";
import { motion } from "framer-motion";
import { EASE_EMPHASIS } from "../../lib/motion";
import { Button } from "@storyteller/ui-button";
import { LoadingSpinner } from "@storyteller/ui-loading-spinner";
import {
@@ -859,25 +861,45 @@ export default function Library() {
<div className="flex items-center gap-1 bg-ui-controls/40 rounded-xl p-1">
<Link
to="/library"
className={`flex items-center gap-2 px-3 sm:px-4 py-1 sm:py-1.5 rounded-lg text-xs sm:text-sm font-medium transition-colors whitespace-nowrap ${
className={`relative flex items-center gap-2 px-3 sm:px-4 py-1 sm:py-1.5 rounded-md text-xs sm:text-sm font-medium transition-colors whitespace-nowrap ${
tab === "unsorted"
? "bg-ui-controls text-white"
? "text-white"
: "text-white/60 hover:text-white"
}`}
>
<FontAwesomeIcon icon={faBorderAll} className="text-xs" />
<span>All Assets</span>
{tab === "unsorted" && (
<motion.span
layoutId="library-tab-indicator"
className="absolute inset-0 rounded-md bg-ui-controls"
transition={{ duration: 0.32, ease: EASE_EMPHASIS }}
/>
)}
<FontAwesomeIcon
icon={faBorderAll}
className="relative z-10 text-xs"
/>
<span className="relative z-10">All Assets</span>
</Link>
<Link
to="/library/folders"
className={`flex items-center gap-2 px-3 sm:px-4 py-1 sm:py-1.5 rounded-lg text-xs sm:text-sm font-medium transition-colors whitespace-nowrap ${
className={`relative flex items-center gap-2 px-3 sm:px-4 py-1 sm:py-1.5 rounded-md text-xs sm:text-sm font-medium transition-colors whitespace-nowrap ${
tab === "folders"
? "bg-ui-controls text-white"
? "text-white"
: "text-white/60 hover:text-white"
}`}
>
<FontAwesomeIcon icon={faFolder} className="text-xs" />
<span>Folders</span>
{tab === "folders" && (
<motion.span
layoutId="library-tab-indicator"
className="absolute inset-0 rounded-md bg-ui-controls"
transition={{ duration: 0.32, ease: EASE_EMPHASIS }}
/>
)}
<FontAwesomeIcon
icon={faFolder}
className="relative z-10 text-xs"
/>
<span className="relative z-10">Folders</span>
</Link>
</div>
{tab === "unsorted" && (
@@ -901,17 +923,26 @@ export default function Library() {
<button
key={filter.id}
onClick={() => navigate(filter.route)}
className={`flex items-center gap-1.5 sm:gap-2 px-2.5 sm:px-4 py-1 sm:py-1.5 rounded-lg text-xs sm:text-sm font-medium transition-colors whitespace-nowrap ${
className={`relative flex items-center gap-1.5 sm:gap-2 px-2.5 sm:px-4 py-1 sm:py-1.5 rounded-md text-xs sm:text-sm font-medium transition-colors whitespace-nowrap ${
activeFilter === filter.id
? "bg-ui-controls text-white"
? "text-white"
: "text-white/60 hover:text-white"
}`}
>
{activeFilter === filter.id && (
<motion.span
layoutId="library-filter-indicator"
className="absolute inset-0 rounded-md bg-ui-controls"
transition={{ duration: 0.32, ease: EASE_EMPHASIS }}
/>
)}
<FontAwesomeIcon
icon={filter.icon}
className="text-xs"
className="relative z-10 text-xs"
/>
<span className="hidden sm:inline">{filter.label}</span>
<span className="relative z-10 hidden sm:inline">
{filter.label}
</span>
</button>
))}
</div>
@@ -1063,7 +1094,15 @@ export default function Library() {
)
) : /* ── Unsorted ── */ initialLoading && allItems.length === 0 ? (
<div>
<div className="h-4 w-24 rounded bg-white/[0.06] mb-3" />
<div
className="h-4 w-24 rounded mb-3"
style={{
background:
"linear-gradient(100deg, rgba(255,255,255,0.05) 30%, rgba(255,255,255,0.1) 50%, rgba(255,255,255,0.05) 70%)",
backgroundSize: "200% 100%",
animation: "lib-shimmer 1.6s ease-in-out infinite",
}}
/>
<div className={GRID_CLASS}>
{Array.from({ length: 15 }).map((_, i) => (
<div
@@ -1071,15 +1110,18 @@ export default function Library() {
className="aspect-square rounded-lg overflow-hidden"
>
<div
className="h-full w-full bg-white/[0.06]"
className="h-full w-full"
style={{
animation: `pulse 1.8s ease-in-out ${i * 0.07}s infinite`,
background:
"linear-gradient(100deg, rgba(255,255,255,0.04) 30%, rgba(255,255,255,0.09) 50%, rgba(255,255,255,0.04) 70%)",
backgroundSize: "200% 100%",
animation: `lib-shimmer 1.6s ease-in-out ${i * 0.08}s infinite`,
}}
/>
</div>
))}
</div>
<style>{`@keyframes pulse {0%,100%{opacity:.4}50%{opacity:.8}}`}</style>
<style>{`@keyframes lib-shimmer {0%{background-position:200% 0}100%{background-position:-200% 0}}`}</style>
</div>
) : rootEmpty ? (
<div className="flex flex-col items-center justify-center py-20">
@@ -11,6 +11,7 @@ import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { UsersApi } from "@storyteller/api";
import { AuthHeader, AuthFooter, GoogleLoginButton } from "../../components/auth";
import Seo from "../../components/seo";
import { Reveal, RevealGroup } from "../../components/motion/reveal";
import { refreshSession } from "../../lib/session";
import { hasActiveSubscription } from "../../lib/billing";
@@ -71,6 +72,8 @@ const Login = () => {
/>
<AuthHeader title="Welcome Back" subtitle="Log in to your account" />
<RevealGroup inView={false} delayChildren={0.24} stagger={0.07}>
<Reveal>
<form
className="space-y-4"
onSubmit={(e) => {
@@ -141,31 +144,37 @@ const Login = () => {
</Button>
</div>
</form>
</Reveal>
<div className="relative my-6 flex items-center justify-center">
<Reveal className="relative my-6 flex items-center justify-center">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-white/10" />
</div>
<span className="relative bg-[#1C1C20] px-4 text-xs uppercase tracking-widest text-white/40">
or
</span>
</div>
</Reveal>
<GoogleLoginButton
mode="login"
onSuccess={handleGoogleSuccess}
onError={handleGoogleError}
/>
<Reveal>
<GoogleLoginButton
mode="login"
onSuccess={handleGoogleSuccess}
onError={handleGoogleError}
/>
</Reveal>
<AuthFooter>
Don't have an account?{" "}
<Link
to="/signup"
className="font-semibold text-primary transition-colors hover:text-primary-400"
>
Sign up
</Link>
</AuthFooter>
<Reveal>
<AuthFooter>
Don't have an account?{" "}
<Link
to="/signup"
className="font-semibold text-primary transition-colors hover:text-primary-400"
>
Sign up
</Link>
</AuthFooter>
</Reveal>
</RevealGroup>
</>
);
};
@@ -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;
};
@@ -37,6 +37,21 @@ import { useSceneCacheStore } from "./scene-cache-store";
const apiHost = () =>
StorytellerApiHostStore.getInstance().getApiSchemeAndHost();
// In local dev, route absolute cross-origin asset URLs (CDN splats/GLBs/images)
// through the Vite `/__cdn` proxy so they aren't blocked by CORS — the CDN only
// sends CORS headers for the production origin. In production this is a no-op:
// the asset is fetched directly from its real URL.
function maybeProxyCdnUrl(url: string): string {
if (!import.meta.env.DEV) return url;
try {
const parsed = new URL(url, window.location.origin);
if (parsed.origin === window.location.origin) return url;
return `/__cdn/${parsed.host}${parsed.pathname}${parsed.search}`;
} catch {
return url;
}
}
// ─── Scene file IO ─────────────────────────────────────────────────────────
const saveSceneViaApi = async (
@@ -206,7 +221,7 @@ export const useWebAppPageSceneAdapter = (
},
fetchAsset: (url: string, init?: { signal?: AbortSignal }) =>
fetch(url, { mode: "cors", signal: init?.signal }),
fetch(maybeProxyCdnUrl(url), { mode: "cors", signal: init?.signal }),
getCdnOrigin: () => GetCdnOrigin(),
getApiSchemeAndHost: apiHost,
@@ -10,6 +10,7 @@ import {
PricingPromoBanner,
} from "@storyteller/ui-pricing-table";
import { CreditsModal } from "../../components/credits-modal";
import { Reveal } from "../../components/motion/reveal";
const SeedanceBanner = () => (
<div className="flex flex-col gap-5">
@@ -152,10 +153,7 @@ const Pricing = () => {
) : (
<div className="max-w-6xl mx-auto">
<PricingPromoBanner className="mb-10 sm:mb-12" />
<div className="text-center mb-14" data-reveal>
{/* <span className="inline-block text-sm font-semibold uppercase tracking-[0.18em] text-primary mb-3">
Plans
</span> */}
<Reveal as="div" inView={false} y={20} className="text-center mb-14">
<h1 className="text-4xl sm:text-5xl md:text-6xl tracking-[-0.035em] font-medium leading-[1.02] mb-5">
Invest in <span className="font-serif-italic">yourself</span>.
</h1>
@@ -164,7 +162,7 @@ const Pricing = () => {
Your subscription helps keep ArtCraft free and open for
everyone.
</p>
</div>
</Reveal>
{hasPlan && (
<BuyMoreCreditsCta onClick={() => setCreditsModalOpen(true)} />
)}
@@ -1,6 +1,7 @@
import { Link, useNavigate } from "react-router-dom";
import { AuthHeader, AuthFooter, SignupForm } from "../../components/auth";
import Seo from "../../components/seo";
import { Reveal, RevealGroup } from "../../components/motion/reveal";
const Signup = () => {
const navigate = useNavigate();
@@ -13,17 +14,26 @@ const Signup = () => {
/>
<AuthHeader title="Create an Account" subtitle="Join thousands of creators" />
<SignupForm onSuccess={() => navigate("/welcome")} signupSource="artcraft" />
<RevealGroup inView={false} delayChildren={0.24} stagger={0.07}>
<Reveal>
<SignupForm
onSuccess={() => navigate("/welcome")}
signupSource="artcraft"
/>
</Reveal>
<AuthFooter>
Already have an account?{" "}
<Link
to="/login"
className="font-semibold text-primary transition-colors hover:text-primary-400"
>
Log in
</Link>
</AuthFooter>
<Reveal>
<AuthFooter>
Already have an account?{" "}
<Link
to="/login"
className="font-semibold text-primary transition-colors hover:text-primary-400"
>
Log in
</Link>
</AuthFooter>
</Reveal>
</RevealGroup>
</>
);
};
@@ -35,6 +35,39 @@
--border: 0 0% 18%;
--input: 0 0% 18%;
--ring: 217 91% 60%;
/* ── Motion tokens ──────────────────────────────────────────────────
Shared easing curves + durations so motion across the app feels like
one system, not a pile of one-offs. Mirrored as JS constants in
src/lib/motion.ts for framer-motion. "Balanced" direction: chrome and
navigation stay restrained; the creative canvas gets to be expressive.
Curves:
--ease-out decelerate-to-rest; the default for things entering.
--ease-in-out symmetric; for things that move and settle in place.
--ease-spring gentle overshoot; for playful, tactile moments.
--ease-emphasis Apple/Linear "expo" curve; for confident, premium slides. */
--ease-out: cubic-bezier(0.22, 1, 0.36, 1);
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
--ease-emphasis: cubic-bezier(0.32, 0.72, 0, 1);
/* Durations — keep chrome snappy, reserve longer ones for canvas drama. */
--dur-fast: 140ms;
--dur-base: 220ms;
--dur-slow: 340ms;
--dur-page: 260ms;
}
/* Respect users who ask for less motion: collapse durations to near-zero
so transitions become instant cuts rather than being removed wholesale. */
@media (prefers-reduced-motion: reduce) {
:root {
--dur-fast: 1ms;
--dur-base: 1ms;
--dur-slow: 1ms;
--dur-page: 1ms;
}
}
}
@@ -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"
},
@@ -38,6 +38,22 @@ export default defineConfig(() => ({
Origin: 'https://api.storyteller.ai',
},
},
// Dev-only CDN proxy. Media assets (splats, GLBs, images) are fetched
// from the CDN with fetch(), but the CDN only sends CORS headers for the
// production origin — not localhost. The web adapter's fetchAsset rewrites
// absolute CDN URLs to `/__cdn/<host>/<path>` in DEV; this forwards them
// server-side (no browser CORS). The target host is taken from the path
// so any cdn-N.fakeyou.com / Cloudflare host works.
'/__cdn': {
target: 'https://cdn-2.fakeyou.com',
changeOrigin: true,
secure: true,
router: (req) => {
const match = req.url?.match(/^\/__cdn\/([^/]+)/);
return match ? `https://${match[1]}` : 'https://cdn-2.fakeyou.com';
},
rewrite: (path) => path.replace(/^\/__cdn\/[^/]+/, ''),
},
},
},
preview:{
@@ -11,6 +11,7 @@ import {
faPenNib,
faCrosshairs,
faSparkles,
faObjectGroup,
} from "@fortawesome/pro-solid-svg-icons";
import { useMemo } from "react";
import {
@@ -34,7 +35,8 @@ export type AppId =
| "ANGLES"
| "STORYBOARD"
| "BACKGROUND_CHANGE"
| "VIDEO_EDITOR";
| "VIDEO_EDITOR"
| "MOODBOARD";
export interface AppDescriptor {
id: AppId;
@@ -290,6 +292,16 @@ export const ALL_APPS: FullAppItem[] = [
color: "bg-fuchsia-600/40",
badge: "NEW",
},
{
id: "moodboard",
label: "Moodboard",
description: "Collect references and steer generations from a board",
icon: faObjectGroup,
category: "generate",
action: "MOODBOARD",
color: "bg-orange-500/40",
badge: "BETA",
},
{
id: "background-change",
label: "Background Change",
@@ -398,6 +410,7 @@ export const goToApp = (action?: string) => {
"STORYBOARD",
"BACKGROUND_CHANGE",
"VIDEO_EDITOR",
"MOODBOARD",
].includes(action)
) {
if (action === "STORYBOARD") {
@@ -46,10 +46,7 @@ import {
} from "@storyteller/tauri-events";
import { SoundManager } from "@storyteller/soundboard";
import { useStoryboardPageEnabled } from "@storyteller/ui-settings-modal";
import {
DomLevels,
usePageSceneStore,
} from "@storyteller/ui-pagescene";
import { DomLevels, usePageSceneStore } from "@storyteller/ui-pagescene";
import { useActiveJobs } from "~/hooks/useActiveJobs";
import { useBackgroundLoadingMedia } from "~/hooks/useBackgroundLoadingMedia";
@@ -74,6 +71,7 @@ import { Storyboard } from "./PageStoryboard";
import { PageBackgroundChange } from "./PageBackgroundChange";
import { PageScene } from "./PageScene";
import { PageVideoEditor } from "./PageVideoEditor";
import { PageMoodboard } from "./PageMoodboard";
import {
topNavMediaId,
topNavMediaUrl,
@@ -223,9 +221,17 @@ const TabBody = ({ sceneToken }: { sceneToken?: string }) => {
case "3D":
return <PageScene sceneToken={sceneToken} />;
case "APPS":
return <div><AppsIndexPage /></div>;
return (
<div>
<AppsIndexPage />
</div>
);
case "2D":
return <div><PageDrawWithGalleryDrop /></div>;
return (
<div>
<PageDrawWithGalleryDrop />
</div>
);
case "IMAGE":
return (
<div>
@@ -236,27 +242,83 @@ const TabBody = ({ sceneToken }: { sceneToken?: string }) => {
</div>
);
case "VIDEO":
return <div><ImageToVideo /></div>;
return (
<div>
<ImageToVideo />
</div>
);
case "VIDEO_FRAME_EXTRACTOR":
return <div><VideoFrameExtractor /></div>;
return (
<div>
<VideoFrameExtractor />
</div>
);
case "VIDEO_WATERMARK_REMOVAL":
return <div><VideoWatermarkRemover /></div>;
return (
<div>
<VideoWatermarkRemover />
</div>
);
case "IMAGE_WATERMARK_REMOVAL":
return <div><ImageWatermarkRemover /></div>;
return (
<div>
<ImageWatermarkRemover />
</div>
);
case "IMAGE_TO_3D_OBJECT":
return <div><ImageTo3DObject /></div>;
return (
<div>
<ImageTo3DObject />
</div>
);
case "IMAGE_TO_3D_WORLD":
return <div><ImageTo3DWorld /></div>;
return (
<div>
<ImageTo3DWorld />
</div>
);
case "REMOVE_BACKGROUND":
return <div><RemoveBackground /></div>;
return (
<div>
<RemoveBackground />
</div>
);
case "ANGLES":
return <div><Angles /></div>;
return (
<div>
<Angles />
</div>
);
case "STORYBOARD":
return storyboardPageEnabled ? <div><Storyboard /></div> : null;
return storyboardPageEnabled ? (
<div>
<Storyboard />
</div>
) : null;
case "BACKGROUND_CHANGE":
return <div><PageBackgroundChange /></div>;
return (
<div>
<PageBackgroundChange />
</div>
);
case "VIDEO_EDITOR":
return <div className="h-[calc(100vh-3rem)] w-full"><PageVideoEditor /></div>;
return (
<div className="h-[calc(100vh-3rem)] w-full">
<PageVideoEditor />
</div>
);
case "MOODBOARD":
// The TopBar is fixed and 56px tall, so the board sits below it and fills
// the rest of the viewport. h-screen + pt-[56px] (border-box) makes the
// board area exactly viewport-minus-topbar, and overflow-hidden keeps
// sub-pixel 100vh rounding from spawning page scrollbars (which w-screen
// ancestors would turn into a horizontal scrollbar via the Windows
// scrollbar gutter).
return (
<div className="h-[calc(100vh-56px)] w-full overflow-hidden">
<PageMoodboard />
</div>
);
default:
return null;
}
@@ -0,0 +1,35 @@
import { useEffect } from "react";
import {
MoodboardWorkspace,
dispatchGalleryMoodboardDrop,
} from "@storyteller/ui-moodboard";
import {
GalleryItem,
onImageDrop,
removeImageDropListener,
} from "@storyteller/ui-gallery-modal";
import { desktopMoodboardAdapter } from "./desktopMoodboardAdapter";
// Desktop moodboard tab. The shared workspace fills its parent; MainApp's
// TabBody wraps this in a sized container.
export const PageMoodboard = () => {
useGalleryDropBridge();
return <MoodboardWorkspace adapter={desktopMoodboardAdapter} />;
};
// Bridges gallery-modal's onImageDrop into the moodboard's board-drop event, so
// dragging a library image onto the grid/canvas adds it. Mounted only while the
// moodboard tab is active, so it doesn't race the 2D/3D tab drop handlers.
const useGalleryDropBridge = () => {
useEffect(() => {
const handler = onImageDrop(
(item: GalleryItem, position: { x: number; y: number }) => {
dispatchGalleryMoodboardDrop(item, position);
},
);
return () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (handler) removeImageDropListener(handler as any);
};
}, []);
};
@@ -0,0 +1,107 @@
import { useState } from "react";
import toast from "react-hot-toast";
import { UploaderStates } from "@storyteller/common";
import { UploadImageMedia } from "@storyteller/api";
import { GalleryModal, GalleryItem } from "@storyteller/ui-gallery-modal";
import { useLoginModalStore } from "@storyteller/ui-login-modal";
import { usePromptImageStore, RefImage } from "@storyteller/ui-promptbox";
import type {
MoodboardAdapter,
MoodboardReference,
MoodboardLibraryPickerProps,
} from "@storyteller/ui-moodboard";
import { useTabStore } from "../Stores/TabState";
// Desktop (Tauri) implementation of the moodboard's platform seams.
const uploadImage = async (file: File): Promise<string | null> => {
let token: string | null = null;
try {
await UploadImageMedia({
title: file.name || "Moodboard image",
assetFile: file,
progressCallback: (state) => {
if (state.status === UploaderStates.success && state.data) {
token = String(state.data);
}
},
});
} catch (err) {
console.error("[Moodboard] upload failed", err);
}
return token;
};
const sendToGeneration = (refs: MoodboardReference[]): void => {
const newRefs: RefImage[] = refs.map((r) => ({
id: r.id,
url: r.url,
file: new File([], r.id),
mediaToken: r.mediaToken,
}));
const store = usePromptImageStore.getState();
store.setReferenceImages(dedupeByToken([...store.referenceImages, ...newRefs]));
void useTabStore.getState().setActiveTab("IMAGE");
toast.success(
`${refs.length} reference${refs.length > 1 ? "s" : ""} added to the prompt`,
);
};
const DesktopLibraryPicker = ({
open,
onClose,
onPick,
}: MoodboardLibraryPickerProps) => {
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const close = () => {
setSelectedIds([]);
onClose();
};
return (
<GalleryModal
isOpen={open}
onClose={close}
onLoginClick={() => {
close();
useLoginModalStore.getState().openModal();
}}
mode="select"
selectedItemIds={selectedIds}
onSelectItem={(id: string) =>
setSelectedIds((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
)
}
onUseSelected={(items: GalleryItem[]) => {
onPick(
items.map((it) => ({
url: it.fullImage || it.thumbnail || "",
mediaToken: it.id ?? null,
kind: it.mediaClass === "video" ? "video" : "image",
})),
);
setSelectedIds([]);
}}
forceFilter="image"
/>
);
};
export const desktopMoodboardAdapter: MoodboardAdapter = {
uploadImage,
sendToGeneration,
renderLibraryPicker: (props) => <DesktopLibraryPicker {...props} />,
};
const dedupeByToken = (refs: RefImage[]): RefImage[] => {
const seen = new Set<string>();
const out: RefImage[] = [];
for (const r of refs) {
if (r.mediaToken) {
if (seen.has(r.mediaToken)) continue;
seen.add(r.mediaToken);
}
out.push(r);
}
return out;
};
@@ -0,0 +1,2 @@
export { PageMoodboard } from "./PageMoodboard";
export { desktopMoodboardAdapter } from "./desktopMoodboardAdapter";
@@ -17,7 +17,8 @@ export type TabId =
| "ANGLES"
| "STORYBOARD"
| "BACKGROUND_CHANGE"
| "VIDEO_EDITOR";
| "VIDEO_EDITOR"
| "MOODBOARD";
const DEFAULT_TAB: TabId = "APPS";
+3
View File
@@ -31,6 +31,9 @@
{
"path": "../../libs/components/video-editor"
},
{
"path": "../../libs/components/moodboard"
},
{
"path": "../../libs/components/vfx"
},
@@ -1,5 +1,6 @@
export * from "./lib/gallery-modal";
export * from "./lib/galleryModalSignals";
export * from "./lib/DragGhost";
export * from "./lib/GalleryDragComponent";
export * from "./lib/GalleryDraggableItem";
export * from "./lib/GalleryItemMenuItems";
@@ -0,0 +1,145 @@
import { useEffect, useRef, useState } from "react";
import ReactDOM from "react-dom";
// Shared "floating thumbnail" that follows the cursor while dragging an item out
// of a library. Tuned to feel like physically carrying something rather than
// teleporting a flat image: it lifts in with a scale/opacity pop on pickup,
// trails the cursor with a touch of weight, and tilts into the direction of
// motion based on drag velocity. All motion is folded into a single transform
// driven by one rAF loop.
//
// Decoupled from any specific drag system via a `getState` callback the rAF polls
// each frame, so both the gallery (galleryDnd) and the 3D asset library (its own
// store-driven DnD) can reuse the exact same ghost.
const APPEAR_MS = 170; // pickup lift duration
const FOLLOW = 0.4; // cursor-follow easing (1 = rigid, lower = more trail/weight)
const TILT_PER_PX = 0.5; // velocity → tilt (deg per px/frame)
const MAX_TILT = 14; // clamp so it never looks broken
export interface DragGhostState {
/** Page-space cursor position (document coords). */
x: number;
y: number;
thumbnail: string;
/** Optional caption rendered under the thumbnail (asset library shows names). */
label?: string;
}
interface DragGhostProps {
/** Polled every frame; return null when nothing is being dragged. */
getState: () => DragGhostState | null;
width?: number;
height?: number;
/** Pass "anonymous" for CORS-tainted asset thumbnails; omit for the gallery. */
crossOrigin?: "anonymous" | "use-credentials";
}
function easeOutCubic(t: number): number {
return 1 - Math.pow(1 - t, 3);
}
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
export function DragGhost({
getState,
width = 120,
height = 120,
crossOrigin,
}: DragGhostProps) {
const [, force] = useState(0);
// Keep the latest getState in a ref so the rAF loop (started once) always reads
// fresh drag state without restarting on every render.
const getStateRef = useRef(getState);
getStateRef.current = getState;
// Animation state kept in refs so the rAF loop mutates without re-rendering.
const appearStartRef = useRef<number | null>(null);
const renderXRef = useRef(0);
const renderYRef = useRef(0);
const prevXRef = useRef(0);
const tiltRef = useRef(0);
useEffect(() => {
let frame: number;
function update() {
const state = getStateRef.current();
if (state) {
// On the first frame of a drag, snap the rendered position to the cursor
// and start the lift-in timer so it grows from the pickup point.
if (appearStartRef.current === null) {
appearStartRef.current = performance.now();
renderXRef.current = state.x;
renderYRef.current = state.y;
prevXRef.current = state.x;
}
renderXRef.current += (state.x - renderXRef.current) * FOLLOW;
renderYRef.current += (state.y - renderYRef.current) * FOLLOW;
const velocityX = state.x - prevXRef.current;
prevXRef.current = state.x;
const targetTilt = clamp(velocityX * TILT_PER_PX, -MAX_TILT, MAX_TILT);
// Ease the tilt toward target so it leans and recovers smoothly.
tiltRef.current += (targetTilt - tiltRef.current) * 0.2;
} else {
appearStartRef.current = null;
tiltRef.current = 0;
}
force((n) => (n + 1) & 0xffff);
frame = requestAnimationFrame(update);
}
frame = requestAnimationFrame(update);
return () => cancelAnimationFrame(frame);
}, []);
const state = getState();
if (
!state ||
appearStartRef.current === null ||
typeof document === "undefined"
) {
return null;
}
const appear = easeOutCubic(
clamp((performance.now() - appearStartRef.current) / APPEAR_MS, 0, 1),
);
const scale = 0.62 + 0.38 * appear;
return ReactDOM.createPortal(
<div
style={{
position: "fixed",
left: renderXRef.current + 1,
top: renderYRef.current - height / 2,
width,
height,
opacity: appear,
transform: `rotate(${tiltRef.current}deg) scale(${scale})`,
transformOrigin: "center center",
willChange: "transform, opacity",
zIndex: 10000,
pointerEvents: "none",
}}
className="flex cursor-grabbing flex-col overflow-hidden rounded-xl shadow-[0_18px_40px_-12px_rgba(0,0,0,0.7)] ring-1 ring-white/20"
>
<img
src={state.thumbnail}
alt={state.label ?? ""}
crossOrigin={crossOrigin}
className="pointer-events-none min-h-0 w-full flex-1 select-none object-cover"
/>
{state.label && (
<div className="w-full select-none truncate bg-ui-controls px-2 py-1 text-center text-[12px]">
{state.label}
</div>
)}
</div>,
document.body,
);
}
@@ -1,40 +1,22 @@
import React, { useEffect, useState } from "react";
import ReactDOM from "react-dom";
import { DragGhost } from "./DragGhost";
import galleryDnd from "./galleryDnd";
export const GalleryDragComponent: React.FC = () => {
const [dragState, setDragState] = useState(galleryDnd.getDragState());
useEffect(() => {
let frame: number;
function update() {
setDragState({ ...galleryDnd.getDragState() });
frame = requestAnimationFrame(update);
}
frame = requestAnimationFrame(update);
return () => cancelAnimationFrame(frame);
}, []);
if (!dragState.isDragging || !dragState.item) return null;
const { currX, currY, item } = dragState;
const size = 120;
const style: React.CSSProperties = {
left: currX + 1,
top: currY - size / 2,
};
return ReactDOM.createPortal(
<div
style={style}
className="fixed z-[1000] w-[120px] h-[120px] pointer-events-none aspect-square flex flex-col bg-black/70 rounded-xl overflow-hidden shadow-lg cursor-grabbing"
>
<img
src={item.thumbnail || item.fullImage || ""}
alt={item.label}
className="pointer-events-none select-none w-full h-full object-cover rounded-xl"
/>
</div>,
document.body
);
};
/**
* The floating thumbnail that follows the cursor while dragging a gallery item.
* Thin wrapper that feeds the shared {@link DragGhost} from galleryDnd's state.
*/
export const GalleryDragComponent: React.FC = () => (
<DragGhost
width={120}
height={120}
getState={() => {
const state = galleryDnd.getDragState();
if (!state.isDragging || !state.item) return null;
return {
x: state.currX,
y: state.currY,
thumbnail: state.item.thumbnail || state.item.fullImage || "",
};
}}
/>
);
@@ -68,8 +68,10 @@ export const GalleryFolderChip: React.FC<GalleryFolderChipProps> = ({
}}
style={colorCode ? { borderColor: colorCode } : undefined}
className={twMerge(
"group/chip relative w-full aspect-square overflow-hidden rounded-md border-2 border-ui-controls/40 bg-ui-controls/20 transition-colors cursor-pointer hover:border-primary/60",
"[&.folder-drag-over]:border-primary/60 [&.folder-drag-over]:bg-primary/20",
"group/chip relative w-full aspect-square overflow-hidden rounded-md border-2 border-ui-controls/40 bg-ui-controls/20 transition-[transform,border-color,background-color,box-shadow] duration-200 ease-out cursor-pointer hover:border-primary/60",
// A dragged item hovering the folder makes it lift and glow — clear,
// tactile "drop here" feedback rather than a flat color swap.
"[&.folder-drag-over]:scale-[1.04] [&.folder-drag-over]:border-primary [&.folder-drag-over]:bg-primary/20 [&.folder-drag-over]:ring-2 [&.folder-drag-over]:ring-primary/40",
)}
aria-label={name}
>
@@ -43,7 +43,7 @@ import {
PLACEHOLDER_IMAGES,
} from "@storyteller/common";
import {
galleryModalVisibleDuringDrag,
galleryModalDraggingUnder,
galleryReopenAfterDragSignal,
galleryModalVisibleViewMode,
galleryModalLightboxMediaId,
@@ -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,
@@ -2125,10 +2130,23 @@ export const GalleryModal = React.memo(
<>
<Modal
resizable={mode === "view"}
// While dragging an item out, the panel becomes pointer-transparent so
// the drag passes under it onto the canvas. With "Reopen after adding"
// ON it steps back to translucent (stays to keep adding); with it OFF
// it eases all the way out (it's closing after the drop anyway). Both
// go through the same spring, so neither is abrupt.
contentInteractive={!galleryModalDraggingUnder.value}
contentDimmed={
galleryModalDraggingUnder.value &&
galleryReopenAfterDragSignal.value
}
contentHidden={
galleryModalDraggingUnder.value &&
!galleryReopenAfterDragSignal.value
}
isOpen={
mode === "view"
? galleryModalVisibleViewMode.value &&
galleryModalVisibleDuringDrag.value
? galleryModalVisibleViewMode.value
: typeof isOpen === "boolean"
? isOpen
: true
@@ -2686,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 ? (
@@ -1,6 +1,7 @@
import { GalleryItem } from "./gallery-modal";
import {
galleryModalVisibleDuringDrag,
galleryModalDraggingUnder,
galleryModalVisibleViewMode,
galleryReopenAfterDragSignal,
} from "./galleryModalSignals";
@@ -12,7 +13,6 @@ interface DragState {
startY: number;
currX: number;
currY: number;
modalHidden: boolean;
}
const dragState: DragState = {
@@ -23,7 +23,6 @@ const dragState: DragState = {
startY: 0,
currX: 0,
currY: 0,
modalHidden: false,
};
const dragThreshold = 5;
@@ -73,6 +72,40 @@ function removeDragPreview() {
}
}
// ── Drop-success ripple ──────────────────────────────────────────────────────
// A quick brand-colored ring that expands and fades at the drop point, so a
// successful drop reads as a deliberate landing instead of the item silently
// vanishing. Self-removing; skipped under reduced-motion.
function spawnDropRipple(x: number, y: number) {
if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) return;
const ring = document.createElement("div");
ring.style.cssText = `
position: fixed;
left: ${x}px;
top: ${y}px;
z-index: 99998;
width: 30px;
height: 30px;
margin-left: -15px;
margin-top: -15px;
border-radius: 9999px;
border: 2px solid var(--primary, #2d81ff);
pointer-events: none;
`;
document.body.appendChild(ring);
const anim = ring.animate(
[
{ transform: "scale(0.4)", opacity: 0.9 },
{ transform: "scale(2.6)", opacity: 0 },
],
{ duration: 460, easing: "cubic-bezier(0.22, 1, 0.36, 1)" },
);
anim.onfinish = () => ring.remove();
anim.oncancel = () => ring.remove();
}
// ── Drag lifecycle ───────────────────────────────────────────────────────────
function onPointerDown(
@@ -88,12 +121,35 @@ function onPointerDown(
dragState.currX = event.pageX;
dragState.currY = event.pageY;
dragState.isDragging = false;
dragState.modalHidden = false;
document.body.style.cursor = "grabbing";
window.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", onPointerUp);
}
/**
* The folder chip under a screen point, found geometrically. We can't use
* `elementFromPoint` here: once a drag begins the modal goes pointer-transparent
* (so the drag can reach the canvas), which would make hit-testing skip the
* folder chips entirely.
*/
function folderIdAt(clientX: number, clientY: number): string | null {
const chips = Array.from(
document.querySelectorAll<HTMLElement>("[data-folder-id]"),
);
for (const chip of chips) {
const rect = chip.getBoundingClientRect();
if (
clientX >= rect.left &&
clientX <= rect.right &&
clientY >= rect.top &&
clientY <= rect.bottom
) {
return chip.getAttribute("data-folder-id");
}
}
return null;
}
function onPointerMove(event: PointerEvent) {
if (!dragState.item) return;
const deltaX = event.pageX - dragState.startX;
@@ -104,39 +160,27 @@ function onPointerMove(event: PointerEvent) {
) {
dragState.isDragging = true;
createDragPreview(dragState.items.length);
// Let pointer events pass through the gallery the moment the drag begins, so
// the item can be dropped anywhere — including under the modal. The panel
// eases to translucent (reopen on) or all the way out (reopen off) via the
// Modal's contentDimmed/contentHidden, so the transition is never abrupt.
galleryModalDraggingUnder.value = true;
}
dragState.currX = event.pageX;
dragState.currY = event.pageY;
if (dragState.isDragging) {
updateDragPreviewPosition(event.clientX, event.clientY);
}
if (dragState.isDragging && !dragState.modalHidden) {
// Check if cursor left the modal area - if so, hide modal for scene drop
const modalEl = document.querySelector("[data-gallery-modal]");
if (modalEl) {
const rect = modalEl.getBoundingClientRect();
const isOutside =
event.clientX < rect.left ||
event.clientX > rect.right ||
event.clientY < rect.top ||
event.clientY > rect.bottom;
if (isOutside) {
dragState.modalHidden = true;
galleryModalVisibleDuringDrag.value = false;
}
}
// Update folder hover indicators while inside the modal
if (!dragState.modalHidden) {
const folderEl = document
.elementFromPoint(event.clientX, event.clientY)
?.closest("[data-folder-id]");
document.querySelectorAll("[data-folder-id]").forEach((el) => {
el.classList.toggle("folder-drag-over", el === folderEl);
});
}
// Highlight the folder under the cursor (if any) — folders still accept
// drops even while the gallery is dimmed.
const overFolder = folderIdAt(event.clientX, event.clientY);
document.querySelectorAll("[data-folder-id]").forEach((el) => {
el.classList.toggle(
"folder-drag-over",
el.getAttribute("data-folder-id") === overFolder,
);
});
}
}
@@ -187,25 +231,28 @@ export function removeFolderDropListener(handler: (e: any) => void) {
}
function onPointerUp(event: PointerEvent) {
const wasModalHidden = dragState.modalHidden;
// When closing after an add (reopen off), keep `draggingUnder` true so the
// panel stays faded-out through the close — clearing it would race the close
// animation and flash the hidden panel back up.
let closedHidden = false;
if (dragState.item && dragState.isDragging) {
if (!dragState.modalHidden) {
// Still inside modal - check for folder drop
const folderEl = document
.elementFromPoint(event.clientX, event.clientY)
?.closest("[data-folder-id]");
if (folderEl) {
const folderId = folderEl.getAttribute("data-folder-id")!;
emitFolderDrop(dragState.items, folderId);
}
} else {
// Modal was hidden - scene drop (existing behavior)
if (
dragState.item.mediaClass === "image" ||
dragState.item.mediaClass === "dimensional"
) {
emitImageDrop(dragState.item, { x: event.pageX, y: event.pageY });
const folderId = folderIdAt(event.clientX, event.clientY);
if (folderId) {
// Dropped onto a folder — organize, and keep the gallery open.
emitFolderDrop(dragState.items, folderId);
spawnDropRipple(event.clientX, event.clientY);
} else if (
dragState.item.mediaClass === "image" ||
dragState.item.mediaClass === "dimensional"
) {
// Dropped onto the canvas (anywhere not on a folder) — add to the scene.
emitImageDrop(dragState.item, { x: event.pageX, y: event.pageY });
spawnDropRipple(event.clientX, event.clientY);
// Close the gallery after adding unless the user asked it to stay open.
if (!galleryReopenAfterDragSignal.value) {
galleryModalVisibleViewMode.value = false;
closedHidden = true;
}
}
}
@@ -220,10 +267,11 @@ function onPointerUp(event: PointerEvent) {
dragState.item = null;
dragState.items = [];
dragState.isDragging = false;
dragState.modalHidden = false;
if (wasModalHidden) {
galleryModalVisibleDuringDrag.value = galleryReopenAfterDragSignal.value;
// Refocus the gallery (un-dim, restore pointer events) — unless it's closing,
// in which case it stays faded until reopened (reset on open).
if (!closedHidden) {
galleryModalDraggingUnder.value = false;
}
document.body.style.cursor = "";
@@ -4,6 +4,11 @@ import { signal } from "@preact/signals-react";
export const galleryModalVisibleDuringDrag = signal(true);
export const galleryReopenAfterDragSignal = signal(false);
export const galleryModalVisibleViewMode = signal(false);
// True while an item is being dragged out of the gallery. The modal stays open
// but goes translucent and pointer-transparent so the drag can pass "under" it
// onto the canvas; it refocuses (or closes, per the reopen-after-adding setting)
// on drop.
export const galleryModalDraggingUnder = signal(false);
// Lightbox Modal Signals
export const galleryModalLightboxMediaId = signal<string | null>(null);
@@ -6,7 +6,7 @@ import { IconDefinition } from "@fortawesome/pro-solid-svg-icons";
import { CloseButton } from "@storyteller/ui-close-button";
import { useRef, useState, useEffect, useContext, createContext } from "react";
import { cloneElement, isValidElement } from "react";
import { useTransition, animated } from "@react-spring/web";
import { useTransition, useSpring, to, animated } from "@react-spring/web";
import {
faUpRightAndDownLeftFromCenter,
faDownLeftAndUpRightToCenter,
@@ -183,6 +183,9 @@ export const Modal = ({
closeOnOutsideClick = true,
closeOnEsc = true,
allowBackgroundInteraction = false,
contentInteractive = true,
contentDimmed = false,
contentHidden = false,
expandable = false,
accessibleTitle,
accessibleDescription,
@@ -207,6 +210,28 @@ export const Modal = ({
closeOnOutsideClick?: boolean;
closeOnEsc?: boolean;
allowBackgroundInteraction?: boolean;
/**
* When false, the modal's content panel ignores pointer events (events fall
* through to whatever is behind it). Lets a caller make the panel a passive,
* see-through overlay — e.g. the gallery going translucent so a drag can pass
* under it onto the canvas. Defaults to true.
*/
contentInteractive?: boolean;
/**
* When true, fades the content panel down (transition-eased). Applied to an
* inner wrapper so it composes with — rather than fights — the spring opacity
* the modal animates on open/close. Pair with `contentInteractive={false}` for
* a passive, see-through overlay. Defaults to false.
*/
contentDimmed?: boolean;
/**
* When true, eases the whole panel all the way out (opacity → 0, slightly more
* scale-down) through the same spring as `contentDimmed`, so a "hide it" reads
* as a smooth step-away rather than an abrupt close. The panel stays mounted;
* the caller flips `isOpen` once it's faded if it wants to fully unmount.
* Takes precedence over `contentDimmed`. Defaults to false.
*/
contentHidden?: boolean;
expandable?: boolean;
accessibleTitle?: string;
accessibleDescription?: string;
@@ -250,6 +275,17 @@ export const Modal = ({
},
});
// Smoothly drives the `contentDimmed`/`contentHidden` "step back": the panel
// eases to a target opacity factor (1 = shown, 0.4 = translucent, 0 = hidden)
// with a paired scale-down, so going translucent or hiding reads as a
// deliberate recede rather than a flicker or an abrupt cut. easeOutCubic over
// 280ms: responsive on the way down, calm as it settles.
const dimFactor = contentHidden ? 0 : contentDimmed ? 0.4 : 1;
const dimSpring = useSpring({
factor: dimFactor,
config: { duration: 280, easing: (t: number) => 1 - Math.pow(1 - t, 3) },
});
const backdropTransitions = useTransition(isOpen, {
from: { opacity: 0 },
enter: { opacity: 1 },
@@ -872,6 +908,16 @@ export const Modal = ({
}, [isOpen, zIndex, onClose, closeOnEsc]);
const getModalStyle = (): React.CSSProperties => {
// Pointer behavior of the content panel: explicitly off when the caller
// wants it passive (drag-through); otherwise re-enabled when the rest of the
// screen is click-through (allowBackgroundInteraction makes the wrappers
// pointer-none, so the panel needs pointer-auto to stay interactive).
const pointerStyle: React.CSSProperties = !contentInteractive
? { pointerEvents: "none" }
: allowBackgroundInteraction
? { pointerEvents: "auto" }
: {};
// fill the entire viewport even if theres a stored size or position
if (expanded) {
return {
@@ -882,7 +928,7 @@ export const Modal = ({
zIndex,
width: "100vw",
height: "100vh",
...(allowBackgroundInteraction ? { pointerEvents: "auto" } : {}),
...pointerStyle,
};
}
@@ -892,7 +938,7 @@ export const Modal = ({
...(resizable && size
? { width: size.width, height: size.height }
: {}),
...(allowBackgroundInteraction ? { pointerEvents: "auto" } : {}),
...pointerStyle,
};
}
return {
@@ -902,7 +948,7 @@ export const Modal = ({
margin: 0,
zIndex,
...(resizable && size ? { width: size.width, height: size.height } : {}),
...(allowBackgroundInteraction ? { pointerEvents: "auto" } : {}),
...pointerStyle,
};
};
@@ -985,14 +1031,35 @@ export const Modal = ({
ref={modalRef}
style={{
...getModalStyle(),
opacity: styles.opacity,
transform: styles.transform,
// The dim/hide factor eases the whole panel (background
// included): translucent (0.4) or hidden (0), with a
// paired scale-down. Composed with the open/close spring
// so both animations coexist (factor multiplies opacity;
// the scale-down is appended to the spring transform).
opacity: to(
[styles.opacity, dimSpring.factor],
(o, f) => (o as number) * (f as number),
),
transform: to(
[styles.transform, dimSpring.factor],
(tr, f) => `${tr} scale(${0.93 + 0.07 * (f as number)})`,
),
transformOrigin: "center center",
willChange: "transform, opacity", // Optimize for animations
outline: "none",
}}
>
<div className="w-full h-full">
<div
className={twMerge(
"w-full h-full",
// When dimmed/hiding for a drag-under, fade out media so
// the colored thumbnails don't block the view of
// whatever's behind the translucent panel — the layout
// (faint card outlines) stays as a placeholder.
(contentDimmed || contentHidden) &&
"[&_img]:opacity-0 [&_video]:opacity-0 [&_img]:transition-opacity [&_video]:transition-opacity [&_img]:duration-200 [&_video]:duration-200",
)}
>
{accessibleDescription && (
<VisuallyHidden asChild>
<Dialog.Description>
@@ -0,0 +1,17 @@
{
"name": "@storyteller/ui-moodboard",
"version": "0.0.1",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"development": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
}
}
}
@@ -0,0 +1,49 @@
// Public API for @storyteller/ui-moodboard
// Components — each platform supplies a MoodboardAdapter.
// MoodboardWorkspace is the full surface (Grid + freeform Konva Canvas + Present);
// the individual views are also exported for apps that want to compose their own.
export { MoodboardWorkspace } from "./lib/MoodboardWorkspace";
export { BoardGridView as MoodboardGrid } from "./lib/grid/BoardGridView";
export { PresentationView as MoodboardPresentation } from "./lib/grid/PresentationView";
// Stores + selectors. useMoodboardStore is the Konva canvas runtime state
// (separate from the durable board model in useBoardLibraryStore).
export { useBoardLibraryStore } from "./lib/boards/BoardLibraryStore";
export { useMoodboardStore } from "./lib/canvas/MoodboardStore";
export {
useActiveBoard,
filterItems,
collectTags,
} from "./lib/boards/boardSelectors";
// Adapter (platform seams)
export type {
MoodboardAdapter,
MoodboardReference,
MoodboardPickedItem,
MoodboardLibraryPickerProps,
} from "./lib/adapter";
// Gallery → board drop bridge. Host apps wire their gallery's drop callback to
// this; the grid + canvas views listen for the event it dispatches.
export {
dispatchGalleryMoodboardDrop,
GALLERY_MOODBOARD_DROP_EVENT,
} from "./lib/galleryDrop";
export type { DroppedGalleryItem } from "./lib/galleryDrop";
// Board model types
export type {
Board,
BoardItem,
BoardImageItem,
BoardVideoItem,
BoardTextItem,
BoardLinkItem,
BoardColorItem,
BoardSection,
BoardItemKind,
ViewMode,
GridDensity,
} from "./lib/boards/boardTypes";
@@ -0,0 +1,133 @@
import { ReactNode, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import {
faTableCells,
faVectorSquare,
faPlay,
} from "@fortawesome/pro-regular-svg-icons";
import { BoardGridView } from "./grid/BoardGridView";
import { PresentationView } from "./grid/PresentationView";
import { useBoardLibraryStore } from "./boards/BoardLibraryStore";
import { useActiveBoard } from "./boards/boardSelectors";
import type { ViewMode } from "./boards/boardTypes";
import type { MoodboardAdapter } from "./adapter";
import { Moodboard } from "./canvas/Moodboard";
interface Props {
adapter: MoodboardAdapter;
// Host chrome rendered in the top-right cluster, left of nothing — beside the
// Present button. Webapp hosts that hide the global topbar inject their nav
// actions (pricing/credits/task queue/profile) here, mirroring the 3D and
// video editors. Tauri leaves it unset.
topBarEndSlot?: ReactNode;
}
// Root moodboard surface. Switches how the board is rendered — Grid (virtualized
// masonry) vs Canvas (freeform Konva planning) — plus a transient Presentation
// overlay. Fills its parent; the host app sizes the container. Platform seams
// (upload, library picker, send-to-generation) arrive via the adapter.
export const MoodboardWorkspace = ({ adapter, topBarEndSlot }: Props) => {
const viewMode = useBoardLibraryStore((s) => s.viewMode);
const setViewMode = useBoardLibraryStore((s) => s.setViewMode);
const board = useActiveBoard();
// Presentation is a transient overlay, not a persisted view mode — so a
// reload never reopens straight into a slideshow.
const [presenting, setPresenting] = useState(false);
const presentItems = board
? board.itemOrder.map((id) => board.items[id]).filter((it) => Boolean(it))
: [];
return (
<div
data-moodboard-root
className="relative h-full w-full overflow-hidden"
// Desktop themes set --st-bg (gray/light/black/aurora) so the board follows
// the active theme. The webapp never defines --st-bg, so it falls back to
// its own --background token (0 0% 9% / #171717), matching the rest of the app.
style={{ background: "var(--st-bg, hsl(0 0% 9%))" }}
>
{viewMode === "grid" ? (
<BoardGridView active={!presenting} adapter={adapter} />
) : (
<Moodboard adapter={adapter} />
)}
<ViewSwitch mode={viewMode} onChange={setViewMode} />
<TopRightCluster
onPresent={() => setPresenting(true)}
endSlot={topBarEndSlot}
/>
{presenting && (
<PresentationView
items={presentItems}
onClose={() => setPresenting(false)}
/>
)}
</div>
);
};
const OPTIONS: Array<{ mode: ViewMode; label: string; icon: IconDefinition }> =
[
{ mode: "grid", label: "Grid", icon: faTableCells },
{ mode: "canvas", label: "Canvas", icon: faVectorSquare },
];
const ViewSwitch = ({
mode,
onChange,
}: {
mode: ViewMode;
onChange: (m: ViewMode) => void;
}) => (
<div className="glass absolute left-3 top-3 z-40 flex items-center gap-0.5 rounded-2xl border border-ui-divider p-1 shadow-[0_8px_24px_-12px_rgba(0,0,0,0.45)]">
{OPTIONS.map((opt) => {
const active = opt.mode === mode;
return (
<button
key={opt.mode}
type="button"
onClick={() => onChange(opt.mode)}
className={[
"flex items-center gap-2 rounded-xl px-3.5 py-1.5 text-sm font-medium",
"transition-colors duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary",
active
? "bg-base-fg/15 text-base-fg"
: "text-base-fg/55 hover:text-base-fg",
].join(" ")}
>
<FontAwesomeIcon icon={opt.icon} className="h-3.5 w-3.5" />
{opt.label}
</button>
);
})}
</div>
);
// Top-right chrome: the Present action (always present, including Tauri) and the
// host's relocated nav actions (webapp only, via endSlot). The wrapper is
// click-through so the gap between the two never blocks the board beneath.
const TopRightCluster = ({
onPresent,
endSlot,
}: {
onPresent: () => void;
endSlot?: ReactNode;
}) => (
<div className="pointer-events-none absolute right-3 top-3 z-40 flex items-center gap-2">
<div className="glass pointer-events-auto flex items-center rounded-xl border border-ui-divider p-0.5 shadow-[0_8px_24px_-12px_rgba(0,0,0,0.45)]">
<button
type="button"
onClick={onPresent}
title="Present"
aria-label="Present"
className="flex items-center gap-2 rounded-xl px-3.5 py-1.5 text-sm font-medium text-base-fg/55 transition-colors duration-150 hover:text-base-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
<FontAwesomeIcon icon={faPlay} className="h-3 w-3" />
Present
</button>
</div>
{endSlot && <div className="pointer-events-auto">{endSlot}</div>}
</div>
);
@@ -0,0 +1,37 @@
import type { ReactNode } from "react";
// Platform-specific seams the moodboard needs but can't own itself. Each app
// (desktop Tauri / web) supplies a concrete adapter; the lib stays portable.
export interface MoodboardReference {
id: string;
url: string;
mediaToken: string;
}
export interface MoodboardPickedItem {
url: string;
mediaToken: string | null;
kind: "image" | "video";
}
export interface MoodboardLibraryPickerProps {
open: boolean;
onClose: () => void;
onPick: (items: MoodboardPickedItem[]) => void;
}
export interface MoodboardAdapter {
/** Upload a file, resolving to a durable media token (or null if the platform
* can't provide one). When present, uploaded images become reference-capable
* and persist beyond the blob URL. */
uploadImage?: (file: File) => Promise<string | null>;
/** Push selected board images to the platform's generation surface
* (desktop: prompt store + tab switch; web: sessionStorage + route nav). */
sendToGeneration: (refs: MoodboardReference[]) => void;
/** Optional library/gallery picker as a render-prop. Omit to disable the
* "From library" action (e.g. when a platform's picker isn't wired yet). */
renderLibraryPicker?: (props: MoodboardLibraryPickerProps) => ReactNode;
}
@@ -0,0 +1,74 @@
import { beforeEach, describe, expect, it } from "vitest";
import { useBoardLibraryStore } from "./BoardLibraryStore";
// Fresh store per test — clear persisted boards and any selection.
const reset = () => {
useBoardLibraryStore.setState({
boards: {},
boardOrder: [],
activeBoardId: null,
selectedItemIds: new Set(),
});
};
const seedItem = (boardId: string): string =>
useBoardLibraryStore.getState().addTextItem(boardId, "note");
describe("BoardLibraryStore sections", () => {
beforeEach(reset);
it("creates, renames, and collapses a section", () => {
const store = useBoardLibraryStore.getState();
const boardId = store.createBoard("B");
const sectionId = store.createSection(boardId, "Refs");
let board = useBoardLibraryStore.getState().boards[boardId];
expect(board.sections).toEqual([{ id: sectionId, name: "Refs" }]);
store.renameSection(boardId, sectionId, "Hero shots");
store.toggleSectionCollapsed(boardId, sectionId);
board = useBoardLibraryStore.getState().boards[boardId];
expect(board.sections[0].name).toBe("Hero shots");
expect(board.sections[0].collapsed).toBe(true);
});
it("falls back to a default name for blank section names", () => {
const store = useBoardLibraryStore.getState();
const boardId = store.createBoard("B");
const sectionId = store.createSection(boardId, " ");
const board = useBoardLibraryStore.getState().boards[boardId];
expect(board.sections.find((s) => s.id === sectionId)?.name).toBe(
"New section",
);
});
it("assigns items into a section and back to ungrouped", () => {
const store = useBoardLibraryStore.getState();
const boardId = store.createBoard("B");
const itemId = seedItem(boardId);
const sectionId = store.createSection(boardId);
store.assignItemsToSection(boardId, [itemId], sectionId);
expect(
useBoardLibraryStore.getState().boards[boardId].items[itemId].sectionId,
).toBe(sectionId);
store.assignItemsToSection(boardId, [itemId], null);
expect(
useBoardLibraryStore.getState().boards[boardId].items[itemId].sectionId,
).toBeNull();
});
it("returns items to the ungrouped flow when their section is deleted", () => {
const store = useBoardLibraryStore.getState();
const boardId = store.createBoard("B");
const itemId = seedItem(boardId);
const sectionId = store.createSection(boardId);
store.assignItemsToSection(boardId, [itemId], sectionId);
store.deleteSection(boardId, sectionId);
const board = useBoardLibraryStore.getState().boards[boardId];
expect(board.sections).toHaveLength(0);
expect(board.items[itemId].sectionId).toBeNull();
});
});
@@ -0,0 +1,547 @@
import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
import { v4 as uuidv4 } from "uuid";
import {
Board,
BoardImageItem,
BoardItem,
BoardLinkItem,
BoardVideoItem,
COLOR_ITEM_ASPECT,
GridDensity,
LINK_ITEM_ASPECT,
TEXT_ITEM_ASPECT,
ViewMode,
} from "./boardTypes";
const STORAGE_KEY = "artcraft_moodboard_library";
const STORAGE_VERSION = 1;
// Persisted slice of the library (excludes transient UI: selection, search,
// tag filters). All mutations go through the actions below — that indirection
// is the seam a future backend sync layer slots into without touching views.
interface PersistedLibrary {
boards: Record<string, Board>;
boardOrder: string[];
activeBoardId: string | null;
viewMode: ViewMode;
density: GridDensity;
}
interface BoardLibraryState extends PersistedLibrary {
// Transient (not persisted)
searchQuery: string;
activeTagFilters: string[];
ratingFilter: number;
selectedItemIds: Set<string>;
// Board lifecycle
ensureActiveBoard: () => string;
createBoard: (name?: string) => string;
renameBoard: (id: string, name: string) => void;
deleteBoard: (id: string) => void;
setActiveBoard: (id: string) => void;
// Item entry — typed creators that fill base fields + aspect.
addImageItem: (
boardId: string,
data: Pick<BoardImageItem, "src" | "mediaId" | "naturalW" | "naturalH"> &
Partial<Pick<BoardImageItem, "caption">>,
) => string;
addVideoItem: (
boardId: string,
data: Pick<BoardVideoItem, "src" | "mediaId" | "naturalW" | "naturalH">,
) => string;
addTextItem: (boardId: string, text: string) => string;
addLinkItem: (
boardId: string,
data: Pick<BoardLinkItem, "url" | "title" | "description" | "image">,
) => string;
addColorItem: (boardId: string, color: string) => string;
updateItem: (boardId: string, id: string, patch: Partial<BoardItem>) => void;
removeItems: (boardId: string, ids: string[]) => void;
// Triage: set the same rating (0-5) on many items in one history step.
rateItems: (boardId: string, ids: string[], rating: number) => void;
// Sections — organize items into named lanes within a board.
createSection: (boardId: string, name?: string) => string;
renameSection: (boardId: string, sectionId: string, name: string) => void;
deleteSection: (boardId: string, sectionId: string) => void;
toggleSectionCollapsed: (boardId: string, sectionId: string) => void;
// Move items into a section (or back to the ungrouped flow with null).
assignItemsToSection: (
boardId: string,
ids: string[],
sectionId: string | null,
) => void;
// View + filters
setViewMode: (mode: ViewMode) => void;
setDensity: (density: GridDensity) => void;
setSearchQuery: (q: string) => void;
toggleTagFilter: (tag: string) => void;
setRatingFilter: (min: number) => void;
clearFilters: () => void;
// Selection (operates on the active board)
setSelection: (ids: Iterable<string>) => void;
// Clicks accumulate; `additive` is accepted for caller compatibility but no
// longer changes behavior (a plain click already toggles within the set).
toggleSelection: (id: string, additive?: boolean) => void;
clearSelection: () => void;
deleteSelected: () => void;
}
const DEFAULT_BOARD_NAME = "Untitled board";
const DEFAULT_SECTION_NAME = "New section";
// Monotonic counter for stable createdAt-like ordering. Date.now() is avoided
// so behaviour stays deterministic and testable; ordering is all we need.
let seq = 0;
const nextSeq = () => ++seq;
const mediaAspect = (naturalW: number, naturalH: number): number => {
if (naturalW > 0 && naturalH > 0) return naturalH / naturalW;
return 1;
};
export const useBoardLibraryStore = create<BoardLibraryState>()(
persist(
(set, get) => ({
boards: {},
boardOrder: [],
activeBoardId: null,
viewMode: "grid",
density: "comfortable",
searchQuery: "",
activeTagFilters: [],
ratingFilter: 0,
selectedItemIds: new Set(),
ensureActiveBoard: () => {
const { activeBoardId, boards } = get();
if (activeBoardId && boards[activeBoardId]) return activeBoardId;
const firstId = get().boardOrder[0];
if (firstId && boards[firstId]) {
set({ activeBoardId: firstId });
return firstId;
}
return get().createBoard();
},
createBoard: (name) => {
const id = uuidv4();
const order = nextSeq();
const board: Board = {
id,
name: name?.trim() || DEFAULT_BOARD_NAME,
createdAt: order,
updatedAt: order,
itemOrder: [],
items: {},
sections: [],
};
set((s) => ({
boards: { ...s.boards, [id]: board },
boardOrder: [...s.boardOrder, id],
activeBoardId: id,
}));
return id;
},
renameBoard: (id, name) => {
set((s) => {
const board = s.boards[id];
if (!board) return s;
return {
boards: {
...s.boards,
[id]: { ...board, name: name.trim() || DEFAULT_BOARD_NAME },
},
};
});
},
deleteBoard: (id) => {
set((s) => {
if (!s.boards[id]) return s;
Object.values(s.boards[id].items).forEach(revokeBlobSrc);
const boards = { ...s.boards };
delete boards[id];
const boardOrder = s.boardOrder.filter((b) => b !== id);
const activeBoardId =
s.activeBoardId === id ? boardOrder[0] ?? null : s.activeBoardId;
return { boards, boardOrder, activeBoardId };
});
},
setActiveBoard: (id) => {
if (!get().boards[id]) return;
set({ activeBoardId: id, selectedItemIds: new Set() });
},
addImageItem: (boardId, data) =>
addItemToBoard(set, get, boardId, (base) => ({
...base,
kind: "image",
src: data.src,
mediaId: data.mediaId,
naturalW: data.naturalW,
naturalH: data.naturalH,
caption: data.caption ?? "",
aspect: mediaAspect(data.naturalW, data.naturalH),
})),
addVideoItem: (boardId, data) =>
addItemToBoard(set, get, boardId, (base) => ({
...base,
kind: "video",
src: data.src,
mediaId: data.mediaId,
naturalW: data.naturalW,
naturalH: data.naturalH,
aspect: mediaAspect(data.naturalW, data.naturalH),
})),
addTextItem: (boardId, text) =>
addItemToBoard(set, get, boardId, (base) => ({
...base,
kind: "text",
text,
aspect: TEXT_ITEM_ASPECT,
})),
addLinkItem: (boardId, data) =>
addItemToBoard(set, get, boardId, (base) => ({
...base,
kind: "link",
url: data.url,
title: data.title,
description: data.description,
image: data.image,
aspect: data.image ? LINK_ITEM_ASPECT : TEXT_ITEM_ASPECT,
})),
addColorItem: (boardId, color) =>
addItemToBoard(set, get, boardId, (base) => ({
...base,
kind: "color",
color,
aspect: COLOR_ITEM_ASPECT,
})),
updateItem: (boardId, id, patch) => {
set((s) => {
const board = s.boards[boardId];
if (!board || !board.items[id]) return s;
const merged = { ...board.items[id], ...patch } as BoardItem;
return {
boards: {
...s.boards,
[boardId]: {
...board,
items: { ...board.items, [id]: merged },
updatedAt: nextSeq(),
},
},
};
});
},
removeItems: (boardId, ids) => {
if (ids.length === 0) return;
set((s) => {
const board = s.boards[boardId];
if (!board) return s;
const remove = new Set(ids);
const items = { ...board.items };
remove.forEach((id) => {
revokeBlobSrc(items[id]);
delete items[id];
});
const selectedItemIds = new Set(s.selectedItemIds);
remove.forEach((id) => selectedItemIds.delete(id));
return {
boards: {
...s.boards,
[boardId]: {
...board,
items,
itemOrder: board.itemOrder.filter((x) => !remove.has(x)),
updatedAt: nextSeq(),
},
},
selectedItemIds,
};
});
},
rateItems: (boardId, ids, rating) => {
if (ids.length === 0) return;
const clamped = Math.max(0, Math.min(5, Math.round(rating)));
set((s) => {
const board = s.boards[boardId];
if (!board) return s;
const items = { ...board.items };
ids.forEach((id) => {
const it = items[id];
if (it) items[id] = { ...it, rating: clamped };
});
return {
boards: {
...s.boards,
[boardId]: { ...board, items, updatedAt: nextSeq() },
},
};
});
},
createSection: (boardId, name) => {
const id = uuidv4();
set((s) => {
const board = s.boards[boardId];
if (!board) return s;
const section = { id, name: name?.trim() || DEFAULT_SECTION_NAME };
return {
boards: {
...s.boards,
[boardId]: {
...board,
sections: [...board.sections, section],
updatedAt: nextSeq(),
},
},
};
});
return id;
},
renameSection: (boardId, sectionId, name) => {
set((s) => {
const board = s.boards[boardId];
if (!board) return s;
return {
boards: {
...s.boards,
[boardId]: {
...board,
sections: board.sections.map((sec) =>
sec.id === sectionId
? { ...sec, name: name.trim() || DEFAULT_SECTION_NAME }
: sec,
),
},
},
};
});
},
deleteSection: (boardId, sectionId) => {
set((s) => {
const board = s.boards[boardId];
if (!board) return s;
// Items in the section fall back to the ungrouped flow.
const items = { ...board.items };
for (const [id, it] of Object.entries(items)) {
if (it.sectionId === sectionId) items[id] = { ...it, sectionId: null };
}
return {
boards: {
...s.boards,
[boardId]: {
...board,
items,
sections: board.sections.filter((sec) => sec.id !== sectionId),
updatedAt: nextSeq(),
},
},
};
});
},
toggleSectionCollapsed: (boardId, sectionId) => {
set((s) => {
const board = s.boards[boardId];
if (!board) return s;
return {
boards: {
...s.boards,
[boardId]: {
...board,
sections: board.sections.map((sec) =>
sec.id === sectionId
? { ...sec, collapsed: !sec.collapsed }
: sec,
),
},
},
};
});
},
assignItemsToSection: (boardId, ids, sectionId) => {
if (ids.length === 0) return;
set((s) => {
const board = s.boards[boardId];
if (!board) return s;
const target = new Set(ids);
const items = { ...board.items };
target.forEach((id) => {
const it = items[id];
if (it) items[id] = { ...it, sectionId };
});
return {
boards: {
...s.boards,
[boardId]: { ...board, items, updatedAt: nextSeq() },
},
};
});
},
setViewMode: (mode) => set({ viewMode: mode }),
setDensity: (density) => set({ density }),
setSearchQuery: (q) => set({ searchQuery: q }),
toggleTagFilter: (tag) =>
set((s) => ({
activeTagFilters: s.activeTagFilters.includes(tag)
? s.activeTagFilters.filter((t) => t !== tag)
: [...s.activeTagFilters, tag],
})),
setRatingFilter: (min) => set({ ratingFilter: Math.max(0, Math.min(5, min)) }),
clearFilters: () =>
set({ searchQuery: "", activeTagFilters: [], ratingFilter: 0 }),
setSelection: (ids) => set({ selectedItemIds: new Set(ids) }),
// Every click accumulates: a plain click adds the item to the current
// selection (or removes it if already selected), so clicking item after
// item builds up a multi-selection without needing a modifier. The
// `additive` flag (ctrl/cmd/shift-click) behaves identically here; the
// distinction is only meaningful for marquee/range selection.
toggleSelection: (id) =>
set((s) => {
const next = new Set(s.selectedItemIds);
if (next.has(id)) next.delete(id);
else next.add(id);
return { selectedItemIds: next };
}),
clearSelection: () => set({ selectedItemIds: new Set() }),
deleteSelected: () => {
const { activeBoardId, selectedItemIds } = get();
if (!activeBoardId || selectedItemIds.size === 0) return;
get().removeItems(activeBoardId, Array.from(selectedItemIds));
},
}),
{
name: STORAGE_KEY,
version: STORAGE_VERSION,
storage: createJSONStorage(() => localStorage),
// Only the library + view prefs persist; selection/search are per-session.
// Blob-backed items are dropped — their object URLs die with the document,
// so persisting them would only resurrect broken tiles after a reload.
partialize: (s): PersistedLibrary => ({
boards: stripEphemeralItems(s.boards),
boardOrder: s.boardOrder,
activeBoardId: s.activeBoardId,
viewMode: s.viewMode,
density: s.density,
}),
// Keep `seq` ahead of any restored ordering values so new items always
// sort after persisted ones.
onRehydrateStorage: () => (state) => {
if (!state) return;
let max = 0;
Object.values(state.boards).forEach((b) => {
max = Math.max(max, b.createdAt, b.updatedAt);
Object.values(b.items).forEach((it) => {
max = Math.max(max, it.createdAt);
});
});
seq = Math.max(seq, max);
},
},
),
);
// ---------- helpers ----------
// Returns an item's blob: object URL, or null if it isn't blob-backed. Narrows
// to image/video internally so callers get a plain string back.
const blobSrc = (item: BoardItem): string | null => {
if (item.kind !== "image" && item.kind !== "video") return null;
return item.src.startsWith("blob:") ? item.src : null;
};
// Release an item's object URL when it leaves the store, so dropped/uploaded
// blobs don't pin their backing File for the life of the document.
const revokeBlobSrc = (item: BoardItem | undefined): void => {
const src = item ? blobSrc(item) : null;
if (!src) return;
try {
URL.revokeObjectURL(src);
} catch {
// ignore
}
};
// Persistence view of the boards with blob-backed (ephemeral) items removed and
// their order entries pruned. Non-blob image/video (library picks) are kept.
const stripEphemeralItems = (
boards: Record<string, Board>,
): Record<string, Board> => {
const out: Record<string, Board> = {};
for (const [boardId, board] of Object.entries(boards)) {
const items: Record<string, BoardItem> = {};
for (const [itemId, item] of Object.entries(board.items)) {
if (!blobSrc(item)) items[itemId] = item;
}
out[boardId] = {
...board,
items,
itemOrder: board.itemOrder.filter((id) => items[id]),
};
}
return out;
};
// Shared item-insertion path used by every typed creator. The `build` callback
// receives the filled base fields and returns the full discriminated item.
const addItemToBoard = (
set: (fn: (s: BoardLibraryState) => Partial<BoardLibraryState>) => void,
get: () => BoardLibraryState,
boardId: string,
build: (base: {
id: string;
sectionId: null;
createdAt: number;
tags: string[];
rating: number;
}) => BoardItem,
): string => {
const id = uuidv4();
const item = build({
id,
sectionId: null,
createdAt: nextSeq(),
tags: [],
rating: 0,
});
set((s) => {
const board = s.boards[boardId];
if (!board) return s;
return {
boards: {
...s.boards,
[boardId]: {
...board,
items: { ...board.items, [id]: item },
itemOrder: [...board.itemOrder, id],
updatedAt: nextSeq(),
},
},
};
});
return id;
};
@@ -0,0 +1,53 @@
import { useBoardLibraryStore } from "./BoardLibraryStore";
import { Board, BoardItem } from "./boardTypes";
export const useActiveBoard = (): Board | null =>
useBoardLibraryStore((s) =>
s.activeBoardId ? s.boards[s.activeBoardId] ?? null : null,
);
// Items in display order, narrowed by search query, tag filters, and a minimum
// rating. Pure so the grid can memoize on (board, query, tagFilters, minRating).
export const filterItems = (
board: Board,
query: string,
tagFilters: string[],
minRating = 0,
): BoardItem[] => {
const q = query.trim().toLowerCase();
return board.itemOrder
.map((id) => board.items[id])
.filter((it): it is BoardItem => Boolean(it))
.filter((it) => {
if (minRating > 0 && it.rating < minRating) return false;
if (tagFilters.length && !tagFilters.every((t) => it.tags.includes(t))) {
return false;
}
return q ? itemMatchesText(it, q) : true;
});
};
// All distinct tags across a board, for the filter chips.
export const collectTags = (board: Board): string[] => {
const seen = new Set<string>();
board.itemOrder.forEach((id) => {
board.items[id]?.tags.forEach((t) => seen.add(t));
});
return Array.from(seen).sort();
};
const itemMatchesText = (it: BoardItem, q: string): boolean => {
if (it.tags.some((t) => t.toLowerCase().includes(q))) return true;
switch (it.kind) {
case "text":
return it.text.toLowerCase().includes(q);
case "link":
return `${it.title} ${it.description} ${it.url}`.toLowerCase().includes(q);
case "image":
return it.caption.toLowerCase().includes(q);
case "color":
return it.color.toLowerCase().includes(q);
default:
return false;
}
};
@@ -0,0 +1,90 @@
// The board model is the source of truth for the moodboard feature. Both the
// Grid view (virtualized masonry) and the Canvas view render from it. Items
// carry a deterministic `aspect` so the masonry positioner can lay out and
// virtualize without measuring the DOM.
export type BoardItemKind = "image" | "video" | "text" | "link" | "color";
export type ViewMode = "grid" | "canvas";
// Grid density maps to a target column width + gap (see gridLayout.ts). Named
// rather than numeric so it reads well in the UI and persists stably.
export type GridDensity = "comfortable" | "cozy" | "compact";
interface BoardItemBase {
id: string;
kind: BoardItemKind;
// null = ungrouped (lives in the board's default flow).
sectionId: string | null;
createdAt: number;
tags: string[];
// height / width ratio used by the masonry positioner.
aspect: number;
// 0 = unrated; 1-5 reserved for Lightroom-style triage (Phase 3).
rating: number;
}
export interface BoardImageItem extends BoardItemBase {
kind: "image";
src: string;
mediaId: string | null;
naturalW: number;
naturalH: number;
caption: string;
}
export interface BoardVideoItem extends BoardItemBase {
kind: "video";
src: string;
mediaId: string | null;
naturalW: number;
naturalH: number;
}
export interface BoardTextItem extends BoardItemBase {
kind: "text";
text: string;
}
export interface BoardLinkItem extends BoardItemBase {
kind: "link";
url: string;
title: string;
description: string;
image: string | null;
}
export interface BoardColorItem extends BoardItemBase {
kind: "color";
color: string;
}
export type BoardItem =
| BoardImageItem
| BoardVideoItem
| BoardTextItem
| BoardLinkItem
| BoardColorItem;
export interface BoardSection {
id: string;
name: string;
// Header-collapsed in the grid. Persists so a tidied board stays tidy.
collapsed?: boolean;
}
export interface Board {
id: string;
name: string;
createdAt: number;
updatedAt: number;
// Display / z order for items in the grid.
itemOrder: string[];
items: Record<string, BoardItem>;
sections: BoardSection[];
}
// Nominal aspect ratios for non-media items, so masonry stays deterministic.
export const TEXT_ITEM_ASPECT = 0.62;
export const LINK_ITEM_ASPECT = 0.92;
export const COLOR_ITEM_ASPECT = 1;
@@ -0,0 +1,21 @@
// Client-side link metadata — no proxy, no network beyond the favicon <img>.
// Bare display host: strips the scheme and a leading "www.".
export const hostnameOf = (url: string): string => {
try {
return new URL(url).hostname.replace(/^www\./, "");
} catch {
return url;
}
};
// The site's own favicon, served from its origin (no third-party proxy). Falls
// back to a link glyph at the render site when the <img> errors.
export const faviconOf = (url: string): string | null => {
try {
const u = new URL(url);
return `${u.protocol}//${u.hostname}/favicon.ico`;
} catch {
return null;
}
};
@@ -0,0 +1,30 @@
// Probe intrinsic dimensions so masonry can lay an item out before it paints.
// Both resolve (never reject) with a square fallback so a failed probe still
// yields a usable tile.
export const measureImage = (
url: string,
): Promise<{ w: number; h: number }> =>
new Promise((resolve) => {
const img = new window.Image();
// No crossOrigin: we only read naturalWidth/Height (which never taint a
// canvas), and forcing anonymous CORS makes non-CORS images fail to load —
// collapsing them to the 320x320 fallback. palette.ts sets crossOrigin
// itself, where getImageData actually needs it.
img.onload = () => resolve({ w: img.naturalWidth, h: img.naturalHeight });
img.onerror = () => resolve({ w: 320, h: 320 });
img.src = url;
});
export const measureVideo = (
url: string,
): Promise<{ w: number; h: number }> =>
new Promise((resolve) => {
const v = document.createElement("video");
v.preload = "metadata";
v.muted = true;
v.onloadedmetadata = () =>
resolve({ w: v.videoWidth || 320, h: v.videoHeight || 320 });
v.onerror = () => resolve({ w: 320, h: 320 });
v.src = url;
});
@@ -0,0 +1,77 @@
// Client-side color extraction. Downscales the image to a small sample and
// buckets colors (4 bits/channel) to find a representative palette + dominant
// color, with near-duplicate suppression. Returns [] on a cross-origin tainted
// canvas (getImageData throws) — callers degrade gracefully.
const SAMPLE = 40;
const MIN_DISTANCE = 48;
interface Rgb {
r: number;
g: number;
b: number;
}
export const extractPalette = (url: string, count = 5): Promise<string[]> =>
new Promise((resolve) => {
const img = new window.Image();
img.crossOrigin = "anonymous";
img.onload = () => {
try {
resolve(quantize(img, count));
} catch {
resolve([]);
}
};
img.onerror = () => resolve([]);
img.src = url;
});
const quantize = (img: HTMLImageElement, count: number): string[] => {
const canvas = document.createElement("canvas");
canvas.width = SAMPLE;
canvas.height = SAMPLE;
const ctx = canvas.getContext("2d");
if (!ctx) return [];
ctx.drawImage(img, 0, 0, SAMPLE, SAMPLE);
const { data } = ctx.getImageData(0, 0, SAMPLE, SAMPLE);
const buckets = new Map<number, Rgb & { n: number }>();
for (let i = 0; i < data.length; i += 4) {
if (data[i + 3] < 128) continue;
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
const key = ((r >> 4) << 8) | ((g >> 4) << 4) | (b >> 4);
const e = buckets.get(key);
if (e) {
e.r += r;
e.g += g;
e.b += b;
e.n += 1;
} else {
buckets.set(key, { r, g, b, n: 1 });
}
}
const sorted = Array.from(buckets.values()).sort((a, b) => b.n - a.n);
const chosen: Rgb[] = [];
for (const e of sorted) {
const c: Rgb = {
r: Math.round(e.r / e.n),
g: Math.round(e.g / e.n),
b: Math.round(e.b / e.n),
};
if (chosen.every((o) => distance(o, c) > MIN_DISTANCE)) chosen.push(c);
if (chosen.length >= count) break;
}
return chosen.map((c) => toHex(c));
};
const distance = (a: Rgb, b: Rgb): number =>
Math.abs(a.r - b.r) + Math.abs(a.g - b.g) + Math.abs(a.b - b.b);
const toHex = ({ r, g, b }: Rgb): string => {
const h = (r << 16) | (g << 8) | b;
return `#${h.toString(16).padStart(6, "0").toUpperCase()}`;
};
@@ -0,0 +1,188 @@
import { ReactNode, useCallback, useEffect, useRef, useState } from "react";
import toast from "react-hot-toast";
import { useBoardLibraryStore } from "./BoardLibraryStore";
import { BoardImageItem } from "./boardTypes";
import { measureImage, measureVideo } from "./measureMedia";
import type { MoodboardAdapter, MoodboardPickedItem } from "../adapter";
interface UseReturn {
triggerUpload: () => void;
triggerGallery: () => void;
/** Adds an empty note and returns its id so the grid can open it for editing. */
addNote: () => string;
modals: ReactNode;
}
// Generic add-flows: upload, library pick (via adapter render-prop), clipboard
// paste (image + URL), note. The platform-specific bits — how a file is
// uploaded and what the library picker is — come from the adapter. (Color entry
// lives in the toolbar's ColorPickerPopover, which commits via addColorItem.)
export const useBoardItemEntry = (
adapter: MoodboardAdapter,
enabled: boolean,
): UseReturn => {
const fileInputRef = useRef<HTMLInputElement>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const triggerUpload = useCallback(() => fileInputRef.current?.click(), []);
const triggerGallery = useCallback(() => {
if (adapter.renderLibraryPicker) setPickerOpen(true);
else
toast("Library picker isn't available here — upload or paste for now", {
icon: "🖼️",
});
}, [adapter]);
const addNote = useCallback(() => {
const store = useBoardLibraryStore.getState();
// Empty text → the card shows its "Note" placeholder and the grid drops
// straight into edit mode so the user types instead of clearing "New note".
const id = store.addTextItem(store.ensureActiveBoard(), "");
store.setSelection([id]);
return id;
}, []);
// Place the image immediately from a blob, then (if the platform supports it)
// upload to capture a durable mediaToken so it becomes reference-capable.
const addImageFile = useCallback(
async (file: File) => {
const store = useBoardLibraryStore.getState();
const boardId = store.ensureActiveBoard();
const blobUrl = URL.createObjectURL(file);
const dims = await measureImage(blobUrl);
const itemId = store.addImageItem(boardId, {
src: blobUrl,
mediaId: null,
naturalW: dims.w,
naturalH: dims.h,
});
if (!adapter.uploadImage) return;
try {
const token = await adapter.uploadImage(file);
if (token) {
const patch: Partial<BoardImageItem> = { mediaId: token };
useBoardLibraryStore.getState().updateItem(boardId, itemId, patch);
}
} catch (err) {
console.error("[Moodboard] upload failed", err);
}
},
[adapter],
);
const handleFiles = useCallback(
async (files: FileList) => {
for (const file of Array.from(files)) {
if (file.type.startsWith("image/")) await addImageFile(file);
}
},
[addImageFile],
);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files && files.length) void handleFiles(files);
if (fileInputRef.current) fileInputRef.current.value = "";
};
const handlePicked = useCallback(async (items: MoodboardPickedItem[]) => {
const store = useBoardLibraryStore.getState();
const boardId = store.ensureActiveBoard();
for (const it of items) {
if (!it.url) continue;
if (it.kind === "video") {
const dims = await measureVideo(it.url);
store.addVideoItem(boardId, {
src: it.url,
mediaId: it.mediaToken,
naturalW: dims.w,
naturalH: dims.h,
});
} else {
const dims = await measureImage(it.url);
store.addImageItem(boardId, {
src: it.url,
mediaId: it.mediaToken,
naturalW: dims.w,
naturalH: dims.h,
});
}
}
setPickerOpen(false);
}, []);
// ----- clipboard paste (images + URLs) -----
useEffect(() => {
if (!enabled) return undefined;
const onPaste = (e: ClipboardEvent) => {
const target = e.target as HTMLElement | null;
if (target && /input|textarea/i.test(target.tagName)) return;
const data = e.clipboardData;
if (!data) return;
const imageFile = Array.from(data.items)
.find((it) => it.type.startsWith("image/"))
?.getAsFile();
if (imageFile) {
e.preventDefault();
void addImageFile(imageFile);
return;
}
const text = data.getData("text/plain").trim();
if (isUrl(text)) {
e.preventDefault();
const store = useBoardLibraryStore.getState();
const boardId = store.ensureActiveBoard();
store.addLinkItem(boardId, {
url: text,
title: hostnameOf(text),
description: "",
image: null,
});
}
};
window.addEventListener("paste", onPaste);
return () => window.removeEventListener("paste", onPaste);
}, [enabled, addImageFile]);
const modals = (
<>
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={handleFileChange}
/>
{adapter.renderLibraryPicker?.({
open: pickerOpen,
onClose: () => setPickerOpen(false),
onPick: (items) => void handlePicked(items),
})}
</>
);
return { triggerUpload, triggerGallery, addNote, modals };
};
// ---------- helpers ----------
const isUrl = (text: string): boolean => {
if (!/^https?:\/\//i.test(text)) return false;
try {
new URL(text);
return true;
} catch {
return false;
}
};
const hostnameOf = (url: string): string => {
try {
return new URL(url).hostname.replace(/^www\./, "");
} catch {
return url;
}
};
@@ -0,0 +1,27 @@
import { ReactNode } from "react";
import {
ToolbarShell,
ToolbarIconButton,
ToolbarDivider,
} from "../ui/Toolbar";
interface FloatingToolbarProps {
children: ReactNode;
className?: string;
}
// Canvas top bar — the same glass island as the Grid toolbar (ToolbarShell),
// centered and detached from the top edge.
export const FloatingToolbar = ({
children,
className,
}: FloatingToolbarProps) => (
<div className="pointer-events-none flex w-full justify-center pt-3">
<ToolbarShell className={className}>{children}</ToolbarShell>
</div>
);
// Re-exported under the canvas names so existing callers stay unchanged while
// sharing one button/divider look with the Grid toolbar.
export const FloatingToolbarDivider = ToolbarDivider;
export const FloatingToolbarButton = ToolbarIconButton;
@@ -0,0 +1,83 @@
import { useEffect, useRef } from "react";
import Konva from "konva";
import { MoodboardToolbar } from "./MoodboardToolbar";
import { MoodboardStage } from "./MoodboardStage";
import { TextEditOverlay } from "./TextEditOverlay";
import { useMoodboardStore } from "./MoodboardStore";
import { useUndoRedo } from "./interactions/useUndoRedo";
import { usePasteHandler } from "./interactions/usePasteHandler";
import { useGalleryDropEvent } from "./interactions/useGalleryDropEvent";
import { useKeyboardShortcuts } from "./interactions/useKeyboardShortcuts";
import { useShortcutCheatsheet } from "./interactions/useShortcutCheatsheet";
import { useMoodboardImageEntry } from "./useMoodboardImageEntry";
import { RecenterIndicator } from "./overlays/RecenterIndicator";
import { ShortcutCheatsheet } from "./overlays/ShortcutCheatsheet";
import type { MoodboardAdapter } from "../adapter";
// import { EmptyMoodboardCTA } from "./EmptyMoodboardCTA";
interface Props {
adapter: MoodboardAdapter;
}
export const Moodboard = ({ adapter }: Props) => {
const containerRef = useRef<HTMLDivElement | null>(null);
const stageRef = useRef<Konva.Stage | null>(null);
const deleteSelected = useMoodboardStore((s) => s.deleteSelected);
const setSelection = useMoodboardStore((s) => s.setSelection);
const editingTextId = useMoodboardStore((s) => s.transient.editingTextId);
// const isEmpty = useMoodboardStore((s) => s.rootOrder.length === 0);
useUndoRedo(true);
usePasteHandler(adapter, true, stageRef);
useGalleryDropEvent(true, stageRef);
useKeyboardShortcuts(true);
const cheatsheetVisible = useShortcutCheatsheet();
const { triggerUpload, triggerGallery, modals } = useMoodboardImageEntry(
adapter,
stageRef,
);
// Delete / Backspace removes the current selection. Skip when typing in
// an input or while a text node is in edit mode.
useEffect(() => {
const handler = (e: KeyboardEvent) => {
const target = e.target as HTMLElement | null;
if (target && /input|textarea/i.test(target.tagName)) return;
if (target && target.isContentEditable) return;
if (editingTextId) return;
if (e.key === "Delete" || e.key === "Backspace") {
e.preventDefault();
deleteSelected();
} else if (e.key === "Escape") {
setSelection([]);
}
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [deleteSelected, setSelection, editingTextId]);
return (
<div
ref={containerRef}
className="relative h-full w-full overflow-hidden bg-ui-panel"
>
<MoodboardStage containerRef={containerRef} stageRef={stageRef} />
<TextEditOverlay containerRef={containerRef} />
<RecenterIndicator />
<ShortcutCheatsheet visible={cheatsheetVisible} />
{/* {isEmpty && (
<EmptyMoodboardCTA
onUploadClick={triggerUpload}
onGalleryClick={triggerGallery}
/>
)} */}
<div className="absolute left-0 right-0 top-0 z-10">
<MoodboardToolbar
onUploadClick={triggerUpload}
onGalleryClick={triggerGallery}
/>
</div>
{modals}
</div>
);
};
@@ -0,0 +1,66 @@
import { memo } from "react";
import { Shape } from "react-konva";
import Konva from "konva";
import { Rect } from "./types";
interface Props {
// Visible region in stage (document) coordinates. Driven by pan/zoom.
viewport: Rect;
spacing: number;
zoom: number;
dotColor?: string;
// On-screen radius in CSS pixels. Converted to stage units so dots stay
// the same visual size regardless of zoom.
dotRadiusPx?: number;
}
// Minimum on-screen gap between dots before we drop to a coarser grid.
// At zoom 1× with spacing 32, dots are 32px apart; as you zoom out, the
// LOD step doubles `spacing` so density never drops below this bound.
const MIN_SCREEN_SPACING = 16;
// Vector dotted grid that follows the visible viewport. Dots stay anchored
// at fixed stage coords (multiples of the effective spacing); zooming out
// coarsens the grid in powers of 2 so we never draw tens of thousands of
// arcs per frame.
const MoodboardBackgroundInner = ({
viewport,
spacing,
zoom,
dotColor = "rgba(255,255,255,0.18)",
dotRadiusPx = 1.2,
}: Props) => {
const baseOnScreen = spacing * zoom;
const lodStep =
baseOnScreen >= MIN_SCREEN_SPACING
? 0
: Math.ceil(Math.log2(MIN_SCREEN_SPACING / Math.max(baseOnScreen, 1e-6)));
const effectiveSpacing = spacing * Math.pow(2, lodStep);
const radius = dotRadiusPx / Math.max(zoom, 1e-6);
return (
<Shape
x={0}
y={0}
listening={false}
sceneFunc={(ctx: Konva.Context, shape) => {
const startX = Math.floor(viewport.x / effectiveSpacing) * effectiveSpacing;
const startY = Math.floor(viewport.y / effectiveSpacing) * effectiveSpacing;
const endX = viewport.x + viewport.width;
const endY = viewport.y + viewport.height;
ctx.fillStyle = dotColor;
ctx.beginPath();
for (let x = startX; x <= endX; x += effectiveSpacing) {
for (let y = startY; y <= endY; y += effectiveSpacing) {
ctx.moveTo(x + radius, y);
ctx.arc(x, y, radius, 0, Math.PI * 2);
}
}
ctx.fill();
ctx.fillStrokeShape(shape);
}}
/>
);
};
export const MoodboardBackground = memo(MoodboardBackgroundInner);
@@ -0,0 +1,47 @@
import { create } from "zustand";
import { MoodboardSnapshot } from "./types";
const MAX_HISTORY = 50;
interface MoodboardHistoryState {
past: MoodboardSnapshot[];
future: MoodboardSnapshot[];
push: (snapshot: MoodboardSnapshot) => void;
undo: (current: MoodboardSnapshot) => MoodboardSnapshot | null;
redo: (current: MoodboardSnapshot) => MoodboardSnapshot | null;
clear: () => void;
}
export const useMoodboardHistoryStore = create<MoodboardHistoryState>(
(set, get) => ({
past: [],
future: [],
push: (snapshot) => {
const past = get().past;
const next = [...past, snapshot];
if (next.length > MAX_HISTORY) next.shift();
set({ past: next, future: [] });
},
undo: (current) => {
const { past, future } = get();
if (past.length === 0) return null;
const prior = past[past.length - 1];
set({
past: past.slice(0, -1),
future: [...future, current],
});
return prior;
},
redo: (current) => {
const { past, future } = get();
if (future.length === 0) return null;
const next = future[future.length - 1];
set({
past: [...past, current],
future: future.slice(0, -1),
});
return next;
},
clear: () => set({ past: [], future: [] }),
}),
);
@@ -0,0 +1,301 @@
import { useCallback, useEffect, useMemo } from "react";
import Konva from "konva";
import { Stage, Layer, Rect, Line, Group, Transformer } from "react-konva";
import { useThemeFgRgb } from "./useThemeFgRgb";
import { useShallow } from "zustand/react/shallow";
import { useMoodboardStore } from "./MoodboardStore";
import { MoodboardBackground } from "./MoodboardBackground";
import { ImageNode } from "./nodes/ImageNode";
import { VideoNode } from "./nodes/VideoNode";
import { TextNode } from "./nodes/TextNode";
import { GroupNode } from "./nodes/GroupNode";
import { useSelection } from "./interactions/useSelection";
import { useTransformer } from "./interactions/useTransformer";
import { useViewportControls } from "./interactions/useViewportControls";
import { stagePointerPos } from "./interactions/useStagePointer";
interface Props {
containerRef: React.RefObject<HTMLDivElement | null>;
stageRef: React.MutableRefObject<Konva.Stage | null>;
}
export const MoodboardStage = ({ containerRef, stageRef }: Props) => {
const transformerRef = useTransformer(stageRef);
const nodes = useMoodboardStore((s) => s.nodes);
const rootOrder = useMoodboardStore((s) => s.rootOrder);
const selectedIds = useMoodboardStore((s) => s.selectedIds);
const tool = useMoodboardStore((s) => s.tool);
const gridSpacing = useMoodboardStore((s) => s.gridSpacing);
// Only subscribe to the transient fields this component actually renders
// (marquee + lasso). Skips re-renders on `editingTextId` and `isPanning`
// toggles, which don't affect Stage JSX.
const { marquee, lassoPath, guides } = useMoodboardStore(
useShallow((s) => ({
marquee: s.transient.marquee,
lassoPath: s.transient.lassoPath,
guides: s.transient.guides,
})),
);
const viewport = useMoodboardStore((s) => s.viewport);
const canvasSize = useMoodboardStore((s) => s.canvasSize);
const setLastDropPoint = useMoodboardStore((s) => s.setLastDropPoint);
const setCanvasSize = useMoodboardStore((s) => s.setCanvasSize);
const toggleInSelection = useMoodboardStore((s) => s.toggleInSelection);
const setEditingText = useMoodboardStore((s) => s.setEditingText);
const addText = useMoodboardStore((s) => s.addText);
const zoom = viewport.zoom;
const pan = viewport.pan;
const fgRgb = useThemeFgRgb();
// Track wrapper size with ResizeObserver so the Stage fills its container.
// The size lives in the store so overlays (recenter indicator, future
// minimap) can read it without drilling refs through the tree.
useEffect(() => {
const el = containerRef.current;
if (!el) return undefined;
const updateSize = () => {
setCanvasSize({ width: el.clientWidth, height: el.clientHeight });
};
updateSize();
const ro = new ResizeObserver(updateSize);
ro.observe(el);
return () => ro.disconnect();
}, [containerRef, setCanvasSize]);
useSelection(stageRef);
useViewportControls(stageRef);
// Update lastDropPoint as the cursor moves across the stage so external
// drops (uploads, paste fallbacks) land near the cursor.
useEffect(() => {
const stage = stageRef.current;
if (!stage) return undefined;
const handle = () => {
const pos = stagePointerPos(stage);
if (pos) setLastDropPoint(pos);
};
stage.on("mousemove.moodboardCursor", handle);
return () => {
stage.off("mousemove.moodboardCursor");
};
}, [setLastDropPoint, stageRef]);
// Stable across renders so memoized node children see an unchanged
// onSelect prop and skip re-render when only camera/selection state
// changes. Depends only on a Zustand action, which itself is stable.
const handleNodeSelect = useCallback(
(id: string, e: Konva.KonvaEventObject<MouseEvent | TouchEvent>) => {
const evt = e.evt as MouseEvent | TouchEvent;
const additive = "shiftKey" in evt && evt.shiftKey;
toggleInSelection(id, additive);
},
[toggleInSelection],
);
const handleStageClick = (e: Konva.KonvaEventObject<MouseEvent>) => {
if (e.target !== stageRef.current) return;
if (tool === "text") {
const pos = stagePointerPos(stageRef.current);
if (pos) addText(pos);
return;
}
// Don't setSelection([]) here: Konva fires `click` after any marquee
// drag that ends on bare canvas, which would stomp on the selection
// `useSelection` just committed. The zero-area branch in `useSelection`
// handles the "bare click with no drag" case.
setEditingText(null);
};
// Visible region in stage coords; drives the dotted background grid so
// it follows pan/zoom rather than rendering a fixed (0,0,w,h) block.
// Memoized so transient store updates (marquee, lasso, isPanning) don't
// churn its identity and force `MoodboardBackground` to re-run sceneFunc
// every frame of a marquee drag.
const visibleViewport = useMemo(
() => ({
x: -pan.x / zoom,
y: -pan.y / zoom,
width: canvasSize.width / zoom,
height: canvasSize.height / zoom,
}),
[pan.x, pan.y, zoom, canvasSize.width, canvasSize.height],
);
return (
<Stage
ref={(s) => {
stageRef.current = s;
}}
width={canvasSize.width}
height={canvasSize.height}
x={pan.x}
y={pan.y}
scaleX={zoom}
scaleY={zoom}
onClick={handleStageClick}
onTap={handleStageClick}
style={{ cursor: tool === "text" ? "text" : "default" }}
>
<Layer listening={false}>
<MoodboardBackground
viewport={visibleViewport}
spacing={gridSpacing}
zoom={zoom}
dotColor={`rgb(${fgRgb} / 0.10)`}
/>
</Layer>
<Layer>
{rootOrder.map((id) => {
const n = nodes[id];
if (!n) return null;
const selected = selectedIds.has(id);
const draggable = tool === "select";
if (n.kind === "image") {
return (
<ImageNode
key={id}
node={n}
draggable={draggable}
selected={selected}
onSelect={handleNodeSelect}
/>
);
}
if (n.kind === "video") {
return (
<VideoNode
key={id}
node={n}
draggable={draggable}
selected={selected}
onSelect={handleNodeSelect}
/>
);
}
if (n.kind === "text") {
return (
<TextNode
key={id}
node={n}
draggable={draggable}
selected={selected}
onSelect={handleNodeSelect}
/>
);
}
if (n.kind === "group") {
return (
<GroupNode
key={id}
node={n}
selected={selected}
onSelect={handleNodeSelect}
/>
);
}
return null;
})}
<Transformer
ref={(t) => {
transformerRef.current = t;
}}
rotateEnabled
keepRatio={false}
/>
</Layer>
<Layer listening={false}>
{Array.from(selectedIds).map((id) => {
const n = nodes[id];
if (!n || n.parentId !== null) return null;
if (n.kind === "group") {
// Union AABB of children in group-local coords, wrapped in a
// Konva Group so the outline rotates around the group's origin.
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const cid of n.childIds) {
const c = nodes[cid];
if (!c) continue;
minX = Math.min(minX, c.x);
minY = Math.min(minY, c.y);
maxX = Math.max(maxX, c.x + c.width);
maxY = Math.max(maxY, c.y + c.height);
}
if (!Number.isFinite(minX)) return null;
return (
<Group
key={`outline-${id}`}
x={n.x}
y={n.y}
rotation={n.rotation}
listening={false}
>
<Rect
x={minX}
y={minY}
width={maxX - minX}
height={maxY - minY}
stroke="#3b82f6"
strokeWidth={1 / zoom}
dash={[4 / zoom, 4 / zoom]}
listening={false}
/>
</Group>
);
}
return (
<Rect
key={`outline-${id}`}
x={n.x}
y={n.y}
width={n.width}
height={n.height}
rotation={n.rotation}
stroke="#3b82f6"
strokeWidth={1 / zoom}
dash={[4 / zoom, 4 / zoom]}
listening={false}
/>
);
})}
{marquee && (
<Rect
x={marquee.x}
y={marquee.y}
width={marquee.width}
height={marquee.height}
stroke="#3b82f6"
strokeWidth={1 / zoom}
dash={[4 / zoom, 4 / zoom]}
fill="rgba(59,130,246,0.08)"
/>
)}
{lassoPath && lassoPath.length >= 2 && (
<Line
points={lassoPath.flatMap((p) => [p.x, p.y])}
stroke="#3b82f6"
strokeWidth={1 / zoom}
dash={[4 / zoom, 4 / zoom]}
closed={false}
/>
)}
{guides.map((g, i) => (
<Line
key={`guide-${i}`}
points={
g.axis === "x"
? [g.pos, g.from, g.pos, g.to]
: [g.from, g.pos, g.to, g.pos]
}
stroke="#f43f5e"
strokeWidth={1 / zoom}
listening={false}
/>
))}
</Layer>
</Stage>
);
};
@@ -0,0 +1,706 @@
import { create } from "zustand";
import { v4 as uuidv4 } from "uuid";
import {
CardNode,
GroupNode,
ImageNode,
MoodboardNode,
MoodboardSnapshot,
Rect,
TextNode,
Tool,
Vec2,
VideoNode,
} from "./types";
import { useMoodboardHistoryStore } from "./MoodboardHistoryStore";
import {
DEFAULT_CARD_COLOR,
DEFAULT_CARD_PADDING,
DEFAULT_CARD_WIDTH,
computeCardHeight,
} from "./cardLayout";
import {
CanvasSize,
Viewport,
computeAABBById,
computeContentBounds,
fitViewportToBounds,
} from "./layout/geometry";
import { SnapGuide } from "./layout/snapping";
// Hover state published during a node drag so overlay UI can highlight the card
// (or to-be card) the dragged node would land in. `targetId` is null when the
// pointer is over bare canvas.
export interface DragPreview {
draggedId: string;
targetId: string | null;
}
interface Transient {
marquee: Rect | null;
lassoPath: Vec2[] | null;
editingTextId: string | null;
isPanning: boolean;
dragPreview: DragPreview | null;
// Alignment guides shown live during a snapped drag.
guides: SnapGuide[];
}
interface MoodboardState {
nodes: Record<string, MoodboardNode>;
rootOrder: string[];
selectedIds: Set<string>;
tool: Tool;
gridSpacing: number;
// Snap a dragged node to nearby node edges/centers + the grid.
snapEnabled: boolean;
lastDropPoint: Vec2;
viewport: Viewport;
canvasSize: CanvasSize;
transient: Transient;
setTool: (t: Tool) => void;
toggleSnap: () => void;
setGuides: (guides: SnapGuide[]) => void;
setLastDropPoint: (p: Vec2) => void;
setZoom: (z: number) => void;
setPan: (p: Vec2) => void;
setCanvasSize: (s: CanvasSize) => void;
resetViewport: () => void;
fitToContent: (padding?: number) => void;
selectAll: () => void;
addImage: (
src: string,
pos: Vec2,
naturalW: number,
naturalH: number,
mediaId?: string | null,
) => string;
addVideo: (
src: string,
pos: Vec2,
naturalW: number,
naturalH: number,
mediaId?: string | null,
) => string;
addText: (pos: Vec2) => string;
updateNode: (id: string, patch: Partial<MoodboardNode>) => void;
setCardColor: (id: string, color: string) => void;
deleteSelected: () => void;
applyLayoutPatches: (
patches: Array<{ id: string; patch: Partial<MoodboardNode> }>,
) => void;
setSelection: (ids: Iterable<string>) => void;
toggleInSelection: (id: string, additive: boolean) => void;
clearSelection: () => void;
group: () => string | null;
ungroup: () => void;
// Atomic multi-cluster grouping for "auto-group by proximity": creates one
// group per provided cluster of ids, with a single history entry covering
// all of them. Returns the new group ids.
groupClusters: (clusters: string[][]) => string[];
// Card stacking (drag-onto-node gesture). `createCardFromDrop` forms a new
// card from a bare target node plus the dragged node; `appendToCard` adds the
// dragged node into an existing card. Both remove the dragged node from the
// root and reflow the card's height.
createCardFromDrop: (draggedId: string, targetId: string) => string | null;
appendToCard: (cardId: string, draggedId: string) => void;
setMarquee: (r: Rect | null) => void;
setLassoPath: (p: Vec2[] | null) => void;
setEditingText: (id: string | null) => void;
setIsPanning: (v: boolean) => void;
setDragPreview: (p: DragPreview | null) => void;
// Snapshot helpers used by history wiring (do NOT push to history themselves).
snapshot: () => MoodboardSnapshot;
applySnapshot: (snap: MoodboardSnapshot) => void;
pushHistory: () => void;
undo: () => void;
redo: () => void;
reset: () => void;
}
const DEFAULT_TEXT_W = 200;
const DEFAULT_TEXT_H = 60;
const DEFAULT_FONT_SIZE = 24;
const DEFAULT_TEXT_COLOR = "#FFFFFF";
const MAX_IMAGE_DIMENSION = 480;
const fitWithinBox = (w: number, h: number, max: number): { w: number; h: number } => {
if (w <= max && h <= max) return { w, h };
const ratio = Math.min(max / w, max / h);
return { w: w * ratio, h: h * ratio };
};
export const useMoodboardStore = create<MoodboardState>((set, get) => ({
nodes: {},
rootOrder: [],
selectedIds: new Set(),
tool: "select",
gridSpacing: 32,
snapEnabled: true,
lastDropPoint: { x: 200, y: 200 },
viewport: { zoom: 1, pan: { x: 0, y: 0 } },
canvasSize: { width: 800, height: 600 },
transient: {
marquee: null,
lassoPath: null,
editingTextId: null,
isPanning: false,
dragPreview: null,
guides: [],
},
setTool: (t) => set({ tool: t }),
toggleSnap: () => set((s) => ({ snapEnabled: !s.snapEnabled })),
setGuides: (guides) =>
set((s) => ({ transient: { ...s.transient, guides } })),
setLastDropPoint: (p) => set({ lastDropPoint: p }),
setZoom: (z) => set((s) => ({ viewport: { ...s.viewport, zoom: z } })),
setPan: (p) => set((s) => ({ viewport: { ...s.viewport, pan: p } })),
setCanvasSize: (sz) => set({ canvasSize: sz }),
resetViewport: () =>
set({ viewport: { zoom: 1, pan: { x: 0, y: 0 } } }),
fitToContent: (padding = 48) => {
const { nodes, rootOrder, canvasSize } = get();
const bounds = computeContentBounds(nodes, rootOrder);
if (!bounds) return;
set({ viewport: fitViewportToBounds(bounds, canvasSize, padding) });
},
selectAll: () => {
const { rootOrder } = get();
if (rootOrder.length === 0) return;
set({ selectedIds: new Set(rootOrder) });
},
addImage: (src, pos, naturalW, naturalH, mediaId = null) => {
get().pushHistory();
const id = uuidv4();
const fit = fitWithinBox(naturalW || 320, naturalH || 320, MAX_IMAGE_DIMENSION);
const node: ImageNode = {
id,
kind: "image",
parentId: null,
x: pos.x - fit.w / 2,
y: pos.y - fit.h / 2,
width: fit.w,
height: fit.h,
rotation: 0,
zIndex: get().rootOrder.length,
src,
mediaId,
naturalW,
naturalH,
};
set((s) => ({
nodes: { ...s.nodes, [id]: node },
rootOrder: [...s.rootOrder, id],
selectedIds: new Set([id]),
}));
return id;
},
addVideo: (src, pos, naturalW, naturalH, mediaId = null) => {
get().pushHistory();
const id = uuidv4();
const fit = fitWithinBox(naturalW || 320, naturalH || 320, MAX_IMAGE_DIMENSION);
const node: VideoNode = {
id,
kind: "video",
parentId: null,
x: pos.x - fit.w / 2,
y: pos.y - fit.h / 2,
width: fit.w,
height: fit.h,
rotation: 0,
zIndex: get().rootOrder.length,
src,
mediaId,
naturalW,
naturalH,
// Silent looping autoplay is the Milanote-style default — browsers
// only permit autoplay when muted, so these three travel together.
muted: true,
loop: true,
autoplay: true,
};
set((s) => ({
nodes: { ...s.nodes, [id]: node },
rootOrder: [...s.rootOrder, id],
selectedIds: new Set([id]),
}));
return id;
},
addText: (pos) => {
get().pushHistory();
const id = uuidv4();
const node: TextNode = {
id,
kind: "text",
parentId: null,
x: pos.x - DEFAULT_TEXT_W / 2,
y: pos.y - DEFAULT_TEXT_H / 2,
width: DEFAULT_TEXT_W,
height: DEFAULT_TEXT_H,
rotation: 0,
zIndex: get().rootOrder.length,
text: "Note",
fontSize: DEFAULT_FONT_SIZE,
color: DEFAULT_TEXT_COLOR,
};
set((s) => ({
nodes: { ...s.nodes, [id]: node },
rootOrder: [...s.rootOrder, id],
selectedIds: new Set([id]),
transient: { ...s.transient, editingTextId: id },
}));
return id;
},
updateNode: (id, patch) => {
set((s) => {
const existing = s.nodes[id];
if (!existing) return s;
// Keep discriminant intact by spreading then overlaying with patch fields.
const merged = { ...existing, ...patch } as MoodboardNode;
return { nodes: { ...s.nodes, [id]: merged } };
});
},
setCardColor: (id, color) => {
// Live edit (called continuously while dragging the color picker), so it
// deliberately skips history — the undo stack would otherwise flood with a
// frame per pointermove.
set((s) => {
const n = s.nodes[id];
if (!n || n.kind !== "card") return s;
return { nodes: { ...s.nodes, [id]: { ...n, backgroundColor: color } } };
});
},
deleteSelected: () => {
const { selectedIds } = get();
if (selectedIds.size === 0) return;
get().pushHistory();
set((s) => {
const removed = new Set<string>();
const collect = (id: string) => {
const n = s.nodes[id];
if (!n) return;
removed.add(id);
if (n.kind === "group" || n.kind === "card") n.childIds.forEach(collect);
};
selectedIds.forEach(collect);
const nodes: Record<string, MoodboardNode> = {};
Object.entries(s.nodes).forEach(([id, n]) => {
if (removed.has(id)) return;
// Filter dangling child references in container nodes.
if (n.kind === "group" || n.kind === "card") {
nodes[id] = {
...n,
childIds: n.childIds.filter((c) => !removed.has(c)),
};
} else {
nodes[id] = n;
}
});
return {
nodes,
rootOrder: s.rootOrder.filter((id) => !removed.has(id)),
selectedIds: new Set(),
};
});
},
applyLayoutPatches: (patches) => {
if (patches.length === 0) return;
get().pushHistory();
set((s) => {
const nodes = { ...s.nodes };
patches.forEach(({ id, patch }) => {
const existing = nodes[id];
if (!existing) return;
nodes[id] = { ...existing, ...patch } as MoodboardNode;
});
return { nodes };
});
},
setSelection: (ids) => {
set({ selectedIds: stripAncestorsAndDescendants(get().nodes, new Set(ids)) });
},
toggleInSelection: (id, additive) => {
set((s) => {
const next = additive ? new Set(s.selectedIds) : new Set<string>();
if (additive && s.selectedIds.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return { selectedIds: stripAncestorsAndDescendants(s.nodes, next) };
});
},
clearSelection: () => set({ selectedIds: new Set() }),
group: () => {
const { selectedIds, nodes } = get();
const topLevelIds = Array.from(selectedIds).filter((id) => {
const n = nodes[id];
return n && n.parentId === null;
});
if (topLevelIds.length < 2) return null;
get().pushHistory();
const groupId = uuidv4();
const aabb = computeAABBById(nodes, topLevelIds);
if (!aabb) return null;
const groupNode: GroupNode = {
id: groupId,
kind: "group",
parentId: null,
x: aabb.x,
y: aabb.y,
width: aabb.width,
height: aabb.height,
rotation: 0,
zIndex: get().rootOrder.length,
childIds: topLevelIds,
};
set((s) => {
const newNodes: Record<string, MoodboardNode> = { ...s.nodes };
// Rebase each child position to be group-local.
topLevelIds.forEach((id) => {
const n = newNodes[id];
if (!n) return;
newNodes[id] = {
...n,
parentId: groupId,
x: n.x - aabb.x,
y: n.y - aabb.y,
} as MoodboardNode;
});
newNodes[groupId] = groupNode;
return {
nodes: newNodes,
rootOrder: [
...s.rootOrder.filter((id) => !topLevelIds.includes(id)),
groupId,
],
selectedIds: new Set([groupId]),
};
});
return groupId;
},
groupClusters: (clusters) => {
// Single-shot atomic version of group() that creates one group per
// cluster of ids, with one history entry covering all of them. Used by
// "Auto-group by proximity" so undo collapses to a single step.
const validClusters = clusters.filter((cluster) => cluster.length >= 2);
if (validClusters.length === 0) return [];
get().pushHistory();
const newGroupIds: string[] = [];
set((s) => {
const newNodes: Record<string, MoodboardNode> = { ...s.nodes };
let newRootOrder = [...s.rootOrder];
for (const cluster of validClusters) {
const topLevelIds = cluster.filter((id) => {
const n = newNodes[id];
return n && n.parentId === null;
});
if (topLevelIds.length < 2) continue;
const aabb = computeAABBById(newNodes, topLevelIds);
if (!aabb) continue;
const groupId = uuidv4();
const groupNode: GroupNode = {
id: groupId,
kind: "group",
parentId: null,
x: aabb.x,
y: aabb.y,
width: aabb.width,
height: aabb.height,
rotation: 0,
zIndex: newRootOrder.length,
childIds: topLevelIds,
};
topLevelIds.forEach((id) => {
const n = newNodes[id];
if (!n) return;
newNodes[id] = {
...n,
parentId: groupId,
x: n.x - aabb.x,
y: n.y - aabb.y,
} as MoodboardNode;
});
newNodes[groupId] = groupNode;
newRootOrder = [
...newRootOrder.filter((id) => !topLevelIds.includes(id)),
groupId,
];
newGroupIds.push(groupId);
}
return {
nodes: newNodes,
rootOrder: newRootOrder,
selectedIds: new Set(newGroupIds),
};
});
return newGroupIds;
},
createCardFromDrop: (draggedId, targetId) => {
const { nodes } = get();
const dragged = nodes[draggedId];
const target = nodes[targetId];
if (!dragged || !target || draggedId === targetId) return null;
// If the target is already a card, this is really an append.
if (target.kind === "card") {
get().appendToCard(targetId, draggedId);
return targetId;
}
// Only top-level bare nodes can seed a new card.
if (target.parentId !== null || dragged.parentId !== null) return null;
get().pushHistory();
const cardId = uuidv4();
// Target was there first, so it sits on top; the dragged node stacks below.
const childIds = [targetId, draggedId];
set((s) => {
const newNodes: Record<string, MoodboardNode> = { ...s.nodes };
childIds.forEach((cid) => {
const c = newNodes[cid];
if (!c) return;
// Children are reparented; their x/y are ignored by the card's flex
// layout, so we leave them as-is to make ungrouping cheap later.
newNodes[cid] = { ...c, parentId: cardId } as MoodboardNode;
});
const base: CardNode = {
id: cardId,
kind: "card",
parentId: null,
x: target.x,
y: target.y,
width: DEFAULT_CARD_WIDTH,
height: 0,
rotation: 0,
zIndex: s.rootOrder.length,
childIds,
padding: DEFAULT_CARD_PADDING,
backgroundColor: DEFAULT_CARD_COLOR,
};
newNodes[cardId] = { ...base, height: computeCardHeight(base, newNodes) };
return {
nodes: newNodes,
rootOrder: [
...s.rootOrder.filter((id) => id !== targetId && id !== draggedId),
cardId,
],
selectedIds: new Set([cardId]),
};
});
return cardId;
},
appendToCard: (cardId, draggedId) => {
const { nodes } = get();
const card = nodes[cardId];
const dragged = nodes[draggedId];
if (!card || card.kind !== "card" || !dragged) return;
if (draggedId === cardId || card.childIds.includes(draggedId)) return;
get().pushHistory();
set((s) => {
const current = s.nodes[cardId];
const child = s.nodes[draggedId];
if (!current || current.kind !== "card" || !child) return s;
const newNodes: Record<string, MoodboardNode> = { ...s.nodes };
newNodes[draggedId] = { ...child, parentId: cardId } as MoodboardNode;
const base: CardNode = {
...current,
childIds: [...current.childIds, draggedId],
};
newNodes[cardId] = { ...base, height: computeCardHeight(base, newNodes) };
return {
nodes: newNodes,
rootOrder: s.rootOrder.filter((id) => id !== draggedId),
selectedIds: new Set([cardId]),
};
});
},
ungroup: () => {
const { selectedIds, nodes } = get();
const groupIds = Array.from(selectedIds).filter(
(id) => nodes[id]?.kind === "group",
);
if (groupIds.length === 0) return;
get().pushHistory();
set((s) => {
const newNodes: Record<string, MoodboardNode> = { ...s.nodes };
const newRootOrder = [...s.rootOrder];
const releasedChildIds: string[] = [];
groupIds.forEach((gid) => {
const g = newNodes[gid];
if (!g || g.kind !== "group") return;
// Rebase children back to stage-local using group's transform.
g.childIds.forEach((cid) => {
const child = newNodes[cid];
if (!child) return;
const local = { x: child.x, y: child.y };
const stagePoint = applyGroupTransform(local, g);
newNodes[cid] = {
...child,
parentId: null,
x: stagePoint.x,
y: stagePoint.y,
// Carry the group's rotation onto the child so the visual stays put.
rotation: child.rotation + g.rotation,
} as MoodboardNode;
releasedChildIds.push(cid);
});
delete newNodes[gid];
const idx = newRootOrder.indexOf(gid);
if (idx >= 0) newRootOrder.splice(idx, 1);
});
releasedChildIds.forEach((cid) => newRootOrder.push(cid));
return {
nodes: newNodes,
rootOrder: newRootOrder,
selectedIds: new Set(releasedChildIds),
};
});
},
setMarquee: (r) =>
set((s) => ({ transient: { ...s.transient, marquee: r } })),
setLassoPath: (p) =>
set((s) => ({ transient: { ...s.transient, lassoPath: p } })),
setEditingText: (id) =>
set((s) => ({ transient: { ...s.transient, editingTextId: id } })),
setIsPanning: (v) =>
set((s) => ({ transient: { ...s.transient, isPanning: v } })),
setDragPreview: (p) =>
set((s) => ({ transient: { ...s.transient, dragPreview: p } })),
snapshot: () => {
const { nodes, rootOrder, selectedIds } = get();
return {
nodes: structuredClone(nodes),
rootOrder: [...rootOrder],
selectedIds: Array.from(selectedIds),
};
},
applySnapshot: (snap) => {
set({
nodes: structuredClone(snap.nodes),
rootOrder: [...snap.rootOrder],
selectedIds: new Set(snap.selectedIds),
});
},
pushHistory: () => {
useMoodboardHistoryStore.getState().push(get().snapshot());
},
undo: () => {
const current = get().snapshot();
const prior = useMoodboardHistoryStore.getState().undo(current);
if (prior) get().applySnapshot(prior);
},
redo: () => {
const current = get().snapshot();
const next = useMoodboardHistoryStore.getState().redo(current);
if (next) get().applySnapshot(next);
},
reset: () => {
useMoodboardHistoryStore.getState().clear();
set({
nodes: {},
rootOrder: [],
selectedIds: new Set(),
tool: "select",
lastDropPoint: { x: 200, y: 200 },
viewport: { zoom: 1, pan: { x: 0, y: 0 } },
transient: {
marquee: null,
lassoPath: null,
editingTextId: null,
isPanning: false,
dragPreview: null,
guides: [],
},
});
},
}));
// ---------- helpers ----------
const stripAncestorsAndDescendants = (
nodes: Record<string, MoodboardNode>,
ids: Set<string>,
): Set<string> => {
const result = new Set(ids);
// Drop any node whose ancestor (parent chain up to root) is in the set.
ids.forEach((id) => {
let cur = nodes[id];
while (cur && cur.parentId) {
if (result.has(cur.parentId)) {
result.delete(id);
break;
}
cur = nodes[cur.parentId];
}
});
// Drop any descendant whose ancestor container (group or card) is in the set.
ids.forEach((id) => {
const n = nodes[id];
if (n && (n.kind === "group" || n.kind === "card")) {
const stack = [...n.childIds];
while (stack.length) {
const c = stack.pop()!;
if (result.has(c)) result.delete(c);
const cn = nodes[c];
if (cn && (cn.kind === "group" || cn.kind === "card")) {
stack.push(...cn.childIds);
}
}
}
});
return result;
};
// Maps a group-local point to stage-local coords using the group's transform.
const applyGroupTransform = (local: Vec2, g: GroupNode): Vec2 => {
const rad = (g.rotation * Math.PI) / 180;
const cos = Math.cos(rad);
const sin = Math.sin(rad);
return {
x: g.x + local.x * cos - local.y * sin,
y: g.y + local.x * sin + local.y * cos,
};
};
@@ -0,0 +1,217 @@
import {
faMousePointer,
faDrawPolygon,
faObjectGroup,
faObjectUngroup,
faGripVertical,
faTableCells,
faDiagramProject,
faRotateLeft,
faRotateRight,
faTrash,
faPlus,
faImages,
faArrowUpFromBracket,
faText,
faMagnet,
} from "@fortawesome/pro-solid-svg-icons";
import { ButtonIconSelect } from "@storyteller/ui-button-icon-select";
import { PopoverMenu } from "@storyteller/ui-popover";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Tooltip } from "@storyteller/ui-tooltip";
import {
FloatingToolbar,
FloatingToolbarButton,
FloatingToolbarDivider,
} from "./FloatingToolbar";
import { useMoodboardStore } from "./MoodboardStore";
import { Tool } from "./types";
import { MOD, fmtShortcut } from "./overlays/shortcuts";
import { computeFitToGridPatches } from "./layout/fitToGrid";
import { computeAABB } from "./layout/geometry";
import { computePackPatches } from "./layout/packCollage";
import { clusterByProximity } from "./layout/clusterProximity";
const MODES: Array<{
value: Tool;
icon: typeof faMousePointer;
tooltip: string;
}> = [
{ value: "select", icon: faMousePointer, tooltip: "Select (V)" },
{ value: "lasso", icon: faDrawPolygon, tooltip: "Lasso (L)" },
{ value: "text", icon: faText, tooltip: "Text (T)" },
];
interface Props {
onUploadClick: () => void;
onGalleryClick: () => void;
}
export const MoodboardToolbar = ({ onUploadClick, onGalleryClick }: Props) => {
const tool = useMoodboardStore((s) => s.tool);
const setTool = useMoodboardStore((s) => s.setTool);
const group = useMoodboardStore((s) => s.group);
const groupClusters = useMoodboardStore((s) => s.groupClusters);
const ungroup = useMoodboardStore((s) => s.ungroup);
const deleteSelected = useMoodboardStore((s) => s.deleteSelected);
const undo = useMoodboardStore((s) => s.undo);
const redo = useMoodboardStore((s) => s.redo);
const applyLayoutPatches = useMoodboardStore((s) => s.applyLayoutPatches);
const selectedIds = useMoodboardStore((s) => s.selectedIds);
const nodes = useMoodboardStore((s) => s.nodes);
const rootOrder = useMoodboardStore((s) => s.rootOrder);
const gridSpacing = useMoodboardStore((s) => s.gridSpacing);
const snapEnabled = useMoodboardStore((s) => s.snapEnabled);
const toggleSnap = useMoodboardStore((s) => s.toggleSnap);
const handleFitToGrid = () => {
if (selectedIds.size === 0) return;
const targetNodes = Array.from(selectedIds)
.map((id) => nodes[id])
.filter(Boolean);
applyLayoutPatches(computeFitToGridPatches(targetNodes, gridSpacing));
};
const handlePackCollage = () => {
const ids = selectedIds.size > 0 ? Array.from(selectedIds) : rootOrder;
const targetNodes = ids
.map((id) => nodes[id])
.filter((n) => n && n.parentId === null);
if (targetNodes.length < 2) return;
const aabb = computeAABB(targetNodes);
if (!aabb) return;
applyLayoutPatches(computePackPatches(targetNodes, aabb));
};
const handleAutoGroup = () => {
const ids = selectedIds.size > 0 ? Array.from(selectedIds) : rootOrder;
const targetNodes = ids
.map((id) => nodes[id])
.filter((n) => n && n.parentId === null);
if (targetNodes.length < 2) return;
const clusters = clusterByProximity(targetNodes);
const clusterIds = clusters.map((c) => c.map((n) => n.id));
groupClusters(clusterIds);
};
const handleAddAction = (action: string) => {
if (action === "upload") onUploadClick();
else if (action === "library") onGalleryClick();
};
return (
<FloatingToolbar>
<Tooltip
content="Add an image"
position="bottom"
delay={300}
closeOnClick
>
<PopoverMenu
mode="button"
position="bottom"
panelTitle="Add an image"
items={[
{
label: "Upload Image",
selected: false,
icon: (
<FontAwesomeIcon
icon={faArrowUpFromBracket}
className="h-4 w-4"
/>
),
action: "upload",
},
{
label: "Pick from Library",
selected: false,
icon: <FontAwesomeIcon icon={faImages} className="h-4 w-4" />,
action: "library",
},
]}
onPanelAction={handleAddAction}
showIconsInList
buttonClassName="h-9 w-9 rounded-[10px] border-transparent bg-primary/90 text-lg text-white hover:bg-primary/70"
triggerIcon={<FontAwesomeIcon icon={faPlus} className="text-xl" />}
/>
</Tooltip>
<FloatingToolbarDivider />
<ButtonIconSelect
options={MODES}
onOptionChange={(value) => setTool(value as Tool)}
selectedOption={tool}
/>
<FloatingToolbarDivider />
<FloatingToolbarButton
icon={faObjectGroup}
label={`Group (${fmtShortcut([MOD, "G"])})`}
onClick={() => group()}
disabled={selectedIds.size < 2}
tooltipDelay={100}
/>
<FloatingToolbarButton
icon={faObjectUngroup}
label={`Ungroup (${fmtShortcut([MOD, "Shift", "G"])})`}
onClick={() => ungroup()}
disabled={selectedIds.size === 0}
tooltipDelay={100}
/>
<FloatingToolbarDivider />
<FloatingToolbarButton
icon={faTableCells}
label="Fit to grid"
onClick={handleFitToGrid}
disabled={selectedIds.size === 0}
tooltipDelay={100}
/>
<FloatingToolbarButton
icon={faGripVertical}
label="Pack collage"
onClick={handlePackCollage}
tooltipDelay={100}
/>
<FloatingToolbarButton
icon={faDiagramProject}
label="Auto-group by proximity"
onClick={handleAutoGroup}
tooltipDelay={100}
/>
<FloatingToolbarButton
icon={faMagnet}
label={snapEnabled ? "Snapping on" : "Snapping off"}
active={snapEnabled}
onClick={toggleSnap}
tooltipDelay={100}
/>
<FloatingToolbarDivider />
<FloatingToolbarButton
icon={faRotateLeft}
label={`Undo (${fmtShortcut([MOD, "Z"])})`}
onClick={undo}
tooltipDelay={100}
/>
<FloatingToolbarButton
icon={faRotateRight}
label={`Redo (${fmtShortcut([MOD, "Shift", "Z"])})`}
onClick={redo}
tooltipDelay={100}
/>
<FloatingToolbarButton
icon={faTrash}
label="Delete (Del)"
onClick={deleteSelected}
disabled={selectedIds.size === 0}
tooltipDelay={100}
/>
</FloatingToolbar>
);
};
@@ -0,0 +1,82 @@
import { useEffect, useRef } from "react";
import { useMoodboardStore } from "./MoodboardStore";
interface Props {
containerRef: React.RefObject<HTMLDivElement | null>;
}
// Floats a positioned <textarea> over the editing text node so the user can
// type directly. Konva Text has no native edit mode; this is the standard
// react-konva pattern. Phase 1 has no zoom/pan, so absolute positioning is
// simply (node.x, node.y) within the container.
export const TextEditOverlay = ({ containerRef }: Props) => {
const editingId = useMoodboardStore((s) => s.transient.editingTextId);
const node = useMoodboardStore((s) =>
editingId ? s.nodes[editingId] : null,
);
const updateNode = useMoodboardStore((s) => s.updateNode);
const pushHistory = useMoodboardStore((s) => s.pushHistory);
const setEditingText = useMoodboardStore((s) => s.setEditingText);
const taRef = useRef<HTMLTextAreaElement | null>(null);
const initialTextRef = useRef<string>("");
useEffect(() => {
if (editingId && node && node.kind === "text") {
initialTextRef.current = node.text;
requestAnimationFrame(() => {
taRef.current?.focus();
taRef.current?.select();
});
}
}, [editingId, node]);
if (!editingId || !node || node.kind !== "text") return null;
const commit = () => {
if (!taRef.current) return;
const newText = taRef.current.value;
if (newText !== initialTextRef.current) {
pushHistory();
updateNode(node.id, { text: newText });
}
setEditingText(null);
};
const cancel = () => {
setEditingText(null);
};
return (
<textarea
ref={taRef}
defaultValue={node.text}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault();
cancel();
} else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
commit();
}
}}
style={{
position: "absolute",
left: node.x,
top: node.y,
width: node.width,
height: node.height,
background: "rgba(15,15,18,0.85)",
color: node.color,
border: "1px dashed rgba(59,130,246,0.6)",
outline: "none",
padding: 6,
fontSize: node.fontSize,
fontFamily: "inherit",
resize: "none",
overflow: "hidden",
zIndex: 5,
}}
/>
);
};
@@ -0,0 +1,37 @@
import { CardNode, MoodboardNode } from "./types";
export const DEFAULT_CARD_WIDTH = 280;
export const DEFAULT_CARD_PADDING = 8;
export const DEFAULT_CARD_COLOR = "#FFFFFF";
// Height of one child when laid out inside a card: child's native aspect
// ratio applied to (cardWidth - 2*padding), falling back to the child's own
// height when aspect isn't available (text nodes, nested containers).
export const childRenderedHeight = (
child: MoodboardNode,
innerWidth: number,
): number => {
if (child.kind === "image" || child.kind === "video") {
const ratio =
child.naturalH > 0 && child.naturalW > 0
? child.naturalH / child.naturalW
: child.height / Math.max(child.width, 1);
return innerWidth * ratio;
}
if (child.kind === "text") return child.height;
return child.height;
};
export const computeCardHeight = (
card: CardNode,
nodes: Record<string, MoodboardNode>,
): number => {
const innerW = Math.max(card.width - card.padding * 2, 1);
let total = card.padding;
for (const cid of card.childIds) {
const c = nodes[cid];
if (!c) continue;
total += childRenderedHeight(c, innerW) + card.padding;
}
return Math.max(total, card.padding * 2);
};
@@ -0,0 +1,59 @@
import { useEffect } from "react";
import Konva from "konva";
import { useMoodboardStore } from "../MoodboardStore";
import {
DroppedGalleryItem,
GALLERY_MOODBOARD_DROP_EVENT,
} from "../../galleryDrop";
interface GalleryMoodboardDropDetail {
item: DroppedGalleryItem;
canvasPosition: { x: number; y: number };
}
const measure = (url: string): Promise<{ w: number; h: number }> =>
new Promise((resolve) => {
const i = new window.Image();
i.crossOrigin = "anonymous";
i.onload = () => resolve({ w: i.naturalWidth, h: i.naturalHeight });
i.onerror = () => resolve({ w: 320, h: 320 });
i.src = url;
});
// Listens for gallery-moodboard-drop CustomEvents dispatched by the
// PageEditor when the user drops a gallery item over the canvas. Mirrors
// PageDraw's gallery-2d-drop listener pattern but scoped to moodboard.
export const useGalleryDropEvent = (
active: boolean,
stageRef: React.RefObject<Konva.Stage | null>,
) => {
const addImage = useMoodboardStore((s) => s.addImage);
useEffect(() => {
if (!active) return undefined;
const handler = async (e: Event) => {
const ce = e as CustomEvent<GalleryMoodboardDropDetail>;
const detail = ce.detail;
if (!detail) return;
const { item, canvasPosition } = detail;
const url = item.fullImage || item.thumbnail;
if (!url) return;
const stage = stageRef.current;
const stagePoint = stage
? {
x: (canvasPosition.x - stage.x()) / stage.scaleX(),
y: (canvasPosition.y - stage.y()) / stage.scaleY(),
}
: canvasPosition;
const dims = await measure(url);
addImage(url, stagePoint, dims.w, dims.h, item.id ?? null);
};
window.addEventListener(GALLERY_MOODBOARD_DROP_EVENT, handler);
return () =>
window.removeEventListener(GALLERY_MOODBOARD_DROP_EVENT, handler);
}, [active, addImage, stageRef]);
};
@@ -0,0 +1,77 @@
import { useEffect } from "react";
import { useMoodboardStore } from "../MoodboardStore";
const isEditableTarget = (target: EventTarget | null): boolean => {
const el = target as HTMLElement | null;
if (!el) return false;
if (/input|textarea/i.test(el.tagName)) return true;
if (el.isContentEditable) return true;
return false;
};
// Figma-style shortcuts that aren't already wired elsewhere.
// Cmd+Z / Cmd+Shift+Z live in useUndoRedo; Cmd+0 + Space-pan live in
// useViewportControls; Delete / Escape live in Moodboard.tsx.
export const useKeyboardShortcuts = (active: boolean) => {
const setTool = useMoodboardStore((s) => s.setTool);
const selectAll = useMoodboardStore((s) => s.selectAll);
const group = useMoodboardStore((s) => s.group);
const ungroup = useMoodboardStore((s) => s.ungroup);
const fitToContent = useMoodboardStore((s) => s.fitToContent);
useEffect(() => {
if (!active) return undefined;
const isMac = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
const handler = (e: KeyboardEvent) => {
if (isEditableTarget(e.target)) return;
// Skip while typing in the text-edit overlay (covered by isEditableTarget
// too, but belt-and-suspenders in case the overlay loses target focus).
if (useMoodboardStore.getState().transient.editingTextId) return;
const mod = isMac ? e.metaKey : e.ctrlKey;
const k = e.key.toLowerCase();
// Modifier combos
if (mod && !e.shiftKey && k === "a") {
e.preventDefault();
selectAll();
return;
}
if (mod && !e.shiftKey && k === "g") {
e.preventDefault();
group();
return;
}
if (mod && e.shiftKey && k === "g") {
e.preventDefault();
ungroup();
return;
}
// Shift-only combos
if (e.shiftKey && !mod && !e.altKey && k === "1") {
e.preventDefault();
fitToContent();
return;
}
// Plain (no modifiers) tool switches — skip if any modifier is held
// so Cmd+V (paste) etc. never triggers the Text tool.
if (mod || e.shiftKey || e.altKey) return;
if (k === "v") {
e.preventDefault();
setTool("select");
} else if (k === "l") {
e.preventDefault();
setTool("lasso");
} else if (k === "t") {
e.preventDefault();
setTool("text");
}
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [active, setTool, selectAll, group, ungroup, fitToContent]);
};
@@ -0,0 +1,61 @@
import { useMemo } from "react";
import Konva from "konva";
import { useMoodboardStore } from "../MoodboardStore";
import { computeSnap, SnapBox } from "../layout/snapping";
// Screen-space snap radius; divided by zoom so the feel is constant at any zoom.
const SNAP_SCREEN_PX = 6;
export interface NodeDragHandlers {
onDragStart: () => void;
onDragMove: (e: Konva.KonvaEventObject<DragEvent>) => void;
onDragEnd: (e: Konva.KonvaEventObject<DragEvent>) => void;
}
// Shared drag wiring for every canvas node. onDragStart snapshots history;
// onDragMove snaps the live position to nearby node edges/centers + the grid and
// publishes alignment guides; onDragEnd commits the final position and clears
// the guides. Reads the store imperatively inside handlers so the hook itself
// doesn't re-render on every node move.
export const useNodeDrag = (nodeId: string): NodeDragHandlers => {
const updateNode = useMoodboardStore((s) => s.updateNode);
const pushHistory = useMoodboardStore((s) => s.pushHistory);
const setGuides = useMoodboardStore((s) => s.setGuides);
return useMemo(
() => ({
onDragStart: () => pushHistory(),
onDragMove: (e) => {
const target = e.target;
const state = useMoodboardStore.getState();
if (!state.snapEnabled) return;
const node = state.nodes[nodeId];
if (!node) return;
const box: SnapBox = {
x: target.x(),
y: target.y(),
width: node.width,
height: node.height,
};
const others: SnapBox[] = [];
for (const id of state.rootOrder) {
if (id === nodeId) continue;
const n = state.nodes[id];
if (n) others.push({ x: n.x, y: n.y, width: n.width, height: n.height });
}
const threshold = SNAP_SCREEN_PX / state.viewport.zoom;
const result = computeSnap(box, others, threshold, state.gridSpacing);
target.x(result.x);
target.y(result.y);
setGuides(result.guides);
},
onDragEnd: (e) => {
updateNode(nodeId, { x: e.target.x(), y: e.target.y() });
setGuides([]);
},
}),
[nodeId, updateNode, pushHistory, setGuides],
);
};
@@ -0,0 +1,72 @@
import { useEffect } from "react";
import Konva from "konva";
import { useMoodboardStore } from "../MoodboardStore";
import type { MoodboardAdapter } from "../../adapter";
// Loads natural dimensions for an image URL.
const measure = (url: string): Promise<{ w: number; h: number }> =>
new Promise((resolve, reject) => {
const i = new window.Image();
i.onload = () => resolve({ w: i.naturalWidth, h: i.naturalHeight });
i.onerror = reject;
i.src = url;
});
// Listens for paste events while the moodboard page is mounted. For each
// image entry on the clipboard, immediately drops a blob-URL image onto the
// canvas at the viewport center, then asynchronously uploads it to the
// library so it lives in the user's gallery.
export const usePasteHandler = (
adapter: MoodboardAdapter,
active: boolean,
stageRef: React.RefObject<Konva.Stage | null>,
) => {
const addImage = useMoodboardStore((s) => s.addImage);
useEffect(() => {
if (!active) return undefined;
const handler = async (e: ClipboardEvent) => {
const target = e.target as HTMLElement | null;
if (target && /input|textarea/i.test(target.tagName)) return;
if (target && target.isContentEditable) return;
const items = e.clipboardData?.items;
if (!items) return;
const fileItems: File[] = [];
for (let i = 0; i < items.length; i++) {
const it = items[i];
if (it.kind === "file" && it.type.startsWith("image/")) {
const f = it.getAsFile();
if (f) fileItems.push(f);
}
}
if (fileItems.length === 0) return;
e.preventDefault();
const stage = stageRef.current;
const center = stage
? { x: stage.width() / 2, y: stage.height() / 2 }
: { x: 400, y: 400 };
for (const file of fileItems) {
const blobUrl = URL.createObjectURL(file);
try {
const dims = await measure(blobUrl);
addImage(blobUrl, center, dims.w, dims.h, null);
} catch (err) {
console.error("[Moodboard] paste measure failed", err);
addImage(blobUrl, center, 320, 320, null);
}
// Fire-and-forget: also push to the platform's media library so the
// image persists in the user's gallery.
adapter.uploadImage?.(file).catch((err) => {
console.error("[Moodboard] background upload failed", err);
});
}
};
window.addEventListener("paste", handler);
return () => window.removeEventListener("paste", handler);
}, [adapter, active, addImage, stageRef]);
};
@@ -0,0 +1,141 @@
import { useEffect, useRef } from "react";
import Konva from "konva";
import { useMoodboardStore } from "../MoodboardStore";
import { Vec2 } from "../types";
import { stagePointerPos } from "./useStagePointer";
import { pointInPolygon } from "../layout/pointInPolygon";
import { polygonIntersectsRect } from "../layout/segmentsIntersect";
import { rectCorners, rectFromPoints, rectsIntersect } from "../layout/geometry";
const MIN_DRAG = 4; // screen px before a press becomes a drag
interface DragState {
origin: Vec2;
path: Vec2[];
active: boolean;
mode: "rect" | "lasso" | null;
}
// Selection state machine for rectangle marquee (Select tool) and free-form
// lasso (Lasso tool). Pan gestures (middle-mouse, spacebar+left) are owned
// by `useViewportControls` and stop the event at the capture phase, so
// they never reach Konva's mousedown and therefore never reach this hook.
export const useSelection = (
stageRef: React.RefObject<Konva.Stage | null>,
) => {
const drag = useRef<DragState>({
origin: { x: 0, y: 0 },
path: [],
active: false,
mode: null,
});
const tool = useMoodboardStore((s) => s.tool);
const setMarquee = useMoodboardStore((s) => s.setMarquee);
const setLassoPath = useMoodboardStore((s) => s.setLassoPath);
const setSelection = useMoodboardStore((s) => s.setSelection);
useEffect(() => {
if (tool !== "select" && tool !== "lasso") return;
const stage = stageRef.current;
if (!stage) return;
const handleMouseDown = (e: Konva.KonvaEventObject<MouseEvent>) => {
// Only on bare canvas — clicks on nodes are handled by the node's
// own onMouseDown for single-select.
if (e.target !== stage) return;
// Primary button only; middle-mouse is the pan hook's domain.
if (e.evt.button !== 0) return;
const pos = stagePointerPos(stage);
if (!pos) return;
drag.current = {
origin: pos,
path: tool === "lasso" ? [pos] : [],
active: true,
mode: tool === "lasso" ? "lasso" : "rect",
};
};
const handleMouseMove = () => {
if (!drag.current.active) return;
const pos = stagePointerPos(stage);
if (!pos) return;
if (drag.current.mode === "rect") {
// Suppress marquee until the drag exceeds a threshold so bare clicks
// don't leave a zero-area rect behind.
const dx = pos.x - drag.current.origin.x;
const dy = pos.y - drag.current.origin.y;
if (Math.abs(dx) < MIN_DRAG && Math.abs(dy) < MIN_DRAG) return;
setMarquee(rectFromPoints(drag.current.origin, pos));
} else if (drag.current.mode === "lasso") {
drag.current.path.push(pos);
// Slice copy so subscribers see a new identity.
setLassoPath([...drag.current.path]);
}
};
const commitSelection = () => {
const { nodes, rootOrder } = useMoodboardStore.getState();
const candidates = rootOrder
.map((id) => nodes[id])
.filter((n): n is NonNullable<typeof n> => Boolean(n));
if (drag.current.mode === "rect") {
const rect = useMoodboardStore.getState().transient.marquee;
if (!rect || (rect.width < MIN_DRAG && rect.height < MIN_DRAG)) {
setSelection([]);
} else {
const hit = candidates
.filter((n) =>
rectsIntersect(
{ x: n.x, y: n.y, width: n.width, height: n.height },
rect,
),
)
.map((n) => n.id);
setSelection(hit);
}
setMarquee(null);
} else if (drag.current.mode === "lasso") {
const path = drag.current.path;
if (path.length < 3) {
setSelection([]);
} else {
const closed = [...path, path[0]];
const hit = candidates
.filter((n) => {
const corners = rectCorners(n);
if (corners.some((c) => pointInPolygon(c, closed))) return true;
if (polygonIntersectsRect(closed, n)) return true;
return false;
})
.map((n) => n.id);
setSelection(hit);
}
setLassoPath(null);
}
drag.current.active = false;
drag.current.mode = null;
drag.current.path = [];
};
const handleMouseUp = () => {
if (!drag.current.active) return;
commitSelection();
};
stage.on("mousedown.moodboardSelection", handleMouseDown);
stage.on("mousemove.moodboardSelection", handleMouseMove);
stage.on("mouseup.moodboardSelection", handleMouseUp);
const onWindowUp = () => {
if (drag.current.active) commitSelection();
};
window.addEventListener("mouseup", onWindowUp);
return () => {
stage.off("mousedown.moodboardSelection");
stage.off("mousemove.moodboardSelection");
stage.off("mouseup.moodboardSelection");
window.removeEventListener("mouseup", onWindowUp);
};
}, [stageRef, tool, setMarquee, setLassoPath, setSelection]);
};
@@ -0,0 +1,74 @@
import { useEffect, useState } from "react";
const HOLD_MS = 3000;
// Shows the cheatsheet when the user holds Ctrl (or Cmd on Mac) alone for
// HOLD_MS with no other key pressed. Any other keydown cancels the timer,
// so a real `Ctrl+C` / `Cmd+X` never trips it. Releasing the modifier hides
// the cheatsheet immediately.
export const useShortcutCheatsheet = (): boolean => {
const [visible, setVisible] = useState(false);
useEffect(() => {
const isMac = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
const modKey = isMac ? "Meta" : "Control";
let timer: ReturnType<typeof setTimeout> | null = null;
let modHeld = false;
const clearTimer = () => {
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
};
const hide = () => {
setVisible((v) => (v ? false : v));
};
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === modKey) {
// Ignore auto-repeat keydown while already held.
if (modHeld) return;
modHeld = true;
clearTimer();
timer = setTimeout(() => {
timer = null;
setVisible(true);
}, HOLD_MS);
return;
}
// Any non-modifier key press while the modifier is held cancels the
// planned cheatsheet — the user is running a real combo.
clearTimer();
hide();
};
const onKeyUp = (e: KeyboardEvent) => {
if (e.key === modKey) {
modHeld = false;
clearTimer();
hide();
}
};
// Losing focus while holding the key should also cancel.
const onBlur = () => {
modHeld = false;
clearTimer();
hide();
};
document.addEventListener("keydown", onKeyDown);
document.addEventListener("keyup", onKeyUp);
window.addEventListener("blur", onBlur);
return () => {
document.removeEventListener("keydown", onKeyDown);
document.removeEventListener("keyup", onKeyUp);
window.removeEventListener("blur", onBlur);
clearTimer();
};
}, []);
return visible;
};
@@ -0,0 +1,17 @@
import Konva from "konva";
import { Vec2 } from "../types";
// Converts a raw KonvaEventObject pointer position to stage-local coordinates,
// accounting for stage offset and scale. Phase 1 has no zoom/pan so this is a
// thin wrapper, but encapsulating it keeps interaction hooks consistent.
export const stagePointerPos = (
stage: Konva.Stage | null,
): Vec2 | null => {
if (!stage) return null;
const pointer = stage.getPointerPosition();
if (!pointer) return null;
return {
x: (pointer.x - stage.x()) / stage.scaleX(),
y: (pointer.y - stage.y()) / stage.scaleY(),
};
};
@@ -0,0 +1,153 @@
import { useEffect, useRef } from "react";
import Konva from "konva";
import { useMoodboardStore } from "../MoodboardStore";
import { MoodboardNode } from "../types";
interface NodePatch {
id: string;
patch: Partial<MoodboardNode>;
}
// Owns a single Konva.Transformer ref. Re-attaches its `nodes()` whenever
// the selection OR the root structure changes (e.g. group/ungroup
// reparented the previously-selected ids). On transformend, bakes
// scaleX/scaleY into width/height — and for groups, propagates the scale
// into each child's position/size so the resize sticks (Figma/Excalidraw
// behavior) instead of snapping back when the Group's scale resets to 1.
export const useTransformer = (stageRef: React.RefObject<Konva.Stage | null>) => {
const transformerRef = useRef<Konva.Transformer | null>(null);
const selectedIds = useMoodboardStore((s) => s.selectedIds);
// rootOrder is included so a re-attach happens after group()/ungroup()
// reparents Konva nodes — without it, findOne can return stale results.
const rootOrder = useMoodboardStore((s) => s.rootOrder);
const editingTextId = useMoodboardStore((s) => s.transient.editingTextId);
useEffect(() => {
const stage = stageRef.current;
const transformer = transformerRef.current;
if (!stage || !transformer) return;
// Defer one frame so any pending Konva re-render commits before we
// findOne — otherwise the new Group node may not exist yet.
const raf = requestAnimationFrame(() => {
const selectedNodes: Konva.Node[] = [];
selectedIds.forEach((id) => {
// Plain `#id` lookup — Konva's selector parser strips all spaces, so
// a CSS.escape'd id (e.g. `\38 abc...` for a UUID starting with `8`)
// ends up being compared as `\38abc...` and never matches. Our ids
// are UUIDs (hex + dashes), which are already safe selectors.
const found = stage.findOne(`#${id}`);
if (found) selectedNodes.push(found);
});
transformer.nodes(selectedNodes);
transformer.forceUpdate();
transformer.getLayer()?.batchDraw();
});
return () => cancelAnimationFrame(raf);
}, [selectedIds, rootOrder, stageRef]);
useEffect(() => {
const transformer = transformerRef.current;
if (!transformer) return;
const handleTransformEnd = () => {
const store = useMoodboardStore.getState();
store.pushHistory();
const patches: NodePatch[] = [];
transformer.nodes().forEach((node) => {
const id = node.id();
if (!id) return;
const scaleX = node.scaleX();
const scaleY = node.scaleY();
const storeNode = store.nodes[id];
if (!storeNode) return;
// Groups: bake the scale into each child's position + size so the
// resize survives the Group's own scale reset. Konva's <Group>
// doesn't store width/height in a way that lets the children just
// inherit the scale — we have to push it down explicitly.
if (storeNode.kind === "group") {
const uniformScale = (Math.abs(scaleX) + Math.abs(scaleY)) / 2;
for (const childId of storeNode.childIds) {
const child = store.nodes[childId];
if (!child) continue;
const childPatch: Partial<MoodboardNode> = {
x: child.x * scaleX,
y: child.y * scaleY,
width: child.width * scaleX,
height: child.height * scaleY,
};
// Text inside a scaled group needs fontSize to track the scale,
// otherwise the text stays the same size and overflows / floats.
if (child.kind === "text") {
(childPatch as Partial<MoodboardNode> & { fontSize?: number })
.fontSize = child.fontSize * uniformScale;
}
patches.push({ id: childId, patch: childPatch });
}
}
// Bake the scale into the node's own width/height too, then reset
// the Konva scale so subsequent transforms compose cleanly.
const baseW = node.width() || 1;
const baseH = node.height() || 1;
const newWidth = baseW * scaleX;
const newHeight = baseH * scaleY;
node.scaleX(1);
node.scaleY(1);
node.width(newWidth);
node.height(newHeight);
patches.push({
id,
patch: {
x: node.x(),
y: node.y(),
width: newWidth,
height: newHeight,
rotation: node.rotation(),
},
});
});
// Apply all patches in one store mutation so the tree only renders
// once — keeps the visual transition from "scaled preview" to "baked
// result" tight, with no flicker.
if (patches.length === 0) return;
useMoodboardStore.setState((s) => {
const nodes = { ...s.nodes };
for (const { id, patch } of patches) {
const existing = nodes[id];
if (!existing) continue;
nodes[id] = { ...existing, ...patch } as MoodboardNode;
}
return { nodes };
});
// The Transformer caches its bbox from `getClientRect()` of the
// attached node. For a Konva.Group, that walks children — but the
// children haven't React-re-rendered to their new sizes yet, so a
// sync forceUpdate would read the OLD bounds and the box would snap
// back. Defer one frame so the children commit first.
requestAnimationFrame(() => {
transformer.forceUpdate();
transformer.getLayer()?.batchDraw();
});
};
transformer.on("transformend", handleTransformEnd);
return () => {
transformer.off("transformend", handleTransformEnd);
};
}, []);
// Hide transformer entirely while editing a text node (avoid handle conflicts).
useEffect(() => {
const transformer = transformerRef.current;
if (!transformer) return;
transformer.visible(editingTextId === null);
transformer.getLayer()?.batchDraw();
}, [editingTextId]);
return transformerRef;
};
@@ -0,0 +1,37 @@
import { useEffect } from "react";
import { useMoodboardStore } from "../MoodboardStore";
// Standard editor hotkeys:
// - Cmd+Z (Mac) / Ctrl+Z (Win) → undo
// - Cmd+Shift+Z (Mac), Ctrl+Y or Ctrl+Shift+Z (Win) → redo
export const useUndoRedo = (active: boolean) => {
const undo = useMoodboardStore((s) => s.undo);
const redo = useMoodboardStore((s) => s.redo);
useEffect(() => {
if (!active) return undefined;
const handler = (e: KeyboardEvent) => {
const target = e.target as HTMLElement | null;
if (target && /input|textarea/i.test(target.tagName)) return;
if (target && target.isContentEditable) return;
const isMac = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
const mod = isMac ? e.metaKey : e.ctrlKey;
if (!mod) return;
const k = e.key.toLowerCase();
if (k === "z" && !e.shiftKey) {
e.preventDefault();
undo();
} else if (
(isMac && k === "z" && e.shiftKey) ||
(!isMac && k === "y") ||
(!isMac && k === "z" && e.shiftKey)
) {
e.preventDefault();
redo();
}
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [active, undo, redo]);
};
@@ -0,0 +1,181 @@
import { useEffect, useRef } from "react";
import Konva from "konva";
import { useMoodboardStore } from "../MoodboardStore";
import { Vec2 } from "../types";
import { ZOOM_MAX, ZOOM_MIN, clamp } from "../layout/geometry";
const ZOOM_STEP = 1.1;
const isEditableTarget = (target: EventTarget | null): boolean => {
const el = target as HTMLElement | null;
if (!el) return false;
if (/input|textarea/i.test(el.tagName)) return true;
if (el.isContentEditable) return true;
return false;
};
// Centralizes wheel-zoom and three pan gestures (drag-empty, spacebar+drag,
// middle-click drag) for the moodboard canvas. Reads/writes viewport from
// the store directly — no more ref props, which keeps a future minimap or
// other camera-driving UI (recenter button, Shift+1 fit, etc.) able to
// co-exist with these gestures.
export const useViewportControls = (
stageRef: React.RefObject<Konva.Stage | null>,
) => {
const setZoom = useMoodboardStore((s) => s.setZoom);
const setPan = useMoodboardStore((s) => s.setPan);
const resetViewport = useMoodboardStore((s) => s.resetViewport);
const setIsPanning = useMoodboardStore((s) => s.setIsPanning);
// Track spacebar so any subsequent mousedown becomes a pan, not a select.
const spaceHeld = useRef(false);
// Whether a pan drag is currently in flight, plus its origin in screen px
// and the pan offset captured at drag start.
const panState = useRef<{
active: boolean;
startScreen: Vec2;
startPan: Vec2;
}>({
active: false,
startScreen: { x: 0, y: 0 },
startPan: { x: 0, y: 0 },
});
// Wheel: zoom around cursor.
useEffect(() => {
const stage = stageRef.current;
if (!stage) return undefined;
const container = stage.container();
const handleWheel = (e: WheelEvent) => {
// Ignore zoom if the user is typing in the text-edit overlay or an input.
if (isEditableTarget(document.activeElement)) return;
e.preventDefault();
const { viewport } = useMoodboardStore.getState();
const oldScale = viewport.zoom;
const pointer = stage.getPointerPosition();
if (!pointer) return;
const stagePos = viewport.pan;
const mousePointTo = {
x: (pointer.x - stagePos.x) / oldScale,
y: (pointer.y - stagePos.y) / oldScale,
};
const direction = e.deltaY > 0 ? -1 : 1;
const newScale = clamp(
direction > 0 ? oldScale * ZOOM_STEP : oldScale / ZOOM_STEP,
ZOOM_MIN,
ZOOM_MAX,
);
const newPan = {
x: pointer.x - mousePointTo.x * newScale,
y: pointer.y - mousePointTo.y * newScale,
};
setZoom(newScale);
setPan(newPan);
};
container.addEventListener("wheel", handleWheel, { passive: false });
return () => {
container.removeEventListener("wheel", handleWheel);
};
}, [stageRef, setZoom, setPan]);
// Pan gestures: middle-mouse drag, or spacebar+left-drag. Plain left
// click is reserved for selection (clicking nodes, lasso, future marquee)
// so it never triggers pan, even on empty canvas.
useEffect(() => {
const stage = stageRef.current;
if (!stage) return undefined;
const container = stage.container();
const beginPan = (clientX: number, clientY: number) => {
panState.current = {
active: true,
startScreen: { x: clientX, y: clientY },
startPan: { ...useMoodboardStore.getState().viewport.pan },
};
setIsPanning(true);
container.style.cursor = "grabbing";
};
const endPan = () => {
if (!panState.current.active) return;
panState.current.active = false;
setIsPanning(false);
container.style.cursor = spaceHeld.current ? "grab" : "";
};
const handleMouseDown = (e: MouseEvent) => {
// Middle-mouse always pans. stopPropagation so Konva's internal
// mousedown dispatcher never runs — otherwise `useSelection` would
// start a marquee underneath the pan.
if (e.button === 1) {
e.preventDefault();
e.stopPropagation();
beginPan(e.clientX, e.clientY);
return;
}
// Spacebar + left-click pans (Figma convention).
if (e.button === 0 && spaceHeld.current) {
e.preventDefault();
e.stopPropagation();
beginPan(e.clientX, e.clientY);
}
};
const handleMouseMove = (e: MouseEvent) => {
if (!panState.current.active) return;
const dx = e.clientX - panState.current.startScreen.x;
const dy = e.clientY - panState.current.startScreen.y;
setPan({
x: panState.current.startPan.x + dx,
y: panState.current.startPan.y + dy,
});
};
const handleMouseUp = () => endPan();
const handleKeyDown = (e: KeyboardEvent) => {
if (isEditableTarget(e.target)) return;
if (e.code === "Space" && !spaceHeld.current) {
spaceHeld.current = true;
if (!panState.current.active) container.style.cursor = "grab";
// Prevent page from scrolling on spacebar.
e.preventDefault();
}
// Cmd/Ctrl+0 → reset zoom + pan.
const isMac = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
const mod = isMac ? e.metaKey : e.ctrlKey;
if (mod && e.key === "0") {
e.preventDefault();
resetViewport();
}
};
const handleKeyUp = (e: KeyboardEvent) => {
if (e.code === "Space") {
spaceHeld.current = false;
if (!panState.current.active) container.style.cursor = "";
}
};
// Capture-phase so we run BEFORE Konva's internal mousedown dispatch.
// stopPropagation inside pan-claim branches then prevents Konva from
// forwarding the event to node/stage handlers.
container.addEventListener("mousedown", handleMouseDown, { capture: true });
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleMouseUp);
document.addEventListener("keydown", handleKeyDown);
document.addEventListener("keyup", handleKeyUp);
return () => {
container.removeEventListener("mousedown", handleMouseDown, {
capture: true,
} as EventListenerOptions);
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp);
document.removeEventListener("keydown", handleKeyDown);
document.removeEventListener("keyup", handleKeyUp);
container.style.cursor = "";
};
}, [stageRef, setPan, resetViewport, setIsPanning]);
};
@@ -0,0 +1,51 @@
import {
clusterByProximity,
defaultEpsForNodes,
} from "./clusterProximity";
import { MoodboardNode } from "../types";
const node = (id: string, x: number, y: number, size = 50): MoodboardNode => ({
id,
kind: "image",
parentId: null,
x,
y,
width: size,
height: size,
rotation: 0,
zIndex: 0,
src: "",
mediaId: null,
naturalW: size,
naturalH: size,
});
describe("clusterByProximity", () => {
it("returns no clusters for empty input", () => {
expect(clusterByProximity([])).toEqual([]);
});
it("groups close neighbors and separates far ones", () => {
const nodes = [
node("a", 0, 0, 50),
node("b", 30, 30, 50),
node("c", 1000, 1000, 50),
];
const clusters = clusterByProximity(nodes, 100);
expect(clusters.length).toBe(2);
const sizes = clusters.map((c) => c.length).sort();
expect(sizes).toEqual([1, 2]);
});
it("treats every input as a singleton when eps is tiny", () => {
const nodes = [node("a", 0, 0), node("b", 5, 5), node("c", 10, 10)];
const clusters = clusterByProximity(nodes, 0.0001);
expect(clusters.length).toBe(3);
expect(clusters.every((c) => c.length === 1)).toBe(true);
});
it("derives a positive default eps from median size", () => {
const eps = defaultEpsForNodes([node("a", 0, 0, 100), node("b", 0, 0, 200)]);
expect(eps).toBeGreaterThan(0);
});
});
@@ -0,0 +1,64 @@
import { MoodboardNode, Vec2 } from "../types";
const center = (n: MoodboardNode): Vec2 => ({
x: n.x + n.width / 2,
y: n.y + n.height / 2,
});
const median = (xs: number[]): number => {
if (xs.length === 0) return 0;
const sorted = [...xs].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 === 0
? (sorted[mid - 1] + sorted[mid]) / 2
: sorted[mid];
};
export const defaultEpsForNodes = (nodes: MoodboardNode[]): number => {
if (nodes.length === 0) return 0;
const dims = nodes.map((n) => Math.max(n.width, n.height));
return median(dims) * 1.5;
};
// DBSCAN with minPts = 1 → equivalent to connected-components on the
// eps-graph. Every input node ends up in some cluster (no noise points).
export const clusterByProximity = (
nodes: MoodboardNode[],
eps?: number,
): MoodboardNode[][] => {
if (nodes.length === 0) return [];
const e = eps ?? defaultEpsForNodes(nodes);
const e2 = e * e;
const centers = nodes.map(center);
const visited = new Array(nodes.length).fill(false);
const clusters: MoodboardNode[][] = [];
const neighbors = (i: number): number[] => {
const ci = centers[i];
const out: number[] = [];
for (let j = 0; j < centers.length; j++) {
if (j === i) continue;
const dx = centers[j].x - ci.x;
const dy = centers[j].y - ci.y;
if (dx * dx + dy * dy <= e2) out.push(j);
}
return out;
};
for (let i = 0; i < nodes.length; i++) {
if (visited[i]) continue;
const cluster: MoodboardNode[] = [];
const queue = [i];
while (queue.length) {
const idx = queue.shift()!;
if (visited[idx]) continue;
visited[idx] = true;
cluster.push(nodes[idx]);
for (const n of neighbors(idx)) {
if (!visited[n]) queue.push(n);
}
}
clusters.push(cluster);
}
return clusters;
};
@@ -0,0 +1,23 @@
import { MoodboardNode } from "../types";
export interface LayoutPatch {
id: string;
patch: Partial<MoodboardNode>;
}
export const computeFitToGridPatches = (
nodes: MoodboardNode[],
gridSpacing: number,
): LayoutPatch[] => {
if (gridSpacing <= 0) return [];
const patches: LayoutPatch[] = [];
for (const n of nodes) {
if (n.parentId !== null) continue; // only snap top-level nodes
const snappedX = Math.round(n.x / gridSpacing) * gridSpacing;
const snappedY = Math.round(n.y / gridSpacing) * gridSpacing;
if (snappedX !== n.x || snappedY !== n.y) {
patches.push({ id: n.id, patch: { x: snappedX, y: snappedY } });
}
}
return patches;
};
@@ -0,0 +1,174 @@
import { MoodboardNode, Rect, Vec2 } from "../types";
export interface Viewport {
zoom: number;
pan: Vec2;
}
export interface CanvasSize {
width: number;
height: number;
}
// Global zoom clamps — shared by gesture handlers, fit-to-content, and any
// future minimap so the camera can never land outside a consistent range.
export const ZOOM_MIN = 0.1;
export const ZOOM_MAX = 8;
// ---------- scalar + rect primitives ----------
export const clamp = (v: number, lo: number, hi: number): number =>
Math.max(lo, Math.min(hi, v));
export const rectFromPoints = (a: Vec2, b: Vec2): Rect => ({
x: Math.min(a.x, b.x),
y: Math.min(a.y, b.y),
width: Math.abs(b.x - a.x),
height: Math.abs(b.y - a.y),
});
export const rectCorners = (r: {
x: number;
y: number;
width: number;
height: number;
}): Vec2[] => [
{ x: r.x, y: r.y },
{ x: r.x + r.width, y: r.y },
{ x: r.x + r.width, y: r.y + r.height },
{ x: r.x, y: r.y + r.height },
];
export const rectsIntersect = (a: Rect, b: Rect): boolean =>
!(
a.x + a.width < b.x ||
a.x > b.x + b.width ||
a.y + a.height < b.y ||
a.y > b.y + b.height
);
// ---------- AABB over a set of nodes ----------
export const computeAABB = (nodes: MoodboardNode[]): Rect | null => {
if (nodes.length === 0) return null;
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const n of nodes) {
minX = Math.min(minX, n.x);
minY = Math.min(minY, n.y);
maxX = Math.max(maxX, n.x + n.width);
maxY = Math.max(maxY, n.y + n.height);
}
if (!Number.isFinite(minX)) return null;
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
};
export const computeAABBById = (
nodes: Record<string, MoodboardNode>,
ids: string[],
): Rect | null => {
const picked: MoodboardNode[] = [];
for (const id of ids) {
const n = nodes[id];
if (n) picked.push(n);
}
return computeAABB(picked);
};
// AABB of every top-level node. Treats rotated nodes as their own
// (x, y, w, h) rect for simplicity — fine for "is content on screen?"
// checks and minimap overviews.
export const computeContentBounds = (
nodes: Record<string, MoodboardNode>,
rootOrder: string[],
): Rect | null => computeAABBById(nodes, rootOrder);
// ---------- viewport math ----------
// Visible stage-coord rect = what the camera currently sees.
// canvas_px = pan + stage_coord * zoom, so the visible stage region maps to
// (-pan / zoom) .. (-pan / zoom + canvas / zoom).
export const computeVisibleRect = (
viewport: Viewport,
canvas: CanvasSize,
): Rect => ({
x: -viewport.pan.x / viewport.zoom,
y: -viewport.pan.y / viewport.zoom,
width: canvas.width / viewport.zoom,
height: canvas.height / viewport.zoom,
});
// True if any top-level node's AABB intersects the visible viewport.
// Used by the recenter indicator; a minimap can use this to style the
// viewport rectangle differently when off-content.
export const anyContentVisible = (
nodes: Record<string, MoodboardNode>,
rootOrder: string[],
viewport: Viewport,
canvas: CanvasSize,
): boolean => {
const visible = computeVisibleRect(viewport, canvas);
for (const id of rootOrder) {
const n = nodes[id];
if (!n) continue;
if (
rectsIntersect(
{ x: n.x, y: n.y, width: n.width, height: n.height },
visible,
)
) {
return true;
}
}
return false;
};
// Returns the viewport that frames `bounds` inside `canvas` with `padding`
// screen pixels of margin on all sides, clamped to [ZOOM_MIN, ZOOM_MAX].
// Degenerate bounds (zero width/height) fall back to zoom=1 centered on
// the point.
export const fitViewportToBounds = (
bounds: Rect,
canvas: CanvasSize,
padding: number,
): Viewport => {
if (bounds.width <= 0 || bounds.height <= 0) {
return {
zoom: 1,
pan: {
x: canvas.width / 2 - bounds.x,
y: canvas.height / 2 - bounds.y,
},
};
}
const availW = Math.max(canvas.width - 2 * padding, 1);
const availH = Math.max(canvas.height - 2 * padding, 1);
const zoom = clamp(
Math.min(availW / bounds.width, availH / bounds.height),
ZOOM_MIN,
ZOOM_MAX,
);
const centerX = bounds.x + bounds.width / 2;
const centerY = bounds.y + bounds.height / 2;
return {
zoom,
pan: {
x: canvas.width / 2 - centerX * zoom,
y: canvas.height / 2 - centerY * zoom,
},
};
};
// Returns the pan that centers the viewport on a given stage-coord point
// at the current zoom. Handy for minimap drag-to-navigate: stage point
// under the minimap cursor → pan such that it lands in the canvas center.
export const panToCenterOn = (
stagePoint: Vec2,
zoom: number,
canvas: CanvasSize,
): Vec2 => ({
x: canvas.width / 2 - stagePoint.x * zoom,
y: canvas.height / 2 - stagePoint.y * zoom,
});
@@ -0,0 +1,67 @@
import { computeAABB } from "./geometry";
import { computePackPatches } from "./packCollage";
import { MoodboardNode } from "../types";
const img = (
id: string,
x: number,
y: number,
width: number,
height: number,
): MoodboardNode => ({
id,
kind: "image",
parentId: null,
x,
y,
width,
height,
rotation: 0,
zIndex: 0,
src: "",
mediaId: null,
naturalW: width,
naturalH: height,
});
describe("packCollage", () => {
it("returns no patches for empty input", () => {
const patches = computePackPatches([], { x: 0, y: 0, width: 800, height: 600 });
expect(patches).toEqual([]);
});
it("packs three equal-aspect images into a single row that fills width with gaps", () => {
const nodes = [
img("a", 0, 0, 200, 200),
img("b", 0, 0, 200, 200),
img("c", 0, 0, 200, 200),
];
const bounds = { x: 0, y: 0, width: 600, height: 400 };
const patches = computePackPatches(nodes, bounds, { rowHeight: 180, gap: 8 });
expect(patches.length).toBe(3);
// All in one row → same y, identical heights, total widths + 2 gaps == bounds.width.
const ys = patches.map((p) => p.patch.y!);
expect(ys[0]).toBe(0);
expect(ys.every((y) => y === ys[0])).toBe(true);
const widths = patches.map((p) => p.patch.width!);
const totalW = widths.reduce((a, b) => a + b, 0) + 2 * 8;
expect(totalW).toBeCloseTo(600, 1);
const heights = patches.map((p) => p.patch.height!);
expect(heights.every((h) => Math.abs(h - heights[0]) < 1e-6)).toBe(true);
});
it("breaks into multiple rows when aspects sum exceeds target", () => {
const wide = (id: string) => img(id, 0, 0, 400, 100); // aspect 4
const nodes = [wide("a"), wide("b"), wide("c"), wide("d")];
const bounds = { x: 0, y: 0, width: 400, height: 600 };
const patches = computePackPatches(nodes, bounds, { rowHeight: 100, gap: 0 });
const distinctYs = new Set(patches.map((p) => p.patch.y!));
expect(distinctYs.size).toBeGreaterThan(1);
});
it("computeAABB returns null on empty and a tight box on inputs", () => {
expect(computeAABB([])).toBeNull();
const aabb = computeAABB([img("a", 10, 20, 100, 50), img("b", 60, 0, 80, 200)]);
expect(aabb).toEqual({ x: 10, y: 0, width: 130, height: 200 });
});
});
@@ -0,0 +1,72 @@
import { MoodboardNode, Rect } from "../types";
import { LayoutPatch } from "./fitToGrid";
export interface PackOptions {
rowHeight: number;
gap: number;
}
const DEFAULTS: PackOptions = { rowHeight: 180, gap: 8 };
export const computePackPatches = (
nodes: MoodboardNode[],
bounds: Rect,
opts: Partial<PackOptions> = {},
): LayoutPatch[] => {
const { rowHeight, gap } = { ...DEFAULTS, ...opts };
if (nodes.length === 0 || bounds.width <= 0) return [];
const aspects = nodes.map((n) =>
n.height > 0 ? n.width / n.height : 1,
);
// Greedy row break: stop adding when summed aspect (at target rowHeight)
// would overflow the bounds width including per-item gaps.
type Row = { indices: number[]; aspectSum: number };
const rows: Row[] = [];
let current: Row = { indices: [], aspectSum: 0 };
const targetAspect = bounds.width / rowHeight;
for (let i = 0; i < nodes.length; i++) {
const a = aspects[i];
const projected = current.aspectSum + a;
const projectedGapAspect =
(current.indices.length * gap) / rowHeight;
if (
current.indices.length > 0 &&
projected + projectedGapAspect >= targetAspect
) {
rows.push(current);
current = { indices: [], aspectSum: 0 };
}
current.indices.push(i);
current.aspectSum += a;
}
if (current.indices.length > 0) rows.push(current);
const patches: LayoutPatch[] = [];
let y = bounds.y;
rows.forEach((row, rowIdx) => {
const isLast = rowIdx === rows.length - 1;
const gapsTotal = (row.indices.length - 1) * gap;
const availW = Math.max(1, bounds.width - gapsTotal);
let rowH = availW / row.aspectSum;
if (isLast && rowH > rowHeight * 1.5) {
rowH = rowHeight;
}
let x = bounds.x;
for (const i of row.indices) {
const w = aspects[i] * rowH;
patches.push({
id: nodes[i].id,
patch: { x, y, width: w, height: rowH, rotation: 0 },
});
x += w + gap;
}
y += rowH + gap;
});
return patches;
};
@@ -0,0 +1,54 @@
import { pointInPolygon } from "./pointInPolygon";
import {
segmentsIntersect,
polygonIntersectsRect,
} from "./segmentsIntersect";
const square = [
{ x: 0, y: 0 },
{ x: 10, y: 0 },
{ x: 10, y: 10 },
{ x: 0, y: 10 },
];
describe("pointInPolygon", () => {
it("returns true for points strictly inside", () => {
expect(pointInPolygon({ x: 5, y: 5 }, square)).toBe(true);
});
it("returns false for points outside", () => {
expect(pointInPolygon({ x: 20, y: 5 }, square)).toBe(false);
});
it("returns false for degenerate polygons", () => {
expect(pointInPolygon({ x: 0, y: 0 }, [{ x: 0, y: 0 }, { x: 1, y: 1 }])).toBe(false);
});
});
describe("segmentsIntersect", () => {
it("detects crossing segments", () => {
expect(
segmentsIntersect({ x: 0, y: 0 }, { x: 10, y: 10 }, { x: 0, y: 10 }, { x: 10, y: 0 }),
).toBe(true);
});
it("rejects non-crossing segments", () => {
expect(
segmentsIntersect({ x: 0, y: 0 }, { x: 1, y: 1 }, { x: 5, y: 5 }, { x: 6, y: 6 }),
).toBe(false);
});
});
describe("polygonIntersectsRect", () => {
it("returns true when an edge crosses a rect side", () => {
const polyline = [
{ x: -5, y: 5 },
{ x: 15, y: 5 },
];
expect(polygonIntersectsRect(polyline, { x: 0, y: 0, width: 10, height: 10 })).toBe(true);
});
it("returns false when polygon is entirely outside and does not touch", () => {
const polyline = [
{ x: 100, y: 100 },
{ x: 200, y: 100 },
];
expect(polygonIntersectsRect(polyline, { x: 0, y: 0, width: 10, height: 10 })).toBe(false);
});
});
@@ -0,0 +1,17 @@
import { Vec2 } from "../types";
export const pointInPolygon = (point: Vec2, polygon: Vec2[]): boolean => {
if (polygon.length < 3) return false;
let inside = false;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const xi = polygon[i].x;
const yi = polygon[i].y;
const xj = polygon[j].x;
const yj = polygon[j].y;
const intersect =
yi > point.y !== yj > point.y &&
point.x < ((xj - xi) * (point.y - yi)) / (yj - yi + Number.EPSILON) + xi;
if (intersect) inside = !inside;
}
return inside;
};
@@ -0,0 +1,46 @@
import { Vec2 } from "../types";
const cross = (ax: number, ay: number, bx: number, by: number) =>
ax * by - ay * bx;
export const segmentsIntersect = (
p1: Vec2,
p2: Vec2,
p3: Vec2,
p4: Vec2,
): boolean => {
const d1x = p2.x - p1.x;
const d1y = p2.y - p1.y;
const d2x = p4.x - p3.x;
const d2y = p4.y - p3.y;
const denom = cross(d1x, d1y, d2x, d2y);
if (denom === 0) return false; // parallel or collinear
const dx = p3.x - p1.x;
const dy = p3.y - p1.y;
const t = cross(dx, dy, d2x, d2y) / denom;
const u = cross(dx, dy, d1x, d1y) / denom;
return t >= 0 && t <= 1 && u >= 0 && u <= 1;
};
export const polygonIntersectsRect = (
polygon: Vec2[],
rect: { x: number; y: number; width: number; height: number },
): boolean => {
if (polygon.length < 2) return false;
const corners: Vec2[] = [
{ x: rect.x, y: rect.y },
{ x: rect.x + rect.width, y: rect.y },
{ x: rect.x + rect.width, y: rect.y + rect.height },
{ x: rect.x, y: rect.y + rect.height },
];
for (let i = 0; i < polygon.length; i++) {
const a = polygon[i];
const b = polygon[(i + 1) % polygon.length];
for (let j = 0; j < 4; j++) {
const c = corners[j];
const d = corners[(j + 1) % 4];
if (segmentsIntersect(a, b, c, d)) return true;
}
}
return false;
};
@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import { SnapBox, computeSnap } from "./snapping";
const box = (x: number, y: number, w = 100, h = 100): SnapBox => ({
x,
y,
width: w,
height: h,
});
describe("computeSnap", () => {
it("snaps a near-aligned left edge onto another node's left edge", () => {
const moving = box(103, 400);
const result = computeSnap(moving, [box(100, 0)], 6, null);
expect(result.x).toBe(100);
expect(result.guides.some((g) => g.axis === "x" && g.pos === 100)).toBe(true);
});
it("snaps centers together", () => {
// Other center-x = 150. Moving (w=100) center aligns when x = 100.
const moving = box(98, 400);
const result = computeSnap(moving, [box(100, 0)], 6, null);
expect(result.x).toBe(100);
});
it("leaves position untouched beyond the threshold", () => {
const moving = box(120, 400);
const result = computeSnap(moving, [box(100, 0)], 6, null);
expect(result.x).toBe(120);
expect(result.guides).toHaveLength(0);
});
it("falls back to the grid when no node aligns", () => {
const moving = box(33, 65);
const result = computeSnap(moving, [], 6, 32);
expect(result.x).toBe(32);
expect(result.y).toBe(64);
});
it("prefers node alignment over the grid on a given axis", () => {
// x has a node edge at 100 (moving from 97) within threshold; grid=32 would
// pull to 96 — node alignment must win.
const moving = box(97, 400);
const result = computeSnap(moving, [box(100, 0)], 6, 32);
expect(result.x).toBe(100);
});
it("emits a guide spanning both boxes on the shared axis", () => {
const moving = box(100, 300);
const result = computeSnap(moving, [box(100, 0)], 6, null);
const guide = result.guides.find((g) => g.axis === "x");
expect(guide).toBeDefined();
expect(guide?.from).toBe(0);
expect(guide?.to).toBe(400);
});
});
@@ -0,0 +1,110 @@
// Drag snapping for the freeform canvas. A moving node's near/center/far edges
// align to the same edges of nearby nodes (and, as a fallback, the background
// grid). Pure + deterministic so it can be unit-tested without Konva.
export interface SnapBox {
x: number;
y: number;
width: number;
height: number;
}
export interface SnapGuide {
axis: "x" | "y";
// Stage coordinate of the guide line on its axis.
pos: number;
// Cross-axis extent the guide spans (so it visually connects the two nodes).
from: number;
to: number;
}
export interface SnapResult {
x: number;
y: number;
guides: SnapGuide[];
}
// near edge, center, far edge — the three alignment anchors per axis.
const xAnchors = (b: SnapBox): number[] => [b.x, b.x + b.width / 2, b.x + b.width];
const yAnchors = (b: SnapBox): number[] => [b.y, b.y + b.height / 2, b.y + b.height];
// Snaps `moving` against `others` within `threshold` (stage units). `grid`, when
// set, snaps the top-left to the grid on any axis that found no node alignment.
export const computeSnap = (
moving: SnapBox,
others: SnapBox[],
threshold: number,
grid: number | null,
): SnapResult => {
let bestDX = threshold + 1;
let snapX: number | null = null;
let alignX = 0;
let otherX: SnapBox | null = null;
let bestDY = threshold + 1;
let snapY: number | null = null;
let alignY = 0;
let otherY: SnapBox | null = null;
const mxs = xAnchors(moving);
const mys = yAnchors(moving);
for (const o of others) {
for (const ox of xAnchors(o)) {
for (const mx of mxs) {
const d = Math.abs(ox - mx);
if (d < bestDX) {
bestDX = d;
snapX = moving.x + (ox - mx);
alignX = ox;
otherX = o;
}
}
}
for (const oy of yAnchors(o)) {
for (const my of mys) {
const d = Math.abs(oy - my);
if (d < bestDY) {
bestDY = d;
snapY = moving.y + (oy - my);
alignY = oy;
otherY = o;
}
}
}
}
// Grid fallback per axis (no guide line — the dotted grid is the reference).
if (snapX === null && grid) {
const gx = Math.round(moving.x / grid) * grid;
if (Math.abs(gx - moving.x) <= threshold) snapX = gx;
}
if (snapY === null && grid) {
const gy = Math.round(moving.y / grid) * grid;
if (Math.abs(gy - moving.y) <= threshold) snapY = gy;
}
const finalX = snapX ?? moving.x;
const finalY = snapY ?? moving.y;
const snapped: SnapBox = { ...moving, x: finalX, y: finalY };
const guides: SnapGuide[] = [];
if (otherX) {
guides.push({
axis: "x",
pos: alignX,
from: Math.min(snapped.y, otherX.y),
to: Math.max(snapped.y + snapped.height, otherX.y + otherX.height),
});
}
if (otherY) {
guides.push({
axis: "y",
pos: alignY,
from: Math.min(snapped.x, otherY.x),
to: Math.max(snapped.x + snapped.width, otherY.x + otherY.width),
});
}
return { x: finalX, y: finalY, guides };
};
@@ -0,0 +1,112 @@
import { memo } from "react";
import Konva from "konva";
import { Group, Rect } from "react-konva";
import { useShallow } from "zustand/react/shallow";
import { GroupNode as GroupNodeData, MoodboardNode } from "../types";
import { useMoodboardStore } from "../MoodboardStore";
import { useNodeDrag } from "../interactions/useNodeDrag";
import { ImageNode } from "./ImageNode";
import { TextNode } from "./TextNode";
interface Props {
node: GroupNodeData;
selected: boolean;
onSelect: (
id: string,
e: Konva.KonvaEventObject<MouseEvent | TouchEvent>,
) => void;
}
// Shared no-op for child renders: the group absorbs all clicks, so child
// nodes never fire `onSelect`. Using a module-level constant keeps the
// reference stable across renders, which lets the memoized child nodes
// skip work when nothing else about them changed.
const NO_OP_SELECT = () => {};
const GroupNodeInner = ({ node, selected, onSelect }: Props) => {
const drag = useNodeDrag(node.id);
// Subscribe only to this group's own children, with a shallow compare on
// the returned array. Avoids re-rendering every group whenever any node
// anywhere in the board updates (which would otherwise mutate the whole
// `nodes` map reference).
const children = useMoodboardStore(
useShallow((s) =>
node.childIds
.map((id) => s.nodes[id])
.filter((n): n is MoodboardNode => Boolean(n)),
),
);
const handleSelect = (
e: Konva.KonvaEventObject<MouseEvent | TouchEvent>,
) => onSelect(node.id, e);
return (
<Group
id={node.id}
x={node.x}
y={node.y}
width={node.width}
height={node.height}
rotation={node.rotation}
draggable
onMouseDown={handleSelect}
onTouchStart={handleSelect}
onDragStart={drag.onDragStart}
onDragMove={drag.onDragMove}
onDragEnd={drag.onDragEnd}
>
{/* Transparent hit/transform target so the Transformer has stable bounds. */}
<Rect
x={0}
y={0}
width={node.width}
height={node.height}
fill="transparent"
stroke={selected ? "#3b82f6" : "transparent"}
strokeWidth={1}
dash={selected ? [4, 4] : undefined}
listening={false}
/>
{children.map((child) => {
if (child.kind === "image") {
return (
<ImageNode
key={child.id}
node={child}
draggable={false}
selected={false}
onSelect={NO_OP_SELECT}
/>
);
}
if (child.kind === "text") {
return (
<TextNode
key={child.id}
node={child}
draggable={false}
selected={false}
onSelect={NO_OP_SELECT}
/>
);
}
// Nested groups not exercised in phase 1, but render recursively for future.
if (child.kind === "group") {
return (
<GroupNode
key={child.id}
node={child}
selected={false}
onSelect={NO_OP_SELECT}
/>
);
}
return null;
})}
</Group>
);
};
export const GroupNode = memo(GroupNodeInner);
@@ -0,0 +1,74 @@
import { memo, useEffect, useRef, useState } from "react";
import Konva from "konva";
import { Image as KonvaImage, Rect } from "react-konva";
import { ImageNode as ImageNodeData } from "../types";
import { useNodeDrag } from "../interactions/useNodeDrag";
interface Props {
node: ImageNodeData;
draggable: boolean;
selected: boolean;
// Receives the node id so parents can pass one stable callback to every
// sibling rather than allocating a per-item closure on each render.
onSelect: (
id: string,
e: Konva.KonvaEventObject<MouseEvent | TouchEvent>,
) => void;
}
const ImageNodeInner = ({ node, draggable, selected, onSelect }: Props) => {
const drag = useNodeDrag(node.id);
const ref = useRef<Konva.Image | null>(null);
const [img, setImg] = useState<HTMLImageElement | null>(null);
useEffect(() => {
const i = new window.Image();
i.crossOrigin = "anonymous";
i.onload = () => setImg(i);
i.src = node.src;
return () => {
i.onload = null;
};
}, [node.src]);
const handleSelect = (
e: Konva.KonvaEventObject<MouseEvent | TouchEvent>,
) => onSelect(node.id, e);
return (
<>
{!img && (
<Rect
x={node.x}
y={node.y}
width={node.width}
height={node.height}
rotation={node.rotation}
fill="#222"
stroke={selected ? "#3b82f6" : "transparent"}
strokeWidth={1}
/>
)}
<KonvaImage
id={node.id}
ref={(n) => {
ref.current = n;
}}
image={img ?? undefined}
x={node.x}
y={node.y}
width={node.width}
height={node.height}
rotation={node.rotation}
draggable={draggable}
onMouseDown={handleSelect}
onTouchStart={handleSelect}
onDragStart={drag.onDragStart}
onDragMove={drag.onDragMove}
onDragEnd={drag.onDragEnd}
/>
</>
);
};
export const ImageNode = memo(ImageNodeInner);
@@ -0,0 +1,57 @@
import { memo } from "react";
import Konva from "konva";
import { Text as KonvaText } from "react-konva";
import { TextNode as TextNodeData } from "../types";
import { useMoodboardStore } from "../MoodboardStore";
import { useNodeDrag } from "../interactions/useNodeDrag";
interface Props {
node: TextNodeData;
draggable: boolean;
selected: boolean;
onSelect: (
id: string,
e: Konva.KonvaEventObject<MouseEvent | TouchEvent>,
) => void;
}
const TextNodeInner = ({ node, draggable, selected, onSelect }: Props) => {
const drag = useNodeDrag(node.id);
const setEditingText = useMoodboardStore((s) => s.setEditingText);
// Selector compares a single id — only the TextNode whose isEditing
// actually flips will re-render when someone enters/exits text edit.
const isEditing = useMoodboardStore(
(s) => s.transient.editingTextId === node.id,
);
const handleSelect = (
e: Konva.KonvaEventObject<MouseEvent | TouchEvent>,
) => onSelect(node.id, e);
return (
<KonvaText
id={node.id}
x={node.x}
y={node.y}
width={node.width}
height={node.height}
rotation={node.rotation}
text={isEditing ? "" : node.text || "Note"}
fontSize={node.fontSize}
fill={node.color}
padding={6}
draggable={draggable}
onMouseDown={handleSelect}
onTouchStart={handleSelect}
onDblClick={() => setEditingText(node.id)}
onDblTap={() => setEditingText(node.id)}
onDragStart={drag.onDragStart}
onDragMove={drag.onDragMove}
onDragEnd={drag.onDragEnd}
stroke={selected ? "#3b82f6" : undefined}
strokeWidth={selected ? 0.5 : 0}
/>
);
};
export const TextNode = memo(TextNodeInner);
@@ -0,0 +1,105 @@
import { memo, useEffect, useRef, useState } from "react";
import Konva from "konva";
import { Image as KonvaImage, Rect } from "react-konva";
import { VideoNode as VideoNodeData } from "../types";
import { useNodeDrag } from "../interactions/useNodeDrag";
interface Props {
node: VideoNodeData;
draggable: boolean;
selected: boolean;
// Receives the node id so parents can pass one stable callback to every
// sibling rather than allocating a per-item closure on each render.
onSelect: (
id: string,
e: Konva.KonvaEventObject<MouseEvent | TouchEvent>,
) => void;
}
// Konva can't render a <video> directly — it paints whatever HTMLImageElement
// or HTMLVideoElement you hand its Image node, but only on draw. So we own the
// element imperatively and run a Konva.Animation tied to the node's layer to
// repaint each frame while the video plays. This mirrors ImageNode otherwise.
const VideoNodeInner = ({ node, draggable, selected, onSelect }: Props) => {
const drag = useNodeDrag(node.id);
const ref = useRef<Konva.Image | null>(null);
const [video, setVideo] = useState<HTMLVideoElement | null>(null);
// Build the element once per src; autoplay only succeeds while muted, which
// is why addVideo() forces muted+loop+autoplay to travel together.
useEffect(() => {
const el = document.createElement("video");
el.crossOrigin = "anonymous";
el.src = node.src;
el.muted = node.muted;
el.loop = node.loop;
el.autoplay = node.autoplay;
el.playsInline = true;
el.preload = "metadata";
const onLoaded = () => setVideo(el);
el.addEventListener("loadedmetadata", onLoaded);
if (node.autoplay) void el.play().catch(() => undefined);
return () => {
el.removeEventListener("loadedmetadata", onLoaded);
el.pause();
el.removeAttribute("src");
el.load();
setVideo(null);
};
}, [node.src, node.muted, node.loop, node.autoplay]);
// Drive repaints off the layer the image node lives on, for as long as the
// element is actually playing. The animation auto-stops on unmount/cleanup.
useEffect(() => {
const imageNode = ref.current;
if (!video || !imageNode) return undefined;
const layer = imageNode.getLayer();
if (!layer) return undefined;
const anim = new Konva.Animation(() => undefined, layer);
anim.start();
return () => {
anim.stop();
};
}, [video]);
const handleSelect = (
e: Konva.KonvaEventObject<MouseEvent | TouchEvent>,
) => onSelect(node.id, e);
return (
<>
{!video && (
<Rect
x={node.x}
y={node.y}
width={node.width}
height={node.height}
rotation={node.rotation}
fill="#222"
stroke={selected ? "#3b82f6" : "transparent"}
strokeWidth={1}
/>
)}
<KonvaImage
id={node.id}
ref={(n) => {
ref.current = n;
}}
image={video ?? undefined}
x={node.x}
y={node.y}
width={node.width}
height={node.height}
rotation={node.rotation}
draggable={draggable}
onMouseDown={handleSelect}
onTouchStart={handleSelect}
onDragStart={drag.onDragStart}
onDragMove={drag.onDragMove}
onDragEnd={drag.onDragEnd}
/>
</>
);
};
export const VideoNode = memo(VideoNodeInner);
@@ -0,0 +1,32 @@
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCompress } from "@fortawesome/pro-solid-svg-icons";
import { useMoodboardStore } from "../MoodboardStore";
import { anyContentVisible } from "../layout/geometry";
// Small pill button in the bottom-right that appears when the user has
// panned or zoomed so far that no content is on screen. Clicking it calls
// `fitToContent` to frame all nodes back into view. Hidden on an empty
// board (nothing to recenter on) and whenever any node intersects the
// visible viewport.
export const RecenterIndicator = () => {
const nodes = useMoodboardStore((s) => s.nodes);
const rootOrder = useMoodboardStore((s) => s.rootOrder);
const viewport = useMoodboardStore((s) => s.viewport);
const canvasSize = useMoodboardStore((s) => s.canvasSize);
const fitToContent = useMoodboardStore((s) => s.fitToContent);
if (rootOrder.length === 0) return null;
if (anyContentVisible(nodes, rootOrder, viewport, canvasSize)) return null;
return (
<button
type="button"
onClick={() => fitToContent()}
title="Zoom to fit (Shift+1)"
className="pointer-events-auto absolute bottom-4 right-4 z-20 flex items-center gap-2 rounded-full border border-white/20 bg-black/70 px-3 py-2 text-sm text-white/90 shadow-lg backdrop-blur-sm transition-colors hover:bg-black/80"
>
<FontAwesomeIcon icon={faCompress} />
<span>Content off-screen — recenter</span>
</button>
);
};
@@ -0,0 +1,76 @@
import { SHORTCUTS, ShortcutRow } from "./shortcuts";
interface Props {
visible: boolean;
}
const SECTION_ORDER: ShortcutRow["section"][] = [
"Tools",
"Selection",
"Editing",
"Viewport",
"History",
];
// Translucent, non-interactive overlay that lists all keyboard shortcuts.
// Shown while the user holds Ctrl/Cmd alone for 3 s (see
// `useShortcutCheatsheet.ts`). `pointer-events: none` so it never steals
// focus — the user can keep panning / zooming / typing while it's visible.
export const ShortcutCheatsheet = ({ visible }: Props) => {
if (!visible) return null;
// Group rows by section, preserving declaration order within each.
const bySection = new Map<ShortcutRow["section"], ShortcutRow[]>();
for (const row of SHORTCUTS) {
const arr = bySection.get(row.section) ?? [];
arr.push(row);
bySection.set(row.section, arr);
}
return (
<div
aria-hidden
className="pointer-events-none absolute inset-0 z-30 flex items-center justify-center"
>
<div className="max-h-[80%] w-[min(640px,90%)] overflow-auto rounded-xl border border-white/15 bg-black/70 p-6 text-white/90 shadow-2xl backdrop-blur-sm">
<div className="mb-3 text-xs uppercase tracking-widest text-white/50">
Keyboard shortcuts
</div>
<div className="grid grid-cols-1 gap-x-8 gap-y-6 sm:grid-cols-2">
{SECTION_ORDER.map((section) => {
const rows = bySection.get(section);
if (!rows) return null;
return (
<div key={section}>
<div className="mb-2 text-xs font-semibold uppercase tracking-wider text-white/60">
{section}
</div>
<ul className="flex flex-col gap-1.5">
{rows.map((row) => (
<li
key={row.label}
className="flex items-center justify-between gap-3 text-sm"
>
<span className="text-white/80">{row.label}</span>
<span className="flex items-center gap-1">
{row.keys.map((k, i) => (
<Key key={`${row.label}-${i}`} label={k} />
))}
</span>
</li>
))}
</ul>
</div>
);
})}
</div>
</div>
</div>
);
};
const Key = ({ label }: { label: string }) => (
<kbd className="rounded border border-white/20 bg-white/10 px-1.5 py-0.5 font-mono text-[11px] text-white/90">
{label}
</kbd>
);
@@ -0,0 +1,54 @@
// Single source of truth for moodboard keyboard shortcuts. Consumed by
// `ShortcutCheatsheet.tsx` (rendering) and indirectly by
// `useKeyboardShortcuts.ts` (wiring). The `keys` array is the label shown
// to the user; the actual matching logic lives in the hook because some
// shortcuts are modifier-sensitive and platform-specific.
export interface ShortcutRow {
label: string;
keys: string[];
section: "Tools" | "Selection" | "Editing" | "Viewport" | "History";
}
const isMac =
typeof navigator !== "undefined" &&
/Mac|iPod|iPhone|iPad/.test(navigator.platform);
export const MOD = isMac ? "⌘" : "Ctrl";
// Renders a key combo as a compact tooltip label. Mac uses adjacent symbols
// (⌘⇧Z); other platforms use "+" separators (Ctrl+Shift+Z). Pass keys in
// order, e.g. [MOD, "Shift", "Z"].
export const fmtShortcut = (keys: string[]): string =>
isMac
? keys.map((k) => (k === "Shift" ? "⇧" : k)).join("")
: keys.join("+");
export const SHORTCUTS: ShortcutRow[] = [
// Tools
{ section: "Tools", label: "Select tool", keys: ["V"] },
{ section: "Tools", label: "Lasso tool", keys: ["L"] },
{ section: "Tools", label: "Text tool", keys: ["T"] },
// Selection
{ section: "Selection", label: "Select all", keys: [MOD, "A"] },
{ section: "Selection", label: "Clear selection", keys: ["Esc"] },
{ section: "Selection", label: "Add / remove from selection", keys: ["Shift", "Click"] },
// Editing
{ section: "Editing", label: "Delete selection", keys: ["Delete"] },
{ section: "Editing", label: "Group selection", keys: [MOD, "G"] },
{ section: "Editing", label: "Ungroup selection", keys: [MOD, "Shift", "G"] },
{ section: "Editing", label: "Paste image", keys: [MOD, "V"] },
// Viewport
{ section: "Viewport", label: "Pan", keys: ["Middle-click", "drag"] },
{ section: "Viewport", label: "Pan (alt)", keys: ["Space", "drag"] },
{ section: "Viewport", label: "Zoom", keys: ["Scroll"] },
{ section: "Viewport", label: "Reset zoom", keys: [MOD, "0"] },
{ section: "Viewport", label: "Zoom to fit", keys: ["Shift", "1"] },
// History
{ section: "History", label: "Undo", keys: [MOD, "Z"] },
{ section: "History", label: "Redo", keys: [MOD, "Shift", "Z"] },
];
@@ -0,0 +1,71 @@
export type Vec2 = { x: number; y: number };
export type Rect = { x: number; y: number; width: number; height: number };
export type Tool = "select" | "lasso" | "text";
interface BaseNode {
id: string;
parentId: string | null;
// Stage-local for top-level nodes; group-local for children of a group.
x: number;
y: number;
width: number;
height: number;
// Degrees, Konva convention.
rotation: number;
zIndex: number;
}
export type ImageNode = BaseNode & {
kind: "image";
src: string;
mediaId: string | null;
naturalW: number;
naturalH: number;
};
export type VideoNode = BaseNode & {
kind: "video";
src: string;
mediaId: string | null;
naturalW: number;
naturalH: number;
muted: boolean;
loop: boolean;
autoplay: boolean;
};
export type TextNode = BaseNode & {
kind: "text";
text: string;
fontSize: number;
color: string;
};
export type GroupNode = BaseNode & {
kind: "group";
childIds: string[];
};
// A card stacks its children in a vertical flex column (see CardNodeHtml). Unlike
// a group, children are laid out by the card (their own x/y are ignored) and the
// card owns a padding + background. `width` is authoritative; `height` is derived
// from the children via computeCardHeight whenever the child set changes.
export type CardNode = BaseNode & {
kind: "card";
childIds: string[];
padding: number;
backgroundColor: string;
};
export type MoodboardNode =
| ImageNode
| VideoNode
| TextNode
| GroupNode
| CardNode;
export interface MoodboardSnapshot {
nodes: Record<string, MoodboardNode>;
rootOrder: string[];
selectedIds: string[];
}

Some files were not shown because too many files have changed in this diff Show More