feat: major editor overhaul (assets, properties, timeline, fonts) (#709)

* feat: major editor overhaul (assets, properties, timeline, fonts)

Refactor editor core systems to standardize UI architecture and improve performance.

Assets & Properties:
- Replace monolithic property items with composable `Section` architecture.
- Add specialized sections for Transform, Blending, and Text.
- Implement `NumberField` with scrubbing and math evaluation.
- Add new ColorPicker with EyeDropper and multiple format support.
- Standardize asset panels using new `PanelView` layout.

Fonts & Stickers:
- Implement custom font atlas/sprite system for high-performance previews.
- Add virtualized FontPicker with search and favorites.
- Refactor stickers to use a provider-based architecture (icons, emoji, flags, shapes).
- Standardize sticker IDs to `provider:value` format.

Timeline & Interaction:
- Convert bookmarks to rich objects with notes, colors, and duration.
- Refactor drag-and-drop to use Command pattern (enabling proper undo/redo).
- Add Shift modifier to disable snapping during moves/resizes.
- Add new overlays for layout guides and text editing.

Renderer:
- Add support for multi-line text, custom line-height, and letter-spacing.
- Implement global composite operation (blend modes).
- Update sticker node to resolve dynamic provider IDs.

Infrastructure:
- Add storage migrations (v3->v6) for text weights, sticker IDs, and bookmarks.
- Update global styles and core UI components (Button, Input, Popover).

* add ts-nocheck directive to settings-legacy.tsx to suppress TypeScript errors

* fix: correct global composite operation assignment in TextNode to ensure proper blend mode handling

* deleted shadcn components with errors

* formatting

* fix linter issues

* migrate from next middleware to proxy

* add missing component back

* add breadcrumb back

* chore: add @radix-ui/react-primitive deps

* chore: more deps

* chore: add missing env vars to bun-ci

* next env
This commit is contained in:
Maze 2026-02-23 03:24:02 +01:00 committed by GitHub
parent fca99d6126
commit 93d1e3383c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
215 changed files with 26981 additions and 8365 deletions

View File

@ -49,7 +49,7 @@ Review every point below carefully to ensure files follow consistent code style
- [ ] Readability over brevity — use `element` not `el`, `event` not `e` - [ ] Readability over brevity — use `element` not `el`, `event` not `e`
- [ ] Booleans are named `isSomething`, `hasSomething`, or `shouldSomething` — not `something` - [ ] Booleans are named `isSomething`, `hasSomething`, or `shouldSomething` — not `something`
- [ ] No title case in text/UI — use `Hello world` not `Hello World` - [ ] No title case for multi-word text/UI — use `Hello world` not `Hello World`
## Tailwind & Styling ## Tailwind & Styling
@ -61,12 +61,15 @@ Review every point below carefully to ensure files follow consistent code style
<PlusIcon className="!size-6" /> {/* ✅ correct */} <PlusIcon className="!size-6" /> {/* ✅ correct */}
<PlusIcon className="size-6" /> {/* ❌ wrong */} <PlusIcon className="size-6" /> {/* ❌ wrong */}
<PlusIcon className="!size-4" /> {/* ❌ unnecessary, size-4 is default */} <PlusIcon className="!size-4" /> {/* ❌ unnecessary, size-4 is default */}
<PlusIcon className="size-4" />{" "}
{/* ❌ completely wrong, 1) doesn't override and 2) size-4 is default */}
</Button> </Button>
``` ```
## State Management (Zustand) ## State Management (Zustand)
- [ ] React components never use `someStore.getState()` — use the `useSomeStore` hook instead - [ ] React components never use `someStore.getState()` — use the `useSomeStore` hook instead
- [ ] High-frequency stores (timeline, playback, selections) use selectors — `useStore((s) => s.value)` not `const { value } = useStore()`
- [ ] Store/manager methods are not passed as props — sub-components access them directly - [ ] Store/manager methods are not passed as props — sub-components access them directly
```tsx ```tsx
@ -91,6 +94,11 @@ Review every point below carefully to ensure files follow consistent code style
- [ ] Code is scannable — use variables and helper functions to make intent clear at a glance - [ ] Code is scannable — use variables and helper functions to make intent clear at a glance
- [ ] Complex logic is extracted into well-named variables or helpers - [ ] Complex logic is extracted into well-named variables or helpers
- [ ] No magic numbers or magic values — extract inline literals into named constants
- Applies to colors, durations, thresholds, sizes, config values, etc.
- If it's domain-specific to one file, a `const` at the top of that file is fine
- If it's generic enough, it belongs in `constants/`
- [ ] No redundant single/plural function variants — if a function can operate on multiple items, it should accept an array and handle both cases. Don't create `doThing()` + `doThings()`. - [ ] No redundant single/plural function variants — if a function can operate on multiple items, it should accept an array and handle both cases. Don't create `doThing()` + `doThings()`.
```tsx ```tsx
@ -116,4 +124,25 @@ Review every point below carefully to ensure files follow consistent code style
--- ---
> Every decision, every edit must be carefully considered. Everything matters. ## Review Methodology
Do NOT review by reading the file top-to-bottom and noting what jumps out. Instead:
1. Go through each checklist section **one at a time**
2. For each section, scan the **entire file** for violations of that specific rule
3. Only move to the next section after you've exhausted the current one
4. After all sections are checked, do a final pass: re-read every checklist item and confirm you didn't skip it
Before outputting the table, list each checklist section and confirm you checked it:
`Signatures ✓ | TypeScript ✓ | JSX ✓ | Organization ✓ | Comments ✓ | Naming ✓ | Tailwind ✓ | State ✓ | Quality ✓ | Keywords ✓`
---
## IMPORTANT: Review Rules
- **ONLY** flag issues that are explicitly covered by a checklist item above.
- Do **NOT** invent your own rules, suggestions, or "nice to haves" as primary issues.
- The review output must be a table of issues, each one mapping to a specific checklist item.
- If something looks off but isn't covered by the checklist, you can mention it as a brief side note at the end — but keep it clearly separate from the actual review. Always default to fixing the issues covered by the checklist above, unless the user says otherwise.
> You WILL miss things if you try to review the whole file in one pass. Iterate rule by rule.

File diff suppressed because it is too large Load Diff

7
.cursor/settings.json Normal file
View File

@ -0,0 +1,7 @@
{
"plugins": {
"figma": {
"enabled": true
}
}
}

View File

@ -27,7 +27,16 @@ jobs:
NEXT_PUBLIC_SITE_URL: "http://localhost:3000" NEXT_PUBLIC_SITE_URL: "http://localhost:3000"
UPSTASH_REDIS_REST_URL: "https://your-upstash-redis-url" UPSTASH_REDIS_REST_URL: "https://your-upstash-redis-url"
UPSTASH_REDIS_REST_TOKEN: "your-upstash-redis-token" UPSTASH_REDIS_REST_TOKEN: "your-upstash-redis-token"
NEXT_PUBLIC_MARBLE_API_URL: "https://placeholder.example.com"
MARBLE_WORKSPACE_KEY: "placeholder"
FREESOUND_CLIENT_ID: "placeholder"
FREESOUND_API_KEY: "placeholder"
CLOUDFLARE_ACCOUNT_ID: "placeholder"
R2_ACCESS_KEY_ID: "placeholder"
R2_SECRET_ACCESS_KEY: "placeholder"
R2_BUCKET_NAME: "placeholder"
MODAL_TRANSCRIPTION_URL: "https://placeholder.example.com"
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4

36
.vscode/settings.json vendored
View File

@ -1,20 +1,20 @@
{ {
"editor.defaultFormatter": "esbenp.prettier-vscode", "editor.defaultFormatter": "esbenp.prettier-vscode",
"[javascript][typescript][javascriptreact][typescriptreact][json][jsonc][css][graphql]": { "[javascript][typescript][javascriptreact][typescriptreact][json][jsonc][css][graphql]": {
"editor.defaultFormatter": "esbenp.prettier-vscode" "editor.defaultFormatter": "esbenp.prettier-vscode"
}, },
"typescript.tsdk": "node_modules/typescript/lib", "typescript.tsdk": "node_modules/typescript/lib",
"editor.formatOnSave": true, "editor.formatOnSave": true,
"editor.formatOnPaste": true, "editor.formatOnPaste": true,
"editor.codeActionsOnSave": { "editor.codeActionsOnSave": {
"source.fixAll.biome": "explicit", "source.fixAll.biome": "explicit",
"source.organizeImports.biome": "explicit" "source.organizeImports.biome": "explicit"
}, },
"emmet.showExpandedAbbreviation": "never", "emmet.showExpandedAbbreviation": "never",
"[typescriptreact]": { "[typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome" "editor.defaultFormatter": "biomejs.biome"
}, },
"[typescript]": { "[typescript]": {
"editor.defaultFormatter": "biomejs.biome" "editor.defaultFormatter": "biomejs.biome"
} }
} }

4
apps/web/.gitignore vendored
View File

@ -5,4 +5,6 @@
.env* .env*
!.env.example !.env.example
.next/ .next/
.font-cache/

View File

@ -1,21 +1,21 @@
{ {
"$schema": "https://ui.shadcn.com/schema.json", "$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york", "style": "new-york",
"rsc": true, "rsc": true,
"tsx": true, "tsx": true,
"tailwind": { "tailwind": {
"config": "", "config": "",
"css": "src/app/globals.css", "css": "src/app/globals.css",
"baseColor": "neutral", "baseColor": "neutral",
"cssVariables": true, "cssVariables": true,
"prefix": "" "prefix": ""
}, },
"aliases": { "aliases": {
"components": "@/components", "components": "@/components",
"utils": "@/lib/utils", "utils": "@/lib/utils",
"ui": "@/components/ui", "ui": "@/components/ui",
"lib": "@/lib", "lib": "@/lib",
"hooks": "@/hooks" "hooks": "@/hooks"
}, },
"iconLibrary": "lucide" "iconLibrary": "lucide"
} }

View File

@ -4,20 +4,20 @@ import { webEnv } from "@opencut/env/web";
// Load the right env file based on environment // Load the right env file based on environment
if (webEnv.NODE_ENV === "production") { if (webEnv.NODE_ENV === "production") {
dotenv.config({ path: ".env.production" }); dotenv.config({ path: ".env.production" });
} else { } else {
dotenv.config({ path: ".env.local" }); dotenv.config({ path: ".env.local" });
} }
export default { export default {
schema: "./src/schema.ts", schema: "./src/schema.ts",
dialect: "postgresql", dialect: "postgresql",
migrations: { migrations: {
table: "drizzle_migrations", table: "drizzle_migrations",
}, },
dbCredentials: { dbCredentials: {
url: webEnv.DATABASE_URL, url: webEnv.DATABASE_URL,
}, },
out: "./migrations", out: "./migrations",
strict: webEnv.NODE_ENV === "production", strict: webEnv.NODE_ENV === "production",
} satisfies Config; } satisfies Config;

View File

@ -1,344 +1,344 @@
{ {
"id": "33a6742f-89da-4ac5-958f-421aa1cf9bd6", "id": "33a6742f-89da-4ac5-958f-421aa1cf9bd6",
"prevId": "00000000-0000-0000-0000-000000000000", "prevId": "00000000-0000-0000-0000-000000000000",
"version": "7", "version": "7",
"dialect": "postgresql", "dialect": "postgresql",
"tables": { "tables": {
"public.accounts": { "public.accounts": {
"name": "accounts", "name": "accounts",
"schema": "", "schema": "",
"columns": { "columns": {
"id": { "id": {
"name": "id", "name": "id",
"type": "text", "type": "text",
"primaryKey": true, "primaryKey": true,
"notNull": true "notNull": true
}, },
"account_id": { "account_id": {
"name": "account_id", "name": "account_id",
"type": "text", "type": "text",
"primaryKey": false, "primaryKey": false,
"notNull": true "notNull": true
}, },
"provider_id": { "provider_id": {
"name": "provider_id", "name": "provider_id",
"type": "text", "type": "text",
"primaryKey": false, "primaryKey": false,
"notNull": true "notNull": true
}, },
"user_id": { "user_id": {
"name": "user_id", "name": "user_id",
"type": "text", "type": "text",
"primaryKey": false, "primaryKey": false,
"notNull": true "notNull": true
}, },
"access_token": { "access_token": {
"name": "access_token", "name": "access_token",
"type": "text", "type": "text",
"primaryKey": false, "primaryKey": false,
"notNull": false "notNull": false
}, },
"refresh_token": { "refresh_token": {
"name": "refresh_token", "name": "refresh_token",
"type": "text", "type": "text",
"primaryKey": false, "primaryKey": false,
"notNull": false "notNull": false
}, },
"id_token": { "id_token": {
"name": "id_token", "name": "id_token",
"type": "text", "type": "text",
"primaryKey": false, "primaryKey": false,
"notNull": false "notNull": false
}, },
"access_token_expires_at": { "access_token_expires_at": {
"name": "access_token_expires_at", "name": "access_token_expires_at",
"type": "timestamp", "type": "timestamp",
"primaryKey": false, "primaryKey": false,
"notNull": false "notNull": false
}, },
"refresh_token_expires_at": { "refresh_token_expires_at": {
"name": "refresh_token_expires_at", "name": "refresh_token_expires_at",
"type": "timestamp", "type": "timestamp",
"primaryKey": false, "primaryKey": false,
"notNull": false "notNull": false
}, },
"scope": { "scope": {
"name": "scope", "name": "scope",
"type": "text", "type": "text",
"primaryKey": false, "primaryKey": false,
"notNull": false "notNull": false
}, },
"password": { "password": {
"name": "password", "name": "password",
"type": "text", "type": "text",
"primaryKey": false, "primaryKey": false,
"notNull": false "notNull": false
}, },
"created_at": { "created_at": {
"name": "created_at", "name": "created_at",
"type": "timestamp", "type": "timestamp",
"primaryKey": false, "primaryKey": false,
"notNull": true "notNull": true
}, },
"updated_at": { "updated_at": {
"name": "updated_at", "name": "updated_at",
"type": "timestamp", "type": "timestamp",
"primaryKey": false, "primaryKey": false,
"notNull": true "notNull": true
} }
}, },
"indexes": {}, "indexes": {},
"foreignKeys": { "foreignKeys": {
"accounts_user_id_users_id_fk": { "accounts_user_id_users_id_fk": {
"name": "accounts_user_id_users_id_fk", "name": "accounts_user_id_users_id_fk",
"tableFrom": "accounts", "tableFrom": "accounts",
"tableTo": "users", "tableTo": "users",
"columnsFrom": ["user_id"], "columnsFrom": ["user_id"],
"columnsTo": ["id"], "columnsTo": ["id"],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
}, },
"compositePrimaryKeys": {}, "compositePrimaryKeys": {},
"uniqueConstraints": {}, "uniqueConstraints": {},
"policies": {}, "policies": {},
"checkConstraints": {}, "checkConstraints": {},
"isRLSEnabled": true "isRLSEnabled": true
}, },
"public.sessions": { "public.sessions": {
"name": "sessions", "name": "sessions",
"schema": "", "schema": "",
"columns": { "columns": {
"id": { "id": {
"name": "id", "name": "id",
"type": "text", "type": "text",
"primaryKey": true, "primaryKey": true,
"notNull": true "notNull": true
}, },
"expires_at": { "expires_at": {
"name": "expires_at", "name": "expires_at",
"type": "timestamp", "type": "timestamp",
"primaryKey": false, "primaryKey": false,
"notNull": true "notNull": true
}, },
"token": { "token": {
"name": "token", "name": "token",
"type": "text", "type": "text",
"primaryKey": false, "primaryKey": false,
"notNull": true "notNull": true
}, },
"created_at": { "created_at": {
"name": "created_at", "name": "created_at",
"type": "timestamp", "type": "timestamp",
"primaryKey": false, "primaryKey": false,
"notNull": true "notNull": true
}, },
"updated_at": { "updated_at": {
"name": "updated_at", "name": "updated_at",
"type": "timestamp", "type": "timestamp",
"primaryKey": false, "primaryKey": false,
"notNull": true "notNull": true
}, },
"ip_address": { "ip_address": {
"name": "ip_address", "name": "ip_address",
"type": "text", "type": "text",
"primaryKey": false, "primaryKey": false,
"notNull": false "notNull": false
}, },
"user_agent": { "user_agent": {
"name": "user_agent", "name": "user_agent",
"type": "text", "type": "text",
"primaryKey": false, "primaryKey": false,
"notNull": false "notNull": false
}, },
"user_id": { "user_id": {
"name": "user_id", "name": "user_id",
"type": "text", "type": "text",
"primaryKey": false, "primaryKey": false,
"notNull": true "notNull": true
} }
}, },
"indexes": {}, "indexes": {},
"foreignKeys": { "foreignKeys": {
"sessions_user_id_users_id_fk": { "sessions_user_id_users_id_fk": {
"name": "sessions_user_id_users_id_fk", "name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions", "tableFrom": "sessions",
"tableTo": "users", "tableTo": "users",
"columnsFrom": ["user_id"], "columnsFrom": ["user_id"],
"columnsTo": ["id"], "columnsTo": ["id"],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
}, },
"compositePrimaryKeys": {}, "compositePrimaryKeys": {},
"uniqueConstraints": { "uniqueConstraints": {
"sessions_token_unique": { "sessions_token_unique": {
"name": "sessions_token_unique", "name": "sessions_token_unique",
"nullsNotDistinct": false, "nullsNotDistinct": false,
"columns": ["token"] "columns": ["token"]
} }
}, },
"policies": {}, "policies": {},
"checkConstraints": {}, "checkConstraints": {},
"isRLSEnabled": true "isRLSEnabled": true
}, },
"public.users": { "public.users": {
"name": "users", "name": "users",
"schema": "", "schema": "",
"columns": { "columns": {
"id": { "id": {
"name": "id", "name": "id",
"type": "text", "type": "text",
"primaryKey": true, "primaryKey": true,
"notNull": true "notNull": true
}, },
"name": { "name": {
"name": "name", "name": "name",
"type": "text", "type": "text",
"primaryKey": false, "primaryKey": false,
"notNull": true "notNull": true
}, },
"email": { "email": {
"name": "email", "name": "email",
"type": "text", "type": "text",
"primaryKey": false, "primaryKey": false,
"notNull": true "notNull": true
}, },
"email_verified": { "email_verified": {
"name": "email_verified", "name": "email_verified",
"type": "boolean", "type": "boolean",
"primaryKey": false, "primaryKey": false,
"notNull": true "notNull": true
}, },
"image": { "image": {
"name": "image", "name": "image",
"type": "text", "type": "text",
"primaryKey": false, "primaryKey": false,
"notNull": false "notNull": false
}, },
"created_at": { "created_at": {
"name": "created_at", "name": "created_at",
"type": "timestamp", "type": "timestamp",
"primaryKey": false, "primaryKey": false,
"notNull": true "notNull": true
}, },
"updated_at": { "updated_at": {
"name": "updated_at", "name": "updated_at",
"type": "timestamp", "type": "timestamp",
"primaryKey": false, "primaryKey": false,
"notNull": true "notNull": true
} }
}, },
"indexes": {}, "indexes": {},
"foreignKeys": {}, "foreignKeys": {},
"compositePrimaryKeys": {}, "compositePrimaryKeys": {},
"uniqueConstraints": { "uniqueConstraints": {
"users_email_unique": { "users_email_unique": {
"name": "users_email_unique", "name": "users_email_unique",
"nullsNotDistinct": false, "nullsNotDistinct": false,
"columns": ["email"] "columns": ["email"]
} }
}, },
"policies": {}, "policies": {},
"checkConstraints": {}, "checkConstraints": {},
"isRLSEnabled": true "isRLSEnabled": true
}, },
"public.verifications": { "public.verifications": {
"name": "verifications", "name": "verifications",
"schema": "", "schema": "",
"columns": { "columns": {
"id": { "id": {
"name": "id", "name": "id",
"type": "text", "type": "text",
"primaryKey": true, "primaryKey": true,
"notNull": true "notNull": true
}, },
"identifier": { "identifier": {
"name": "identifier", "name": "identifier",
"type": "text", "type": "text",
"primaryKey": false, "primaryKey": false,
"notNull": true "notNull": true
}, },
"value": { "value": {
"name": "value", "name": "value",
"type": "text", "type": "text",
"primaryKey": false, "primaryKey": false,
"notNull": true "notNull": true
}, },
"expires_at": { "expires_at": {
"name": "expires_at", "name": "expires_at",
"type": "timestamp", "type": "timestamp",
"primaryKey": false, "primaryKey": false,
"notNull": true "notNull": true
}, },
"created_at": { "created_at": {
"name": "created_at", "name": "created_at",
"type": "timestamp", "type": "timestamp",
"primaryKey": false, "primaryKey": false,
"notNull": false "notNull": false
}, },
"updated_at": { "updated_at": {
"name": "updated_at", "name": "updated_at",
"type": "timestamp", "type": "timestamp",
"primaryKey": false, "primaryKey": false,
"notNull": false "notNull": false
} }
}, },
"indexes": {}, "indexes": {},
"foreignKeys": {}, "foreignKeys": {},
"compositePrimaryKeys": {}, "compositePrimaryKeys": {},
"uniqueConstraints": {}, "uniqueConstraints": {},
"policies": {}, "policies": {},
"checkConstraints": {}, "checkConstraints": {},
"isRLSEnabled": true "isRLSEnabled": true
}, },
"public.waitlist": { "public.waitlist": {
"name": "waitlist", "name": "waitlist",
"schema": "", "schema": "",
"columns": { "columns": {
"id": { "id": {
"name": "id", "name": "id",
"type": "text", "type": "text",
"primaryKey": true, "primaryKey": true,
"notNull": true "notNull": true
}, },
"email": { "email": {
"name": "email", "name": "email",
"type": "text", "type": "text",
"primaryKey": false, "primaryKey": false,
"notNull": true "notNull": true
}, },
"created_at": { "created_at": {
"name": "created_at", "name": "created_at",
"type": "timestamp", "type": "timestamp",
"primaryKey": false, "primaryKey": false,
"notNull": true "notNull": true
} }
}, },
"indexes": {}, "indexes": {},
"foreignKeys": {}, "foreignKeys": {},
"compositePrimaryKeys": {}, "compositePrimaryKeys": {},
"uniqueConstraints": { "uniqueConstraints": {
"waitlist_email_unique": { "waitlist_email_unique": {
"name": "waitlist_email_unique", "name": "waitlist_email_unique",
"nullsNotDistinct": false, "nullsNotDistinct": false,
"columns": ["email"] "columns": ["email"]
} }
}, },
"policies": {}, "policies": {},
"checkConstraints": {}, "checkConstraints": {},
"isRLSEnabled": true "isRLSEnabled": true
} }
}, },
"enums": {}, "enums": {},
"schemas": {}, "schemas": {},
"sequences": {}, "sequences": {},
"roles": {}, "roles": {},
"policies": {}, "policies": {},
"views": {}, "views": {},
"_meta": { "_meta": {
"columns": {}, "columns": {},
"schemas": {}, "schemas": {},
"tables": {} "tables": {}
} }
} }

View File

@ -1,13 +1,13 @@
{ {
"version": "7", "version": "7",
"dialect": "postgresql", "dialect": "postgresql",
"entries": [ "entries": [
{ {
"idx": 0, "idx": 0,
"version": "7", "version": "7",
"when": 1750753385927, "when": 1750753385927,
"tag": "0000_brainy_saracen", "tag": "0000_brainy_saracen",
"breakpoints": true "breakpoints": true
} }
] ]
} }

View File

@ -2,48 +2,48 @@ import type { NextConfig } from "next";
import { withBotId } from "botid/next/config"; import { withBotId } from "botid/next/config";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
compiler: { compiler: {
removeConsole: process.env.NODE_ENV === "production", removeConsole: process.env.NODE_ENV === "production",
}, },
reactStrictMode: true, reactStrictMode: true,
productionBrowserSourceMaps: true, productionBrowserSourceMaps: true,
output: "standalone", output: "standalone",
images: { images: {
remotePatterns: [ remotePatterns: [
{ {
protocol: "https", protocol: "https",
hostname: "plus.unsplash.com", hostname: "plus.unsplash.com",
}, },
{ {
protocol: "https", protocol: "https",
hostname: "images.unsplash.com", hostname: "images.unsplash.com",
}, },
{ {
protocol: "https", protocol: "https",
hostname: "images.marblecms.com", hostname: "images.marblecms.com",
}, },
{ {
protocol: "https", protocol: "https",
hostname: "lh3.googleusercontent.com", hostname: "lh3.googleusercontent.com",
}, },
{ {
protocol: "https", protocol: "https",
hostname: "avatars.githubusercontent.com", hostname: "avatars.githubusercontent.com",
}, },
{ {
protocol: "https", protocol: "https",
hostname: "api.iconify.design", hostname: "api.iconify.design",
}, },
{ {
protocol: "https", protocol: "https",
hostname: "api.simplesvg.com", hostname: "api.simplesvg.com",
}, },
{ {
protocol: "https", protocol: "https",
hostname: "api.unisvg.com", hostname: "api.unisvg.com",
}, },
], ],
}, },
}; };
export default withBotId(nextConfig); export default withBotId(nextConfig);

View File

@ -1,98 +1,107 @@
{ {
"name": "@opencut/web", "name": "@opencut/web",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"packageManager": "bun@1.2.18", "packageManager": "bun@1.2.18",
"scripts": { "scripts": {
"dev": "next dev --turbopack", "dev": "next dev --turbopack",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "biome check src/", "lint": "biome check src/",
"lint:fix": "biome check src/ --write", "lint:fix": "biome check src/ --write",
"format": "biome format src/ --write", "format": "biome format src/ --write",
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate", "db:migrate": "drizzle-kit migrate",
"db:push:local": "cross-env NODE_ENV=development drizzle-kit push", "db:push:local": "cross-env NODE_ENV=development drizzle-kit push",
"db:push:prod": "cross-env NODE_ENV=production drizzle-kit push" "db:push:prod": "cross-env NODE_ENV=production drizzle-kit push"
}, },
"dependencies": { "dependencies": {
"@ffmpeg/core": "^0.12.10", "@ffmpeg/core": "^0.12.10",
"@ffmpeg/ffmpeg": "^0.12.15", "@ffmpeg/ffmpeg": "^0.12.15",
"@ffmpeg/util": "^0.12.2", "@ffmpeg/util": "^0.12.2",
"@hello-pangea/dnd": "^18.0.1", "@hello-pangea/dnd": "^18.0.1",
"@hookform/resolvers": "^3.9.1", "@hookform/resolvers": "^3.9.1",
"@hugeicons/core-free-icons": "^3.1.1", "@hugeicons/core-free-icons": "^3.1.1",
"@hugeicons/react": "^1.1.4", "@hugeicons/react": "^1.1.4",
"@huggingface/transformers": "^3.8.1", "@huggingface/transformers": "^3.8.1",
"@opencut/env": "workspace:*", "@opencut/env": "workspace:*",
"@opencut/ui": "workspace:*", "@opencut/ui": "workspace:*",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-primitive": "^2.1.4",
"@upstash/ratelimit": "^2.0.6", "@radix-ui/react-select": "^2.2.6",
"@upstash/redis": "^1.35.4", "@radix-ui/react-separator": "^1.1.8",
"@vercel/analytics": "^1.4.1", "@radix-ui/react-slot": "^1.2.4",
"aws4fetch": "^1.0.20", "@radix-ui/react-tooltip": "^1.2.8",
"better-auth": "^1.2.7", "@types/culori": "^4.0.1",
"botid": "^1.4.2", "@upstash/ratelimit": "^2.0.6",
"class-variance-authority": "^0.7.1", "@upstash/redis": "^1.35.4",
"clsx": "^2.1.1", "@vercel/analytics": "^1.4.1",
"cmdk": "^1.0.0", "aws4fetch": "^1.0.20",
"dayjs": "^1.11.13", "better-auth": "^1.2.7",
"drizzle-orm": "^0.44.2", "botid": "^1.4.2",
"embla-carousel-react": "^8.5.1", "class-variance-authority": "^0.7.1",
"eventemitter3": "^5.0.1", "clsx": "^2.1.1",
"feed": "^5.1.0", "cmdk": "^1.0.0",
"input-otp": "^1.4.1", "culori": "^4.0.2",
"lucide-react": "^0.562.0", "dayjs": "^1.11.13",
"mediabunny": "^1.29.1", "drizzle-orm": "^0.44.2",
"motion": "^12.18.1", "embla-carousel-react": "^8.5.1",
"nanoid": "^5.1.5", "eventemitter3": "^5.0.1",
"next": "16.1.3", "feed": "^5.1.0",
"next-themes": "^0.4.4", "input-otp": "^1.4.1",
"pg": "^8.16.2", "lucide-react": "^0.562.0",
"postgres": "^3.4.5", "mediabunny": "^1.29.1",
"radix-ui": "^1.4.2", "motion": "^12.18.1",
"react": "^19.0.0", "nanoid": "^5.1.5",
"react-day-picker": "^8.10.1", "next": "16.1.3",
"react-dom": "^19.0.0", "next-themes": "^0.4.4",
"react-hook-form": "^7.54.0", "pg": "^8.16.2",
"react-icons": "^5.4.0", "postgres": "^3.4.5",
"react-markdown": "^10.1.0", "radix-ui": "^1.4.3",
"react-phone-number-input": "^3.4.11", "react": "^19.0.0",
"react-resizable-panels": "^2.1.7", "react-day-picker": "^8.10.1",
"recharts": "^2.14.1", "react-dom": "^19.0.0",
"rehype-autolink-headings": "^7.1.0", "react-hook-form": "^7.54.0",
"rehype-parse": "^9.0.1", "react-icons": "^5.4.0",
"rehype-sanitize": "^6.0.0", "react-markdown": "^10.1.0",
"rehype-slug": "^6.0.0", "react-phone-number-input": "^3.4.11",
"rehype-stringify": "^10.0.1", "react-resizable-panels": "^2.1.7",
"sonner": "^1.7.1", "react-window": "^2.2.7",
"tailwind-merge": "^2.5.5", "recharts": "^2.14.1",
"tailwindcss-animate": "^1.0.7", "rehype-autolink-headings": "^7.1.0",
"unified": "^11.0.5", "rehype-parse": "^9.0.1",
"use-deep-compare-effect": "^1.8.1", "rehype-sanitize": "^6.0.0",
"vaul": "^1.1.1", "rehype-slug": "^6.0.0",
"wavesurfer.js": "^7.9.8", "rehype-stringify": "^10.0.1",
"zod": "^3.25.67", "sonner": "^1.7.1",
"zustand": "^5.0.2" "tailwind-merge": "^2.5.5",
}, "tailwindcss-animate": "^1.0.7",
"devDependencies": { "unified": "^11.0.5",
"@tailwindcss/postcss": "^4.1.11", "use-deep-compare-effect": "^1.8.1",
"@tailwindcss/typography": "^0.5.16", "vaul": "^1.1.2",
"@types/bun": "latest", "wavesurfer.js": "^7.9.8",
"@types/node": "^24.2.1", "zod": "^3.25.67",
"@types/pg": "^8.15.4", "zustand": "^5.0.2"
"@types/react": "^19", },
"@types/react-dom": "^19", "devDependencies": {
"cross-env": "^7.0.3", "@napi-rs/canvas": "^0.1.92",
"drizzle-kit": "^0.31.4", "@tailwindcss/postcss": "^4.1.11",
"dotenv": "^16.5.0", "@tailwindcss/typography": "^0.5.16",
"postcss": "^8", "@types/bun": "latest",
"tailwindcss": "^4.1.11", "@types/node": "^24.2.1",
"tsx": "^4.7.1", "@types/pg": "^8.15.4",
"typescript": "^5.8.3" "@types/react": "^19",
} "@types/react-dom": "^19",
"cross-env": "^7.0.3",
"dotenv": "^16.5.0",
"drizzle-kit": "^0.31.4",
"postcss": "^8",
"sharp": "^0.34.5",
"tailwindcss": "^4.1.11",
"tsx": "^4.7.1",
"typescript": "^5.8.3"
}
} }

View File

@ -1,8 +1,8 @@
/** @type {import('postcss-load-config').Config} */ /** @type {import('postcss-load-config').Config} */
const config = { const config = {
plugins: { plugins: {
"@tailwindcss/postcss": {}, "@tailwindcss/postcss": {},
}, },
}; };
export default config; export default config;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,44 +1,44 @@
{ {
"name": "OpenCut", "name": "OpenCut",
"description": "A simple but powerful video editor that gets the job done. In your browser.", "description": "A simple but powerful video editor that gets the job done. In your browser.",
"display": "standalone", "display": "standalone",
"start_url": "/", "start_url": "/",
"icons": [ "icons": [
{ {
"src": "/icons/android-icon-36x36.png", "src": "/icons/android-icon-36x36.png",
"sizes": "36x36", "sizes": "36x36",
"type": "image\/png", "type": "image\/png",
"density": "0.75" "density": "0.75"
}, },
{ {
"src": "/icons/android-icon-48x48.png", "src": "/icons/android-icon-48x48.png",
"sizes": "48x48", "sizes": "48x48",
"type": "image\/png", "type": "image\/png",
"density": "1.0" "density": "1.0"
}, },
{ {
"src": "/icons/android-icon-72x72.png", "src": "/icons/android-icon-72x72.png",
"sizes": "72x72", "sizes": "72x72",
"type": "image\/png", "type": "image\/png",
"density": "1.5" "density": "1.5"
}, },
{ {
"src": "/icons/android-icon-96x96.png", "src": "/icons/android-icon-96x96.png",
"sizes": "96x96", "sizes": "96x96",
"type": "image\/png", "type": "image\/png",
"density": "2.0" "density": "2.0"
}, },
{ {
"src": "/icons/android-icon-144x144.png", "src": "/icons/android-icon-144x144.png",
"sizes": "144x144", "sizes": "144x144",
"type": "image\/png", "type": "image\/png",
"density": "3.0" "density": "3.0"
}, },
{ {
"src": "/icons/android-icon-192x192.png", "src": "/icons/android-icon-192x192.png",
"sizes": "192x192", "sizes": "192x192",
"type": "image\/png", "type": "image\/png",
"density": "4.0" "density": "4.0"
} }
] ]
} }

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<circle cx="50" cy="50" r="44" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 126 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<polygon points="50,6 94,50 50,94 6,50" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 135 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<polygon points="28,8 72,8 94,50 72,92 28,92 6,50" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 146 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<rect x="10" y="10" width="80" height="80" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 138 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<polygon points="50,6 61,37 94,37 67,56 77,88 50,68 23,88 33,56 6,37 39,37" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 171 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<polygon points="50,8 94,92 6,92" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 129 B

View File

@ -0,0 +1,281 @@
/**
* Generates font sprite atlas for the font picker.
*
* Downloads Google Fonts from Fontsource, renders each font name as a sprite,
* packs them into chunk images, and outputs a JSON atlas + AVIF images.
*
* Run: npx tsx scripts/generate-font-sprites.ts
* Deps: @napi-rs/canvas sharp (install as devDependencies)
*/
import { createCanvas, GlobalFonts } from "@napi-rs/canvas";
import sharp from "sharp";
import { mkdir, writeFile, readFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const FONT_SIZE = 24;
const ROW_HEIGHT = 40;
const CANVAS_WIDTH = 1200;
const MAX_CHUNK_HEIGHT = 800;
const PADDING_X = 14;
const WIDTH_BUFFER = 8;
const CONCURRENT_DOWNLOADS = 30;
const OUTPUT_DIR = join(__dirname, "..", "public", "fonts");
const CACHE_DIR = join(__dirname, "..", ".font-cache");
const FONTSOURCE_API = "https://api.fontsource.org/v1/fonts";
interface FontsourceFont {
id: string;
family: string;
subsets: string[];
weights: number[];
styles: string[];
category: string;
type: string;
}
interface MeasuredFont {
family: string;
width: number;
styles: string[];
}
interface PackedFont {
family: string;
x: number;
y: number;
w: number;
}
interface AtlasEntry {
x: number;
y: number;
w: number;
ch: number;
s: string[];
}
async function fetchFontList(): Promise<FontsourceFont[]> {
console.log("Fetching font list from Fontsource...");
const response = await fetch(FONTSOURCE_API);
if (!response.ok) throw new Error(`Fontsource API error: ${response.status}`);
const data: FontsourceFont[] = await response.json();
const filtered = data.filter(
(font) =>
font.type === "google" &&
font.subsets.includes("latin") &&
font.weights.includes(400),
);
console.log(
` ${filtered.length} Google fonts with Latin subset + 400 weight`,
);
return filtered;
}
async function downloadFont({ id }: { id: string }): Promise<Buffer | null> {
const cachePath = join(CACHE_DIR, `${id}.woff2`);
if (existsSync(cachePath)) {
return readFile(cachePath);
}
// Fontsource CDN serves woff2 for all Google fonts
const url = `https://cdn.jsdelivr.net/fontsource/fonts/${id}@latest/latin-400-normal.woff2`;
try {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const buffer = Buffer.from(await response.arrayBuffer());
await writeFile(cachePath, buffer);
return buffer;
} catch {
return null;
}
}
async function downloadAllFonts({
fonts,
concurrency,
}: {
fonts: FontsourceFont[];
concurrency: number;
}): Promise<Map<string, Buffer>> {
console.log(
`Downloading ${fonts.length} font files (${concurrency} concurrent)...`,
);
const results = new Map<string, Buffer>();
let nextIndex = 0;
let completed = 0;
async function worker() {
while (nextIndex < fonts.length) {
const index = nextIndex++;
const font = fonts[index];
const buffer = await downloadFont({ id: font.id });
if (buffer) results.set(font.id, buffer);
completed++;
if (completed % 100 === 0 || completed === fonts.length) {
process.stdout.write(`\r ${completed}/${fonts.length}`);
}
}
}
await Promise.all(Array.from({ length: concurrency }, () => worker()));
console.log(`\n Downloaded ${results.size}/${fonts.length} fonts`);
return results;
}
function measureFonts({
fonts,
fontBuffers,
}: {
fonts: FontsourceFont[];
fontBuffers: Map<string, Buffer>;
}): MeasuredFont[] {
console.log("Registering fonts and measuring text...");
const measured: MeasuredFont[] = [];
const canvas = createCanvas(CANVAS_WIDTH, ROW_HEIGHT);
const ctx = canvas.getContext("2d");
for (const font of fonts) {
const buffer = fontBuffers.get(font.id);
if (!buffer) continue;
try {
const ok = GlobalFonts.register(buffer, font.family);
if (!ok) continue;
ctx.font = `${FONT_SIZE}px "${font.family}"`;
const metrics = ctx.measureText(font.family);
const width = Math.ceil(metrics.width) + WIDTH_BUFFER;
const styles: string[] = [];
for (const weight of font.weights) {
if (font.styles.includes("normal")) styles.push(String(weight));
if (font.styles.includes("italic")) styles.push(`${weight}i`);
}
measured.push({ family: font.family, width, styles });
} catch {
// skip fonts that fail to register
}
}
measured.sort((a, b) => a.family.localeCompare(b.family));
console.log(` ${measured.length} fonts measured`);
return measured;
}
function packIntoChunks({ measured }: { measured: MeasuredFont[] }): {
atlas: Record<string, AtlasEntry>;
chunks: PackedFont[][];
} {
console.log("Packing into sprite chunks...");
const atlas: Record<string, AtlasEntry> = {};
const chunks: PackedFont[][] = [[]];
let chunkIndex = 0;
let cursorX = 0;
let cursorY = 0;
for (const font of measured) {
// New row if doesn't fit horizontally
if (cursorX + font.width > CANVAS_WIDTH) {
cursorX = 0;
cursorY += ROW_HEIGHT;
}
// New chunk if doesn't fit vertically
if (cursorY + ROW_HEIGHT > MAX_CHUNK_HEIGHT) {
chunkIndex++;
chunks.push([]);
cursorX = 0;
cursorY = 0;
}
// Same data goes to BOTH the atlas and the chunk render list
const x = cursorX;
const y = cursorY;
atlas[font.family] = {
x,
y,
w: font.width,
ch: chunkIndex,
s: font.styles,
};
chunks[chunkIndex].push({ family: font.family, x, y, w: font.width });
cursorX += font.width + PADDING_X;
}
console.log(` ${chunks.length} chunks`);
return { atlas, chunks };
}
async function renderChunks({
chunks,
}: {
chunks: PackedFont[][];
}): Promise<void> {
console.log("Rendering sprite chunks...");
for (let i = 0; i < chunks.length; i++) {
const chunk = chunks[i];
const chunkHeight = Math.max(...chunk.map((f) => f.y)) + ROW_HEIGHT;
const canvas = createCanvas(CANVAS_WIDTH, chunkHeight);
const ctx = canvas.getContext("2d");
for (const font of chunk) {
ctx.font = `${FONT_SIZE}px "${font.family}"`;
ctx.fillStyle = "#000000";
ctx.textBaseline = "middle";
ctx.fillText(font.family, font.x, font.y + ROW_HEIGHT / 2);
}
const pngBuffer = canvas.toBuffer("image/png");
await sharp(pngBuffer)
.avif({ quality: 80 })
.toFile(join(OUTPUT_DIR, `font-chunk-${i}.avif`));
console.log(
` Chunk ${i}: ${chunk.length} fonts, ${CANVAS_WIDTH}×${chunkHeight}`,
);
}
}
async function main() {
await mkdir(OUTPUT_DIR, { recursive: true });
await mkdir(CACHE_DIR, { recursive: true });
const fonts = await fetchFontList();
const fontBuffers = await downloadAllFonts({
fonts,
concurrency: CONCURRENT_DOWNLOADS,
});
const measured = measureFonts({ fonts, fontBuffers });
const { atlas, chunks } = packIntoChunks({ measured });
await renderChunks({ chunks });
// Write atlas JSON (compact for smaller file size)
await writeFile(
join(OUTPUT_DIR, "font-atlas.json"),
JSON.stringify({ fonts: atlas }),
);
const totalFonts = Object.keys(atlas).length;
console.log(
`\nDone! ${totalFonts} fonts in ${chunks.length} chunks → ${OUTPUT_DIR}`,
);
}
main().catch((error) => {
console.error("Failed:", error);
process.exit(1);
});

View File

@ -1,280 +1,280 @@
import { webEnv } from "@opencut/env/web"; import { webEnv } from "@opencut/env/web";
import { type NextRequest, NextResponse } from "next/server"; import { type NextRequest, NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { checkRateLimit } from "@/lib/rate-limit"; import { checkRateLimit } from "@/lib/rate-limit";
const searchParamsSchema = z.object({ const searchParamsSchema = z.object({
q: z.string().max(500, "Query too long").optional(), q: z.string().max(500, "Query too long").optional(),
type: z.enum(["songs", "effects"]).optional(), type: z.enum(["songs", "effects"]).optional(),
page: z.coerce.number().int().min(1).max(1000).default(1), page: z.coerce.number().int().min(1).max(1000).default(1),
page_size: z.coerce.number().int().min(1).max(150).default(20), page_size: z.coerce.number().int().min(1).max(150).default(20),
sort: z sort: z
.enum(["downloads", "rating", "created", "score"]) .enum(["downloads", "rating", "created", "score"])
.default("downloads"), .default("downloads"),
min_rating: z.coerce.number().min(0).max(5).default(3), min_rating: z.coerce.number().min(0).max(5).default(3),
commercial_only: z.coerce.boolean().default(true), commercial_only: z.coerce.boolean().default(true),
}); });
const freesoundResultSchema = z.object({ const freesoundResultSchema = z.object({
id: z.number(), id: z.number(),
name: z.string(), name: z.string(),
description: z.string(), description: z.string(),
url: z.string().url(), url: z.string().url(),
previews: z previews: z
.object({ .object({
"preview-hq-mp3": z.string().url(), "preview-hq-mp3": z.string().url(),
"preview-lq-mp3": z.string().url(), "preview-lq-mp3": z.string().url(),
"preview-hq-ogg": z.string().url(), "preview-hq-ogg": z.string().url(),
"preview-lq-ogg": z.string().url(), "preview-lq-ogg": z.string().url(),
}) })
.optional(), .optional(),
download: z.string().url().optional(), download: z.string().url().optional(),
duration: z.number(), duration: z.number(),
filesize: z.number(), filesize: z.number(),
type: z.string(), type: z.string(),
channels: z.number(), channels: z.number(),
bitrate: z.number(), bitrate: z.number(),
bitdepth: z.number(), bitdepth: z.number(),
samplerate: z.number(), samplerate: z.number(),
username: z.string(), username: z.string(),
tags: z.array(z.string()), tags: z.array(z.string()),
license: z.string(), license: z.string(),
created: z.string(), created: z.string(),
num_downloads: z.number().optional(), num_downloads: z.number().optional(),
avg_rating: z.number().optional(), avg_rating: z.number().optional(),
num_ratings: z.number().optional(), num_ratings: z.number().optional(),
}); });
const freesoundResponseSchema = z.object({ const freesoundResponseSchema = z.object({
count: z.number(), count: z.number(),
next: z.string().url().nullable(), next: z.string().url().nullable(),
previous: z.string().url().nullable(), previous: z.string().url().nullable(),
results: z.array(freesoundResultSchema), results: z.array(freesoundResultSchema),
}); });
const transformedResultSchema = z.object({ const transformedResultSchema = z.object({
id: z.number(), id: z.number(),
name: z.string(), name: z.string(),
description: z.string(), description: z.string(),
url: z.string(), url: z.string(),
previewUrl: z.string().optional(), previewUrl: z.string().optional(),
downloadUrl: z.string().optional(), downloadUrl: z.string().optional(),
duration: z.number(), duration: z.number(),
filesize: z.number(), filesize: z.number(),
type: z.string(), type: z.string(),
channels: z.number(), channels: z.number(),
bitrate: z.number(), bitrate: z.number(),
bitdepth: z.number(), bitdepth: z.number(),
samplerate: z.number(), samplerate: z.number(),
username: z.string(), username: z.string(),
tags: z.array(z.string()), tags: z.array(z.string()),
license: z.string(), license: z.string(),
created: z.string(), created: z.string(),
downloads: z.number().optional(), downloads: z.number().optional(),
rating: z.number().optional(), rating: z.number().optional(),
ratingCount: z.number().optional(), ratingCount: z.number().optional(),
}); });
const apiResponseSchema = z.object({ const apiResponseSchema = z.object({
count: z.number(), count: z.number(),
next: z.string().nullable(), next: z.string().nullable(),
previous: z.string().nullable(), previous: z.string().nullable(),
results: z.array(transformedResultSchema), results: z.array(transformedResultSchema),
query: z.string().optional(), query: z.string().optional(),
type: z.string(), type: z.string(),
page: z.number(), page: z.number(),
pageSize: z.number(), pageSize: z.number(),
sort: z.string(), sort: z.string(),
minRating: z.number().optional(), minRating: z.number().optional(),
}); });
function buildSortParameter({ query, sort }: { query?: string; sort: string }) { function buildSortParameter({ query, sort }: { query?: string; sort: string }) {
if (!query) return `${sort}_desc`; if (!query) return `${sort}_desc`;
return sort === "score" ? "score" : `${sort}_desc`; return sort === "score" ? "score" : `${sort}_desc`;
} }
function applyEffectsFilters({ function applyEffectsFilters({
params, params,
min_rating, min_rating,
commercial_only, commercial_only,
}: { }: {
params: URLSearchParams; params: URLSearchParams;
min_rating: number; min_rating: number;
commercial_only: boolean; commercial_only: boolean;
}) { }) {
params.append("filter", "duration:[* TO 30.0]"); params.append("filter", "duration:[* TO 30.0]");
params.append("filter", `avg_rating:[${min_rating} TO *]`); params.append("filter", `avg_rating:[${min_rating} TO *]`);
if (commercial_only) { if (commercial_only) {
params.append( params.append(
"filter", "filter",
'license:("Attribution" OR "Creative Commons 0" OR "Attribution Noncommercial" OR "Attribution Commercial")', 'license:("Attribution" OR "Creative Commons 0" OR "Attribution Noncommercial" OR "Attribution Commercial")',
); );
} }
params.append( params.append(
"filter", "filter",
"tag:sound-effect OR tag:sfx OR tag:foley OR tag:ambient OR tag:nature OR tag:mechanical OR tag:electronic OR tag:impact OR tag:whoosh OR tag:explosion", "tag:sound-effect OR tag:sfx OR tag:foley OR tag:ambient OR tag:nature OR tag:mechanical OR tag:electronic OR tag:impact OR tag:whoosh OR tag:explosion",
); );
} }
function transformFreesoundResult( function transformFreesoundResult(
result: z.infer<typeof freesoundResultSchema>, result: z.infer<typeof freesoundResultSchema>,
) { ) {
return { return {
id: result.id, id: result.id,
name: result.name, name: result.name,
description: result.description, description: result.description,
url: result.url, url: result.url,
previewUrl: previewUrl:
result.previews?.["preview-hq-mp3"] || result.previews?.["preview-hq-mp3"] ||
result.previews?.["preview-lq-mp3"], result.previews?.["preview-lq-mp3"],
downloadUrl: result.download, downloadUrl: result.download,
duration: result.duration, duration: result.duration,
filesize: result.filesize, filesize: result.filesize,
type: result.type, type: result.type,
channels: result.channels, channels: result.channels,
bitrate: result.bitrate, bitrate: result.bitrate,
bitdepth: result.bitdepth, bitdepth: result.bitdepth,
samplerate: result.samplerate, samplerate: result.samplerate,
username: result.username, username: result.username,
tags: result.tags, tags: result.tags,
license: result.license, license: result.license,
created: result.created, created: result.created,
downloads: result.num_downloads || 0, downloads: result.num_downloads || 0,
rating: result.avg_rating || 0, rating: result.avg_rating || 0,
ratingCount: result.num_ratings || 0, ratingCount: result.num_ratings || 0,
}; };
} }
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
const { limited } = await checkRateLimit({ request }); const { limited } = await checkRateLimit({ request });
if (limited) { if (limited) {
return NextResponse.json({ error: "Too many requests" }, { status: 429 }); return NextResponse.json({ error: "Too many requests" }, { status: 429 });
} }
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const validationResult = searchParamsSchema.safeParse({ const validationResult = searchParamsSchema.safeParse({
q: searchParams.get("q") || undefined, q: searchParams.get("q") || undefined,
type: searchParams.get("type") || undefined, type: searchParams.get("type") || undefined,
page: searchParams.get("page") || undefined, page: searchParams.get("page") || undefined,
page_size: searchParams.get("page_size") || undefined, page_size: searchParams.get("page_size") || undefined,
sort: searchParams.get("sort") || undefined, sort: searchParams.get("sort") || undefined,
min_rating: searchParams.get("min_rating") || undefined, min_rating: searchParams.get("min_rating") || undefined,
}); });
if (!validationResult.success) { if (!validationResult.success) {
return NextResponse.json( return NextResponse.json(
{ {
error: "Invalid parameters", error: "Invalid parameters",
details: validationResult.error.flatten().fieldErrors, details: validationResult.error.flatten().fieldErrors,
}, },
{ status: 400 }, { status: 400 },
); );
} }
const { const {
q: query, q: query,
type, type,
page, page,
page_size: pageSize, page_size: pageSize,
sort, sort,
min_rating, min_rating,
commercial_only, commercial_only,
} = validationResult.data; } = validationResult.data;
if (type === "songs") { if (type === "songs") {
return NextResponse.json( return NextResponse.json(
{ {
error: "Songs are not available yet", error: "Songs are not available yet",
message: message:
"Song search functionality is coming soon. Try searching for sound effects instead.", "Song search functionality is coming soon. Try searching for sound effects instead.",
}, },
{ status: 501 }, { status: 501 },
); );
} }
const baseUrl = "https://freesound.org/apiv2/search/text/"; const baseUrl = "https://freesound.org/apiv2/search/text/";
const sortParam = buildSortParameter({ query, sort }); const sortParam = buildSortParameter({ query, sort });
const params = new URLSearchParams({ const params = new URLSearchParams({
query: query || "", query: query || "",
token: webEnv.FREESOUND_API_KEY, token: webEnv.FREESOUND_API_KEY,
page: page.toString(), page: page.toString(),
page_size: pageSize.toString(), page_size: pageSize.toString(),
sort: sortParam, sort: sortParam,
fields: fields:
"id,name,description,url,previews,download,duration,filesize,type,channels,bitrate,bitdepth,samplerate,username,tags,license,created,num_downloads,avg_rating,num_ratings", "id,name,description,url,previews,download,duration,filesize,type,channels,bitrate,bitdepth,samplerate,username,tags,license,created,num_downloads,avg_rating,num_ratings",
}); });
const isEffectsSearch = type === "effects" || !type; const isEffectsSearch = type === "effects" || !type;
if (isEffectsSearch) { if (isEffectsSearch) {
applyEffectsFilters({ params, min_rating, commercial_only }); applyEffectsFilters({ params, min_rating, commercial_only });
} }
const response = await fetch(`${baseUrl}?${params.toString()}`); const response = await fetch(`${baseUrl}?${params.toString()}`);
if (!response.ok) { if (!response.ok) {
const errorText = await response.text(); const errorText = await response.text();
console.error("Freesound API error:", response.status, errorText); console.error("Freesound API error:", response.status, errorText);
return NextResponse.json( return NextResponse.json(
{ error: "Failed to search sounds" }, { error: "Failed to search sounds" },
{ status: response.status }, { status: response.status },
); );
} }
const rawData = await response.json(); const rawData = await response.json();
const freesoundValidation = freesoundResponseSchema.safeParse(rawData); const freesoundValidation = freesoundResponseSchema.safeParse(rawData);
if (!freesoundValidation.success) { if (!freesoundValidation.success) {
console.error( console.error(
"Invalid Freesound API response:", "Invalid Freesound API response:",
freesoundValidation.error, freesoundValidation.error,
); );
return NextResponse.json( return NextResponse.json(
{ error: "Invalid response from Freesound API" }, { error: "Invalid response from Freesound API" },
{ status: 502 }, { status: 502 },
); );
} }
const data = freesoundValidation.data; const data = freesoundValidation.data;
const transformedResults = data.results.map(transformFreesoundResult); const transformedResults = data.results.map(transformFreesoundResult);
const responseData = { const responseData = {
count: data.count, count: data.count,
next: data.next, next: data.next,
previous: data.previous, previous: data.previous,
results: transformedResults, results: transformedResults,
query: query || "", query: query || "",
type: type || "effects", type: type || "effects",
page, page,
pageSize, pageSize,
sort, sort,
minRating: min_rating, minRating: min_rating,
}; };
const responseValidation = apiResponseSchema.safeParse(responseData); const responseValidation = apiResponseSchema.safeParse(responseData);
if (!responseValidation.success) { if (!responseValidation.success) {
console.error( console.error(
"Invalid API response structure:", "Invalid API response structure:",
responseValidation.error, responseValidation.error,
); );
return NextResponse.json( return NextResponse.json(
{ error: "Internal response formatting error" }, { error: "Internal response formatting error" },
{ status: 500 }, { status: 500 },
); );
} }
return NextResponse.json(responseValidation.data); return NextResponse.json(responseValidation.data);
} catch (error) { } catch (error) {
console.error("Error searching sounds:", error); console.error("Error searching sounds:", error);
return NextResponse.json( return NextResponse.json(
{ error: "Internal server error" }, { error: "Internal server error" },
{ status: 500 }, { status: 500 },
); );
} }
} }

View File

@ -120,7 +120,9 @@ function PostMeta({ date, publishedAt }: { date: string; publishedAt: Date }) {
function PostTitle({ title }: { title: string }) { function PostTitle({ title }: { title: string }) {
return ( return (
<h1 className="text-5xl font-bold tracking-tight md:text-4xl text-center">{title}</h1> <h1 className="text-5xl font-bold tracking-tight md:text-4xl text-center">
{title}
</h1>
); );
} }

View File

@ -15,6 +15,7 @@ import { EditorProvider } from "@/components/providers/editor-provider";
import { Onboarding } from "@/components/editor/onboarding"; import { Onboarding } from "@/components/editor/onboarding";
import { MigrationDialog } from "@/components/editor/dialogs/migration-dialog"; import { MigrationDialog } from "@/components/editor/dialogs/migration-dialog";
import { usePanelStore } from "@/stores/panel-store"; import { usePanelStore } from "@/stores/panel-store";
import { usePasteMedia } from "@/hooks/use-paste-media";
export default function Editor() { export default function Editor() {
const params = useParams(); const params = useParams();
@ -35,6 +36,7 @@ export default function Editor() {
} }
function EditorLayout() { function EditorLayout() {
usePasteMedia();
const { panels, setPanel } = usePanelStore(); const { panels, setPanel } = usePanelStore();
return ( return (

View File

@ -8,115 +8,115 @@
@plugin "tailwindcss-animate"; @plugin "tailwindcss-animate";
:root { :root {
--background: hsl(0, 0%, 100%); --background: hsl(0, 0%, 100%);
--foreground: hsl(0 0% 11%); --foreground: hsl(0 0% 11%);
--card: hsl(0, 0%, 100%); --card: hsl(0, 0%, 100%);
--card-foreground: hsl(0 0% 11%); --card-foreground: hsl(0 0% 11%);
--popover: hsl(0, 0%, 100%); --popover: hsl(0, 0%, 100%);
--popover-hover: hsl(0, 0%, 96%); --popover-hover: hsl(0, 0%, 96%);
--popover-foreground: hsl(0 0% 2%); --popover-foreground: hsl(0 0% 2%);
--primary: #009dff; --primary: #009dff;
--primary-foreground: hsl(0, 0%, 100%); --primary-foreground: hsl(0, 0%, 100%);
--secondary: hsl(204, 100%, 97%); --secondary: hsl(204, 100%, 97%);
--secondary-border: hsl(204, 100%, 94%); --secondary-border: hsl(204, 100%, 94%);
--secondary-foreground: hsl(200, 98%, 39%); --secondary-foreground: hsl(200, 98%, 39%);
--muted: hsl(0 0% 85.1%); --muted: hsl(0 0% 85.1%);
--muted-foreground: hsl(0 0% 50%); --muted-foreground: hsl(0 0% 50%);
--accent: hsl(0, 0%, 96%); --accent: hsl(0, 0%, 96%);
--accent-foreground: hsl(0 0% 2%); --accent-foreground: hsl(0 0% 2%);
--destructive: hsl(0, 83%, 50%); --destructive: hsl(0, 83%, 50%);
--destructive-foreground: hsl(0, 0%, 100%); --destructive-foreground: hsl(0, 0%, 100%);
--constructive: hsl(141, 71%, 48%); --constructive: hsl(141, 71%, 48%);
--constructive-foreground: hsl(0, 0%, 100%); --constructive-foreground: hsl(0, 0%, 100%);
--border: hsl(0 0% 91%); --border: hsl(0 0% 91%);
--input: hsl(0 0% 85.1%); --input: hsl(0, 0%, 100%);
--ring: hsl(0, 0%, 55%); --ring: hsl(0, 0%, 55%);
--chart-1: hsl(220 70% 50%); --chart-1: hsl(220 70% 50%);
--chart-2: hsl(160 60% 45%); --chart-2: hsl(160 60% 45%);
--chart-3: hsl(30 80% 55%); --chart-3: hsl(30 80% 55%);
--chart-4: hsl(280 65% 60%); --chart-4: hsl(280 65% 60%);
--chart-5: hsl(340 75% 55%); --chart-5: hsl(340 75% 55%);
--sidebar-background: hsl(0 0% 96.1%); --sidebar-background: hsl(0 0% 96.1%);
--sidebar-foreground: hsl(0 0% 2%); --sidebar-foreground: hsl(0 0% 2%);
--sidebar-primary: hsl(0 0% 2%); --sidebar-primary: hsl(0 0% 2%);
--sidebar-primary-foreground: hsl(0 0% 91%); --sidebar-primary-foreground: hsl(0 0% 91%);
--sidebar-accent: hsl(0 0% 85.1%); --sidebar-accent: hsl(0 0% 85.1%);
--sidebar-accent-foreground: hsl(0 0% 2%); --sidebar-accent-foreground: hsl(0 0% 2%);
--sidebar-border: hsl(0 0% 85.1%); --sidebar-border: hsl(0 0% 85.1%);
--sidebar-ring: hsl(0 0% 16.9%); --sidebar-ring: hsl(0 0% 16.9%);
--sidebar: hsl(0 0% 98%); --sidebar: hsl(0 0% 98%);
} }
.panel { .panel {
--background: hsl(216 13% 98%); --background: hsl(216 13% 98%);
--foreground: hsl(0 0% 13%); --foreground: hsl(0 0% 13%);
--card: hsl(0, 0%, 98%); --card: hsl(0, 0%, 98%);
--card-foreground: hsl(0 0% 13%); --card-foreground: hsl(0 0% 13%);
--primary-foreground: hsl(0, 0%, 98%); --primary-foreground: hsl(0, 0%, 98%);
--secondary: hsl(204, 100%, 95%); --secondary: hsl(204, 100%, 95%);
--secondary-border: hsl(204, 100%, 92%); --secondary-border: hsl(204, 100%, 92%);
--secondary-foreground: hsl(200, 98%, 37%); --secondary-foreground: hsl(200, 98%, 37%);
--muted: hsl(0 0% 83.1%); --muted: hsl(0 0% 83.1%);
--muted-foreground: hsl(0 0% 48%); --muted-foreground: hsl(0 0% 48%);
--accent: hsl(0, 0%, 93%); --accent: hsl(0, 0%, 93%);
--accent-foreground: hsl(0 0% 5%); --accent-foreground: hsl(0 0% 5%);
--destructive: hsl(0, 83%, 50%); --destructive: hsl(0, 83%, 50%);
--destructive-foreground: hsl(0, 0%, 98%); --destructive-foreground: hsl(0, 0%, 98%);
--constructive: hsl(141, 71%, 48%); --constructive: hsl(141, 71%, 48%);
--constructive-foreground: hsl(0, 0%, 98%); --constructive-foreground: hsl(0, 0%, 98%);
--border: hsl(0 0% 89%); --border: hsl(0 0% 89%);
--input: hsl(0 0% 83.1%); --input: hsl(0 0% 93%);
--ring: hsl(0, 0%, 53%); --ring: hsl(0, 0%, 53%);
} }
.dark { .dark {
--background: hsl(0, 0%, 7%); --background: hsl(0, 0%, 5%);
--foreground: hsl(0 0% 87%); --foreground: hsl(0 0% 87%);
--card: hsl(0, 0%, 7%); --card: hsl(0, 0%, 5%);
--card-foreground: hsl(0 0% 87%); --card-foreground: hsl(0 0% 87%);
--popover: hsl(0, 0%, 16%); --popover: hsl(0, 0%, 5%);
--popover-hover: hsl(0, 0%, 22%); --popover-hover: hsl(0, 0%, 22%);
--popover-foreground: hsl(0 0% 95%); --popover-foreground: hsl(0 0% 95%);
--secondary: hsl(204, 100%, 12%); --secondary: hsl(204, 100%, 12%);
--secondary-border: hsl(204, 100%, 15%); --secondary-border: hsl(204, 100%, 15%);
--secondary-foreground: hsl(200, 98%, 61%); --secondary-foreground: hsl(200, 98%, 61%);
--muted: hsl(0 0% 20%); --muted: hsl(0 0% 20%);
--accent: hsl(0, 0%, 14%); --accent: hsl(0, 0%, 14%);
--accent-foreground: hsl(0 0% 95%); --accent-foreground: hsl(0 0% 95%);
--constructive: hsl(141, 71%, 52%); --constructive: hsl(141, 71%, 52%);
--border: hsl(0 0% 16%); --border: hsl(0 0% 16%);
--input: hsl(0 0% 20%); --input: hsl(0 0% 5%);
--ring: hsl(0, 0%, 50%); --ring: hsl(0, 0%, 50%);
--sidebar-background: hsl(0 0% 8%); --sidebar-background: hsl(0 0% 8%);
--sidebar-foreground: hsl(0 0% 95%); --sidebar-foreground: hsl(0 0% 95%);
--sidebar-primary: hsl(0 0% 95%); --sidebar-primary: hsl(0 0% 95%);
--sidebar-primary-foreground: hsl(0 0% 15%); --sidebar-primary-foreground: hsl(0 0% 15%);
--sidebar-accent: hsl(0 0% 20%); --sidebar-accent: hsl(0 0% 20%);
--sidebar-accent-foreground: hsl(0 0% 95%); --sidebar-accent-foreground: hsl(0 0% 95%);
--sidebar-border: hsl(0 0% 20%); --sidebar-border: hsl(0 0% 20%);
--sidebar-ring: hsl(0 0% 83.1%); --sidebar-ring: hsl(0 0% 83.1%);
--sidebar: hsl(0 0% 6%); --sidebar: hsl(0 0% 6%);
} }
.dark .panel { .dark .panel {
--background: hsl(0 0% 10%); --background: hsl(0 0% 10%);
--foreground: hsl(0 0% 85%); --foreground: hsl(0 0% 85%);
--card: hsl(0, 0%, 10%); --card: hsl(0, 0%, 10%);
--card-foreground: hsl(0 0% 85%); --card-foreground: hsl(0 0% 85%);
--secondary: hsl(204, 100%, 12%); --secondary: hsl(204, 100%, 12%);
--secondary-border: hsl(204, 100%, 17%); --secondary-border: hsl(204, 100%, 17%);
--secondary-foreground: hsl(200, 98%, 63%); --secondary-foreground: hsl(200, 98%, 63%);
--muted: hsl(0 0% 22%); --muted: hsl(0 0% 22%);
--accent: hsl(0, 0%, 15%); --accent: hsl(0, 0%, 15%);
--accent-foreground: hsl(0 0% 93%); --accent-foreground: hsl(0 0% 93%);
--constructive: hsl(141, 71%, 52%); --constructive: hsl(141, 71%, 52%);
--border: hsl(0 0% 18%); --border: hsl(0 0% 18%);
--input: hsl(0 0% 22%); --input: hsl(0 0% 22%);
--ring: hsl(0, 0%, 52%); --ring: hsl(0, 0%, 52%);
} }
@layer base { @layer base {
/* /*
The default border color has changed to `currentcolor` in Tailwind CSS v4, The default border color has changed to `currentcolor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3. looks the same as it did with Tailwind CSS v3.
@ -124,166 +124,165 @@
If we ever want to remove these styles, we need to add an explicit border If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults. color utility to any element that depends on these defaults.
*/ */
*, *,
::after, ::after,
::before, ::before,
::backdrop, ::backdrop,
::file-selector-button { ::file-selector-button {
border-color: var(--color-gray-200, currentcolor); border-color: var(--color-gray-200, currentcolor);
} }
/* Other default base styles */ /* Other default base styles */
* { * {
@apply border-border; @apply border-border;
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
/* Prevent back/forward swipe */ /* Prevent back/forward swipe */
overscroll-behavior-x: contain; overscroll-behavior-x: contain;
} }
::selection { ::selection {
@apply bg-primary/35 selection:text-primary-foreground; @apply bg-primary/35 selection:text-primary-foreground;
} }
} }
@theme inline { @theme inline {
/* Responsive breakpoints */ /* Responsive breakpoints */
--breakpoint-xs: 30rem; --breakpoint-xs: 30rem;
/* Typography */ /* Typography */
--font-sans: var(--font-inter), sans-serif; --font-sans: var(--font-inter), sans-serif;
/* Font sizes */ /* Font sizes */
--text-xl: 1.2rem; --text-xs: 0.72rem;
--text-base: 0.92rem; --text-sm: 0.79rem;
--text-base--line-height: calc(1.5 / 0.95); --text-base: 0.92rem;
--text-xs: 0.75rem; --text-base--line-height: calc(1.5 / 0.95);
--text-sm: 0.85rem; --text-xs--line-height: calc(1 / 0.8);
--text-xs--line-height: calc(1 / 0.8);
/* Border radius */ /* Border radius */
--radius-lg: 0.82rem; --radius-lg: 0.82rem;
--radius-md: 0.65rem; --radius-md: 0.65rem;
--radius-sm: 0.35rem; --radius-sm: 0.35rem;
/* Palette mapped to root design tokens */ /* Palette mapped to root design tokens */
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--color-card: var(--card); --color-card: var(--card);
--color-card-foreground: var(--card-foreground); --color-card-foreground: var(--card-foreground);
--color-popover: var(--popover); --color-popover: var(--popover);
--color-popover-hover: var(--popover-hover); --color-popover-hover: var(--popover-hover);
--color-popover-foreground: var(--popover-foreground); --color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary); --color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground); --color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary); --color-secondary: var(--secondary);
--color-secondary-border: var(--secondary-border); --color-secondary-border: var(--secondary-border);
--color-secondary-foreground: var(--secondary-foreground); --color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted); --color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground); --color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent); --color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground); --color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive); --color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground); --color-destructive-foreground: var(--destructive-foreground);
--color-constructive: var(--constructive); --color-constructive: var(--constructive);
--color-constructive-foreground: var(--constructive-foreground); --color-constructive-foreground: var(--constructive-foreground);
--color-border: var(--border); --color-border: var(--border);
--color-input: var(--input); --color-input: var(--input);
--color-ring: var(--ring); --color-ring: var(--ring);
/* Chart colors */ /* Chart colors */
--color-chart-1: var(--chart-1); --color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2); --color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3); --color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4); --color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5); --color-chart-5: var(--chart-5);
/* Sidebar */ /* Sidebar */
--color-sidebar: var(--sidebar-background); --color-sidebar: var(--sidebar-background);
--color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary); --color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground); --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent); --color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
/* Animations */ /* Animations */
--animate-accordion-down: accordion-down 0.2s ease-out; --animate-accordion-down: accordion-down 0.2s ease-out;
--animate-accordion-up: accordion-up 0.2s ease-out; --animate-accordion-up: accordion-up 0.2s ease-out;
@keyframes accordion-down { @keyframes accordion-down {
from { from {
height: 0; height: 0;
} }
to { to {
height: var(--radix-accordion-content-height); height: var(--radix-accordion-content-height);
} }
} }
@keyframes accordion-up { @keyframes accordion-up {
from { from {
height: var(--radix-accordion-content-height); height: var(--radix-accordion-content-height);
} }
to { to {
height: 0; height: 0;
} }
} }
} }
@utility scrollbar-hidden { @utility scrollbar-hidden {
-ms-overflow-style: none; -ms-overflow-style: none;
scrollbar-width: none; scrollbar-width: none;
&::-webkit-scrollbar { &::-webkit-scrollbar {
display: none; display: none;
} }
} }
@utility scrollbar-x-hidden { @utility scrollbar-x-hidden {
-ms-overflow-style: none; -ms-overflow-style: none;
scrollbar-width: none; scrollbar-width: none;
&::-webkit-scrollbar:horizontal { &::-webkit-scrollbar:horizontal {
display: none; display: none;
} }
} }
@utility scrollbar-y-hidden { @utility scrollbar-y-hidden {
-ms-overflow-style: none; -ms-overflow-style: none;
scrollbar-width: none; scrollbar-width: none;
&::-webkit-scrollbar:vertical { &::-webkit-scrollbar:vertical {
display: none; display: none;
} }
} }
@utility scrollbar-thin { @utility scrollbar-thin {
&::-webkit-scrollbar { &::-webkit-scrollbar {
width: 6px; width: 6px;
height: 8px; height: 8px;
} }
&::-webkit-scrollbar-track { &::-webkit-scrollbar-track {
background: transparent; background: transparent;
} }
&::-webkit-scrollbar-thumb { &::-webkit-scrollbar-thumb {
background: var(--border); background: var(--border);
border-radius: 4px; border-radius: 4px;
} }
&::-webkit-scrollbar-thumb:hover { &::-webkit-scrollbar-thumb:hover {
background: var(--muted-foreground); background: var(--muted-foreground);
} }
} }
@layer base { @layer base {
* { * {
@apply border-border outline-ring/50; @apply border-border outline-ring/50;
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
} }

View File

@ -245,7 +245,6 @@ function ProjectsToolbar({ projectIds }: { projectIds: string[] }) {
</Button> </Button>
</SortDropdown> </SortDropdown>
<Button <Button
type="button"
variant="text" variant="text"
className="text-muted-foreground" className="text-muted-foreground"
onClick={() => onClick={() =>
@ -768,7 +767,6 @@ function ProjectMenu({
<DropdownMenu open={isOpen} onOpenChange={onOpenChange}> <DropdownMenu open={isOpen} onOpenChange={onOpenChange}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
type="button"
variant="background" variant="background"
className={ className={
isGrid isGrid

View File

@ -19,7 +19,7 @@ import { ThemeToggle } from "../theme-toggle";
import { DEFAULT_LOGO_URL, SOCIAL_LINKS } from "@/constants/site-constants"; import { DEFAULT_LOGO_URL, SOCIAL_LINKS } from "@/constants/site-constants";
import { toast } from "sonner"; import { toast } from "sonner";
import { useEditor } from "@/hooks/use-editor"; import { useEditor } from "@/hooks/use-editor";
import { ArrowLeft02Icon, CommandIcon } from "@hugeicons/core-free-icons"; import { CommandIcon, Logout05Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react"; import { HugeiconsIcon } from "@hugeicons/react";
import { ShortcutsDialog } from "./dialogs/shortcuts-dialog"; import { ShortcutsDialog } from "./dialogs/shortcuts-dialog";
import Image from "next/image"; import Image from "next/image";
@ -118,32 +118,30 @@ function ProjectDropdown() {
/> />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="start" className="z-100 w-52"> <DropdownMenuContent align="start" className="z-100 w-44">
<DropdownMenuItem <DropdownMenuItem
className="flex items-center gap-1.5"
onClick={handleExit} onClick={handleExit}
disabled={isExiting} disabled={isExiting}
icon={<HugeiconsIcon icon={Logout05Icon} />}
> >
<HugeiconsIcon icon={ArrowLeft02Icon} className="size-4" />
Exit project Exit project
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem
className="flex items-center gap-1.5"
onClick={() => setOpenDialog("shortcuts")} onClick={() => setOpenDialog("shortcuts")}
icon={<HugeiconsIcon icon={CommandIcon} />}
> >
<HugeiconsIcon icon={CommandIcon} className="size-4" /> Shortcuts
Keyboard shortcuts
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem asChild>
<DropdownMenuSeparator />
<DropdownMenuItem asChild icon={<FaDiscord className="!size-4" />}>
<Link <Link
href={SOCIAL_LINKS.discord} href={SOCIAL_LINKS.discord}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center gap-1.5"
> >
<FaDiscord className="size-4" />
Discord Discord
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>

View File

@ -23,7 +23,11 @@ import {
type ExportQuality, type ExportQuality,
type ExportResult, type ExportResult,
} from "@/types/export"; } from "@/types/export";
import { PropertyGroup } from "@/components/editor/panels/properties/property-item"; import {
Section,
SectionContent,
SectionHeader,
} from "@/components/editor/panels/properties/section";
import { useEditor } from "@/hooks/use-editor"; import { useEditor } from "@/hooks/use-editor";
import { DEFAULT_EXPORT_OPTIONS } from "@/constants/export-constants"; import { DEFAULT_EXPORT_OPTIONS } from "@/constants/export-constants";
@ -162,78 +166,83 @@ function ExportPopover({
{!isExporting && ( {!isExporting && (
<> <>
<div className="flex flex-col"> <div className="flex flex-col">
<PropertyGroup <Section collapsible defaultOpen={false} hasBorderTop={false}>
title="Format" <SectionHeader title="Format" />
defaultExpanded={false} <SectionContent>
hasBorderTop={false} <RadioGroup
> value={format}
<RadioGroup onValueChange={(value) => {
value={format} if (isExportFormat(value)) {
onValueChange={(value) => { setFormat(value);
if (isExportFormat(value)) { }
setFormat(value); }}
} >
}} <div className="flex items-center space-x-2">
> <RadioGroupItem value="mp4" id="mp4" />
<div className="flex items-center space-x-2"> <Label htmlFor="mp4">
<RadioGroupItem value="mp4" id="mp4" /> MP4 (H.264) - Better compatibility
<Label htmlFor="mp4"> </Label>
MP4 (H.264) - Better compatibility </div>
</Label> <div className="flex items-center space-x-2">
</div> <RadioGroupItem value="webm" id="webm" />
<div className="flex items-center space-x-2"> <Label htmlFor="webm">
<RadioGroupItem value="webm" id="webm" /> WebM (VP9) - Smaller file size
<Label htmlFor="webm"> </Label>
WebM (VP9) - Smaller file size </div>
</Label> </RadioGroup>
</div> </SectionContent>
</RadioGroup> </Section>
</PropertyGroup>
<PropertyGroup title="Quality" defaultExpanded={false}> <Section collapsible defaultOpen={false}>
<RadioGroup <SectionHeader title="Quality" />
value={quality} <SectionContent>
onValueChange={(value) => { <RadioGroup
if (isExportQuality(value)) { value={quality}
setQuality(value); onValueChange={(value) => {
} if (isExportQuality(value)) {
}} setQuality(value);
> }
}}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="low" id="low" />
<Label htmlFor="low">Low - Smallest file size</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="medium" id="medium" />
<Label htmlFor="medium">Medium - Balanced</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="high" id="high" />
<Label htmlFor="high">High - Recommended</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="very_high" id="very_high" />
<Label htmlFor="very_high">
Very High - Largest file size
</Label>
</div>
</RadioGroup>
</SectionContent>
</Section>
<Section collapsible defaultOpen={false}>
<SectionHeader title="Audio" />
<SectionContent>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<RadioGroupItem value="low" id="low" /> <Checkbox
<Label htmlFor="low">Low - Smallest file size</Label> id="include-audio"
</div> checked={includeAudio}
<div className="flex items-center space-x-2"> onCheckedChange={(checked) =>
<RadioGroupItem value="medium" id="medium" /> setIncludeAudio(!!checked)
<Label htmlFor="medium">Medium - Balanced</Label> }
</div> />
<div className="flex items-center space-x-2"> <Label htmlFor="include-audio">
<RadioGroupItem value="high" id="high" /> Include audio in export
<Label htmlFor="high">High - Recommended</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="very_high" id="very_high" />
<Label htmlFor="very_high">
Very High - Largest file size
</Label> </Label>
</div> </div>
</RadioGroup> </SectionContent>
</PropertyGroup> </Section>
<PropertyGroup title="Audio" defaultExpanded={false}>
<div className="flex items-center space-x-2">
<Checkbox
id="include-audio"
checked={includeAudio}
onCheckedChange={(checked) =>
setIncludeAudio(!!checked)
}
/>
<Label htmlFor="include-audio">
Include audio in export
</Label>
</div>
</PropertyGroup>
</div> </div>
<div className="p-3 pt-0"> <div className="p-3 pt-0">

View File

@ -163,7 +163,9 @@ export function DraggableItem({
<div className="size-6 flex-shrink-0 overflow-hidden rounded-[0.35rem]"> <div className="size-6 flex-shrink-0 overflow-hidden rounded-[0.35rem]">
{preview} {preview}
</div> </div>
<span className="w-full flex-1 truncate text-sm text-left">{name}</span> <span className="w-full flex-1 truncate text-sm text-left">
{name}
</span>
</button> </button>
</div> </div>
)} )}

View File

@ -4,7 +4,7 @@ import { Separator } from "@/components/ui/separator";
import { type Tab, useAssetsPanelStore } from "@/stores/assets-panel-store"; import { type Tab, useAssetsPanelStore } from "@/stores/assets-panel-store";
import { TabBar } from "./tabbar"; import { TabBar } from "./tabbar";
import { Captions } from "./views/captions"; import { Captions } from "./views/captions";
import { MediaView } from "./views/media"; import { MediaView } from "./views/assets";
import { SettingsView } from "./views/settings"; import { SettingsView } from "./views/settings";
import { SoundsView } from "./views/sounds"; import { SoundsView } from "./views/sounds";
import { StickersView } from "./views/stickers"; import { StickersView } from "./views/stickers";

View File

@ -61,7 +61,8 @@ export function TabBar() {
aria-label={tab.label} aria-label={tab.label}
className={cn( className={cn(
"flex-col !p-1.5 !rounded-sm !h-auto [&_svg]:size-4.5", "flex-col !p-1.5 !rounded-sm !h-auto [&_svg]:size-4.5",
activeTab !== tabKey && "border border-transparent text-muted-foreground", activeTab !== tabKey &&
"border border-transparent text-muted-foreground",
)} )}
onClick={() => setActiveTab(tabKey)} onClick={() => setActiveTab(tabKey)}
> >

View File

@ -3,6 +3,7 @@
import Image from "next/image"; import Image from "next/image";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { PanelView } from "@/components/editor/panels/assets/views/base-view";
import { MediaDragOverlay } from "@/components/editor/panels/assets/drag-overlay"; import { MediaDragOverlay } from "@/components/editor/panels/assets/drag-overlay";
import { DraggableItem } from "@/components/editor/panels/assets/draggable-item"; import { DraggableItem } from "@/components/editor/panels/assets/draggable-item";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -29,14 +30,9 @@ import { useEditor } from "@/hooks/use-editor";
import { useFileUpload } from "@/hooks/use-file-upload"; import { useFileUpload } from "@/hooks/use-file-upload";
import { useRevealItem } from "@/hooks/use-reveal-item"; import { useRevealItem } from "@/hooks/use-reveal-item";
import { processMediaAssets } from "@/lib/media/processing"; import { processMediaAssets } from "@/lib/media/processing";
import { import { buildElementFromMedia } from "@/lib/timeline/element-utils";
buildImageElement,
buildUploadAudioElement,
buildVideoElement,
} from "@/lib/timeline/element-utils";
import { useAssetsPanelStore } from "@/stores/assets-panel-store"; import { useAssetsPanelStore } from "@/stores/assets-panel-store";
import type { MediaAsset } from "@/types/assets"; import type { MediaAsset } from "@/types/assets";
import type { CreateTimelineElement } from "@/types/timeline";
import { cn } from "@/utils/ui"; import { cn } from "@/utils/ui";
import { import {
CloudUploadIcon, CloudUploadIcon,
@ -132,7 +128,15 @@ export function MediaView() {
asset: MediaAsset; asset: MediaAsset;
startTime: number; startTime: number;
}): boolean => { }): boolean => {
const element = createElementFromMedia({ asset, startTime }); const duration =
asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
const element = buildElementFromMedia({
mediaId: asset.id,
mediaType: asset.type,
name: asset.name,
duration,
startTime,
});
editor.timeline.insertElement({ editor.timeline.insertElement({
element, element,
placement: { mode: "auto" }, placement: { mode: "auto" },
@ -194,171 +198,166 @@ export function MediaView() {
const renderCompactPreview = (item: MediaAsset) => const renderCompactPreview = (item: MediaAsset) =>
previewComponents.get(`compact-${item.id}`); previewComponents.get(`compact-${item.id}`);
const mediaActions = (
<div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="ghost"
onClick={() =>
setMediaViewMode(mediaViewMode === "grid" ? "list" : "grid")
}
disabled={isProcessing}
className="items-center justify-center"
>
{mediaViewMode === "grid" ? (
<HugeiconsIcon icon={LeftToRightListDashIcon} />
) : (
<HugeiconsIcon icon={GridViewIcon} />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>
{mediaViewMode === "grid"
? "Switch to list view"
: "Switch to grid view"}
</p>
</TooltipContent>
<Tooltip>
<DropdownMenu>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="ghost"
disabled={isProcessing}
className="items-center justify-center"
>
<HugeiconsIcon icon={SortingOneNineIcon} />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<DropdownMenuContent align="end">
<SortMenuItem
label="Name"
sortKey="name"
currentSortBy={sortBy}
currentSortOrder={sortOrder}
onSort={({ key }) => {
if (sortBy === key) {
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
} else {
setSortBy(key);
setSortOrder("asc");
}
}}
/>
<SortMenuItem
label="Type"
sortKey="type"
currentSortBy={sortBy}
currentSortOrder={sortOrder}
onSort={({ key }) => {
if (sortBy === key) {
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
} else {
setSortBy(key);
setSortOrder("asc");
}
}}
/>
<SortMenuItem
label="Duration"
sortKey="duration"
currentSortBy={sortBy}
currentSortOrder={sortOrder}
onSort={({ key }) => {
if (sortBy === key) {
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
} else {
setSortBy(key);
setSortOrder("asc");
}
}}
/>
<SortMenuItem
label="File size"
sortKey="size"
currentSortBy={sortBy}
currentSortOrder={sortOrder}
onSort={({ key }) => {
if (sortBy === key) {
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
} else {
setSortBy(key);
setSortOrder("asc");
}
}}
/>
</DropdownMenuContent>
</DropdownMenu>
<TooltipContent>
<p>
Sort by {sortBy} (
{sortOrder === "asc" ? "ascending" : "descending"})
</p>
</TooltipContent>
</Tooltip>
</Tooltip>
</TooltipProvider>
<Button
variant="outline"
onClick={openFilePicker}
disabled={isProcessing}
size="sm"
className="items-center justify-center gap-1.5 ml-1.5"
>
<HugeiconsIcon icon={CloudUploadIcon} />
Import
</Button>
</div>
);
return ( return (
<> <>
<input {...fileInputProps} /> <input {...fileInputProps} />
<div <PanelView
className={`relative flex h-full flex-col gap-1 ${isDragOver ? "bg-accent/30" : ""}`} title="Assets"
actions={mediaActions}
className={isDragOver ? "bg-accent/30" : ""}
{...dragProps} {...dragProps}
> >
<div className="bg-background h-12 px-4 pr-2 flex items-center justify-between border-b"> {isDragOver || filteredMediaItems.length === 0 ? (
<span className="text-muted-foreground text-sm">Assets</span> <MediaDragOverlay
<div className="flex items-center gap-0"> isVisible={true}
<TooltipProvider> isProcessing={isProcessing}
<Tooltip> progress={progress}
<TooltipTrigger asChild> onClick={openFilePicker}
<Button />
size="icon" ) : mediaViewMode === "grid" ? (
variant="text" <GridView
onClick={() => items={filteredMediaItems}
setMediaViewMode( renderPreview={renderPreview}
mediaViewMode === "grid" ? "list" : "grid", onRemove={handleRemove}
) onAddToTimeline={addElementAtTime}
} highlightedId={highlightedId}
disabled={isProcessing} registerElement={registerElement}
className="items-center justify-center" />
> ) : (
{mediaViewMode === "grid" ? ( <ListView
<HugeiconsIcon icon={LeftToRightListDashIcon} /> items={filteredMediaItems}
) : ( renderPreview={renderCompactPreview}
<HugeiconsIcon icon={GridViewIcon} /> onRemove={handleRemove}
)} onAddToTimeline={addElementAtTime}
</Button> highlightedId={highlightedId}
</TooltipTrigger> registerElement={registerElement}
<TooltipContent> />
<p> )}
{mediaViewMode === "grid" </PanelView>
? "Switch to list view"
: "Switch to grid view"}
</p>
</TooltipContent>
<Tooltip>
<DropdownMenu>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="text"
disabled={isProcessing}
className="items-center justify-center"
>
<HugeiconsIcon icon={SortingOneNineIcon} />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<DropdownMenuContent align="end">
<SortMenuItem
label="Name"
sortKey="name"
currentSortBy={sortBy}
currentSortOrder={sortOrder}
onSort={({ key }) => {
if (sortBy === key) {
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
} else {
setSortBy(key);
setSortOrder("asc");
}
}}
/>
<SortMenuItem
label="Type"
sortKey="type"
currentSortBy={sortBy}
currentSortOrder={sortOrder}
onSort={({ key }) => {
if (sortBy === key) {
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
} else {
setSortBy(key);
setSortOrder("asc");
}
}}
/>
<SortMenuItem
label="Duration"
sortKey="duration"
currentSortBy={sortBy}
currentSortOrder={sortOrder}
onSort={({ key }) => {
if (sortBy === key) {
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
} else {
setSortBy(key);
setSortOrder("asc");
}
}}
/>
<SortMenuItem
label="File size"
sortKey="size"
currentSortBy={sortBy}
currentSortOrder={sortOrder}
onSort={({ key }) => {
if (sortBy === key) {
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
} else {
setSortBy(key);
setSortOrder("asc");
}
}}
/>
</DropdownMenuContent>
</DropdownMenu>
<TooltipContent>
<p>
Sort by {sortBy} (
{sortOrder === "asc" ? "ascending" : "descending"})
</p>
</TooltipContent>
</Tooltip>
</Tooltip>
</TooltipProvider>
<Button
variant="outline"
onClick={openFilePicker}
disabled={isProcessing}
size="sm"
className="items-center justify-center gap-1.5 ml-1.5 hover:bg-accent px-3"
>
<HugeiconsIcon icon={CloudUploadIcon} />
Import
</Button>
</div>
</div>
<div className="scrollbar-thin size-full overflow-y-auto pt-1">
<div className="w-full flex-1 p-3 pt-0">
{isDragOver || filteredMediaItems.length === 0 ? (
<MediaDragOverlay
isVisible={true}
isProcessing={isProcessing}
progress={progress}
onClick={openFilePicker}
/>
) : mediaViewMode === "grid" ? (
<GridView
items={filteredMediaItems}
renderPreview={renderPreview}
onRemove={handleRemove}
onAddToTimeline={addElementAtTime}
highlightedId={highlightedId}
registerElement={registerElement}
/>
) : (
<ListView
items={filteredMediaItems}
renderPreview={renderCompactPreview}
onRemove={handleRemove}
onAddToTimeline={addElementAtTime}
highlightedId={highlightedId}
registerElement={registerElement}
/>
)}
</div>
</div>
</div>
</> </>
); );
} }
@ -636,40 +635,3 @@ function SortMenuItem({
</DropdownMenuItem> </DropdownMenuItem>
); );
} }
function createElementFromMedia({
asset,
startTime,
}: {
asset: MediaAsset;
startTime: number;
}): CreateTimelineElement {
const duration =
asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
switch (asset.type) {
case "video":
return buildVideoElement({
mediaId: asset.id,
name: asset.name,
duration,
startTime,
});
case "image":
return buildImageElement({
mediaId: asset.id,
name: asset.name,
duration,
startTime,
});
case "audio":
return buildUploadAudioElement({
mediaId: asset.id,
name: asset.name,
duration,
startTime,
});
default:
throw new Error(`Unsupported media type: ${asset.type}`);
}
}

View File

@ -0,0 +1,52 @@
import { cn } from "@/utils/ui";
interface PanelViewProps extends React.HTMLAttributes<HTMLDivElement> {
title?: string;
actions?: React.ReactNode;
children: React.ReactNode;
contentClassName?: string;
hideHeader?: boolean;
ref?: React.Ref<HTMLDivElement>;
onScroll?: React.UIEventHandler<HTMLDivElement>;
scrollRef?: React.Ref<HTMLDivElement>;
}
export function PanelView({
title,
actions,
children,
className,
contentClassName,
hideHeader = false,
ref,
onScroll,
scrollRef,
...rest
}: PanelViewProps) {
return (
<div
className={cn("relative flex h-full flex-col", className)}
ref={ref}
{...rest}
>
{!hideHeader && (
<div className="bg-background h-11 shrink-0 px-4 pr-2 flex items-center justify-between border-b">
<span className="text-muted-foreground text-sm">{title}</span>
{actions}
</div>
)}
<div
className={cn(
"scrollbar-thin size-full overflow-y-auto",
hideHeader ? "pt-4" : "pt-2",
)}
ref={scrollRef}
onScroll={onScroll}
>
<div className={cn("w-full flex-1 px-2 pt-0", contentClassName)}>
{children}
</div>
</div>
</div>
);
}

View File

@ -1,5 +1,5 @@
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { PanelBaseView as BaseView } from "@/components/editor/panels/panel-base-view"; import { PanelView } from "@/components/editor/panels/assets/views/base-view";
import { import {
Select, Select,
SelectContent, SelectContent,
@ -108,10 +108,7 @@ export function Captions() {
}; };
return ( return (
<BaseView <PanelView title="Captions" ref={containerRef}>
ref={containerRef}
className="flex h-full flex-col justify-between"
>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<Label>Language</Label> <Label>Language</Label>
<Select <Select
@ -148,6 +145,6 @@ export function Captions() {
{isProcessing ? processingStep : "Generate transcript"} {isProcessing ? processingStep : "Generate transcript"}
</Button> </Button>
</div> </div>
</BaseView> </PanelView>
); );
} }

View File

@ -0,0 +1,332 @@
// @ts-nocheck
"use client";
import Image from "next/image";
import { memo, useCallback, useMemo } from "react";
import { PanelView } from "@/components/editor/panels/assets/views/base-view";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
BLUR_INTENSITY_PRESETS,
DEFAULT_BLUR_INTENSITY,
DEFAULT_COLOR,
FPS_PRESETS,
} from "@/constants/project-constants";
import { patternCraftGradients } from "@/data/colors/pattern-craft";
import { colors } from "@/data/colors/solid";
import { syntaxUIGradients } from "@/data/colors/syntax-ui";
import { useEditor } from "@/hooks/use-editor";
import { useEditorStore } from "@/stores/editor-store";
import { dimensionToAspectRatio } from "@/utils/geometry";
import { cn } from "@/utils/ui";
import {
PropertyGroup,
PropertyItem,
PropertyItemLabel,
PropertyItemValue,
} from "@/components/editor/panels/properties/section";
export function SettingsView() {
return (
<PanelView title="Project settings" contentClassName="px-0">
<div className="flex flex-col gap-8">
<ProjectInfoView />
<BackgroundView />
</div>
</PanelView>
);
}
function ProjectInfoView() {
const editor = useEditor();
const activeProject = editor.project.getActive();
const { canvasPresets } = useEditorStore();
const findPresetIndexByAspectRatio = ({
presets,
targetAspectRatio,
}: {
presets: Array<{ width: number; height: number }>;
targetAspectRatio: string;
}) => {
for (let index = 0; index < presets.length; index++) {
const preset = presets[index];
const presetAspectRatio = dimensionToAspectRatio({
width: preset.width,
height: preset.height,
});
if (presetAspectRatio === targetAspectRatio) {
return index;
}
}
return -1;
};
const currentCanvasSize = activeProject.settings.canvasSize;
const currentAspectRatio = dimensionToAspectRatio(currentCanvasSize);
const originalCanvasSize = activeProject.settings.originalCanvasSize ?? null;
const presetIndex = findPresetIndexByAspectRatio({
presets: canvasPresets,
targetAspectRatio: currentAspectRatio,
});
const originalPresetValue = "original";
const selectedPresetValue =
presetIndex !== -1 ? presetIndex.toString() : originalPresetValue;
const handleAspectRatioChange = ({ value }: { value: string }) => {
if (value === originalPresetValue) {
const canvasSize = originalCanvasSize ?? currentCanvasSize;
editor.project.updateSettings({
settings: { canvasSize },
});
return;
}
const index = parseInt(value, 10);
const preset = canvasPresets[index];
if (preset) {
editor.project.updateSettings({ settings: { canvasSize: preset } });
}
};
const handleFpsChange = (value: string) => {
const fps = parseFloat(value);
editor.project.updateSettings({ settings: { fps } });
};
return (
<div className="flex flex-col gap-4">
<PropertyItem direction="column">
<PropertyItemLabel>Name</PropertyItemLabel>
<PropertyItemValue>{activeProject.metadata.name}</PropertyItemValue>
</PropertyItem>
<PropertyItem direction="column">
<PropertyItemLabel>Aspect ratio</PropertyItemLabel>
<PropertyItemValue>
<Select
value={selectedPresetValue}
onValueChange={(value) => handleAspectRatioChange({ value })}
>
<SelectTrigger>
<SelectValue placeholder="Select an aspect ratio" />
</SelectTrigger>
<SelectContent>
<SelectItem value={originalPresetValue}>Original</SelectItem>
{canvasPresets.map((preset, index) => {
const label = dimensionToAspectRatio({
width: preset.width,
height: preset.height,
});
return (
<SelectItem key={label} value={index.toString()}>
{label}
</SelectItem>
);
})}
</SelectContent>
</Select>
</PropertyItemValue>
</PropertyItem>
<PropertyItem direction="column">
<PropertyItemLabel>Frame rate</PropertyItemLabel>
<PropertyItemValue>
<Select
value={activeProject.settings.fps.toString()}
onValueChange={handleFpsChange}
>
<SelectTrigger>
<SelectValue placeholder="Select a frame rate" />
</SelectTrigger>
<SelectContent>
{FPS_PRESETS.map((preset) => (
<SelectItem key={preset.value} value={preset.value}>
{preset.label}
</SelectItem>
))}
</SelectContent>
</Select>
</PropertyItemValue>
</PropertyItem>
</div>
);
}
const BlurPreview = memo(
({
blur,
isSelected,
onSelect,
}: {
blur: { label: string; value: number };
isSelected: boolean;
onSelect: () => void;
}) => (
<button
className={cn(
"border-foreground/15 hover:border-primary relative aspect-square size-20 cursor-pointer overflow-hidden rounded-sm border",
isSelected && "border-primary border-2",
)}
onClick={onSelect}
type="button"
aria-label={`Select ${blur.label} blur`}
>
<Image
src="https://images.unsplash.com/photo-1501785888041-af3ef285b470?q=80&w=1470&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"
alt={`Blur preview ${blur.label}`}
fill
className="object-cover"
style={{ filter: `blur(${blur.value}px)` }}
loading="eager"
/>
<div className="absolute right-1 bottom-1 left-1 text-center">
<span className="rounded bg-black/50 px-1 text-xs text-white">
{blur.label}
</span>
</div>
</button>
),
);
BlurPreview.displayName = "BlurPreview";
const BackgroundPreviews = memo(
({
backgrounds,
currentBackgroundColor,
isColorBackground,
handleColorSelect,
useBackgroundColor = false,
}: {
backgrounds: string[];
currentBackgroundColor: string;
isColorBackground: boolean;
handleColorSelect: ({ bg }: { bg: string }) => void;
useBackgroundColor?: boolean;
}) => {
return useMemo(
() =>
backgrounds.map((bg, index) => (
<button
key={`${index}-${bg}`}
className={cn(
"border-foreground/15 hover:border-primary aspect-square size-20 cursor-pointer rounded-sm border",
isColorBackground &&
bg === currentBackgroundColor &&
"border-primary border-2",
)}
style={
useBackgroundColor
? { backgroundColor: bg }
: {
background: bg,
backgroundSize: "cover",
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
}
}
onClick={() => handleColorSelect({ bg })}
type="button"
aria-label={`Select background ${useBackgroundColor ? bg : index + 1}`}
/>
)),
[
backgrounds,
isColorBackground,
currentBackgroundColor,
handleColorSelect,
useBackgroundColor,
],
);
},
);
BackgroundPreviews.displayName = "BackgroundPreviews";
function BackgroundView() {
const editor = useEditor();
const activeProject = editor.project.getActive();
const blurLevels = useMemo(() => BLUR_INTENSITY_PRESETS, []);
const handleBlurSelect = useCallback(
async ({ blurIntensity }: { blurIntensity: number }) => {
await editor.project.updateSettings({
settings: { background: { type: "blur", blurIntensity } },
});
},
[editor.project],
);
const handleColorSelect = useCallback(
async ({ color }: { color: string }) => {
await editor.project.updateSettings({
settings: { background: { type: "color", color } },
});
},
[editor.project],
);
const currentBlurIntensity =
activeProject.settings.background.type === "blur"
? activeProject.settings.background.blurIntensity
: DEFAULT_BLUR_INTENSITY;
const currentBackgroundColor =
activeProject.settings.background.type === "color"
? activeProject.settings.background.color
: DEFAULT_COLOR;
const isBlurBackground = activeProject.settings.background.type === "blur";
const isColorBackground = activeProject.settings.background.type === "color";
const blurPreviews = useMemo(
() =>
blurLevels.map((blur) => (
<BlurPreview
key={blur.value}
blur={blur}
isSelected={isBlurBackground && currentBlurIntensity === blur.value}
onSelect={() => handleBlurSelect({ blurIntensity: blur.value })}
/>
)),
[blurLevels, isBlurBackground, currentBlurIntensity, handleBlurSelect],
);
const backgroundSections = [
{ title: "Colors", backgrounds: colors, useBackgroundColor: true },
{ title: "Pattern craft", backgrounds: patternCraftGradients },
{ title: "Syntax UI", backgrounds: syntaxUIGradients },
];
return (
<div className="flex flex-col">
<PropertyGroup title="Blur" hasBorderTop={false} defaultExpanded={false}>
<div className="flex flex-wrap gap-2">{blurPreviews}</div>
</PropertyGroup>
{backgroundSections.map((section) => (
<PropertyGroup
key={section.title}
title={section.title}
defaultExpanded={false}
>
<div className="flex flex-wrap gap-2">
<BackgroundPreviews
backgrounds={section.backgrounds}
currentBackgroundColor={currentBackgroundColor}
isColorBackground={isColorBackground}
handleColorSelect={({ bg }) => handleColorSelect({ color: bg })}
useBackgroundColor={section.useBackgroundColor}
/>
</div>
</PropertyGroup>
))}
</div>
);
}

View File

@ -1,8 +1,6 @@
"use client"; "use client";
import Image from "next/image"; import { PanelView } from "@/components/editor/panels/assets/views/base-view";
import { memo, useCallback, useMemo } from "react";
import { PanelBaseView as BaseView } from "@/components/editor/panels/panel-base-view";
import { import {
Select, Select,
SelectContent, SelectContent,
@ -10,63 +8,54 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { import { FPS_PRESETS } from "@/constants/project-constants";
BLUR_INTENSITY_PRESETS,
DEFAULT_BLUR_INTENSITY,
DEFAULT_COLOR,
FPS_PRESETS,
} from "@/constants/project-constants";
import { patternCraftGradients } from "@/data/colors/pattern-craft";
import { colors } from "@/data/colors/solid";
import { syntaxUIGradients } from "@/data/colors/syntax-ui";
import { useEditor } from "@/hooks/use-editor"; import { useEditor } from "@/hooks/use-editor";
import { useEditorStore } from "@/stores/editor-store"; import { useEditorStore } from "@/stores/editor-store";
import { dimensionToAspectRatio } from "@/utils/geometry"; import { dimensionToAspectRatio } from "@/utils/geometry";
import { cn } from "@/utils/ui";
import { import {
PropertyGroup, Section,
PropertyItem, SectionContent,
PropertyItemLabel, SectionHeader,
PropertyItemValue, } from "@/components/editor/panels/properties/section";
} from "@/components/editor/panels/properties/property-item"; import { Label } from "@/components/ui/label";
import { ColorPicker } from "@/components/ui/color-picker"; import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { useState } from "react";
export function SettingsView() { export function SettingsView() {
return <ProjectSettingsTabs />; const [open, setOpen] = useState(false);
}
function ProjectSettingsTabs() {
return ( return (
<BaseView <PanelView contentClassName="px-0" hideHeader>
defaultTab="project-info" <div className="flex flex-col">
tabs={[ <Section hasBorderTop={false}>
{ <SectionContent>
value: "project-info", <ProjectInfoContent />
label: "Project info", </SectionContent>
content: ( </Section>
<div className="p-5"> <Popover open={open} onOpenChange={setOpen}>
<ProjectInfoView /> <Section className="cursor-pointer">
</div> <PopoverTrigger asChild>
), <div>
}, <SectionHeader title="Background">
{ <div className="size-4 rounded-sm bg-red-500" />
value: "background", </SectionHeader>
label: "Background",
content: (
<div className="flex h-full flex-col justify-between">
<div className="flex-1">
<BackgroundView />
</div> </div>
</div> </PopoverTrigger>
), </Section>
}, <PopoverContent>
]} <div className="size-4 rounded-sm bg-red-500" />
className="flex h-full flex-col justify-between p-0" </PopoverContent>
/> </Popover>
</div>
</PanelView>
); );
} }
function ProjectInfoView() { function ProjectInfoContent() {
const editor = useEditor(); const editor = useEditor();
const activeProject = editor.project.getActive(); const activeProject = editor.project.getActive();
const { canvasPresets } = useEditorStore(); const { canvasPresets } = useEditorStore();
@ -124,232 +113,55 @@ function ProjectInfoView() {
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<PropertyItem direction="column"> <div className="flex flex-col gap-2">
<PropertyItemLabel>Name</PropertyItemLabel> <Label>Name</Label>
<PropertyItemValue>{activeProject.metadata.name}</PropertyItemValue> <span className="leading-none text-sm">
</PropertyItem> {activeProject.metadata.name}
<PropertyItem direction="column">
<PropertyItemLabel>Aspect ratio</PropertyItemLabel>
<PropertyItemValue>
<Select
value={selectedPresetValue}
onValueChange={(value) => handleAspectRatioChange({ value })}
>
<SelectTrigger>
<SelectValue placeholder="Select an aspect ratio" />
</SelectTrigger>
<SelectContent>
<SelectItem value={originalPresetValue}>Original</SelectItem>
{canvasPresets.map((preset, index) => {
const label = dimensionToAspectRatio({
width: preset.width,
height: preset.height,
});
return (
<SelectItem key={label} value={index.toString()}>
{label}
</SelectItem>
);
})}
</SelectContent>
</Select>
</PropertyItemValue>
</PropertyItem>
<PropertyItem direction="column">
<PropertyItemLabel>Frame rate</PropertyItemLabel>
<PropertyItemValue>
<Select
value={activeProject.settings.fps.toString()}
onValueChange={handleFpsChange}
>
<SelectTrigger>
<SelectValue placeholder="Select a frame rate" />
</SelectTrigger>
<SelectContent>
{FPS_PRESETS.map((preset) => (
<SelectItem key={preset.value} value={preset.value}>
{preset.label}
</SelectItem>
))}
</SelectContent>
</Select>
</PropertyItemValue>
</PropertyItem>
</div>
);
}
const BlurPreview = memo(
({
blur,
isSelected,
onSelect,
}: {
blur: { label: string; value: number };
isSelected: boolean;
onSelect: () => void;
}) => (
<button
className={cn(
"border-foreground/15 hover:border-primary relative aspect-square size-20 cursor-pointer overflow-hidden rounded-sm border",
isSelected && "border-primary border-2",
)}
onClick={onSelect}
type="button"
aria-label={`Select ${blur.label} blur`}
>
<Image
src="https://images.unsplash.com/photo-1501785888041-af3ef285b470?q=80&w=1470&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"
alt={`Blur preview ${blur.label}`}
fill
className="object-cover"
style={{ filter: `blur(${blur.value}px)` }}
loading="eager"
/>
<div className="absolute right-1 bottom-1 left-1 text-center">
<span className="rounded bg-black/50 px-1 text-xs text-white">
{blur.label}
</span> </span>
</div> </div>
</button> <div className="flex flex-col gap-2">
), <Label>Aspect ratio</Label>
); <Select
value={selectedPresetValue}
BlurPreview.displayName = "BlurPreview"; onValueChange={(value) => handleAspectRatioChange({ value })}
const BackgroundPreviews = memo(
({
backgrounds,
currentBackgroundColor,
isColorBackground,
handleColorSelect,
useBackgroundColor = false,
}: {
backgrounds: string[];
currentBackgroundColor: string;
isColorBackground: boolean;
handleColorSelect: ({ bg }: { bg: string }) => void;
useBackgroundColor?: boolean;
}) => {
return useMemo(
() =>
backgrounds.map((bg, index) => (
<button
key={`${index}-${bg}`}
className={cn(
"border-foreground/15 hover:border-primary aspect-square size-20 cursor-pointer rounded-sm border",
isColorBackground &&
bg === currentBackgroundColor &&
"border-primary border-2",
)}
style={
useBackgroundColor
? { backgroundColor: bg }
: {
background: bg,
backgroundSize: "cover",
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
}
}
onClick={() => handleColorSelect({ bg })}
type="button"
aria-label={`Select background ${useBackgroundColor ? bg : index + 1}`}
/>
)),
[
backgrounds,
isColorBackground,
currentBackgroundColor,
handleColorSelect,
useBackgroundColor,
],
);
},
);
BackgroundPreviews.displayName = "BackgroundPreviews";
function BackgroundView() {
const editor = useEditor();
const activeProject = editor.project.getActive();
const blurLevels = useMemo(() => BLUR_INTENSITY_PRESETS, []);
const handleBlurSelect = useCallback(
async ({ blurIntensity }: { blurIntensity: number }) => {
await editor.project.updateSettings({
settings: { background: { type: "blur", blurIntensity } },
});
},
[editor.project],
);
const handleColorSelect = useCallback(
async ({ color }: { color: string }) => {
await editor.project.updateSettings({
settings: { background: { type: "color", color } },
});
},
[editor.project],
);
const currentBlurIntensity =
activeProject.settings.background.type === "blur"
? activeProject.settings.background.blurIntensity
: DEFAULT_BLUR_INTENSITY;
const currentBackgroundColor =
activeProject.settings.background.type === "color"
? activeProject.settings.background.color
: DEFAULT_COLOR;
const isBlurBackground = activeProject.settings.background.type === "blur";
const isColorBackground = activeProject.settings.background.type === "color";
const blurPreviews = useMemo(
() =>
blurLevels.map((blur) => (
<BlurPreview
key={blur.value}
blur={blur}
isSelected={isBlurBackground && currentBlurIntensity === blur.value}
onSelect={() => handleBlurSelect({ blurIntensity: blur.value })}
/>
)),
[blurLevels, isBlurBackground, currentBlurIntensity, handleBlurSelect],
);
const backgroundSections = [
{ title: "Colors", backgrounds: colors, useBackgroundColor: true },
{ title: "Pattern craft", backgrounds: patternCraftGradients },
{ title: "Syntax UI", backgrounds: syntaxUIGradients },
];
return (
<div className="flex h-full flex-col">
<PropertyGroup title="Blur" hasBorderTop={false} defaultExpanded={false}>
<div className="flex flex-wrap gap-2">{blurPreviews}</div>
</PropertyGroup>
{backgroundSections.map((section) => (
<PropertyGroup
key={section.title}
title={section.title}
defaultExpanded={false}
> >
<div className="flex flex-wrap gap-2"> <SelectTrigger className="w-fit">
<BackgroundPreviews <SelectValue placeholder="Select an aspect ratio" />
backgrounds={section.backgrounds} </SelectTrigger>
currentBackgroundColor={currentBackgroundColor} <SelectContent>
isColorBackground={isColorBackground} <SelectItem value={originalPresetValue}>Original</SelectItem>
handleColorSelect={({ bg }) => handleColorSelect({ color: bg })} {canvasPresets.map((preset, index) => {
useBackgroundColor={section.useBackgroundColor} const label = dimensionToAspectRatio({
/> width: preset.width,
</div> height: preset.height,
</PropertyGroup> });
))} return (
<SelectItem key={label} value={index.toString()}>
{label}
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label>Frame rate</Label>
<Select
value={activeProject.settings.fps.toString()}
onValueChange={handleFpsChange}
>
<SelectTrigger className="w-fit">
<SelectValue placeholder="Select a frame rate" />
</SelectTrigger>
<SelectContent>
{FPS_PRESETS.map((preset) => (
<SelectItem key={preset.value} value={preset.value}>
{preset.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div> </div>
); );
} }

View File

@ -235,7 +235,7 @@ function SoundEffectsView() {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Input <Input
placeholder="Search sound effects" placeholder="Search sound effects"
className="bg-accent w-full" className="w-full"
containerClassName="w-full" containerClassName="w-full"
value={searchQuery} value={searchQuery}
onChange={({ currentTarget }) => onChange={({ currentTarget }) =>

View File

@ -4,144 +4,104 @@ import Image from "next/image";
import type { CSSProperties } from "react"; import type { CSSProperties } from "react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { PanelView } from "@/components/editor/panels/assets/views/base-view";
import { DraggableItem } from "@/components/editor/panels/assets/draggable-item"; import { DraggableItem } from "@/components/editor/panels/assets/draggable-item";
import { PanelBaseView as BaseView } from "@/components/editor/panels/panel-base-view";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { InputWithBack } from "@/components/ui/input-with-back";
import { ScrollArea } from "@/components/ui/scroll-area";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { STICKER_CATEGORIES } from "@/constants/stickers-constants";
import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
import { import {
buildIconSvgUrl, resolveStickerId,
getIconSvgUrl, type StickerItem as StickerData,
ICONIFY_HOSTS, } from "@/lib/stickers";
POPULAR_COLLECTIONS,
} from "@/lib/iconify-api";
import { useStickersStore } from "@/stores/stickers-store"; import { useStickersStore } from "@/stores/stickers-store";
import type { StickerCategory } from "@/types/stickers";
import { cn } from "@/utils/ui"; import { cn } from "@/utils/ui";
import { import {
ArrowRightIcon,
HappyIcon, HappyIcon,
ClockIcon, ClockIcon,
LayoutGridIcon,
MultiplicationSignIcon, MultiplicationSignIcon,
SparklesIcon, Search01Icon,
HashtagIcon,
} from "@hugeicons/core-free-icons"; } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react"; import { HugeiconsIcon } from "@hugeicons/react";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import {
function isStickerCategory(value: string): value is StickerCategory { Select,
return STICKER_CATEGORIES.includes(value as StickerCategory); SelectContent,
} SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { OcSlidersVerticalIcon } from "@opencut/ui/icons";
import type { StickerCategory } from "@/types/stickers";
import { STICKER_CATEGORIES } from "@/constants/sticker-constants";
import { parseStickerId } from "@/lib/stickers/sticker-id";
export function StickersView() { export function StickersView() {
const { selectedCategory, setSelectedCategory } = useStickersStore(); const { selectedCategory, setSelectedCategory } = useStickersStore();
return ( return (
<BaseView <PanelView
value={selectedCategory} title="Stickers"
onValueChange={(v) => { actions={
if (isStickerCategory(v)) { <div className="flex items-center">
setSelectedCategory({ category: v }); <Select
} value={selectedCategory}
}} onValueChange={(value: StickerCategory) =>
tabs={[ setSelectedCategory({ category: value })
{ }
value: "all", >
label: "All", <SelectTrigger variant="outline" size="sm" className="mr-1.5">
icon: <HugeiconsIcon icon={LayoutGridIcon} className="size-3" />, <SelectValue placeholder="All" />
content: <StickersContentView category="all" />, </SelectTrigger>
}, <SelectContent>
{ {Object.entries(STICKER_CATEGORIES).map(([category, label]) => (
value: "general", <SelectItem key={category} value={category}>
label: "Icons", {label}
icon: <HugeiconsIcon icon={SparklesIcon} className="size-3" />, </SelectItem>
content: <StickersContentView category="general" />, ))}
}, </SelectContent>
{ </Select>
value: "brands",
label: "Brands", <Button variant="ghost" size="icon">
icon: <HugeiconsIcon icon={HashtagIcon} className="size-3" />, <HugeiconsIcon icon={Search01Icon} className="!size-3.5" />
content: <StickersContentView category="brands" />, </Button>
},
{ <Button variant="ghost" size="icon">
value: "emoji", <OcSlidersVerticalIcon className="!size-3.5" />
label: "Emoji", </Button>
icon: <HugeiconsIcon icon={HappyIcon} className="size-3" />, </div>
content: <StickersContentView category="emoji" />, }
}, >
]} <StickersContentView />
className="flex h-full flex-col overflow-hidden p-0" </PanelView>
/>
); );
} }
function StickerGrid({ function StickerGrid({
icons, items,
onAdd, shouldCapSize = false,
addingSticker,
capSize = false,
}: { }: {
icons: string[]; items: StickerData[];
onAdd: (iconName: string) => void; shouldCapSize?: boolean;
addingSticker: string | null;
capSize?: boolean;
}) { }) {
const gridStyle: CSSProperties & { const gridStyle: CSSProperties & {
"--sticker-min": string; "--sticker-min": string;
"--sticker-max"?: string; "--sticker-max"?: string;
} = { } = {
gridTemplateColumns: capSize gridTemplateColumns: shouldCapSize
? "repeat(auto-fill, minmax(var(--sticker-min, 96px), var(--sticker-max, 160px)))" ? "repeat(auto-fill, minmax(var(--sticker-min, 96px), var(--sticker-max, 160px)))"
: "repeat(auto-fit, minmax(var(--sticker-min, 96px), 1fr))", : "repeat(auto-fit, minmax(var(--sticker-min, 96px), 1fr))",
"--sticker-min": "96px", "--sticker-min": "96px",
...(capSize ? { "--sticker-max": "160px" } : {}), ...(shouldCapSize ? { "--sticker-max": "160px" } : {}),
}; };
return ( return (
<div className="grid gap-2" style={gridStyle}> <div className="grid gap-2" style={gridStyle}>
{icons.map((iconName) => ( {items.map((item) => (
<StickerItem <StickerItem key={item.id} item={item} shouldCapSize={shouldCapSize} />
key={iconName}
iconName={iconName}
onAdd={onAdd}
isAdding={addingSticker === iconName}
capSize={capSize}
/>
))}
</div>
);
}
function CollectionGrid({
collections,
onSelectCollection,
}: {
collections: Array<{
prefix: string;
name: string;
total: number;
category?: string;
}>;
onSelectCollection: ({ prefix }: { prefix: string }) => void;
}) {
return (
<div className="grid grid-cols-1 gap-2">
{collections.map((collection) => (
<CollectionItem
key={collection.prefix}
title={collection.name}
subtitle={`${collection.total.toLocaleString()} icons${collection.category ? `${collection.category}` : ""}`}
onClick={() => onSelectCollection({ prefix: collection.prefix })}
/>
))} ))}
</div> </div>
); );
@ -162,372 +122,125 @@ function EmptyView({ message }: { message: string }) {
); );
} }
function StickersContentView({ category }: { category: StickerCategory }) { function StickersContentView() {
const { const {
searchQuery, searchQuery,
selectedCollection,
viewMode, viewMode,
collections,
currentCollection,
searchResults, searchResults,
recentStickers, recentStickers,
isLoadingCollections,
isLoadingCollection,
isSearching, isSearching,
setSearchQuery,
setSelectedCollection,
loadCollections,
searchStickers,
addStickerToTimeline,
clearRecentStickers, clearRecentStickers,
setSelectedCategory,
addingSticker,
} = useStickersStore(); } = useStickersStore();
const [localSearchQuery, setLocalSearchQuery] = useState(searchQuery); const itemsToDisplay = useMemo(() => {
const [collectionsToShow, setCollectionsToShow] = useState(20); if (viewMode === "search" && searchResults) {
const [showCollectionItems, setShowCollectionItems] = useState(false); return searchResults.items;
const filteredCollections = useMemo(() => {
if (category === "all") {
return Object.entries(collections).map(([prefix, collection]) => ({
prefix,
name: collection.name,
total: collection.total,
category: collection.category,
}));
} }
const collectionList = return [];
POPULAR_COLLECTIONS[category as keyof typeof POPULAR_COLLECTIONS]; }, [viewMode, searchResults]);
if (!collectionList) return [];
return collectionList const recentStickerItems = useMemo(() => {
.map((c) => { const items: StickerData[] = [];
const collection = collections[c.prefix]; for (const stickerId of recentStickers) {
return collection const recentStickerItem = toRecentStickerItem({ stickerId });
? { if (recentStickerItem) {
prefix: c.prefix, items.push(recentStickerItem);
name: c.name,
total: collection.total,
}
: null;
})
.filter(Boolean) as Array<{
prefix: string;
name: string;
total: number;
}>;
}, [collections, category]);
const { scrollAreaRef, handleScroll } = useInfiniteScroll({
onLoadMore: () => setCollectionsToShow((prev) => prev + 20),
hasMore: filteredCollections.length > collectionsToShow,
isLoading: isLoadingCollections,
enabled: viewMode === "browse" && !selectedCollection && category === "all",
});
useEffect(() => {
if (Object.keys(collections).length === 0) {
loadCollections();
}
}, [collections, loadCollections]);
useEffect(() => {
const timer = setTimeout(() => {
if (localSearchQuery !== searchQuery) {
setSearchQuery({ query: localSearchQuery });
if (localSearchQuery.trim()) {
searchStickers({ query: localSearchQuery });
}
} }
}, 500); }
return items;
}, [recentStickers]);
return () => clearTimeout(timer); return (
}, [localSearchQuery, searchQuery, searchStickers, setSearchQuery]); <div className="flex h-full flex-col gap-4">
{recentStickerItems.length > 0 && viewMode === "browse" && (
<div className="flex h-full flex-col gap-2">
<div className="flex items-center gap-2">
<HugeiconsIcon
icon={ClockIcon}
className="text-muted-foreground size-4"
/>
<span className="text-sm font-medium">Recent</span>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={clearRecentStickers}
className="hover:bg-accent ml-auto flex size-5 items-center justify-center rounded p-0"
>
<HugeiconsIcon
icon={MultiplicationSignIcon}
className="text-muted-foreground size-3"
/>
</button>
</TooltipTrigger>
<TooltipContent>
<p>Clear recent stickers</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<StickerGrid items={recentStickerItems.slice(0, 12)} shouldCapSize />
</div>
)}
const handleAddSticker = async (iconName: string) => { {viewMode === "search" && (
<div className="h-full">
{isSearching ? (
<div className="flex items-center justify-center py-8">
<Spinner className="text-muted-foreground size-6" />
</div>
) : searchResults?.items.length ? (
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-sm">
{searchResults.total} results
</span>
</div>
<StickerGrid items={itemsToDisplay} shouldCapSize />
</div>
) : searchQuery ? (
<EmptyView message={`No stickers found for "${searchQuery}"`} />
) : null}
</div>
)}
</div>
);
}
interface StickerItemProps {
item: StickerData;
shouldCapSize?: boolean;
}
function StickerItem({ item, shouldCapSize = false }: StickerItemProps) {
const { addingSticker, addStickerToTimeline } = useStickersStore();
const isAdding = addingSticker === item.id;
const [hasImageError, setHasImageError] = useState(false);
useEffect(() => {
if (!item.id) {
return;
}
setHasImageError(false);
}, [item.id]);
const displayName = item.name;
const handleAdd = async () => {
try { try {
await addStickerToTimeline({ iconName }); await addStickerToTimeline({
stickerId: item.id,
name: item.name,
});
} catch (error) { } catch (error) {
console.error("Failed to add sticker:", error); console.error("Failed to add sticker:", error);
toast.error("Failed to add sticker to timeline"); toast.error("Failed to add sticker to timeline");
} }
}; };
const iconsToDisplay = useMemo(() => { const preview = hasImageError ? (
if (viewMode === "search" && searchResults) {
return searchResults.icons;
}
if (viewMode === "collection" && currentCollection) {
const icons: string[] = [];
if (currentCollection.uncategorized) {
icons.push(
...currentCollection.uncategorized.map(
(name) => `${currentCollection.prefix}:${name}`,
),
);
}
if (currentCollection.categories) {
Object.values(currentCollection.categories).forEach((categoryIcons) => {
icons.push(
...categoryIcons.map(
(name) => `${currentCollection.prefix}:${name}`,
),
);
});
}
return icons.slice(0, 100);
}
return [];
}, [viewMode, searchResults, currentCollection]);
const isInCollection = viewMode === "collection" && !!selectedCollection;
useEffect(() => {
if (isInCollection) {
setShowCollectionItems(false);
const timer = setTimeout(() => setShowCollectionItems(true), 350);
return () => clearTimeout(timer);
} else {
setShowCollectionItems(false);
}
}, [isInCollection]);
return (
<div className="mt-1 flex h-full flex-col gap-5 p-4">
<div className="space-y-3">
<InputWithBack
isExpanded={isInCollection}
setIsExpanded={(expanded) => {
if (!expanded && isInCollection) {
setSelectedCollection({ collection: null });
}
}}
placeholder={
category === "all"
? "Search all stickers"
: category === "general"
? "Search icons"
: category === "brands"
? "Search brands"
: "Search Emojis"
}
value={localSearchQuery}
onChange={setLocalSearchQuery}
disableAnimation={true}
/>
</div>
<div className="relative h-full overflow-hidden">
<ScrollArea
className="h-full flex-1"
ref={scrollAreaRef}
onScrollCapture={handleScroll}
>
<div className="flex h-full flex-col gap-4">
{recentStickers.length > 0 && viewMode === "browse" && (
<div className="h-full">
<div className="mb-2 flex items-center gap-2">
<HugeiconsIcon
icon={ClockIcon}
className="text-muted-foreground size-4"
/>
<span className="text-sm font-medium">Recent</span>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={clearRecentStickers}
className="hover:bg-accent ml-auto flex size-5 items-center justify-center rounded p-0"
>
<HugeiconsIcon
icon={MultiplicationSignIcon}
className="text-muted-foreground size-3"
/>
</button>
</TooltipTrigger>
<TooltipContent>
<p>Clear recent stickers</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<StickerGrid
icons={recentStickers.slice(0, 12)}
onAdd={handleAddSticker}
addingSticker={addingSticker}
capSize
/>
</div>
)}
{viewMode === "collection" && selectedCollection && (
<div className="h-full">
{isLoadingCollection ? (
<div className="flex items-center justify-center py-8">
<Spinner className="text-muted-foreground size-6" />
</div>
) : showCollectionItems ? (
<StickerGrid
icons={iconsToDisplay}
onAdd={handleAddSticker}
addingSticker={addingSticker}
/>
) : (
<div className="flex items-center justify-center py-8">
<Spinner className="text-muted-foreground size-6" />
</div>
)}
</div>
)}
{viewMode === "search" && (
<div className="h-full">
{isSearching ? (
<div className="flex items-center justify-center py-8">
<Spinner className="text-muted-foreground size-6" />
</div>
) : searchResults?.icons.length ? (
<>
<div className="mb-3 flex items-center justify-between">
<span className="text-muted-foreground text-sm">
{searchResults.total} results
</span>
</div>
<StickerGrid
icons={iconsToDisplay}
onAdd={handleAddSticker}
addingSticker={addingSticker}
capSize
/>
</>
) : searchQuery ? (
<div className="flex flex-col items-center justify-center gap-3 py-8">
<EmptyView
message={`No stickers found for "${searchQuery}"`}
/>
{category !== "all" && (
<Button
variant="outline"
onClick={() => {
const q = localSearchQuery || searchQuery;
if (q) {
setSearchQuery({ query: q });
}
setSelectedCategory({ category: "all" });
if (q) {
searchStickers({ query: q });
}
}}
>
Search in all icons
</Button>
)}
</div>
) : null}
</div>
)}
{viewMode === "browse" && !selectedCollection && (
<div className="h-full space-y-4">
{isLoadingCollections ? (
<div className="flex items-center justify-center py-8">
<Spinner className="text-muted-foreground size-6" />
</div>
) : (
<>
{category !== "all" && (
<div className="h-full">
<CollectionGrid
collections={filteredCollections}
onSelectCollection={({ prefix }) =>
setSelectedCollection({ collection: prefix })
}
/>
</div>
)}
{category === "all" && filteredCollections.length > 0 && (
<div className="h-full">
<CollectionGrid
collections={filteredCollections.slice(
0,
collectionsToShow,
)}
onSelectCollection={({ prefix }) =>
setSelectedCollection({ collection: prefix })
}
/>
</div>
)}
</>
)}
</div>
)}
</div>
</ScrollArea>
</div>
</div>
);
}
interface CollectionItemProps {
title: string;
subtitle: string;
onClick: () => void;
}
function CollectionItem({ title, subtitle, onClick }: CollectionItemProps) {
return (
<Button
variant="outline"
className="h-auto justify-between rounded-md py-2"
onClick={onClick}
>
<div className="text-left">
<p className="font-medium">{title}</p>
<p className="text-muted-foreground text-xs">{subtitle}</p>
</div>
<HugeiconsIcon icon={ArrowRightIcon} className="size-4" />
</Button>
);
}
interface StickerItemProps {
iconName: string;
onAdd: (iconName: string) => void;
isAdding?: boolean;
capSize?: boolean;
}
function StickerItem({
iconName,
onAdd,
isAdding,
capSize = false,
}: StickerItemProps) {
const [imageError, setImageError] = useState(false);
const [hostIndex, setHostIndex] = useState(0);
useEffect(() => {
if (!iconName) {
return;
}
setImageError(false);
setHostIndex(0);
}, [iconName]);
const displayName = iconName.split(":")[1] || iconName;
const collectionPrefix = iconName.split(":")[0];
const preview = imageError ? (
<div className="flex size-full items-center justify-center p-2"> <div className="flex size-full items-center justify-center p-2">
<span className="text-muted-foreground text-center text-xs break-all"> <span className="text-muted-foreground text-center text-xs break-all">
{displayName} {displayName}
@ -536,21 +249,13 @@ function StickerItem({
) : ( ) : (
<div className="flex size-full items-center justify-center p-4"> <div className="flex size-full items-center justify-center p-4">
<Image <Image
src={ src={item.previewUrl}
hostIndex === 0
? getIconSvgUrl(iconName, { width: 64, height: 64 })
: buildIconSvgUrl(
ICONIFY_HOSTS[Math.min(hostIndex, ICONIFY_HOSTS.length - 1)],
iconName,
{ width: 64, height: 64 },
)
}
alt={displayName} alt={displayName}
width={64} width={64}
height={64} height={64}
className="size-full object-contain" className="size-full object-contain"
style={ style={
capSize shouldCapSize
? { ? {
maxWidth: "var(--sticker-max, 160px)", maxWidth: "var(--sticker-max, 160px)",
maxHeight: "var(--sticker-max, 160px)", maxHeight: "var(--sticker-max, 160px)",
@ -558,12 +263,7 @@ function StickerItem({
: undefined : undefined
} }
onError={() => { onError={() => {
const next = hostIndex + 1; setHasImageError(true);
if (next < ICONIFY_HOSTS.length) {
setHostIndex(next);
} else {
setImageError(true);
}
}} }}
loading="lazy" loading="lazy"
unoptimized unoptimized
@ -572,44 +272,63 @@ function StickerItem({
); );
return ( return (
<Tooltip> <div
<TooltipTrigger asChild> className={cn("relative", isAdding && "pointer-events-none opacity-50")}
<div >
className={cn( <DraggableItem
"relative", name={displayName}
isAdding && "pointer-events-none opacity-50", preview={preview}
)} dragData={{
> id: item.id,
<DraggableItem type: "sticker",
name={displayName} name: displayName,
preview={preview} stickerId: item.id,
dragData={{ }}
id: iconName, onAddToTimeline={handleAdd}
type: "sticker", aspectRatio={1}
name: displayName, shouldShowLabel={false}
iconName, isRounded
}} variant="card"
onAddToTimeline={() => onAdd(iconName)} containerClassName="w-full"
aspectRatio={1} />
shouldShowLabel={false} {isAdding && (
isRounded={true} <div className="absolute inset-0 z-10 flex items-center justify-center rounded-md bg-black/60">
variant="card" <Spinner className="size-6 text-white" />
className=""
containerClassName="w-full"
/>
{isAdding && (
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-md bg-black/60">
<Spinner className="size-6 text-white" />
</div>
)}
</div> </div>
</TooltipTrigger> )}
<TooltipContent> </div>
<div className="space-y-1">
<p className="font-medium">{displayName}</p>
<p className="text-muted-foreground text-xs">{collectionPrefix}</p>
</div>
</TooltipContent>
</Tooltip>
); );
} }
function getStickerNameFromId({ stickerId }: { stickerId: string }): string {
const stickerIdParts = stickerId.split(":");
if (stickerIdParts.length <= 1) {
return stickerId;
}
return (
stickerIdParts.slice(1).join(":").split(":").pop()?.replaceAll("-", " ") ??
stickerId
);
}
function toRecentStickerItem({
stickerId,
}: {
stickerId: string;
}): StickerData | null {
try {
const { providerId } = parseStickerId({ stickerId });
return {
id: stickerId,
provider: providerId,
name: getStickerNameFromId({ stickerId }),
previewUrl: resolveStickerId({
stickerId,
options: { width: 64, height: 64 },
}),
metadata: {},
};
} catch {
return null;
}
}

View File

@ -1,5 +1,5 @@
import { DraggableItem } from "@/components/editor/panels/assets/draggable-item"; import { DraggableItem } from "@/components/editor/panels/assets/draggable-item";
import { PanelBaseView as BaseView } from "@/components/editor/panels/panel-base-view"; import { PanelView } from "@/components/editor/panels/assets/views/base-view";
import { useEditor } from "@/hooks/use-editor"; import { useEditor } from "@/hooks/use-editor";
import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants"; import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
import { buildTextElement } from "@/lib/timeline/element-utils"; import { buildTextElement } from "@/lib/timeline/element-utils";
@ -23,7 +23,7 @@ export function TextView() {
}; };
return ( return (
<BaseView> <PanelView title="Text">
<DraggableItem <DraggableItem
name="Default text" name="Default text"
preview={ preview={
@ -41,6 +41,6 @@ export function TextView() {
onAddToTimeline={handleAddToTimeline} onAddToTimeline={handleAddToTimeline}
shouldShowLabel={false} shouldShowLabel={false}
/> />
</BaseView> </PanelView>
); );
} }

View File

@ -1,85 +0,0 @@
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/utils/ui";
interface PanelBaseViewProps {
children?: React.ReactNode;
defaultTab?: string;
value?: string;
onValueChange?: (value: string) => void;
tabs?: {
value: string;
label: string;
icon?: React.ReactNode;
content: React.ReactNode;
}[];
className?: string;
ref?: React.Ref<HTMLDivElement>;
}
function ViewContent({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<ScrollArea className="flex-1 scrollbar-hidden">
<div className={cn("p-5", className)}>{children}</div>
</ScrollArea>
);
}
export function PanelBaseView({
children,
defaultTab,
value,
onValueChange,
tabs,
className = "",
ref,
}: PanelBaseViewProps) {
return (
<div className={cn("flex h-full flex-col", className)} ref={ref}>
{!tabs || tabs.length === 0 ? (
<ViewContent className={className}>{children}</ViewContent>
) : (
<Tabs
defaultValue={defaultTab}
value={value}
onValueChange={onValueChange}
className="flex h-full flex-col"
>
<div className="bg-background sticky top-0 z-10">
<div className="px-3 pt-3 pb-0">
<TabsList>
{tabs.map((tab) => (
<TabsTrigger key={tab.value} value={tab.value}>
{tab.icon ? (
<span className="mr-1 inline-flex items-center">
{tab.icon}
</span>
) : null}
{tab.label}
</TabsTrigger>
))}
</TabsList>
</div>
<Separator className="mt-3" />
</div>
{tabs.map((tab) => (
<TabsContent
key={tab.value}
value={tab.value}
className="mt-0 flex min-h-0 flex-1 flex-col"
>
<ViewContent className={className}>{tab.content}</ViewContent>
</TabsContent>
))}
</Tabs>
)}
</div>
);
}

View File

@ -0,0 +1,59 @@
"use client";
import { useEditor } from "@/hooks/use-editor";
import { findCurrentScene } from "@/lib/scenes";
import { getBookmarksActiveAtTime } from "@/lib/timeline/bookmarks";
export function BookmarkNoteOverlay() {
const editor = useEditor();
const currentTime = editor.playback.getCurrentTime();
const activeProject = editor.project.getActive();
if (!activeProject) {
return null;
}
const activeScene = findCurrentScene({
scenes: activeProject.scenes,
currentSceneId: activeProject.currentSceneId,
});
if (!activeScene) {
return null;
}
const bookmarks = activeScene.bookmarks;
const activeBookmarks = getBookmarksActiveAtTime({
bookmarks,
time: currentTime,
});
const bookmarksWithNotes = activeBookmarks.filter(
(bookmark) => bookmark.note != null && bookmark.note.trim() !== "",
);
if (bookmarksWithNotes.length === 0) {
return null;
}
return (
<div
className="pointer-events-none absolute top-2 left-2 flex flex-col gap-1.5"
aria-live="polite"
>
{bookmarksWithNotes.map((bookmark) => (
<div
key={bookmark.time}
className="flex max-w-[min(200px,50vw)] px-2.5 py-1.5 text-left text-white text-xs shadow-md backdrop-blur-sm"
style={{
backgroundColor: "rgb(0 0 0 / 0.5)",
borderLeft: bookmark.color
? `3px solid ${bookmark.color}`
: "3px solid var(--primary)",
}}
>
{bookmark.note}
</div>
))}
</div>
);
}

View File

@ -0,0 +1,35 @@
"use client";
import {
ContextMenuCheckboxItem,
ContextMenuContent,
ContextMenuItem,
} from "@/components/ui/context-menu";
import { usePreviewStore } from "@/stores/preview-store";
export function PreviewContextMenu({
onToggleFullscreen,
containerRef,
}: {
onToggleFullscreen: () => void;
containerRef: React.RefObject<HTMLElement | null>;
}) {
const { overlays, setOverlayVisibility } = usePreviewStore();
return (
<ContextMenuContent className="w-56" container={containerRef.current}>
<ContextMenuItem onClick={onToggleFullscreen} inset>
Full screen
</ContextMenuItem>
<ContextMenuCheckboxItem
checked={overlays.bookmarks}
onCheckedChange={(checked) =>
setOverlayVisibility({ overlay: "bookmarks", isVisible: !!checked })
}
>
Show bookmarks
</ContextMenuCheckboxItem>
<ContextMenuItem inset>Show grid</ContextMenuItem>
</ContextMenuContent>
);
}

View File

@ -9,18 +9,13 @@ import { useFullscreen } from "@/hooks/use-fullscreen";
import { CanvasRenderer } from "@/services/renderer/canvas-renderer"; import { CanvasRenderer } from "@/services/renderer/canvas-renderer";
import type { RootNode } from "@/services/renderer/nodes/root-node"; import type { RootNode } from "@/services/renderer/nodes/root-node";
import { buildScene } from "@/services/renderer/scene-builder"; import { buildScene } from "@/services/renderer/scene-builder";
import { formatTimeCode, getLastFrameTime } from "@/lib/time"; import { getLastFrameTime } from "@/lib/time";
import { PreviewInteractionOverlay } from "./preview-interaction-overlay"; import { PreviewInteractionOverlay } from "./preview-interaction-overlay";
import { EditableTimecode } from "@/components/editable-timecode"; import { BookmarkNoteOverlay } from "./bookmark-note-overlay";
import { invokeAction } from "@/lib/actions"; import { ContextMenu, ContextMenuTrigger } from "@/components/ui/context-menu";
import { Button } from "@/components/ui/button"; import { usePreviewStore } from "@/stores/preview-store";
import { import { PreviewContextMenu } from "./context-menu";
FullScreenIcon, import { PreviewToolbar } from "./toolbar";
PauseIcon,
PlayIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { cn } from "@/utils/ui";
function usePreviewSize() { function usePreviewSize() {
const editor = useEditor(); const editor = useEditor();
@ -32,6 +27,30 @@ function usePreviewSize() {
}; };
} }
export function PreviewPanel() {
const containerRef = useRef<HTMLDivElement>(null);
const { isFullscreen, toggleFullscreen } = useFullscreen({ containerRef });
return (
<div
ref={containerRef}
className="panel bg-background relative flex size-full min-h-0 min-w-0 flex-col rounded-sm border"
>
<div className="flex min-h-0 min-w-0 flex-1 items-center justify-center p-2 pb-0">
<PreviewCanvas
onToggleFullscreen={toggleFullscreen}
containerRef={containerRef}
/>
<RenderTreeController />
</div>
<PreviewToolbar
isFullscreen={isFullscreen}
onToggleFullscreen={toggleFullscreen}
/>
</div>
);
}
function RenderTreeController() { function RenderTreeController() {
const editor = useEditor(); const editor = useEditor();
const tracks = editor.timeline.getTracks(); const tracks = editor.timeline.getTracks();
@ -58,98 +77,24 @@ function RenderTreeController() {
return null; return null;
} }
export function PreviewPanel() { function PreviewCanvas({
const containerRef = useRef<HTMLDivElement>(null);
const { isFullscreen, toggleFullscreen } = useFullscreen({ containerRef });
return (
<div
ref={containerRef}
className={cn(
"panel bg-background relative flex h-full min-h-0 w-full min-w-0 flex-col rounded-sm border",
isFullscreen && "bg-background",
)}
>
<div className="flex min-h-0 min-w-0 flex-1 items-center justify-center p-2 pb-0">
<PreviewCanvas />
<RenderTreeController />
</div>
<PreviewToolbar
isFullscreen={isFullscreen}
onToggleFullscreen={toggleFullscreen}
/>
</div>
);
}
function PreviewToolbar({
isFullscreen,
onToggleFullscreen, onToggleFullscreen,
containerRef,
}: { }: {
isFullscreen: boolean;
onToggleFullscreen: () => void; onToggleFullscreen: () => void;
containerRef: React.RefObject<HTMLElement | null>;
}) { }) {
const editor = useEditor();
const isPlaying = editor.playback.getIsPlaying();
const currentTime = editor.playback.getCurrentTime();
const totalDuration = editor.timeline.getTotalDuration();
const fps = editor.project.getActive().settings.fps;
return (
<div className="grid grid-cols-[1fr_auto_1fr] items-center pb-3 pt-5 px-5">
<div className="flex items-center mt-1">
<EditableTimecode
time={currentTime}
duration={totalDuration}
format="HH:MM:SS:FF"
fps={fps}
onTimeChange={({ time }) => editor.playback.seek({ time })}
className="text-center"
/>
<span className="text-muted-foreground px-2 font-mono text-xs">/</span>
<span className="text-muted-foreground font-mono text-xs">
{formatTimeCode({
timeInSeconds: totalDuration,
format: "HH:MM:SS:FF",
fps,
})}
</span>
</div>
<Button
variant="text"
size="icon"
type="button"
onClick={() => invokeAction("toggle-play")}
>
<HugeiconsIcon icon={isPlaying ? PauseIcon : PlayIcon} />
</Button>
<div className="justify-self-end">
<Button
variant="text"
size="icon"
type="button"
onClick={onToggleFullscreen}
title={isFullscreen ? "Exit fullscreen" : "Enter fullscreen"}
>
<HugeiconsIcon icon={FullScreenIcon} />
</Button>
</div>
</div>
);
}
function PreviewCanvas() {
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null); const outerContainerRef = useRef<HTMLDivElement>(null);
const canvasBoundsRef = useRef<HTMLDivElement>(null);
const lastFrameRef = useRef(-1); const lastFrameRef = useRef(-1);
const lastSceneRef = useRef<RootNode | null>(null); const lastSceneRef = useRef<RootNode | null>(null);
const renderingRef = useRef(false); const renderingRef = useRef(false);
const { width: nativeWidth, height: nativeHeight } = usePreviewSize(); const { width: nativeWidth, height: nativeHeight } = usePreviewSize();
const containerSize = useContainerSize({ containerRef }); const containerSize = useContainerSize({ containerRef: outerContainerRef });
const editor = useEditor(); const editor = useEditor();
const activeProject = editor.project.getActive(); const activeProject = editor.project.getActive();
const { overlays } = usePreviewStore();
const renderer = useMemo(() => { const renderer = useMemo(() => {
return new CanvasRenderer({ return new CanvasRenderer({
@ -224,24 +169,42 @@ function PreviewCanvas() {
return ( return (
<div <div
ref={containerRef} ref={outerContainerRef}
className="relative flex h-full w-full items-center justify-center" className="relative flex size-full items-center justify-center"
> >
<canvas <ContextMenu>
ref={canvasRef} <ContextMenuTrigger asChild>
width={nativeWidth} <div
height={nativeHeight} ref={canvasBoundsRef}
className="block border" className="relative"
style={{ style={{ width: displaySize.width, height: displaySize.height }}
width: displaySize.width, >
height: displaySize.height, <canvas
background: ref={canvasRef}
activeProject.settings.background.type === "blur" width={nativeWidth}
? "transparent" height={nativeHeight}
: activeProject?.settings.background.color, className="block border"
}} style={{
/> width: displaySize.width,
<PreviewInteractionOverlay canvasRef={canvasRef} /> height: displaySize.height,
background:
activeProject.settings.background.type === "blur"
? "transparent"
: activeProject?.settings.background.color,
}}
/>
<PreviewInteractionOverlay
canvasRef={canvasRef}
containerRef={canvasBoundsRef}
/>
{overlays.bookmarks && <BookmarkNoteOverlay />}
</div>
</ContextMenuTrigger>
<PreviewContextMenu
onToggleFullscreen={onToggleFullscreen}
containerRef={containerRef}
/>
</ContextMenu>
</div> </div>
); );
} }

View File

@ -1,27 +1,27 @@
"use client"; "use client";
import { useEditorStore } from "@/stores/editor-store"; import { usePreviewStore } from "@/stores/preview-store";
import Image from "next/image"; import Image from "next/image";
function TikTokGuide() { function TikTokGuide() {
return ( return (
<div className="pointer-events-none absolute inset-0"> <div className="pointer-events-none absolute inset-0">
<Image <Image
src="/platform-guides/tiktok-blueprint.png" src="/platform-guides/tiktok-blueprint.png"
alt="TikTok layout guide" alt="TikTok layout guide"
className="absolute inset-0 size-full object-contain" className="absolute inset-0 size-full object-contain"
draggable={false} draggable={false}
fill fill
/> />
</div> </div>
); );
} }
export function LayoutGuideOverlay() { export function LayoutGuideOverlay() {
const { layoutGuide } = useEditorStore(); const { layoutGuide } = usePreviewStore();
if (layoutGuide.platform === null) return null; if (layoutGuide.platform === null) return null;
if (layoutGuide.platform === "tiktok") return <TikTokGuide />; if (layoutGuide.platform === "tiktok") return <TikTokGuide />;
return null; return null;
} }

View File

@ -1,20 +1,54 @@
import { usePreviewInteraction } from "@/hooks/use-preview-interaction"; import { usePreviewInteraction } from "@/hooks/use-preview-interaction";
import { cn } from "@/utils/ui"; import { TransformHandles } from "./transform-handles";
import { SnapGuides } from "./snap-guides";
import { TextEditOverlay } from "./text-edit-overlay";
export function PreviewInteractionOverlay({ export function PreviewInteractionOverlay({
canvasRef, canvasRef,
containerRef,
}: { }: {
canvasRef: React.RefObject<HTMLCanvasElement | null>; canvasRef: React.RefObject<HTMLCanvasElement | null>;
containerRef: React.RefObject<HTMLDivElement | null>;
}) { }) {
const { onPointerDown, onPointerMove, onPointerUp } = const {
usePreviewInteraction({ canvasRef }); onPointerDown,
onPointerMove,
onPointerUp,
onDoubleClick,
snapLines,
editingText,
commitTextEdit,
cancelTextEdit,
} = usePreviewInteraction({ canvasRef });
return ( return (
<div <div className="absolute inset-0">
className={cn("absolute inset-0 pointer-events-auto")} {/* biome-ignore lint/a11y/noStaticElementInteractions: canvas overlay, pointer-only interaction */}
onPointerDown={onPointerDown} <div
onPointerMove={onPointerMove} className="absolute inset-0 pointer-events-auto"
onPointerUp={onPointerUp} onPointerDown={onPointerDown}
/> onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onDoubleClick={onDoubleClick}
/>
{editingText ? (
<TextEditOverlay
canvasRef={canvasRef}
containerRef={containerRef}
trackId={editingText.trackId}
elementId={editingText.elementId}
element={editingText.element}
onCommit={commitTextEdit}
onCancel={cancelTextEdit}
/>
) : (
<TransformHandles canvasRef={canvasRef} containerRef={containerRef} />
)}
<SnapGuides
lines={snapLines}
canvasRef={canvasRef}
containerRef={containerRef}
/>
</div>
); );
} }

View File

@ -0,0 +1,65 @@
"use client";
import { useEditor } from "@/hooks/use-editor";
import type { SnapLine } from "@/lib/preview/preview-snap";
import { positionToOverlay } from "@/lib/preview/preview-coords";
export function SnapGuides({
lines,
canvasRef,
containerRef,
}: {
lines: SnapLine[];
canvasRef: React.RefObject<HTMLCanvasElement | null>;
containerRef: React.RefObject<HTMLDivElement | null>;
}) {
const editor = useEditor();
const canvasSize = editor.project.getActive().settings.canvasSize;
const canvasRect = canvasRef.current?.getBoundingClientRect();
const containerRect = containerRef.current?.getBoundingClientRect();
if (!canvasRect || !containerRect || lines.length === 0) {
return null;
}
const toOverlayX = (logicalX: number) =>
positionToOverlay({
positionX: logicalX,
positionY: 0,
canvasRect,
containerRect,
canvasSize,
}).x;
const toOverlayY = (logicalY: number) =>
positionToOverlay({
positionX: 0,
positionY: logicalY,
canvasRect,
containerRect,
canvasSize,
}).y;
return (
<div className="pointer-events-none absolute inset-0" aria-hidden>
{lines.map((line) => {
if (line.type === "vertical") {
return (
<div
key={`vertical-${line.position}`}
className="absolute top-0 bottom-0 w-px bg-white/70"
style={{ left: toOverlayX(line.position) }}
/>
);
}
return (
<div
key={`horizontal-${line.position}`}
className="absolute left-0 right-0 h-px bg-white/70"
style={{ top: toOverlayY(line.position) }}
/>
);
})}
</div>
);
}

View File

@ -0,0 +1,149 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
import { useEditor } from "@/hooks/use-editor";
import type { TextElement } from "@/types/timeline";
import {
positionToOverlay,
getDisplayScale,
} from "@/lib/preview/preview-coords";
import {
DEFAULT_LINE_HEIGHT,
FONT_SIZE_SCALE_REFERENCE,
} from "@/constants/text-constants";
const TEXT_BACKGROUND_PADDING = "4px 8px";
const TEXT_EDIT_VERTICAL_OFFSET_EM = 0.06;
export function TextEditOverlay({
canvasRef,
containerRef,
trackId,
elementId,
element,
onCommit,
onCancel,
}: {
canvasRef: React.RefObject<HTMLCanvasElement | null>;
containerRef: React.RefObject<HTMLDivElement | null>;
trackId: string;
elementId: string;
element: TextElement;
onCommit: () => void;
onCancel: () => void;
}) {
const editor = useEditor();
const divRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const div = divRef.current;
if (!div) return;
div.focus();
const range = document.createRange();
range.selectNodeContents(div);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
}, []);
const handleInput = useCallback(() => {
const div = divRef.current;
if (!div) return;
const text = div.innerText;
editor.timeline.previewElements({
updates: [{ trackId, elementId, updates: { content: text } }],
});
}, [editor.timeline, trackId, elementId]);
const handleKeyDown = useCallback(
(event: React.KeyboardEvent) => {
const { key } = event;
if (key === "Escape") {
event.preventDefault();
onCancel();
return;
}
},
[onCancel],
);
const canvasRect = canvasRef.current?.getBoundingClientRect();
const containerRect = containerRef.current?.getBoundingClientRect();
const canvasSize = editor.project.getActive().settings.canvasSize;
if (!canvasRect || !containerRect || !canvasSize) return null;
const { x: posX, y: posY } = positionToOverlay({
positionX: element.transform.position.x,
positionY: element.transform.position.y,
canvasRect,
containerRect,
canvasSize,
});
const { x: displayScaleX } = getDisplayScale({
canvasRect,
canvasSize,
});
const displayFontSize =
element.fontSize *
(canvasSize.height / FONT_SIZE_SCALE_REFERENCE) *
displayScaleX;
const verticalAlignmentOffset =
displayFontSize * TEXT_EDIT_VERTICAL_OFFSET_EM;
const lineHeight = element.lineHeight ?? DEFAULT_LINE_HEIGHT;
const fontWeight = element.fontWeight === "bold" ? "bold" : "normal";
const fontStyle = element.fontStyle === "italic" ? "italic" : "normal";
const letterSpacing = element.letterSpacing ?? 0;
const backgroundColor =
element.backgroundColor === "transparent"
? "transparent"
: element.backgroundColor;
return (
<div
className="absolute"
style={{
left: posX,
top: posY - verticalAlignmentOffset,
transform: `translate(-50%, -50%) scale(${element.transform.scale}) rotate(${element.transform.rotate}deg)`,
transformOrigin: "center center",
}}
>
{/* biome-ignore lint/a11y/useSemanticElements: contenteditable required for multiline, IME, paste */}
<div
ref={divRef}
contentEditable
suppressContentEditableWarning
tabIndex={0}
role="textbox"
aria-label="Edit text"
className="cursor-text select-text outline-none whitespace-pre"
style={{
fontSize: displayFontSize,
fontFamily: element.fontFamily,
fontWeight,
fontStyle,
textAlign: element.textAlign,
letterSpacing: `${letterSpacing}px`,
lineHeight,
color: element.color,
backgroundColor,
minHeight: displayFontSize * lineHeight,
textDecoration: element.textDecoration ?? "none",
padding:
backgroundColor === "transparent" ? 0 : TEXT_BACKGROUND_PADDING,
minWidth: 1,
}}
onInput={handleInput}
onBlur={onCommit}
onKeyDown={handleKeyDown}
>
{element.content || ""}
</div>
</div>
);
}

View File

@ -0,0 +1,80 @@
"use client";
import { useEditor } from "@/hooks/use-editor";
import { formatTimeCode } from "@/lib/time";
import { invokeAction } from "@/lib/actions";
import { EditableTimecode } from "@/components/editable-timecode";
import { Button } from "@/components/ui/button";
import {
FullScreenIcon,
PauseIcon,
PlayIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { OcSocialIcon } from "@opencut/ui/icons";
import { Separator } from "@/components/ui/separator";
export function PreviewToolbar({
isFullscreen,
onToggleFullscreen,
}: {
isFullscreen: boolean;
onToggleFullscreen: () => void;
}) {
const editor = useEditor();
const isPlaying = editor.playback.getIsPlaying();
const currentTime = editor.playback.getCurrentTime();
const totalDuration = editor.timeline.getTotalDuration();
const fps = editor.project.getActive().settings.fps;
return (
<div className="grid grid-cols-[1fr_auto_1fr] items-center pb-3 pt-5 px-5">
<div className="flex items-center">
<EditableTimecode
time={currentTime}
duration={totalDuration}
format="HH:MM:SS:FF"
fps={fps}
onTimeChange={({ time }) => editor.playback.seek({ time })}
className="text-center"
/>
<span className="text-muted-foreground px-2 font-mono text-xs">/</span>
<span className="text-muted-foreground font-mono text-xs">
{formatTimeCode({
timeInSeconds: totalDuration,
format: "HH:MM:SS:FF",
fps,
})}
</span>
</div>
<Button
variant="text"
size="icon"
onClick={() => invokeAction("toggle-play")}
>
<HugeiconsIcon icon={isPlaying ? PauseIcon : PlayIcon} />
</Button>
<div className="justify-self-end flex items-center gap-2.5">
<Button
variant="secondary"
size="sm"
className="[&_svg]:size-auto px-1 h-7"
onClick={onToggleFullscreen}
title={isFullscreen ? "Exit fullscreen" : "Enter fullscreen"}
>
<OcSocialIcon size={20} />
</Button>
<Separator orientation="vertical" className="h-4" />
<Button
variant="text"
onClick={onToggleFullscreen}
title={isFullscreen ? "Exit fullscreen" : "Enter fullscreen"}
>
<HugeiconsIcon icon={FullScreenIcon} />
</Button>
</div>
</div>
);
}

View File

@ -0,0 +1,283 @@
"use client";
import { useTransformHandles } from "@/hooks/use-transform-handles";
import { useEditor } from "@/hooks/use-editor";
import { isVisualElement } from "@/lib/timeline/element-utils";
import { SnapGuides } from "./snap-guides";
import { canvasToOverlay, getDisplayScale } from "@/lib/preview/preview-coords";
import type { ElementBounds } from "@/lib/preview/element-bounds";
import { cn } from "@/utils/ui";
import { HugeiconsIcon } from "@hugeicons/react";
import { Rotate01Icon } from "@hugeicons/core-free-icons";
const HANDLE_SIZE = 10;
const ROTATION_HANDLE_OFFSET = 24;
const ROTATION_HANDLE_RADIUS = 10;
const CORNER_HIT_AREA_SIZE = 18;
type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
const CORNERS: Corner[] = [
"top-left",
"top-right",
"bottom-left",
"bottom-right",
];
function getCornerPosition({
bounds,
corner,
}: {
bounds: ElementBounds;
corner: Corner;
}): { x: number; y: number } {
const halfW = bounds.width / 2;
const halfH = bounds.height / 2;
const angleRad = (bounds.rotation * Math.PI) / 180;
const cos = Math.cos(angleRad);
const sin = Math.sin(angleRad);
const localX =
corner === "top-left" || corner === "bottom-left" ? -halfW : halfW;
const localY =
corner === "top-left" || corner === "top-right" ? -halfH : halfH;
return {
x: bounds.cx + (localX * cos - localY * sin),
y: bounds.cy + (localX * sin + localY * cos),
};
}
function getRotationHandlePosition({ bounds }: { bounds: ElementBounds }): {
x: number;
y: number;
} {
const angleRad = (bounds.rotation * Math.PI) / 180;
const cos = Math.cos(angleRad);
const sin = Math.sin(angleRad);
const localY = -bounds.height / 2 - ROTATION_HANDLE_OFFSET;
return {
x: bounds.cx - localY * sin,
y: bounds.cy + localY * cos,
};
}
function getOverlayContext({
canvasRef,
containerRef,
}: {
canvasRef: React.RefObject<HTMLCanvasElement | null>;
containerRef: React.RefObject<HTMLDivElement | null>;
}): {
canvasRect: DOMRect;
containerRect: DOMRect;
} | null {
const canvasRect = canvasRef.current?.getBoundingClientRect();
const containerRect = containerRef.current?.getBoundingClientRect();
if (!canvasRect || !containerRect) return null;
return { canvasRect, containerRect };
}
export function TransformHandles({
canvasRef,
containerRef,
}: {
canvasRef: React.RefObject<HTMLCanvasElement | null>;
containerRef: React.RefObject<HTMLDivElement | null>;
}) {
const {
selectedWithBounds,
hasVisualSelection,
snapLines,
handleCornerPointerDown,
handleRotationPointerDown,
handlePointerMove,
handlePointerUp,
} = useTransformHandles({ canvasRef });
const editor = useEditor();
const canvasSize = editor.project.getActive().settings.canvasSize;
if (!hasVisualSelection || !selectedWithBounds) return null;
const overlayContext = getOverlayContext({ canvasRef, containerRef });
if (!overlayContext) return null;
const { bounds, element } = selectedWithBounds;
if (!isVisualElement(element)) return null;
const { canvasRect, containerRect } = overlayContext;
const displayScale = getDisplayScale({ canvasRect, canvasSize });
const toOverlay = ({
canvasX,
canvasY,
}: {
canvasX: number;
canvasY: number;
}) =>
canvasToOverlay({
canvasX,
canvasY,
canvasRect,
containerRect,
canvasSize,
});
const center = toOverlay({ canvasX: bounds.cx, canvasY: bounds.cy });
const outlineWidth = bounds.width * displayScale.x;
const outlineHeight = bounds.height * displayScale.y;
const rotationHandle = getRotationHandlePosition({ bounds });
const rotationHandleScreen = toOverlay({
canvasX: rotationHandle.x,
canvasY: rotationHandle.y,
});
return (
<div className="pointer-events-none absolute inset-0" aria-hidden>
<SnapGuides
lines={snapLines}
canvasRef={canvasRef}
containerRef={containerRef}
/>
<BoundingBoxOutline
center={center}
outlineWidth={outlineWidth}
outlineHeight={outlineHeight}
rotation={bounds.rotation}
/>
{CORNERS.map((corner) => {
const cornerPosition = getCornerPosition({ bounds, corner });
const screen = toOverlay({
canvasX: cornerPosition.x,
canvasY: cornerPosition.y,
});
return (
<CornerHandle
key={corner}
corner={corner}
screen={screen}
onPointerDown={(event) =>
handleCornerPointerDown({ event, corner })
}
onPointerMove={(event) => handlePointerMove({ event })}
onPointerUp={(event) => handlePointerUp({ event })}
/>
);
})}
<RotationHandle
screen={rotationHandleScreen}
onPointerDown={(event) => handleRotationPointerDown({ event })}
onPointerMove={(event) => handlePointerMove({ event })}
onPointerUp={(event) => handlePointerUp({ event })}
/>
</div>
);
}
function BoundingBoxOutline({
center,
outlineWidth,
outlineHeight,
rotation,
}: {
center: { x: number; y: number };
outlineWidth: number;
outlineHeight: number;
rotation: number;
}) {
return (
<div
className="pointer-events-none absolute"
style={{
left: center.x - outlineWidth / 2,
top: center.y - outlineHeight / 2,
width: outlineWidth,
height: outlineHeight,
transform: `rotate(${rotation}deg)`,
transformOrigin: "center center",
border: "1px solid white",
boxSizing: "border-box",
opacity: 0.75,
}}
/>
);
}
function CornerHandle({
corner,
screen,
onPointerDown,
onPointerMove,
onPointerUp,
}: {
corner: Corner;
screen: { x: number; y: number };
onPointerDown: (event: React.PointerEvent) => void;
onPointerMove: (event: React.PointerEvent) => void;
onPointerUp: (event: React.PointerEvent) => void;
}) {
const isNesw = corner === "top-right" || corner === "bottom-left";
return (
<button
type="button"
className={cn(
"absolute flex items-center justify-center outline-none",
isNesw ? "cursor-nesw-resize" : "cursor-nwse-resize",
)}
style={{
left: screen.x - CORNER_HIT_AREA_SIZE / 2,
top: screen.y - CORNER_HIT_AREA_SIZE / 2,
width: CORNER_HIT_AREA_SIZE,
height: CORNER_HIT_AREA_SIZE,
pointerEvents: "auto",
}}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerLeave={onPointerUp}
onKeyDown={(event) => event.key === "Enter" && event.preventDefault()}
onKeyUp={(event) => event.key === "Enter" && event.preventDefault()}
>
<div
className="rounded-sm bg-white"
style={{ width: HANDLE_SIZE, height: HANDLE_SIZE }}
/>
</button>
);
}
function RotationHandle({
screen,
onPointerDown,
onPointerMove,
onPointerUp,
}: {
screen: { x: number; y: number };
onPointerDown: (event: React.PointerEvent) => void;
onPointerMove: (event: React.PointerEvent) => void;
onPointerUp: (event: React.PointerEvent) => void;
}) {
return (
<button
type="button"
className="absolute flex items-center justify-center rounded-full bg-white text-black shadow-sm outline-none"
style={{
left: screen.x - ROTATION_HANDLE_RADIUS,
top: screen.y - ROTATION_HANDLE_RADIUS,
width: ROTATION_HANDLE_RADIUS * 2,
height: ROTATION_HANDLE_RADIUS * 2,
pointerEvents: "auto",
}}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerLeave={onPointerUp}
onKeyDown={(event) => event.key === "Enter" && event.preventDefault()}
onKeyUp={(event) => event.key === "Enter" && event.preventDefault()}
>
<HugeiconsIcon icon={Rotate01Icon} className="size-3" strokeWidth={2.5} />
</button>
);
}

View File

@ -17,4 +17,4 @@ export function EmptyView() {
</div> </div>
</div> </div>
); );
} }

View File

@ -0,0 +1,72 @@
import { useReducer, useRef } from "react";
import { evaluateMathExpression } from "@/utils/math";
function looksLikeExpression({ input }: { input: string }): boolean {
const trimmed = input.trim();
if (!trimmed) return false;
if (/[+*/]/.test(input)) return true;
const minusIndex = trimmed.indexOf("-");
return minusIndex > 0;
}
export function usePropertyDraft<T>({
displayValue: sourceDisplay,
parse,
onPreview,
onCommit,
supportsExpressions = true,
}: {
displayValue: string;
parse: (input: string) => T | null;
onPreview: (value: T) => void;
onCommit: () => void;
supportsExpressions?: boolean;
}) {
const [, forceRender] = useReducer(
(renderVersion: number) => renderVersion + 1,
0,
);
const isEditing = useRef(false);
const draft = useRef("");
return {
displayValue: isEditing.current ? draft.current : sourceDisplay,
scrubTo: (value: number) => {
const parsed = parse(String(value));
if (parsed !== null) onPreview(parsed);
},
commitScrub: onCommit,
onFocus: () => {
isEditing.current = true;
draft.current = sourceDisplay;
forceRender();
},
onChange: (
event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) => {
draft.current = event.target.value;
forceRender();
const parsed = parse(event.target.value);
if (parsed !== null) {
onPreview(parsed);
}
},
onBlur: () => {
if (
supportsExpressions &&
looksLikeExpression({ input: draft.current })
) {
const evaluated = evaluateMathExpression({ input: draft.current });
if (evaluated !== null) {
const parsed = parse(String(evaluated));
if (parsed !== null) onPreview(parsed);
}
}
onCommit();
isEditing.current = false;
draft.current = "";
forceRender();
},
};
}

View File

@ -1,132 +0,0 @@
import { useState } from "react";
import { cn } from "@/utils/ui";
import { HugeiconsIcon } from "@hugeicons/react";
import { MinusSignIcon, PlusSignIcon } from "@hugeicons/core-free-icons";
interface PropertyItemProps {
direction?: "row" | "column";
children: React.ReactNode;
className?: string;
}
export function PropertyItem({
direction = "row",
children,
className,
}: PropertyItemProps) {
return (
<div
className={cn(
"flex gap-2",
direction === "row"
? "items-center justify-between gap-6"
: "flex-col gap-1.5",
className,
)}
>
{children}
</div>
);
}
export function PropertyItemLabel({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<span className={cn("text-muted-foreground text-xs", className)}>
{children}
</span>
);
}
export function PropertyItemValue({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return <div className={cn("flex-1 text-sm", className)}>{children}</div>;
}
interface PropertyGroupProps {
title: string;
children: React.ReactNode;
defaultExpanded?: boolean;
collapsible?: boolean;
className?: string;
hasBorderTop?: boolean;
hasBorderBottom?: boolean;
}
export function PropertyGroup({
title,
children,
defaultExpanded = true,
collapsible = true,
className,
hasBorderTop = true,
hasBorderBottom = true,
}: PropertyGroupProps) {
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
return (
<div
className={cn(
"flex flex-col",
hasBorderTop && "border-t",
hasBorderBottom && "last:border-b",
className,
)}
>
{collapsible ? (
<button
type="button"
className="flex items-center justify-between p-3.5 cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
<PropertyGroupTitle isExpanded={isExpanded}>
{title}
</PropertyGroupTitle>
<HugeiconsIcon
icon={isExpanded ? MinusSignIcon : PlusSignIcon}
className={cn(
"size-3",
isExpanded ? "text-foreground" : "text-muted-foreground",
)}
/>
</button>
) : (
<div className="flex items-center justify-between p-4">
<PropertyGroupTitle isExpanded>{title}</PropertyGroupTitle>
</div>
)}
{(collapsible ? isExpanded : true) && (
<div className="p-3 pt-0">{children}</div>
)}
</div>
);
}
function PropertyGroupTitle({
children,
isExpanded = false,
}: {
children: React.ReactNode;
isExpanded?: boolean;
}) {
return (
<span
className={cn(
"text-xs font-medium",
isExpanded ? "text-foreground" : "text-muted-foreground",
)}
>
{children}
</span>
);
}

View File

@ -0,0 +1,161 @@
import { createContext, useContext, useState } from "react";
import { cn } from "@/utils/ui";
import { HugeiconsIcon } from "@hugeicons/react";
import { ArrowDownIcon } from "@hugeicons/core-free-icons";
const sectionExpandedCache = new Map<string, boolean>();
interface SectionContext {
isOpen: boolean;
toggle: () => void;
collapsible: boolean;
}
const SectionCtx = createContext<SectionContext | null>(null);
function useSectionContext() {
return useContext(SectionCtx);
}
interface SectionProps {
children: React.ReactNode;
collapsible?: boolean;
defaultOpen?: boolean;
sectionKey?: string;
className?: string;
hasBorderTop?: boolean;
hasBorderBottom?: boolean;
}
export function Section({
children,
collapsible = false,
defaultOpen = true,
sectionKey,
className,
hasBorderTop = true,
hasBorderBottom = true,
}: SectionProps) {
const cached = sectionKey ? sectionExpandedCache.get(sectionKey) : undefined;
const [isOpen, setIsOpen] = useState(cached ?? defaultOpen);
const toggle = () => {
const next = !isOpen;
setIsOpen(next);
if (sectionKey) sectionExpandedCache.set(sectionKey, next);
};
return (
<SectionCtx.Provider value={{ isOpen, toggle, collapsible }}>
<div
className={cn(
"flex flex-col",
hasBorderTop && "border-t",
hasBorderBottom && "last:border-b",
className,
)}
>
{children}
</div>
</SectionCtx.Provider>
);
}
interface SectionHeaderProps {
title: string;
children?: React.ReactNode;
onClick?: () => void;
className?: string;
}
export function SectionHeader({
title,
children,
onClick,
className,
}: SectionHeaderProps) {
const ctx = useSectionContext();
const isCollapsible = ctx?.collapsible ?? false;
const isOpen = ctx?.isOpen ?? true;
const isInteractive = isCollapsible || !!onClick;
const handleClick = isCollapsible ? ctx?.toggle : onClick;
const content = (
<>
<span
className={cn(
"text-sm font-medium",
isOpen ? "text-foreground" : "text-muted-foreground",
)}
>
{title}
</span>
<div className="flex items-center gap-1">
{children}
{isCollapsible && (
<HugeiconsIcon
icon={ArrowDownIcon}
className={cn(
"size-3 shrink-0 transition-transform duration-200 ease-out",
isOpen
? "rotate-0 text-foreground"
: "-rotate-90 text-muted-foreground",
)}
/>
)}
</div>
</>
);
const baseClassName = cn(
"flex w-full items-center justify-between h-11 px-3.5",
className,
);
if (isInteractive) {
return (
<button
type="button"
className={cn(baseClassName, "cursor-pointer text-left")}
onClick={(event) => {
handleClick?.();
event.currentTarget.blur();
}}
>
{content}
</button>
);
}
return <div className={baseClassName}>{content}</div>;
}
export function SectionContent({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
const ctx = useSectionContext();
const isCollapsible = ctx?.collapsible ?? false;
const isOpen = ctx?.isOpen ?? true;
if (isCollapsible) {
return (
<div
className={cn(
"grid transition-[grid-template-rows] duration-100 ease-out",
isOpen ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
)}
>
<div className="overflow-hidden">
<div className={cn("p-4 pt-0", className)}>{children}</div>
</div>
</div>
);
}
return <div className={cn("p-4 pt-0", className)}>{children}</div>;
}

View File

@ -0,0 +1,196 @@
import { useEditor } from "@/hooks/use-editor";
import { usePropertyDraft } from "../hooks/use-property-draft";
import { clamp } from "@/utils/math";
import { NumberField } from "@/components/ui/number-field";
import {
DEFAULT_BLEND_MODE,
DEFAULT_OPACITY,
} from "@/constants/timeline-constants";
import { OcCheckerboardIcon } from "@opencut/ui/icons";
import { Fragment, useRef } from "react";
import { Section, SectionContent, SectionHeader } from "../section";
import {
Select,
SelectContent,
SelectItem,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { BlendMode } from "@/types/rendering";
import type { ElementType } from "@/types/timeline";
import { HugeiconsIcon } from "@hugeicons/react";
import { RainDropIcon } from "@hugeicons/core-free-icons";
type BlendingElement = {
id: string;
opacity: number;
type: ElementType;
blendMode?: BlendMode;
};
const BLEND_MODE_GROUPS = [
[{ value: "normal", label: "Normal" }],
[
{ value: "darken", label: "Darken" },
{ value: "multiply", label: "Multiply" },
{ value: "color-burn", label: "Color Burn" },
],
[
{ value: "lighten", label: "Lighten" },
{ value: "screen", label: "Screen" },
{ value: "plus-lighter", label: "Plus Lighter" },
{ value: "color-dodge", label: "Color Dodge" },
],
[
{ value: "overlay", label: "Overlay" },
{ value: "soft-light", label: "Soft Light" },
{ value: "hard-light", label: "Hard Light" },
],
[
{ value: "difference", label: "Difference" },
{ value: "exclusion", label: "Exclusion" },
],
[
{ value: "hue", label: "Hue" },
{ value: "saturation", label: "Saturation" },
{ value: "color", label: "Color" },
{ value: "luminosity", label: "Luminosity" },
],
];
export function BlendingSection({
element,
trackId,
}: {
element: BlendingElement;
trackId: string;
}) {
const editor = useEditor();
const blendMode = element.blendMode ?? DEFAULT_BLEND_MODE;
const didSelectRef = useRef(false);
const committedBlendModeRef = useRef(blendMode);
if (!editor.timeline.isPreviewActive()) {
committedBlendModeRef.current = blendMode;
}
const previewBlendMode = (value: BlendMode) =>
editor.timeline.previewElements({
updates: [
{ trackId, elementId: element.id, updates: { blendMode: value } },
],
});
const commitBlendMode = (value: string) => {
if (editor.timeline.isPreviewActive()) {
editor.timeline.commitPreview();
} else {
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
updates: { blendMode: value as BlendMode },
},
],
});
}
didSelectRef.current = true;
};
const handleBlendModeOpenChange = (isOpen: boolean) => {
if (!isOpen) {
if (!didSelectRef.current) editor.timeline.discardPreview();
didSelectRef.current = false;
}
};
const opacity = usePropertyDraft({
displayValue: Math.round(element.opacity * 100).toString(),
parse: (input) => {
const parsed = parseFloat(input);
if (Number.isNaN(parsed)) return null;
return clamp({ value: parsed, min: 0, max: 100 }) / 100;
},
onPreview: (value) =>
editor.timeline.previewElements({
updates: [
{ trackId, elementId: element.id, updates: { opacity: value } },
],
}),
onCommit: () => editor.timeline.commitPreview(),
});
return (
<Section collapsible sectionKey={`${element.type}:blending`}>
<SectionHeader title="Blending" />
<SectionContent>
<div className="flex items-start gap-2">
<div className="w-1/2 space-y-1.5">
<NumberField
className="w-full"
icon={
<OcCheckerboardIcon className="size-3.5 text-muted-foreground" />
}
value={opacity.displayValue}
min={0}
max={100}
onFocus={opacity.onFocus}
onChange={opacity.onChange}
onBlur={opacity.onBlur}
onScrub={opacity.scrubTo}
onScrubEnd={opacity.commitScrub}
onReset={() =>
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
updates: { opacity: DEFAULT_OPACITY },
},
],
})
}
isDefault={element.opacity === DEFAULT_OPACITY}
dragSensitivity="slow"
/>
</div>
<div className="w-1/2 space-y-1.5">
<Select
value={committedBlendModeRef.current}
onOpenChange={handleBlendModeOpenChange}
onValueChange={commitBlendMode}
>
<SelectTrigger
icon={<HugeiconsIcon icon={RainDropIcon} />}
className="w-full"
>
<SelectValue placeholder="Select blend mode" />
</SelectTrigger>
<SelectContent className="w-36">
{BLEND_MODE_GROUPS.map((group, groupIndex) => (
<Fragment key={group[0]?.value ?? `group-${groupIndex}`}>
{group.map((option) => (
<SelectItem
key={option.value}
value={option.value}
onPointerEnter={() =>
previewBlendMode(option.value as BlendMode)
}
>
{option.label}
</SelectItem>
))}
{groupIndex < BLEND_MODE_GROUPS.length - 1 ? (
<SelectSeparator />
) : null}
</Fragment>
))}
</SelectContent>
</Select>
</div>
</div>
</SectionContent>
</Section>
);
}

View File

@ -0,0 +1,2 @@
export * from "./transform";
export * from "./blending";

View File

@ -0,0 +1,248 @@
import { NumberField } from "@/components/ui/number-field";
import { useEditor } from "@/hooks/use-editor";
import { usePropertyDraft } from "../hooks/use-property-draft";
import { clamp } from "@/utils/math";
import type { ElementType, Transform } from "@/types/timeline";
import { Section, SectionContent, SectionHeader } from "../section";
import { Button } from "@/components/ui/button";
import { HugeiconsIcon } from "@hugeicons/react";
import {
ArrowExpandIcon,
Link05Icon,
RotateClockwiseIcon,
} from "@hugeicons/core-free-icons";
import { useState } from "react";
import { DEFAULT_TRANSFORM } from "@/constants/timeline-constants";
type TransformElement = {
id: string;
transform: Transform;
type: ElementType;
};
function parseFloat_({ input }: { input: string }): number | null {
const parsed = parseFloat(input);
return Number.isNaN(parsed) ? null : parsed;
}
export function TransformSection({
element,
trackId,
}: {
element: TransformElement;
trackId: string;
}) {
const editor = useEditor();
const [isScaleLocked, setIsScaleLocked] = useState(false);
const previewTransform = (transform: Partial<Transform>) => {
editor.timeline.previewElements({
updates: [
{
trackId,
elementId: element.id,
updates: { transform: { ...element.transform, ...transform } },
},
],
});
};
const commit = () => editor.timeline.commitPreview();
const positionX = usePropertyDraft({
displayValue: Math.round(element.transform.position.x).toString(),
parse: (input) => parseFloat_({ input }),
onPreview: (value) =>
previewTransform({
position: { ...element.transform.position, x: value },
}),
onCommit: commit,
});
const positionY = usePropertyDraft({
displayValue: Math.round(element.transform.position.y).toString(),
parse: (input) => parseFloat_({ input }),
onPreview: (value) =>
previewTransform({
position: { ...element.transform.position, y: value },
}),
onCommit: commit,
});
const scale = usePropertyDraft({
displayValue: Math.round(element.transform.scale * 100).toString(),
parse: (input) => {
const parsed = parseFloat_({ input });
if (parsed === null) return null;
return Math.max(parsed, 1) / 100;
},
onPreview: (value) => previewTransform({ scale: value }),
onCommit: commit,
});
const scaleFieldProps = {
className: "flex-1",
value: scale.displayValue,
onFocus: scale.onFocus,
onChange: scale.onChange,
onBlur: scale.onBlur,
dragSensitivity: "slow" as const,
onScrub: scale.scrubTo,
onScrubEnd: scale.commitScrub,
onReset: () =>
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
updates: {
transform: {
...element.transform,
scale: DEFAULT_TRANSFORM.scale,
},
},
},
],
}),
isDefault: element.transform.scale === DEFAULT_TRANSFORM.scale,
};
const rotation = usePropertyDraft({
displayValue: Math.round(element.transform.rotate).toString(),
parse: (input) => {
const parsed = parseFloat_({ input });
if (parsed === null) return null;
return clamp({ value: parsed, min: -360, max: 360 });
},
onPreview: (value) => previewTransform({ rotate: value }),
onCommit: commit,
});
return (
<Section collapsible sectionKey={`${element.type}:transform`}>
<SectionHeader title="Transform" />
<SectionContent>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
{isScaleLocked ? (
<>
<NumberField icon="W" {...scaleFieldProps} />
<NumberField icon="H" {...scaleFieldProps} />
</>
) : (
<NumberField
icon={<HugeiconsIcon icon={ArrowExpandIcon} />}
{...scaleFieldProps}
className="flex-1"
/>
)}
<Button
variant={isScaleLocked ? "secondary" : "ghost"}
size="icon"
aria-pressed={isScaleLocked}
onClick={() => setIsScaleLocked((isLocked) => !isLocked)}
>
<HugeiconsIcon icon={Link05Icon} />
</Button>
</div>
<div className="flex items-center gap-2">
<NumberField
icon="X"
className="flex-1"
value={positionX.displayValue}
onFocus={positionX.onFocus}
onChange={positionX.onChange}
onBlur={positionX.onBlur}
onScrub={positionX.scrubTo}
onScrubEnd={positionX.commitScrub}
onReset={() =>
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
updates: {
transform: {
...element.transform,
position: {
...element.transform.position,
x: DEFAULT_TRANSFORM.position.x,
},
},
},
},
],
})
}
isDefault={
element.transform.position.x === DEFAULT_TRANSFORM.position.x
}
/>
<NumberField
icon="Y"
className="flex-1"
value={positionY.displayValue}
onFocus={positionY.onFocus}
onChange={positionY.onChange}
onBlur={positionY.onBlur}
onScrub={positionY.scrubTo}
onScrubEnd={positionY.commitScrub}
onReset={() =>
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
updates: {
transform: {
...element.transform,
position: {
...element.transform.position,
y: DEFAULT_TRANSFORM.position.y,
},
},
},
},
],
})
}
isDefault={
element.transform.position.y === DEFAULT_TRANSFORM.position.y
}
/>
</div>
<div className="flex items-center gap-2">
<NumberField
icon={<HugeiconsIcon icon={RotateClockwiseIcon} />}
className="flex-1"
value={rotation.displayValue}
onFocus={rotation.onFocus}
onChange={rotation.onChange}
onBlur={rotation.onBlur}
dragSensitivity="slow"
onScrub={rotation.scrubTo}
onScrubEnd={rotation.commitScrub}
onReset={() =>
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
updates: {
transform: {
...element.transform,
rotate: DEFAULT_TRANSFORM.rotate,
},
},
},
],
})
}
isDefault={element.transform.rotate === DEFAULT_TRANSFORM.rotate}
/>
</div>
</div>
</SectionContent>
</Section>
);
}

View File

@ -1,24 +1,31 @@
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { FontPicker } from "@/components/ui/font-picker"; import { FontPicker } from "@/components/ui/font-picker";
import type { FontFamily } from "@/constants/font-constants";
import type { TextElement } from "@/types/timeline"; import type { TextElement } from "@/types/timeline";
import { Slider } from "@/components/ui/slider"; import { Slider } from "@/components/ui/slider";
import { Input } from "@/components/ui/input"; import { NumberField } from "@/components/ui/number-field";
import { Button } from "@/components/ui/button"; import { useRef } from "react";
import { useReducer, useRef } from "react"; import { Section, SectionContent, SectionHeader } from "./section";
import { PanelBaseView } from "@/components/editor/panels/panel-base-view";
import {
PropertyGroup,
PropertyItem,
PropertyItemLabel,
PropertyItemValue,
} from "./property-item";
import { ColorPicker } from "@/components/ui/color-picker"; import { ColorPicker } from "@/components/ui/color-picker";
import { uppercase } from "@/utils/string"; import { uppercase } from "@/utils/string";
import { clamp } from "@/utils/math"; import { clamp } from "@/utils/math";
import { useEditor } from "@/hooks/use-editor"; import { useEditor } from "@/hooks/use-editor";
import { DEFAULT_COLOR } from "@/constants/project-constants"; import { DEFAULT_COLOR } from "@/constants/project-constants";
import { MIN_FONT_SIZE, MAX_FONT_SIZE } from "@/constants/text-constants"; import {
DEFAULT_TEXT_ELEMENT,
MAX_FONT_SIZE,
MIN_FONT_SIZE,
} from "@/constants/text-constants";
import { usePropertyDraft } from "./hooks/use-property-draft";
import { TransformSection, BlendingSection } from "./sections";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@/components/ui/select";
import { OcFontWeightIcon } from "@opencut/ui/icons";
import { Label } from "@/components/ui/label";
export function TextProperties({ export function TextProperties({
element, element,
@ -26,625 +33,230 @@ export function TextProperties({
}: { }: {
element: TextElement; element: TextElement;
trackId: string; trackId: string;
}) {
return (
<div className="flex h-full flex-col">
<ContentSection element={element} trackId={trackId} />
<TransformSection element={element} trackId={trackId} />
<BlendingSection element={element} trackId={trackId} />
<FontSection element={element} trackId={trackId} />
<ColorSection element={element} trackId={trackId} />
</div>
);
}
function ContentSection({
element,
trackId,
}: {
element: TextElement;
trackId: string;
}) { }) {
const editor = useEditor(); const editor = useEditor();
const containerRef = useRef<HTMLDivElement>(null);
const [, forceRender] = useReducer((x: number) => x + 1, 0);
const isEditingFontSize = useRef(false);
const isEditingOpacity = useRef(false);
const isEditingContent = useRef(false);
const fontSizeDraft = useRef("");
const opacityDraft = useRef("");
const contentDraft = useRef("");
const fontSizeDisplay = isEditingFontSize.current const content = usePropertyDraft({
? fontSizeDraft.current displayValue: element.content,
: element.fontSize.toString(); parse: (input) => input,
const opacityDisplay = isEditingOpacity.current onPreview: (value) =>
? opacityDraft.current editor.timeline.previewElements({
: Math.round(element.opacity * 100).toString();
const contentDisplay = isEditingContent.current
? contentDraft.current
: element.content;
const lastSelectedColor = useRef(DEFAULT_COLOR);
const initialFontSizeRef = useRef<number | null>(null);
const initialOpacityRef = useRef<number | null>(null);
const initialContentRef = useRef<string | null>(null);
const initialColorRef = useRef<string | null>(null);
const initialBgColorRef = useRef<string | null>(null);
const handleFontSizeChange = ({ value }: { value: string }) => {
fontSizeDraft.current = value;
forceRender();
if (value.trim() !== "") {
if (initialFontSizeRef.current === null) {
initialFontSizeRef.current = element.fontSize;
}
const parsed = parseInt(value, 10);
const fontSize = Number.isNaN(parsed)
? element.fontSize
: clamp({ value: parsed, min: MIN_FONT_SIZE, max: MAX_FONT_SIZE });
editor.timeline.updateElements({
updates: [{ trackId, elementId: element.id, updates: { fontSize } }],
pushHistory: false,
});
}
};
const handleFontSizeBlur = () => {
if (initialFontSizeRef.current !== null) {
const parsed = parseInt(fontSizeDraft.current, 10);
const fontSize = Number.isNaN(parsed)
? element.fontSize
: clamp({ value: parsed, min: MIN_FONT_SIZE, max: MAX_FONT_SIZE });
editor.timeline.updateElements({
updates: [ updates: [
{ { trackId, elementId: element.id, updates: { content: value } },
trackId,
elementId: element.id,
updates: { fontSize: initialFontSizeRef.current },
},
], ],
pushHistory: false, }),
}); onCommit: () => editor.timeline.commitPreview(),
editor.timeline.updateElements({ });
updates: [{ trackId, elementId: element.id, updates: { fontSize } }],
pushHistory: true,
});
initialFontSizeRef.current = null;
}
isEditingFontSize.current = false;
fontSizeDraft.current = "";
forceRender();
};
const handleOpacityChange = ({ value }: { value: string }) => {
opacityDraft.current = value;
forceRender();
if (value.trim() !== "") {
if (initialOpacityRef.current === null) {
initialOpacityRef.current = element.opacity;
}
const parsed = parseInt(value, 10);
const opacityPercent = Number.isNaN(parsed)
? Math.round(element.opacity * 100)
: clamp({ value: parsed, min: 0, max: 100 });
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
updates: { opacity: opacityPercent / 100 },
},
],
pushHistory: false,
});
}
};
const handleOpacityBlur = () => {
if (initialOpacityRef.current !== null) {
const parsed = parseInt(opacityDraft.current, 10);
const opacityPercent = Number.isNaN(parsed)
? Math.round(element.opacity * 100)
: clamp({ value: parsed, min: 0, max: 100 });
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
updates: { opacity: initialOpacityRef.current },
},
],
pushHistory: false,
});
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
updates: { opacity: opacityPercent / 100 },
},
],
pushHistory: true,
});
initialOpacityRef.current = null;
}
isEditingOpacity.current = false;
opacityDraft.current = "";
forceRender();
};
const handleColorChange = ({ color }: { color: string }) => {
if (color !== "transparent") {
lastSelectedColor.current = color;
}
if (initialBgColorRef.current === null) {
initialBgColorRef.current = element.backgroundColor;
}
if (initialBgColorRef.current !== null) {
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
updates: { backgroundColor: color },
},
],
pushHistory: false,
});
} else {
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
updates: { backgroundColor: color },
},
],
});
}
};
const handleColorChangeEnd = ({ color }: { color: string }) => {
if (initialBgColorRef.current !== null) {
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
updates: { backgroundColor: initialBgColorRef.current },
},
],
pushHistory: false,
});
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
updates: { backgroundColor: `#${color}` },
},
],
pushHistory: true,
});
initialBgColorRef.current = null;
}
};
return ( return (
<div className="flex h-full flex-col" ref={containerRef}> <Section collapsible sectionKey="text:content" hasBorderTop={false}>
<PanelBaseView className="p-0"> <SectionHeader title="Content" />
<PropertyGroup title="Content" hasBorderTop={false} collapsible={false}> <SectionContent>
<Textarea <Textarea
placeholder="Name" placeholder="Name"
value={contentDisplay} value={content.displayValue}
className="bg-accent min-h-20" className="min-h-20"
onFocus={() => { onFocus={content.onFocus}
isEditingContent.current = true; onChange={content.onChange}
contentDraft.current = element.content; onBlur={content.onBlur}
initialContentRef.current = element.content; />
forceRender(); </SectionContent>
}} </Section>
onChange={(event) => { );
contentDraft.current = event.target.value; }
forceRender();
if (initialContentRef.current === null) { function FontSection({
initialContentRef.current = element.content; element,
} trackId,
}: {
element: TextElement;
trackId: string;
}) {
const editor = useEditor();
const fontSize = usePropertyDraft({
displayValue: element.fontSize.toString(),
parse: (input) => {
const parsed = parseFloat(input);
if (Number.isNaN(parsed)) return null;
return clamp({ value: parsed, min: MIN_FONT_SIZE, max: MAX_FONT_SIZE });
},
onPreview: (value) =>
editor.timeline.previewElements({
updates: [
{ trackId, elementId: element.id, updates: { fontSize: value } },
],
}),
onCommit: () => editor.timeline.commitPreview(),
});
return (
<Section collapsible sectionKey="text:font">
<SectionHeader title="Font" />
<SectionContent>
<div className="flex flex-col gap-2">
<FontPicker
defaultValue={element.fontFamily}
onValueChange={(value) =>
editor.timeline.updateElements({ editor.timeline.updateElements({
updates: [ updates: [
{ {
trackId, trackId,
elementId: element.id, elementId: element.id,
updates: { content: event.target.value }, updates: { fontFamily: value },
}, },
], ],
pushHistory: false, })
}); }
}}
onBlur={() => {
if (initialContentRef.current !== null) {
const finalContent = contentDraft.current;
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
updates: { content: initialContentRef.current },
},
],
pushHistory: false,
});
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
updates: { content: finalContent },
},
],
pushHistory: true,
});
initialContentRef.current = null;
}
isEditingContent.current = false;
contentDraft.current = "";
forceRender();
}}
/> />
</PropertyGroup> <Select value={element.fontWeight}>
<PropertyGroup title="Typography" collapsible={false}> <SelectTrigger className="w-full" icon={<OcFontWeightIcon />}>
<div className="space-y-6"> <SelectValue placeholder="Select weight" />
<PropertyItem direction="column"> </SelectTrigger>
<PropertyItemLabel>Font</PropertyItemLabel> <SelectContent>
<PropertyItemValue> <SelectItem value="100">Thin</SelectItem>
<FontPicker <SelectItem value="200">Extra Light</SelectItem>
defaultValue={element.fontFamily} <SelectItem value="300">Light</SelectItem>
onValueChange={(value: FontFamily) => <SelectItem value="400">Normal</SelectItem>
editor.timeline.updateElements({ <SelectItem value="500">Medium</SelectItem>
updates: [ <SelectItem value="600">Semi Bold</SelectItem>
{ <SelectItem value="700">Bold</SelectItem>
trackId, <SelectItem value="800">Extra Bold</SelectItem>
elementId: element.id, <SelectItem value="900">Black</SelectItem>
updates: { fontFamily: value }, </SelectContent>
}, </Select>
],
}) <Label>Font size</Label>
} <div className="flex items-center gap-2">
/> <Slider
</PropertyItemValue> value={[element.fontSize]}
</PropertyItem> min={MIN_FONT_SIZE}
<PropertyItem direction="column"> max={MAX_FONT_SIZE}
<PropertyItemLabel>Style</PropertyItemLabel> step={1}
<PropertyItemValue> onValueChange={([value]) =>
<div className="flex items-center gap-2"> editor.timeline.previewElements({
<Button updates: [
variant={ {
element.fontWeight === "bold" ? "default" : "outline" trackId,
} elementId: element.id,
size="sm" updates: { fontSize: value },
onClick={() => },
editor.timeline.updateElements({ ],
updates: [ })
{ }
trackId, onValueCommit={() => editor.timeline.commitPreview()}
elementId: element.id, className="w-full"
updates: { />
fontWeight: <NumberField
element.fontWeight === "bold" className="w-18 shrink-0"
? "normal" value={fontSize.displayValue}
: "bold", min={MIN_FONT_SIZE}
}, max={MAX_FONT_SIZE}
}, onFocus={fontSize.onFocus}
], onChange={fontSize.onChange}
}) onBlur={fontSize.onBlur}
} onReset={() =>
className="h-8 px-3 font-bold" editor.timeline.updateElements({
> updates: [
B {
</Button> trackId,
<Button elementId: element.id,
variant={ updates: {
element.fontStyle === "italic" ? "default" : "outline" fontSize: DEFAULT_TEXT_ELEMENT.fontSize,
} },
size="sm" },
onClick={() => ],
editor.timeline.updateElements({ })
updates: [ }
{ isDefault={element.fontSize === DEFAULT_TEXT_ELEMENT.fontSize}
trackId, />
elementId: element.id,
updates: {
fontStyle:
element.fontStyle === "italic"
? "normal"
: "italic",
},
},
],
})
}
className="h-8 px-3 italic"
>
I
</Button>
<Button
variant={
element.textDecoration === "underline"
? "default"
: "outline"
}
size="sm"
onClick={() =>
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
updates: {
textDecoration:
element.textDecoration === "underline"
? "none"
: "underline",
},
},
],
})
}
className="h-8 px-3 underline"
>
U
</Button>
<Button
variant={
element.textDecoration === "line-through"
? "default"
: "outline"
}
size="sm"
onClick={() =>
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
updates: {
textDecoration:
element.textDecoration === "line-through"
? "none"
: "line-through",
},
},
],
})
}
className="h-8 px-3 line-through"
>
S
</Button>
</div>
</PropertyItemValue>
</PropertyItem>
<PropertyItem direction="column">
<PropertyItemLabel>Font size</PropertyItemLabel>
<PropertyItemValue>
<div className="flex items-center gap-2">
<Slider
value={[element.fontSize]}
min={MIN_FONT_SIZE}
max={MAX_FONT_SIZE}
step={1}
onValueChange={([value]) => {
if (initialFontSizeRef.current === null) {
initialFontSizeRef.current = element.fontSize;
}
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
updates: { fontSize: value },
},
],
pushHistory: false,
});
}}
onValueCommit={([value]) => {
if (initialFontSizeRef.current !== null) {
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
updates: {
fontSize: initialFontSizeRef.current,
},
},
],
pushHistory: false,
});
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
updates: { fontSize: value },
},
],
pushHistory: true,
});
initialFontSizeRef.current = null;
}
}}
className="w-full"
/>
<Input
type="number"
value={fontSizeDisplay}
min={MIN_FONT_SIZE}
max={MAX_FONT_SIZE}
onFocus={() => {
isEditingFontSize.current = true;
fontSizeDraft.current = element.fontSize.toString();
forceRender();
}}
onChange={(e) =>
handleFontSizeChange({ value: e.target.value })
}
onBlur={handleFontSizeBlur}
className="bg-accent h-7 w-12 [appearance:textfield] rounded-sm px-2 text-center !text-xs [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
/>
</div>
</PropertyItemValue>
</PropertyItem>
</div> </div>
</PropertyGroup> </div>
<PropertyGroup title="Appearance" collapsible={false}> </SectionContent>
<div className="space-y-6"> </Section>
<PropertyItem direction="column"> );
<PropertyItemLabel>Color</PropertyItemLabel> }
<PropertyItemValue>
<ColorPicker function ColorSection({
value={uppercase({ element,
string: (element.color || "FFFFFF").replace("#", ""), trackId,
})} }: {
onChange={(color) => { element: TextElement;
if (initialColorRef.current === null) { trackId: string;
initialColorRef.current = element.color || "#FFFFFF"; }) {
} const editor = useEditor();
if (initialColorRef.current !== null) { const lastSelectedColor = useRef(DEFAULT_COLOR);
editor.timeline.updateElements({
updates: [ return (
{ <Section collapsible sectionKey="text:color" hasBorderBottom={false}>
trackId, <SectionHeader title="Color" />
elementId: element.id, <SectionContent>
updates: { color: `#${color}` }, <div className="flex flex-col gap-6">
}, <ColorPicker
], value={uppercase({
pushHistory: false, string: (element.color || "FFFFFF").replace("#", ""),
}); })}
} else { onChange={(color) =>
editor.timeline.updateElements({ editor.timeline.previewElements({
updates: [ updates: [
{ {
trackId, trackId,
elementId: element.id, elementId: element.id,
updates: { color: `#${color}` }, updates: { color: `#${color}` },
}, },
], ],
}); })
} }
}} onChangeEnd={() => editor.timeline.commitPreview()}
onChangeEnd={(color) => { />
if (initialColorRef.current !== null) { <ColorPicker
editor.timeline.updateElements({ value={
updates: [ element.backgroundColor === "transparent"
{ ? lastSelectedColor.current.replace("#", "")
trackId, : element.backgroundColor.replace("#", "")
elementId: element.id, }
updates: { color: initialColorRef.current }, onChange={(color) => {
}, const hexColor = `#${color}`;
], if (color !== "transparent") {
pushHistory: false, lastSelectedColor.current = hexColor;
}); }
editor.timeline.updateElements({ editor.timeline.previewElements({
updates: [ updates: [
{ {
trackId, trackId,
elementId: element.id, elementId: element.id,
updates: { color: `#${color}` }, updates: { backgroundColor: hexColor },
}, },
], ],
pushHistory: true, });
}); }}
initialColorRef.current = null; onChangeEnd={() => editor.timeline.commitPreview()}
} className={
}} element.backgroundColor === "transparent"
containerRef={containerRef} ? "pointer-events-none opacity-50"
/> : ""
</PropertyItemValue> }
</PropertyItem> />
<PropertyItem direction="column"> </div>
<PropertyItemLabel>Opacity</PropertyItemLabel> </SectionContent>
<PropertyItemValue> </Section>
<div className="flex items-center gap-2">
<Slider
value={[element.opacity * 100]}
min={0}
max={100}
step={1}
onValueChange={([value]) => {
if (initialOpacityRef.current === null) {
initialOpacityRef.current = element.opacity;
}
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
updates: { opacity: value / 100 },
},
],
pushHistory: false,
});
}}
onValueCommit={([value]) => {
if (initialOpacityRef.current !== null) {
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
updates: { opacity: initialOpacityRef.current },
},
],
pushHistory: false,
});
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
updates: { opacity: value / 100 },
},
],
pushHistory: true,
});
initialOpacityRef.current = null;
}
}}
className="w-full"
/>
<Input
type="number"
value={opacityDisplay}
min={0}
max={100}
onFocus={() => {
isEditingOpacity.current = true;
opacityDraft.current = Math.round(
element.opacity * 100,
).toString();
forceRender();
}}
onChange={(e) =>
handleOpacityChange({ value: e.target.value })
}
onBlur={handleOpacityBlur}
className="bg-accent h-7 w-12 [appearance:textfield] rounded-sm text-center !text-xs [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
/>
</div>
</PropertyItemValue>
</PropertyItem>
<PropertyItem direction="column">
<PropertyItemLabel>Background</PropertyItemLabel>
<PropertyItemValue>
<ColorPicker
value={
element.backgroundColor === "transparent"
? lastSelectedColor.current.replace("#", "")
: element.backgroundColor.replace("#", "")
}
onChange={(color) =>
handleColorChange({ color: `#${color}` })
}
onChangeEnd={(color) => handleColorChangeEnd({ color })}
containerRef={containerRef}
className={
element.backgroundColor === "transparent"
? "pointer-events-none opacity-50"
: ""
}
/>
</PropertyItemValue>
</PropertyItem>
</div>
</PropertyGroup>
</PanelBaseView>
</div>
); );
} }

View File

@ -1,21 +1,75 @@
"use client";
import { useEffect, useState } from "react";
import type { EditorCore } from "@/core";
import { useEditor } from "@/hooks/use-editor"; import { useEditor } from "@/hooks/use-editor";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import type { BookmarkDragState } from "@/hooks/timeline/use-bookmark-drag";
import { BOOKMARK_TIME_EPSILON } from "@/lib/timeline/bookmarks";
import {
DEFAULT_BOOKMARK_COLOR,
TIMELINE_CONSTANTS,
} from "@/constants/timeline-constants";
import { DEFAULT_FPS } from "@/constants/project-constants";
import { getSnappedSeekTime } from "@/lib/time"; import { getSnappedSeekTime } from "@/lib/time";
import { Bookmark02Icon } from "@hugeicons/core-free-icons"; import {
ArrowTurnBackwardIcon,
Delete02Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react"; import { HugeiconsIcon } from "@hugeicons/react";
import type { Bookmark } from "@/types/timeline";
import {
Popover,
PopoverAnchor,
PopoverContent,
} from "@/components/ui/popover";
import { Input } from "@/components/ui/input";
import { ColorPicker } from "@/components/ui/color-picker";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { uppercase } from "@/utils/string";
import { clamp } from "@/utils/math";
const PIXELS_PER_SECOND = TIMELINE_CONSTANTS.PIXELS_PER_SECOND;
const MIN_BOOKMARK_WIDTH_PX = 2;
const BOOKMARK_MARKER_WIDTH_PX = 12;
const BOOKMARK_MARKER_HEIGHT_PX = 15;
const BOOKMARK_HALF_WIDTH_PX = BOOKMARK_MARKER_WIDTH_PX / 2;
const BOOKMARK_MARKER_CLIP_PATH =
"polygon(50% 100%, 12% 72%, 12% 10%, 88% 10%, 88% 72%)";
function seekToBookmarkTime({
editor,
time,
}: {
editor: EditorCore;
time: number;
}) {
const activeProject = editor.project.getActive();
const duration = editor.timeline.getTotalDuration();
const fps = activeProject?.settings.fps ?? DEFAULT_FPS;
const snappedTime = getSnappedSeekTime({ rawTime: time, duration, fps });
editor.playback.seek({ time: snappedTime });
}
interface TimelineBookmarksRowProps { interface TimelineBookmarksRowProps {
zoomLevel: number; zoomLevel: number;
dynamicTimelineWidth: number; dynamicTimelineWidth: number;
handleWheel: (e: React.WheelEvent) => void; dragState: BookmarkDragState;
handleTimelineContentClick: (e: React.MouseEvent) => void; onBookmarkMouseDown: (params: {
handleRulerTrackingMouseDown: (e: React.MouseEvent) => void; event: React.MouseEvent;
handleRulerMouseDown: (e: React.MouseEvent) => void; bookmark: Bookmark;
}) => void;
handleWheel: (event: React.WheelEvent) => void;
handleTimelineContentClick: (event: React.MouseEvent) => void;
handleRulerTrackingMouseDown: (event: React.MouseEvent) => void;
handleRulerMouseDown: (event: React.MouseEvent) => void;
} }
export function TimelineBookmarksRow({ export function TimelineBookmarksRow({
zoomLevel, zoomLevel,
dynamicTimelineWidth, dynamicTimelineWidth,
dragState,
onBookmarkMouseDown,
handleWheel, handleWheel,
handleTimelineContentClick, handleTimelineContentClick,
handleRulerTrackingMouseDown, handleRulerTrackingMouseDown,
@ -34,17 +88,23 @@ export function TimelineBookmarksRow({
aria-label="Timeline ruler" aria-label="Timeline ruler"
type="button" type="button"
onWheel={handleWheel} onWheel={handleWheel}
onClick={handleTimelineContentClick} onClick={(event) => {
if (!event.currentTarget.contains(event.target as Node)) return;
handleTimelineContentClick(event);
}}
onMouseDown={(event) => { onMouseDown={(event) => {
if (!event.currentTarget.contains(event.target as Node)) return;
handleRulerMouseDown(event); handleRulerMouseDown(event);
handleRulerTrackingMouseDown(event); handleRulerTrackingMouseDown(event);
}} }}
> >
{activeScene.bookmarks.map((time: number) => ( {activeScene.bookmarks.map((bookmark) => (
<TimelineBookmark <TimelineBookmark
key={`bookmark-row-${time}`} key={`bookmark-${bookmark.time}`}
time={time} bookmark={bookmark}
zoomLevel={zoomLevel} zoomLevel={zoomLevel}
dragState={dragState}
onBookmarkMouseDown={onBookmarkMouseDown}
/> />
))} ))}
</button> </button>
@ -52,60 +112,305 @@ export function TimelineBookmarksRow({
); );
} }
export function TimelineBookmark({ function TimelineBookmark({
time, bookmark,
zoomLevel, zoomLevel,
dragState,
onBookmarkMouseDown,
}: { }: {
time: number; bookmark: Bookmark;
zoomLevel: number; zoomLevel: number;
dragState: BookmarkDragState;
onBookmarkMouseDown: (params: {
event: React.MouseEvent;
bookmark: Bookmark;
}) => void;
}) { }) {
const editor = useEditor(); const editor = useEditor();
const activeProject = editor.project.getActive();
const duration = editor.timeline.getTotalDuration(); const duration = editor.timeline.getTotalDuration();
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const handleBookmarkActivate = ({ const isDragging =
event, dragState.isDragging &&
}: { dragState.bookmarkTime !== null &&
event: Math.abs(dragState.bookmarkTime - bookmark.time) < BOOKMARK_TIME_EPSILON;
| React.MouseEvent<HTMLButtonElement>
| React.KeyboardEvent<HTMLButtonElement>; const displayTime = isDragging ? dragState.currentTime : bookmark.time;
}) => { const time = bookmark.time;
const bookmarkDuration = bookmark.duration ?? 0;
const durationWidth =
bookmarkDuration > 0 ? bookmarkDuration * PIXELS_PER_SECOND * zoomLevel : 0;
const hasDurationRange = durationWidth > MIN_BOOKMARK_WIDTH_PX;
const bookmarkWidth = BOOKMARK_MARKER_WIDTH_PX + Math.max(durationWidth, 0);
const left = displayTime * PIXELS_PER_SECOND * zoomLevel;
const bookmarkLeft = left - BOOKMARK_HALF_WIDTH_PX;
const rightHalfLeft = BOOKMARK_HALF_WIDTH_PX + Math.max(durationWidth, 0);
const iconColor = bookmark.color ?? DEFAULT_BOOKMARK_COLOR;
const handleSeek = () => seekToBookmarkTime({ editor, time });
const handleClick = (event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
if (event.detail === 2) {
setIsPopoverOpen(true);
} else {
handleSeek();
}
};
const handleKeyDown = (event: React.KeyboardEvent) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
handleSeek();
};
const handleMouseDown = (event: React.MouseEvent) => {
onBookmarkMouseDown({ event, bookmark });
event.preventDefault();
event.stopPropagation(); event.stopPropagation();
const framesPerSecond = activeProject?.settings.fps ?? 30;
const snappedTime = getSnappedSeekTime({
rawTime: time,
duration,
fps: framesPerSecond,
});
editor.playback.seek({ time: snappedTime });
}; };
return ( return (
<button <Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
className="absolute top-0 h-10 w-0.5 cursor-pointer border-0 bg-transparent p-0" <PopoverAnchor asChild>
style={{ <button
left: `${time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`, className={`absolute top-0 h-full min-w-0.5 border-0 bg-transparent p-0 ${isDragging ? "cursor-grabbing" : "cursor-grab"}`}
}} style={{
aria-label={`Seek to bookmark at ${time}s`} left: `${bookmarkLeft}px`,
type="button" width: `${bookmarkWidth}px`,
onMouseDown={(event) => { }}
event.preventDefault(); aria-label={`Bookmark at ${time.toFixed(1)}s`}
event.stopPropagation(); type="button"
}} onMouseDown={handleMouseDown}
onClick={(event) => handleBookmarkActivate({ event })} onClick={handleClick}
onKeyDown={(event) => { onKeyDown={handleKeyDown}
if (event.key !== "Enter" && event.key !== " ") return; >
event.preventDefault(); {hasDurationRange ? (
handleBookmarkActivate({ event }); <div
}} className="absolute opacity-30"
> style={{
<div className="text-primary absolute top-[-1px] left-[-5px]"> top: 1.5,
<HugeiconsIcon height: BOOKMARK_MARKER_HEIGHT_PX - 2.5,
icon={Bookmark02Icon} left: BOOKMARK_HALF_WIDTH_PX,
aria-hidden="true" width: durationWidth,
className="fill-primary size-3" backgroundColor: iconColor,
}}
/>
) : null}
<div
className="absolute top-0 overflow-hidden"
style={{
left: 0,
width: BOOKMARK_HALF_WIDTH_PX,
height: BOOKMARK_MARKER_HEIGHT_PX,
}}
>
<div
className="absolute inset-0"
style={{
width: BOOKMARK_MARKER_WIDTH_PX,
height: BOOKMARK_MARKER_HEIGHT_PX,
clipPath: BOOKMARK_MARKER_CLIP_PATH,
backgroundColor: "hsl(var(--background))",
}}
/>
<div
className="absolute"
style={{
top: 1,
left: 1,
width: BOOKMARK_MARKER_WIDTH_PX - 2,
height: BOOKMARK_MARKER_HEIGHT_PX - 2,
clipPath: BOOKMARK_MARKER_CLIP_PATH,
backgroundColor: iconColor,
}}
/>
</div>
<div
className="absolute top-0 overflow-hidden"
style={{
left: rightHalfLeft,
width: BOOKMARK_HALF_WIDTH_PX,
height: BOOKMARK_MARKER_HEIGHT_PX,
}}
>
<div
className="absolute top-0"
style={{
left: -BOOKMARK_HALF_WIDTH_PX,
width: BOOKMARK_MARKER_WIDTH_PX,
height: BOOKMARK_MARKER_HEIGHT_PX,
clipPath: BOOKMARK_MARKER_CLIP_PATH,
backgroundColor: "hsl(var(--background))",
}}
/>
<div
className="absolute"
style={{
top: 1,
left: 1 - BOOKMARK_HALF_WIDTH_PX,
width: BOOKMARK_MARKER_WIDTH_PX - 2,
height: BOOKMARK_MARKER_HEIGHT_PX - 2,
clipPath: BOOKMARK_MARKER_CLIP_PATH,
backgroundColor: iconColor,
}}
/>
</div>
</button>
</PopoverAnchor>
<PopoverContent
className="w-64 flex flex-col gap-3 p-3"
align="start"
side="bottom"
sideOffset={8}
onOpenAutoFocus={(event) => event.preventDefault()}
>
<BookmarkPopoverContent
bookmark={bookmark}
time={time}
timelineDuration={duration}
onPopoverClose={() => setIsPopoverOpen(false)}
/> />
</div> </PopoverContent>
</button> </Popover>
);
}
function BookmarkPopoverContent({
bookmark,
time,
timelineDuration,
onPopoverClose,
}: {
bookmark: Bookmark;
time: number;
timelineDuration: number;
onPopoverClose: () => void;
}) {
const editor = useEditor();
const [draftColorHex, setDraftColorHex] = useState(
(bookmark.color ?? DEFAULT_BOOKMARK_COLOR).replace("#", "").toUpperCase(),
);
useEffect(() => {
setDraftColorHex(
(bookmark.color ?? DEFAULT_BOOKMARK_COLOR).replace("#", "").toUpperCase(),
);
}, [bookmark.color]);
const handleRemove = () => {
editor.scenes.removeBookmark({ time });
onPopoverClose();
};
const handleUpdate = ({
note,
color,
duration,
}: Partial<{ note: string; color: string; duration: number }>) => {
const updates: Partial<{ note: string; color: string; duration: number }> =
{};
if (note !== undefined && note !== bookmark.note) updates.note = note;
if (
color !== undefined &&
color.toUpperCase() !== (bookmark.color ?? "").toUpperCase()
) {
updates.color = color;
}
if (duration !== undefined && duration !== bookmark.duration) {
updates.duration = duration;
}
if (Object.keys(updates).length === 0) return;
editor.scenes.updateBookmark({ time, updates });
};
return (
<>
<div className="flex flex-col gap-2">
<Label className="text-xs">Note</Label>
<Input
placeholder="Add a note..."
value={bookmark.note ?? ""}
onChange={(event) => handleUpdate({ note: event.target.value })}
className="h-8 text-sm"
/>
</div>
<div className="flex flex-col gap-2">
<Label className="text-xs">Color</Label>
<div className="relative">
<ColorPicker
value={uppercase({ string: draftColorHex })}
onChange={(color) => setDraftColorHex(uppercase({ string: color }))}
onChangeEnd={(color) =>
handleUpdate({ color: `#${uppercase({ string: color })}` })
}
className="bg-background border"
/>
{bookmark.color &&
bookmark.color.replace(/^#/, "").toUpperCase() !==
DEFAULT_BOOKMARK_COLOR.replace(/^#/, "").toUpperCase() && (
<Button
type="button"
variant="text"
size="text"
aria-label="Reset to default color"
className="absolute top-1/2 right-1 -translate-y-1/2 mr-1"
onClick={() =>
editor.scenes.updateBookmark({
time,
updates: { color: undefined },
})
}
>
<HugeiconsIcon
icon={ArrowTurnBackwardIcon}
className="!size-3.5"
/>
</Button>
)}
</div>
</div>
<div className="flex flex-col gap-2">
<Label className="text-xs">Duration</Label>
<div className="flex items-center gap-1.5">
<Input
type="number"
min={0}
step={0.1}
value={bookmark.duration ?? 0}
onChange={(event) => {
const parsed = parseFloat(event.target.value);
const value = Number.isNaN(parsed)
? 0
: clamp({
value: parsed,
min: 0,
max: Math.max(0, timelineDuration - time),
});
handleUpdate({ duration: value });
}}
className="h-8 text-sm"
containerClassName="w-full"
/>
</div>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="text-destructive hover:bg-destructive/10"
onClick={handleRemove}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
handleRemove();
}
}}
aria-label="delete bookmark"
>
<HugeiconsIcon icon={Delete02Icon} className="!size-3.5" />
Delete
</Button>
</>
); );
} }

View File

@ -47,6 +47,8 @@ import { useTimelineSeek } from "@/hooks/timeline/use-timeline-seek";
import { useTimelineDragDrop } from "@/hooks/timeline/use-timeline-drag-drop"; import { useTimelineDragDrop } from "@/hooks/timeline/use-timeline-drag-drop";
import { TimelineRuler } from "./timeline-ruler"; import { TimelineRuler } from "./timeline-ruler";
import { TimelineBookmarksRow } from "./bookmarks"; import { TimelineBookmarksRow } from "./bookmarks";
import { useBookmarkDrag } from "@/hooks/timeline/use-bookmark-drag";
import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
import { useTimelineStore } from "@/stores/timeline-store"; import { useTimelineStore } from "@/stores/timeline-store";
import { useEditor } from "@/hooks/use-editor"; import { useEditor } from "@/hooks/use-editor";
import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead"; import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
@ -126,6 +128,17 @@ export function Timeline() {
onSnapPointChange: handleSnapPointChange, onSnapPointChange: handleSnapPointChange,
}); });
const {
dragState: bookmarkDragState,
handleBookmarkMouseDown,
lastMouseXRef: bookmarkLastMouseXRef,
} = useBookmarkDrag({
zoomLevel,
scrollRef: tracksScrollRef,
snappingEnabled,
onSnapPointChange: handleSnapPointChange,
});
const { handleRulerMouseDown: handlePlayheadRulerMouseDown } = const { handleRulerMouseDown: handlePlayheadRulerMouseDown } =
useTimelinePlayhead({ useTimelinePlayhead({
zoomLevel, zoomLevel,
@ -168,10 +181,18 @@ export function Timeline() {
containerWidth, containerWidth,
); );
useEdgeAutoScroll({
isActive: bookmarkDragState.isDragging,
getMouseClientX: () => bookmarkLastMouseXRef.current,
rulerScrollRef: tracksScrollRef,
tracksScrollRef,
contentWidth: dynamicTimelineWidth,
});
const showSnapIndicator = const showSnapIndicator =
snappingEnabled && snappingEnabled &&
currentSnapPoint !== null && currentSnapPoint !== null &&
(dragState.isDragging || isResizing); (dragState.isDragging || bookmarkDragState.isDragging || isResizing);
const { const {
handleTracksMouseDown, handleTracksMouseDown,
@ -351,7 +372,7 @@ export function Timeline() {
> >
<div <div
ref={timelineHeaderRef} ref={timelineHeaderRef}
className="bg-background sticky top-0 z-30 flex flex-col" className="bg-background sticky top-0 flex flex-col"
> >
<TimelineRuler <TimelineRuler
zoomLevel={zoomLevel} zoomLevel={zoomLevel}
@ -366,6 +387,8 @@ export function Timeline() {
<TimelineBookmarksRow <TimelineBookmarksRow
zoomLevel={zoomLevel} zoomLevel={zoomLevel}
dynamicTimelineWidth={dynamicTimelineWidth} dynamicTimelineWidth={dynamicTimelineWidth}
dragState={bookmarkDragState}
onBookmarkMouseDown={handleBookmarkMouseDown}
handleWheel={handleWheel} handleWheel={handleWheel}
handleTimelineContentClick={handleRulerClick} handleTimelineContentClick={handleRulerClick}
handleRulerTrackingMouseDown={handleRulerMouseDown} handleRulerTrackingMouseDown={handleRulerMouseDown}
@ -433,7 +456,7 @@ export function Timeline() {
/> />
</div> </div>
</ContextMenuTrigger> </ContextMenuTrigger>
<ContextMenuContent className="z-200 w-40"> <ContextMenuContent className="w-40">
<ContextMenuItem <ContextMenuItem
icon={<HugeiconsIcon icon={TaskAdd02Icon} />} icon={<HugeiconsIcon icon={TaskAdd02Icon} />}
onClick={(e) => { onClick={(e) => {

View File

@ -38,7 +38,7 @@ export function SnapIndicator({
return ( return (
<div <div
className="pointer-events-none absolute z-90" className="pointer-events-none absolute"
style={{ style={{
left: `${leftPosition}px`, left: `${leftPosition}px`,
top: topPosition, top: topPosition,

View File

@ -29,6 +29,7 @@ import type { MediaAsset } from "@/types/assets";
import { mediaSupportsAudio } from "@/lib/media/media-utils"; import { mediaSupportsAudio } from "@/lib/media/media-utils";
import { getActionDefinition, type TAction, invokeAction } from "@/lib/actions"; import { getActionDefinition, type TAction, invokeAction } from "@/lib/actions";
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
import { resolveStickerId } from "@/lib/stickers";
import Image from "next/image"; import Image from "next/image";
import { import {
ScissorIcon, ScissorIcon,
@ -139,9 +140,7 @@ export function TimelineElement({
<ContextMenu> <ContextMenu>
<ContextMenuTrigger asChild> <ContextMenuTrigger asChild>
<div <div
className={`absolute top-0 h-full select-none ${ className={`absolute top-0 h-full select-none`}
isBeingDragged ? "z-30" : "z-10"
}`}
style={{ style={{
left: `${elementLeft}px`, left: `${elementLeft}px`,
width: `${elementWidth}px`, width: `${elementWidth}px`,
@ -155,7 +154,6 @@ export function TimelineElement({
element={element} element={element}
track={track} track={track}
isSelected={isSelected} isSelected={isSelected}
isBeingDragged={isBeingDragged}
hasAudio={hasAudio} hasAudio={hasAudio}
isMuted={isMuted} isMuted={isMuted}
mediaAssets={mediaAssets} mediaAssets={mediaAssets}
@ -165,7 +163,7 @@ export function TimelineElement({
/> />
</div> </div>
</ContextMenuTrigger> </ContextMenuTrigger>
<ContextMenuContent className="z-200 w-64"> <ContextMenuContent className="w-64">
<ActionMenuItem <ActionMenuItem
action="split" action="split"
icon={<HugeiconsIcon icon={ScissorIcon} />} icon={<HugeiconsIcon icon={ScissorIcon} />}
@ -227,7 +225,6 @@ function ElementInner({
element, element,
track, track,
isSelected, isSelected,
isBeingDragged,
hasAudio, hasAudio,
isMuted, isMuted,
mediaAssets, mediaAssets,
@ -238,7 +235,6 @@ function ElementInner({
element: TimelineElementType; element: TimelineElementType;
track: TimelineTrack; track: TimelineTrack;
isSelected: boolean; isSelected: boolean;
isBeingDragged: boolean;
hasAudio: boolean; hasAudio: boolean;
isMuted: boolean; isMuted: boolean;
mediaAssets: MediaAsset[]; mediaAssets: MediaAsset[];
@ -248,7 +244,7 @@ function ElementInner({
element: TimelineElementType, element: TimelineElementType,
) => void; ) => void;
handleResizeStart: (params: { handleResizeStart: (params: {
e: React.MouseEvent; event: React.MouseEvent;
elementId: string; elementId: string;
side: "left" | "right"; side: "left" | "right";
}) => void; }) => void;
@ -259,7 +255,7 @@ function ElementInner({
{ {
type: track.type, type: track.type,
}, },
)} ${isBeingDragged ? "z-30" : "z-10"} ${canElementBeHidden(element) && element.hidden ? "opacity-50" : ""}`} )} ${canElementBeHidden(element) && element.hidden ? "opacity-50" : ""}`}
> >
<button <button
type="button" type="button"
@ -321,7 +317,7 @@ function ResizeHandle({
side: "left" | "right"; side: "left" | "right";
elementId: string; elementId: string;
handleResizeStart: (params: { handleResizeStart: (params: {
e: React.MouseEvent; event: React.MouseEvent;
elementId: string; elementId: string;
side: "left" | "right"; side: "left" | "right";
}) => void; }) => void;
@ -330,8 +326,8 @@ function ResizeHandle({
return ( return (
<button <button
type="button" type="button"
className={`bg-primary absolute top-0 bottom-0 z-50 flex w-[0.6rem] items-center justify-center ${isLeft ? "left-0 cursor-w-resize" : "right-0 cursor-e-resize"}`} className={`bg-primary absolute top-0 bottom-0 flex w-[0.6rem] items-center justify-center ${isLeft ? "left-0 cursor-w-resize" : "right-0 cursor-e-resize"}`}
onMouseDown={(e) => handleResizeStart({ e, elementId, side })} onMouseDown={(event) => handleResizeStart({ event, elementId, side })}
aria-label={`${isLeft ? "Left" : "Right"} resize handle`} aria-label={`${isLeft ? "Left" : "Right"} resize handle`}
> >
<div className="bg-foreground h-[1.5rem] w-[0.2rem] rounded-full" /> <div className="bg-foreground h-[1.5rem] w-[0.2rem] rounded-full" />
@ -362,7 +358,10 @@ function ElementContent({
return ( return (
<div className="flex size-full items-center gap-2 pl-2"> <div className="flex size-full items-center gap-2 pl-2">
<Image <Image
src={`https://api.iconify.design/${element.iconName}.svg?width=20&height=20`} src={resolveStickerId({
stickerId: element.stickerId,
options: { width: 20, height: 20 },
})}
alt={element.name} alt={element.name}
className="size-5 shrink-0" className="size-5 shrink-0"
width={20} width={20}

View File

@ -72,20 +72,22 @@ export function TimelinePlayhead({
aria-valuemax={duration} aria-valuemax={duration}
aria-valuenow={playheadPosition} aria-valuenow={playheadPosition}
tabIndex={0} tabIndex={0}
className="pointer-events-auto absolute z-60" className="pointer-events-none absolute z-5"
style={{ style={{
left: `${leftPosition}px`, left: `${leftPosition}px`,
top: 0, top: 0,
height: `${totalHeight}px`, height: `${totalHeight}px`,
width: "2px", width: "2px",
}} }}
onMouseDown={handlePlayheadMouseDown}
onKeyDown={handlePlayheadKeyDown} onKeyDown={handlePlayheadKeyDown}
> >
<div className="bg-foreground absolute left-0 h-full w-0.5 cursor-col-resize" /> <div className="bg-foreground pointer-events-none absolute left-0 h-full w-0.5" />
<div <button
className={`absolute top-1 left-1/2 size-3 -translate-x-1/2 transform rounded-full border-2 shadow-xs ${isSnappingToPlayhead ? "bg-foreground border-foreground" : "bg-foreground border-foreground/50"}`} type="button"
aria-label="Drag playhead"
className={`pointer-events-auto absolute top-1 left-1/2 size-3 -translate-x-1/2 transform cursor-col-resize rounded-full border-2 shadow-xs ${isSnappingToPlayhead ? "bg-foreground border-foreground" : "bg-foreground border-foreground/50"}`}
onMouseDown={handlePlayheadMouseDown}
/> />
</div> </div>
); );

View File

@ -171,7 +171,7 @@ function SceneSelector() {
<SplitButtonLeft>{currentScene?.name || "No Scene"}</SplitButtonLeft> <SplitButtonLeft>{currentScene?.name || "No Scene"}</SplitButtonLeft>
<SplitButtonSeparator /> <SplitButtonSeparator />
<ScenesView> <ScenesView>
<SplitButtonRight onClick={() => {}} type="button"> <SplitButtonRight onClick={() => {}}>
<HugeiconsIcon icon={Layers01Icon} className="size-4" /> <HugeiconsIcon icon={Layers01Icon} className="size-4" />
</SplitButtonRight> </SplitButtonRight>
</ScenesView> </ScenesView>
@ -222,7 +222,6 @@ function ToolbarRightSection({
<Button <Button
variant="text" variant="text"
size="icon" size="icon"
type="button"
onClick={() => onZoom({ direction: "out" })} onClick={() => onZoom({ direction: "out" })}
> >
<HugeiconsIcon icon={SearchMinusIcon} /> <HugeiconsIcon icon={SearchMinusIcon} />
@ -240,7 +239,6 @@ function ToolbarRightSection({
<Button <Button
variant="text" variant="text"
size="icon" size="icon"
type="button"
onClick={() => onZoom({ direction: "in" })} onClick={() => onZoom({ direction: "in" })}
> >
<HugeiconsIcon icon={SearchAddIcon} /> <HugeiconsIcon icon={SearchAddIcon} />
@ -269,7 +267,6 @@ function ToolbarButton({
<Button <Button
variant={isActive ? "secondary" : "text"} variant={isActive ? "secondary" : "text"}
size="icon" size="icon"
type="button"
onClick={(event) => onClick({ event })} onClick={(event) => onClick({ event })}
className={cn( className={cn(
"rounded-sm", "rounded-sm",

View File

@ -1,10 +1,7 @@
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import Link from "next/link"; import Link from "next/link";
import { SOCIAL_LINKS } from "@/constants/site-constants"; import { SOCIAL_LINKS } from "@/constants/site-constants";
import { import { GithubIcon, Link04Icon } from "@hugeicons/core-free-icons";
GithubIcon,
Link04Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react"; import { HugeiconsIcon } from "@hugeicons/react";
export function GitHubContributeSection({ export function GitHubContributeSection({

View File

@ -9,6 +9,7 @@ import {
useKeybindingDisabler, useKeybindingDisabler,
} from "@/hooks/use-keybindings"; } from "@/hooks/use-keybindings";
import { useEditorActions } from "@/hooks/actions/use-editor-actions"; import { useEditorActions } from "@/hooks/actions/use-editor-actions";
import { prefetchFontAtlas } from "@/lib/fonts/google-fonts";
interface EditorProviderProps { interface EditorProviderProps {
projectId: string; projectId: string;
@ -42,6 +43,7 @@ export function EditorProvider({ projectId, children }: EditorProviderProps) {
if (cancelled) return; if (cancelled) return;
setIsLoading(false); setIsLoading(false);
prefetchFontAtlas();
} catch (err) { } catch (err) {
if (cancelled) return; if (cancelled) return;

View File

@ -1,36 +1,39 @@
"use client"; "use client";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import { cn } from "@/utils/ui"; import { cn } from "@/utils/ui";
import { Sun03Icon } from "@hugeicons/core-free-icons"; import { Sun03Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react"; import { HugeiconsIcon } from "@hugeicons/react";
interface ThemeToggleProps { interface ThemeToggleProps {
className?: string; className?: string;
iconClassName?: string; iconClassName?: string;
onToggle?: (e: React.MouseEvent<HTMLButtonElement>) => void; onToggle?: (e: React.MouseEvent<HTMLButtonElement>) => void;
} }
export function ThemeToggle({ export function ThemeToggle({
className, className,
iconClassName, iconClassName,
onToggle, onToggle,
}: ThemeToggleProps) { }: ThemeToggleProps) {
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
return ( return (
<Button <Button
size="icon" size="icon"
variant="ghost" variant="ghost"
className={cn("size-8", className)} className={cn("size-8", className)}
onClick={(e) => { onClick={(e) => {
setTheme(theme === "dark" ? "light" : "dark"); setTheme(theme === "dark" ? "light" : "dark");
onToggle?.(e); onToggle?.(e);
}} }}
> >
<HugeiconsIcon icon={Sun03Icon} className={cn("!size-[1.1rem]", iconClassName)} /> <HugeiconsIcon
<span className="sr-only">{theme === "dark" ? "Light" : "Dark"}</span> icon={Sun03Icon}
</Button> className={cn("!size-[1.1rem]", iconClassName)}
); />
} <span className="sr-only">{theme === "dark" ? "Light" : "Dark"}</span>
</Button>
);
}

View File

@ -1,6 +1,5 @@
import * as React from "react"; import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority"; import { cva, type VariantProps } from "class-variance-authority";
import { AlertTriangle } from "lucide-react";
import { cn } from "@/utils/ui"; import { cn } from "@/utils/ui";
import { HugeiconsIcon } from "@hugeicons/react"; import { HugeiconsIcon } from "@hugeicons/react";
import { Alert02Icon } from "@hugeicons/core-free-icons"; import { Alert02Icon } from "@hugeicons/core-free-icons";
@ -32,7 +31,10 @@ const Alert = React.forwardRef<
{...props} {...props}
> >
{variant === "destructive" && ( {variant === "destructive" && (
<HugeiconsIcon icon={Alert02Icon} className="size-5 text-destructive mt-0.5" /> <HugeiconsIcon
icon={Alert02Icon}
className="size-5 text-destructive mt-0.5"
/>
)} )}
{children} {children}
</div> </div>
@ -45,7 +47,10 @@ const AlertTitle = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<h5 <h5
ref={ref} ref={ref}
className={cn("mb-2 text-base leading-none font-semibold tracking-tight", className)} className={cn(
"mb-2 text-base leading-none font-semibold tracking-tight",
className,
)}
{...props} {...props}
/> />
)); ));

View File

@ -38,10 +38,10 @@ const AvatarFallback = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback <AvatarPrimitive.Fallback
ref={ref} ref={ref}
className={cn( className={cn(
"bg-muted flex size-full items-center justify-center rounded-full", "bg-muted flex size-full items-center justify-center rounded-full",
className, className,
)} )}
{...props} {...props}
/> />
)); ));

View File

@ -1,108 +1,99 @@
import * as React from "react";
import { Slot as SlotPrimitive } from "radix-ui";
import { ChevronRight, MoreHorizontal } from "lucide-react"; import { ChevronRight, MoreHorizontal } from "lucide-react";
import { Slot } from "radix-ui";
import { cn } from "@/utils/ui"; import { cn } from "@/utils/ui";
const Breadcrumb = React.forwardRef< function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
HTMLElement, return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
React.ComponentPropsWithoutRef<"nav"> & { }
separator?: React.ReactNode;
}
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />);
Breadcrumb.displayName = "Breadcrumb";
const BreadcrumbList = React.forwardRef<
HTMLOListElement,
React.ComponentPropsWithoutRef<"ol">
>(({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn(
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
className,
)}
{...props}
/>
));
BreadcrumbList.displayName = "BreadcrumbList";
const BreadcrumbItem = React.forwardRef<
HTMLLIElement,
React.ComponentPropsWithoutRef<"li">
>(({ className, ...props }, ref) => (
<li
ref={ref}
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
));
BreadcrumbItem.displayName = "BreadcrumbItem";
const BreadcrumbLink = React.forwardRef<
HTMLAnchorElement,
React.ComponentPropsWithoutRef<"a"> & {
asChild?: boolean;
}
>(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? SlotPrimitive.Slot : "a";
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return ( return (
<Comp <ol
ref={ref} data-slot="breadcrumb-list"
className={cn("hover:text-foreground", className)} className={cn(
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
className,
)}
{...props} {...props}
/> />
); );
}); }
BreadcrumbLink.displayName = "BreadcrumbLink";
const BreadcrumbPage = React.forwardRef< function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
HTMLSpanElement, return (
React.ComponentPropsWithoutRef<"span"> <li
>(({ className, ...props }, ref) => ( data-slot="breadcrumb-item"
<span className={cn("inline-flex items-center gap-1.5", className)}
ref={ref} {...props}
role="link" />
aria-disabled="true" );
aria-current="page" }
className={cn("text-foreground font-normal", className)}
{...props}
/>
));
BreadcrumbPage.displayName = "BreadcrumbPage";
const BreadcrumbSeparator = ({ function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot.Root : "a";
return (
<Comp
data-slot="breadcrumb-link"
className={cn("hover:text-foreground transition-colors", className)}
{...props}
/>
);
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
aria-current="page"
className={cn("text-foreground font-normal", className)}
{...props}
/>
);
}
function BreadcrumbSeparator({
children, children,
className, className,
...props ...props
}: React.ComponentProps<"li">) => ( }: React.ComponentProps<"li">) {
<li return (
role="presentation" <li
aria-hidden="true" data-slot="breadcrumb-separator"
className={cn("[&>svg]:size-3.5", className)} role="presentation"
{...props} aria-hidden="true"
> className={cn("[&>svg]:size-3.5", className)}
{children ?? <ChevronRight />} {...props}
</li> >
); {children ?? <ChevronRight />}
BreadcrumbSeparator.displayName = "BreadcrumbSeparator"; </li>
);
}
const BreadcrumbEllipsis = ({ function BreadcrumbEllipsis({
className, className,
...props ...props
}: React.ComponentProps<"span">) => ( }: React.ComponentProps<"span">) {
<span return (
role="presentation" <span
aria-hidden="true" data-slot="breadcrumb-ellipsis"
className={cn("flex size-9 items-center justify-center", className)} role="presentation"
{...props} aria-hidden="true"
> className={cn("flex size-9 items-center justify-center", className)}
<MoreHorizontal className="size-4" /> {...props}
<span className="sr-only">More</span> >
</span> <MoreHorizontal className="size-4" />
); <span className="sr-only">More</span>
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"; </span>
);
}
export { export {
Breadcrumb, Breadcrumb,

View File

@ -1,7 +1,6 @@
import * as React from "react"; import * as React from "react";
import { Slot as SlotPrimitive } from "radix-ui"; import { Slot as SlotPrimitive } from "radix-ui";
import { cva, type VariantProps } from "class-variance-authority"; import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/utils/ui"; import { cn } from "@/utils/ui";
const buttonVariants = cva( const buttonVariants = cva(
@ -9,18 +8,14 @@ const buttonVariants = cva(
{ {
variants: { variants: {
variant: { variant: {
default: default: "bg-primary text-primary-foreground hover:bg-primary/90",
"bg-primary text-primary-foreground hover:bg-primary/90", background: "bg-background text-foreground hover:bg-background/90",
background: foreground: "bg-foreground text-background hover:bg-foreground/90",
"bg-background text-foreground hover:bg-background/90",
foreground:
"bg-foreground text-background hover:bg-foreground/90",
destructive: destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/80", "bg-destructive text-destructive-foreground hover:bg-destructive/80",
"destructive-foreground": "destructive-foreground":
"border bg-background hover:bg-destructive/15 text-destructive", "border bg-background hover:bg-destructive/15 text-destructive",
outline: outline: "border border-border bg-transparent hover:bg-accent/50",
"border border-border bg-transparent hover:bg-accent/50",
secondary: secondary:
"bg-secondary text-secondary-foreground border border-secondary-border", "bg-secondary text-secondary-foreground border border-secondary-border",
text: "bg-transparent rounded-none opacity-100 hover:opacity-75", text: "bg-transparent rounded-none opacity-100 hover:opacity-75",
@ -28,10 +23,10 @@ const buttonVariants = cva(
link: "text-primary underline-offset-4 hover:underline !p-0 !h-auto", link: "text-primary underline-offset-4 hover:underline !p-0 !h-auto",
}, },
size: { size: {
default: "h-9.5 px-4 py-2", default: "h-9 px-4 py-2",
sm: "h-8 p-1 px-2 text-xs rounded-sm", sm: "h-7.5 p-1 px-2.5 text-sm rounded-sm",
lg: "h-10 p-5 px-6", lg: "h-10 p-5 px-6",
icon: "size-7", icon: "size-7 rounded-sm",
text: "p-0", text: "p-0",
}, },
}, },
@ -54,8 +49,11 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
const effectiveSize = size ?? (variant === "text" ? "text" : "default"); const effectiveSize = size ?? (variant === "text" ? "text" : "default");
return ( return (
<Comp <Comp
className={cn(buttonVariants({ variant, size: effectiveSize, className }))} className={cn(
buttonVariants({ variant, size: effectiveSize, className }),
)}
ref={ref} ref={ref}
type="button"
{...props} {...props}
/> />
); );

View File

@ -60,8 +60,8 @@ function Calendar({
...classNames, ...classNames,
}} }}
components={{ components={{
IconLeft: ({ ...props }) => <ChevronLeft className="size-4" />, IconLeft: () => <ChevronLeft className="size-4" />,
IconRight: ({ ...props }) => <ChevronRight className="size-4" />, IconRight: () => <ChevronRight className="size-4" />,
}} }}
{...props} {...props}
/> />

View File

@ -1,262 +0,0 @@
"use client";
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react";
import { ArrowLeft, ArrowRight } from "lucide-react";
import * as React from "react";
import { cn } from "@/utils/ui";
import { Button } from "./button";
type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin = UseCarouselParameters[1];
type CarouselProps = {
opts?: CarouselOptions;
plugins?: CarouselPlugin;
orientation?: "horizontal" | "vertical";
setApi?: (api: CarouselApi) => void;
};
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
api: ReturnType<typeof useEmblaCarousel>[1];
scrollPrev: () => void;
scrollNext: () => void;
canScrollPrev: boolean;
canScrollNext: boolean;
} & CarouselProps;
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
function useCarousel() {
const context = React.useContext(CarouselContext);
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />");
}
return context;
}
const Carousel = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & CarouselProps
>(
(
{
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
},
ref,
) => {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins,
);
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
const [canScrollNext, setCanScrollNext] = React.useState(false);
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) {
return;
}
setCanScrollPrev(api.canScrollPrev());
setCanScrollNext(api.canScrollNext());
}, []);
const scrollPrev = React.useCallback(() => {
api?.scrollPrev();
}, [api]);
const scrollNext = React.useCallback(() => {
api?.scrollNext();
}, [api]);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault();
scrollPrev();
} else if (event.key === "ArrowRight") {
event.preventDefault();
scrollNext();
}
},
[scrollPrev, scrollNext],
);
React.useEffect(() => {
if (!api || !setApi) {
return;
}
setApi(api);
}, [api, setApi]);
React.useEffect(() => {
if (!api) {
return;
}
onSelect(api);
api.on("reInit", onSelect);
api.on("select", onSelect);
return () => {
api?.off("select", onSelect);
};
}, [api, onSelect]);
return (
<CarouselContext.Provider
value={{
carouselRef,
api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
ref={ref}
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
);
},
);
Carousel.displayName = "Carousel";
const CarouselContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { carouselRef, orientation } = useCarousel();
return (
<div ref={carouselRef} className="overflow-hidden">
<div
ref={ref}
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className,
)}
{...props}
/>
</div>
);
});
CarouselContent.displayName = "CarouselContent";
const CarouselItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { orientation } = useCarousel();
return (
<div
ref={ref}
role="group"
aria-roledescription="slide"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className,
)}
{...props}
/>
);
});
CarouselItem.displayName = "CarouselItem";
const CarouselPrevious = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -left-12 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft className="size-4" />
<span className="sr-only">Previous slide</span>
</Button>
);
});
CarouselPrevious.displayName = "CarouselPrevious";
const CarouselNext = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel();
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -right-12 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight className="size-4" />
<span className="sr-only">Next slide</span>
</Button>
);
});
CarouselNext.displayName = "CarouselNext";
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
};

View File

@ -1,370 +0,0 @@
"use client";
import * as React from "react";
import * as RechartsPrimitive from "recharts";
import {
NameType,
Payload,
ValueType,
} from "recharts/types/component/DefaultTooltipContent";
import { cn } from "@/utils/ui";
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const;
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode;
icon?: React.ComponentType;
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
);
};
type ChartContextProps = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />");
}
return context;
}
const ChartContainer = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
config: ChartConfig;
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"];
}
>(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
return (
<ChartContext.Provider value={{ config }}>
<div
data-chart={chartId}
ref={ref}
className={cn(
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className,
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
});
ChartContainer.displayName = "Chart";
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([_, config]) => config.theme || config.color,
);
if (!colorConfig.length) {
return null;
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join("\n")}
}
`,
)
.join("\n"),
}}
/>
);
};
const ChartTooltip = RechartsPrimitive.Tooltip;
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
}
>(
(
{
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
},
ref,
) => {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload;
const key = `${labelKey || item.dataKey || item.name || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label;
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
);
}
if (!value) {
return null;
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
]);
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== "dot";
return (
<div
ref={ref}
className={cn(
"border-border/50 bg-background grid min-w-32 items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
className,
)}
>
{nestLabel ? null : tooltipLabel}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload.fill || item.color;
return (
<div
key={item.dataKey}
className={cn(
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:size-2.5",
indicator === "dot" && "items-center",
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"size-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
},
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center",
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="text-foreground font-mono font-medium tabular-nums">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
},
);
ChartTooltipContent.displayName = "ChartTooltip";
const ChartLegend = RechartsPrimitive.Legend;
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean;
nameKey?: string;
}
>(
(
{ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
ref,
) => {
const { config } = useChart();
if (!payload?.length) {
return null;
}
return (
<div
ref={ref}
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className,
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={item.value}
className={cn(
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:size-3",
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="size-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
},
);
ChartLegendContent.displayName = "ChartLegend";
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string,
) {
if (typeof payload !== "object" || payload === null) {
return;
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string;
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config];
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
};

View File

@ -1,133 +1,80 @@
import { forwardRef, useEffect, useRef, useState } from "react"; import { forwardRef, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { cn } from "@/utils/ui"; import { cn } from "@/utils/ui";
import { Input } from "./input"; import { Input } from "./input";
import {
Popover,
PopoverClose,
PopoverContent,
PopoverTrigger,
} from "./popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "./select";
import { Button } from "./button";
import { Cancel01Icon, ColorPickerIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import {
type ColorFormat,
appendAlpha,
extractColorFromText,
formatColorValue,
hexToHsv,
hsvToHex,
parseColorInput,
parseHexAlpha,
} from "@/utils/color";
interface ColorPickerProps { interface ColorPickerProps {
value?: string; value?: string;
onChange?: (value: string) => void; onChange?: (value: string) => void;
onChangeEnd?: (value: string) => void; onChangeEnd?: (value: string) => void;
className?: string; className?: string;
containerRef?: React.RefObject<HTMLDivElement | null>;
} }
const hexToHsv = (hex: string) => {
const r = parseInt(hex.slice(0, 2), 16) / 255;
const g = parseInt(hex.slice(2, 4), 16) / 255;
const b = parseInt(hex.slice(4, 6), 16) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const diff = max - min;
let h = 0;
const s = max === 0 ? 0 : diff / max;
const v = max;
if (diff !== 0) {
switch (max) {
case r:
h = ((g - b) / diff) % 6;
break;
case g:
h = (b - r) / diff + 2;
break;
case b:
h = (r - g) / diff + 4;
break;
}
}
h = (h * 60 + 360) % 360;
if (Number.isNaN(h)) h = 0;
return [h, s, v];
};
const hsvToHex = (h: number, s: number, v: number) => {
const c = v * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = v - c;
let r = 0,
g = 0,
b = 0;
if (h >= 0 && h < 60) {
r = c;
g = x;
b = 0;
} else if (h >= 60 && h < 120) {
r = x;
g = c;
b = 0;
} else if (h >= 120 && h < 180) {
r = 0;
g = c;
b = x;
} else if (h >= 180 && h < 240) {
r = 0;
g = x;
b = c;
} else if (h >= 240 && h < 300) {
r = x;
g = 0;
b = c;
} else if (h >= 300 && h < 360) {
r = c;
g = 0;
b = x;
}
r = Math.round((r + m) * 255);
g = Math.round((g + m) * 255);
b = Math.round((b + m) * 255);
return [r, g, b].map((x) => x.toString(16).padStart(2, "0")).join("");
};
const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>( const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
({ className, value = "FFFFFF", onChange, onChangeEnd, containerRef, ...props }, ref) => { ({ className, value = "FFFFFF", onChange, onChangeEnd, ...props }, ref) => {
const [isOpen, setIsOpen] = useState(false); const [isDragging, setIsDragging] = useState<
const [isDragging, setIsDragging] = useState<"saturation" | "hue" | null>( "saturation" | "hue" | "opacity" | null
null, >(null);
);
const [pickerPosition, setPickerPosition] = useState({
right: 0,
bottom: 0,
});
const [internalHue, setInternalHue] = useState(0); const [internalHue, setInternalHue] = useState(0);
const [inputValue, setInputValue] = useState(value); const [inputValue, setInputValue] = useState(value);
const [colorFormat, setColorFormat] = useState<ColorFormat>("hex");
const pickerRef = useRef<HTMLDivElement>(null);
const saturationRef = useRef<HTMLButtonElement>(null); const saturationRef = useRef<HTMLButtonElement>(null);
const hueRef = useRef<HTMLButtonElement>(null); const hueRef = useRef<HTMLButtonElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null); const opacityRef = useRef<HTMLButtonElement>(null);
const latestDragColorRef = useRef<string | null>(null); const latestDragColorRef = useRef<string | null>(null);
const [h, s, v] = hexToHsv(value); const isEyeDropperSupported =
const displayHue = s > 0 ? h : internalHue; typeof window !== "undefined" && "EyeDropper" in window;
useEffect(() => { const { rgb: rgbValue, alpha } = parseHexAlpha({ hex: value });
setInputValue(value); const [h, s, v] = hexToHsv({ hex: rgbValue });
}, [value]);
useEffect(() => { const handleEyeDropper = async () => {
const handleClickOutside = (event: MouseEvent) => { if (!isEyeDropperSupported || !EyeDropper) return;
if ( try {
pickerRef.current && const dropper = new EyeDropper();
!pickerRef.current.contains(event.target as Node) const result = await dropper.open();
) { const hex = result.sRGBHex.replace("#", "").toLowerCase();
setIsOpen(false); const finalHex = appendAlpha({ rgbHex: hex, alpha });
} onChange?.(finalHex);
}; onChangeEnd?.(finalHex);
} catch {
if (isOpen) { // user cancelled the picker
document.addEventListener("mousedown", handleClickOutside);
return () =>
document.removeEventListener("mousedown", handleClickOutside);
} }
}, [isOpen]); };
const hueDiff = Math.abs(h - internalHue);
const isSameHueWrapped = hueDiff < 1 || Math.abs(hueDiff - 360) < 1;
const displayHue = s === 0 || isSameHueWrapped ? internalHue : h;
useEffect(() => {
setInputValue(formatColorValue({ hex: value, format: colorFormat }));
}, [value, colorFormat]);
useEffect(() => { useEffect(() => {
const handleMouseMove = (e: MouseEvent) => { const handleMouseMove = (e: MouseEvent) => {
@ -143,9 +90,10 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
0, 0,
Math.min(1, (e.clientY - rect.top) / rect.height), Math.min(1, (e.clientY - rect.top) / rect.height),
); );
const newS = x; const newHex = appendAlpha({
const newV = 1 - y; rgbHex: hsvToHex({ h: displayHue, s: x, v: 1 - y }),
const newHex = hsvToHex(displayHue, newS, newV); alpha,
});
latestDragColorRef.current = newHex; latestDragColorRef.current = newHex;
onChange?.(newHex); onChange?.(newHex);
} }
@ -159,11 +107,25 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
const newH = x * 360; const newH = x * 360;
setInternalHue(newH); setInternalHue(newH);
if (s > 0) { if (s > 0) {
const newHex = hsvToHex(newH, s, v); const newHex = appendAlpha({
rgbHex: hsvToHex({ h: newH, s, v }),
alpha,
});
latestDragColorRef.current = newHex; latestDragColorRef.current = newHex;
onChange?.(newHex); onChange?.(newHex);
} }
} }
if (isDragging === "opacity" && opacityRef.current) {
const rect = opacityRef.current.getBoundingClientRect();
const x = Math.max(
0,
Math.min(1, (e.clientX - rect.left) / rect.width),
);
const newHex = appendAlpha({ rgbHex: rgbValue, alpha: x });
latestDragColorRef.current = newHex;
onChange?.(newHex);
}
}; };
const handleMouseUp = () => { const handleMouseUp = () => {
@ -182,7 +144,7 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
document.removeEventListener("mouseup", handleMouseUp); document.removeEventListener("mouseup", handleMouseUp);
}; };
} }
}, [isDragging, displayHue, s, v, onChange]); }, [isDragging, displayHue, s, v, alpha, rgbValue, onChange, onChangeEnd]);
const handleSaturationMouseDown = (e: React.MouseEvent) => { const handleSaturationMouseDown = (e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
@ -192,9 +154,10 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
const rect = saturationElement.getBoundingClientRect(); const rect = saturationElement.getBoundingClientRect();
const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height)); const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height));
const newS = x; const newHex = appendAlpha({
const newV = 1 - y; rgbHex: hsvToHex({ h: displayHue, s: x, v: 1 - y }),
const newHex = hsvToHex(displayHue, newS, newV); alpha,
});
latestDragColorRef.current = newHex; latestDragColorRef.current = newHex;
onChange?.(newHex); onChange?.(newHex);
}; };
@ -209,28 +172,83 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
const newH = x * 360; const newH = x * 360;
setInternalHue(newH); setInternalHue(newH);
if (s > 0) { if (s > 0) {
const newHex = hsvToHex(newH, s, v); const newHex = appendAlpha({
rgbHex: hsvToHex({ h: newH, s, v }),
alpha,
});
latestDragColorRef.current = newHex; latestDragColorRef.current = newHex;
onChange?.(newHex); onChange?.(newHex);
} }
}; };
const handleOpacityMouseDown = (e: React.MouseEvent) => {
e.preventDefault();
const opacityElement = opacityRef.current;
if (!opacityElement) return;
setIsDragging("opacity");
const rect = opacityElement.getBoundingClientRect();
const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
const newHex = appendAlpha({ rgbHex: rgbValue, alpha: x });
latestDragColorRef.current = newHex;
onChange?.(newHex);
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const hex = e.target.value.replace("#", ""); setInputValue(
setInputValue(hex); colorFormat === "hex"
? e.target.value.replace("#", "")
: e.target.value,
);
};
const commitInputValue = () => {
const parsed = parseColorInput({
input: inputValue,
format: colorFormat,
});
if (parsed) {
const nextHex = appendAlpha({ rgbHex: parsed, alpha });
onChange?.(nextHex);
onChangeEnd?.(nextHex);
return;
}
const extracted = extractColorFromText({ text: inputValue });
if (extracted) {
const hasExplicitAlpha = extracted.length > 6;
const finalHex = hasExplicitAlpha
? extracted
: appendAlpha({ rgbHex: extracted, alpha });
onChange?.(finalHex);
onChangeEnd?.(finalHex);
}
}; };
const handleInputBlur = () => { const handleInputBlur = () => {
onChange?.(inputValue); commitInputValue();
}; };
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") { if (e.key === "Enter") {
onChange?.(inputValue); commitInputValue();
e.currentTarget.blur(); e.currentTarget.blur();
} }
}; };
const handlePaste = (event: React.ClipboardEvent<HTMLInputElement>) => {
const pastedText = event.clipboardData.getData("text");
const extractedHex = extractColorFromText({ text: pastedText });
if (!extractedHex) return;
event.preventDefault();
const hasExplicitAlpha = extractedHex.length > 6;
const finalHex = hasExplicitAlpha
? extractedHex
: appendAlpha({ rgbHex: extractedHex, alpha });
onChange?.(finalHex);
onChangeEnd?.(finalHex);
};
const saturationStyle = { const saturationStyle = {
background: `linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, hsl(${displayHue}, 100%, 50%))`, background: `linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, hsl(${displayHue}, 100%, 50%))`,
}; };
@ -240,90 +258,178 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
"linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%)", "linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%)",
}; };
const checkerboardStyle = {
backgroundImage: `
linear-gradient(45deg, rgba(0,0,0,0.1) 25%, transparent 25%),
linear-gradient(-45deg, rgba(0,0,0,0.1) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, rgba(0,0,0,0.1) 75%),
linear-gradient(-45deg, transparent 75%, rgba(0,0,0,0.1) 75%)
`,
backgroundSize: "8px 8px",
backgroundPosition: "0 0, 0 4px, 4px -4px, -4px 0px",
backgroundColor: "#fff",
};
return ( return (
<div className="relative flex-1"> <Popover>
<div <div
ref={ref} ref={ref}
className={cn( className={cn(
"bg-accent flex h-8 items-center gap-2 rounded-md px-[0.45rem]", "bg-accent flex h-8 flex-1 items-center gap-2 rounded-md px-[0.45rem]",
className, className,
)} )}
{...props} {...props}
> >
<button <PopoverTrigger asChild>
ref={triggerRef} <button
className="size-4.5 cursor-pointer border rounded-sm hover:ring-2 hover:ring-white/20" className="size-4.5 cursor-pointer border rounded-sm hover:ring-1 hover:ring-foreground/20"
style={{ backgroundColor: `#${value}` }} style={{ backgroundColor: `#${value}` }}
type="button" type="button"
onClick={() => { />
if (!isOpen && triggerRef.current && containerRef?.current) { </PopoverTrigger>
const containerRect =
containerRef.current.getBoundingClientRect();
setPickerPosition({
right: window.innerWidth - containerRect.left - 8,
bottom: window.innerHeight - containerRect.bottom,
});
}
setIsOpen(!isOpen);
}}
/>
<div className="flex flex-1 items-center"> <div className="flex flex-1 items-center">
<Input <Input
className="!border-0 bg-transparent p-0 !ring-0 !ring-offset-0 uppercase" className={cn(
"!border-0 bg-transparent p-0 !ring-0 !ring-offset-0",
colorFormat === "hex" && "uppercase",
)}
size="sm" size="sm"
containerClassName="w-full" containerClassName="w-full"
value={inputValue} value={inputValue}
onChange={handleInputChange} onChange={handleInputChange}
onBlur={handleInputBlur} onBlur={handleInputBlur}
onKeyDown={handleInputKeyDown} onKeyDown={handleInputKeyDown}
onPaste={handlePaste}
/> />
</div> </div>
</div> </div>
<PopoverContent
{isOpen && className="w-64 px-0 select-none flex flex-col gap-3 py-2"
createPortal( side="left"
<div sideOffset={8}
ref={pickerRef} onOpenAutoFocus={(event) => {
className="bg-popover border-border fixed z-50 rounded-lg border p-4 shadow-lg select-none" event.preventDefault();
style={{ }}
right: pickerPosition.right, onCloseAutoFocus={(event) => {
bottom: pickerPosition.bottom, event.preventDefault();
}} }}
onInteractOutside={(event) => {
if (isDragging) event.preventDefault();
}}
>
<header className="border-b flex justify-between items-center pb-2 px-2">
<Select defaultValue="custom">
<SelectTrigger variant="outline">
<SelectValue placeholder="Select a mode" />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value="custom">Custom</SelectItem>
<SelectItem value="saved">Saved</SelectItem>
</SelectContent>
</Select>
<div>
{isEyeDropperSupported && (
<Button
variant="ghost"
size="icon"
type="button"
onClick={handleEyeDropper}
>
<HugeiconsIcon icon={ColorPickerIcon} />
</Button>
)}
<PopoverClose asChild>
<Button variant="ghost" size="icon" type="button">
<HugeiconsIcon icon={Cancel01Icon} />
</Button>
</PopoverClose>
</div>
</header>
<div className="px-2 flex flex-col gap-3">
<button
ref={saturationRef}
className="relative h-44 aspect-square w-full appearance-none border-0 bg-transparent p-0"
style={saturationStyle}
type="button"
onMouseDown={handleSaturationMouseDown}
> >
<button <ColorCircle
ref={saturationRef} size="sm"
className="relative mb-3 h-32 w-48 cursor-crosshair appearance-none border-0 bg-transparent p-0" position={{ left: `${s * 100}%`, top: `${(1 - v) * 100}%` }}
style={saturationStyle} color={`#${value}`}
type="button" />
onMouseDown={handleSaturationMouseDown} </button>
>
<ColorCircle
size="sm"
position={{ left: `${s * 100}%`, top: `${(1 - v) * 100}%` }}
color={`#${value}`}
/>
</button>
<button <button
ref={hueRef} ref={hueRef}
className="relative h-4 w-48 cursor-pointer rounded-lg appearance-none border-0 bg-transparent p-0" className="relative h-4 w-full rounded-lg appearance-none border-0 bg-transparent p-0"
style={hueStyle} style={hueStyle}
type="button" type="button"
onMouseDown={handleHueMouseDown} onMouseDown={handleHueMouseDown}
>
<ColorCircle
size="md"
position={{
left: `calc(0.5rem + (100% - 1rem) * ${displayHue / 360})`,
top: "50%",
}}
/>
</button>
<button
ref={opacityRef}
className="relative h-4 w-full overflow-hidden rounded-lg appearance-none border-0 p-0"
style={checkerboardStyle}
type="button"
onMouseDown={handleOpacityMouseDown}
>
<div
className="absolute inset-0 rounded-lg"
style={{
background: `linear-gradient(to right, transparent, #${rgbValue})`,
}}
/>
<ColorCircle
size="md"
position={{
left: `calc(0.5rem + (100% - 1rem) * ${alpha})`,
top: "50%",
}}
/>
</button>
<div className="flex items-center gap-2">
<Select
value={colorFormat}
onValueChange={(value) => setColorFormat(value as ColorFormat)}
> >
<ColorCircle <SelectTrigger variant="outline" className="min-w-18 max-w-18">
size="md" <SelectValue />
position={{ </SelectTrigger>
left: `${(displayHue / 360) * 100}%`, <SelectContent>
top: "50%", <SelectItem value="hex">HEX</SelectItem>
}} <SelectItem value="rgb">RGB</SelectItem>
color={`#${value}`} <SelectItem value="hsl">HSL</SelectItem>
/> <SelectItem value="hsv">HSV</SelectItem>
</button> </SelectContent>
</div>, </Select>
document.body,
)} <Input
</div> className={cn(
"h-7 rounded-sm p-2.5",
colorFormat === "hex" && "uppercase",
)}
type="text"
value={inputValue}
onChange={handleInputChange}
onBlur={handleInputBlur}
onKeyDown={handleInputKeyDown}
onPaste={handlePaste}
/>
</div>
</div>
</PopoverContent>
</Popover>
); );
}, },
); );
@ -336,7 +442,7 @@ const ColorCircle = ({
}: { }: {
size: "sm" | "md"; size: "sm" | "md";
position: { left: string; top: string }; position: { left: string; top: string };
color: string; color?: string;
}) => ( }) => (
<div <div
className={`pointer-events-none absolute rounded-full border-3 border-white shadow-lg ${ className={`pointer-events-none absolute rounded-full border-3 border-white shadow-lg ${

View File

@ -1,152 +0,0 @@
"use client";
import * as React from "react";
import type { DialogProps } from "@radix-ui/react-dialog";
import { Command as CommandPrimitive } from "cmdk";
import { Search } from "lucide-react";
import { cn } from "@/utils/ui";
import { Dialog, DialogContent } from "./dialog";
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"bg-popover text-popover-foreground flex size-full flex-col overflow-hidden rounded-md",
className,
)}
{...props}
/>
));
Command.displayName = CommandPrimitive.displayName;
const CommandDialog = ({ children, ...props }: DialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0">
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:size-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:size-5">
{children}
</Command>
</DialogContent>
</Dialog>
);
};
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
</div>
));
CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-x-hidden overflow-y-auto", className)}
{...props}
/>
));
CommandList.displayName = CommandPrimitive.List.displayName;
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
));
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className,
)}
{...props}
/>
));
CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
));
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"data-[selected=true]:bg-popover-hover data-[selected=true]:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
className,
)}
{...props}
/>
));
CommandItem.displayName = CommandPrimitive.Item.displayName;
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className,
)}
{...props}
/>
);
};
CommandShortcut.displayName = "CommandShortcut";
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};

View File

@ -5,7 +5,11 @@ import { ContextMenu as ContextMenuPrimitive } from "radix-ui";
import { cva, type VariantProps } from "class-variance-authority"; import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/utils/ui"; import { cn } from "@/utils/ui";
import { HugeiconsIcon } from "@hugeicons/react"; import { HugeiconsIcon } from "@hugeicons/react";
import { Tick02Icon, ArrowRightIcon, CircleIcon } from "@hugeicons/core-free-icons" import {
Tick02Icon,
ArrowRightIcon,
CircleIcon,
} from "@hugeicons/core-free-icons";
const ContextMenu = ContextMenuPrimitive.Root; const ContextMenu = ContextMenuPrimitive.Root;
@ -20,12 +24,14 @@ const ContextMenuSub = ContextMenuPrimitive.Sub;
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup; const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
const contextMenuItemVariants = cva( const contextMenuItemVariants = cva(
"relative flex cursor-pointer select-none items-center gap-2.5 px-4 py-1.5 last:pb-1 text-base outline-hidden data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0", "relative flex cursor-pointer select-none items-center gap-2.5 px-4 py-1.5 text-sm outline-hidden data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0",
{ {
variants: { variants: {
variant: { variant: {
default: "focus:bg-accent/35 focus:text-accent-foreground [&_svg]:text-muted-foreground", default:
destructive: "text-destructive focus:bg-destructive/5 focus:text-destructive [&_svg]:text-destructive", "focus:bg-accent focus:text-accent-foreground [&_svg]:text-muted-foreground",
destructive:
"text-destructive focus:bg-destructive/5 focus:text-destructive [&_svg]:text-destructive",
}, },
}, },
defaultVariants: { defaultVariants: {
@ -41,22 +47,32 @@ const ContextMenuSubTrigger = React.forwardRef<
variant?: VariantProps<typeof contextMenuItemVariants>["variant"]; variant?: VariantProps<typeof contextMenuItemVariants>["variant"];
icon?: React.ReactNode; icon?: React.ReactNode;
} }
>(({ className, inset, children, variant = "default", icon, ...props }, ref) => ( >(
<ContextMenuPrimitive.SubTrigger (
ref={ref} { className, inset, children, variant = "default", icon, ...props },
className={cn( ref,
contextMenuItemVariants({ variant }), ) => (
"data-[state=open]:bg-accent data-[state=open]:text-accent-foreground", <ContextMenuPrimitive.SubTrigger
inset && "pl-8", ref={ref}
className, className={cn(
)} contextMenuItemVariants({ variant }),
{...props} "data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
> inset && "pl-8",
{icon && <span className="size-4 shrink-0 text-muted-foreground">{icon}</span>} className,
{children} )}
<HugeiconsIcon icon={ArrowRightIcon} className="ml-auto text-muted-foreground/80" /> {...props}
</ContextMenuPrimitive.SubTrigger> >
)); {icon && (
<span className="size-4 shrink-0 text-muted-foreground">{icon}</span>
)}
{children}
<HugeiconsIcon
icon={ArrowRightIcon}
className="ml-auto text-muted-foreground/80"
/>
</ContextMenuPrimitive.SubTrigger>
),
);
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName; ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
const ContextMenuSubContent = React.forwardRef< const ContextMenuSubContent = React.forwardRef<
@ -76,13 +92,15 @@ ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName;
const ContextMenuContent = React.forwardRef< const ContextMenuContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Content>, React.ElementRef<typeof ContextMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content> React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content> & {
>(({ className, ...props }, ref) => ( container?: HTMLElement | null;
<ContextMenuPrimitive.Portal> }
>(({ className, container, ...props }, ref) => (
<ContextMenuPrimitive.Portal container={container ?? undefined}>
<ContextMenuPrimitive.Content <ContextMenuPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
"bg-popover text-popover-foreground z-50 min-w-48 overflow-hidden rounded-lg border shadow-xl py-2.5", "bg-popover text-popover-foreground z-50 min-w-48 overflow-hidden rounded-lg border shadow-xl py-1.5",
className, className,
)} )}
{...props} {...props}
@ -99,25 +117,46 @@ const ContextMenuItem = React.forwardRef<
icon?: React.ReactNode; icon?: React.ReactNode;
textRight?: string; textRight?: string;
} }
>(({ className, inset, variant = "default", icon, children, textRight, ...props }, ref) => ( >(
<ContextMenuPrimitive.Item (
ref={ref} {
className={cn(
contextMenuItemVariants({ variant }),
inset && "pl-8",
className, className,
)} inset,
{...props} variant = "default",
> icon,
{icon && <span className="[&_svg]:size-4 [&_svg]:shrink-0">{icon}</span>} children,
{children} textRight,
{textRight && ( ...props
<span className="ml-auto text-[0.60rem] tracking-widest text-muted-foreground/80 mb-0.5"> },
{textRight} ref,
</span> ) => {
)} const shouldInsetContent = inset || Boolean(icon);
</ContextMenuPrimitive.Item>
)); return (
<ContextMenuPrimitive.Item
ref={ref}
className={cn(
contextMenuItemVariants({ variant }),
shouldInsetContent && "pl-8",
className,
)}
{...props}
>
{icon && (
<span className="absolute left-3 flex size-3.5 items-center justify-center text-muted-foreground [&_svg]:size-4 [&_svg]:shrink-0">
{icon}
</span>
)}
{children}
{textRight && (
<span className="ml-auto text-[0.60rem] tracking-widest text-muted-foreground/80 mb-0.5">
{textRight}
</span>
)}
</ContextMenuPrimitive.Item>
);
},
);
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName; ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
const ContextMenuCheckboxItem = React.forwardRef< const ContextMenuCheckboxItem = React.forwardRef<
@ -126,22 +165,33 @@ const ContextMenuCheckboxItem = React.forwardRef<
variant?: VariantProps<typeof contextMenuItemVariants>["variant"]; variant?: VariantProps<typeof contextMenuItemVariants>["variant"];
icon?: React.ReactNode; icon?: React.ReactNode;
} }
>(({ className, children, checked, variant = "default", icon, ...props }, ref) => ( >(
<ContextMenuPrimitive.CheckboxItem (
ref={ref} { className, children, checked, variant = "default", icon, ...props },
className={cn(contextMenuItemVariants({ variant }), "pr-2 pl-8", className)} ref,
checked={checked} ) => (
{...props} <ContextMenuPrimitive.CheckboxItem
> ref={ref}
<span className="absolute left-2 flex size-3.5 items-center justify-center"> className={cn(
<ContextMenuPrimitive.ItemIndicator> contextMenuItemVariants({ variant }),
<HugeiconsIcon icon={Tick02Icon} className="size-4" /> "pr-2 pl-8",
</ContextMenuPrimitive.ItemIndicator> className,
</span> )}
{icon && <span className="size-4 shrink-0 text-muted-foreground">{icon}</span>} checked={checked}
{children} {...props}
</ContextMenuPrimitive.CheckboxItem> >
)); <span className="absolute left-3 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<HugeiconsIcon icon={Tick02Icon} className="size-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{icon && (
<span className="size-4 shrink-0 text-muted-foreground">{icon}</span>
)}
{children}
</ContextMenuPrimitive.CheckboxItem>
),
);
ContextMenuCheckboxItem.displayName = ContextMenuCheckboxItem.displayName =
ContextMenuPrimitive.CheckboxItem.displayName; ContextMenuPrimitive.CheckboxItem.displayName;
@ -162,7 +212,9 @@ const ContextMenuRadioItem = React.forwardRef<
<HugeiconsIcon icon={CircleIcon} className="size-2 fill-current" /> <HugeiconsIcon icon={CircleIcon} className="size-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator> </ContextMenuPrimitive.ItemIndicator>
</span> </span>
{icon && <span className="size-4 shrink-0 text-muted-foreground">{icon}</span>} {icon && (
<span className="size-4 shrink-0 text-muted-foreground">{icon}</span>
)}
{children} {children}
</ContextMenuPrimitive.RadioItem> </ContextMenuPrimitive.RadioItem>
)); ));
@ -184,7 +236,9 @@ const ContextMenuLabel = React.forwardRef<
)} )}
{...props} {...props}
> >
{icon && <span className="size-4 shrink-0 text-muted-foreground">{icon}</span>} {icon && (
<span className="size-4 shrink-0 text-muted-foreground">{icon}</span>
)}
{children} {children}
</ContextMenuPrimitive.Label> </ContextMenuPrimitive.Label>
)); ));
@ -208,7 +262,10 @@ const ContextMenuShortcut = ({
}: React.HTMLAttributes<HTMLSpanElement>) => { }: React.HTMLAttributes<HTMLSpanElement>) => {
return ( return (
<span <span
className={cn("ml-auto text-xs tracking-widest text-muted-foreground opacity-60", className)} className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground opacity-60",
className,
)}
{...props} {...props}
/> />
); );

View File

@ -1,118 +0,0 @@
"use client";
import * as React from "react";
import { Drawer as DrawerPrimitive } from "vaul";
import { cn } from "@/utils/ui";
const Drawer = ({
shouldScaleBackground = true,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
<DrawerPrimitive.Root
shouldScaleBackground={shouldScaleBackground}
{...props}
/>
);
Drawer.displayName = "Drawer";
const DrawerTrigger = DrawerPrimitive.Trigger;
const DrawerPortal = DrawerPrimitive.Portal;
const DrawerClose = DrawerPrimitive.Close;
const DrawerOverlay = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Overlay
ref={ref}
className={cn("fixed inset-0 z-50 bg-black/80", className)}
{...props}
/>
));
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DrawerPortal>
<DrawerOverlay />
<DrawerPrimitive.Content
ref={ref}
className={cn(
"bg-background fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border",
className,
)}
{...props}
>
<div className="bg-muted mx-auto mt-4 h-2 w-[100px] rounded-full" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
));
DrawerContent.displayName = "DrawerContent";
const DrawerHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
{...props}
/>
);
DrawerHeader.displayName = "DrawerHeader";
const DrawerFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
);
DrawerFooter.displayName = "DrawerFooter";
const DrawerTitle = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Title
ref={ref}
className={cn(
"text-lg leading-none font-semibold tracking-tight",
className,
)}
{...props}
/>
));
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
const DrawerDescription = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Description
ref={ref}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
));
DrawerDescription.displayName = DrawerPrimitive.Description.displayName;
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
};

View File

@ -20,7 +20,7 @@ const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const dropdownMenuItemVariants = cva( const dropdownMenuItemVariants = cva(
"relative flex cursor-pointer select-none items-center gap-2 rounded-lg px-2.5 py-2 text-sm text-foreground/85 outline-hidden data-[highlighted]:bg-popover-hover data-disabled:pointer-events-none data-disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0", "relative flex cursor-pointer select-none items-center gap-2 rounded-md px-3 py-2 text-sm text-foreground outline-hidden data-[highlighted]:bg-popover-hover data-disabled:pointer-events-none data-disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
{ {
variants: { variants: {
variant: { variant: {
@ -66,7 +66,7 @@ const DropdownMenuSubContent = React.forwardRef<
<DropdownMenuPrimitive.SubContent <DropdownMenuPrimitive.SubContent
ref={ref} ref={ref}
className={cn( className={cn(
"bg-popover text-popover-foreground z-50 min-w-32 overflow-hidden rounded-2xl border p-2 shadow-lg", "group/menu bg-popover text-popover-foreground z-50 min-w-32 overflow-hidden rounded-2xl border p-2 shadow-lg",
className, className,
)} )}
{...props} {...props}
@ -88,7 +88,7 @@ const DropdownMenuContent = React.forwardRef<
e.preventDefault(); e.preventDefault();
}} }}
className={cn( className={cn(
"bg-popover text-popover-foreground z-50 min-w-32 overflow-hidden rounded-lg border p-2 shadow-lg", "group/menu bg-popover text-popover-foreground z-50 min-w-32 overflow-hidden rounded-lg border p-1.5 shadow-lg",
className, className,
)} )}
{...props} {...props}
@ -101,19 +101,61 @@ const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>, React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean; inset?: boolean;
icon?: React.ReactNode;
variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"]; variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"];
} }
>(({ className, inset, variant = "default", ...props }, ref) => ( >(
<DropdownMenuPrimitive.Item (
ref={ref} {
className={cn(
dropdownMenuItemVariants({ variant }),
inset && "pl-8",
className, className,
)} inset,
{...props} icon,
/> variant = "default",
)); children,
asChild,
...props
},
ref,
) => {
const iconSlot = (
<span className="hidden size-4 shrink-0 items-center justify-center group-has-[[data-has-icon]]/menu:flex">
{icon}
</span>
);
const renderedChildren =
asChild && React.isValidElement(children) ? (
React.cloneElement(
children as React.ReactElement<{ children?: React.ReactNode }>,
{},
iconSlot,
(children as React.ReactElement<{ children?: React.ReactNode }>).props
.children,
)
) : (
<>
{iconSlot}
{children}
</>
);
return (
<DropdownMenuPrimitive.Item
ref={ref}
asChild={asChild}
data-has-icon={icon ? "" : undefined}
className={cn(
dropdownMenuItemVariants({ variant }),
inset && "pl-8",
className,
)}
{...props}
>
{renderedChildren}
</DropdownMenuPrimitive.Item>
);
},
);
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef< const DropdownMenuCheckboxItem = React.forwardRef<

View File

@ -1,16 +1,51 @@
"use client";
import { import {
Select, useState,
SelectContent, useMemo,
SelectItem, useRef,
SelectTrigger, useEffect,
SelectValue, useCallback,
} from "@/components/ui/select"; type CSSProperties,
import { FONT_OPTIONS, type FontFamily } from "@/constants/font-constants"; } from "react";
import { List, type RowComponentProps } from "react-window";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import {
getCachedFontAtlas,
loadFullFont,
prefetchFontAtlas,
clearFontAtlasCache,
} from "@/lib/fonts/google-fonts";
import type { FontAtlas, FontAtlasEntry } from "@/types/fonts";
import { cn } from "@/utils/ui"; import { cn } from "@/utils/ui";
import { ChevronDown, Search, Upload } from "lucide-react";
import { HugeiconsIcon } from "@hugeicons/react";
import { TextFontIcon } from "@hugeicons/core-free-icons";
const FONT_TABS = [
{ key: "all", label: "All fonts" },
{ key: "favorites", label: "Favorites" },
{ key: "my-fonts", label: "My fonts" },
] as const;
type FontTab = (typeof FONT_TABS)[number]["key"];
const ROW_HEIGHT = 40;
const SPRITE_ROW_HEIGHT = 40;
const PREVIEW_SCALE = 0.8;
const LIST_WIDTH = 288;
const MAX_LIST_HEIGHT = 288;
const OVERSCAN = 15;
interface FontPickerProps { interface FontPickerProps {
defaultValue?: FontFamily; defaultValue?: string;
onValueChange?: (value: FontFamily) => void; onValueChange?: (value: string) => void;
className?: string; className?: string;
} }
@ -19,24 +54,256 @@ export function FontPicker({
onValueChange, onValueChange,
className, className,
}: FontPickerProps) { }: FontPickerProps) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const [activeTab, setActiveTab] = useState<FontTab>("all");
const [atlas, setAtlas] = useState<FontAtlas | null>(() =>
getCachedFontAtlas(),
);
const [status, setStatus] = useState<"idle" | "loading" | "error">(() =>
getCachedFontAtlas() ? "idle" : "loading",
);
const searchInputRef = useRef<HTMLInputElement>(null);
const fontNames = useMemo(() => {
if (!atlas) return [];
return Object.keys(atlas.fonts).sort();
}, [atlas]);
const filteredFonts = useMemo(() => {
if (!search) return fontNames;
const query = search.toLowerCase();
return fontNames.filter((name) => name.toLowerCase().includes(query));
}, [fontNames, search]);
const listHeight = Math.min(
MAX_LIST_HEIGHT,
filteredFonts.length * ROW_HEIGHT,
);
const handleSelect = useCallback(
async ({ family }: { family: string }) => {
try {
await loadFullFont({ family });
onValueChange?.(family);
} catch {
onValueChange?.(family);
}
setOpen(false);
},
[onValueChange],
);
// Load atlas on first open if cache is empty (fallback when prefetch hasn't completed)
useEffect(() => {
if (!open || atlas) return;
setStatus("loading");
prefetchFontAtlas().then((data) => {
if (data) {
setAtlas(data);
setStatus("idle");
} else {
setStatus("error");
}
});
}, [open, atlas]);
useEffect(() => {
if (!open) {
setSearch("");
setActiveTab("all");
}
}, [open]);
const handleRetry = useCallback(() => {
clearFontAtlasCache();
setStatus("loading");
prefetchFontAtlas().then((data) => {
if (data) {
setAtlas(data);
setStatus("idle");
} else {
setStatus("error");
}
});
}, []);
return ( return (
<Select defaultValue={defaultValue} onValueChange={onValueChange}> <Popover open={open} onOpenChange={setOpen}>
<SelectTrigger <PopoverTrigger
className={cn("w-full", className)} className={cn(
"border-border bg-accent flex h-7 w-full cursor-pointer items-center justify-between gap-1 rounded-md border px-2.5 text-sm whitespace-nowrap focus-visible:border-primary focus-visible:ring-0 focus:outline-hidden",
className,
)}
> >
<SelectValue placeholder="Select a font" /> <div className="flex min-w-0 items-center gap-1.5">
</SelectTrigger> <span className="text-muted-foreground [&_svg]:size-3.5 shrink-0">
<SelectContent> <HugeiconsIcon icon={TextFontIcon} />
{FONT_OPTIONS.map((font) => ( </span>
<SelectItem <span className="truncate" style={{ fontFamily: defaultValue }}>
key={font.value} {defaultValue ?? "Select a font"}
value={font.value} </span>
style={{ fontFamily: font.value }} </div>
<ChevronDown className="size-3 shrink-0 opacity-50" />
</PopoverTrigger>
<PopoverContent
className="w-72 p-0 overflow-hidden"
align="start"
side="left"
onOpenAutoFocus={(event) => {
event.preventDefault();
searchInputRef.current?.focus();
}}
onCloseAutoFocus={(event) => {
event.preventDefault();
event.stopPropagation();
}}
>
<div className="relative px-3 py-1.5">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-3.5 shrink-0 opacity-50" />
<Input
ref={searchInputRef}
placeholder="Search fonts..."
value={search}
onChange={(event) => setSearch(event.target.value)}
size="xs"
className="w-full pl-5 bg-transparent !border-none !shadow-none"
/>
</div>
<div className="flex border-b px-3">
{FONT_TABS.map((tab) => (
<button
key={tab.key}
type="button"
className={cn(
"px-3 py-1.5 text-xs border-b-2 -mb-px",
activeTab === tab.key
? "border-foreground text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground",
)}
onClick={() => setActiveTab(tab.key)}
>
{tab.label}
</button>
))}
</div>
{status === "loading" && (
<div className="py-8 text-center text-sm text-muted-foreground">
Loading fonts...
</div>
)}
{status === "error" && (
<div className="flex flex-col items-center gap-3 py-8 px-4">
<p className="text-sm text-muted-foreground text-center">
Failed to load font previews.
</p>
<Button variant="outline" size="sm" onClick={handleRetry}>
Retry
</Button>
</div>
)}
{status === "idle" &&
fontNames.length > 0 &&
filteredFonts.length === 0 && (
<div className="py-6 text-center text-sm text-muted-foreground">
No fonts found.
</div>
)}
{status === "idle" && atlas && filteredFonts.length > 0 && (
<List
rowCount={filteredFonts.length}
rowHeight={ROW_HEIGHT}
overscanCount={OVERSCAN}
rowComponent={FontRow}
rowProps={{
atlas,
filteredFonts,
selectedFont: defaultValue,
onFontSelect: handleSelect,
}}
style={{ height: listHeight, width: LIST_WIDTH }}
/>
)}
<div className="border-t p-1">
<Button
variant="ghost"
size="sm"
className="w-full justify-start text-muted-foreground h-8 font-normal"
onClick={() => {
// TODO: Implement local font loading
console.log("Load local fonts clicked");
}}
> >
{font.label} <Upload className="!size-3.5" />
</SelectItem> Load local fonts
))} </Button>
</SelectContent> </div>
</Select> </PopoverContent>
</Popover>
);
}
function FontSpritePreview({ entry }: { entry: FontAtlasEntry }) {
return (
<div
className="shrink-0"
style={{
width: entry.w,
height: SPRITE_ROW_HEIGHT,
backgroundColor: "currentColor",
WebkitMaskImage: `url(/fonts/font-chunk-${entry.ch}.avif)`,
WebkitMaskPosition: `-${entry.x}px -${entry.y}px`,
WebkitMaskRepeat: "no-repeat",
maskImage: `url(/fonts/font-chunk-${entry.ch}.avif)`,
maskPosition: `-${entry.x}px -${entry.y}px`,
maskRepeat: "no-repeat",
transform: `scale(${PREVIEW_SCALE})`,
transformOrigin: "left center",
}}
/>
);
}
type FontRowProps = {
atlas: FontAtlas;
filteredFonts: string[];
selectedFont: string | undefined;
onFontSelect: (params: { family: string }) => void;
};
function FontRow({
index,
style,
atlas,
filteredFonts,
selectedFont,
onFontSelect,
}: RowComponentProps<FontRowProps>) {
const fontName = filteredFonts[index];
const entry = atlas.fonts[fontName];
const isSelected = fontName === selectedFont;
return (
<button
type="button"
style={style as CSSProperties}
className={cn(
"flex w-full cursor-pointer items-center gap-2 px-3 outline-hidden hover:bg-popover-hover",
isSelected && "bg-popover-hover",
)}
onClick={() => onFontSelect({ family: fontName })}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onFontSelect({ family: fontName });
}
}}
aria-label={fontName}
>
<div className="min-w-0 overflow-hidden">
<FontSpritePreview entry={entry} />
</div>
</button>
); );
} }

View File

@ -1,75 +0,0 @@
"use client";
import * as React from "react";
import { OTPInput, OTPInputContext } from "input-otp";
import { Minus } from "lucide-react";
import { cn } from "@/utils/ui";
const InputOTP = React.forwardRef<
React.ElementRef<typeof OTPInput>,
React.ComponentPropsWithoutRef<typeof OTPInput>
>(({ className, containerClassName, ...props }, ref) => (
<OTPInput
ref={ref}
containerClassName={cn(
"flex items-center justify-between gap-2 w-full has-disabled:opacity-50",
containerClassName,
)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
));
InputOTP.displayName = "InputOTP";
const InputOTPGroup = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div">
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-1 items-center gap-2", className)}
{...props}
/>
));
InputOTPGroup.displayName = "InputOTPGroup";
const InputOTPSlot = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div"> & { index: number }
>(({ index, className, ...props }, ref) => {
const inputOTPContext = React.useContext(OTPInputContext);
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index];
return (
<div
ref={ref}
className={cn(
"border-input relative flex aspect-square min-w-[36px] flex-1 items-center justify-center border text-lg shadow-xs first:rounded-l-md first:border-l last:rounded-r-md",
isActive && "ring-ring z-10 ring-1",
className,
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
</div>
)}
</div>
);
});
InputOTPSlot.displayName = "InputOTPSlot";
const InputOTPSeparator = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div">
>(({ ...props }, ref) => (
<div ref={ref} role="separator" {...props}>
<Minus />
</div>
));
InputOTPSeparator.displayName = "InputOTPSeparator";
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };

View File

@ -1,88 +1,85 @@
"use client"; "use client";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { ArrowLeft, Search } from "lucide-react"; import { ArrowLeft, Search } from "lucide-react";
import { motion } from "motion/react"; import { motion } from "motion/react";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
interface InputWithBackProps { interface InputWithBackProps {
isExpanded: boolean; isExpanded: boolean;
setIsExpanded: (isExpanded: boolean) => void; setIsExpanded: (isExpanded: boolean) => void;
placeholder?: string; placeholder?: string;
value?: string; value?: string;
onChange?: (value: string) => void; onChange?: (value: string) => void;
disableAnimation?: boolean; disableAnimation?: boolean;
} }
export function InputWithBack({ export function InputWithBack({
isExpanded, isExpanded,
setIsExpanded, setIsExpanded,
placeholder = "Search anything", placeholder = "Search anything",
value, value,
onChange, onChange,
disableAnimation = false, disableAnimation = false,
}: InputWithBackProps) { }: InputWithBackProps) {
const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null); const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null);
const [buttonOffset, setButtonOffset] = useState(-60); const [buttonOffset, setButtonOffset] = useState(-60);
const smoothTransition = { const smoothTransition = {
duration: disableAnimation ? 0 : 0.35, duration: disableAnimation ? 0 : 0.35,
ease: [0.25, 0.1, 0.25, 1] as const, ease: [0.25, 0.1, 0.25, 1] as const,
}; };
useEffect(() => { useEffect(() => {
if (containerRef) { if (containerRef) {
const rect = containerRef.getBoundingClientRect(); const rect = containerRef.getBoundingClientRect();
setButtonOffset(-rect.left - 48); setButtonOffset(-rect.left - 48);
} }
}, [containerRef]); }, [containerRef]);
return ( return (
<div ref={setContainerRef} className="relative w-full"> <div ref={setContainerRef} className="relative w-full">
<motion.div <motion.div
className="absolute top-1/2 left-0 z-10 -translate-y-1/2 cursor-pointer hover:opacity-75" className="absolute top-1/2 left-0 z-10 -translate-y-1/2 cursor-pointer hover:opacity-75"
initial={{ initial={{
x: isExpanded ? 0 : buttonOffset, x: isExpanded ? 0 : buttonOffset,
opacity: isExpanded ? 1 : 0.5, opacity: isExpanded ? 1 : 0.5,
}} }}
animate={{ animate={{
x: isExpanded ? 0 : buttonOffset, x: isExpanded ? 0 : buttonOffset,
opacity: isExpanded ? 1 : 0.5, opacity: isExpanded ? 1 : 0.5,
}} }}
transition={smoothTransition} transition={smoothTransition}
onClick={() => setIsExpanded(!isExpanded)} onClick={() => setIsExpanded(!isExpanded)}
> >
<Button <Button variant="outline" className="bg-accent !size-9 rounded-full">
variant="outline" <ArrowLeft />
className="bg-accent !size-9 rounded-full" </Button>
> </motion.div>
<ArrowLeft /> <div
</Button> className="relative flex-1"
</motion.div> style={{ marginLeft: "0px", paddingLeft: "0px" }}
<div >
className="relative flex-1" <motion.div
style={{ marginLeft: "0px", paddingLeft: "0px" }} className="relative"
> initial={{
<motion.div marginLeft: isExpanded ? 50 : 0,
className="relative" }}
initial={{ animate={{
marginLeft: isExpanded ? 50 : 0, marginLeft: isExpanded ? 50 : 0,
}} }}
animate={{ transition={smoothTransition}
marginLeft: isExpanded ? 50 : 0, >
}} <Search className="text-muted-foreground absolute top-1/2 left-3 size-4 -translate-y-1/2" />
transition={smoothTransition} <Input
> placeholder={placeholder}
<Search className="text-muted-foreground absolute top-1/2 left-3 size-4 -translate-y-1/2" /> className="bg-accent w-full pl-9"
<Input value={value}
placeholder={placeholder} onChange={(e) => onChange?.(e.target.value)}
className="bg-accent w-full pl-9" />
value={value} </motion.div>
onChange={(e) => onChange?.(e.target.value)} </div>
/> </div>
</motion.div> );
</div> }
</div>
);
}

View File

@ -8,17 +8,18 @@ import { forwardRef, type ComponentProps } from "react";
import { useState } from "react"; import { useState } from "react";
const inputVariants = cva( const inputVariants = cva(
"file:text-foreground placeholder:text-muted-foreground border-border bg-background flex w-full min-w-0 rounded-md border shadow-xs outline-none file:inline-flex file:border-0 file:bg-transparent file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 focus-visible:ring-4 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", "file:text-foreground placeholder:text-muted-foreground border-border bg-input flex w-full min-w-0 rounded-md border shadow-xs outline-none file:inline-flex file:border-0 file:bg-transparent file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 focus-visible:ring-0 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{ {
variants: { variants: {
variant: { variant: {
default: "selection:bg-primary selection:text-primary-foreground focus-visible:border-primary focus-visible:ring-primary/10", default: "selection:bg-primary selection:text-primary-foreground",
destructive: "selection:bg-destructive selection:text-destructive-foreground focus-visible:border-destructive focus-visible:ring-destructive/10", destructive:
"selection:bg-destructive selection:text-destructive-foreground focus-visible:border-destructive focus-visible:ring-destructive/10",
}, },
size: { size: {
default: default: "h-9 px-3 py-1 text-base file:h-7 file:text-sm md:text-sm",
"h-9 px-3 py-1 text-base file:h-7 file:text-sm md:text-sm", xs: "h-7 px-3 text-xs file:h-6 file:text-xs",
sm: "h-8 px-3 text-xs file:h-6 file:text-xs", sm: "h-8 px-3 text-sm file:h-6 file:text-xs",
lg: "h-10 px-4 text-base file:h-8 file:text-sm md:text-sm", lg: "h-10 px-4 text-base file:h-8 file:text-sm md:text-sm",
}, },
}, },
@ -102,7 +103,6 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
/> />
{showClear && ( {showClear && (
<Button <Button
type="button"
variant="text" variant="text"
size="icon" size="icon"
onMouseDown={(e) => { onMouseDown={(e) => {
@ -117,7 +117,6 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
)} )}
{showPasswordToggle && ( {showPasswordToggle && (
<Button <Button
type="button"
variant="text" variant="text"
size="icon" size="icon"
onClick={() => onShowPasswordChange?.(!showPassword)} onClick={() => onShowPasswordChange?.(!showPassword)}

View File

@ -0,0 +1,169 @@
"use client";
import { cn } from "@/utils/ui";
import { useRef, useState, type ComponentProps } from "react";
import { useFocusLock } from "@/hooks/use-focus-lock";
import { Button } from "@/components/ui/button";
import { HugeiconsIcon } from "@hugeicons/react";
import { ArrowTurnBackwardIcon } from "@hugeicons/core-free-icons";
const DRAG_SENSITIVITIES = {
default: 1,
slow: 0.5,
} as const;
type DragSensitivity = "default" | "slow";
interface NumberFieldProps
extends Omit<ComponentProps<"input">, "size" | "type"> {
icon?: React.ReactNode;
dragSensitivity?: DragSensitivity;
onScrub?: (value: number) => void;
onScrubEnd?: () => void;
allowExpressions?: boolean;
onReset?: () => void;
isDefault?: boolean;
}
function NumberField({
className,
icon,
disabled,
dragSensitivity = "default",
onScrub,
onScrubEnd,
value,
allowExpressions = true,
onKeyDown,
onFocus,
onBlur,
onMouseDown,
onReset,
isDefault = false,
ref,
...props
}: NumberFieldProps & { ref?: React.Ref<HTMLInputElement> }) {
const iconRef = useRef<HTMLSpanElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const startValueRef = useRef(0);
const cumulativeDeltaRef = useRef(0);
const [isInputFocused, setIsInputFocused] = useState(false);
const { containerRef: wrapperRef } = useFocusLock<HTMLDivElement>({
isActive: isInputFocused,
onDismiss: () => inputRef.current?.blur(),
cursor: "text",
allowSelector: "input, textarea, [contenteditable]",
});
const handleIconPointerDown = (event: React.PointerEvent) => {
if (!onScrub || disabled || event.button !== 0) return;
const parsed = parseFloat(String(value ?? "0"));
startValueRef.current = Number.isNaN(parsed) ? 0 : parsed;
cumulativeDeltaRef.current = 0;
let hasReceivedFirstMove = false;
iconRef.current?.requestPointerLock();
const handlePointerMove = (moveEvent: PointerEvent) => {
// first movementX after pointer lock often contains a bogus warp delta
if (!hasReceivedFirstMove) {
hasReceivedFirstMove = true;
return;
}
cumulativeDeltaRef.current += moveEvent.movementX;
const newValue =
startValueRef.current +
cumulativeDeltaRef.current * DRAG_SENSITIVITIES[dragSensitivity];
onScrub(newValue);
};
const handlePointerUp = () => {
document.removeEventListener("pointermove", handlePointerMove);
document.removeEventListener("pointerup", handlePointerUp);
document.exitPointerLock();
onScrubEnd?.();
};
document.addEventListener("pointermove", handlePointerMove);
document.addEventListener("pointerup", handlePointerUp);
};
const canScrub = Boolean(icon && onScrub);
return (
<div
ref={wrapperRef}
className={cn(
"border-border bg-accent flex h-7 w-full min-w-0 items-center rounded-md border text-sm outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 focus-within:border-primary focus-within:ring-0 focus-within:ring-primary/10 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
disabled && "pointer-events-none cursor-not-allowed opacity-50",
className,
)}
>
{icon && (
<span
ref={iconRef}
className={cn(
"text-muted-foreground [&_svg]:!size-3.5 shrink-0 select-none pl-2.5 text-sm leading-none",
canScrub && "cursor-ew-resize",
)}
onPointerDown={canScrub ? handleIconPointerDown : undefined}
>
{icon}
</span>
)}
<input
type={allowExpressions ? "text" : "number"}
inputMode={allowExpressions ? "decimal" : undefined}
ref={inputRef}
disabled={disabled}
value={value}
className={cn(
"min-w-0 flex-1 text-sm leading-none bg-transparent outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none",
icon ? "px-1.5" : "pl-2.5",
onReset ? "pr-0" : "pr-2.5",
)}
onMouseDown={(event) => {
const inputElement = event.currentTarget;
const shouldPreventNativeCaretPlacement =
event.button === 0 && document.activeElement !== inputElement;
if (shouldPreventNativeCaretPlacement) {
event.preventDefault();
inputElement.focus();
inputElement.select();
}
onMouseDown?.(event);
}}
onFocus={(event) => {
setIsInputFocused(true);
event.currentTarget.select();
onFocus?.(event);
}}
onKeyDown={(event) => {
const shouldBlurInput =
event.key === "Enter" || event.key === "Escape";
if (shouldBlurInput) event.currentTarget.blur();
onKeyDown?.(event);
}}
onBlur={(event) => {
setIsInputFocused(false);
onBlur?.(event);
}}
{...props}
/>
{onReset && !isDefault && (
<div className="shrink-0 pr-2 flex items-center">
<Button
variant="text"
size="text"
aria-label="Reset to default"
onClick={onReset}
>
<HugeiconsIcon icon={ArrowTurnBackwardIcon} className="!size-3.5" />
</Button>
</div>
)}
</div>
);
}
export { NumberField };

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