mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-27 12:10:08 -04:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e3f6628c1 | ||
|
|
214eda6818 | ||
|
|
47648b19ae | ||
|
|
379e528e0a | ||
|
|
9965e2544d | ||
|
|
61a0880c80 | ||
|
|
c5c68d301a |
@@ -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;
|
||||
+25
@@ -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>,
|
||||
}
|
||||
+19
@@ -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,
|
||||
}
|
||||
+69
@@ -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,
|
||||
|
||||
+127
@@ -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<_>>(),
|
||||
})
|
||||
}
|
||||
+107
@@ -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,
|
||||
}))
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod batch_get_job_status_handler;
|
||||
pub mod get_job_status_handler;
|
||||
pub mod record_to_payload;
|
||||
+127
@@ -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;
|
||||
|
||||
+12
@@ -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()))
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
# Artcraft Moodboard — Design & Flow
|
||||
|
||||
> How the moodboard looks, feels, and flows. Written through a high-end product-design lens
|
||||
> (Linear/Apple-tier restraint: haptic depth, spring-physics motion, calm surfaces) and grounded
|
||||
> in the shipped implementation (`@storyteller/ui-moodboard`). Companion to `DESIGN_SYSTEM.md`
|
||||
> (tokens/components), `FEATURE_PLAN.md` (roadmap), and `FEATURE_BACKLOG.md` (what's next).
|
||||
|
||||
---
|
||||
|
||||
## 1. Design philosophy
|
||||
|
||||
Three convictions drive every decision:
|
||||
|
||||
1. **The board is an input to generation, not a graveyard.** Generic moodboards (Pinterest,
|
||||
Milanote) are dead ends — references go in, nothing comes out. Artcraft owns the engine, so the
|
||||
board is one gesture away from a generation and vice-versa. Every surface keeps that loop in
|
||||
reach without ever shouting about it.
|
||||
2. **Native, then elevated.** The moodboard lives *inside* Artcraft, so it speaks the app's visual
|
||||
language (the `--st-*` theme tokens, Fira Sans, the `glass` material, the brand blue). Elevation
|
||||
comes from *composition* — nested "double-bezel" enclosures, floating glass islands, motion with
|
||||
mass — never from a louder palette. The board should feel like it was always part of the app.
|
||||
3. **The imagery is the interface.** Chrome recedes; references dominate. No like-counts, no
|
||||
notification noise, no decorative gradients competing with the user's work. Calm by default
|
||||
(the Cosmos/Are.na lesson), dense where it counts.
|
||||
|
||||
The felt quality we're after: **a precise, quiet instrument** — fast, tactile, and a little bit
|
||||
joyful, where heavy operations feel weightless and nothing ever teleports.
|
||||
|
||||
---
|
||||
|
||||
## 2. Visual language (the short version)
|
||||
|
||||
The full token/component spec is in `DESIGN_SYSTEM.md`; the design *intent* behind it:
|
||||
|
||||
- **Double-bezel enclosures.** Cards, the inspector, and toolbars are never flat on the canvas.
|
||||
A subtle outer shell (plane-1, hairline ring, `rounded-[18px]`, `p-1`) cradles an inner core
|
||||
(plane-2, concentric `rounded-[14px]`, an inset top-highlight). It reads like a glass plate
|
||||
seated in a machined tray — physical, considered, expensive.
|
||||
- **Floating glass islands.** The toolbar and the view switch are detached pills, not edge-glued
|
||||
bars (`glass` + hairline + ambient shadow, `backdrop-blur` only on these fixed elements — never
|
||||
on the scrolling grid, where blur would torch scroll performance).
|
||||
- **Spring-physics motion.** Custom cubic-beziers, never `linear`/`ease-in-out`. A hover lift, a
|
||||
card landing on drop with a touch of overshoot, a palette fading up. Animate only `transform`,
|
||||
`opacity`, `filter` — GPU-safe. `prefers-reduced-motion` collapses springs to fades.
|
||||
- **Concentric squircle radii** and **ambient, layered shadows** (never a harsh `shadow-md`).
|
||||
- **One display moment.** Body/UI is Fira Sans (the app font, for zero context-switch); the only
|
||||
"display" flourish is the editorial empty-state heading and presentation mode.
|
||||
|
||||
---
|
||||
|
||||
## 3. Information architecture
|
||||
|
||||
```
|
||||
Board (durable model, localStorage today → backend later)
|
||||
├── items: image · video · text · link · color (each: tags, rating, deterministic aspect)
|
||||
├── sections (model-ready; UI is a near-term add)
|
||||
└── rendered three ways — ALL views are projections of the same board:
|
||||
├── GRID virtualized masonry — the default; scan / search / triage at volume
|
||||
├── CANVAS freeform Konva stage — arrange / compare / plan spatially
|
||||
└── PRESENT full-bleed deck — review & share
|
||||
```
|
||||
|
||||
**Why grid-first.** 90% of moodboard time is scanning, searching, and triaging — the grid wins
|
||||
those high-frequency jobs and stays at 60fps (canvas-only tools like Milanote lag at a few hundred
|
||||
items; we virtualize from item aspect ratios so only the viewport mounts). The freeform canvas is
|
||||
the *delighter* for spatial thinking, not the foundation. **The board, not the view, is what plugs
|
||||
into generation** — "use as reference" works regardless of how you're looking at it.
|
||||
|
||||
---
|
||||
|
||||
## 4. Core flows
|
||||
|
||||
Each flow below lists the **gesture**, then the **design rationale**.
|
||||
|
||||
### 4.1 Capture — get references in with zero friction
|
||||
The fastest path wins, so we offer every path and make the empty state *say so*.
|
||||
|
||||
- **Drag images** from the desktop onto the board → a dashed primary-tinted drop overlay confirms
|
||||
the target; files land at the cursor.
|
||||
- **Paste** — an image from the clipboard drops instantly; a **URL** becomes a link card
|
||||
(hostname title today; rich OG preview is a backlog item).
|
||||
- **Upload** (toolbar `↑` / empty-state CTA) → the image appears immediately from a blob, then
|
||||
uploads in the background to capture a durable media token (so it becomes reference-capable).
|
||||
- **From library** → the shared gallery picker (Artcraft's own `GalleryModal`), so muscle memory
|
||||
transfers from the rest of the app. Not signed in? The picker now shows a **Log in** button
|
||||
(not a dead "Retry"), routed per-platform via the adapter.
|
||||
- **Note / Color** → a sticky note or an OS-picked swatch, one click each.
|
||||
|
||||
*Rationale:* friction is the enemy of collection. The empty state is an **editorial hero**
|
||||
("Start collecting ideas") that doubles as the drop affordance — it teaches the gestures while
|
||||
looking like a finished product, not a blank slate.
|
||||
|
||||
### 4.2 Organize — keep a growing board legible
|
||||
- **Density** — a segmented control (Compact / Cozy / Comfortable) re-flows the masonry; the
|
||||
layout is target-width-driven, so it adapts to any container (Savee's adjustable-density bar).
|
||||
- **Search + tag chips** — text query over captions/tags/links; click a chip to filter.
|
||||
- **Multi-select** — click, shift/⌘-click, or marquee-drag on bare canvas; a floating glass
|
||||
**selection bar** rises from the bottom with the count + actions.
|
||||
|
||||
### 4.3 Triage — sift keepers from a batch, fast
|
||||
Lightroom is the gold standard for high-volume culling, so we borrow its muscle memory:
|
||||
|
||||
- With items selected, **`1`–`5`** rate, **`0`** clears, **`P`** picks (5 stars). A rating badge
|
||||
persists on the tile.
|
||||
- The toolbar **star filter** cycles "All → 1+ … → 5+ → All" to winnow to keepers.
|
||||
- The selection bar surfaces the shortcuts inline (`1–5 rate · 0 clear`) so they're *discoverable*,
|
||||
not hidden lore.
|
||||
- **Delete is deliberate** — `Delete`/`Backspace` only (we removed `x` as a delete alias: it sits
|
||||
in the number row and the board has no undo, so a stray press must not destroy work).
|
||||
|
||||
*Rationale:* triage is keyboard-first because it's repetitive and high-volume; every key saves a
|
||||
trip to the mouse. Destructive actions get friction; constructive ones get none.
|
||||
|
||||
### 4.4 The board → generate loop — the differentiator
|
||||
This is the move no generic moodboard can make.
|
||||
|
||||
- **"Use as reference"** — on a card's hover rail, in the selection bar, or in the inspector.
|
||||
Selected board images are pushed into the image-generation prompt as style references (via their
|
||||
media tokens) and the user lands on the Create-Image surface, refs pre-filled. Krea/Firefly
|
||||
proved board→generate; Artcraft closes it natively.
|
||||
- The seam is the **adapter** (§6): desktop seeds `usePromptImageStore` + switches to the Image
|
||||
tab; web seeds `useCreateImageStore` + navigates to `/create-image`. Same gesture, same result,
|
||||
two platforms.
|
||||
|
||||
*Rationale:* the highest-leverage feature is the one that turns a passive collection into momentum.
|
||||
It's always one click from any view, and it never blocks — it sends and gets out of the way.
|
||||
|
||||
### 4.5 Inspect — the lightbox as a workbench
|
||||
Double-click or the rail's expand icon opens a full-screen **inspector**:
|
||||
|
||||
- Large media on a glass-blurred backdrop (blur is fine here — it's a fixed overlay), a
|
||||
double-bezel side panel, `←/→` to walk the board, `Esc` to close, background scroll locked,
|
||||
`role="dialog"`.
|
||||
- The panel is a small workbench: **rating** stars, a **tag editor**, an **extracted palette**
|
||||
(client-side color quantization — click a swatch to copy, or "add swatches to board"), and
|
||||
**Use as reference**. The palette is keyed on the image src, so editing tags never re-decodes
|
||||
the image or flickers.
|
||||
|
||||
### 4.6 Canvas — think spatially
|
||||
The freeform Konva stage for arranging, comparing, and planning:
|
||||
|
||||
- Pan/zoom, marquee + lasso select, 8-handle transform, grouping, undo/redo, a shortcut cheatsheet,
|
||||
and auto-layout helpers (pack / fit-to-grid / cluster-by-proximity) that already exist and want
|
||||
better surfacing. Alignment/snapping + a background grid are the next polish (backlog).
|
||||
- Drop a generation onto the canvas, set images side by side, group a coherent set, then send that
|
||||
set to generation. It's the planning room; the grid is the library.
|
||||
|
||||
### 4.7 Present — review & share
|
||||
A distraction-free, full-bleed deck: one item at a time, `←/→/space` to navigate, `Esc` to exit,
|
||||
launched from the view switch as a transient overlay (never persisted, so a reload never reopens
|
||||
into a slideshow). For client/team review and screen-sharing.
|
||||
|
||||
---
|
||||
|
||||
## 5. Micro-UX niceties (the details that read as "expensive")
|
||||
|
||||
- **Hover action rail** — a glass pill on each card fades *up* on hover (translate + opacity), with
|
||||
Open / Use-as-reference / Delete. Buttons stop propagation so they never double as a select.
|
||||
- **Card states choreographed** — hover lifts the shell 2px and deepens the shadow; selected swaps
|
||||
to a 2px brand ring; drag scales to 1.03; drop lands with a snap-overshoot.
|
||||
- **Selection check + rating** — solid scrims (not blur) inside the scrolling grid for legibility
|
||||
without GPU cost.
|
||||
- **Focus-visible rings everywhere** — keyboard users see where they are, not just hover users.
|
||||
- **The Login button** — the gallery picker now invites action ("Log in to browse your library" +
|
||||
a real primary button) instead of a confusing "Retry" link.
|
||||
- **Stagger on entry** — grid items and presentation reveals come in with a short staggered
|
||||
fade-up, so the board feels assembled, not dumped.
|
||||
|
||||
---
|
||||
|
||||
## 6. Cross-platform via the adapter
|
||||
|
||||
One library, two hosts, identical experience. The lib owns everything platform-agnostic; each app
|
||||
injects a `MoodboardAdapter` for its three seams:
|
||||
|
||||
```ts
|
||||
interface MoodboardAdapter {
|
||||
uploadImage?: (file) => Promise<mediaToken | null>; // capture a durable token
|
||||
sendToGeneration: (refs) => void; // board → prompt
|
||||
renderLibraryPicker?: (props) => ReactNode; // the gallery picker (render-prop)
|
||||
}
|
||||
```
|
||||
|
||||
- **Desktop (Tauri):** upload via `@storyteller/api`, picker via `GalleryModal`, send → prompt
|
||||
store + tab switch, login → the login modal.
|
||||
- **Web (artcraft-webapp):** upload via the webapp uploader, picker via the same `GalleryModal`,
|
||||
send → create-image store + `/create-image`, login → navigate `/login`.
|
||||
|
||||
*Rationale:* the adapter is the seam where platform reality lives, so the *experience* stays a
|
||||
single, consistent design across surfaces — and the same polish ships everywhere at once.
|
||||
|
||||
---
|
||||
|
||||
## 7. Motion & performance contract
|
||||
|
||||
- **Curves:** spring (`cubic-bezier(0.32,0.72,0,1)`) for settle/zoom; smooth
|
||||
(`cubic-bezier(0.4,0,0.2,1)`, ~200ms) for hover/selection; snap with slight overshoot on drop.
|
||||
- **Only `transform`/`opacity`/`filter`** animate — never layout properties.
|
||||
- **`backdrop-blur` only on fixed chrome** (toolbar, view switch, inspector/present backdrops);
|
||||
the virtualized scrolling grid uses solid scrims so scrolling stays at 60fps.
|
||||
- **Virtualize aggressively** — deterministic masonry from item aspects means only viewport-visible
|
||||
cards mount; the grid holds thousands of items without lag (the competitor failure mode).
|
||||
- **Respect `prefers-reduced-motion`** — springs become instant, fades remain.
|
||||
|
||||
---
|
||||
|
||||
## 8. Accessibility & polish checklist
|
||||
|
||||
- [x] Theme-correct: consumes `--st-*` only, so light/dark/gray/black/aurora themes work for free.
|
||||
- [x] Focus-visible rings on interactive controls; inspector is a labelled `role="dialog"` with
|
||||
scroll-lock and keyboard nav.
|
||||
- [x] Color is never the only signal (drop state pairs a tint with a dashed outline + icon).
|
||||
- [x] Destructive delete is deliberate (no single-printable-key delete); no silent data loss.
|
||||
- [ ] Cards are not yet keyboard-focusable/selectable (pointer-first) — a known a11y gap.
|
||||
- [ ] Toolbar/inspector don't yet collapse gracefully below ~768px (desktop-first) — responsive
|
||||
pass pending.
|
||||
|
||||
---
|
||||
|
||||
## 9. Where the design goes next
|
||||
|
||||
The research-backed backlog (`FEATURE_BACKLOG.md`) extends this flow without changing its spine —
|
||||
client-side ML (in-browser captioning, auto-tagging, find-similar, palette/color search) makes the
|
||||
board *understand itself*; canvas snapping/alignment and a region→palette tool deepen planning; and
|
||||
the generate-into-a-board / remix loop tightens the differentiator. Collaboration & sharing are
|
||||
deferred behind a flag (they need the backend). The throughline stays constant: **collect with no
|
||||
friction, organize effortlessly, and turn the board into the next generation — calmly.**
|
||||
@@ -0,0 +1,101 @@
|
||||
# Moodboard — UX Change Plan
|
||||
|
||||
> Derived from the recommendations in `DESIGN_AND_FLOW.md` (the high-end design/UX doc), checked
|
||||
> against the **actual implementation**. This is a *design-and-UX polish* plan — closing the gap
|
||||
> between the experience the doc describes and what's shipped. (AI/ML features live separately in
|
||||
> `FEATURE_BACKLOG.md`; backend-dependent work like sharing stays deferred.)
|
||||
|
||||
## Audit — doc says vs. reality
|
||||
|
||||
| Recommendation (DESIGN_AND_FLOW.md) | Status today | Gap |
|
||||
|---|---|---|
|
||||
| §5 "Stagger on entry — items fade-up assembled" | **Not built** — grid renders the virtualized slice with no entry motion | Yes |
|
||||
| §8 "Focus-visible rings on interactive controls" | **Partial** — only card action-rail + toolbar add-buttons | Yes |
|
||||
| §8 "Cards not yet keyboard-focusable/selectable" | **Not built** — pointer-only | Yes (acknowledged) |
|
||||
| §8 "Toolbar/inspector don't collapse < ~768px" | **Not built** — fixed horizontal island; inspector is a fixed 2-pane | Yes (acknowledged) |
|
||||
| §4.2 "Sections" | Model-ready, **no UI** | Yes |
|
||||
| §4.1 "Paste URL → link card" | Hostname-only, **no OG preview** | Yes (needs proxy) |
|
||||
| §4.6 "Auto-layout helpers want better surfacing" | Helpers exist (`pack`/`fitToGrid`/`cluster`), **under-exposed** | Yes |
|
||||
| §5 double-bezel, glass islands, spring motion, hover rail, card states, scrims, login button, triage hint, inspector dialog | **Built** ✓ | — |
|
||||
|
||||
Everything in the bottom row is genuinely shipped; the rows above are the work.
|
||||
|
||||
---
|
||||
|
||||
## The plan (prioritized by impact ÷ effort)
|
||||
|
||||
Each item: **what + UX**, **why** (design rationale), **effort (S/M/L)**, **acceptance**.
|
||||
|
||||
### P1 — Motion & accessibility foundation (do first)
|
||||
|
||||
**1.1 Entry motion — staggered fade-up on the grid.** *(the doc claims it; it isn't there)*
|
||||
- **What:** as cards mount into the viewport, fade-up from `translate-y-2 opacity-0 blur-[2px]` → rest over ~320ms on `--mb-ease-smooth`, with an index-based delay capped at ~12 items so a full screen feels *assembled*, not dumped. Must be virtualization-safe: animate only on first mount, key the animation so re-renders/scroll don't re-trigger; respect `prefers-reduced-motion`.
|
||||
- **Why:** the single biggest "expensive feel" lever the doc names; the board currently pops in flatly.
|
||||
- **Effort:** **S–M.** **Acceptance:** opening a board / switching density plays one clean stagger; scrolling does **not** re-animate; reduced-motion shows instant.
|
||||
|
||||
**1.2 Keyboard-navigable cards.**
|
||||
- **What:** card root becomes focusable (`tabIndex=0`, `role="button"`, `aria-label`); `Enter`/`Space` opens the inspector, `X`/`Delete` deletes the focused card (with the same deliberate-delete rule), and arrow keys move focus across the masonry (geometry-aware: left/right within a row, up/down to the nearest card). Selection follows focus with `Space`; `Shift`+arrows extends.
|
||||
- **Why:** the doc's headline a11y gap; also a power-user speed win (no mouse round-trips during triage).
|
||||
- **Effort:** **M.** **Acceptance:** a keyboard-only user can tab into the grid, move with arrows, open/rate/delete, and select — with a visible focus ring on each card.
|
||||
|
||||
**1.3 Complete focus-visible coverage.**
|
||||
- **What:** add focus-visible rings to every remaining interactive control — `SelectionBar` buttons, `SmartSearchBar` input + clear + tag chips, density segmented control, rating-filter toggle, the view switch, and all `ItemInspector` controls (rating stars, tag input, palette swatches, actions).
|
||||
- **Why:** §8 says "everywhere"; today it's two components.
|
||||
- **Effort:** **S.** **Acceptance:** tabbing through the whole moodboard always shows where focus is.
|
||||
|
||||
### P2 — Responsive & micro-UX completeness
|
||||
|
||||
**2.1 Responsive toolbar.**
|
||||
- **What:** below `md`, the floating island stops being one wide row — it becomes a compact glass bar: board name + a `+`-menu (collapsing the 4 add-buttons), density, rating, and a search **icon** that expands into a sheet; the whole thing `overflow-x-auto` as a last resort so it never breaks the layout.
|
||||
- **Why:** §8 gap; the webapp can be viewed narrow, and today the island overflows.
|
||||
- **Effort:** **M.** **Acceptance:** at 375px wide the toolbar is usable and doesn't clip; at ≥`md` it's unchanged.
|
||||
|
||||
**2.2 Responsive inspector.**
|
||||
- **What:** below `md`, the inspector stacks (media on top, the panel as a bottom sheet) instead of the fixed media-left / 320px-panel split; `h-[100dvh]`; nav arrows move to the corners.
|
||||
- **Why:** §8 gap; the current side-by-side is unusable on phones.
|
||||
- **Effort:** **S–M.** **Acceptance:** inspector is fully usable at 375px.
|
||||
|
||||
**2.3 Sections UI.**
|
||||
- **What:** group items into named, collapsible sections in the grid (the model already supports `sections` + `sectionId`). A section header row with a name (inline-rename), count, collapse toggle, and "new section"; drag a selection into a section; an "Ungrouped" default lane.
|
||||
- **Why:** §4.2 calls it a near-term add; big boards spiral without it (Pinterest Sections / MJ folder-groups).
|
||||
- **Effort:** **M.** **Acceptance:** create/rename/collapse sections; assign items; persists.
|
||||
|
||||
**2.4 Surface canvas auto-layout helpers.**
|
||||
- **What:** a one-click **Tidy / Arrange** menu on the canvas toolbar exposing the already-built `pack` (justified rows), `fitToGrid`, and `cluster-by-proximity`, plus "align selection."
|
||||
- **Why:** §4.6 — the logic exists but is hidden; cheap, high-delight.
|
||||
- **Effort:** **S.** **Acceptance:** the three layouts run from a visible menu; undoable.
|
||||
|
||||
**2.5 "Button-in-button" primary CTAs.**
|
||||
- **What:** apply the doc's nested trailing-icon pattern (already in the empty-state CTA) to the primary actions — "Use as reference" and the inspector's primary button — with the magnetic hover physics (icon translates diagonally + scales on hover, button `active:scale-[0.98]`).
|
||||
- **Why:** §5 haptic consistency; makes the differentiator action feel premium.
|
||||
- **Effort:** **S.** **Acceptance:** primary CTAs share one polished button anatomy.
|
||||
|
||||
### P3 — Larger canvas & capture polish
|
||||
|
||||
**2.6 Canvas alignment + snapping + background grid.**
|
||||
- **What:** snap a dragged node to other nodes' edges/centers with guide lines (DIY on Konva `dragmove`), plus a toggleable background grid + snap-to-grid.
|
||||
- **Why:** §4.6/§9 — makes the freeform canvas feel professional (PureRef 2.1).
|
||||
- **Effort:** **M.** (also tracked in `FEATURE_BACKLOG.md` 3.1/3.2.) **Acceptance:** dragging shows guides + snaps; grid toggles.
|
||||
|
||||
**2.7 Rich URL preview (link cards).**
|
||||
- **What:** paste a URL → fetch OG title + image for a real preview card (vs hostname today).
|
||||
- **Why:** §4.1 — link cards look unfinished without a thumbnail.
|
||||
- **Effort:** **S (client) / M (proxy).** **Dependency:** OG fetch needs a CORS unfurl proxy → **flag as backend-dependent / defer the image; ship title now.**
|
||||
|
||||
**2.8 Drag-from-library onto the board.**
|
||||
- **What:** let a gallery item be dragged directly onto the grid/canvas (not only picked via the modal), reusing Artcraft's existing gallery-drag event.
|
||||
- **Why:** §4.1 — the lowest-friction capture path for already-owned media.
|
||||
- **Effort:** **M.** **Acceptance:** drag a library thumbnail onto the board → it lands as a tokened item.
|
||||
|
||||
---
|
||||
|
||||
## Sequencing
|
||||
|
||||
- **Sprint 1 (feel + a11y):** 1.1 entry motion, 1.2 keyboard cards, 1.3 focus coverage. *One cohesive "the board feels finished and is fully keyboard-able" release.*
|
||||
- **Sprint 2 (responsive + organize):** 2.1 toolbar, 2.2 inspector, 2.3 sections, 2.5 CTAs.
|
||||
- **Sprint 3 (canvas + capture):** 2.4 surface auto-layout, 2.6 snapping/grid, 2.8 drag-from-library, 2.7 link previews (title now, image when a proxy exists).
|
||||
|
||||
## Recommended first move
|
||||
**Sprint 1** — it's the highest "expensive feel ÷ effort" and closes the doc's two explicit a11y
|
||||
gaps + the one motion claim that isn't yet true. All client-side, no backend, fits the shared lib
|
||||
cleanly, and verifiable headlessly (focus order, reduced-motion, DOM-on-mount).
|
||||
@@ -18,6 +18,7 @@ import CreateVFX from "../pages/create-vfx";
|
||||
import PageScene from "../pages/pagescene";
|
||||
import PageDraw from "../pages/pagedraw";
|
||||
import VideoEditorPage from "../pages/video-editor";
|
||||
import MoodboardPage from "../pages/moodboard";
|
||||
import Pricing from "../pages/pricing";
|
||||
import Support from "../pages/support/support";
|
||||
import Login from "../pages/login";
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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]);
|
||||
};
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
import { useEffect } from "react";
|
||||
import { useMoodboardStore } from "../MoodboardStore";
|
||||
|
||||
const isEditableTarget = (target: EventTarget | null): boolean => {
|
||||
const el = target as HTMLElement | null;
|
||||
if (!el) return false;
|
||||
if (/input|textarea/i.test(el.tagName)) return true;
|
||||
if (el.isContentEditable) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
// Figma-style shortcuts that aren't already wired elsewhere.
|
||||
// Cmd+Z / Cmd+Shift+Z live in useUndoRedo; Cmd+0 + Space-pan live in
|
||||
// useViewportControls; Delete / Escape live in Moodboard.tsx.
|
||||
export const useKeyboardShortcuts = (active: boolean) => {
|
||||
const setTool = useMoodboardStore((s) => s.setTool);
|
||||
const selectAll = useMoodboardStore((s) => s.selectAll);
|
||||
const group = useMoodboardStore((s) => s.group);
|
||||
const ungroup = useMoodboardStore((s) => s.ungroup);
|
||||
const fitToContent = useMoodboardStore((s) => s.fitToContent);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return undefined;
|
||||
const isMac = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
|
||||
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (isEditableTarget(e.target)) return;
|
||||
// Skip while typing in the text-edit overlay (covered by isEditableTarget
|
||||
// too, but belt-and-suspenders in case the overlay loses target focus).
|
||||
if (useMoodboardStore.getState().transient.editingTextId) return;
|
||||
|
||||
const mod = isMac ? e.metaKey : e.ctrlKey;
|
||||
const k = e.key.toLowerCase();
|
||||
|
||||
// Modifier combos
|
||||
if (mod && !e.shiftKey && k === "a") {
|
||||
e.preventDefault();
|
||||
selectAll();
|
||||
return;
|
||||
}
|
||||
if (mod && !e.shiftKey && k === "g") {
|
||||
e.preventDefault();
|
||||
group();
|
||||
return;
|
||||
}
|
||||
if (mod && e.shiftKey && k === "g") {
|
||||
e.preventDefault();
|
||||
ungroup();
|
||||
return;
|
||||
}
|
||||
|
||||
// Shift-only combos
|
||||
if (e.shiftKey && !mod && !e.altKey && k === "1") {
|
||||
e.preventDefault();
|
||||
fitToContent();
|
||||
return;
|
||||
}
|
||||
|
||||
// Plain (no modifiers) tool switches — skip if any modifier is held
|
||||
// so Cmd+V (paste) etc. never triggers the Text tool.
|
||||
if (mod || e.shiftKey || e.altKey) return;
|
||||
if (k === "v") {
|
||||
e.preventDefault();
|
||||
setTool("select");
|
||||
} else if (k === "l") {
|
||||
e.preventDefault();
|
||||
setTool("lasso");
|
||||
} else if (k === "t") {
|
||||
e.preventDefault();
|
||||
setTool("text");
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handler);
|
||||
return () => document.removeEventListener("keydown", handler);
|
||||
}, [active, setTool, selectAll, group, ungroup, fitToContent]);
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useMemo } from "react";
|
||||
import Konva from "konva";
|
||||
import { useMoodboardStore } from "../MoodboardStore";
|
||||
import { computeSnap, SnapBox } from "../layout/snapping";
|
||||
|
||||
// Screen-space snap radius; divided by zoom so the feel is constant at any zoom.
|
||||
const SNAP_SCREEN_PX = 6;
|
||||
|
||||
export interface NodeDragHandlers {
|
||||
onDragStart: () => void;
|
||||
onDragMove: (e: Konva.KonvaEventObject<DragEvent>) => void;
|
||||
onDragEnd: (e: Konva.KonvaEventObject<DragEvent>) => void;
|
||||
}
|
||||
|
||||
// Shared drag wiring for every canvas node. onDragStart snapshots history;
|
||||
// onDragMove snaps the live position to nearby node edges/centers + the grid and
|
||||
// publishes alignment guides; onDragEnd commits the final position and clears
|
||||
// the guides. Reads the store imperatively inside handlers so the hook itself
|
||||
// doesn't re-render on every node move.
|
||||
export const useNodeDrag = (nodeId: string): NodeDragHandlers => {
|
||||
const updateNode = useMoodboardStore((s) => s.updateNode);
|
||||
const pushHistory = useMoodboardStore((s) => s.pushHistory);
|
||||
const setGuides = useMoodboardStore((s) => s.setGuides);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
onDragStart: () => pushHistory(),
|
||||
onDragMove: (e) => {
|
||||
const target = e.target;
|
||||
const state = useMoodboardStore.getState();
|
||||
if (!state.snapEnabled) return;
|
||||
const node = state.nodes[nodeId];
|
||||
if (!node) return;
|
||||
|
||||
const box: SnapBox = {
|
||||
x: target.x(),
|
||||
y: target.y(),
|
||||
width: node.width,
|
||||
height: node.height,
|
||||
};
|
||||
const others: SnapBox[] = [];
|
||||
for (const id of state.rootOrder) {
|
||||
if (id === nodeId) continue;
|
||||
const n = state.nodes[id];
|
||||
if (n) others.push({ x: n.x, y: n.y, width: n.width, height: n.height });
|
||||
}
|
||||
|
||||
const threshold = SNAP_SCREEN_PX / state.viewport.zoom;
|
||||
const result = computeSnap(box, others, threshold, state.gridSpacing);
|
||||
target.x(result.x);
|
||||
target.y(result.y);
|
||||
setGuides(result.guides);
|
||||
},
|
||||
onDragEnd: (e) => {
|
||||
updateNode(nodeId, { x: e.target.x(), y: e.target.y() });
|
||||
setGuides([]);
|
||||
},
|
||||
}),
|
||||
[nodeId, updateNode, pushHistory, setGuides],
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useEffect } from "react";
|
||||
import Konva from "konva";
|
||||
import { useMoodboardStore } from "../MoodboardStore";
|
||||
import type { MoodboardAdapter } from "../../adapter";
|
||||
|
||||
// Loads natural dimensions for an image URL.
|
||||
const measure = (url: string): Promise<{ w: number; h: number }> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const i = new window.Image();
|
||||
i.onload = () => resolve({ w: i.naturalWidth, h: i.naturalHeight });
|
||||
i.onerror = reject;
|
||||
i.src = url;
|
||||
});
|
||||
|
||||
// Listens for paste events while the moodboard page is mounted. For each
|
||||
// image entry on the clipboard, immediately drops a blob-URL image onto the
|
||||
// canvas at the viewport center, then asynchronously uploads it to the
|
||||
// library so it lives in the user's gallery.
|
||||
export const usePasteHandler = (
|
||||
adapter: MoodboardAdapter,
|
||||
active: boolean,
|
||||
stageRef: React.RefObject<Konva.Stage | null>,
|
||||
) => {
|
||||
const addImage = useMoodboardStore((s) => s.addImage);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return undefined;
|
||||
|
||||
const handler = async (e: ClipboardEvent) => {
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (target && /input|textarea/i.test(target.tagName)) return;
|
||||
if (target && target.isContentEditable) return;
|
||||
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
const fileItems: File[] = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const it = items[i];
|
||||
if (it.kind === "file" && it.type.startsWith("image/")) {
|
||||
const f = it.getAsFile();
|
||||
if (f) fileItems.push(f);
|
||||
}
|
||||
}
|
||||
if (fileItems.length === 0) return;
|
||||
e.preventDefault();
|
||||
|
||||
const stage = stageRef.current;
|
||||
const center = stage
|
||||
? { x: stage.width() / 2, y: stage.height() / 2 }
|
||||
: { x: 400, y: 400 };
|
||||
|
||||
for (const file of fileItems) {
|
||||
const blobUrl = URL.createObjectURL(file);
|
||||
try {
|
||||
const dims = await measure(blobUrl);
|
||||
addImage(blobUrl, center, dims.w, dims.h, null);
|
||||
} catch (err) {
|
||||
console.error("[Moodboard] paste measure failed", err);
|
||||
addImage(blobUrl, center, 320, 320, null);
|
||||
}
|
||||
// Fire-and-forget: also push to the platform's media library so the
|
||||
// image persists in the user's gallery.
|
||||
adapter.uploadImage?.(file).catch((err) => {
|
||||
console.error("[Moodboard] background upload failed", err);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("paste", handler);
|
||||
return () => window.removeEventListener("paste", handler);
|
||||
}, [adapter, active, addImage, stageRef]);
|
||||
};
|
||||
@@ -0,0 +1,141 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import Konva from "konva";
|
||||
import { useMoodboardStore } from "../MoodboardStore";
|
||||
import { Vec2 } from "../types";
|
||||
import { stagePointerPos } from "./useStagePointer";
|
||||
import { pointInPolygon } from "../layout/pointInPolygon";
|
||||
import { polygonIntersectsRect } from "../layout/segmentsIntersect";
|
||||
import { rectCorners, rectFromPoints, rectsIntersect } from "../layout/geometry";
|
||||
|
||||
const MIN_DRAG = 4; // screen px before a press becomes a drag
|
||||
|
||||
interface DragState {
|
||||
origin: Vec2;
|
||||
path: Vec2[];
|
||||
active: boolean;
|
||||
mode: "rect" | "lasso" | null;
|
||||
}
|
||||
|
||||
// Selection state machine for rectangle marquee (Select tool) and free-form
|
||||
// lasso (Lasso tool). Pan gestures (middle-mouse, spacebar+left) are owned
|
||||
// by `useViewportControls` and stop the event at the capture phase, so
|
||||
// they never reach Konva's mousedown and therefore never reach this hook.
|
||||
export const useSelection = (
|
||||
stageRef: React.RefObject<Konva.Stage | null>,
|
||||
) => {
|
||||
const drag = useRef<DragState>({
|
||||
origin: { x: 0, y: 0 },
|
||||
path: [],
|
||||
active: false,
|
||||
mode: null,
|
||||
});
|
||||
|
||||
const tool = useMoodboardStore((s) => s.tool);
|
||||
const setMarquee = useMoodboardStore((s) => s.setMarquee);
|
||||
const setLassoPath = useMoodboardStore((s) => s.setLassoPath);
|
||||
const setSelection = useMoodboardStore((s) => s.setSelection);
|
||||
|
||||
useEffect(() => {
|
||||
if (tool !== "select" && tool !== "lasso") return;
|
||||
const stage = stageRef.current;
|
||||
if (!stage) return;
|
||||
|
||||
const handleMouseDown = (e: Konva.KonvaEventObject<MouseEvent>) => {
|
||||
// Only on bare canvas — clicks on nodes are handled by the node's
|
||||
// own onMouseDown for single-select.
|
||||
if (e.target !== stage) return;
|
||||
// Primary button only; middle-mouse is the pan hook's domain.
|
||||
if (e.evt.button !== 0) return;
|
||||
const pos = stagePointerPos(stage);
|
||||
if (!pos) return;
|
||||
drag.current = {
|
||||
origin: pos,
|
||||
path: tool === "lasso" ? [pos] : [],
|
||||
active: true,
|
||||
mode: tool === "lasso" ? "lasso" : "rect",
|
||||
};
|
||||
};
|
||||
|
||||
const handleMouseMove = () => {
|
||||
if (!drag.current.active) return;
|
||||
const pos = stagePointerPos(stage);
|
||||
if (!pos) return;
|
||||
if (drag.current.mode === "rect") {
|
||||
// Suppress marquee until the drag exceeds a threshold so bare clicks
|
||||
// don't leave a zero-area rect behind.
|
||||
const dx = pos.x - drag.current.origin.x;
|
||||
const dy = pos.y - drag.current.origin.y;
|
||||
if (Math.abs(dx) < MIN_DRAG && Math.abs(dy) < MIN_DRAG) return;
|
||||
setMarquee(rectFromPoints(drag.current.origin, pos));
|
||||
} else if (drag.current.mode === "lasso") {
|
||||
drag.current.path.push(pos);
|
||||
// Slice copy so subscribers see a new identity.
|
||||
setLassoPath([...drag.current.path]);
|
||||
}
|
||||
};
|
||||
|
||||
const commitSelection = () => {
|
||||
const { nodes, rootOrder } = useMoodboardStore.getState();
|
||||
const candidates = rootOrder
|
||||
.map((id) => nodes[id])
|
||||
.filter((n): n is NonNullable<typeof n> => Boolean(n));
|
||||
|
||||
if (drag.current.mode === "rect") {
|
||||
const rect = useMoodboardStore.getState().transient.marquee;
|
||||
if (!rect || (rect.width < MIN_DRAG && rect.height < MIN_DRAG)) {
|
||||
setSelection([]);
|
||||
} else {
|
||||
const hit = candidates
|
||||
.filter((n) =>
|
||||
rectsIntersect(
|
||||
{ x: n.x, y: n.y, width: n.width, height: n.height },
|
||||
rect,
|
||||
),
|
||||
)
|
||||
.map((n) => n.id);
|
||||
setSelection(hit);
|
||||
}
|
||||
setMarquee(null);
|
||||
} else if (drag.current.mode === "lasso") {
|
||||
const path = drag.current.path;
|
||||
if (path.length < 3) {
|
||||
setSelection([]);
|
||||
} else {
|
||||
const closed = [...path, path[0]];
|
||||
const hit = candidates
|
||||
.filter((n) => {
|
||||
const corners = rectCorners(n);
|
||||
if (corners.some((c) => pointInPolygon(c, closed))) return true;
|
||||
if (polygonIntersectsRect(closed, n)) return true;
|
||||
return false;
|
||||
})
|
||||
.map((n) => n.id);
|
||||
setSelection(hit);
|
||||
}
|
||||
setLassoPath(null);
|
||||
}
|
||||
drag.current.active = false;
|
||||
drag.current.mode = null;
|
||||
drag.current.path = [];
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (!drag.current.active) return;
|
||||
commitSelection();
|
||||
};
|
||||
|
||||
stage.on("mousedown.moodboardSelection", handleMouseDown);
|
||||
stage.on("mousemove.moodboardSelection", handleMouseMove);
|
||||
stage.on("mouseup.moodboardSelection", handleMouseUp);
|
||||
const onWindowUp = () => {
|
||||
if (drag.current.active) commitSelection();
|
||||
};
|
||||
window.addEventListener("mouseup", onWindowUp);
|
||||
return () => {
|
||||
stage.off("mousedown.moodboardSelection");
|
||||
stage.off("mousemove.moodboardSelection");
|
||||
stage.off("mouseup.moodboardSelection");
|
||||
window.removeEventListener("mouseup", onWindowUp);
|
||||
};
|
||||
}, [stageRef, tool, setMarquee, setLassoPath, setSelection]);
|
||||
};
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const HOLD_MS = 3000;
|
||||
|
||||
// Shows the cheatsheet when the user holds Ctrl (or Cmd on Mac) alone for
|
||||
// HOLD_MS with no other key pressed. Any other keydown cancels the timer,
|
||||
// so a real `Ctrl+C` / `Cmd+X` never trips it. Releasing the modifier hides
|
||||
// the cheatsheet immediately.
|
||||
export const useShortcutCheatsheet = (): boolean => {
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const isMac = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
|
||||
const modKey = isMac ? "Meta" : "Control";
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let modHeld = false;
|
||||
|
||||
const clearTimer = () => {
|
||||
if (timer !== null) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const hide = () => {
|
||||
setVisible((v) => (v ? false : v));
|
||||
};
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === modKey) {
|
||||
// Ignore auto-repeat keydown while already held.
|
||||
if (modHeld) return;
|
||||
modHeld = true;
|
||||
clearTimer();
|
||||
timer = setTimeout(() => {
|
||||
timer = null;
|
||||
setVisible(true);
|
||||
}, HOLD_MS);
|
||||
return;
|
||||
}
|
||||
// Any non-modifier key press while the modifier is held cancels the
|
||||
// planned cheatsheet — the user is running a real combo.
|
||||
clearTimer();
|
||||
hide();
|
||||
};
|
||||
|
||||
const onKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.key === modKey) {
|
||||
modHeld = false;
|
||||
clearTimer();
|
||||
hide();
|
||||
}
|
||||
};
|
||||
|
||||
// Losing focus while holding the key should also cancel.
|
||||
const onBlur = () => {
|
||||
modHeld = false;
|
||||
clearTimer();
|
||||
hide();
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
document.addEventListener("keyup", onKeyUp);
|
||||
window.addEventListener("blur", onBlur);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
document.removeEventListener("keyup", onKeyUp);
|
||||
window.removeEventListener("blur", onBlur);
|
||||
clearTimer();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return visible;
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import Konva from "konva";
|
||||
import { Vec2 } from "../types";
|
||||
|
||||
// Converts a raw KonvaEventObject pointer position to stage-local coordinates,
|
||||
// accounting for stage offset and scale. Phase 1 has no zoom/pan so this is a
|
||||
// thin wrapper, but encapsulating it keeps interaction hooks consistent.
|
||||
export const stagePointerPos = (
|
||||
stage: Konva.Stage | null,
|
||||
): Vec2 | null => {
|
||||
if (!stage) return null;
|
||||
const pointer = stage.getPointerPosition();
|
||||
if (!pointer) return null;
|
||||
return {
|
||||
x: (pointer.x - stage.x()) / stage.scaleX(),
|
||||
y: (pointer.y - stage.y()) / stage.scaleY(),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import Konva from "konva";
|
||||
import { useMoodboardStore } from "../MoodboardStore";
|
||||
import { MoodboardNode } from "../types";
|
||||
|
||||
interface NodePatch {
|
||||
id: string;
|
||||
patch: Partial<MoodboardNode>;
|
||||
}
|
||||
|
||||
// Owns a single Konva.Transformer ref. Re-attaches its `nodes()` whenever
|
||||
// the selection OR the root structure changes (e.g. group/ungroup
|
||||
// reparented the previously-selected ids). On transformend, bakes
|
||||
// scaleX/scaleY into width/height — and for groups, propagates the scale
|
||||
// into each child's position/size so the resize sticks (Figma/Excalidraw
|
||||
// behavior) instead of snapping back when the Group's scale resets to 1.
|
||||
export const useTransformer = (stageRef: React.RefObject<Konva.Stage | null>) => {
|
||||
const transformerRef = useRef<Konva.Transformer | null>(null);
|
||||
const selectedIds = useMoodboardStore((s) => s.selectedIds);
|
||||
// rootOrder is included so a re-attach happens after group()/ungroup()
|
||||
// reparents Konva nodes — without it, findOne can return stale results.
|
||||
const rootOrder = useMoodboardStore((s) => s.rootOrder);
|
||||
const editingTextId = useMoodboardStore((s) => s.transient.editingTextId);
|
||||
|
||||
useEffect(() => {
|
||||
const stage = stageRef.current;
|
||||
const transformer = transformerRef.current;
|
||||
if (!stage || !transformer) return;
|
||||
// Defer one frame so any pending Konva re-render commits before we
|
||||
// findOne — otherwise the new Group node may not exist yet.
|
||||
const raf = requestAnimationFrame(() => {
|
||||
const selectedNodes: Konva.Node[] = [];
|
||||
selectedIds.forEach((id) => {
|
||||
// Plain `#id` lookup — Konva's selector parser strips all spaces, so
|
||||
// a CSS.escape'd id (e.g. `\38 abc...` for a UUID starting with `8`)
|
||||
// ends up being compared as `\38abc...` and never matches. Our ids
|
||||
// are UUIDs (hex + dashes), which are already safe selectors.
|
||||
const found = stage.findOne(`#${id}`);
|
||||
if (found) selectedNodes.push(found);
|
||||
});
|
||||
transformer.nodes(selectedNodes);
|
||||
transformer.forceUpdate();
|
||||
transformer.getLayer()?.batchDraw();
|
||||
});
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [selectedIds, rootOrder, stageRef]);
|
||||
|
||||
useEffect(() => {
|
||||
const transformer = transformerRef.current;
|
||||
if (!transformer) return;
|
||||
|
||||
const handleTransformEnd = () => {
|
||||
const store = useMoodboardStore.getState();
|
||||
store.pushHistory();
|
||||
|
||||
const patches: NodePatch[] = [];
|
||||
|
||||
transformer.nodes().forEach((node) => {
|
||||
const id = node.id();
|
||||
if (!id) return;
|
||||
const scaleX = node.scaleX();
|
||||
const scaleY = node.scaleY();
|
||||
const storeNode = store.nodes[id];
|
||||
if (!storeNode) return;
|
||||
|
||||
// Groups: bake the scale into each child's position + size so the
|
||||
// resize survives the Group's own scale reset. Konva's <Group>
|
||||
// doesn't store width/height in a way that lets the children just
|
||||
// inherit the scale — we have to push it down explicitly.
|
||||
if (storeNode.kind === "group") {
|
||||
const uniformScale = (Math.abs(scaleX) + Math.abs(scaleY)) / 2;
|
||||
for (const childId of storeNode.childIds) {
|
||||
const child = store.nodes[childId];
|
||||
if (!child) continue;
|
||||
const childPatch: Partial<MoodboardNode> = {
|
||||
x: child.x * scaleX,
|
||||
y: child.y * scaleY,
|
||||
width: child.width * scaleX,
|
||||
height: child.height * scaleY,
|
||||
};
|
||||
// Text inside a scaled group needs fontSize to track the scale,
|
||||
// otherwise the text stays the same size and overflows / floats.
|
||||
if (child.kind === "text") {
|
||||
(childPatch as Partial<MoodboardNode> & { fontSize?: number })
|
||||
.fontSize = child.fontSize * uniformScale;
|
||||
}
|
||||
patches.push({ id: childId, patch: childPatch });
|
||||
}
|
||||
}
|
||||
|
||||
// Bake the scale into the node's own width/height too, then reset
|
||||
// the Konva scale so subsequent transforms compose cleanly.
|
||||
const baseW = node.width() || 1;
|
||||
const baseH = node.height() || 1;
|
||||
const newWidth = baseW * scaleX;
|
||||
const newHeight = baseH * scaleY;
|
||||
node.scaleX(1);
|
||||
node.scaleY(1);
|
||||
node.width(newWidth);
|
||||
node.height(newHeight);
|
||||
|
||||
patches.push({
|
||||
id,
|
||||
patch: {
|
||||
x: node.x(),
|
||||
y: node.y(),
|
||||
width: newWidth,
|
||||
height: newHeight,
|
||||
rotation: node.rotation(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Apply all patches in one store mutation so the tree only renders
|
||||
// once — keeps the visual transition from "scaled preview" to "baked
|
||||
// result" tight, with no flicker.
|
||||
if (patches.length === 0) return;
|
||||
useMoodboardStore.setState((s) => {
|
||||
const nodes = { ...s.nodes };
|
||||
for (const { id, patch } of patches) {
|
||||
const existing = nodes[id];
|
||||
if (!existing) continue;
|
||||
nodes[id] = { ...existing, ...patch } as MoodboardNode;
|
||||
}
|
||||
return { nodes };
|
||||
});
|
||||
|
||||
// The Transformer caches its bbox from `getClientRect()` of the
|
||||
// attached node. For a Konva.Group, that walks children — but the
|
||||
// children haven't React-re-rendered to their new sizes yet, so a
|
||||
// sync forceUpdate would read the OLD bounds and the box would snap
|
||||
// back. Defer one frame so the children commit first.
|
||||
requestAnimationFrame(() => {
|
||||
transformer.forceUpdate();
|
||||
transformer.getLayer()?.batchDraw();
|
||||
});
|
||||
};
|
||||
transformer.on("transformend", handleTransformEnd);
|
||||
return () => {
|
||||
transformer.off("transformend", handleTransformEnd);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Hide transformer entirely while editing a text node (avoid handle conflicts).
|
||||
useEffect(() => {
|
||||
const transformer = transformerRef.current;
|
||||
if (!transformer) return;
|
||||
transformer.visible(editingTextId === null);
|
||||
transformer.getLayer()?.batchDraw();
|
||||
}, [editingTextId]);
|
||||
|
||||
return transformerRef;
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useEffect } from "react";
|
||||
import { useMoodboardStore } from "../MoodboardStore";
|
||||
|
||||
// Standard editor hotkeys:
|
||||
// - Cmd+Z (Mac) / Ctrl+Z (Win) → undo
|
||||
// - Cmd+Shift+Z (Mac), Ctrl+Y or Ctrl+Shift+Z (Win) → redo
|
||||
export const useUndoRedo = (active: boolean) => {
|
||||
const undo = useMoodboardStore((s) => s.undo);
|
||||
const redo = useMoodboardStore((s) => s.redo);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return undefined;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (target && /input|textarea/i.test(target.tagName)) return;
|
||||
if (target && target.isContentEditable) return;
|
||||
|
||||
const isMac = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
|
||||
const mod = isMac ? e.metaKey : e.ctrlKey;
|
||||
if (!mod) return;
|
||||
const k = e.key.toLowerCase();
|
||||
if (k === "z" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
undo();
|
||||
} else if (
|
||||
(isMac && k === "z" && e.shiftKey) ||
|
||||
(!isMac && k === "y") ||
|
||||
(!isMac && k === "z" && e.shiftKey)
|
||||
) {
|
||||
e.preventDefault();
|
||||
redo();
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", handler);
|
||||
return () => document.removeEventListener("keydown", handler);
|
||||
}, [active, undo, redo]);
|
||||
};
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import Konva from "konva";
|
||||
import { useMoodboardStore } from "../MoodboardStore";
|
||||
import { Vec2 } from "../types";
|
||||
import { ZOOM_MAX, ZOOM_MIN, clamp } from "../layout/geometry";
|
||||
|
||||
const ZOOM_STEP = 1.1;
|
||||
|
||||
const isEditableTarget = (target: EventTarget | null): boolean => {
|
||||
const el = target as HTMLElement | null;
|
||||
if (!el) return false;
|
||||
if (/input|textarea/i.test(el.tagName)) return true;
|
||||
if (el.isContentEditable) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
// Centralizes wheel-zoom and three pan gestures (drag-empty, spacebar+drag,
|
||||
// middle-click drag) for the moodboard canvas. Reads/writes viewport from
|
||||
// the store directly — no more ref props, which keeps a future minimap or
|
||||
// other camera-driving UI (recenter button, Shift+1 fit, etc.) able to
|
||||
// co-exist with these gestures.
|
||||
export const useViewportControls = (
|
||||
stageRef: React.RefObject<Konva.Stage | null>,
|
||||
) => {
|
||||
const setZoom = useMoodboardStore((s) => s.setZoom);
|
||||
const setPan = useMoodboardStore((s) => s.setPan);
|
||||
const resetViewport = useMoodboardStore((s) => s.resetViewport);
|
||||
const setIsPanning = useMoodboardStore((s) => s.setIsPanning);
|
||||
|
||||
// Track spacebar so any subsequent mousedown becomes a pan, not a select.
|
||||
const spaceHeld = useRef(false);
|
||||
// Whether a pan drag is currently in flight, plus its origin in screen px
|
||||
// and the pan offset captured at drag start.
|
||||
const panState = useRef<{
|
||||
active: boolean;
|
||||
startScreen: Vec2;
|
||||
startPan: Vec2;
|
||||
}>({
|
||||
active: false,
|
||||
startScreen: { x: 0, y: 0 },
|
||||
startPan: { x: 0, y: 0 },
|
||||
});
|
||||
|
||||
// Wheel: zoom around cursor.
|
||||
useEffect(() => {
|
||||
const stage = stageRef.current;
|
||||
if (!stage) return undefined;
|
||||
const container = stage.container();
|
||||
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
// Ignore zoom if the user is typing in the text-edit overlay or an input.
|
||||
if (isEditableTarget(document.activeElement)) return;
|
||||
e.preventDefault();
|
||||
const { viewport } = useMoodboardStore.getState();
|
||||
const oldScale = viewport.zoom;
|
||||
const pointer = stage.getPointerPosition();
|
||||
if (!pointer) return;
|
||||
const stagePos = viewport.pan;
|
||||
const mousePointTo = {
|
||||
x: (pointer.x - stagePos.x) / oldScale,
|
||||
y: (pointer.y - stagePos.y) / oldScale,
|
||||
};
|
||||
const direction = e.deltaY > 0 ? -1 : 1;
|
||||
const newScale = clamp(
|
||||
direction > 0 ? oldScale * ZOOM_STEP : oldScale / ZOOM_STEP,
|
||||
ZOOM_MIN,
|
||||
ZOOM_MAX,
|
||||
);
|
||||
const newPan = {
|
||||
x: pointer.x - mousePointTo.x * newScale,
|
||||
y: pointer.y - mousePointTo.y * newScale,
|
||||
};
|
||||
setZoom(newScale);
|
||||
setPan(newPan);
|
||||
};
|
||||
|
||||
container.addEventListener("wheel", handleWheel, { passive: false });
|
||||
return () => {
|
||||
container.removeEventListener("wheel", handleWheel);
|
||||
};
|
||||
}, [stageRef, setZoom, setPan]);
|
||||
|
||||
// Pan gestures: middle-mouse drag, or spacebar+left-drag. Plain left
|
||||
// click is reserved for selection (clicking nodes, lasso, future marquee)
|
||||
// so it never triggers pan, even on empty canvas.
|
||||
useEffect(() => {
|
||||
const stage = stageRef.current;
|
||||
if (!stage) return undefined;
|
||||
const container = stage.container();
|
||||
|
||||
const beginPan = (clientX: number, clientY: number) => {
|
||||
panState.current = {
|
||||
active: true,
|
||||
startScreen: { x: clientX, y: clientY },
|
||||
startPan: { ...useMoodboardStore.getState().viewport.pan },
|
||||
};
|
||||
setIsPanning(true);
|
||||
container.style.cursor = "grabbing";
|
||||
};
|
||||
|
||||
const endPan = () => {
|
||||
if (!panState.current.active) return;
|
||||
panState.current.active = false;
|
||||
setIsPanning(false);
|
||||
container.style.cursor = spaceHeld.current ? "grab" : "";
|
||||
};
|
||||
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
// Middle-mouse always pans. stopPropagation so Konva's internal
|
||||
// mousedown dispatcher never runs — otherwise `useSelection` would
|
||||
// start a marquee underneath the pan.
|
||||
if (e.button === 1) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
beginPan(e.clientX, e.clientY);
|
||||
return;
|
||||
}
|
||||
// Spacebar + left-click pans (Figma convention).
|
||||
if (e.button === 0 && spaceHeld.current) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
beginPan(e.clientX, e.clientY);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!panState.current.active) return;
|
||||
const dx = e.clientX - panState.current.startScreen.x;
|
||||
const dy = e.clientY - panState.current.startScreen.y;
|
||||
setPan({
|
||||
x: panState.current.startPan.x + dx,
|
||||
y: panState.current.startPan.y + dy,
|
||||
});
|
||||
};
|
||||
|
||||
const handleMouseUp = () => endPan();
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (isEditableTarget(e.target)) return;
|
||||
if (e.code === "Space" && !spaceHeld.current) {
|
||||
spaceHeld.current = true;
|
||||
if (!panState.current.active) container.style.cursor = "grab";
|
||||
// Prevent page from scrolling on spacebar.
|
||||
e.preventDefault();
|
||||
}
|
||||
// Cmd/Ctrl+0 → reset zoom + pan.
|
||||
const isMac = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
|
||||
const mod = isMac ? e.metaKey : e.ctrlKey;
|
||||
if (mod && e.key === "0") {
|
||||
e.preventDefault();
|
||||
resetViewport();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.code === "Space") {
|
||||
spaceHeld.current = false;
|
||||
if (!panState.current.active) container.style.cursor = "";
|
||||
}
|
||||
};
|
||||
|
||||
// Capture-phase so we run BEFORE Konva's internal mousedown dispatch.
|
||||
// stopPropagation inside pan-claim branches then prevents Konva from
|
||||
// forwarding the event to node/stage handlers.
|
||||
container.addEventListener("mousedown", handleMouseDown, { capture: true });
|
||||
window.addEventListener("mousemove", handleMouseMove);
|
||||
window.addEventListener("mouseup", handleMouseUp);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
document.addEventListener("keyup", handleKeyUp);
|
||||
return () => {
|
||||
container.removeEventListener("mousedown", handleMouseDown, {
|
||||
capture: true,
|
||||
} as EventListenerOptions);
|
||||
window.removeEventListener("mousemove", handleMouseMove);
|
||||
window.removeEventListener("mouseup", handleMouseUp);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
document.removeEventListener("keyup", handleKeyUp);
|
||||
container.style.cursor = "";
|
||||
};
|
||||
}, [stageRef, setPan, resetViewport, setIsPanning]);
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
clusterByProximity,
|
||||
defaultEpsForNodes,
|
||||
} from "./clusterProximity";
|
||||
import { MoodboardNode } from "../types";
|
||||
|
||||
const node = (id: string, x: number, y: number, size = 50): MoodboardNode => ({
|
||||
id,
|
||||
kind: "image",
|
||||
parentId: null,
|
||||
x,
|
||||
y,
|
||||
width: size,
|
||||
height: size,
|
||||
rotation: 0,
|
||||
zIndex: 0,
|
||||
src: "",
|
||||
mediaId: null,
|
||||
naturalW: size,
|
||||
naturalH: size,
|
||||
});
|
||||
|
||||
describe("clusterByProximity", () => {
|
||||
it("returns no clusters for empty input", () => {
|
||||
expect(clusterByProximity([])).toEqual([]);
|
||||
});
|
||||
|
||||
it("groups close neighbors and separates far ones", () => {
|
||||
const nodes = [
|
||||
node("a", 0, 0, 50),
|
||||
node("b", 30, 30, 50),
|
||||
node("c", 1000, 1000, 50),
|
||||
];
|
||||
const clusters = clusterByProximity(nodes, 100);
|
||||
expect(clusters.length).toBe(2);
|
||||
const sizes = clusters.map((c) => c.length).sort();
|
||||
expect(sizes).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("treats every input as a singleton when eps is tiny", () => {
|
||||
const nodes = [node("a", 0, 0), node("b", 5, 5), node("c", 10, 10)];
|
||||
const clusters = clusterByProximity(nodes, 0.0001);
|
||||
expect(clusters.length).toBe(3);
|
||||
expect(clusters.every((c) => c.length === 1)).toBe(true);
|
||||
});
|
||||
|
||||
it("derives a positive default eps from median size", () => {
|
||||
const eps = defaultEpsForNodes([node("a", 0, 0, 100), node("b", 0, 0, 200)]);
|
||||
expect(eps).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { MoodboardNode, Vec2 } from "../types";
|
||||
|
||||
const center = (n: MoodboardNode): Vec2 => ({
|
||||
x: n.x + n.width / 2,
|
||||
y: n.y + n.height / 2,
|
||||
});
|
||||
|
||||
const median = (xs: number[]): number => {
|
||||
if (xs.length === 0) return 0;
|
||||
const sorted = [...xs].sort((a, b) => a - b);
|
||||
const mid = Math.floor(sorted.length / 2);
|
||||
return sorted.length % 2 === 0
|
||||
? (sorted[mid - 1] + sorted[mid]) / 2
|
||||
: sorted[mid];
|
||||
};
|
||||
|
||||
export const defaultEpsForNodes = (nodes: MoodboardNode[]): number => {
|
||||
if (nodes.length === 0) return 0;
|
||||
const dims = nodes.map((n) => Math.max(n.width, n.height));
|
||||
return median(dims) * 1.5;
|
||||
};
|
||||
|
||||
// DBSCAN with minPts = 1 → equivalent to connected-components on the
|
||||
// eps-graph. Every input node ends up in some cluster (no noise points).
|
||||
export const clusterByProximity = (
|
||||
nodes: MoodboardNode[],
|
||||
eps?: number,
|
||||
): MoodboardNode[][] => {
|
||||
if (nodes.length === 0) return [];
|
||||
const e = eps ?? defaultEpsForNodes(nodes);
|
||||
const e2 = e * e;
|
||||
const centers = nodes.map(center);
|
||||
const visited = new Array(nodes.length).fill(false);
|
||||
const clusters: MoodboardNode[][] = [];
|
||||
|
||||
const neighbors = (i: number): number[] => {
|
||||
const ci = centers[i];
|
||||
const out: number[] = [];
|
||||
for (let j = 0; j < centers.length; j++) {
|
||||
if (j === i) continue;
|
||||
const dx = centers[j].x - ci.x;
|
||||
const dy = centers[j].y - ci.y;
|
||||
if (dx * dx + dy * dy <= e2) out.push(j);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
if (visited[i]) continue;
|
||||
const cluster: MoodboardNode[] = [];
|
||||
const queue = [i];
|
||||
while (queue.length) {
|
||||
const idx = queue.shift()!;
|
||||
if (visited[idx]) continue;
|
||||
visited[idx] = true;
|
||||
cluster.push(nodes[idx]);
|
||||
for (const n of neighbors(idx)) {
|
||||
if (!visited[n]) queue.push(n);
|
||||
}
|
||||
}
|
||||
clusters.push(cluster);
|
||||
}
|
||||
return clusters;
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { MoodboardNode } from "../types";
|
||||
|
||||
export interface LayoutPatch {
|
||||
id: string;
|
||||
patch: Partial<MoodboardNode>;
|
||||
}
|
||||
|
||||
export const computeFitToGridPatches = (
|
||||
nodes: MoodboardNode[],
|
||||
gridSpacing: number,
|
||||
): LayoutPatch[] => {
|
||||
if (gridSpacing <= 0) return [];
|
||||
const patches: LayoutPatch[] = [];
|
||||
for (const n of nodes) {
|
||||
if (n.parentId !== null) continue; // only snap top-level nodes
|
||||
const snappedX = Math.round(n.x / gridSpacing) * gridSpacing;
|
||||
const snappedY = Math.round(n.y / gridSpacing) * gridSpacing;
|
||||
if (snappedX !== n.x || snappedY !== n.y) {
|
||||
patches.push({ id: n.id, patch: { x: snappedX, y: snappedY } });
|
||||
}
|
||||
}
|
||||
return patches;
|
||||
};
|
||||
@@ -0,0 +1,174 @@
|
||||
import { MoodboardNode, Rect, Vec2 } from "../types";
|
||||
|
||||
export interface Viewport {
|
||||
zoom: number;
|
||||
pan: Vec2;
|
||||
}
|
||||
|
||||
export interface CanvasSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
// Global zoom clamps — shared by gesture handlers, fit-to-content, and any
|
||||
// future minimap so the camera can never land outside a consistent range.
|
||||
export const ZOOM_MIN = 0.1;
|
||||
export const ZOOM_MAX = 8;
|
||||
|
||||
// ---------- scalar + rect primitives ----------
|
||||
|
||||
export const clamp = (v: number, lo: number, hi: number): number =>
|
||||
Math.max(lo, Math.min(hi, v));
|
||||
|
||||
export const rectFromPoints = (a: Vec2, b: Vec2): Rect => ({
|
||||
x: Math.min(a.x, b.x),
|
||||
y: Math.min(a.y, b.y),
|
||||
width: Math.abs(b.x - a.x),
|
||||
height: Math.abs(b.y - a.y),
|
||||
});
|
||||
|
||||
export const rectCorners = (r: {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}): Vec2[] => [
|
||||
{ x: r.x, y: r.y },
|
||||
{ x: r.x + r.width, y: r.y },
|
||||
{ x: r.x + r.width, y: r.y + r.height },
|
||||
{ x: r.x, y: r.y + r.height },
|
||||
];
|
||||
|
||||
export const rectsIntersect = (a: Rect, b: Rect): boolean =>
|
||||
!(
|
||||
a.x + a.width < b.x ||
|
||||
a.x > b.x + b.width ||
|
||||
a.y + a.height < b.y ||
|
||||
a.y > b.y + b.height
|
||||
);
|
||||
|
||||
// ---------- AABB over a set of nodes ----------
|
||||
|
||||
export const computeAABB = (nodes: MoodboardNode[]): Rect | null => {
|
||||
if (nodes.length === 0) return null;
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
for (const n of nodes) {
|
||||
minX = Math.min(minX, n.x);
|
||||
minY = Math.min(minY, n.y);
|
||||
maxX = Math.max(maxX, n.x + n.width);
|
||||
maxY = Math.max(maxY, n.y + n.height);
|
||||
}
|
||||
if (!Number.isFinite(minX)) return null;
|
||||
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
|
||||
};
|
||||
|
||||
export const computeAABBById = (
|
||||
nodes: Record<string, MoodboardNode>,
|
||||
ids: string[],
|
||||
): Rect | null => {
|
||||
const picked: MoodboardNode[] = [];
|
||||
for (const id of ids) {
|
||||
const n = nodes[id];
|
||||
if (n) picked.push(n);
|
||||
}
|
||||
return computeAABB(picked);
|
||||
};
|
||||
|
||||
// AABB of every top-level node. Treats rotated nodes as their own
|
||||
// (x, y, w, h) rect for simplicity — fine for "is content on screen?"
|
||||
// checks and minimap overviews.
|
||||
export const computeContentBounds = (
|
||||
nodes: Record<string, MoodboardNode>,
|
||||
rootOrder: string[],
|
||||
): Rect | null => computeAABBById(nodes, rootOrder);
|
||||
|
||||
// ---------- viewport math ----------
|
||||
|
||||
// Visible stage-coord rect = what the camera currently sees.
|
||||
// canvas_px = pan + stage_coord * zoom, so the visible stage region maps to
|
||||
// (-pan / zoom) .. (-pan / zoom + canvas / zoom).
|
||||
export const computeVisibleRect = (
|
||||
viewport: Viewport,
|
||||
canvas: CanvasSize,
|
||||
): Rect => ({
|
||||
x: -viewport.pan.x / viewport.zoom,
|
||||
y: -viewport.pan.y / viewport.zoom,
|
||||
width: canvas.width / viewport.zoom,
|
||||
height: canvas.height / viewport.zoom,
|
||||
});
|
||||
|
||||
// True if any top-level node's AABB intersects the visible viewport.
|
||||
// Used by the recenter indicator; a minimap can use this to style the
|
||||
// viewport rectangle differently when off-content.
|
||||
export const anyContentVisible = (
|
||||
nodes: Record<string, MoodboardNode>,
|
||||
rootOrder: string[],
|
||||
viewport: Viewport,
|
||||
canvas: CanvasSize,
|
||||
): boolean => {
|
||||
const visible = computeVisibleRect(viewport, canvas);
|
||||
for (const id of rootOrder) {
|
||||
const n = nodes[id];
|
||||
if (!n) continue;
|
||||
if (
|
||||
rectsIntersect(
|
||||
{ x: n.x, y: n.y, width: n.width, height: n.height },
|
||||
visible,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Returns the viewport that frames `bounds` inside `canvas` with `padding`
|
||||
// screen pixels of margin on all sides, clamped to [ZOOM_MIN, ZOOM_MAX].
|
||||
// Degenerate bounds (zero width/height) fall back to zoom=1 centered on
|
||||
// the point.
|
||||
export const fitViewportToBounds = (
|
||||
bounds: Rect,
|
||||
canvas: CanvasSize,
|
||||
padding: number,
|
||||
): Viewport => {
|
||||
if (bounds.width <= 0 || bounds.height <= 0) {
|
||||
return {
|
||||
zoom: 1,
|
||||
pan: {
|
||||
x: canvas.width / 2 - bounds.x,
|
||||
y: canvas.height / 2 - bounds.y,
|
||||
},
|
||||
};
|
||||
}
|
||||
const availW = Math.max(canvas.width - 2 * padding, 1);
|
||||
const availH = Math.max(canvas.height - 2 * padding, 1);
|
||||
const zoom = clamp(
|
||||
Math.min(availW / bounds.width, availH / bounds.height),
|
||||
ZOOM_MIN,
|
||||
ZOOM_MAX,
|
||||
);
|
||||
const centerX = bounds.x + bounds.width / 2;
|
||||
const centerY = bounds.y + bounds.height / 2;
|
||||
return {
|
||||
zoom,
|
||||
pan: {
|
||||
x: canvas.width / 2 - centerX * zoom,
|
||||
y: canvas.height / 2 - centerY * zoom,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// Returns the pan that centers the viewport on a given stage-coord point
|
||||
// at the current zoom. Handy for minimap drag-to-navigate: stage point
|
||||
// under the minimap cursor → pan such that it lands in the canvas center.
|
||||
export const panToCenterOn = (
|
||||
stagePoint: Vec2,
|
||||
zoom: number,
|
||||
canvas: CanvasSize,
|
||||
): Vec2 => ({
|
||||
x: canvas.width / 2 - stagePoint.x * zoom,
|
||||
y: canvas.height / 2 - stagePoint.y * zoom,
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { computeAABB } from "./geometry";
|
||||
import { computePackPatches } from "./packCollage";
|
||||
import { MoodboardNode } from "../types";
|
||||
|
||||
const img = (
|
||||
id: string,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
): MoodboardNode => ({
|
||||
id,
|
||||
kind: "image",
|
||||
parentId: null,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
rotation: 0,
|
||||
zIndex: 0,
|
||||
src: "",
|
||||
mediaId: null,
|
||||
naturalW: width,
|
||||
naturalH: height,
|
||||
});
|
||||
|
||||
describe("packCollage", () => {
|
||||
it("returns no patches for empty input", () => {
|
||||
const patches = computePackPatches([], { x: 0, y: 0, width: 800, height: 600 });
|
||||
expect(patches).toEqual([]);
|
||||
});
|
||||
|
||||
it("packs three equal-aspect images into a single row that fills width with gaps", () => {
|
||||
const nodes = [
|
||||
img("a", 0, 0, 200, 200),
|
||||
img("b", 0, 0, 200, 200),
|
||||
img("c", 0, 0, 200, 200),
|
||||
];
|
||||
const bounds = { x: 0, y: 0, width: 600, height: 400 };
|
||||
const patches = computePackPatches(nodes, bounds, { rowHeight: 180, gap: 8 });
|
||||
expect(patches.length).toBe(3);
|
||||
// All in one row → same y, identical heights, total widths + 2 gaps == bounds.width.
|
||||
const ys = patches.map((p) => p.patch.y!);
|
||||
expect(ys[0]).toBe(0);
|
||||
expect(ys.every((y) => y === ys[0])).toBe(true);
|
||||
const widths = patches.map((p) => p.patch.width!);
|
||||
const totalW = widths.reduce((a, b) => a + b, 0) + 2 * 8;
|
||||
expect(totalW).toBeCloseTo(600, 1);
|
||||
const heights = patches.map((p) => p.patch.height!);
|
||||
expect(heights.every((h) => Math.abs(h - heights[0]) < 1e-6)).toBe(true);
|
||||
});
|
||||
|
||||
it("breaks into multiple rows when aspects sum exceeds target", () => {
|
||||
const wide = (id: string) => img(id, 0, 0, 400, 100); // aspect 4
|
||||
const nodes = [wide("a"), wide("b"), wide("c"), wide("d")];
|
||||
const bounds = { x: 0, y: 0, width: 400, height: 600 };
|
||||
const patches = computePackPatches(nodes, bounds, { rowHeight: 100, gap: 0 });
|
||||
const distinctYs = new Set(patches.map((p) => p.patch.y!));
|
||||
expect(distinctYs.size).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("computeAABB returns null on empty and a tight box on inputs", () => {
|
||||
expect(computeAABB([])).toBeNull();
|
||||
const aabb = computeAABB([img("a", 10, 20, 100, 50), img("b", 60, 0, 80, 200)]);
|
||||
expect(aabb).toEqual({ x: 10, y: 0, width: 130, height: 200 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { MoodboardNode, Rect } from "../types";
|
||||
import { LayoutPatch } from "./fitToGrid";
|
||||
|
||||
export interface PackOptions {
|
||||
rowHeight: number;
|
||||
gap: number;
|
||||
}
|
||||
|
||||
const DEFAULTS: PackOptions = { rowHeight: 180, gap: 8 };
|
||||
|
||||
export const computePackPatches = (
|
||||
nodes: MoodboardNode[],
|
||||
bounds: Rect,
|
||||
opts: Partial<PackOptions> = {},
|
||||
): LayoutPatch[] => {
|
||||
const { rowHeight, gap } = { ...DEFAULTS, ...opts };
|
||||
if (nodes.length === 0 || bounds.width <= 0) return [];
|
||||
|
||||
const aspects = nodes.map((n) =>
|
||||
n.height > 0 ? n.width / n.height : 1,
|
||||
);
|
||||
|
||||
// Greedy row break: stop adding when summed aspect (at target rowHeight)
|
||||
// would overflow the bounds width including per-item gaps.
|
||||
type Row = { indices: number[]; aspectSum: number };
|
||||
const rows: Row[] = [];
|
||||
let current: Row = { indices: [], aspectSum: 0 };
|
||||
|
||||
const targetAspect = bounds.width / rowHeight;
|
||||
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const a = aspects[i];
|
||||
const projected = current.aspectSum + a;
|
||||
const projectedGapAspect =
|
||||
(current.indices.length * gap) / rowHeight;
|
||||
if (
|
||||
current.indices.length > 0 &&
|
||||
projected + projectedGapAspect >= targetAspect
|
||||
) {
|
||||
rows.push(current);
|
||||
current = { indices: [], aspectSum: 0 };
|
||||
}
|
||||
current.indices.push(i);
|
||||
current.aspectSum += a;
|
||||
}
|
||||
if (current.indices.length > 0) rows.push(current);
|
||||
|
||||
const patches: LayoutPatch[] = [];
|
||||
let y = bounds.y;
|
||||
|
||||
rows.forEach((row, rowIdx) => {
|
||||
const isLast = rowIdx === rows.length - 1;
|
||||
const gapsTotal = (row.indices.length - 1) * gap;
|
||||
const availW = Math.max(1, bounds.width - gapsTotal);
|
||||
let rowH = availW / row.aspectSum;
|
||||
if (isLast && rowH > rowHeight * 1.5) {
|
||||
rowH = rowHeight;
|
||||
}
|
||||
let x = bounds.x;
|
||||
for (const i of row.indices) {
|
||||
const w = aspects[i] * rowH;
|
||||
patches.push({
|
||||
id: nodes[i].id,
|
||||
patch: { x, y, width: w, height: rowH, rotation: 0 },
|
||||
});
|
||||
x += w + gap;
|
||||
}
|
||||
y += rowH + gap;
|
||||
});
|
||||
|
||||
return patches;
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import { pointInPolygon } from "./pointInPolygon";
|
||||
import {
|
||||
segmentsIntersect,
|
||||
polygonIntersectsRect,
|
||||
} from "./segmentsIntersect";
|
||||
|
||||
const square = [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 10, y: 0 },
|
||||
{ x: 10, y: 10 },
|
||||
{ x: 0, y: 10 },
|
||||
];
|
||||
|
||||
describe("pointInPolygon", () => {
|
||||
it("returns true for points strictly inside", () => {
|
||||
expect(pointInPolygon({ x: 5, y: 5 }, square)).toBe(true);
|
||||
});
|
||||
it("returns false for points outside", () => {
|
||||
expect(pointInPolygon({ x: 20, y: 5 }, square)).toBe(false);
|
||||
});
|
||||
it("returns false for degenerate polygons", () => {
|
||||
expect(pointInPolygon({ x: 0, y: 0 }, [{ x: 0, y: 0 }, { x: 1, y: 1 }])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("segmentsIntersect", () => {
|
||||
it("detects crossing segments", () => {
|
||||
expect(
|
||||
segmentsIntersect({ x: 0, y: 0 }, { x: 10, y: 10 }, { x: 0, y: 10 }, { x: 10, y: 0 }),
|
||||
).toBe(true);
|
||||
});
|
||||
it("rejects non-crossing segments", () => {
|
||||
expect(
|
||||
segmentsIntersect({ x: 0, y: 0 }, { x: 1, y: 1 }, { x: 5, y: 5 }, { x: 6, y: 6 }),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("polygonIntersectsRect", () => {
|
||||
it("returns true when an edge crosses a rect side", () => {
|
||||
const polyline = [
|
||||
{ x: -5, y: 5 },
|
||||
{ x: 15, y: 5 },
|
||||
];
|
||||
expect(polygonIntersectsRect(polyline, { x: 0, y: 0, width: 10, height: 10 })).toBe(true);
|
||||
});
|
||||
it("returns false when polygon is entirely outside and does not touch", () => {
|
||||
const polyline = [
|
||||
{ x: 100, y: 100 },
|
||||
{ x: 200, y: 100 },
|
||||
];
|
||||
expect(polygonIntersectsRect(polyline, { x: 0, y: 0, width: 10, height: 10 })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Vec2 } from "../types";
|
||||
|
||||
export const pointInPolygon = (point: Vec2, polygon: Vec2[]): boolean => {
|
||||
if (polygon.length < 3) return false;
|
||||
let inside = false;
|
||||
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
||||
const xi = polygon[i].x;
|
||||
const yi = polygon[i].y;
|
||||
const xj = polygon[j].x;
|
||||
const yj = polygon[j].y;
|
||||
const intersect =
|
||||
yi > point.y !== yj > point.y &&
|
||||
point.x < ((xj - xi) * (point.y - yi)) / (yj - yi + Number.EPSILON) + xi;
|
||||
if (intersect) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Vec2 } from "../types";
|
||||
|
||||
const cross = (ax: number, ay: number, bx: number, by: number) =>
|
||||
ax * by - ay * bx;
|
||||
|
||||
export const segmentsIntersect = (
|
||||
p1: Vec2,
|
||||
p2: Vec2,
|
||||
p3: Vec2,
|
||||
p4: Vec2,
|
||||
): boolean => {
|
||||
const d1x = p2.x - p1.x;
|
||||
const d1y = p2.y - p1.y;
|
||||
const d2x = p4.x - p3.x;
|
||||
const d2y = p4.y - p3.y;
|
||||
const denom = cross(d1x, d1y, d2x, d2y);
|
||||
if (denom === 0) return false; // parallel or collinear
|
||||
const dx = p3.x - p1.x;
|
||||
const dy = p3.y - p1.y;
|
||||
const t = cross(dx, dy, d2x, d2y) / denom;
|
||||
const u = cross(dx, dy, d1x, d1y) / denom;
|
||||
return t >= 0 && t <= 1 && u >= 0 && u <= 1;
|
||||
};
|
||||
|
||||
export const polygonIntersectsRect = (
|
||||
polygon: Vec2[],
|
||||
rect: { x: number; y: number; width: number; height: number },
|
||||
): boolean => {
|
||||
if (polygon.length < 2) return false;
|
||||
const corners: Vec2[] = [
|
||||
{ x: rect.x, y: rect.y },
|
||||
{ x: rect.x + rect.width, y: rect.y },
|
||||
{ x: rect.x + rect.width, y: rect.y + rect.height },
|
||||
{ x: rect.x, y: rect.y + rect.height },
|
||||
];
|
||||
for (let i = 0; i < polygon.length; i++) {
|
||||
const a = polygon[i];
|
||||
const b = polygon[(i + 1) % polygon.length];
|
||||
for (let j = 0; j < 4; j++) {
|
||||
const c = corners[j];
|
||||
const d = corners[(j + 1) % 4];
|
||||
if (segmentsIntersect(a, b, c, d)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SnapBox, computeSnap } from "./snapping";
|
||||
|
||||
const box = (x: number, y: number, w = 100, h = 100): SnapBox => ({
|
||||
x,
|
||||
y,
|
||||
width: w,
|
||||
height: h,
|
||||
});
|
||||
|
||||
describe("computeSnap", () => {
|
||||
it("snaps a near-aligned left edge onto another node's left edge", () => {
|
||||
const moving = box(103, 400);
|
||||
const result = computeSnap(moving, [box(100, 0)], 6, null);
|
||||
expect(result.x).toBe(100);
|
||||
expect(result.guides.some((g) => g.axis === "x" && g.pos === 100)).toBe(true);
|
||||
});
|
||||
|
||||
it("snaps centers together", () => {
|
||||
// Other center-x = 150. Moving (w=100) center aligns when x = 100.
|
||||
const moving = box(98, 400);
|
||||
const result = computeSnap(moving, [box(100, 0)], 6, null);
|
||||
expect(result.x).toBe(100);
|
||||
});
|
||||
|
||||
it("leaves position untouched beyond the threshold", () => {
|
||||
const moving = box(120, 400);
|
||||
const result = computeSnap(moving, [box(100, 0)], 6, null);
|
||||
expect(result.x).toBe(120);
|
||||
expect(result.guides).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("falls back to the grid when no node aligns", () => {
|
||||
const moving = box(33, 65);
|
||||
const result = computeSnap(moving, [], 6, 32);
|
||||
expect(result.x).toBe(32);
|
||||
expect(result.y).toBe(64);
|
||||
});
|
||||
|
||||
it("prefers node alignment over the grid on a given axis", () => {
|
||||
// x has a node edge at 100 (moving from 97) within threshold; grid=32 would
|
||||
// pull to 96 — node alignment must win.
|
||||
const moving = box(97, 400);
|
||||
const result = computeSnap(moving, [box(100, 0)], 6, 32);
|
||||
expect(result.x).toBe(100);
|
||||
});
|
||||
|
||||
it("emits a guide spanning both boxes on the shared axis", () => {
|
||||
const moving = box(100, 300);
|
||||
const result = computeSnap(moving, [box(100, 0)], 6, null);
|
||||
const guide = result.guides.find((g) => g.axis === "x");
|
||||
expect(guide).toBeDefined();
|
||||
expect(guide?.from).toBe(0);
|
||||
expect(guide?.to).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
// Drag snapping for the freeform canvas. A moving node's near/center/far edges
|
||||
// align to the same edges of nearby nodes (and, as a fallback, the background
|
||||
// grid). Pure + deterministic so it can be unit-tested without Konva.
|
||||
|
||||
export interface SnapBox {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface SnapGuide {
|
||||
axis: "x" | "y";
|
||||
// Stage coordinate of the guide line on its axis.
|
||||
pos: number;
|
||||
// Cross-axis extent the guide spans (so it visually connects the two nodes).
|
||||
from: number;
|
||||
to: number;
|
||||
}
|
||||
|
||||
export interface SnapResult {
|
||||
x: number;
|
||||
y: number;
|
||||
guides: SnapGuide[];
|
||||
}
|
||||
|
||||
// near edge, center, far edge — the three alignment anchors per axis.
|
||||
const xAnchors = (b: SnapBox): number[] => [b.x, b.x + b.width / 2, b.x + b.width];
|
||||
const yAnchors = (b: SnapBox): number[] => [b.y, b.y + b.height / 2, b.y + b.height];
|
||||
|
||||
// Snaps `moving` against `others` within `threshold` (stage units). `grid`, when
|
||||
// set, snaps the top-left to the grid on any axis that found no node alignment.
|
||||
export const computeSnap = (
|
||||
moving: SnapBox,
|
||||
others: SnapBox[],
|
||||
threshold: number,
|
||||
grid: number | null,
|
||||
): SnapResult => {
|
||||
let bestDX = threshold + 1;
|
||||
let snapX: number | null = null;
|
||||
let alignX = 0;
|
||||
let otherX: SnapBox | null = null;
|
||||
|
||||
let bestDY = threshold + 1;
|
||||
let snapY: number | null = null;
|
||||
let alignY = 0;
|
||||
let otherY: SnapBox | null = null;
|
||||
|
||||
const mxs = xAnchors(moving);
|
||||
const mys = yAnchors(moving);
|
||||
|
||||
for (const o of others) {
|
||||
for (const ox of xAnchors(o)) {
|
||||
for (const mx of mxs) {
|
||||
const d = Math.abs(ox - mx);
|
||||
if (d < bestDX) {
|
||||
bestDX = d;
|
||||
snapX = moving.x + (ox - mx);
|
||||
alignX = ox;
|
||||
otherX = o;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const oy of yAnchors(o)) {
|
||||
for (const my of mys) {
|
||||
const d = Math.abs(oy - my);
|
||||
if (d < bestDY) {
|
||||
bestDY = d;
|
||||
snapY = moving.y + (oy - my);
|
||||
alignY = oy;
|
||||
otherY = o;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Grid fallback per axis (no guide line — the dotted grid is the reference).
|
||||
if (snapX === null && grid) {
|
||||
const gx = Math.round(moving.x / grid) * grid;
|
||||
if (Math.abs(gx - moving.x) <= threshold) snapX = gx;
|
||||
}
|
||||
if (snapY === null && grid) {
|
||||
const gy = Math.round(moving.y / grid) * grid;
|
||||
if (Math.abs(gy - moving.y) <= threshold) snapY = gy;
|
||||
}
|
||||
|
||||
const finalX = snapX ?? moving.x;
|
||||
const finalY = snapY ?? moving.y;
|
||||
const snapped: SnapBox = { ...moving, x: finalX, y: finalY };
|
||||
|
||||
const guides: SnapGuide[] = [];
|
||||
if (otherX) {
|
||||
guides.push({
|
||||
axis: "x",
|
||||
pos: alignX,
|
||||
from: Math.min(snapped.y, otherX.y),
|
||||
to: Math.max(snapped.y + snapped.height, otherX.y + otherX.height),
|
||||
});
|
||||
}
|
||||
if (otherY) {
|
||||
guides.push({
|
||||
axis: "y",
|
||||
pos: alignY,
|
||||
from: Math.min(snapped.x, otherY.x),
|
||||
to: Math.max(snapped.x + snapped.width, otherY.x + otherY.width),
|
||||
});
|
||||
}
|
||||
|
||||
return { x: finalX, y: finalY, guides };
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
import { memo } from "react";
|
||||
import Konva from "konva";
|
||||
import { Group, Rect } from "react-konva";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { GroupNode as GroupNodeData, MoodboardNode } from "../types";
|
||||
import { useMoodboardStore } from "../MoodboardStore";
|
||||
import { useNodeDrag } from "../interactions/useNodeDrag";
|
||||
import { ImageNode } from "./ImageNode";
|
||||
import { TextNode } from "./TextNode";
|
||||
|
||||
interface Props {
|
||||
node: GroupNodeData;
|
||||
selected: boolean;
|
||||
onSelect: (
|
||||
id: string,
|
||||
e: Konva.KonvaEventObject<MouseEvent | TouchEvent>,
|
||||
) => void;
|
||||
}
|
||||
|
||||
// Shared no-op for child renders: the group absorbs all clicks, so child
|
||||
// nodes never fire `onSelect`. Using a module-level constant keeps the
|
||||
// reference stable across renders, which lets the memoized child nodes
|
||||
// skip work when nothing else about them changed.
|
||||
const NO_OP_SELECT = () => {};
|
||||
|
||||
const GroupNodeInner = ({ node, selected, onSelect }: Props) => {
|
||||
const drag = useNodeDrag(node.id);
|
||||
|
||||
// Subscribe only to this group's own children, with a shallow compare on
|
||||
// the returned array. Avoids re-rendering every group whenever any node
|
||||
// anywhere in the board updates (which would otherwise mutate the whole
|
||||
// `nodes` map reference).
|
||||
const children = useMoodboardStore(
|
||||
useShallow((s) =>
|
||||
node.childIds
|
||||
.map((id) => s.nodes[id])
|
||||
.filter((n): n is MoodboardNode => Boolean(n)),
|
||||
),
|
||||
);
|
||||
|
||||
const handleSelect = (
|
||||
e: Konva.KonvaEventObject<MouseEvent | TouchEvent>,
|
||||
) => onSelect(node.id, e);
|
||||
|
||||
return (
|
||||
<Group
|
||||
id={node.id}
|
||||
x={node.x}
|
||||
y={node.y}
|
||||
width={node.width}
|
||||
height={node.height}
|
||||
rotation={node.rotation}
|
||||
draggable
|
||||
onMouseDown={handleSelect}
|
||||
onTouchStart={handleSelect}
|
||||
onDragStart={drag.onDragStart}
|
||||
onDragMove={drag.onDragMove}
|
||||
onDragEnd={drag.onDragEnd}
|
||||
>
|
||||
{/* Transparent hit/transform target so the Transformer has stable bounds. */}
|
||||
<Rect
|
||||
x={0}
|
||||
y={0}
|
||||
width={node.width}
|
||||
height={node.height}
|
||||
fill="transparent"
|
||||
stroke={selected ? "#3b82f6" : "transparent"}
|
||||
strokeWidth={1}
|
||||
dash={selected ? [4, 4] : undefined}
|
||||
listening={false}
|
||||
/>
|
||||
{children.map((child) => {
|
||||
if (child.kind === "image") {
|
||||
return (
|
||||
<ImageNode
|
||||
key={child.id}
|
||||
node={child}
|
||||
draggable={false}
|
||||
selected={false}
|
||||
onSelect={NO_OP_SELECT}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (child.kind === "text") {
|
||||
return (
|
||||
<TextNode
|
||||
key={child.id}
|
||||
node={child}
|
||||
draggable={false}
|
||||
selected={false}
|
||||
onSelect={NO_OP_SELECT}
|
||||
/>
|
||||
);
|
||||
}
|
||||
// Nested groups not exercised in phase 1, but render recursively for future.
|
||||
if (child.kind === "group") {
|
||||
return (
|
||||
<GroupNode
|
||||
key={child.id}
|
||||
node={child}
|
||||
selected={false}
|
||||
onSelect={NO_OP_SELECT}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
export const GroupNode = memo(GroupNodeInner);
|
||||
@@ -0,0 +1,74 @@
|
||||
import { memo, useEffect, useRef, useState } from "react";
|
||||
import Konva from "konva";
|
||||
import { Image as KonvaImage, Rect } from "react-konva";
|
||||
import { ImageNode as ImageNodeData } from "../types";
|
||||
import { useNodeDrag } from "../interactions/useNodeDrag";
|
||||
|
||||
interface Props {
|
||||
node: ImageNodeData;
|
||||
draggable: boolean;
|
||||
selected: boolean;
|
||||
// Receives the node id so parents can pass one stable callback to every
|
||||
// sibling rather than allocating a per-item closure on each render.
|
||||
onSelect: (
|
||||
id: string,
|
||||
e: Konva.KonvaEventObject<MouseEvent | TouchEvent>,
|
||||
) => void;
|
||||
}
|
||||
|
||||
const ImageNodeInner = ({ node, draggable, selected, onSelect }: Props) => {
|
||||
const drag = useNodeDrag(node.id);
|
||||
const ref = useRef<Konva.Image | null>(null);
|
||||
const [img, setImg] = useState<HTMLImageElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const i = new window.Image();
|
||||
i.crossOrigin = "anonymous";
|
||||
i.onload = () => setImg(i);
|
||||
i.src = node.src;
|
||||
return () => {
|
||||
i.onload = null;
|
||||
};
|
||||
}, [node.src]);
|
||||
|
||||
const handleSelect = (
|
||||
e: Konva.KonvaEventObject<MouseEvent | TouchEvent>,
|
||||
) => onSelect(node.id, e);
|
||||
|
||||
return (
|
||||
<>
|
||||
{!img && (
|
||||
<Rect
|
||||
x={node.x}
|
||||
y={node.y}
|
||||
width={node.width}
|
||||
height={node.height}
|
||||
rotation={node.rotation}
|
||||
fill="#222"
|
||||
stroke={selected ? "#3b82f6" : "transparent"}
|
||||
strokeWidth={1}
|
||||
/>
|
||||
)}
|
||||
<KonvaImage
|
||||
id={node.id}
|
||||
ref={(n) => {
|
||||
ref.current = n;
|
||||
}}
|
||||
image={img ?? undefined}
|
||||
x={node.x}
|
||||
y={node.y}
|
||||
width={node.width}
|
||||
height={node.height}
|
||||
rotation={node.rotation}
|
||||
draggable={draggable}
|
||||
onMouseDown={handleSelect}
|
||||
onTouchStart={handleSelect}
|
||||
onDragStart={drag.onDragStart}
|
||||
onDragMove={drag.onDragMove}
|
||||
onDragEnd={drag.onDragEnd}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const ImageNode = memo(ImageNodeInner);
|
||||
@@ -0,0 +1,57 @@
|
||||
import { memo } from "react";
|
||||
import Konva from "konva";
|
||||
import { Text as KonvaText } from "react-konva";
|
||||
import { TextNode as TextNodeData } from "../types";
|
||||
import { useMoodboardStore } from "../MoodboardStore";
|
||||
import { useNodeDrag } from "../interactions/useNodeDrag";
|
||||
|
||||
interface Props {
|
||||
node: TextNodeData;
|
||||
draggable: boolean;
|
||||
selected: boolean;
|
||||
onSelect: (
|
||||
id: string,
|
||||
e: Konva.KonvaEventObject<MouseEvent | TouchEvent>,
|
||||
) => void;
|
||||
}
|
||||
|
||||
const TextNodeInner = ({ node, draggable, selected, onSelect }: Props) => {
|
||||
const drag = useNodeDrag(node.id);
|
||||
const setEditingText = useMoodboardStore((s) => s.setEditingText);
|
||||
// Selector compares a single id — only the TextNode whose isEditing
|
||||
// actually flips will re-render when someone enters/exits text edit.
|
||||
const isEditing = useMoodboardStore(
|
||||
(s) => s.transient.editingTextId === node.id,
|
||||
);
|
||||
|
||||
const handleSelect = (
|
||||
e: Konva.KonvaEventObject<MouseEvent | TouchEvent>,
|
||||
) => onSelect(node.id, e);
|
||||
|
||||
return (
|
||||
<KonvaText
|
||||
id={node.id}
|
||||
x={node.x}
|
||||
y={node.y}
|
||||
width={node.width}
|
||||
height={node.height}
|
||||
rotation={node.rotation}
|
||||
text={isEditing ? "" : node.text || "Note"}
|
||||
fontSize={node.fontSize}
|
||||
fill={node.color}
|
||||
padding={6}
|
||||
draggable={draggable}
|
||||
onMouseDown={handleSelect}
|
||||
onTouchStart={handleSelect}
|
||||
onDblClick={() => setEditingText(node.id)}
|
||||
onDblTap={() => setEditingText(node.id)}
|
||||
onDragStart={drag.onDragStart}
|
||||
onDragMove={drag.onDragMove}
|
||||
onDragEnd={drag.onDragEnd}
|
||||
stroke={selected ? "#3b82f6" : undefined}
|
||||
strokeWidth={selected ? 0.5 : 0}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const TextNode = memo(TextNodeInner);
|
||||
@@ -0,0 +1,105 @@
|
||||
import { memo, useEffect, useRef, useState } from "react";
|
||||
import Konva from "konva";
|
||||
import { Image as KonvaImage, Rect } from "react-konva";
|
||||
import { VideoNode as VideoNodeData } from "../types";
|
||||
import { useNodeDrag } from "../interactions/useNodeDrag";
|
||||
|
||||
interface Props {
|
||||
node: VideoNodeData;
|
||||
draggable: boolean;
|
||||
selected: boolean;
|
||||
// Receives the node id so parents can pass one stable callback to every
|
||||
// sibling rather than allocating a per-item closure on each render.
|
||||
onSelect: (
|
||||
id: string,
|
||||
e: Konva.KonvaEventObject<MouseEvent | TouchEvent>,
|
||||
) => void;
|
||||
}
|
||||
|
||||
// Konva can't render a <video> directly — it paints whatever HTMLImageElement
|
||||
// or HTMLVideoElement you hand its Image node, but only on draw. So we own the
|
||||
// element imperatively and run a Konva.Animation tied to the node's layer to
|
||||
// repaint each frame while the video plays. This mirrors ImageNode otherwise.
|
||||
const VideoNodeInner = ({ node, draggable, selected, onSelect }: Props) => {
|
||||
const drag = useNodeDrag(node.id);
|
||||
const ref = useRef<Konva.Image | null>(null);
|
||||
const [video, setVideo] = useState<HTMLVideoElement | null>(null);
|
||||
|
||||
// Build the element once per src; autoplay only succeeds while muted, which
|
||||
// is why addVideo() forces muted+loop+autoplay to travel together.
|
||||
useEffect(() => {
|
||||
const el = document.createElement("video");
|
||||
el.crossOrigin = "anonymous";
|
||||
el.src = node.src;
|
||||
el.muted = node.muted;
|
||||
el.loop = node.loop;
|
||||
el.autoplay = node.autoplay;
|
||||
el.playsInline = true;
|
||||
el.preload = "metadata";
|
||||
const onLoaded = () => setVideo(el);
|
||||
el.addEventListener("loadedmetadata", onLoaded);
|
||||
if (node.autoplay) void el.play().catch(() => undefined);
|
||||
return () => {
|
||||
el.removeEventListener("loadedmetadata", onLoaded);
|
||||
el.pause();
|
||||
el.removeAttribute("src");
|
||||
el.load();
|
||||
setVideo(null);
|
||||
};
|
||||
}, [node.src, node.muted, node.loop, node.autoplay]);
|
||||
|
||||
// Drive repaints off the layer the image node lives on, for as long as the
|
||||
// element is actually playing. The animation auto-stops on unmount/cleanup.
|
||||
useEffect(() => {
|
||||
const imageNode = ref.current;
|
||||
if (!video || !imageNode) return undefined;
|
||||
const layer = imageNode.getLayer();
|
||||
if (!layer) return undefined;
|
||||
const anim = new Konva.Animation(() => undefined, layer);
|
||||
anim.start();
|
||||
return () => {
|
||||
anim.stop();
|
||||
};
|
||||
}, [video]);
|
||||
|
||||
const handleSelect = (
|
||||
e: Konva.KonvaEventObject<MouseEvent | TouchEvent>,
|
||||
) => onSelect(node.id, e);
|
||||
|
||||
return (
|
||||
<>
|
||||
{!video && (
|
||||
<Rect
|
||||
x={node.x}
|
||||
y={node.y}
|
||||
width={node.width}
|
||||
height={node.height}
|
||||
rotation={node.rotation}
|
||||
fill="#222"
|
||||
stroke={selected ? "#3b82f6" : "transparent"}
|
||||
strokeWidth={1}
|
||||
/>
|
||||
)}
|
||||
<KonvaImage
|
||||
id={node.id}
|
||||
ref={(n) => {
|
||||
ref.current = n;
|
||||
}}
|
||||
image={video ?? undefined}
|
||||
x={node.x}
|
||||
y={node.y}
|
||||
width={node.width}
|
||||
height={node.height}
|
||||
rotation={node.rotation}
|
||||
draggable={draggable}
|
||||
onMouseDown={handleSelect}
|
||||
onTouchStart={handleSelect}
|
||||
onDragStart={drag.onDragStart}
|
||||
onDragMove={drag.onDragMove}
|
||||
onDragEnd={drag.onDragEnd}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const VideoNode = memo(VideoNodeInner);
|
||||
@@ -0,0 +1,32 @@
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faCompress } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { useMoodboardStore } from "../MoodboardStore";
|
||||
import { anyContentVisible } from "../layout/geometry";
|
||||
|
||||
// Small pill button in the bottom-right that appears when the user has
|
||||
// panned or zoomed so far that no content is on screen. Clicking it calls
|
||||
// `fitToContent` to frame all nodes back into view. Hidden on an empty
|
||||
// board (nothing to recenter on) and whenever any node intersects the
|
||||
// visible viewport.
|
||||
export const RecenterIndicator = () => {
|
||||
const nodes = useMoodboardStore((s) => s.nodes);
|
||||
const rootOrder = useMoodboardStore((s) => s.rootOrder);
|
||||
const viewport = useMoodboardStore((s) => s.viewport);
|
||||
const canvasSize = useMoodboardStore((s) => s.canvasSize);
|
||||
const fitToContent = useMoodboardStore((s) => s.fitToContent);
|
||||
|
||||
if (rootOrder.length === 0) return null;
|
||||
if (anyContentVisible(nodes, rootOrder, viewport, canvasSize)) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fitToContent()}
|
||||
title="Zoom to fit (Shift+1)"
|
||||
className="pointer-events-auto absolute bottom-4 right-4 z-20 flex items-center gap-2 rounded-full border border-white/20 bg-black/70 px-3 py-2 text-sm text-white/90 shadow-lg backdrop-blur-sm transition-colors hover:bg-black/80"
|
||||
>
|
||||
<FontAwesomeIcon icon={faCompress} />
|
||||
<span>Content off-screen — recenter</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
import { SHORTCUTS, ShortcutRow } from "./shortcuts";
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
const SECTION_ORDER: ShortcutRow["section"][] = [
|
||||
"Tools",
|
||||
"Selection",
|
||||
"Editing",
|
||||
"Viewport",
|
||||
"History",
|
||||
];
|
||||
|
||||
// Translucent, non-interactive overlay that lists all keyboard shortcuts.
|
||||
// Shown while the user holds Ctrl/Cmd alone for 3 s (see
|
||||
// `useShortcutCheatsheet.ts`). `pointer-events: none` so it never steals
|
||||
// focus — the user can keep panning / zooming / typing while it's visible.
|
||||
export const ShortcutCheatsheet = ({ visible }: Props) => {
|
||||
if (!visible) return null;
|
||||
|
||||
// Group rows by section, preserving declaration order within each.
|
||||
const bySection = new Map<ShortcutRow["section"], ShortcutRow[]>();
|
||||
for (const row of SHORTCUTS) {
|
||||
const arr = bySection.get(row.section) ?? [];
|
||||
arr.push(row);
|
||||
bySection.set(row.section, arr);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 z-30 flex items-center justify-center"
|
||||
>
|
||||
<div className="max-h-[80%] w-[min(640px,90%)] overflow-auto rounded-xl border border-white/15 bg-black/70 p-6 text-white/90 shadow-2xl backdrop-blur-sm">
|
||||
<div className="mb-3 text-xs uppercase tracking-widest text-white/50">
|
||||
Keyboard shortcuts
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-x-8 gap-y-6 sm:grid-cols-2">
|
||||
{SECTION_ORDER.map((section) => {
|
||||
const rows = bySection.get(section);
|
||||
if (!rows) return null;
|
||||
return (
|
||||
<div key={section}>
|
||||
<div className="mb-2 text-xs font-semibold uppercase tracking-wider text-white/60">
|
||||
{section}
|
||||
</div>
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{rows.map((row) => (
|
||||
<li
|
||||
key={row.label}
|
||||
className="flex items-center justify-between gap-3 text-sm"
|
||||
>
|
||||
<span className="text-white/80">{row.label}</span>
|
||||
<span className="flex items-center gap-1">
|
||||
{row.keys.map((k, i) => (
|
||||
<Key key={`${row.label}-${i}`} label={k} />
|
||||
))}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Key = ({ label }: { label: string }) => (
|
||||
<kbd className="rounded border border-white/20 bg-white/10 px-1.5 py-0.5 font-mono text-[11px] text-white/90">
|
||||
{label}
|
||||
</kbd>
|
||||
);
|
||||
@@ -0,0 +1,54 @@
|
||||
// Single source of truth for moodboard keyboard shortcuts. Consumed by
|
||||
// `ShortcutCheatsheet.tsx` (rendering) and indirectly by
|
||||
// `useKeyboardShortcuts.ts` (wiring). The `keys` array is the label shown
|
||||
// to the user; the actual matching logic lives in the hook because some
|
||||
// shortcuts are modifier-sensitive and platform-specific.
|
||||
|
||||
export interface ShortcutRow {
|
||||
label: string;
|
||||
keys: string[];
|
||||
section: "Tools" | "Selection" | "Editing" | "Viewport" | "History";
|
||||
}
|
||||
|
||||
const isMac =
|
||||
typeof navigator !== "undefined" &&
|
||||
/Mac|iPod|iPhone|iPad/.test(navigator.platform);
|
||||
|
||||
export const MOD = isMac ? "⌘" : "Ctrl";
|
||||
|
||||
// Renders a key combo as a compact tooltip label. Mac uses adjacent symbols
|
||||
// (⌘⇧Z); other platforms use "+" separators (Ctrl+Shift+Z). Pass keys in
|
||||
// order, e.g. [MOD, "Shift", "Z"].
|
||||
export const fmtShortcut = (keys: string[]): string =>
|
||||
isMac
|
||||
? keys.map((k) => (k === "Shift" ? "⇧" : k)).join("")
|
||||
: keys.join("+");
|
||||
|
||||
export const SHORTCUTS: ShortcutRow[] = [
|
||||
// Tools
|
||||
{ section: "Tools", label: "Select tool", keys: ["V"] },
|
||||
{ section: "Tools", label: "Lasso tool", keys: ["L"] },
|
||||
{ section: "Tools", label: "Text tool", keys: ["T"] },
|
||||
|
||||
// Selection
|
||||
{ section: "Selection", label: "Select all", keys: [MOD, "A"] },
|
||||
{ section: "Selection", label: "Clear selection", keys: ["Esc"] },
|
||||
{ section: "Selection", label: "Add / remove from selection", keys: ["Shift", "Click"] },
|
||||
|
||||
// Editing
|
||||
{ section: "Editing", label: "Delete selection", keys: ["Delete"] },
|
||||
{ section: "Editing", label: "Group selection", keys: [MOD, "G"] },
|
||||
{ section: "Editing", label: "Ungroup selection", keys: [MOD, "Shift", "G"] },
|
||||
{ section: "Editing", label: "Paste image", keys: [MOD, "V"] },
|
||||
|
||||
// Viewport
|
||||
{ section: "Viewport", label: "Pan", keys: ["Middle-click", "drag"] },
|
||||
{ section: "Viewport", label: "Pan (alt)", keys: ["Space", "drag"] },
|
||||
{ section: "Viewport", label: "Zoom", keys: ["Scroll"] },
|
||||
{ section: "Viewport", label: "Reset zoom", keys: [MOD, "0"] },
|
||||
{ section: "Viewport", label: "Zoom to fit", keys: ["Shift", "1"] },
|
||||
|
||||
// History
|
||||
{ section: "History", label: "Undo", keys: [MOD, "Z"] },
|
||||
{ section: "History", label: "Redo", keys: [MOD, "Shift", "Z"] },
|
||||
];
|
||||
@@ -0,0 +1,71 @@
|
||||
export type Vec2 = { x: number; y: number };
|
||||
export type Rect = { x: number; y: number; width: number; height: number };
|
||||
export type Tool = "select" | "lasso" | "text";
|
||||
|
||||
interface BaseNode {
|
||||
id: string;
|
||||
parentId: string | null;
|
||||
// Stage-local for top-level nodes; group-local for children of a group.
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
// Degrees, Konva convention.
|
||||
rotation: number;
|
||||
zIndex: number;
|
||||
}
|
||||
|
||||
export type ImageNode = BaseNode & {
|
||||
kind: "image";
|
||||
src: string;
|
||||
mediaId: string | null;
|
||||
naturalW: number;
|
||||
naturalH: number;
|
||||
};
|
||||
|
||||
export type VideoNode = BaseNode & {
|
||||
kind: "video";
|
||||
src: string;
|
||||
mediaId: string | null;
|
||||
naturalW: number;
|
||||
naturalH: number;
|
||||
muted: boolean;
|
||||
loop: boolean;
|
||||
autoplay: boolean;
|
||||
};
|
||||
|
||||
export type TextNode = BaseNode & {
|
||||
kind: "text";
|
||||
text: string;
|
||||
fontSize: number;
|
||||
color: string;
|
||||
};
|
||||
|
||||
export type GroupNode = BaseNode & {
|
||||
kind: "group";
|
||||
childIds: string[];
|
||||
};
|
||||
|
||||
// A card stacks its children in a vertical flex column (see CardNodeHtml). Unlike
|
||||
// a group, children are laid out by the card (their own x/y are ignored) and the
|
||||
// card owns a padding + background. `width` is authoritative; `height` is derived
|
||||
// from the children via computeCardHeight whenever the child set changes.
|
||||
export type CardNode = BaseNode & {
|
||||
kind: "card";
|
||||
childIds: string[];
|
||||
padding: number;
|
||||
backgroundColor: string;
|
||||
};
|
||||
|
||||
export type MoodboardNode =
|
||||
| ImageNode
|
||||
| VideoNode
|
||||
| TextNode
|
||||
| GroupNode
|
||||
| CardNode;
|
||||
|
||||
export interface MoodboardSnapshot {
|
||||
nodes: Record<string, MoodboardNode>;
|
||||
rootOrder: string[];
|
||||
selectedIds: string[];
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user