diff options
| author | Maze <167211895+mazeincoding@users.noreply.github.com> | 2026-02-23 03:24:02 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-02-23 03:24:02 +0100 |
| commit | 93d1e3383ce47e45157d70f9ba6251239dae6bc2 (patch) | |
| tree | 1e33a2d7533b65f0e7c8927f03905a1a99937bd1 /apps/web | |
| parent | fca99d6126c31fbb18ed9f1034cee6f940b040e8 (diff) | |
| download | opencut-93d1e3383ce47e45157d70f9ba6251239dae6bc2.tar.gz | |
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
Diffstat (limited to 'apps/web')
199 files changed, 24866 insertions, 7128 deletions
diff --git a/apps/web/.gitignore b/apps/web/.gitignore index dc069c57..4b8a9fe9 100644 --- a/apps/web/.gitignore +++ b/apps/web/.gitignore @@ -5,4 +5,6 @@ .env* !.env.example -.next/
\ No newline at end of file +.next/ + +.font-cache/
\ No newline at end of file diff --git a/apps/web/components.json b/apps/web/components.json index 3289f237..5bcedb31 100644 --- a/apps/web/components.json +++ b/apps/web/components.json @@ -1,21 +1,21 @@ { - "$schema": "https://ui.shadcn.com/schema.json", - "style": "new-york", - "rsc": true, - "tsx": true, - "tailwind": { - "config": "", - "css": "src/app/globals.css", - "baseColor": "neutral", - "cssVariables": true, - "prefix": "" - }, - "aliases": { - "components": "@/components", - "utils": "@/lib/utils", - "ui": "@/components/ui", - "lib": "@/lib", - "hooks": "@/hooks" - }, - "iconLibrary": "lucide" + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" } diff --git a/apps/web/drizzle.config.ts b/apps/web/drizzle.config.ts index 2d53d6b9..be2a59fc 100644 --- a/apps/web/drizzle.config.ts +++ b/apps/web/drizzle.config.ts @@ -4,20 +4,20 @@ import { webEnv } from "@opencut/env/web"; // Load the right env file based on environment if (webEnv.NODE_ENV === "production") { - dotenv.config({ path: ".env.production" }); + dotenv.config({ path: ".env.production" }); } else { - dotenv.config({ path: ".env.local" }); + dotenv.config({ path: ".env.local" }); } export default { - schema: "./src/schema.ts", - dialect: "postgresql", - migrations: { - table: "drizzle_migrations", - }, - dbCredentials: { - url: webEnv.DATABASE_URL, - }, - out: "./migrations", - strict: webEnv.NODE_ENV === "production", + schema: "./src/schema.ts", + dialect: "postgresql", + migrations: { + table: "drizzle_migrations", + }, + dbCredentials: { + url: webEnv.DATABASE_URL, + }, + out: "./migrations", + strict: webEnv.NODE_ENV === "production", } satisfies Config; diff --git a/apps/web/migrations/meta/0000_snapshot.json b/apps/web/migrations/meta/0000_snapshot.json index 8d505274..2f6c9b30 100644 --- a/apps/web/migrations/meta/0000_snapshot.json +++ b/apps/web/migrations/meta/0000_snapshot.json @@ -1,344 +1,344 @@ { - "id": "33a6742f-89da-4ac5-958f-421aa1cf9bd6", - "prevId": "00000000-0000-0000-0000-000000000000", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.accounts": { - "name": "accounts", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "account_id": { - "name": "account_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "provider_id": { - "name": "provider_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "access_token": { - "name": "access_token", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "refresh_token": { - "name": "refresh_token", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "id_token": { - "name": "id_token", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "access_token_expires_at": { - "name": "access_token_expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "refresh_token_expires_at": { - "name": "refresh_token_expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "scope": { - "name": "scope", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "password": { - "name": "password", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": { - "accounts_user_id_users_id_fk": { - "name": "accounts_user_id_users_id_fk", - "tableFrom": "accounts", - "tableTo": "users", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": true - }, - "public.sessions": { - "name": "sessions", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "token": { - "name": "token", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "ip_address": { - "name": "ip_address", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "user_agent": { - "name": "user_agent", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": { - "sessions_user_id_users_id_fk": { - "name": "sessions_user_id_users_id_fk", - "tableFrom": "sessions", - "tableTo": "users", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "sessions_token_unique": { - "name": "sessions_token_unique", - "nullsNotDistinct": false, - "columns": ["token"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": true - }, - "public.users": { - "name": "users", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "email": { - "name": "email", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "email_verified": { - "name": "email_verified", - "type": "boolean", - "primaryKey": false, - "notNull": true - }, - "image": { - "name": "image", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "users_email_unique": { - "name": "users_email_unique", - "nullsNotDistinct": false, - "columns": ["email"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": true - }, - "public.verifications": { - "name": "verifications", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "identifier": { - "name": "identifier", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "value": { - "name": "value", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": true - }, - "public.waitlist": { - "name": "waitlist", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "email": { - "name": "email", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "waitlist_email_unique": { - "name": "waitlist_email_unique", - "nullsNotDistinct": false, - "columns": ["email"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": true - } - }, - "enums": {}, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } + "id": "33a6742f-89da-4ac5-958f-421aa1cf9bd6", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } } diff --git a/apps/web/migrations/meta/_journal.json b/apps/web/migrations/meta/_journal.json index c7d30f16..a3cb0863 100644 --- a/apps/web/migrations/meta/_journal.json +++ b/apps/web/migrations/meta/_journal.json @@ -1,13 +1,13 @@ { - "version": "7", - "dialect": "postgresql", - "entries": [ - { - "idx": 0, - "version": "7", - "when": 1750753385927, - "tag": "0000_brainy_saracen", - "breakpoints": true - } - ] + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1750753385927, + "tag": "0000_brainy_saracen", + "breakpoints": true + } + ] } diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index cd79efb7..fa1ac7f5 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -2,48 +2,48 @@ import type { NextConfig } from "next"; import { withBotId } from "botid/next/config"; const nextConfig: NextConfig = { - compiler: { - removeConsole: process.env.NODE_ENV === "production", - }, - reactStrictMode: true, - productionBrowserSourceMaps: true, - output: "standalone", - images: { - remotePatterns: [ - { - protocol: "https", - hostname: "plus.unsplash.com", - }, - { - protocol: "https", - hostname: "images.unsplash.com", - }, - { - protocol: "https", - hostname: "images.marblecms.com", - }, - { - protocol: "https", - hostname: "lh3.googleusercontent.com", - }, - { - protocol: "https", - hostname: "avatars.githubusercontent.com", - }, - { - protocol: "https", - hostname: "api.iconify.design", - }, - { - protocol: "https", - hostname: "api.simplesvg.com", - }, - { - protocol: "https", - hostname: "api.unisvg.com", - }, - ], - }, + compiler: { + removeConsole: process.env.NODE_ENV === "production", + }, + reactStrictMode: true, + productionBrowserSourceMaps: true, + output: "standalone", + images: { + remotePatterns: [ + { + protocol: "https", + hostname: "plus.unsplash.com", + }, + { + protocol: "https", + hostname: "images.unsplash.com", + }, + { + protocol: "https", + hostname: "images.marblecms.com", + }, + { + protocol: "https", + hostname: "lh3.googleusercontent.com", + }, + { + protocol: "https", + hostname: "avatars.githubusercontent.com", + }, + { + protocol: "https", + hostname: "api.iconify.design", + }, + { + protocol: "https", + hostname: "api.simplesvg.com", + }, + { + protocol: "https", + hostname: "api.unisvg.com", + }, + ], + }, }; export default withBotId(nextConfig); diff --git a/apps/web/package.json b/apps/web/package.json index 31582be1..9a57a379 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,98 +1,107 @@ { - "name": "@opencut/web", - "version": "0.1.0", - "private": true, - "packageManager": "bun@1.2.18", - "scripts": { - "dev": "next dev --turbopack", - "build": "next build", - "start": "next start", - "lint": "biome check src/", - "lint:fix": "biome check src/ --write", - "format": "biome format src/ --write", - "db:generate": "drizzle-kit generate", - "db:migrate": "drizzle-kit migrate", - "db:push:local": "cross-env NODE_ENV=development drizzle-kit push", - "db:push:prod": "cross-env NODE_ENV=production drizzle-kit push" - }, - "dependencies": { - "@ffmpeg/core": "^0.12.10", - "@ffmpeg/ffmpeg": "^0.12.15", - "@ffmpeg/util": "^0.12.2", - "@hello-pangea/dnd": "^18.0.1", - "@hookform/resolvers": "^3.9.1", - "@hugeicons/core-free-icons": "^3.1.1", - "@hugeicons/react": "^1.1.4", - "@huggingface/transformers": "^3.8.1", - "@opencut/env": "workspace:*", - "@opencut/ui": "workspace:*", - "@radix-ui/react-dialog": "^1.1.15", - "@radix-ui/react-select": "^2.2.6", - "@radix-ui/react-separator": "^1.1.8", - "@radix-ui/react-slot": "^1.2.4", - "@radix-ui/react-tooltip": "^1.2.8", - "@upstash/ratelimit": "^2.0.6", - "@upstash/redis": "^1.35.4", - "@vercel/analytics": "^1.4.1", - "aws4fetch": "^1.0.20", - "better-auth": "^1.2.7", - "botid": "^1.4.2", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", - "cmdk": "^1.0.0", - "dayjs": "^1.11.13", - "drizzle-orm": "^0.44.2", - "embla-carousel-react": "^8.5.1", - "eventemitter3": "^5.0.1", - "feed": "^5.1.0", - "input-otp": "^1.4.1", - "lucide-react": "^0.562.0", - "mediabunny": "^1.29.1", - "motion": "^12.18.1", - "nanoid": "^5.1.5", - "next": "16.1.3", - "next-themes": "^0.4.4", - "pg": "^8.16.2", - "postgres": "^3.4.5", - "radix-ui": "^1.4.2", - "react": "^19.0.0", - "react-day-picker": "^8.10.1", - "react-dom": "^19.0.0", - "react-hook-form": "^7.54.0", - "react-icons": "^5.4.0", - "react-markdown": "^10.1.0", - "react-phone-number-input": "^3.4.11", - "react-resizable-panels": "^2.1.7", - "recharts": "^2.14.1", - "rehype-autolink-headings": "^7.1.0", - "rehype-parse": "^9.0.1", - "rehype-sanitize": "^6.0.0", - "rehype-slug": "^6.0.0", - "rehype-stringify": "^10.0.1", - "sonner": "^1.7.1", - "tailwind-merge": "^2.5.5", - "tailwindcss-animate": "^1.0.7", - "unified": "^11.0.5", - "use-deep-compare-effect": "^1.8.1", - "vaul": "^1.1.1", - "wavesurfer.js": "^7.9.8", - "zod": "^3.25.67", - "zustand": "^5.0.2" - }, - "devDependencies": { - "@tailwindcss/postcss": "^4.1.11", - "@tailwindcss/typography": "^0.5.16", - "@types/bun": "latest", - "@types/node": "^24.2.1", - "@types/pg": "^8.15.4", - "@types/react": "^19", - "@types/react-dom": "^19", - "cross-env": "^7.0.3", - "drizzle-kit": "^0.31.4", - "dotenv": "^16.5.0", - "postcss": "^8", - "tailwindcss": "^4.1.11", - "tsx": "^4.7.1", - "typescript": "^5.8.3" - } + "name": "@opencut/web", + "version": "0.1.0", + "private": true, + "packageManager": "bun@1.2.18", + "scripts": { + "dev": "next dev --turbopack", + "build": "next build", + "start": "next start", + "lint": "biome check src/", + "lint:fix": "biome check src/ --write", + "format": "biome format src/ --write", + "db:generate": "drizzle-kit generate", + "db:migrate": "drizzle-kit migrate", + "db:push:local": "cross-env NODE_ENV=development drizzle-kit push", + "db:push:prod": "cross-env NODE_ENV=production drizzle-kit push" + }, + "dependencies": { + "@ffmpeg/core": "^0.12.10", + "@ffmpeg/ffmpeg": "^0.12.15", + "@ffmpeg/util": "^0.12.2", + "@hello-pangea/dnd": "^18.0.1", + "@hookform/resolvers": "^3.9.1", + "@hugeicons/core-free-icons": "^3.1.1", + "@hugeicons/react": "^1.1.4", + "@huggingface/transformers": "^3.8.1", + "@opencut/env": "workspace:*", + "@opencut/ui": "workspace:*", + "@radix-ui/react-accordion": "^1.2.12", + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-primitive": "^2.1.4", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-tooltip": "^1.2.8", + "@types/culori": "^4.0.1", + "@upstash/ratelimit": "^2.0.6", + "@upstash/redis": "^1.35.4", + "@vercel/analytics": "^1.4.1", + "aws4fetch": "^1.0.20", + "better-auth": "^1.2.7", + "botid": "^1.4.2", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.0.0", + "culori": "^4.0.2", + "dayjs": "^1.11.13", + "drizzle-orm": "^0.44.2", + "embla-carousel-react": "^8.5.1", + "eventemitter3": "^5.0.1", + "feed": "^5.1.0", + "input-otp": "^1.4.1", + "lucide-react": "^0.562.0", + "mediabunny": "^1.29.1", + "motion": "^12.18.1", + "nanoid": "^5.1.5", + "next": "16.1.3", + "next-themes": "^0.4.4", + "pg": "^8.16.2", + "postgres": "^3.4.5", + "radix-ui": "^1.4.3", + "react": "^19.0.0", + "react-day-picker": "^8.10.1", + "react-dom": "^19.0.0", + "react-hook-form": "^7.54.0", + "react-icons": "^5.4.0", + "react-markdown": "^10.1.0", + "react-phone-number-input": "^3.4.11", + "react-resizable-panels": "^2.1.7", + "react-window": "^2.2.7", + "recharts": "^2.14.1", + "rehype-autolink-headings": "^7.1.0", + "rehype-parse": "^9.0.1", + "rehype-sanitize": "^6.0.0", + "rehype-slug": "^6.0.0", + "rehype-stringify": "^10.0.1", + "sonner": "^1.7.1", + "tailwind-merge": "^2.5.5", + "tailwindcss-animate": "^1.0.7", + "unified": "^11.0.5", + "use-deep-compare-effect": "^1.8.1", + "vaul": "^1.1.2", + "wavesurfer.js": "^7.9.8", + "zod": "^3.25.67", + "zustand": "^5.0.2" + }, + "devDependencies": { + "@napi-rs/canvas": "^0.1.92", + "@tailwindcss/postcss": "^4.1.11", + "@tailwindcss/typography": "^0.5.16", + "@types/bun": "latest", + "@types/node": "^24.2.1", + "@types/pg": "^8.15.4", + "@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" + } } diff --git a/apps/web/postcss.config.mjs b/apps/web/postcss.config.mjs index 79bcf135..6ba14df9 100644 --- a/apps/web/postcss.config.mjs +++ b/apps/web/postcss.config.mjs @@ -1,8 +1,8 @@ /** @type {import('postcss-load-config').Config} */ const config = { - plugins: { - "@tailwindcss/postcss": {}, - }, + plugins: { + "@tailwindcss/postcss": {}, + }, }; export default config; diff --git a/apps/web/public/countries.json b/apps/web/public/countries.json new file mode 100644 index 00000000..8d53cfae --- /dev/null +++ b/apps/web/public/countries.json @@ -0,0 +1,1794 @@ +[ + { + "name": "Andorra", + "code": "AD", + "languages": ["catalan"], + "flag_colors": ["blue", "yellow", "red"], + "region": "Western Europe" + }, + { + "name": "United Arab Emirates", + "code": "AE", + "languages": ["arabic"], + "flag_colors": ["red", "green", "white", "black"], + "region": "Middle East" + }, + { + "name": "Afghanistan", + "code": "AF", + "languages": ["dari", "pashto"], + "flag_colors": ["black", "red", "green"], + "region": "South Asia" + }, + { + "name": "Antigua and Barbuda", + "code": "AG", + "languages": ["english"], + "flag_colors": ["red", "black", "blue", "white", "yellow"], + "region": "Caribbean" + }, + { + "name": "Anguilla", + "code": "AI", + "languages": ["english"], + "flag_colors": ["blue", "white", "orange"], + "region": "Caribbean" + }, + { + "name": "Albania", + "code": "AL", + "languages": ["albanian"], + "flag_colors": ["red", "black"], + "region": "Southern Europe" + }, + { + "name": "Armenia", + "code": "AM", + "languages": ["armenian"], + "flag_colors": ["red", "blue", "orange"], + "region": "Middle East" + }, + { + "name": "Angola", + "code": "AO", + "languages": ["portuguese"], + "flag_colors": ["red", "black", "yellow"], + "region": "Sub-Saharan Africa" + }, + { + "name": "Antarctica", + "code": "AQ", + "languages": [], + "flag_colors": [], + "region": "Antarctica" + }, + { + "name": "Argentina", + "code": "AR", + "languages": ["spanish"], + "flag_colors": ["blue", "white", "yellow"], + "region": "South America" + }, + { + "name": "American Samoa", + "code": "AS", + "languages": ["english", "samoan"], + "flag_colors": ["red", "white", "blue"], + "region": "Oceania" + }, + { + "name": "Austria", + "code": "AT", + "languages": ["german"], + "flag_colors": ["red", "white"], + "region": "Western Europe" + }, + { + "name": "Australia", + "code": "AU", + "languages": ["english"], + "flag_colors": ["blue", "white", "red"], + "region": "Oceania" + }, + { + "name": "Aruba", + "code": "AW", + "languages": ["dutch", "papiamento"], + "flag_colors": ["blue", "yellow", "red", "white"], + "region": "Caribbean" + }, + { + "name": "Aland Islands", + "code": "AX", + "languages": ["swedish"], + "flag_colors": ["blue", "yellow", "red"], + "region": "Northern Europe" + }, + { + "name": "Azerbaijan", + "code": "AZ", + "languages": ["azerbaijani"], + "flag_colors": ["blue", "red", "green", "white"], + "region": "Middle East" + }, + { + "name": "Bosnia and Herzegovina", + "code": "BA", + "languages": ["bosnian", "croatian", "serbian"], + "flag_colors": ["blue", "yellow", "white"], + "region": "Southern Europe" + }, + { + "name": "Barbados", + "code": "BB", + "languages": ["english"], + "flag_colors": ["blue", "yellow", "black"], + "region": "Caribbean" + }, + { + "name": "Bangladesh", + "code": "BD", + "languages": ["bengali"], + "flag_colors": ["green", "red"], + "region": "South Asia" + }, + { + "name": "Belgium", + "code": "BE", + "languages": ["french", "dutch", "german"], + "flag_colors": ["black", "yellow", "red"], + "region": "Western Europe" + }, + { + "name": "Burkina Faso", + "code": "BF", + "languages": ["french"], + "flag_colors": ["red", "green", "yellow"], + "region": "Sub-Saharan Africa" + }, + { + "name": "Bulgaria", + "code": "BG", + "languages": ["bulgarian"], + "flag_colors": ["white", "green", "red"], + "region": "Eastern Europe" + }, + { + "name": "Bahrain", + "code": "BH", + "languages": ["arabic"], + "flag_colors": ["red", "white"], + "region": "Middle East" + }, + { + "name": "Burundi", + "code": "BI", + "languages": ["kirundi", "french", "english"], + "flag_colors": ["red", "green", "white"], + "region": "Sub-Saharan Africa" + }, + { + "name": "Benin", + "code": "BJ", + "languages": ["french"], + "flag_colors": ["green", "yellow", "red"], + "region": "Sub-Saharan Africa" + }, + { + "name": "Saint Barthelemy", + "code": "BL", + "languages": ["french"], + "flag_colors": ["blue", "white", "red"], + "region": "Caribbean" + }, + { + "name": "Bermuda", + "code": "BM", + "languages": ["english"], + "flag_colors": ["blue", "white", "red"], + "region": "North Atlantic" + }, + { + "name": "Brunei", + "code": "BN", + "languages": ["malay"], + "flag_colors": ["yellow", "white", "black", "red"], + "region": "Southeast Asia" + }, + { + "name": "Bolivia", + "code": "BO", + "languages": ["spanish", "quechua", "aymara"], + "flag_colors": ["red", "yellow", "green"], + "region": "South America" + }, + { + "name": "Sint Eustatius", + "code": "BQ-SE", + "languages": ["dutch", "english"], + "flag_colors": ["blue", "white", "red", "yellow"], + "region": "Caribbean" + }, + { + "name": "Caribbean Netherlands", + "code": "BQ", + "languages": ["dutch", "papiamento", "english"], + "flag_colors": ["blue", "white", "red"], + "region": "Caribbean" + }, + { + "name": "Brazil", + "code": "BR", + "languages": ["portuguese"], + "flag_colors": ["green", "yellow", "blue", "white"], + "region": "South America" + }, + { + "name": "Bahamas", + "code": "BS", + "languages": ["english"], + "flag_colors": ["blue", "yellow", "black"], + "region": "Caribbean" + }, + { + "name": "Bhutan", + "code": "BT", + "languages": ["dzongkha"], + "flag_colors": ["yellow", "orange", "white"], + "region": "South Asia" + }, + { + "name": "Bouvet Island", + "code": "BV", + "languages": [], + "flag_colors": ["red", "white", "blue"], + "region": "Antarctica" + }, + { + "name": "Botswana", + "code": "BW", + "languages": ["english", "tswana"], + "flag_colors": ["blue", "black", "white"], + "region": "Sub-Saharan Africa" + }, + { + "name": "Belarus", + "code": "BY", + "languages": ["belarusian", "russian"], + "flag_colors": ["red", "green", "white"], + "region": "Eastern Europe" + }, + { + "name": "Belize", + "code": "BZ", + "languages": ["english"], + "flag_colors": ["blue", "red", "white"], + "region": "Central America" + }, + { + "name": "Canada", + "code": "CA", + "languages": ["english", "french"], + "flag_colors": ["red", "white"], + "region": "North America" + }, + { + "name": "Cocos (Keeling) Islands", + "code": "CC", + "languages": ["english"], + "flag_colors": ["green", "red", "yellow", "white"], + "region": "Southeast Asia" + }, + { + "name": "Democratic Republic of the Congo", + "code": "CD", + "languages": ["french", "lingala", "swahili", "tshiluba", "kikongo"], + "flag_colors": ["blue", "red", "yellow"], + "region": "Sub-Saharan Africa" + }, + { + "name": "Central African Republic", + "code": "CF", + "languages": ["french", "sango"], + "flag_colors": ["blue", "white", "green", "yellow", "red"], + "region": "Sub-Saharan Africa" + }, + { + "name": "Republic of the Congo", + "code": "CG", + "languages": ["french", "lingala", "kituba"], + "flag_colors": ["green", "yellow", "red"], + "region": "Sub-Saharan Africa" + }, + { + "name": "Switzerland", + "code": "CH", + "languages": ["german", "french", "italian"], + "flag_colors": ["red", "white"], + "region": "Western Europe" + }, + { + "name": "Cote d'Ivoire", + "code": "CI", + "languages": ["french"], + "flag_colors": ["orange", "white", "green"], + "region": "Sub-Saharan Africa" + }, + { + "name": "Cook Islands", + "code": "CK", + "languages": ["english", "cook islands maori"], + "flag_colors": ["blue", "white"], + "region": "Oceania" + }, + { + "name": "Chile", + "code": "CL", + "languages": ["spanish"], + "flag_colors": ["white", "red", "blue"], + "region": "South America" + }, + { + "name": "Cameroon", + "code": "CM", + "languages": ["english", "french"], + "flag_colors": ["green", "red", "yellow"], + "region": "Sub-Saharan Africa" + }, + { + "name": "China", + "code": "CN", + "languages": ["mandarin"], + "flag_colors": ["red", "yellow"], + "region": "East Asia" + }, + { + "name": "Colombia", + "code": "CO", + "languages": ["spanish"], + "flag_colors": ["yellow", "blue", "red"], + "region": "South America" + }, + { + "name": "Costa Rica", + "code": "CR", + "languages": ["spanish"], + "flag_colors": ["blue", "white", "red"], + "region": "Central America" + }, + { + "name": "Cuba", + "code": "CU", + "languages": ["spanish"], + "flag_colors": ["blue", "white", "red"], + "region": "Caribbean" + }, + { + "name": "Cape Verde", + "code": "CV", + "languages": ["portuguese"], + "flag_colors": ["blue", "white", "red", "yellow"], + "region": "Sub-Saharan Africa" + }, + { + "name": "Curacao", + "code": "CW", + "languages": ["dutch", "papiamento", "english"], + "flag_colors": ["blue", "yellow", "white"], + "region": "Caribbean" + }, + { + "name": "Christmas Island", + "code": "CX", + "languages": ["english"], + "flag_colors": ["green", "blue", "yellow", "white"], + "region": "Oceania" + }, + { + "name": "Cyprus", + "code": "CY", + "languages": ["greek", "turkish"], + "flag_colors": ["white", "orange", "green"], + "region": "Middle East" + }, + { + "name": "Czechia", + "code": "CZ", + "languages": ["czech"], + "flag_colors": ["white", "red", "blue"], + "region": "Eastern Europe" + }, + { + "name": "Germany", + "code": "DE", + "languages": ["german"], + "flag_colors": ["black", "red", "yellow"], + "region": "Western Europe" + }, + { + "name": "Djibouti", + "code": "DJ", + "languages": ["arabic", "french", "afar", "somali"], + "flag_colors": ["blue", "green", "white", "red"], + "region": "Sub-Saharan Africa" + }, + { + "name": "Denmark", + "code": "DK", + "languages": ["danish"], + "flag_colors": ["red", "white"], + "region": "Northern Europe" + }, + { + "name": "Dominica", + "code": "DM", + "languages": ["english"], + "flag_colors": ["green", "yellow", "black", "white", "red"], + "region": "Caribbean" + }, + { + "name": "Dominican Republic", + "code": "DO", + "languages": ["spanish"], + "flag_colors": ["red", "white", "blue"], + "region": "Caribbean" + }, + { + "name": "Algeria", + "code": "DZ", + "languages": ["arabic", "tamazight"], + "flag_colors": ["green", "white", "red"], + "region": "North Africa" + }, + { + "name": "Ecuador", + "code": "EC", + "languages": ["spanish"], + "flag_colors": ["yellow", "blue", "red"], + "region": "South America" + }, + { + "name": "Estonia", + "code": "EE", + "languages": ["estonian"], + "flag_colors": ["blue", "black", "white"], + "region": "Northern Europe" + }, + { + "name": "Egypt", + "code": "EG", + "languages": ["arabic"], + "flag_colors": ["red", "white", "black", "yellow"], + "region": "North Africa" + }, + { + "name": "Western Sahara", + "code": "EH", + "languages": ["arabic", "spanish"], + "flag_colors": ["black", "white", "green", "red"], + "region": "North Africa" + }, + { + "name": "Eritrea", + "code": "ER", + "languages": ["tigrinya", "arabic", "english"], + "flag_colors": ["red", "green", "blue", "yellow"], + "region": "Sub-Saharan Africa" + }, + { + "name": "Spain", + "code": "ES", + "languages": ["spanish"], + "flag_colors": ["red", "yellow"], + "region": "Southern Europe" + }, + { + "name": "Ethiopia", + "code": "ET", + "languages": ["amharic", "oromo"], + "flag_colors": ["green", "yellow", "red", "blue"], + "region": "Sub-Saharan Africa" + }, + { + "name": "European Union", + "code": "EU", + "languages": ["english", "french", "german"], + "flag_colors": ["blue", "yellow"], + "region": "Western Europe" + }, + { + "name": "Finland", + "code": "FI", + "languages": ["finnish", "swedish"], + "flag_colors": ["white", "blue"], + "region": "Northern Europe" + }, + { + "name": "Fiji", + "code": "FJ", + "languages": ["english", "fijian", "hindi"], + "flag_colors": ["blue", "white", "red", "yellow"], + "region": "Oceania" + }, + { + "name": "Falkland Islands", + "code": "FK", + "languages": ["english"], + "flag_colors": ["blue", "white", "red", "yellow"], + "region": "South America" + }, + { + "name": "Micronesia", + "code": "FM", + "languages": ["english"], + "flag_colors": ["blue", "white"], + "region": "Oceania" + }, + { + "name": "Faroe Islands", + "code": "FO", + "languages": ["faroese", "danish"], + "flag_colors": ["white", "red", "blue"], + "region": "Northern Europe" + }, + { + "name": "France", + "code": "FR", + "languages": ["french"], + "flag_colors": ["blue", "white", "red"], + "region": "Western Europe" + }, + { + "name": "Gabon", + "code": "GA", + "languages": ["french"], + "flag_colors": ["green", "yellow", "blue"], + "region": "Sub-Saharan Africa" + }, + { + "name": "England", + "code": "GB-ENG", + "languages": ["english"], + "flag_colors": ["red", "white"], + "region": "Western Europe" + }, + { + "name": "Northern Ireland", + "code": "GB-NIR", + "languages": ["english", "irish"], + "flag_colors": ["white", "red"], + "region": "Western Europe" + }, + { + "name": "Scotland", + "code": "GB-SCT", + "languages": ["english", "scottish gaelic"], + "flag_colors": ["blue", "white"], + "region": "Western Europe" + }, + { + "name": "Wales", + "code": "GB-WLS", + "languages": ["english", "welsh"], + "flag_colors": ["green", "white", "red"], + "region": "Western Europe" + }, + { + "name": "United Kingdom", + "code": "GB", + "languages": ["english"], + "flag_colors": ["red", "white", "blue"], + "region": "Western Europe" + }, + { + "name": "Grenada", + "code": "GD", + "languages": ["english"], + "flag_colors": ["red", "yellow", "green", "black"], + "region": "Caribbean" + }, + { + "name": "Georgia", + "code": "GE", + "languages": ["georgian"], + "flag_colors": ["white", "red"], + "region": "Middle East" + }, + { + "name": "French Guiana", + "code": "GF", + "languages": ["french"], + "flag_colors": ["green", "yellow", "red"], + "region": "South America" + }, + { + "name": "Guernsey", + "code": "GG", + "languages": ["english", "french"], + "flag_colors": ["white", "red", "yellow"], + "region": "Western Europe" + }, + { + "name": "Ghana", + "code": "GH", + "languages": ["english"], + "flag_colors": ["red", "yellow", "green", "black"], + "region": "Sub-Saharan Africa" + }, + { + "name": "Gibraltar", + "code": "GI", + "languages": ["english"], + "flag_colors": ["red", "white", "yellow"], + "region": "Southern Europe" + }, + { + "name": "Greenland", + "code": "GL", + "languages": ["kalaallisut", "danish"], + "flag_colors": ["red", "white"], + "region": "North America" + }, + { + "name": "Gambia", + "code": "GM", + "languages": ["english"], + "flag_colors": ["red", "blue", "green", "white"], + "region": "Sub-Saharan Africa" + }, + { + "name": "Guinea", + "code": "GN", + "languages": ["french"], + "flag_colors": ["red", "yellow", "green"], + "region": "Sub-Saharan Africa" + }, + { + "name": "Guadeloupe", + "code": "GP", + "languages": ["french"], + "flag_colors": ["blue", "white", "red"], + "region": "Caribbean" + }, + { + "name": "Equatorial Guinea", + "code": "GQ", + "languages": ["spanish", "french", "portuguese"], + "flag_colors": ["green", "white", "red", "blue", "yellow"], + "region": "Sub-Saharan Africa" + }, + { + "name": "Greece", + "code": "GR", + "languages": ["greek"], + "flag_colors": ["blue", "white"], + "region": "Southern Europe" + }, + { + "name": "South Georgia and the South Sandwich Islands", + "code": "GS", + "languages": [], + "flag_colors": ["blue", "white", "yellow"], + "region": "Atlantic Ocean" + }, + { + "name": "Guatemala", + "code": "GT", + "languages": ["spanish"], + "flag_colors": ["blue", "white"], + "region": "Central America" + }, + { + "name": "Guam", + "code": "GU", + "languages": ["english", "chamorro"], + "flag_colors": ["blue", "red"], + "region": "Oceania" + }, + { + "name": "Guinea-Bissau", + "code": "GW", + "languages": ["portuguese"], + "flag_colors": ["red", "yellow", "green", "black"], + "region": "Sub-Saharan Africa" + }, + { + "name": "Guyana", + "code": "GY", + "languages": ["english"], + "flag_colors": ["green", "yellow", "red", "black", "white"], + "region": "South America" + }, + { + "name": "Hong Kong", + "code": "HK", + "languages": ["cantonese", "english"], + "flag_colors": ["red", "white"], + "region": "East Asia" + }, + { + "name": "Heard Island and McDonald Islands", + "code": "HM", + "languages": [], + "flag_colors": ["blue", "white", "red"], + "region": "Oceania" + }, + { + "name": "Honduras", + "code": "HN", + "languages": ["spanish"], + "flag_colors": ["blue", "white"], + "region": "Central America" + }, + { + "name": "Croatia", + "code": "HR", + "languages": ["croatian"], + "flag_colors": ["red", "white", "blue"], + "region": "Southern Europe" + }, + { + "name": "Haiti", + "code": "HT", + "languages": ["haitian creole", "french"], + "flag_colors": ["blue", "red", "white"], + "region": "Caribbean" + }, + { + "name": "Hungary", + "code": "HU", + "languages": ["hungarian"], + "flag_colors": ["red", "white", "green"], + "region": "Eastern Europe" + }, + { + "name": "Indonesia", + "code": "ID", + "languages": ["indonesian"], + "flag_colors": ["red", "white"], + "region": "Southeast Asia" + }, + { + "name": "Ireland", + "code": "IE", + "languages": ["english", "irish"], + "flag_colors": ["green", "white", "orange"], + "region": "Western Europe" + }, + { + "name": "Israel", + "code": "IL", + "languages": ["hebrew", "arabic"], + "flag_colors": ["blue", "white"], + "region": "Middle East" + }, + { + "name": "Isle of Man", + "code": "IM", + "languages": ["english", "manx"], + "flag_colors": ["red", "yellow"], + "region": "Western Europe" + }, + { + "name": "India", + "code": "IN", + "languages": ["hindi", "english"], + "flag_colors": ["orange", "white", "green", "blue"], + "region": "South Asia" + }, + { + "name": "British Indian Ocean Territory", + "code": "IO", + "languages": ["english"], + "flag_colors": ["blue", "white", "red", "yellow"], + "region": "South Asia" + }, + { + "name": "Iraq", + "code": "IQ", + "languages": ["arabic", "kurdish"], + "flag_colors": ["red", "white", "black", "green"], + "region": "Middle East" + }, + { + "name": "Iran", + "code": "IR", + "languages": ["persian"], + "flag_colors": ["green", "white", "red"], + "region": "Middle East" + }, + { + "name": "Iceland", + "code": "IS", + "languages": ["icelandic"], + "flag_colors": ["blue", "white", "red"], + "region": "Northern Europe" + }, + { + "name": "Italy", + "code": "IT", + "languages": ["italian"], + "flag_colors": ["green", "white", "red"], + "region": "Southern Europe" + }, + { + "name": "Jersey", + "code": "JE", + "languages": ["english", "french"], + "flag_colors": ["white", "red", "yellow"], + "region": "Western Europe" + }, + { + "name": "Jamaica", + "code": "JM", + "languages": ["english"], + "flag_colors": ["green", "yellow", "black"], + "region": "Caribbean" + }, + { + "name": "Jordan", + "code": "JO", + "languages": ["arabic"], + "flag_colors": ["black", "white", "green", "red"], + "region": "Middle East" + }, + { + "name": "Japan", + "code": "JP", + "languages": ["japanese"], + "flag_colors": ["red", "white"], + "region": "East Asia" + }, + { + "name": "Kenya", + "code": "KE", + "languages": ["english", "swahili"], + "flag_colors": ["black", "red", "green", "white"], + "region": "Sub-Saharan Africa" + }, + { + "name": "Kyrgyzstan", + "code": "KG", + "languages": ["kyrgyz", "russian"], + "flag_colors": ["red", "yellow"], + "region": "Central Asia" + }, + { + "name": "Cambodia", + "code": "KH", + "languages": ["khmer"], + "flag_colors": ["blue", "red", "white"], + "region": "Southeast Asia" + }, + { + "name": "Kiribati", + "code": "KI", + "languages": ["english", "kiribati"], + "flag_colors": ["red", "blue", "white", "yellow"], + "region": "Oceania" + }, + { + "name": "Comoros", + "code": "KM", + "languages": ["comorian", "arabic", "french"], + "flag_colors": ["green", "yellow", "white", "red", "blue"], + "region": "Sub-Saharan Africa" + }, + { + "name": "Saint Kitts and Nevis", + "code": "KN", + "languages": ["english"], + "flag_colors": ["green", "red", "black", "yellow", "white"], + "region": "Caribbean" + }, + { + "name": "North Korea", + "code": "KP", + "languages": ["korean"], + "flag_colors": ["red", "blue", "white"], + "region": "East Asia" + }, + { + "name": "South Korea", + "code": "KR", + "languages": ["korean"], + "flag_colors": ["white", "red", "blue", "black"], + "region": "East Asia" + }, + { + "name": "Kuwait", + "code": "KW", + "languages": ["arabic"], + "flag_colors": ["green", "white", "red", "black"], + "region": "Middle East" + }, + { + "name": "Cayman Islands", + "code": "KY", + "languages": ["english"], + "flag_colors": ["blue", "white", "red"], + "region": "Caribbean" + }, + { + "name": "Kazakhstan", + "code": "KZ", + "languages": ["kazakh", "russian"], + "flag_colors": ["blue", "yellow"], + "region": "Central Asia" + }, + { + "name": "Laos", + "code": "LA", + "languages": ["lao"], + "flag_colors": ["red", "blue", "white"], + "region": "Southeast Asia" + }, + { + "name": "Lebanon", + "code": "LB", + "languages": ["arabic"], + "flag_colors": ["red", "white", "green"], + "region": "Middle East" + }, + { + "name": "Saint Lucia", + "code": "LC", + "languages": ["english"], + "flag_colors": ["blue", "yellow", "black", "white"], + "region": "Caribbean" + }, + { + "name": "Liechtenstein", + "code": "LI", + "languages": ["german"], + "flag_colors": ["blue", "red", "yellow"], + "region": "Western Europe" + }, + { + "name": "Sri Lanka", + "code": "LK", + "languages": ["sinhala", "tamil"], + "flag_colors": ["yellow", "green", "orange", "red"], + "region": "South Asia" + }, + { + "name": "Liberia", + "code": "LR", + "languages": ["english"], + "flag_colors": ["red", "white", "blue"], + "region": "Sub-Saharan Africa" + }, + { + "name": "Lesotho", + "code": "LS", + "languages": ["sesotho", "english"], + "flag_colors": ["blue", "white", "green", "black"], + "region": "Sub-Saharan Africa" + }, + { + "name": "Lithuania", + "code": "LT", + "languages": ["lithuanian"], + "flag_colors": ["yellow", "green", "red"], + "region": "Northern Europe" + }, + { + "name": "Luxembourg", + "code": "LU", + "languages": ["luxembourgish", "french", "german"], + "flag_colors": ["red", "white", "blue"], + "region": "Western Europe" + }, + { + "name": "Latvia", + "code": "LV", + "languages": ["latvian"], + "flag_colors": ["red", "white"], + "region": "Northern Europe" + }, + { + "name": "Libya", + "code": "LY", + "languages": ["arabic"], + "flag_colors": ["red", "black", "green", "white"], + "region": "North Africa" + }, + { + "name": "Morocco", + "code": "MA", + "languages": ["arabic", "tamazight"], + "flag_colors": ["red", "green"], + "region": "North Africa" + }, + { + "name": "Monaco", + "code": "MC", + "languages": ["french"], + "flag_colors": ["red", "white"], + "region": "Western Europe" + }, + { + "name": "Moldova", + "code": "MD", + "languages": ["romanian"], + "flag_colors": ["blue", "yellow", "red"], + "region": "Eastern Europe" + }, + { + "name": "Montenegro", + "code": "ME", + "languages": ["montenegrin"], + "flag_colors": ["red", "yellow"], + "region": "Southern Europe" + }, + { + "name": "Saint Martin", + "code": "MF", + "languages": ["french"], + "flag_colors": ["blue", "white", "red"], + "region": "Caribbean" + }, + { + "name": "Madagascar", + "code": "MG", + "languages": ["malagasy", "french"], + "flag_colors": ["white", "red", "green"], + "region": "Sub-Saharan Africa" + }, + { + "name": "Marshall Islands", + "code": "MH", + "languages": ["marshallese", "english"], + "flag_colors": ["blue", "white", "orange"], + "region": "Oceania" + }, + { + "name": "North Macedonia", + "code": "MK", + "languages": ["macedonian", "albanian"], + "flag_colors": ["red", "yellow"], + "region": "Southern Europe" + }, + { + "name": "Mali", + "code": "ML", + "languages": ["french"], + "flag_colors": ["green", "yellow", "red"], + "region": "Sub-Saharan Africa" + }, + { + "name": "Myanmar", + "code": "MM", + "languages": ["burmese"], + "flag_colors": ["yellow", "green", "red", "white"], + "region": "Southeast Asia" + }, + { + "name": "Mongolia", + "code": "MN", + "languages": ["mongolian"], + "flag_colors": ["red", "blue", "yellow"], + "region": "East Asia" + }, + { + "name": "Macao", + "code": "MO", + "languages": ["cantonese", "portuguese"], + "flag_colors": ["green", "white", "yellow"], + "region": "East Asia" + }, + { + "name": "Northern Mariana Islands", + "code": "MP", + "languages": ["english", "chamorro", "carolinian"], + "flag_colors": ["blue", "white"], + "region": "Oceania" + }, + { + "name": "Martinique", + "code": "MQ", + "languages": ["french"], + "flag_colors": ["blue", "white", "red"], + "region": "Caribbean" + }, + { + "name": "Mauritania", + "code": "MR", + "languages": ["arabic"], + "flag_colors": ["green", "yellow", "red"], + "region": "North Africa" + }, + { + "name": "Montserrat", + "code": "MS", + "languages": ["english"], + "flag_colors": ["blue", "green", "white", "yellow", "red"], + "region": "Caribbean" + }, + { + "name": "Malta", + "code": "MT", + "languages": ["maltese", "english"], + "flag_colors": ["white", "red"], + "region": "Southern Europe" + }, + { + "name": "Mauritius", + "code": "MU", + "languages": ["english", "french", "mauritian creole"], + "flag_colors": ["red", "blue", "yellow", "green"], + "region": "Sub-Saharan Africa" + }, + { + "name": "Maldives", + "code": "MV", + "languages": ["divehi"], + "flag_colors": ["red", "green", "white"], + "region": "South Asia" + }, + { + "name": "Malawi", + "code": "MW", + "languages": ["english", "chichewa"], + "flag_colors": ["black", "red", "green"], + "region": "Sub-Saharan Africa" + }, + { + "name": "Mexico", + "code": "MX", + "languages": ["spanish"], + "flag_colors": ["green", "white", "red"], + "region": "North America" + }, + { + "name": "Malaysia", + "code": "MY", + "languages": ["malay"], + "flag_colors": ["red", "white", "blue", "yellow"], + "region": "Southeast Asia" + }, + { + "name": "Mozambique", + "code": "MZ", + "languages": ["portuguese"], + "flag_colors": ["green", "black", "yellow", "white", "red"], + "region": "Sub-Saharan Africa" + }, + { + "name": "Namibia", + "code": "NA", + "languages": ["english"], + "flag_colors": ["blue", "red", "green", "white", "yellow"], + "region": "Sub-Saharan Africa" + }, + { + "name": "New Caledonia", + "code": "NC", + "languages": ["french"], + "flag_colors": ["blue", "red", "green", "yellow", "black"], + "region": "Oceania" + }, + { + "name": "Niger", + "code": "NE", + "languages": ["french"], + "flag_colors": ["orange", "white", "green"], + "region": "Sub-Saharan Africa" + }, + { + "name": "Norfolk Island", + "code": "NF", + "languages": ["english", "norfuk"], + "flag_colors": ["green", "white"], + "region": "Oceania" + }, + { + "name": "Nigeria", + "code": "NG", + "languages": ["english"], + "flag_colors": ["green", "white"], + "region": "Sub-Saharan Africa" + }, + { + "name": "Nicaragua", + "code": "NI", + "languages": ["spanish"], + "flag_colors": ["blue", "white"], + "region": "Central America" + }, + { + "name": "Netherlands", + "code": "NL", + "languages": ["dutch"], + "flag_colors": ["red", "white", "blue"], + "region": "Western Europe" + }, + { + "name": "Norway", + "code": "NO", + "languages": ["norwegian"], + "flag_colors": ["red", "white", "blue"], + "region": "Northern Europe" + }, + { + "name": "Nepal", + "code": "NP", + "languages": ["nepali"], + "flag_colors": ["red", "blue", "white"], + "region": "South Asia" + }, + { + "name": "Nauru", + "code": "NR", + "languages": ["nauruan", "english"], + "flag_colors": ["blue", "yellow", "white"], + "region": "Oceania" + }, + { + "name": "Niue", + "code": "NU", + "languages": ["niuean", "english"], + "flag_colors": ["yellow", "blue", "white", "red"], + "region": "Oceania" + }, + { + "name": "New Zealand", + "code": "NZ", + "languages": ["english", "maori"], + "flag_colors": ["blue", "red", "white"], + "region": "Oceania" + }, + { + "name": "Oman", + "code": "OM", + "languages": ["arabic"], + "flag_colors": ["red", "white", "green"], + "region": "Middle East" + }, + { + "name": "Panama", + "code": "PA", + "languages": ["spanish"], + "flag_colors": ["red", "white", "blue"], + "region": "Central America" + }, + { + "name": "Peru", + "code": "PE", + "languages": ["spanish", "quechua", "aymara"], + "flag_colors": ["red", "white"], + "region": "South America" + }, + { + "name": "French Polynesia", + "code": "PF", + "languages": ["french", "tahitian"], + "flag_colors": ["red", "white"], + "region": "Oceania" + }, + { + "name": "Papua New Guinea", + "code": "PG", + "languages": ["english", "tok pisin", "hiri motu"], + "flag_colors": ["red", "black", "yellow", "white"], + "region": "Oceania" + }, + { + "name": "Philippines", + "code": "PH", + "languages": ["filipino", "english"], + "flag_colors": ["blue", "red", "white", "yellow"], + "region": "Southeast Asia" + }, + { + "name": "Pakistan", + "code": "PK", + "languages": ["urdu", "english"], + "flag_colors": ["green", "white"], + "region": "South Asia" + }, + { + "name": "Poland", + "code": "PL", + "languages": ["polish"], + "flag_colors": ["white", "red"], + "region": "Eastern Europe" + }, + { + "name": "Saint Pierre and Miquelon", + "code": "PM", + "languages": ["french"], + "flag_colors": ["blue", "white", "red", "yellow", "black"], + "region": "North America" + }, + { + "name": "Pitcairn Islands", + "code": "PN", + "languages": ["english", "pitkern"], + "flag_colors": ["blue", "green", "yellow", "red", "white"], + "region": "Oceania" + }, + { + "name": "Puerto Rico", + "code": "PR", + "languages": ["spanish", "english"], + "flag_colors": ["red", "white", "blue"], + "region": "Caribbean" + }, + { + "name": "Palestine", + "code": "PS", + "languages": ["arabic"], + "flag_colors": ["red", "green", "white", "black"], + "region": "Middle East" + }, + { + "name": "Portugal", + "code": "PT", + "languages": ["portuguese"], + "flag_colors": ["green", "red", "yellow"], + "region": "Southern Europe" + }, + { + "name": "Palau", + "code": "PW", + "languages": ["palauan", "english"], + "flag_colors": ["blue", "yellow"], + "region": "Oceania" + }, + { + "name": "Paraguay", + "code": "PY", + "languages": ["spanish", "guarani"], + "flag_colors": ["red", "white", "blue", "yellow"], + "region": "South America" + }, + { + "name": "Qatar", + "code": "QA", + "languages": ["arabic"], + "flag_colors": ["red", "white"], + "region": "Middle East" + }, + { + "name": "Reunion", + "code": "RE", + "languages": ["french", "reunion creole"], + "flag_colors": ["blue", "white", "red"], + "region": "Sub-Saharan Africa" + }, + { + "name": "Romania", + "code": "RO", + "languages": ["romanian"], + "flag_colors": ["blue", "yellow", "red"], + "region": "Eastern Europe" + }, + { + "name": "Serbia", + "code": "RS", + "languages": ["serbian"], + "flag_colors": ["red", "blue", "white", "yellow"], + "region": "Eastern Europe" + }, + { + "name": "Russia", + "code": "RU", + "languages": ["russian"], + "flag_colors": ["white", "blue", "red"], + "region": "Eastern Europe" + }, + { + "name": "Rwanda", + "code": "RW", + "languages": ["kinyarwanda", "french", "english"], + "flag_colors": ["blue", "yellow", "green"], + "region": "Sub-Saharan Africa" + }, + { + "name": "Saudi Arabia", + "code": "SA", + "languages": ["arabic"], + "flag_colors": ["green", "white"], + "region": "Middle East" + }, + { + "name": "Solomon Islands", + "code": "SB", + "languages": ["english"], + "flag_colors": ["blue", "green", "yellow", "white"], + "region": "Oceania" + }, + { + "name": "Seychelles", + "code": "SC", + "languages": ["seychellois creole", "english", "french"], + "flag_colors": ["blue", "yellow", "red", "white", "green"], + "region": "Sub-Saharan Africa" + }, + { + "name": "Sudan", + "code": "SD", + "languages": ["arabic", "english"], + "flag_colors": ["red", "white", "black", "green"], + "region": "North Africa" + }, + { + "name": "Sweden", + "code": "SE", + "languages": ["swedish"], + "flag_colors": ["blue", "yellow"], + "region": "Northern Europe" + }, + { + "name": "Singapore", + "code": "SG", + "languages": ["english", "malay", "mandarin", "tamil"], + "flag_colors": ["red", "white"], + "region": "Southeast Asia" + }, + { + "name": "Saint Helena", + "code": "SH", + "languages": ["english"], + "flag_colors": ["blue", "red", "white", "yellow", "green"], + "region": "Atlantic Ocean" + }, + { + "name": "Slovenia", + "code": "SI", + "languages": ["slovenian"], + "flag_colors": ["white", "blue", "red"], + "region": "Southern Europe" + }, + { + "name": "Svalbard and Jan Mayen", + "code": "SJ", + "languages": ["norwegian"], + "flag_colors": ["red", "white", "blue"], + "region": "Northern Europe" + }, + { + "name": "Slovakia", + "code": "SK", + "languages": ["slovak"], + "flag_colors": ["white", "blue", "red"], + "region": "Eastern Europe" + }, + { + "name": "Sierra Leone", + "code": "SL", + "languages": ["english"], + "flag_colors": ["green", "white", "blue"], + "region": "Sub-Saharan Africa" + }, + { + "name": "San Marino", + "code": "SM", + "languages": ["italian"], + "flag_colors": ["white", "blue"], + "region": "Southern Europe" + }, + { + "name": "Senegal", + "code": "SN", + "languages": ["french"], + "flag_colors": ["green", "yellow", "red"], + "region": "Sub-Saharan Africa" + }, + { + "name": "Somalia", + "code": "SO", + "languages": ["somali", "arabic"], + "flag_colors": ["blue", "white"], + "region": "Sub-Saharan Africa" + }, + { + "name": "Suriname", + "code": "SR", + "languages": ["dutch"], + "flag_colors": ["green", "white", "red", "yellow"], + "region": "South America" + }, + { + "name": "South Sudan", + "code": "SS", + "languages": ["english"], + "flag_colors": ["black", "red", "green", "blue", "white", "yellow"], + "region": "Sub-Saharan Africa" + }, + { + "name": "Sao Tome and Principe", + "code": "ST", + "languages": ["portuguese"], + "flag_colors": ["green", "yellow", "red", "black"], + "region": "Sub-Saharan Africa" + }, + { + "name": "El Salvador", + "code": "SV", + "languages": ["spanish"], + "flag_colors": ["blue", "white"], + "region": "Central America" + }, + { + "name": "Sint Maarten", + "code": "SX", + "languages": ["dutch", "english"], + "flag_colors": ["red", "white", "blue"], + "region": "Caribbean" + }, + { + "name": "Syria", + "code": "SY", + "languages": ["arabic"], + "flag_colors": ["red", "white", "black", "green"], + "region": "Middle East" + }, + { + "name": "Eswatini", + "code": "SZ", + "languages": ["swazi", "english"], + "flag_colors": ["blue", "red", "yellow", "black", "white"], + "region": "Sub-Saharan Africa" + }, + { + "name": "Turks and Caicos Islands", + "code": "TC", + "languages": ["english"], + "flag_colors": ["blue", "red", "white", "yellow", "green"], + "region": "Caribbean" + }, + { + "name": "Chad", + "code": "TD", + "languages": ["french", "arabic"], + "flag_colors": ["blue", "yellow", "red"], + "region": "Sub-Saharan Africa" + }, + { + "name": "French Southern Territories", + "code": "TF", + "languages": ["french"], + "flag_colors": ["blue", "white", "red"], + "region": "Oceania" + }, + { + "name": "Togo", + "code": "TG", + "languages": ["french"], + "flag_colors": ["green", "yellow", "red", "white"], + "region": "Sub-Saharan Africa" + }, + { + "name": "Thailand", + "code": "TH", + "languages": ["thai"], + "flag_colors": ["red", "white", "blue"], + "region": "Southeast Asia" + }, + { + "name": "Tajikistan", + "code": "TJ", + "languages": ["tajik"], + "flag_colors": ["red", "white", "green", "yellow"], + "region": "Central Asia" + }, + { + "name": "Tokelau", + "code": "TK", + "languages": ["tokelauan", "english"], + "flag_colors": ["blue", "yellow", "white"], + "region": "Oceania" + }, + { + "name": "Timor-Leste", + "code": "TL", + "languages": ["tetum", "portuguese"], + "flag_colors": ["red", "yellow", "black", "white"], + "region": "Southeast Asia" + }, + { + "name": "Turkmenistan", + "code": "TM", + "languages": ["turkmen"], + "flag_colors": ["green", "red", "white"], + "region": "Central Asia" + }, + { + "name": "Tunisia", + "code": "TN", + "languages": ["arabic"], + "flag_colors": ["red", "white"], + "region": "North Africa" + }, + { + "name": "Tonga", + "code": "TO", + "languages": ["tongan", "english"], + "flag_colors": ["red", "white"], + "region": "Oceania" + }, + { + "name": "Turkey", + "code": "TR", + "languages": ["turkish"], + "flag_colors": ["red", "white"], + "region": "Middle East" + }, + { + "name": "Trinidad and Tobago", + "code": "TT", + "languages": ["english"], + "flag_colors": ["red", "black", "white"], + "region": "Caribbean" + }, + { + "name": "Tuvalu", + "code": "TV", + "languages": ["tuvaluan", "english"], + "flag_colors": ["blue", "yellow", "red", "white"], + "region": "Oceania" + }, + { + "name": "Taiwan", + "code": "TW", + "languages": ["mandarin"], + "flag_colors": ["red", "blue", "white"], + "region": "East Asia" + }, + { + "name": "Tanzania", + "code": "TZ", + "languages": ["swahili", "english"], + "flag_colors": ["green", "yellow", "black", "blue"], + "region": "Sub-Saharan Africa" + }, + { + "name": "Ukraine", + "code": "UA", + "languages": ["ukrainian"], + "flag_colors": ["blue", "yellow"], + "region": "Eastern Europe" + }, + { + "name": "Uganda", + "code": "UG", + "languages": ["english", "swahili"], + "flag_colors": ["black", "yellow", "red", "white"], + "region": "Sub-Saharan Africa" + }, + { + "name": "United States Minor Outlying Islands", + "code": "UM", + "languages": ["english"], + "flag_colors": ["red", "white", "blue"], + "region": "Oceania" + }, + { + "name": "United States", + "code": "US", + "languages": ["english"], + "flag_colors": ["red", "white", "blue"], + "region": "North America" + }, + { + "name": "Uruguay", + "code": "UY", + "languages": ["spanish"], + "flag_colors": ["white", "blue", "yellow"], + "region": "South America" + }, + { + "name": "Uzbekistan", + "code": "UZ", + "languages": ["uzbek"], + "flag_colors": ["blue", "white", "green", "red"], + "region": "Central Asia" + }, + { + "name": "Vatican City", + "code": "VA", + "languages": ["italian", "latin"], + "flag_colors": ["yellow", "white"], + "region": "Southern Europe" + }, + { + "name": "Saint Vincent and the Grenadines", + "code": "VC", + "languages": ["english"], + "flag_colors": ["blue", "yellow", "green", "white"], + "region": "Caribbean" + }, + { + "name": "Venezuela", + "code": "VE", + "languages": ["spanish"], + "flag_colors": ["yellow", "blue", "red", "white"], + "region": "South America" + }, + { + "name": "British Virgin Islands", + "code": "VG", + "languages": ["english"], + "flag_colors": ["blue", "white", "red"], + "region": "Caribbean" + }, + { + "name": "U.S. Virgin Islands", + "code": "VI", + "languages": ["english"], + "flag_colors": ["red", "white", "blue", "yellow"], + "region": "Caribbean" + }, + { + "name": "Vietnam", + "code": "VN", + "languages": ["vietnamese"], + "flag_colors": ["red", "yellow"], + "region": "Southeast Asia" + }, + { + "name": "Vanuatu", + "code": "VU", + "languages": ["bislama", "english", "french"], + "flag_colors": ["red", "green", "black", "yellow"], + "region": "Oceania" + }, + { + "name": "Wallis and Futuna", + "code": "WF", + "languages": ["french", "wallisian", "futunan"], + "flag_colors": ["red", "white", "blue"], + "region": "Oceania" + }, + { + "name": "Samoa", + "code": "WS", + "languages": ["samoan", "english"], + "flag_colors": ["red", "blue", "white"], + "region": "Oceania" + }, + { + "name": "Kosovo", + "code": "XK", + "languages": ["albanian", "serbian"], + "flag_colors": ["blue", "yellow", "white"], + "region": "Eastern Europe" + }, + { + "name": "Yemen", + "code": "YE", + "languages": ["arabic"], + "flag_colors": ["red", "white", "black"], + "region": "Middle East" + }, + { + "name": "Mayotte", + "code": "YT", + "languages": ["french", "shimaore", "kibushi"], + "flag_colors": ["white"], + "region": "Sub-Saharan Africa" + }, + { + "name": "South Africa", + "code": "ZA", + "languages": ["zulu", "xhosa", "afrikaans", "english"], + "flag_colors": ["red", "blue", "green", "yellow", "black", "white"], + "region": "Sub-Saharan Africa" + }, + { + "name": "Zambia", + "code": "ZM", + "languages": ["english"], + "flag_colors": ["green", "red", "black", "orange"], + "region": "Sub-Saharan Africa" + }, + { + "name": "Zimbabwe", + "code": "ZW", + "languages": ["english", "shona", "ndebele"], + "flag_colors": ["green", "yellow", "red", "black", "white"], + "region": "Sub-Saharan Africa" + } +] diff --git a/apps/web/public/fonts/font-atlas.json b/apps/web/public/fonts/font-atlas.json new file mode 100644 index 00000000..deb0eb91 --- /dev/null +++ b/apps/web/public/fonts/font-atlas.json @@ -0,0 +1,11348 @@ +{ + "fonts": { + "42dot Sans": { + "x": 0, + "y": 0, + "w": 131, + "ch": 0, + "s": ["300", "400", "500", "600", "700", "800"] + }, + "ABeeZee": { "x": 145, "y": 0, "w": 106, "ch": 0, "s": ["400", "400i"] }, + "Abel": { "x": 265, "y": 0, "w": 47, "ch": 0, "s": ["400"] }, + "Abhaya Libre": { + "x": 326, + "y": 0, + "w": 139, + "ch": 0, + "s": ["400", "500", "600", "700", "800"] + }, + "Aboreto": { "x": 479, "y": 0, "w": 124, "ch": 0, "s": ["400"] }, + "Abril Fatface": { "x": 617, "y": 0, "w": 152, "ch": 0, "s": ["400"] }, + "Abyssinica SIL": { "x": 783, "y": 0, "w": 165, "ch": 0, "s": ["400"] }, + "Aclonica": { "x": 962, "y": 0, "w": 122, "ch": 0, "s": ["400"] }, + "Acme": { "x": 1098, "y": 0, "w": 63, "ch": 0, "s": ["400"] }, + "Actor": { "x": 0, "y": 40, "w": 64, "ch": 0, "s": ["400"] }, + "Adamina": { "x": 78, "y": 40, "w": 113, "ch": 0, "s": ["400"] }, + "ADLaM Display": { "x": 205, "y": 40, "w": 183, "ch": 0, "s": ["400"] }, + "Advent Pro": { + "x": 402, + "y": 40, + "w": 107, + "ch": 0, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Afacad": { + "x": 523, + "y": 40, + "w": 77, + "ch": 0, + "s": ["400", "400i", "500", "500i", "600", "600i", "700", "700i"] + }, + "Afacad Flux": { + "x": 614, + "y": 40, + "w": 118, + "ch": 0, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Agbalumo": { "x": 746, "y": 40, "w": 119, "ch": 0, "s": ["400"] }, + "Agdasima": { "x": 879, "y": 40, "w": 81, "ch": 0, "s": ["400", "700"] }, + "Agu Display": { "x": 974, "y": 40, "w": 140, "ch": 0, "s": ["400"] }, + "Aguafina Script": { "x": 0, "y": 80, "w": 120, "ch": 0, "s": ["400"] }, + "Akatab": { + "x": 134, + "y": 80, + "w": 81, + "ch": 0, + "s": ["400", "500", "600", "700", "800", "900"] + }, + "Akaya Kanadaka": { "x": 229, "y": 80, "w": 176, "ch": 0, "s": ["400"] }, + "Akaya Telivigala": { "x": 419, "y": 80, "w": 165, "ch": 0, "s": ["400"] }, + "Akronim": { "x": 598, "y": 80, "w": 79, "ch": 0, "s": ["400"] }, + "Akshar": { + "x": 691, + "y": 80, + "w": 71, + "ch": 0, + "s": ["300", "400", "500", "600", "700"] + }, + "Aladin": { "x": 776, "y": 80, "w": 61, "ch": 0, "s": ["400"] }, + "Alan Sans": { + "x": 851, + "y": 80, + "w": 116, + "ch": 0, + "s": ["300", "400", "500", "600", "700", "800", "900"] + }, + "Alata": { "x": 981, "y": 80, "w": 68, "ch": 0, "s": ["400"] }, + "Alatsi": { "x": 1063, "y": 80, "w": 66, "ch": 0, "s": ["400"] }, + "Albert Sans": { + "x": 0, + "y": 120, + "w": 133, + "ch": 0, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Aldrich": { "x": 147, "y": 120, "w": 94, "ch": 0, "s": ["400"] }, + "Alef": { "x": 255, "y": 120, "w": 54, "ch": 0, "s": ["400", "700"] }, + "Alegreya": { + "x": 323, + "y": 120, + "w": 92, + "ch": 0, + "s": [ + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Alegreya Sans": { + "x": 429, + "y": 120, + "w": 137, + "ch": 0, + "s": [ + "100", + "100i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Alegreya Sans SC": { + "x": 580, + "y": 120, + "w": 179, + "ch": 0, + "s": [ + "100", + "100i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Alegreya SC": { + "x": 773, + "y": 120, + "w": 137, + "ch": 0, + "s": [ + "400", + "400i", + "500", + "500i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Aleo": { + "x": 924, + "y": 120, + "w": 59, + "ch": 0, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Alex Brush": { "x": 997, "y": 120, "w": 117, "ch": 0, "s": ["400"] }, + "Alexandria": { + "x": 0, + "y": 160, + "w": 136, + "ch": 0, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Alfa Slab One": { "x": 150, "y": 160, "w": 180, "ch": 0, "s": ["400"] }, + "Alice": { "x": 344, "y": 160, "w": 60, "ch": 0, "s": ["400"] }, + "Alike": { "x": 418, "y": 160, "w": 62, "ch": 0, "s": ["400"] }, + "Alike Angular": { "x": 494, "y": 160, "w": 155, "ch": 0, "s": ["400"] }, + "Alkalami": { "x": 663, "y": 160, "w": 109, "ch": 0, "s": ["400"] }, + "Alkatra": { + "x": 786, + "y": 160, + "w": 84, + "ch": 0, + "s": ["400", "500", "600", "700"] + }, + "Allan": { "x": 884, "y": 160, "w": 48, "ch": 0, "s": ["400", "700"] }, + "Allerta": { "x": 946, "y": 160, "w": 93, "ch": 0, "s": ["400"] }, + "Allerta Stencil": { "x": 0, "y": 200, "w": 182, "ch": 0, "s": ["400"] }, + "Allison": { "x": 196, "y": 200, "w": 56, "ch": 0, "s": ["400"] }, + "Allura": { "x": 266, "y": 200, "w": 69, "ch": 0, "s": ["400"] }, + "Almarai": { + "x": 349, + "y": 200, + "w": 88, + "ch": 0, + "s": ["300", "400", "700", "800"] + }, + "Almendra": { + "x": 451, + "y": 200, + "w": 107, + "ch": 0, + "s": ["400", "400i", "700", "700i"] + }, + "Almendra Display": { "x": 572, "y": 200, "w": 192, "ch": 0, "s": ["400"] }, + "Almendra SC": { "x": 778, "y": 200, "w": 142, "ch": 0, "s": ["400"] }, + "Alumni Sans": { + "x": 934, + "y": 200, + "w": 96, + "ch": 0, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Alumni Sans Collegiate One": { + "x": 0, + "y": 240, + "w": 201, + "ch": 0, + "s": ["400", "400i"] + }, + "Alumni Sans Inline One": { + "x": 215, + "y": 240, + "w": 180, + "ch": 0, + "s": ["400", "400i"] + }, + "Alumni Sans Pinstripe": { + "x": 409, + "y": 240, + "w": 159, + "ch": 0, + "s": ["400", "400i"] + }, + "Alumni Sans SC": { + "x": 582, + "y": 240, + "w": 125, + "ch": 0, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Amarante": { "x": 721, "y": 240, "w": 108, "ch": 0, "s": ["400"] }, + "Amaranth": { + "x": 843, + "y": 240, + "w": 110, + "ch": 0, + "s": ["400", "400i", "700", "700i"] + }, + "Amarna": { + "x": 967, + "y": 240, + "w": 92, + "ch": 0, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i" + ] + }, + "Amatic SC": { "x": 1073, "y": 240, "w": 77, "ch": 0, "s": ["400", "700"] }, + "Amethysta": { "x": 0, "y": 280, "w": 133, "ch": 0, "s": ["400"] }, + "Amiko": { + "x": 147, + "y": 280, + "w": 84, + "ch": 0, + "s": ["400", "600", "700"] + }, + "Amiri": { + "x": 245, + "y": 280, + "w": 63, + "ch": 0, + "s": ["400", "400i", "700", "700i"] + }, + "Amiri Quran": { "x": 322, "y": 280, "w": 131, "ch": 0, "s": ["400"] }, + "Amita": { "x": 467, "y": 280, "w": 73, "ch": 0, "s": ["400", "700"] }, + "Anaheim": { + "x": 554, + "y": 280, + "w": 93, + "ch": 0, + "s": ["400", "500", "600", "700", "800"] + }, + "Ancizar Sans": { + "x": 661, + "y": 280, + "w": 126, + "ch": 0, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Ancizar Serif": { + "x": 801, + "y": 280, + "w": 136, + "ch": 0, + "s": [ + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Andada Pro": { + "x": 951, + "y": 280, + "w": 137, + "ch": 0, + "s": [ + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i" + ] + }, + "Andika": { + "x": 1102, + "y": 280, + "w": 85, + "ch": 0, + "s": ["400", "400i", "700", "700i"] + }, + "Anek Bangla": { + "x": 0, + "y": 320, + "w": 131, + "ch": 0, + "s": ["100", "200", "300", "400", "500", "600", "700", "800"] + }, + "Anek Devanagari": { + "x": 145, + "y": 320, + "w": 176, + "ch": 0, + "s": ["100", "200", "300", "400", "500", "600", "700", "800"] + }, + "Anek Gujarati": { + "x": 335, + "y": 320, + "w": 143, + "ch": 0, + "s": ["100", "200", "300", "400", "500", "600", "700", "800"] + }, + "Anek Gurmukhi": { + "x": 492, + "y": 320, + "w": 164, + "ch": 0, + "s": ["100", "200", "300", "400", "500", "600", "700", "800"] + }, + "Anek Kannada": { + "x": 670, + "y": 320, + "w": 152, + "ch": 0, + "s": ["100", "200", "300", "400", "500", "600", "700", "800"] + }, + "Anek Latin": { + "x": 836, + "y": 320, + "w": 115, + "ch": 0, + "s": ["100", "200", "300", "400", "500", "600", "700", "800"] + }, + "Anek Malayalam": { + "x": 965, + "y": 320, + "w": 171, + "ch": 0, + "s": ["100", "200", "300", "400", "500", "600", "700", "800"] + }, + "Anek Odia": { + "x": 0, + "y": 360, + "w": 108, + "ch": 0, + "s": ["100", "200", "300", "400", "500", "600", "700", "800"] + }, + "Anek Tamil": { + "x": 122, + "y": 360, + "w": 119, + "ch": 0, + "s": ["100", "200", "300", "400", "500", "600", "700", "800"] + }, + "Anek Telugu": { + "x": 255, + "y": 360, + "w": 131, + "ch": 0, + "s": ["100", "200", "300", "400", "500", "600", "700", "800"] + }, + "Angkor": { "x": 400, "y": 360, "w": 111, "ch": 0, "s": ["400"] }, + "Annapurna SIL": { + "x": 525, + "y": 360, + "w": 151, + "ch": 0, + "s": ["400", "700"] + }, + "Annie Use Your Telescope": { + "x": 690, + "y": 360, + "w": 215, + "ch": 0, + "s": ["400"] + }, + "Anonymous Pro": { + "x": 919, + "y": 360, + "w": 179, + "ch": 0, + "s": ["400", "400i", "700", "700i"] + }, + "Anta": { "x": 1112, "y": 360, "w": 62, "ch": 0, "s": ["400"] }, + "Antic": { "x": 0, "y": 400, "w": 60, "ch": 0, "s": ["400"] }, + "Antic Didone": { "x": 74, "y": 400, "w": 145, "ch": 0, "s": ["400"] }, + "Antic Slab": { "x": 233, "y": 400, "w": 114, "ch": 0, "s": ["400"] }, + "Anton": { "x": 361, "y": 400, "w": 63, "ch": 0, "s": ["400"] }, + "Anton SC": { "x": 438, "y": 400, "w": 93, "ch": 0, "s": ["400"] }, + "Antonio": { + "x": 545, + "y": 400, + "w": 76, + "ch": 0, + "s": ["100", "200", "300", "400", "500", "600", "700"] + }, + "Anuphan": { + "x": 635, + "y": 400, + "w": 105, + "ch": 0, + "s": ["100", "200", "300", "400", "500", "600", "700"] + }, + "Anybody": { + "x": 754, + "y": 400, + "w": 107, + "ch": 0, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Aoboshi One": { "x": 875, "y": 400, "w": 158, "ch": 0, "s": ["400"] }, + "AR One Sans": { + "x": 1047, + "y": 400, + "w": 152, + "ch": 0, + "s": ["400", "500", "600", "700"] + }, + "Arapey": { "x": 0, "y": 440, "w": 75, "ch": 0, "s": ["400", "400i"] }, + "Arbutus": { "x": 89, "y": 440, "w": 128, "ch": 0, "s": ["400"] }, + "Arbutus Slab": { "x": 231, "y": 440, "w": 159, "ch": 0, "s": ["400"] }, + "Architects Daughter": { + "x": 404, + "y": 440, + "w": 237, + "ch": 0, + "s": ["400"] + }, + "Archivo": { + "x": 655, + "y": 440, + "w": 90, + "ch": 0, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Archivo Black": { "x": 759, "y": 440, "w": 190, "ch": 0, "s": ["400"] }, + "Archivo Narrow": { + "x": 963, + "y": 440, + "w": 144, + "ch": 0, + "s": ["400", "400i", "500", "500i", "600", "600i", "700", "700i"] + }, + "Are You Serious": { "x": 0, "y": 480, "w": 142, "ch": 0, "s": ["400"] }, + "Aref Ruqaa": { + "x": 156, + "y": 480, + "w": 130, + "ch": 0, + "s": ["400", "700"] + }, + "Aref Ruqaa Ink": { + "x": 300, + "y": 480, + "w": 172, + "ch": 0, + "s": ["400", "700"] + }, + "Arima": { + "x": 486, + "y": 480, + "w": 73, + "ch": 0, + "s": ["100", "200", "300", "400", "500", "600", "700"] + }, + "Arima Madurai": { + "x": 573, + "y": 480, + "w": 170, + "ch": 0, + "s": ["100", "200", "300", "400", "500", "700", "800", "900"] + }, + "Arimo": { + "x": 757, + "y": 480, + "w": 71, + "ch": 0, + "s": ["400", "400i", "500", "500i", "600", "600i", "700", "700i"] + }, + "Arizonia": { "x": 842, "y": 480, "w": 95, "ch": 0, "s": ["400"] }, + "Armata": { "x": 951, "y": 480, "w": 100, "ch": 0, "s": ["400"] }, + "Arsenal": { + "x": 1065, + "y": 480, + "w": 80, + "ch": 0, + "s": ["400", "400i", "700", "700i"] + }, + "Arsenal SC": { + "x": 0, + "y": 520, + "w": 118, + "ch": 0, + "s": ["400", "400i", "700", "700i"] + }, + "Artifika": { "x": 132, "y": 520, "w": 105, "ch": 0, "s": ["400"] }, + "Arvo": { + "x": 251, + "y": 520, + "w": 65, + "ch": 0, + "s": ["400", "400i", "700", "700i"] + }, + "Arya": { "x": 330, "y": 520, "w": 53, "ch": 0, "s": ["400", "700"] }, + "Asap": { + "x": 397, + "y": 520, + "w": 60, + "ch": 0, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Asap Condensed": { + "x": 471, + "y": 520, + "w": 153, + "ch": 0, + "s": [ + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Asar": { "x": 638, "y": 520, "w": 53, "ch": 0, "s": ["400"] }, + "Asimovian": { "x": 705, "y": 520, "w": 117, "ch": 0, "s": ["400"] }, + "Asset": { "x": 836, "y": 520, "w": 154, "ch": 0, "s": ["400"] }, + "Assistant": { + "x": 1004, + "y": 520, + "w": 98, + "ch": 0, + "s": ["200", "300", "400", "500", "600", "700", "800"] + }, + "Asta Sans": { + "x": 0, + "y": 560, + "w": 115, + "ch": 0, + "s": ["300", "400", "500", "600", "700", "800"] + }, + "Astloch": { "x": 129, "y": 560, "w": 72, "ch": 0, "s": ["400", "700"] }, + "Asul": { "x": 215, "y": 560, "w": 54, "ch": 0, "s": ["400", "700"] }, + "Athiti": { + "x": 283, + "y": 560, + "w": 62, + "ch": 0, + "s": ["200", "300", "400", "500", "600", "700"] + }, + "Atkinson Hyperlegible": { + "x": 359, + "y": 560, + "w": 239, + "ch": 0, + "s": ["400", "400i", "700", "700i"] + }, + "Atkinson Hyperlegible Mono": { + "x": 612, + "y": 560, + "w": 403, + "ch": 0, + "s": [ + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i" + ] + }, + "Atkinson Hyperlegible Next": { + "x": 0, + "y": 600, + "w": 296, + "ch": 0, + "s": [ + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i" + ] + }, + "Atma": { + "x": 310, + "y": 600, + "w": 59, + "ch": 0, + "s": ["300", "400", "500", "600", "700"] + }, + "Atomic Age": { "x": 383, "y": 600, "w": 144, "ch": 0, "s": ["400"] }, + "Aubrey": { "x": 541, "y": 600, "w": 67, "ch": 0, "s": ["400"] }, + "Audiowide": { "x": 622, "y": 600, "w": 145, "ch": 0, "s": ["400"] }, + "Autour One": { "x": 781, "y": 600, "w": 168, "ch": 0, "s": ["400"] }, + "Average": { "x": 963, "y": 600, "w": 88, "ch": 0, "s": ["400"] }, + "Average Sans": { "x": 0, "y": 640, "w": 138, "ch": 0, "s": ["400"] }, + "Averia Gruesa Libre": { + "x": 152, + "y": 640, + "w": 217, + "ch": 0, + "s": ["400"] + }, + "Averia Libre": { + "x": 383, + "y": 640, + "w": 138, + "ch": 0, + "s": ["300", "300i", "400", "400i", "700", "700i"] + }, + "Averia Sans Libre": { + "x": 535, + "y": 640, + "w": 190, + "ch": 0, + "s": ["300", "300i", "400", "400i", "700", "700i"] + }, + "Averia Serif Libre": { + "x": 739, + "y": 640, + "w": 202, + "ch": 0, + "s": ["300", "300i", "400", "400i", "700", "700i"] + }, + "Azeret Mono": { + "x": 955, + "y": 640, + "w": 180, + "ch": 0, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "B612": { + "x": 0, + "y": 680, + "w": 71, + "ch": 0, + "s": ["400", "400i", "700", "700i"] + }, + "B612 Mono": { + "x": 85, + "y": 680, + "w": 149, + "ch": 0, + "s": ["400", "400i", "700", "700i"] + }, + "Babylonica": { "x": 248, "y": 680, "w": 90, "ch": 0, "s": ["400"] }, + "Bacasime Antique": { "x": 352, "y": 680, "w": 182, "ch": 0, "s": ["400"] }, + "Bad Script": { "x": 548, "y": 680, "w": 103, "ch": 0, "s": ["400"] }, + "Badeen Display": { "x": 665, "y": 680, "w": 194, "ch": 0, "s": ["400"] }, + "Bagel Fat One": { "x": 873, "y": 680, "w": 158, "ch": 0, "s": ["400"] }, + "Bahiana": { "x": 1045, "y": 680, "w": 58, "ch": 0, "s": ["400"] }, + "Bahianita": { "x": 1117, "y": 680, "w": 63, "ch": 0, "s": ["400"] }, + "Bai Jamjuree": { + "x": 0, + "y": 720, + "w": 152, + "ch": 0, + "s": [ + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i" + ] + }, + "Bakbak One": { "x": 166, "y": 720, "w": 141, "ch": 0, "s": ["400"] }, + "Ballet": { "x": 321, "y": 720, "w": 65, "ch": 0, "s": ["400"] }, + "Baloo 2": { + "x": 400, + "y": 720, + "w": 84, + "ch": 0, + "s": ["400", "500", "600", "700", "800"] + }, + "Baloo Bhai 2": { + "x": 498, + "y": 720, + "w": 134, + "ch": 0, + "s": ["400", "500", "600", "700", "800"] + }, + "Baloo Bhaijaan 2": { + "x": 646, + "y": 720, + "w": 177, + "ch": 0, + "s": ["400", "500", "600", "700", "800"] + }, + "Baloo Bhaina 2": { + "x": 837, + "y": 720, + "w": 160, + "ch": 0, + "s": ["400", "500", "600", "700", "800"] + }, + "Baloo Chettan 2": { + "x": 1011, + "y": 720, + "w": 172, + "ch": 0, + "s": ["400", "500", "600", "700", "800"] + }, + "Baloo Da 2": { + "x": 0, + "y": 760, + "w": 117, + "ch": 0, + "s": ["400", "500", "600", "700", "800"] + }, + "Baloo Paaji 2": { + "x": 131, + "y": 760, + "w": 138, + "ch": 0, + "s": ["400", "500", "600", "700", "800"] + }, + "Baloo Tamma 2": { + "x": 283, + "y": 760, + "w": 169, + "ch": 0, + "s": ["400", "500", "600", "700", "800"] + }, + "Baloo Tammudu 2": { + "x": 466, + "y": 760, + "w": 191, + "ch": 0, + "s": ["400", "500", "600", "700", "800"] + }, + "Baloo Thambi 2": { + "x": 671, + "y": 760, + "w": 165, + "ch": 0, + "s": ["400", "500", "600", "700", "800"] + }, + "Balsamiq Sans": { + "x": 850, + "y": 760, + "w": 168, + "ch": 0, + "s": ["400", "400i", "700", "700i"] + }, + "Balthazar": { "x": 1032, "y": 760, "w": 96, "ch": 0, "s": ["400"] }, + "Bangers": { "x": 0, "y": 0, "w": 81, "ch": 1, "s": ["400"] }, + "Barlow": { + "x": 95, + "y": 0, + "w": 80, + "ch": 1, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Barlow Condensed": { + "x": 189, + "y": 0, + "w": 158, + "ch": 1, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Barlow Semi Condensed": { + "x": 361, + "y": 0, + "w": 231, + "ch": 1, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Barriecito": { "x": 606, "y": 0, "w": 106, "ch": 1, "s": ["400"] }, + "Barrio": { "x": 726, "y": 0, "w": 80, "ch": 1, "s": ["400"] }, + "Basic": { "x": 820, "y": 0, "w": 61, "ch": 1, "s": ["400"] }, + "Baskervville": { + "x": 895, + "y": 0, + "w": 136, + "ch": 1, + "s": ["400", "400i", "500", "500i", "600", "600i", "700", "700i"] + }, + "Baskervville SC": { + "x": 0, + "y": 40, + "w": 187, + "ch": 1, + "s": ["400", "500", "600", "700"] + }, + "Battambang": { + "x": 201, + "y": 40, + "w": 147, + "ch": 1, + "s": ["100", "300", "400", "700", "900"] + }, + "Baumans": { "x": 362, "y": 40, "w": 101, "ch": 1, "s": ["400"] }, + "Bayon": { "x": 477, "y": 40, "w": 61, "ch": 1, "s": ["400"] }, + "BBH Bartle": { "x": 552, "y": 40, "w": 298, "ch": 1, "s": ["400"] }, + "BBH Bogle": { "x": 864, "y": 40, "w": 98, "ch": 1, "s": ["400"] }, + "BBH Hegarty": { "x": 976, "y": 40, "w": 182, "ch": 1, "s": ["400"] }, + "BBH Sans Bartle": { "x": 0, "y": 80, "w": 427, "ch": 1, "s": ["400"] }, + "BBH Sans Bogle": { "x": 441, "y": 80, "w": 142, "ch": 1, "s": ["400"] }, + "BBH Sans Hegarty": { "x": 597, "y": 80, "w": 254, "ch": 1, "s": ["400"] }, + "Be Vietnam Pro": { + "x": 865, + "y": 80, + "w": 188, + "ch": 1, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Beau Rivage": { "x": 1067, "y": 80, "w": 108, "ch": 1, "s": ["400"] }, + "Bebas Neue": { "x": 0, "y": 120, "w": 96, "ch": 1, "s": ["400"] }, + "Beiruti": { + "x": 110, + "y": 120, + "w": 66, + "ch": 1, + "s": ["200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Belanosima": { + "x": 190, + "y": 120, + "w": 124, + "ch": 1, + "s": ["400", "600", "700"] + }, + "Belgrano": { "x": 328, "y": 120, "w": 115, "ch": 1, "s": ["400"] }, + "Bellefair": { "x": 457, "y": 120, "w": 81, "ch": 1, "s": ["400"] }, + "Belleza": { "x": 552, "y": 120, "w": 79, "ch": 1, "s": ["400"] }, + "Bellota": { + "x": 645, + "y": 120, + "w": 84, + "ch": 1, + "s": ["300", "300i", "400", "400i", "700", "700i"] + }, + "Bellota Text": { + "x": 743, + "y": 120, + "w": 129, + "ch": 1, + "s": ["300", "300i", "400", "400i", "700", "700i"] + }, + "BenchNine": { + "x": 886, + "y": 120, + "w": 82, + "ch": 1, + "s": ["300", "400", "700"] + }, + "Benne": { "x": 982, "y": 120, "w": 66, "ch": 1, "s": ["400"] }, + "Bentham": { "x": 1062, "y": 120, "w": 98, "ch": 1, "s": ["400"] }, + "Berkshire Swash": { "x": 0, "y": 160, "w": 178, "ch": 1, "s": ["400"] }, + "Besley": { + "x": 192, + "y": 160, + "w": 86, + "ch": 1, + "s": [ + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Beth Ellen": { "x": 292, "y": 160, "w": 138, "ch": 1, "s": ["400"] }, + "Bevan": { "x": 444, "y": 160, "w": 94, "ch": 1, "s": ["400", "400i"] }, + "BhuTuka Expanded One": { + "x": 552, + "y": 160, + "w": 412, + "ch": 1, + "s": ["400"] + }, + "Big Shoulders": { + "x": 978, + "y": 160, + "w": 117, + "ch": 1, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Big Shoulders Display": { + "x": 0, + "y": 200, + "w": 158, + "ch": 1, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Big Shoulders Inline": { + "x": 172, + "y": 200, + "w": 167, + "ch": 1, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Big Shoulders Inline Display": { + "x": 353, + "y": 200, + "w": 203, + "ch": 1, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Big Shoulders Inline Text": { + "x": 570, + "y": 200, + "w": 201, + "ch": 1, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Big Shoulders Stencil": { + "x": 785, + "y": 200, + "w": 177, + "ch": 1, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Big Shoulders Stencil Display": { + "x": 976, + "y": 200, + "w": 211, + "ch": 1, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Big Shoulders Stencil Text": { + "x": 0, + "y": 240, + "w": 210, + "ch": 1, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Big Shoulders Text": { + "x": 224, + "y": 240, + "w": 150, + "ch": 1, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Bigelow Rules": { "x": 388, "y": 240, "w": 86, "ch": 1, "s": ["400"] }, + "Bigshot One": { "x": 488, "y": 240, "w": 138, "ch": 1, "s": ["400"] }, + "Bilbo": { "x": 640, "y": 240, "w": 45, "ch": 1, "s": ["400"] }, + "Bilbo Swash Caps": { "x": 699, "y": 240, "w": 144, "ch": 1, "s": ["400"] }, + "BioRhyme": { + "x": 857, + "y": 240, + "w": 139, + "ch": 1, + "s": ["200", "300", "400", "500", "600", "700", "800"] + }, + "BioRhyme Expanded": { + "x": 0, + "y": 280, + "w": 455, + "ch": 1, + "s": ["200", "300", "400", "700", "800"] + }, + "Birthstone": { "x": 469, "y": 280, "w": 79, "ch": 1, "s": ["400"] }, + "Birthstone Bounce": { + "x": 562, + "y": 280, + "w": 167, + "ch": 1, + "s": ["400", "500"] + }, + "Biryani": { + "x": 743, + "y": 280, + "w": 90, + "ch": 1, + "s": ["200", "300", "400", "600", "700", "800", "900"] + }, + "Bitcount": { + "x": 847, + "y": 280, + "w": 124, + "ch": 1, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Bitcount Grid Double": { + "x": 0, + "y": 320, + "w": 296, + "ch": 1, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Bitcount Grid Double Ink": { + "x": 310, + "y": 320, + "w": 354, + "ch": 1, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Bitcount Grid Single": { + "x": 678, + "y": 320, + "w": 296, + "ch": 1, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Bitcount Grid Single Ink": { + "x": 0, + "y": 360, + "w": 354, + "ch": 1, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Bitcount Ink": { + "x": 368, + "y": 360, + "w": 181, + "ch": 1, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Bitcount Prop Double": { + "x": 563, + "y": 360, + "w": 265, + "ch": 1, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Bitcount Prop Double Ink": { + "x": 842, + "y": 360, + "w": 308, + "ch": 1, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Bitcount Prop Single": { + "x": 0, + "y": 400, + "w": 244, + "ch": 1, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Bitcount Prop Single Ink": { + "x": 258, + "y": 400, + "w": 282, + "ch": 1, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Bitcount Single": { + "x": 554, + "y": 400, + "w": 224, + "ch": 1, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Bitcount Single Ink": { + "x": 792, + "y": 400, + "w": 282, + "ch": 1, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Bitter": { + "x": 1088, + "y": 400, + "w": 71, + "ch": 1, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "BIZ UDGothic": { + "x": 0, + "y": 440, + "w": 152, + "ch": 1, + "s": ["400", "700"] + }, + "BIZ UDMincho": { + "x": 166, + "y": 440, + "w": 152, + "ch": 1, + "s": ["400", "700"] + }, + "BIZ UDPGothic": { + "x": 332, + "y": 440, + "w": 209, + "ch": 1, + "s": ["400", "700"] + }, + "BIZ UDPMincho": { + "x": 555, + "y": 440, + "w": 208, + "ch": 1, + "s": ["400", "700"] + }, + "Black And White Picture": { + "x": 777, + "y": 440, + "w": 218, + "ch": 1, + "s": ["400"] + }, + "Black Han Sans": { "x": 0, "y": 480, "w": 212, "ch": 1, "s": ["400"] }, + "Black Ops One": { "x": 226, "y": 480, "w": 191, "ch": 1, "s": ["400"] }, + "Blaka": { "x": 431, "y": 480, "w": 57, "ch": 1, "s": ["400"] }, + "Blaka Hollow": { "x": 502, "y": 480, "w": 119, "ch": 1, "s": ["400"] }, + "Blaka Ink": { "x": 635, "y": 480, "w": 87, "ch": 1, "s": ["400"] }, + "Blinker": { + "x": 736, + "y": 480, + "w": 77, + "ch": 1, + "s": ["100", "200", "300", "400", "600", "700", "800", "900"] + }, + "Bodoni Moda": { + "x": 827, + "y": 480, + "w": 155, + "ch": 1, + "s": [ + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Bodoni Moda SC": { + "x": 996, + "y": 480, + "w": 200, + "ch": 1, + "s": [ + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Bokor": { "x": 0, "y": 520, "w": 58, "ch": 1, "s": ["400"] }, + "Boldonse": { "x": 72, "y": 520, "w": 147, "ch": 1, "s": ["400"] }, + "Bona Nova": { + "x": 233, + "y": 520, + "w": 124, + "ch": 1, + "s": ["400", "400i", "700", "700i"] + }, + "Bona Nova SC": { + "x": 371, + "y": 520, + "w": 170, + "ch": 1, + "s": ["400", "400i", "700", "700i"] + }, + "Bonbon": { "x": 555, "y": 520, "w": 104, "ch": 1, "s": ["400"] }, + "Bonheur Royale": { "x": 673, "y": 520, "w": 124, "ch": 1, "s": ["400"] }, + "Boogaloo": { "x": 811, "y": 520, "w": 81, "ch": 1, "s": ["400"] }, + "Borel": { "x": 906, "y": 520, "w": 69, "ch": 1, "s": ["400"] }, + "Bowlby One": { "x": 989, "y": 520, "w": 174, "ch": 1, "s": ["400"] }, + "Bowlby One SC": { "x": 0, "y": 560, "w": 213, "ch": 1, "s": ["400"] }, + "Braah One": { "x": 227, "y": 560, "w": 124, "ch": 1, "s": ["400"] }, + "Brawler": { "x": 365, "y": 560, "w": 95, "ch": 1, "s": ["400", "700"] }, + "Bree Serif": { "x": 474, "y": 560, "w": 113, "ch": 1, "s": ["400"] }, + "Bricolage Grotesque": { + "x": 601, + "y": 560, + "w": 238, + "ch": 1, + "s": ["200", "300", "400", "500", "600", "700", "800"] + }, + "Briem Hand": { + "x": 853, + "y": 560, + "w": 146, + "ch": 1, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Bruno Ace": { "x": 1013, "y": 560, "w": 168, "ch": 1, "s": ["400"] }, + "Bruno Ace SC": { "x": 0, "y": 600, "w": 219, "ch": 1, "s": ["400"] }, + "Brygada 1918": { + "x": 233, + "y": 600, + "w": 157, + "ch": 1, + "s": ["400", "400i", "500", "500i", "600", "600i", "700", "700i"] + }, + "Bubblegum Sans": { "x": 404, "y": 600, "w": 155, "ch": 1, "s": ["400"] }, + "Bubbler One": { "x": 573, "y": 600, "w": 117, "ch": 1, "s": ["400"] }, + "Buenard": { + "x": 704, + "y": 600, + "w": 93, + "ch": 1, + "s": ["400", "500", "600", "700"] + }, + "Bungee": { "x": 811, "y": 600, "w": 110, "ch": 1, "s": ["400"] }, + "Bungee Hairline": { "x": 935, "y": 600, "w": 249, "ch": 1, "s": ["400"] }, + "Bungee Inline": { "x": 0, "y": 640, "w": 213, "ch": 1, "s": ["400"] }, + "Bungee Outline": { "x": 227, "y": 640, "w": 232, "ch": 1, "s": ["400"] }, + "Bungee Shade": { "x": 473, "y": 640, "w": 229, "ch": 1, "s": ["400"] }, + "Bungee Spice": { "x": 716, "y": 640, "w": 193, "ch": 1, "s": ["400"] }, + "Bungee Tint": { "x": 923, "y": 640, "w": 179, "ch": 1, "s": ["400"] }, + "Butcherman": { "x": 0, "y": 680, "w": 161, "ch": 1, "s": ["400"] }, + "Butterfly Kids": { "x": 175, "y": 680, "w": 116, "ch": 1, "s": ["400"] }, + "Bytesized": { "x": 305, "y": 680, "w": 116, "ch": 1, "s": ["400"] }, + "Cabin": { + "x": 435, + "y": 680, + "w": 67, + "ch": 1, + "s": ["400", "400i", "500", "500i", "600", "600i", "700", "700i"] + }, + "Cabin Condensed": { + "x": 516, + "y": 680, + "w": 163, + "ch": 1, + "s": ["400", "500", "600", "700"] + }, + "Cabin Sketch": { + "x": 693, + "y": 680, + "w": 141, + "ch": 1, + "s": ["400", "700"] + }, + "Cactus Classical Serif": { + "x": 848, + "y": 680, + "w": 235, + "ch": 1, + "s": ["400"] + }, + "Caesar Dressing": { "x": 0, "y": 720, "w": 164, "ch": 1, "s": ["400"] }, + "Cagliostro": { "x": 178, "y": 720, "w": 115, "ch": 1, "s": ["400"] }, + "Cairo": { + "x": 307, + "y": 720, + "w": 60, + "ch": 1, + "s": ["200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Cairo Play": { + "x": 381, + "y": 720, + "w": 108, + "ch": 1, + "s": ["200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Cal Sans": { "x": 503, "y": 720, "w": 99, "ch": 1, "s": ["400"] }, + "Caladea": { + "x": 616, + "y": 720, + "w": 84, + "ch": 1, + "s": ["400", "400i", "700", "700i"] + }, + "Calistoga": { "x": 714, "y": 720, "w": 109, "ch": 1, "s": ["400"] }, + "Calligraffitti": { "x": 837, "y": 720, "w": 124, "ch": 1, "s": ["400"] }, + "Cambay": { + "x": 975, + "y": 720, + "w": 92, + "ch": 1, + "s": ["400", "400i", "700", "700i"] + }, + "Cambo": { "x": 1081, "y": 720, "w": 82, "ch": 1, "s": ["400"] }, + "Candal": { "x": 0, "y": 760, "w": 102, "ch": 1, "s": ["400"] }, + "Cantarell": { + "x": 116, + "y": 760, + "w": 109, + "ch": 1, + "s": ["400", "400i", "700", "700i"] + }, + "Cantata One": { "x": 239, "y": 760, "w": 163, "ch": 1, "s": ["400"] }, + "Cantora One": { "x": 416, "y": 760, "w": 134, "ch": 1, "s": ["400"] }, + "Caprasimo": { "x": 564, "y": 760, "w": 139, "ch": 1, "s": ["400"] }, + "Capriola": { "x": 717, "y": 760, "w": 110, "ch": 1, "s": ["400"] }, + "Caramel": { "x": 841, "y": 760, "w": 66, "ch": 1, "s": ["400"] }, + "Carattere": { "x": 921, "y": 760, "w": 79, "ch": 1, "s": ["400"] }, + "Cardo": { + "x": 1014, + "y": 760, + "w": 72, + "ch": 1, + "s": ["400", "400i", "700", "700i"] + }, + "Carlito": { + "x": 1100, + "y": 760, + "w": 73, + "ch": 1, + "s": ["400", "400i", "700", "700i"] + }, + "Carme": { "x": 0, "y": 0, "w": 79, "ch": 2, "s": ["400"] }, + "Carrois Gothic": { "x": 93, "y": 0, "w": 155, "ch": 2, "s": ["400"] }, + "Carrois Gothic SC": { "x": 262, "y": 0, "w": 211, "ch": 2, "s": ["400"] }, + "Carter One": { "x": 487, "y": 0, "w": 136, "ch": 2, "s": ["400"] }, + "Cascadia Code": { + "x": 637, + "y": 0, + "w": 191, + "ch": 2, + "s": [ + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i" + ] + }, + "Cascadia Mono": { + "x": 842, + "y": 0, + "w": 191, + "ch": 2, + "s": [ + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i" + ] + }, + "Castoro": { "x": 1047, "y": 0, "w": 93, "ch": 2, "s": ["400", "400i"] }, + "Castoro Titling": { "x": 0, "y": 40, "w": 236, "ch": 2, "s": ["400"] }, + "Catamaran": { + "x": 250, + "y": 40, + "w": 116, + "ch": 2, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Caudex": { + "x": 380, + "y": 40, + "w": 92, + "ch": 2, + "s": ["400", "400i", "700", "700i"] + }, + "Cause": { + "x": 486, + "y": 40, + "w": 72, + "ch": 2, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Caveat": { + "x": 572, + "y": 40, + "w": 63, + "ch": 2, + "s": ["400", "500", "600", "700"] + }, + "Caveat Brush": { "x": 649, "y": 40, "w": 120, "ch": 2, "s": ["400"] }, + "Cedarville Cursive": { + "x": 783, + "y": 40, + "w": 203, + "ch": 2, + "s": ["400"] + }, + "Ceviche One": { "x": 1000, "y": 40, "w": 111, "ch": 2, "s": ["400"] }, + "Chakra Petch": { + "x": 0, + "y": 80, + "w": 154, + "ch": 2, + "s": [ + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i" + ] + }, + "Changa": { + "x": 168, + "y": 80, + "w": 86, + "ch": 2, + "s": ["200", "300", "400", "500", "600", "700", "800"] + }, + "Changa One": { + "x": 268, + "y": 80, + "w": 136, + "ch": 2, + "s": ["400", "400i"] + }, + "Chango": { "x": 418, "y": 80, "w": 128, "ch": 2, "s": ["400"] }, + "Charis SIL": { + "x": 560, + "y": 80, + "w": 116, + "ch": 2, + "s": ["400", "400i", "700", "700i"] + }, + "Charm": { "x": 690, "y": 80, "w": 69, "ch": 2, "s": ["400", "700"] }, + "Charmonman": { "x": 773, "y": 80, "w": 136, "ch": 2, "s": ["400", "700"] }, + "Chathura": { + "x": 923, + "y": 80, + "w": 55, + "ch": 2, + "s": ["100", "300", "400", "700", "800"] + }, + "Chau Philomene One": { + "x": 992, + "y": 80, + "w": 200, + "ch": 2, + "s": ["400", "400i"] + }, + "Chela One": { "x": 0, "y": 120, "w": 97, "ch": 2, "s": ["400"] }, + "Chelsea Market": { "x": 111, "y": 120, "w": 200, "ch": 2, "s": ["400"] }, + "Cherish": { "x": 325, "y": 120, "w": 60, "ch": 2, "s": ["400"] }, + "Cherry Bomb One": { "x": 399, "y": 120, "w": 200, "ch": 2, "s": ["400"] }, + "Cherry Cream Soda": { + "x": 613, + "y": 120, + "w": 274, + "ch": 2, + "s": ["400"] + }, + "Cherry Swash": { + "x": 901, + "y": 120, + "w": 159, + "ch": 2, + "s": ["400", "700"] + }, + "Chewy": { "x": 1074, "y": 120, "w": 73, "ch": 2, "s": ["400"] }, + "Chicle": { "x": 0, "y": 160, "w": 56, "ch": 2, "s": ["400"] }, + "Chilanka": { "x": 70, "y": 160, "w": 99, "ch": 2, "s": ["400"] }, + "Chiron GoRound TC": { + "x": 183, + "y": 160, + "w": 225, + "ch": 2, + "s": ["200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Chiron Hei HK": { + "x": 422, + "y": 160, + "w": 163, + "ch": 2, + "s": [ + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Chivo": { + "x": 599, + "y": 160, + "w": 72, + "ch": 2, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Chivo Mono": { + "x": 685, + "y": 160, + "w": 152, + "ch": 2, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Chocolate Classical Sans": { + "x": 851, + "y": 160, + "w": 283, + "ch": 2, + "s": ["400"] + }, + "Chokokutai": { "x": 0, "y": 200, "w": 148, "ch": 2, "s": ["400"] }, + "Chonburi": { "x": 162, "y": 200, "w": 138, "ch": 2, "s": ["400"] }, + "Cinzel": { + "x": 314, + "y": 200, + "w": 94, + "ch": 2, + "s": ["400", "500", "600", "700", "800", "900"] + }, + "Cinzel Decorative": { + "x": 422, + "y": 200, + "w": 262, + "ch": 2, + "s": ["400", "700", "900"] + }, + "Clicker Script": { "x": 698, "y": 200, "w": 117, "ch": 2, "s": ["400"] }, + "Climate Crisis": { "x": 829, "y": 200, "w": 247, "ch": 2, "s": ["400"] }, + "Coda": { "x": 1090, "y": 200, "w": 65, "ch": 2, "s": ["400", "800"] }, + "Codystar": { "x": 0, "y": 240, "w": 137, "ch": 2, "s": ["300", "400"] }, + "Coiny": { "x": 151, "y": 240, "w": 79, "ch": 2, "s": ["400"] }, + "Combo": { "x": 244, "y": 240, "w": 69, "ch": 2, "s": ["400"] }, + "Comfortaa": { + "x": 327, + "y": 240, + "w": 143, + "ch": 2, + "s": ["300", "400", "500", "600", "700"] + }, + "Comforter": { "x": 484, "y": 240, "w": 99, "ch": 2, "s": ["400"] }, + "Comforter Brush": { "x": 597, "y": 240, "w": 147, "ch": 2, "s": ["400"] }, + "Comic Neue": { + "x": 758, + "y": 240, + "w": 133, + "ch": 2, + "s": ["300", "300i", "400", "400i", "700", "700i"] + }, + "Comic Relief": { + "x": 905, + "y": 240, + "w": 147, + "ch": 2, + "s": ["400", "700"] + }, + "Coming Soon": { "x": 0, "y": 280, "w": 152, "ch": 2, "s": ["400"] }, + "Comme": { + "x": 166, + "y": 280, + "w": 96, + "ch": 2, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Commissioner": { + "x": 276, + "y": 280, + "w": 166, + "ch": 2, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Concert One": { "x": 456, "y": 280, "w": 136, "ch": 2, "s": ["400"] }, + "Condiment": { "x": 606, "y": 280, "w": 96, "ch": 2, "s": ["400"] }, + "Contrail One": { "x": 716, "y": 280, "w": 128, "ch": 2, "s": ["400"] }, + "Convergence": { "x": 858, "y": 280, "w": 155, "ch": 2, "s": ["400"] }, + "Cookie": { "x": 1027, "y": 280, "w": 55, "ch": 2, "s": ["400"] }, + "Copse": { "x": 1096, "y": 280, "w": 73, "ch": 2, "s": ["400"] }, + "Coral Pixels": { "x": 0, "y": 320, "w": 140, "ch": 2, "s": ["400"] }, + "Corben": { "x": 154, "y": 320, "w": 91, "ch": 2, "s": ["400", "700"] }, + "Corinthia": { "x": 259, "y": 320, "w": 69, "ch": 2, "s": ["400", "700"] }, + "Cormorant": { + "x": 342, + "y": 320, + "w": 113, + "ch": 2, + "s": [ + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i" + ] + }, + "Cormorant Garamond": { + "x": 469, + "y": 320, + "w": 220, + "ch": 2, + "s": [ + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i" + ] + }, + "Cormorant Infant": { + "x": 703, + "y": 320, + "w": 181, + "ch": 2, + "s": [ + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i" + ] + }, + "Cormorant SC": { + "x": 898, + "y": 320, + "w": 167, + "ch": 2, + "s": ["300", "400", "500", "600", "700"] + }, + "Cormorant Unicase": { + "x": 0, + "y": 360, + "w": 222, + "ch": 2, + "s": ["300", "400", "500", "600", "700"] + }, + "Cormorant Upright": { + "x": 236, + "y": 360, + "w": 188, + "ch": 2, + "s": ["300", "400", "500", "600", "700"] + }, + "Cossette Texte": { + "x": 438, + "y": 360, + "w": 164, + "ch": 2, + "s": ["400", "700"] + }, + "Cossette Titre": { + "x": 616, + "y": 360, + "w": 116, + "ch": 2, + "s": ["400", "700"] + }, + "Courgette": { "x": 746, "y": 360, "w": 109, "ch": 2, "s": ["400"] }, + "Courier Prime": { + "x": 869, + "y": 360, + "w": 196, + "ch": 2, + "s": ["400", "400i", "700", "700i"] + }, + "Cousine": { + "x": 1079, + "y": 360, + "w": 109, + "ch": 2, + "s": ["400", "400i", "700", "700i"] + }, + "Coustard": { "x": 0, "y": 400, "w": 115, "ch": 2, "s": ["400", "900"] }, + "Covered By Your Grace": { + "x": 129, + "y": 400, + "w": 206, + "ch": 2, + "s": ["400"] + }, + "Crafty Girls": { "x": 349, "y": 400, "w": 153, "ch": 2, "s": ["400"] }, + "Creepster": { "x": 516, "y": 400, "w": 101, "ch": 2, "s": ["400"] }, + "Crete Round": { + "x": 631, + "y": 400, + "w": 142, + "ch": 2, + "s": ["400", "400i"] + }, + "Crimson Pro": { + "x": 787, + "y": 400, + "w": 127, + "ch": 2, + "s": [ + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Crimson Text": { + "x": 928, + "y": 400, + "w": 139, + "ch": 2, + "s": ["400", "400i", "600", "600i", "700", "700i"] + }, + "Croissant One": { "x": 0, "y": 440, "w": 186, "ch": 2, "s": ["400"] }, + "Crushed": { "x": 200, "y": 440, "w": 79, "ch": 2, "s": ["400"] }, + "Cuprum": { + "x": 293, + "y": 440, + "w": 77, + "ch": 2, + "s": ["400", "400i", "500", "500i", "600", "600i", "700", "700i"] + }, + "Cute Font": { "x": 384, "y": 440, "w": 78, "ch": 2, "s": ["400"] }, + "Cutive": { "x": 476, "y": 440, "w": 98, "ch": 2, "s": ["400"] }, + "Cutive Mono": { "x": 588, "y": 440, "w": 168, "ch": 2, "s": ["400"] }, + "Dai Banna SIL": { + "x": 770, + "y": 440, + "w": 137, + "ch": 2, + "s": [ + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i" + ] + }, + "Damion": { "x": 921, "y": 440, "w": 78, "ch": 2, "s": ["400"] }, + "Dancing Script": { + "x": 1013, + "y": 440, + "w": 137, + "ch": 2, + "s": ["400", "500", "600", "700"] + }, + "Danfo": { "x": 0, "y": 480, "w": 86, "ch": 2, "s": ["400"] }, + "Dangrek": { "x": 100, "y": 480, "w": 91, "ch": 2, "s": ["400"] }, + "Darker Grotesque": { + "x": 205, + "y": 480, + "w": 163, + "ch": 2, + "s": ["300", "400", "500", "600", "700", "800", "900"] + }, + "Darumadrop One": { "x": 382, "y": 480, "w": 184, "ch": 2, "s": ["400"] }, + "David Libre": { + "x": 580, + "y": 480, + "w": 120, + "ch": 2, + "s": ["400", "500", "700"] + }, + "Dawning of a New Day": { + "x": 714, + "y": 480, + "w": 208, + "ch": 2, + "s": ["400"] + }, + "Days One": { "x": 936, "y": 480, "w": 133, "ch": 2, "s": ["400"] }, + "Dekko": { "x": 1083, "y": 480, "w": 62, "ch": 2, "s": ["400"] }, + "Dela Gothic One": { "x": 0, "y": 520, "w": 234, "ch": 2, "s": ["400"] }, + "Delicious Handrawn": { + "x": 248, + "y": 520, + "w": 161, + "ch": 2, + "s": ["400"] + }, + "Delius": { "x": 423, "y": 520, "w": 74, "ch": 2, "s": ["400"] }, + "Delius Swash Caps": { + "x": 511, + "y": 520, + "w": 214, + "ch": 2, + "s": ["400"] + }, + "Delius Unicase": { + "x": 739, + "y": 520, + "w": 229, + "ch": 2, + "s": ["400", "700"] + }, + "Della Respira": { "x": 982, "y": 520, "w": 149, "ch": 2, "s": ["400"] }, + "Denk One": { "x": 0, "y": 560, "w": 105, "ch": 2, "s": ["400"] }, + "Devonshire": { "x": 119, "y": 560, "w": 87, "ch": 2, "s": ["400"] }, + "Dhurjati": { "x": 220, "y": 560, "w": 75, "ch": 2, "s": ["400"] }, + "Didact Gothic": { "x": 309, "y": 560, "w": 145, "ch": 2, "s": ["400"] }, + "Diphylleia": { "x": 468, "y": 560, "w": 112, "ch": 2, "s": ["400"] }, + "Diplomata": { "x": 594, "y": 560, "w": 252, "ch": 2, "s": ["400"] }, + "Diplomata SC": { "x": 860, "y": 560, "w": 334, "ch": 2, "s": ["400"] }, + "DM Mono": { + "x": 0, + "y": 600, + "w": 109, + "ch": 2, + "s": ["300", "300i", "400", "400i", "500", "500i"] + }, + "DM Sans": { + "x": 123, + "y": 600, + "w": 104, + "ch": 2, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "DM Serif Display": { + "x": 241, + "y": 600, + "w": 181, + "ch": 2, + "s": ["400", "400i"] + }, + "DM Serif Text": { + "x": 436, + "y": 600, + "w": 150, + "ch": 2, + "s": ["400", "400i"] + }, + "Do Hyeon": { "x": 600, "y": 600, "w": 101, "ch": 2, "s": ["400"] }, + "Dokdo": { "x": 715, "y": 600, "w": 68, "ch": 2, "s": ["400"] }, + "Domine": { + "x": 797, + "y": 600, + "w": 101, + "ch": 2, + "s": ["400", "500", "600", "700"] + }, + "Donegal One": { "x": 912, "y": 600, "w": 161, "ch": 2, "s": ["400"] }, + "Dongle": { + "x": 1087, + "y": 600, + "w": 56, + "ch": 2, + "s": ["300", "400", "700"] + }, + "Doppio One": { "x": 0, "y": 640, "w": 138, "ch": 2, "s": ["400"] }, + "Dorsa": { "x": 152, "y": 640, "w": 32, "ch": 2, "s": ["400"] }, + "Dosis": { + "x": 198, + "y": 640, + "w": 56, + "ch": 2, + "s": ["200", "300", "400", "500", "600", "700", "800"] + }, + "DotGothic16": { "x": 268, "y": 640, "w": 140, "ch": 2, "s": ["400"] }, + "Doto": { + "x": 422, + "y": 640, + "w": 66, + "ch": 2, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Dr Sugiyama": { "x": 502, "y": 640, "w": 116, "ch": 2, "s": ["400"] }, + "Duru Sans": { "x": 632, "y": 640, "w": 133, "ch": 2, "s": ["400"] }, + "Dynalight": { "x": 779, "y": 640, "w": 84, "ch": 2, "s": ["400"] }, + "DynaPuff": { + "x": 877, + "y": 640, + "w": 121, + "ch": 2, + "s": ["400", "500", "600", "700"] + }, + "Eagle Lake": { "x": 1012, "y": 640, "w": 153, "ch": 2, "s": ["400"] }, + "East Sea Dokdo": { "x": 0, "y": 680, "w": 118, "ch": 2, "s": ["400"] }, + "Eater": { "x": 132, "y": 680, "w": 90, "ch": 2, "s": ["400"] }, + "EB Garamond": { + "x": 236, + "y": 680, + "w": 141, + "ch": 2, + "s": [ + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i" + ] + }, + "Economica": { + "x": 391, + "y": 680, + "w": 88, + "ch": 2, + "s": ["400", "400i", "700", "700i"] + }, + "Eczar": { + "x": 493, + "y": 680, + "w": 65, + "ch": 2, + "s": ["400", "500", "600", "700", "800"] + }, + "Edu AU VIC WA NT Arrows": { + "x": 572, + "y": 680, + "w": 302, + "ch": 2, + "s": ["400", "500", "600", "700"] + }, + "Edu AU VIC WA NT Dots": { + "x": 888, + "y": 680, + "w": 261, + "ch": 2, + "s": ["400", "500", "600", "700"] + }, + "Edu AU VIC WA NT Guides": { + "x": 0, + "y": 720, + "w": 285, + "ch": 2, + "s": ["400", "500", "600", "700"] + }, + "Edu AU VIC WA NT Hand": { + "x": 299, + "y": 720, + "w": 274, + "ch": 2, + "s": ["400", "500", "600", "700"] + }, + "Edu AU VIC WA NT Pre": { + "x": 587, + "y": 720, + "w": 257, + "ch": 2, + "s": ["400", "500", "600", "700"] + }, + "Edu NSW ACT Cursive": { + "x": 858, + "y": 720, + "w": 276, + "ch": 2, + "s": ["400", "500", "600", "700"] + }, + "Edu NSW ACT Foundation": { + "x": 0, + "y": 760, + "w": 239, + "ch": 2, + "s": ["400", "500", "600", "700"] + }, + "Edu NSW ACT Hand Pre": { + "x": 253, + "y": 760, + "w": 294, + "ch": 2, + "s": ["400", "500", "600", "700"] + }, + "Edu QLD Beginner": { + "x": 561, + "y": 760, + "w": 177, + "ch": 2, + "s": ["400", "500", "600", "700"] + }, + "Edu QLD Hand": { + "x": 752, + "y": 760, + "w": 175, + "ch": 2, + "s": ["400", "500", "600", "700"] + }, + "Edu SA Beginner": { + "x": 941, + "y": 760, + "w": 155, + "ch": 2, + "s": ["400", "500", "600", "700"] + }, + "Edu SA Hand": { + "x": 0, + "y": 0, + "w": 159, + "ch": 3, + "s": ["400", "500", "600", "700"] + }, + "Edu TAS Beginner": { + "x": 173, + "y": 0, + "w": 175, + "ch": 3, + "s": ["400", "500", "600", "700"] + }, + "Edu VIC WA NT Beginner": { + "x": 362, + "y": 0, + "w": 243, + "ch": 3, + "s": ["400", "500", "600", "700"] + }, + "Edu VIC WA NT Hand": { + "x": 619, + "y": 0, + "w": 230, + "ch": 3, + "s": ["400", "500", "600", "700"] + }, + "Edu VIC WA NT Hand Pre": { + "x": 863, + "y": 0, + "w": 282, + "ch": 3, + "s": ["400", "500", "600", "700"] + }, + "El Messiri": { + "x": 0, + "y": 40, + "w": 109, + "ch": 3, + "s": ["400", "500", "600", "700"] + }, + "Electrolize": { "x": 123, "y": 40, "w": 123, "ch": 3, "s": ["400"] }, + "Elms Sans": { + "x": 260, + "y": 40, + "w": 123, + "ch": 3, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Elsie": { "x": 397, "y": 40, "w": 61, "ch": 3, "s": ["400", "900"] }, + "Elsie Swash Caps": { + "x": 472, + "y": 40, + "w": 190, + "ch": 3, + "s": ["400", "900"] + }, + "Emblema One": { "x": 676, "y": 40, "w": 198, "ch": 3, "s": ["400"] }, + "Emilys Candy": { "x": 888, "y": 40, "w": 148, "ch": 3, "s": ["400"] }, + "Encode Sans": { + "x": 1050, + "y": 40, + "w": 141, + "ch": 3, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Encode Sans Condensed": { + "x": 0, + "y": 80, + "w": 227, + "ch": 3, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Encode Sans Expanded": { + "x": 241, + "y": 80, + "w": 287, + "ch": 3, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Encode Sans SC": { + "x": 542, + "y": 80, + "w": 186, + "ch": 3, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Encode Sans Semi Condensed": { + "x": 742, + "y": 80, + "w": 299, + "ch": 3, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Encode Sans Semi Expanded": { + "x": 0, + "y": 120, + "w": 330, + "ch": 3, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Engagement": { "x": 344, "y": 120, "w": 80, "ch": 3, "s": ["400"] }, + "Englebert": { "x": 438, "y": 120, "w": 97, "ch": 3, "s": ["400"] }, + "Enriqueta": { + "x": 549, + "y": 120, + "w": 115, + "ch": 3, + "s": ["400", "500", "600", "700"] + }, + "Ephesis": { "x": 678, "y": 120, "w": 72, "ch": 3, "s": ["400"] }, + "Epilogue": { + "x": 764, + "y": 120, + "w": 109, + "ch": 3, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Epunda Sans": { + "x": 887, + "y": 120, + "w": 133, + "ch": 3, + "s": [ + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Epunda Slab": { + "x": 1034, + "y": 120, + "w": 132, + "ch": 3, + "s": [ + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Erica One": { "x": 0, "y": 160, "w": 131, "ch": 3, "s": ["400"] }, + "Esteban": { "x": 145, "y": 160, "w": 93, "ch": 3, "s": ["400"] }, + "Estonia": { "x": 252, "y": 160, "w": 47, "ch": 3, "s": ["400"] }, + "Euphoria Script": { "x": 313, "y": 160, "w": 127, "ch": 3, "s": ["400"] }, + "Ewert": { "x": 454, "y": 160, "w": 97, "ch": 3, "s": ["400"] }, + "Exile": { "x": 565, "y": 160, "w": 81, "ch": 3, "s": ["400"] }, + "Exo": { + "x": 660, + "y": 160, + "w": 48, + "ch": 3, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Exo 2": { + "x": 722, + "y": 160, + "w": 67, + "ch": 3, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Expletus Sans": { + "x": 803, + "y": 160, + "w": 159, + "ch": 3, + "s": ["400", "400i", "500", "500i", "600", "600i", "700", "700i"] + }, + "Explora": { "x": 976, "y": 160, "w": 87, "ch": 3, "s": ["400"] }, + "Faculty Glyphic": { "x": 0, "y": 200, "w": 182, "ch": 3, "s": ["400"] }, + "Fahkwang": { + "x": 196, + "y": 200, + "w": 133, + "ch": 3, + "s": [ + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i" + ] + }, + "Familjen Grotesk": { + "x": 343, + "y": 200, + "w": 178, + "ch": 3, + "s": ["400", "400i", "500", "500i", "600", "600i", "700", "700i"] + }, + "Fanwood Text": { + "x": 535, + "y": 200, + "w": 140, + "ch": 3, + "s": ["400", "400i"] + }, + "Farro": { + "x": 689, + "y": 200, + "w": 72, + "ch": 3, + "s": ["300", "400", "500", "700"] + }, + "Farsan": { "x": 775, "y": 200, "w": 61, "ch": 3, "s": ["400"] }, + "Fascinate": { "x": 850, "y": 200, "w": 135, "ch": 3, "s": ["400"] }, + "Fascinate Inline": { "x": 0, "y": 240, "w": 220, "ch": 3, "s": ["400"] }, + "Faster One": { "x": 234, "y": 240, "w": 165, "ch": 3, "s": ["400"] }, + "Fasthand": { "x": 413, "y": 240, "w": 84, "ch": 3, "s": ["400"] }, + "Fauna One": { "x": 511, "y": 240, "w": 141, "ch": 3, "s": ["400"] }, + "Faustina": { + "x": 666, + "y": 240, + "w": 97, + "ch": 3, + "s": [ + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i" + ] + }, + "Federant": { "x": 777, "y": 240, "w": 105, "ch": 3, "s": ["400"] }, + "Federo": { "x": 896, "y": 240, "w": 78, "ch": 3, "s": ["400"] }, + "Felipa": { "x": 988, "y": 240, "w": 60, "ch": 3, "s": ["400"] }, + "Fenix": { "x": 1062, "y": 240, "w": 63, "ch": 3, "s": ["400"] }, + "Festive": { "x": 0, "y": 280, "w": 67, "ch": 3, "s": ["400"] }, + "Figtree": { + "x": 81, + "y": 280, + "w": 85, + "ch": 3, + "s": [ + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Finger Paint": { "x": 180, "y": 280, "w": 158, "ch": 3, "s": ["400"] }, + "Finlandica": { + "x": 352, + "y": 280, + "w": 114, + "ch": 3, + "s": ["400", "400i", "500", "500i", "600", "600i", "700", "700i"] + }, + "Fira Code": { + "x": 480, + "y": 280, + "w": 138, + "ch": 3, + "s": ["300", "400", "500", "600", "700"] + }, + "Fira Mono": { + "x": 632, + "y": 280, + "w": 138, + "ch": 3, + "s": ["400", "500", "700"] + }, + "Fira Sans": { + "x": 784, + "y": 280, + "w": 105, + "ch": 3, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Fira Sans Condensed": { + "x": 903, + "y": 280, + "w": 211, + "ch": 3, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Fira Sans Extra Condensed": { + "x": 0, + "y": 320, + "w": 244, + "ch": 3, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Fjalla One": { "x": 258, "y": 320, "w": 98, "ch": 3, "s": ["400"] }, + "Fjord One": { "x": 370, "y": 320, "w": 115, "ch": 3, "s": ["400"] }, + "Flamenco": { "x": 499, "y": 320, "w": 99, "ch": 3, "s": ["300", "400"] }, + "Flavors": { "x": 612, "y": 320, "w": 82, "ch": 3, "s": ["400"] }, + "Fleur De Leah": { "x": 708, "y": 320, "w": 133, "ch": 3, "s": ["400"] }, + "Flow Block": { "x": 855, "y": 320, "w": 126, "ch": 3, "s": ["400"] }, + "Flow Circular": { "x": 995, "y": 320, "w": 145, "ch": 3, "s": ["400"] }, + "Flow Rounded": { "x": 0, "y": 360, "w": 160, "ch": 3, "s": ["400"] }, + "Foldit": { + "x": 174, + "y": 360, + "w": 54, + "ch": 3, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Fondamento": { + "x": 242, + "y": 360, + "w": 133, + "ch": 3, + "s": ["400", "400i"] + }, + "Fontdiner Swanky": { "x": 389, "y": 360, "w": 239, "ch": 3, "s": ["400"] }, + "Forum": { "x": 642, "y": 360, "w": 70, "ch": 3, "s": ["400"] }, + "Fragment Mono": { + "x": 726, + "y": 360, + "w": 201, + "ch": 3, + "s": ["400", "400i"] + }, + "Francois One": { "x": 941, "y": 360, "w": 136, "ch": 3, "s": ["400"] }, + "Frank Ruhl Libre": { + "x": 0, + "y": 400, + "w": 184, + "ch": 3, + "s": ["300", "400", "500", "600", "700", "800", "900"] + }, + "Fraunces": { + "x": 198, + "y": 400, + "w": 112, + "ch": 3, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Freckle Face": { "x": 324, "y": 400, "w": 141, "ch": 3, "s": ["400"] }, + "Fredericka the Great": { + "x": 479, + "y": 400, + "w": 243, + "ch": 3, + "s": ["400"] + }, + "Fredoka": { + "x": 736, + "y": 400, + "w": 96, + "ch": 3, + "s": ["300", "400", "500", "600", "700"] + }, + "Fredoka One": { "x": 846, "y": 400, "w": 155, "ch": 3, "s": ["400"] }, + "Freehand": { "x": 1015, "y": 400, "w": 85, "ch": 3, "s": ["400"] }, + "Freeman": { "x": 0, "y": 440, "w": 91, "ch": 3, "s": ["400"] }, + "Fresca": { "x": 105, "y": 440, "w": 67, "ch": 3, "s": ["400"] }, + "Frijole": { "x": 186, "y": 440, "w": 132, "ch": 3, "s": ["400"] }, + "Fruktur": { "x": 332, "y": 440, "w": 94, "ch": 3, "s": ["400", "400i"] }, + "Fugaz One": { "x": 440, "y": 440, "w": 127, "ch": 3, "s": ["400"] }, + "Fuggles": { "x": 581, "y": 440, "w": 58, "ch": 3, "s": ["400"] }, + "Funnel Display": { + "x": 653, + "y": 440, + "w": 174, + "ch": 3, + "s": ["300", "400", "500", "600", "700", "800"] + }, + "Funnel Sans": { + "x": 841, + "y": 440, + "w": 140, + "ch": 3, + "s": [ + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i" + ] + }, + "Fustat": { + "x": 995, + "y": 440, + "w": 75, + "ch": 3, + "s": ["200", "300", "400", "500", "600", "700", "800"] + }, + "Fuzzy Bubbles": { + "x": 0, + "y": 480, + "w": 179, + "ch": 3, + "s": ["400", "700"] + }, + "Ga Maamli": { "x": 193, "y": 480, "w": 111, "ch": 3, "s": ["400"] }, + "Gabarito": { + "x": 318, + "y": 480, + "w": 99, + "ch": 3, + "s": ["400", "500", "600", "700", "800", "900"] + }, + "Gabriela": { "x": 431, "y": 480, "w": 106, "ch": 3, "s": ["400"] }, + "Gaegu": { + "x": 551, + "y": 480, + "w": 70, + "ch": 3, + "s": ["300", "400", "700"] + }, + "Gafata": { "x": 635, "y": 480, "w": 72, "ch": 3, "s": ["400"] }, + "Gajraj One": { "x": 721, "y": 480, "w": 148, "ch": 3, "s": ["400"] }, + "Galada": { "x": 883, "y": 480, "w": 78, "ch": 3, "s": ["400"] }, + "Galdeano": { "x": 975, "y": 480, "w": 98, "ch": 3, "s": ["400"] }, + "Galindo": { "x": 1087, "y": 480, "w": 101, "ch": 3, "s": ["400"] }, + "Gamja Flower": { "x": 0, "y": 520, "w": 130, "ch": 3, "s": ["400"] }, + "Gantari": { + "x": 144, + "y": 520, + "w": 89, + "ch": 3, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Gasoek One": { "x": 247, "y": 520, "w": 163, "ch": 3, "s": ["400"] }, + "Gayathri": { + "x": 424, + "y": 520, + "w": 99, + "ch": 3, + "s": ["100", "400", "700"] + }, + "Geist": { + "x": 537, + "y": 520, + "w": 66, + "ch": 3, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Geist Mono": { + "x": 617, + "y": 520, + "w": 152, + "ch": 3, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Gelasio": { + "x": 783, + "y": 520, + "w": 87, + "ch": 3, + "s": ["400", "400i", "500", "500i", "600", "600i", "700", "700i"] + }, + "Gemunu Libre": { + "x": 884, + "y": 520, + "w": 126, + "ch": 3, + "s": ["200", "300", "400", "500", "600", "700", "800"] + }, + "Genos": { + "x": 1024, + "y": 520, + "w": 67, + "ch": 3, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Gentium Book Basic": { + "x": 0, + "y": 560, + "w": 209, + "ch": 3, + "s": ["400", "400i", "700", "700i"] + }, + "Gentium Book Plus": { + "x": 223, + "y": 560, + "w": 198, + "ch": 3, + "s": ["400", "400i", "700", "700i"] + }, + "Gentium Plus": { + "x": 435, + "y": 560, + "w": 140, + "ch": 3, + "s": ["400", "400i", "700", "700i"] + }, + "Geo": { "x": 589, "y": 560, "w": 42, "ch": 3, "s": ["400", "400i"] }, + "Geologica": { + "x": 645, + "y": 560, + "w": 122, + "ch": 3, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Geom": { + "x": 781, + "y": 560, + "w": 76, + "ch": 3, + "s": [ + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Georama": { + "x": 871, + "y": 560, + "w": 102, + "ch": 3, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Geostar": { "x": 987, "y": 560, "w": 133, "ch": 3, "s": ["400"] }, + "Geostar Fill": { "x": 0, "y": 600, "w": 192, "ch": 3, "s": ["400"] }, + "Germania One": { "x": 206, "y": 600, "w": 137, "ch": 3, "s": ["400"] }, + "GFS Didot": { "x": 357, "y": 600, "w": 121, "ch": 3, "s": ["400"] }, + "GFS Neohellenic": { + "x": 492, + "y": 600, + "w": 155, + "ch": 3, + "s": ["400", "400i", "700", "700i"] + }, + "Gideon Roman": { "x": 661, "y": 600, "w": 172, "ch": 3, "s": ["400"] }, + "Gidole": { "x": 847, "y": 600, "w": 73, "ch": 3, "s": ["400"] }, + "Gidugu": { "x": 934, "y": 600, "w": 58, "ch": 3, "s": ["400"] }, + "Gilda Display": { "x": 1006, "y": 600, "w": 156, "ch": 3, "s": ["400"] }, + "Girassol": { "x": 0, "y": 640, "w": 91, "ch": 3, "s": ["400"] }, + "Give You Glory": { "x": 105, "y": 640, "w": 170, "ch": 3, "s": ["400"] }, + "Glass Antiqua": { "x": 289, "y": 640, "w": 130, "ch": 3, "s": ["400"] }, + "Glegoo": { "x": 433, "y": 640, "w": 90, "ch": 3, "s": ["400", "700"] }, + "Gloock": { "x": 537, "y": 640, "w": 89, "ch": 3, "s": ["400"] }, + "Gloria Hallelujah": { + "x": 640, + "y": 640, + "w": 190, + "ch": 3, + "s": ["400"] + }, + "Glory": { + "x": 844, + "y": 640, + "w": 56, + "ch": 3, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i" + ] + }, + "Gluten": { + "x": 914, + "y": 640, + "w": 90, + "ch": 3, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Goblin One": { "x": 0, "y": 680, "w": 202, "ch": 3, "s": ["400"] }, + "Gochi Hand": { "x": 216, "y": 680, "w": 121, "ch": 3, "s": ["400"] }, + "Goldman": { "x": 351, "y": 680, "w": 118, "ch": 3, "s": ["400", "700"] }, + "Golos Text": { + "x": 483, + "y": 680, + "w": 130, + "ch": 3, + "s": ["400", "500", "600", "700", "800", "900"] + }, + "Google Sans": { + "x": 627, + "y": 680, + "w": 146, + "ch": 3, + "s": ["400", "400i", "500", "500i", "600", "600i", "700", "700i"] + }, + "Google Sans Code": { + "x": 787, + "y": 680, + "w": 239, + "ch": 3, + "s": [ + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i" + ] + }, + "Google Sans Flex": { + "x": 0, + "y": 720, + "w": 193, + "ch": 3, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Gorditas": { "x": 207, "y": 720, "w": 111, "ch": 3, "s": ["400", "700"] }, + "Gothic A1": { + "x": 332, + "y": 720, + "w": 114, + "ch": 3, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Gotu": { "x": 460, "y": 720, "w": 67, "ch": 3, "s": ["400"] }, + "Goudy Bookletter 1911": { + "x": 541, + "y": 720, + "w": 218, + "ch": 3, + "s": ["400"] + }, + "Gowun Batang": { + "x": 773, + "y": 720, + "w": 162, + "ch": 3, + "s": ["400", "700"] + }, + "Gowun Dodum": { "x": 949, "y": 720, "w": 161, "ch": 3, "s": ["400"] }, + "Graduate": { "x": 0, "y": 760, "w": 134, "ch": 3, "s": ["400"] }, + "Grand Hotel": { "x": 148, "y": 760, "w": 101, "ch": 3, "s": ["400"] }, + "Grandiflora One": { "x": 263, "y": 760, "w": 174, "ch": 3, "s": ["400"] }, + "Grandstander": { + "x": 451, + "y": 760, + "w": 173, + "ch": 3, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Grape Nuts": { "x": 638, "y": 760, "w": 111, "ch": 3, "s": ["400"] }, + "Gravitas One": { "x": 763, "y": 760, "w": 230, "ch": 3, "s": ["400"] }, + "Great Vibes": { "x": 1007, "y": 760, "w": 104, "ch": 3, "s": ["400"] }, + "Grechen Fuemen": { "x": 0, "y": 0, "w": 179, "ch": 4, "s": ["400"] }, + "Grenze": { + "x": 193, + "y": 0, + "w": 69, + "ch": 4, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Grenze Gotisch": { + "x": 276, + "y": 0, + "w": 135, + "ch": 4, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Grey Qo": { "x": 425, "y": 0, "w": 64, "ch": 4, "s": ["400"] }, + "Griffy": { "x": 503, "y": 0, "w": 71, "ch": 4, "s": ["400"] }, + "Gruppo": { "x": 588, "y": 0, "w": 95, "ch": 4, "s": ["400"] }, + "Gudea": { + "x": 697, + "y": 0, + "w": 74, + "ch": 4, + "s": ["400", "400i", "700", "700i"] + }, + "Gugi": { "x": 785, "y": 0, "w": 60, "ch": 4, "s": ["400"] }, + "Gulzar": { "x": 859, "y": 0, "w": 77, "ch": 4, "s": ["400"] }, + "Gupter": { + "x": 950, + "y": 0, + "w": 72, + "ch": 4, + "s": ["400", "500", "700"] + }, + "Gurajada": { "x": 1036, "y": 0, "w": 71, "ch": 4, "s": ["400"] }, + "Gwendolyn": { "x": 0, "y": 40, "w": 89, "ch": 4, "s": ["400", "700"] }, + "Habibi": { "x": 103, "y": 40, "w": 81, "ch": 4, "s": ["400"] }, + "Hachi Maru Pop": { "x": 198, "y": 40, "w": 246, "ch": 4, "s": ["400"] }, + "Hahmlet": { + "x": 458, + "y": 40, + "w": 112, + "ch": 4, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Halant": { + "x": 584, + "y": 40, + "w": 75, + "ch": 4, + "s": ["300", "400", "500", "600", "700"] + }, + "Hammersmith One": { "x": 673, "y": 40, "w": 217, "ch": 4, "s": ["400"] }, + "Hanalei": { "x": 904, "y": 40, "w": 91, "ch": 4, "s": ["400"] }, + "Hanalei Fill": { "x": 1009, "y": 40, "w": 136, "ch": 4, "s": ["400"] }, + "Handjet": { + "x": 0, + "y": 80, + "w": 68, + "ch": 4, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Handlee": { "x": 82, "y": 80, "w": 88, "ch": 4, "s": ["400"] }, + "Hanken Grotesk": { + "x": 184, + "y": 80, + "w": 181, + "ch": 4, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Hanuman": { + "x": 379, + "y": 80, + "w": 123, + "ch": 4, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Happy Monkey": { "x": 516, "y": 80, "w": 189, "ch": 4, "s": ["400"] }, + "Harmattan": { + "x": 719, + "y": 80, + "w": 99, + "ch": 4, + "s": ["400", "500", "600", "700"] + }, + "Headland One": { "x": 832, "y": 80, "w": 185, "ch": 4, "s": ["400"] }, + "Hedvig Letters Sans": { + "x": 0, + "y": 120, + "w": 224, + "ch": 4, + "s": ["400"] + }, + "Hedvig Letters Serif": { + "x": 238, + "y": 120, + "w": 226, + "ch": 4, + "s": ["400"] + }, + "Heebo": { + "x": 478, + "y": 120, + "w": 78, + "ch": 4, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Henny Penny": { "x": 570, "y": 120, "w": 148, "ch": 4, "s": ["400"] }, + "Hepta Slab": { + "x": 732, + "y": 120, + "w": 150, + "ch": 4, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Herr Von Muellerhoff": { + "x": 896, + "y": 120, + "w": 145, + "ch": 4, + "s": ["400"] + }, + "Hi Melody": { "x": 1055, "y": 120, "w": 84, "ch": 4, "s": ["400"] }, + "Hina Mincho": { "x": 0, "y": 160, "w": 128, "ch": 4, "s": ["400"] }, + "Hind": { + "x": 142, + "y": 160, + "w": 56, + "ch": 4, + "s": ["300", "400", "500", "600", "700"] + }, + "Hind Guntur": { + "x": 212, + "y": 160, + "w": 134, + "ch": 4, + "s": ["300", "400", "500", "600", "700"] + }, + "Hind Madurai": { + "x": 360, + "y": 160, + "w": 146, + "ch": 4, + "s": ["300", "400", "500", "600", "700"] + }, + "Hind Mysuru": { + "x": 520, + "y": 160, + "w": 138, + "ch": 4, + "s": ["300", "400", "500", "600", "700"] + }, + "Hind Siliguri": { + "x": 672, + "y": 160, + "w": 130, + "ch": 4, + "s": ["300", "400", "500", "600", "700"] + }, + "Hind Vadodara": { + "x": 816, + "y": 160, + "w": 161, + "ch": 4, + "s": ["300", "400", "500", "600", "700"] + }, + "Holtwood One SC": { "x": 0, "y": 200, "w": 292, "ch": 4, "s": ["400"] }, + "Homemade Apple": { "x": 306, "y": 200, "w": 239, "ch": 4, "s": ["400"] }, + "Homenaje": { "x": 559, "y": 200, "w": 85, "ch": 4, "s": ["400"] }, + "Honk": { "x": 658, "y": 200, "w": 59, "ch": 4, "s": ["400"] }, + "Host Grotesk": { + "x": 731, + "y": 200, + "w": 150, + "ch": 4, + "s": [ + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i" + ] + }, + "Hubballi": { "x": 895, "y": 200, "w": 87, "ch": 4, "s": ["400"] }, + "Hubot Sans": { + "x": 996, + "y": 200, + "w": 137, + "ch": 4, + "s": [ + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Huninn": { "x": 0, "y": 240, "w": 89, "ch": 4, "s": ["400"] }, + "Hurricane": { "x": 103, "y": 240, "w": 76, "ch": 4, "s": ["400"] }, + "Iansui": { "x": 193, "y": 240, "w": 78, "ch": 4, "s": ["400"] }, + "Ibarra Real Nova": { + "x": 285, + "y": 240, + "w": 182, + "ch": 4, + "s": ["400", "400i", "500", "500i", "600", "600i", "700", "700i"] + }, + "IBM Plex Mono": { + "x": 481, + "y": 240, + "w": 196, + "ch": 4, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i" + ] + }, + "IBM Plex Sans": { + "x": 691, + "y": 240, + "w": 163, + "ch": 4, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i" + ] + }, + "IBM Plex Sans Arabic": { + "x": 868, + "y": 240, + "w": 237, + "ch": 4, + "s": ["100", "200", "300", "400", "500", "600", "700"] + }, + "IBM Plex Sans Condensed": { + "x": 0, + "y": 280, + "w": 263, + "ch": 4, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i" + ] + }, + "IBM Plex Sans Devanagari": { + "x": 277, + "y": 280, + "w": 289, + "ch": 4, + "s": ["100", "200", "300", "400", "500", "600", "700"] + }, + "IBM Plex Sans Hebrew": { + "x": 580, + "y": 280, + "w": 253, + "ch": 4, + "s": ["100", "200", "300", "400", "500", "600", "700"] + }, + "IBM Plex Sans JP": { + "x": 847, + "y": 280, + "w": 204, + "ch": 4, + "s": ["100", "200", "300", "400", "500", "600", "700"] + }, + "IBM Plex Sans KR": { + "x": 0, + "y": 320, + "w": 199, + "ch": 4, + "s": ["100", "200", "300", "400", "500", "600", "700"] + }, + "IBM Plex Sans Thai": { + "x": 213, + "y": 320, + "w": 215, + "ch": 4, + "s": ["100", "200", "300", "400", "500", "600", "700"] + }, + "IBM Plex Sans Thai Looped": { + "x": 442, + "y": 320, + "w": 300, + "ch": 4, + "s": ["100", "200", "300", "400", "500", "600", "700"] + }, + "IBM Plex Serif": { + "x": 756, + "y": 320, + "w": 167, + "ch": 4, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i" + ] + }, + "Iceberg": { "x": 937, "y": 320, "w": 84, "ch": 4, "s": ["400"] }, + "Iceland": { "x": 1035, "y": 320, "w": 75, "ch": 4, "s": ["400"] }, + "IM Fell Double Pica": { + "x": 0, + "y": 360, + "w": 204, + "ch": 4, + "s": ["400", "400i"] + }, + "IM Fell Double Pica SC": { + "x": 218, + "y": 360, + "w": 261, + "ch": 4, + "s": ["400"] + }, + "IM Fell DW Pica": { + "x": 493, + "y": 360, + "w": 175, + "ch": 4, + "s": ["400", "400i"] + }, + "IM Fell DW Pica SC": { + "x": 682, + "y": 360, + "w": 220, + "ch": 4, + "s": ["400"] + }, + "IM Fell English": { + "x": 916, + "y": 360, + "w": 168, + "ch": 4, + "s": ["400", "400i"] + }, + "IM Fell English SC": { "x": 0, "y": 400, "w": 215, "ch": 4, "s": ["400"] }, + "IM Fell French Canon": { + "x": 229, + "y": 400, + "w": 233, + "ch": 4, + "s": ["400", "400i"] + }, + "IM Fell French Canon SC": { + "x": 476, + "y": 400, + "w": 279, + "ch": 4, + "s": ["400"] + }, + "IM Fell Great Primer": { + "x": 769, + "y": 400, + "w": 220, + "ch": 4, + "s": ["400", "400i"] + }, + "IM Fell Great Primer SC": { + "x": 0, + "y": 440, + "w": 289, + "ch": 4, + "s": ["400"] + }, + "Imbue": { + "x": 303, + "y": 440, + "w": 52, + "ch": 4, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Imperial Script": { "x": 369, "y": 440, "w": 119, "ch": 4, "s": ["400"] }, + "Imprima": { "x": 502, "y": 440, "w": 98, "ch": 4, "s": ["400"] }, + "Inclusive Sans": { + "x": 614, + "y": 440, + "w": 172, + "ch": 4, + "s": [ + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i" + ] + }, + "Inconsolata": { + "x": 800, + "y": 440, + "w": 140, + "ch": 4, + "s": ["200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Inder": { "x": 954, "y": 440, "w": 64, "ch": 4, "s": ["400"] }, + "Indie Flower": { "x": 1032, "y": 440, "w": 125, "ch": 4, "s": ["400"] }, + "Ingrid Darling": { "x": 0, "y": 480, "w": 114, "ch": 4, "s": ["400"] }, + "Inika": { "x": 128, "y": 480, "w": 67, "ch": 4, "s": ["400", "700"] }, + "Inknut Antiqua": { + "x": 209, + "y": 480, + "w": 211, + "ch": 4, + "s": ["300", "400", "500", "600", "700", "800", "900"] + }, + "Inria Sans": { + "x": 434, + "y": 480, + "w": 110, + "ch": 4, + "s": ["300", "300i", "400", "400i", "700", "700i"] + }, + "Inria Serif": { + "x": 558, + "y": 480, + "w": 120, + "ch": 4, + "s": ["300", "300i", "400", "400i", "700", "700i"] + }, + "Inspiration": { "x": 692, "y": 480, "w": 78, "ch": 4, "s": ["400"] }, + "Instrument Sans": { + "x": 784, + "y": 480, + "w": 189, + "ch": 4, + "s": ["400", "400i", "500", "500i", "600", "600i", "700", "700i"] + }, + "Instrument Serif": { + "x": 987, + "y": 480, + "w": 143, + "ch": 4, + "s": ["400", "400i"] + }, + "Intel One Mono": { + "x": 0, + "y": 520, + "w": 215, + "ch": 4, + "s": [ + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i" + ] + }, + "Inter": { + "x": 229, + "y": 520, + "w": 60, + "ch": 4, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Inter Tight": { + "x": 303, + "y": 520, + "w": 113, + "ch": 4, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Irish Grover": { "x": 430, "y": 520, "w": 139, "ch": 4, "s": ["400"] }, + "Island Moments": { "x": 583, "y": 520, "w": 125, "ch": 4, "s": ["400"] }, + "Istok Web": { + "x": 722, + "y": 520, + "w": 116, + "ch": 4, + "s": ["400", "400i", "700", "700i"] + }, + "Italiana": { "x": 852, "y": 520, "w": 83, "ch": 4, "s": ["400"] }, + "Italianno": { "x": 949, "y": 520, "w": 68, "ch": 4, "s": ["400"] }, + "Itim": { "x": 1031, "y": 520, "w": 52, "ch": 4, "s": ["400"] }, + "Jacquard 12": { "x": 0, "y": 560, "w": 110, "ch": 4, "s": ["400"] }, + "Jacquard 12 Charted": { + "x": 124, + "y": 560, + "w": 184, + "ch": 4, + "s": ["400"] + }, + "Jacquard 24": { "x": 322, "y": 560, "w": 108, "ch": 4, "s": ["400"] }, + "Jacquard 24 Charted": { + "x": 444, + "y": 560, + "w": 179, + "ch": 4, + "s": ["400"] + }, + "Jacquarda Bastarda 9": { + "x": 637, + "y": 560, + "w": 261, + "ch": 4, + "s": ["400"] + }, + "Jacquarda Bastarda 9 Charted": { + "x": 0, + "y": 600, + "w": 357, + "ch": 4, + "s": ["400"] + }, + "Jacques Francois": { "x": 371, "y": 600, "w": 197, "ch": 4, "s": ["400"] }, + "Jacques Francois Shadow": { + "x": 582, + "y": 600, + "w": 315, + "ch": 4, + "s": ["400"] + }, + "Jaini": { "x": 911, "y": 600, "w": 50, "ch": 4, "s": ["400"] }, + "Jaini Purva": { "x": 975, "y": 600, "w": 102, "ch": 4, "s": ["400"] }, + "Jaldi": { "x": 1091, "y": 600, "w": 52, "ch": 4, "s": ["400", "700"] }, + "Jaro": { "x": 0, "y": 640, "w": 50, "ch": 4, "s": ["400"] }, + "Jersey 10": { "x": 64, "y": 640, "w": 84, "ch": 4, "s": ["400"] }, + "Jersey 10 Charted": { + "x": 162, + "y": 640, + "w": 158, + "ch": 4, + "s": ["400"] + }, + "Jersey 15": { "x": 334, "y": 640, "w": 91, "ch": 4, "s": ["400"] }, + "Jersey 15 Charted": { + "x": 439, + "y": 640, + "w": 168, + "ch": 4, + "s": ["400"] + }, + "Jersey 20": { "x": 621, "y": 640, "w": 98, "ch": 4, "s": ["400"] }, + "Jersey 20 Charted": { + "x": 733, + "y": 640, + "w": 176, + "ch": 4, + "s": ["400"] + }, + "Jersey 25": { "x": 923, "y": 640, "w": 102, "ch": 4, "s": ["400"] }, + "Jersey 25 Charted": { "x": 0, "y": 680, "w": 180, "ch": 4, "s": ["400"] }, + "JetBrains Mono": { + "x": 194, + "y": 680, + "w": 210, + "ch": 4, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i" + ] + }, + "Jim Nightshade": { "x": 418, "y": 680, "w": 119, "ch": 4, "s": ["400"] }, + "Joan": { "x": 551, "y": 680, "w": 54, "ch": 4, "s": ["400"] }, + "Jockey One": { "x": 619, "y": 680, "w": 108, "ch": 4, "s": ["400"] }, + "Jolly Lodger": { "x": 741, "y": 680, "w": 90, "ch": 4, "s": ["400"] }, + "Jomhuria": { "x": 845, "y": 680, "w": 64, "ch": 4, "s": ["400"] }, + "Jomolhari": { "x": 923, "y": 680, "w": 116, "ch": 4, "s": ["400"] }, + "Josefin Sans": { + "x": 1053, + "y": 680, + "w": 138, + "ch": 4, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i" + ] + }, + "Josefin Slab": { + "x": 0, + "y": 720, + "w": 123, + "ch": 4, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i" + ] + }, + "Jost": { + "x": 137, + "y": 720, + "w": 43, + "ch": 4, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Joti One": { "x": 194, "y": 720, "w": 100, "ch": 4, "s": ["400"] }, + "Jua": { "x": 308, "y": 720, "w": 48, "ch": 4, "s": ["400"] }, + "Judson": { + "x": 370, + "y": 720, + "w": 80, + "ch": 4, + "s": ["400", "400i", "700", "700i"] + }, + "Julee": { "x": 464, "y": 720, "w": 57, "ch": 4, "s": ["400"] }, + "Julius Sans One": { "x": 535, "y": 720, "w": 210, "ch": 4, "s": ["400"] }, + "Junge": { "x": 759, "y": 720, "w": 73, "ch": 4, "s": ["400"] }, + "Jura": { + "x": 846, + "y": 720, + "w": 59, + "ch": 4, + "s": ["300", "400", "500", "600", "700"] + }, + "Just Another Hand": { + "x": 919, + "y": 720, + "w": 112, + "ch": 4, + "s": ["400"] + }, + "Just Me Again Down Here": { + "x": 0, + "y": 760, + "w": 200, + "ch": 4, + "s": ["400"] + }, + "K2D": { + "x": 214, + "y": 760, + "w": 56, + "ch": 4, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i" + ] + }, + "Kablammo": { "x": 284, "y": 760, "w": 128, "ch": 4, "s": ["400"] }, + "Kadwa": { "x": 426, "y": 760, "w": 87, "ch": 4, "s": ["400", "700"] }, + "Kaisei Decol": { + "x": 527, + "y": 760, + "w": 154, + "ch": 4, + "s": ["400", "500", "700"] + }, + "Kaisei HarunoUmi": { + "x": 695, + "y": 760, + "w": 223, + "ch": 4, + "s": ["400", "500", "700"] + }, + "Kaisei Opti": { + "x": 932, + "y": 760, + "w": 137, + "ch": 4, + "s": ["400", "500", "700"] + }, + "Kaisei Tokumin": { + "x": 0, + "y": 0, + "w": 191, + "ch": 5, + "s": ["400", "500", "700", "800"] + }, + "Kalam": { "x": 205, "y": 0, "w": 70, "ch": 5, "s": ["300", "400", "700"] }, + "Kalnia": { + "x": 289, + "y": 0, + "w": 87, + "ch": 5, + "s": ["100", "200", "300", "400", "500", "600", "700"] + }, + "Kalnia Glaze": { + "x": 390, + "y": 0, + "w": 162, + "ch": 5, + "s": ["100", "200", "300", "400", "500", "600", "700"] + }, + "Kameron": { + "x": 566, + "y": 0, + "w": 107, + "ch": 5, + "s": ["400", "500", "600", "700"] + }, + "Kanchenjunga": { + "x": 687, + "y": 0, + "w": 165, + "ch": 5, + "s": ["400", "500", "600", "700"] + }, + "Kanit": { + "x": 866, + "y": 0, + "w": 65, + "ch": 5, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Kantumruy Pro": { + "x": 945, + "y": 0, + "w": 175, + "ch": 5, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i" + ] + }, + "Kapakana": { "x": 0, "y": 40, "w": 83, "ch": 5, "s": ["300", "400"] }, + "Karantina": { + "x": 97, + "y": 40, + "w": 66, + "ch": 5, + "s": ["300", "400", "700"] + }, + "Karla": { + "x": 177, + "y": 40, + "w": 65, + "ch": 5, + "s": [ + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i" + ] + }, + "Karma": { + "x": 256, + "y": 40, + "w": 79, + "ch": 5, + "s": ["300", "400", "500", "600", "700"] + }, + "Katibeh": { "x": 349, "y": 40, "w": 67, "ch": 5, "s": ["400"] }, + "Kaushan Script": { "x": 430, "y": 40, "w": 159, "ch": 5, "s": ["400"] }, + "Kavivanar": { "x": 603, "y": 40, "w": 107, "ch": 5, "s": ["400"] }, + "Kavoon": { "x": 724, "y": 40, "w": 95, "ch": 5, "s": ["400"] }, + "Kay Pho Du": { + "x": 833, + "y": 40, + "w": 132, + "ch": 5, + "s": ["400", "500", "600", "700"] + }, + "Kdam Thmor Pro": { "x": 979, "y": 40, "w": 189, "ch": 5, "s": ["400"] }, + "Keania One": { "x": 0, "y": 80, "w": 126, "ch": 5, "s": ["400"] }, + "Kedebideri": { + "x": 140, + "y": 80, + "w": 120, + "ch": 5, + "s": ["400", "500", "600", "700", "800", "900"] + }, + "Kelly Slab": { "x": 274, "y": 80, "w": 115, "ch": 5, "s": ["400"] }, + "Kenia": { "x": 403, "y": 80, "w": 57, "ch": 5, "s": ["400"] }, + "Khand": { + "x": 474, + "y": 80, + "w": 61, + "ch": 5, + "s": ["300", "400", "500", "600", "700"] + }, + "Khula": { + "x": 549, + "y": 80, + "w": 68, + "ch": 5, + "s": ["300", "400", "600", "700", "800"] + }, + "Kings": { "x": 631, "y": 80, "w": 63, "ch": 5, "s": ["400"] }, + "Kirang Haerang": { "x": 708, "y": 80, "w": 145, "ch": 5, "s": ["400"] }, + "Kite One": { "x": 867, "y": 80, "w": 97, "ch": 5, "s": ["400"] }, + "Kiwi Maru": { + "x": 978, + "y": 80, + "w": 135, + "ch": 5, + "s": ["300", "400", "500"] + }, + "Klee One": { "x": 0, "y": 120, "w": 109, "ch": 5, "s": ["400", "600"] }, + "Knewave": { "x": 123, "y": 120, "w": 103, "ch": 5, "s": ["400"] }, + "Kodchasan": { + "x": 240, + "y": 120, + "w": 139, + "ch": 5, + "s": [ + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i" + ] + }, + "Kode Mono": { + "x": 393, + "y": 120, + "w": 138, + "ch": 5, + "s": ["400", "500", "600", "700"] + }, + "Koh Santepheap": { + "x": 545, + "y": 120, + "w": 195, + "ch": 5, + "s": ["100", "300", "400", "700", "900"] + }, + "KoHo": { + "x": 754, + "y": 120, + "w": 66, + "ch": 5, + "s": [ + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i" + ] + }, + "Kolker Brush": { "x": 834, "y": 120, "w": 84, "ch": 5, "s": ["400"] }, + "Konkhmer Sleokchher": { + "x": 0, + "y": 160, + "w": 269, + "ch": 5, + "s": ["400"] + }, + "Kosugi": { "x": 283, "y": 160, "w": 80, "ch": 5, "s": ["400"] }, + "Kosugi Maru": { "x": 377, "y": 160, "w": 140, "ch": 5, "s": ["400"] }, + "Kotta One": { "x": 531, "y": 160, "w": 110, "ch": 5, "s": ["400"] }, + "Koulen": { "x": 655, "y": 160, "w": 72, "ch": 5, "s": ["400"] }, + "Kranky": { "x": 741, "y": 160, "w": 87, "ch": 5, "s": ["400"] }, + "Kreon": { + "x": 842, + "y": 160, + "w": 68, + "ch": 5, + "s": ["300", "400", "500", "600", "700"] + }, + "Kristi": { "x": 924, "y": 160, "w": 46, "ch": 5, "s": ["400"] }, + "Krona One": { "x": 984, "y": 160, "w": 173, "ch": 5, "s": ["400"] }, + "Krub": { + "x": 0, + "y": 200, + "w": 62, + "ch": 5, + "s": [ + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i" + ] + }, + "Kufam": { + "x": 76, + "y": 200, + "w": 84, + "ch": 5, + "s": [ + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Kulim Park": { + "x": 174, + "y": 200, + "w": 116, + "ch": 5, + "s": [ + "200", + "200i", + "300", + "300i", + "400", + "400i", + "600", + "600i", + "700", + "700i" + ] + }, + "Kumar One": { "x": 304, "y": 200, "w": 167, "ch": 5, "s": ["400"] }, + "Kumar One Outline": { + "x": 485, + "y": 200, + "w": 277, + "ch": 5, + "s": ["400"] + }, + "Kumbh Sans": { + "x": 776, + "y": 200, + "w": 147, + "ch": 5, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Kurale": { "x": 937, "y": 200, "w": 77, "ch": 5, "s": ["400"] }, + "La Belle Aurore": { "x": 1028, "y": 200, "w": 170, "ch": 5, "s": ["400"] }, + "Labrada": { + "x": 0, + "y": 240, + "w": 91, + "ch": 5, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Lacquer": { "x": 105, "y": 240, "w": 102, "ch": 5, "s": ["400"] }, + "Laila": { + "x": 221, + "y": 240, + "w": 60, + "ch": 5, + "s": ["300", "400", "500", "600", "700"] + }, + "Lakki Reddy": { "x": 295, "y": 240, "w": 138, "ch": 5, "s": ["400"] }, + "Lalezar": { "x": 447, "y": 240, "w": 82, "ch": 5, "s": ["400"] }, + "Lancelot": { "x": 543, "y": 240, "w": 80, "ch": 5, "s": ["400"] }, + "Langar": { "x": 637, "y": 240, "w": 83, "ch": 5, "s": ["400"] }, + "Lateef": { + "x": 734, + "y": 240, + "w": 57, + "ch": 5, + "s": ["200", "300", "400", "500", "600", "700", "800"] + }, + "Lato": { + "x": 805, + "y": 240, + "w": 55, + "ch": 5, + "s": [ + "100", + "100i", + "300", + "300i", + "400", + "400i", + "700", + "700i", + "900", + "900i" + ] + }, + "Lavishly Yours": { "x": 874, "y": 240, "w": 135, "ch": 5, "s": ["400"] }, + "League Gothic": { "x": 1023, "y": 240, "w": 97, "ch": 5, "s": ["400"] }, + "League Script": { "x": 0, "y": 280, "w": 156, "ch": 5, "s": ["400"] }, + "League Spartan": { + "x": 170, + "y": 280, + "w": 166, + "ch": 5, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Leckerli One": { "x": 350, "y": 280, "w": 144, "ch": 5, "s": ["400"] }, + "Ledger": { "x": 508, "y": 280, "w": 91, "ch": 5, "s": ["400"] }, + "Lekton": { + "x": 613, + "y": 280, + "w": 80, + "ch": 5, + "s": ["400", "400i", "700", "700i"] + }, + "Lemon": { "x": 707, "y": 280, "w": 100, "ch": 5, "s": ["400"] }, + "Lemonada": { + "x": 821, + "y": 280, + "w": 153, + "ch": 5, + "s": ["300", "400", "500", "600", "700"] + }, + "Lexend": { + "x": 988, + "y": 280, + "w": 93, + "ch": 5, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Lexend Deca": { + "x": 0, + "y": 320, + "w": 159, + "ch": 5, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Lexend Exa": { + "x": 173, + "y": 320, + "w": 176, + "ch": 5, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Lexend Giga": { + "x": 363, + "y": 320, + "w": 205, + "ch": 5, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Lexend Mega": { + "x": 582, + "y": 320, + "w": 223, + "ch": 5, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Lexend Peta": { + "x": 819, + "y": 320, + "w": 218, + "ch": 5, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Lexend Tera": { + "x": 0, + "y": 360, + "w": 221, + "ch": 5, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Lexend Zetta": { + "x": 235, + "y": 360, + "w": 260, + "ch": 5, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Libertinus Keyboard": { + "x": 509, + "y": 360, + "w": 490, + "ch": 5, + "s": ["400"] + }, + "Libertinus Math": { "x": 1013, "y": 360, "w": 166, "ch": 5, "s": ["400"] }, + "Libertinus Mono": { "x": 0, "y": 400, "w": 239, "ch": 5, "s": ["400"] }, + "Libertinus Sans": { + "x": 253, + "y": 400, + "w": 161, + "ch": 5, + "s": ["400", "400i", "700", "700i"] + }, + "Libertinus Serif": { + "x": 428, + "y": 400, + "w": 160, + "ch": 5, + "s": ["400", "400i", "600", "600i", "700", "700i"] + }, + "Libertinus Serif Display": { + "x": 602, + "y": 400, + "w": 234, + "ch": 5, + "s": ["400"] + }, + "Libre Barcode 128": { + "x": 850, + "y": 400, + "w": 143, + "ch": 5, + "s": ["400"] + }, + "Libre Barcode 128 Text": { + "x": 1007, + "y": 400, + "w": 183, + "ch": 5, + "s": ["400"] + }, + "Libre Barcode 39": { "x": 0, "y": 440, "w": 193, "ch": 5, "s": ["400"] }, + "Libre Barcode 39 Extended": { + "x": 207, + "y": 440, + "w": 492, + "ch": 5, + "s": ["400"] + }, + "Libre Barcode 39 Extended Text": { + "x": 0, + "y": 480, + "w": 584, + "ch": 5, + "s": ["400"] + }, + "Libre Barcode 39 Text": { + "x": 598, + "y": 480, + "w": 250, + "ch": 5, + "s": ["400"] + }, + "Libre Barcode EAN13 Text": { + "x": 862, + "y": 480, + "w": 87, + "ch": 5, + "s": ["400"] + }, + "Libre Baskerville": { + "x": 963, + "y": 480, + "w": 216, + "ch": 5, + "s": ["400", "400i", "500", "500i", "600", "600i", "700", "700i"] + }, + "Libre Bodoni": { + "x": 0, + "y": 520, + "w": 153, + "ch": 5, + "s": ["400", "400i", "500", "500i", "600", "600i", "700", "700i"] + }, + "Libre Caslon Display": { + "x": 167, + "y": 520, + "w": 197, + "ch": 5, + "s": ["400"] + }, + "Libre Caslon Text": { + "x": 378, + "y": 520, + "w": 210, + "ch": 5, + "s": ["400", "400i", "700", "700i"] + }, + "Libre Franklin": { + "x": 602, + "y": 520, + "w": 159, + "ch": 5, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Licorice": { "x": 775, "y": 520, "w": 60, "ch": 5, "s": ["400"] }, + "Life Savers": { + "x": 849, + "y": 520, + "w": 119, + "ch": 5, + "s": ["400", "700", "800"] + }, + "Lilex": { + "x": 982, + "y": 520, + "w": 80, + "ch": 5, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i" + ] + }, + "Lilita One": { "x": 1076, "y": 520, "w": 106, "ch": 5, "s": ["400"] }, + "Lily Script One": { "x": 0, "y": 560, "w": 156, "ch": 5, "s": ["400"] }, + "Limelight": { "x": 170, "y": 560, "w": 132, "ch": 5, "s": ["400"] }, + "Linden Hill": { + "x": 316, + "y": 560, + "w": 113, + "ch": 5, + "s": ["400", "400i"] + }, + "LINE Seed JP": { + "x": 443, + "y": 560, + "w": 169, + "ch": 5, + "s": ["100", "400", "700", "800"] + }, + "Linefont": { + "x": 626, + "y": 560, + "w": 28, + "ch": 5, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Lisu Bosa": { + "x": 668, + "y": 560, + "w": 106, + "ch": 5, + "s": [ + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Liter": { "x": 788, "y": 560, "w": 56, "ch": 5, "s": ["400"] }, + "Literata": { + "x": 858, + "y": 560, + "w": 99, + "ch": 5, + "s": [ + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Liu Jian Mao Cao": { "x": 971, "y": 560, "w": 164, "ch": 5, "s": ["400"] }, + "Livvic": { + "x": 0, + "y": 600, + "w": 66, + "ch": 5, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "900", + "900i" + ] + }, + "Lobster": { "x": 80, "y": 600, "w": 72, "ch": 5, "s": ["400"] }, + "Lobster Two": { + "x": 166, + "y": 600, + "w": 114, + "ch": 5, + "s": ["400", "400i", "700", "700i"] + }, + "Londrina Outline": { "x": 294, "y": 600, "w": 156, "ch": 5, "s": ["400"] }, + "Londrina Shadow": { "x": 464, "y": 600, "w": 165, "ch": 5, "s": ["400"] }, + "Londrina Sketch": { "x": 643, "y": 600, "w": 156, "ch": 5, "s": ["400"] }, + "Londrina Solid": { + "x": 813, + "y": 600, + "w": 136, + "ch": 5, + "s": ["100", "300", "400", "900"] + }, + "Long Cang": { "x": 963, "y": 600, "w": 98, "ch": 5, "s": ["400"] }, + "Lora": { + "x": 1075, + "y": 600, + "w": 59, + "ch": 5, + "s": ["400", "400i", "500", "500i", "600", "600i", "700", "700i"] + }, + "Love Light": { "x": 0, "y": 640, "w": 88, "ch": 5, "s": ["400"] }, + "Love Ya Like A Sister": { + "x": 102, + "y": 640, + "w": 235, + "ch": 5, + "s": ["400"] + }, + "Loved by the King": { + "x": 351, + "y": 640, + "w": 145, + "ch": 5, + "s": ["400"] + }, + "Lovers Quarrel": { "x": 510, "y": 640, "w": 92, "ch": 5, "s": ["400"] }, + "Luckiest Guy": { "x": 616, "y": 640, "w": 156, "ch": 5, "s": ["400"] }, + "Lugrasimo": { "x": 786, "y": 640, "w": 144, "ch": 5, "s": ["400"] }, + "Lumanosimo": { "x": 944, "y": 640, "w": 185, "ch": 5, "s": ["400"] }, + "Lunasima": { "x": 0, "y": 680, "w": 119, "ch": 5, "s": ["400", "700"] }, + "Lusitana": { "x": 133, "y": 680, "w": 96, "ch": 5, "s": ["400", "700"] }, + "Lustria": { "x": 243, "y": 680, "w": 86, "ch": 5, "s": ["400"] }, + "Luxurious Roman": { "x": 343, "y": 680, "w": 201, "ch": 5, "s": ["400"] }, + "Luxurious Script": { "x": 558, "y": 680, "w": 128, "ch": 5, "s": ["400"] }, + "LXGW Marker Gothic": { + "x": 700, + "y": 680, + "w": 233, + "ch": 5, + "s": ["400"] + }, + "LXGW WenKai Mono TC": { + "x": 947, + "y": 680, + "w": 236, + "ch": 5, + "s": ["300", "400", "700"] + }, + "LXGW WenKai TC": { + "x": 0, + "y": 720, + "w": 206, + "ch": 5, + "s": ["300", "400", "700"] + }, + "M PLUS 1": { + "x": 220, + "y": 720, + "w": 120, + "ch": 5, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "M PLUS 1 Code": { + "x": 354, + "y": 720, + "w": 164, + "ch": 5, + "s": ["100", "200", "300", "400", "500", "600", "700"] + }, + "M PLUS 1p": { + "x": 532, + "y": 720, + "w": 129, + "ch": 5, + "s": ["100", "300", "400", "500", "700", "800", "900"] + }, + "M PLUS 2": { + "x": 675, + "y": 720, + "w": 120, + "ch": 5, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "M PLUS Code Latin": { + "x": 809, + "y": 720, + "w": 212, + "ch": 5, + "s": ["100", "200", "300", "400", "500", "600", "700"] + }, + "M PLUS Rounded 1c": { + "x": 0, + "y": 760, + "w": 225, + "ch": 5, + "s": ["100", "300", "400", "500", "700", "800", "900"] + }, + "Ma Shan Zheng": { "x": 239, "y": 760, "w": 126, "ch": 5, "s": ["400"] }, + "Macondo": { "x": 379, "y": 760, "w": 100, "ch": 5, "s": ["400"] }, + "Macondo Swash Caps": { + "x": 493, + "y": 760, + "w": 228, + "ch": 5, + "s": ["400"] + }, + "Mada": { + "x": 735, + "y": 760, + "w": 63, + "ch": 5, + "s": ["200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Madimi One": { "x": 812, "y": 760, "w": 133, "ch": 5, "s": ["400"] }, + "Magra": { "x": 959, "y": 760, "w": 70, "ch": 5, "s": ["400", "700"] }, + "Maiden Orange": { "x": 1043, "y": 760, "w": 127, "ch": 5, "s": ["400"] }, + "Maitree": { + "x": 0, + "y": 0, + "w": 96, + "ch": 6, + "s": ["200", "300", "400", "500", "600", "700"] + }, + "Major Mono Display": { "x": 110, "y": 0, "w": 328, "ch": 6, "s": ["400"] }, + "Mako": { "x": 452, "y": 0, "w": 67, "ch": 6, "s": ["400"] }, + "Mali": { + "x": 533, + "y": 0, + "w": 56, + "ch": 6, + "s": [ + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i" + ] + }, + "Mallanna": { "x": 603, "y": 0, "w": 96, "ch": 6, "s": ["400"] }, + "Maname": { "x": 713, "y": 0, "w": 100, "ch": 6, "s": ["400"] }, + "Mandali": { "x": 827, "y": 0, "w": 94, "ch": 6, "s": ["400"] }, + "Manjari": { + "x": 935, + "y": 0, + "w": 87, + "ch": 6, + "s": ["100", "400", "700"] + }, + "Manrope": { + "x": 1036, + "y": 0, + "w": 107, + "ch": 6, + "s": ["200", "300", "400", "500", "600", "700", "800"] + }, + "Mansalva": { "x": 0, "y": 40, "w": 111, "ch": 6, "s": ["400"] }, + "Manuale": { + "x": 125, + "y": 40, + "w": 101, + "ch": 6, + "s": [ + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i" + ] + }, + "Manufacturing Consent": { + "x": 240, + "y": 40, + "w": 214, + "ch": 6, + "s": ["400"] + }, + "Marcellus": { "x": 468, "y": 40, "w": 114, "ch": 6, "s": ["400"] }, + "Marcellus SC": { "x": 596, "y": 40, "w": 157, "ch": 6, "s": ["400"] }, + "Marck Script": { "x": 767, "y": 40, "w": 137, "ch": 6, "s": ["400"] }, + "Margarine": { "x": 918, "y": 40, "w": 119, "ch": 6, "s": ["400"] }, + "Marhey": { + "x": 1051, + "y": 40, + "w": 107, + "ch": 6, + "s": ["300", "400", "500", "600", "700"] + }, + "Markazi Text": { + "x": 0, + "y": 80, + "w": 113, + "ch": 6, + "s": ["400", "500", "600", "700"] + }, + "Marko One": { "x": 127, "y": 80, "w": 152, "ch": 6, "s": ["400"] }, + "Marmelad": { "x": 293, "y": 80, "w": 118, "ch": 6, "s": ["400"] }, + "Martel": { + "x": 425, + "y": 80, + "w": 88, + "ch": 6, + "s": ["200", "300", "400", "600", "700", "800", "900"] + }, + "Martel Sans": { + "x": 527, + "y": 80, + "w": 143, + "ch": 6, + "s": ["200", "300", "400", "600", "700", "800", "900"] + }, + "Martian Mono": { + "x": 684, + "y": 80, + "w": 210, + "ch": 6, + "s": ["100", "200", "300", "400", "500", "600", "700", "800"] + }, + "Marvel": { + "x": 908, + "y": 80, + "w": 64, + "ch": 6, + "s": ["400", "400i", "700", "700i"] + }, + "Matangi": { + "x": 986, + "y": 80, + "w": 99, + "ch": 6, + "s": ["300", "400", "500", "600", "700", "800", "900"] + }, + "Mate": { "x": 1099, "y": 80, "w": 61, "ch": 6, "s": ["400", "400i"] }, + "Mate SC": { "x": 0, "y": 120, "w": 99, "ch": 6, "s": ["400"] }, + "Matemasie": { "x": 113, "y": 120, "w": 145, "ch": 6, "s": ["400"] }, + "Material Icons": { "x": 272, "y": 120, "w": 344, "ch": 6, "s": ["400"] }, + "Material Icons Outlined": { + "x": 630, + "y": 120, + "w": 560, + "ch": 6, + "s": ["400"] + }, + "Material Icons Round": { + "x": 0, + "y": 160, + "w": 488, + "ch": 6, + "s": ["400"] + }, + "Material Icons Sharp": { + "x": 502, + "y": 160, + "w": 488, + "ch": 6, + "s": ["400"] + }, + "Material Icons Two Tone": { + "x": 0, + "y": 200, + "w": 560, + "ch": 6, + "s": ["400"] + }, + "Material Symbols": { + "x": 574, + "y": 200, + "w": 392, + "ch": 6, + "s": ["100", "200", "300", "400", "500", "600", "700"] + }, + "Material Symbols Outlined": { + "x": 0, + "y": 240, + "w": 608, + "ch": 6, + "s": ["100", "200", "300", "400", "500", "600", "700"] + }, + "Material Symbols Rounded": { + "x": 0, + "y": 280, + "w": 584, + "ch": 6, + "s": ["100", "200", "300", "400", "500", "600", "700"] + }, + "Material Symbols Sharp": { + "x": 598, + "y": 280, + "w": 536, + "ch": 6, + "s": ["100", "200", "300", "400", "500", "600", "700"] + }, + "Maven Pro": { + "x": 0, + "y": 320, + "w": 123, + "ch": 6, + "s": ["400", "500", "600", "700", "800", "900"] + }, + "McLaren": { "x": 137, "y": 320, "w": 118, "ch": 6, "s": ["400"] }, + "Mea Culpa": { "x": 269, "y": 320, "w": 102, "ch": 6, "s": ["400"] }, + "Meddon": { "x": 385, "y": 320, "w": 129, "ch": 6, "s": ["400"] }, + "MedievalSharp": { "x": 528, "y": 320, "w": 170, "ch": 6, "s": ["400"] }, + "Medula One": { "x": 712, "y": 320, "w": 78, "ch": 6, "s": ["400"] }, + "Meera Inimai": { "x": 804, "y": 320, "w": 138, "ch": 6, "s": ["400"] }, + "Megrim": { "x": 956, "y": 320, "w": 87, "ch": 6, "s": ["400"] }, + "Meie Script": { "x": 1057, "y": 320, "w": 133, "ch": 6, "s": ["400"] }, + "Menbere": { + "x": 0, + "y": 360, + "w": 112, + "ch": 6, + "s": ["100", "200", "300", "400", "500", "600", "700"] + }, + "Meow Script": { "x": 126, "y": 360, "w": 119, "ch": 6, "s": ["400"] }, + "Merienda": { + "x": 259, + "y": 360, + "w": 121, + "ch": 6, + "s": ["300", "400", "500", "600", "700", "800", "900"] + }, + "Merienda One": { "x": 394, "y": 360, "w": 177, "ch": 6, "s": ["400"] }, + "Merriweather": { + "x": 585, + "y": 360, + "w": 168, + "ch": 6, + "s": [ + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Merriweather Sans": { + "x": 767, + "y": 360, + "w": 223, + "ch": 6, + "s": [ + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i" + ] + }, + "Metal": { "x": 1004, "y": 360, "w": 58, "ch": 6, "s": ["400"] }, + "Metal Mania": { "x": 1076, "y": 360, "w": 121, "ch": 6, "s": ["400"] }, + "Metamorphous": { "x": 0, "y": 400, "w": 210, "ch": 6, "s": ["400"] }, + "Metrophobic": { "x": 224, "y": 400, "w": 149, "ch": 6, "s": ["400"] }, + "Michroma": { "x": 387, "y": 400, "w": 161, "ch": 6, "s": ["400"] }, + "Micro 5": { "x": 562, "y": 400, "w": 65, "ch": 6, "s": ["400"] }, + "Micro 5 Charted": { "x": 641, "y": 400, "w": 126, "ch": 6, "s": ["400"] }, + "Milonga": { "x": 781, "y": 400, "w": 99, "ch": 6, "s": ["400"] }, + "Miltonian": { "x": 894, "y": 400, "w": 130, "ch": 6, "s": ["400"] }, + "Miltonian Tattoo": { "x": 0, "y": 440, "w": 214, "ch": 6, "s": ["400"] }, + "Mina": { "x": 228, "y": 440, "w": 58, "ch": 6, "s": ["400", "700"] }, + "Mingzat": { "x": 300, "y": 440, "w": 99, "ch": 6, "s": ["400"] }, + "Miniver": { "x": 413, "y": 440, "w": 89, "ch": 6, "s": ["400"] }, + "Miriam Libre": { + "x": 516, + "y": 440, + "w": 161, + "ch": 6, + "s": ["400", "500", "600", "700"] + }, + "Mirza": { + "x": 691, + "y": 440, + "w": 60, + "ch": 6, + "s": ["400", "500", "600", "700"] + }, + "Miss Fajardose": { "x": 765, "y": 440, "w": 108, "ch": 6, "s": ["400"] }, + "Mitr": { + "x": 887, + "y": 440, + "w": 55, + "ch": 6, + "s": ["200", "300", "400", "500", "600", "700"] + }, + "Mochiy Pop One": { "x": 956, "y": 440, "w": 223, "ch": 6, "s": ["400"] }, + "Mochiy Pop P One": { "x": 0, "y": 480, "w": 248, "ch": 6, "s": ["400"] }, + "Modak": { "x": 262, "y": 480, "w": 79, "ch": 6, "s": ["400"] }, + "Modern Antiqua": { "x": 355, "y": 480, "w": 196, "ch": 6, "s": ["400"] }, + "Moderustic": { + "x": 565, + "y": 480, + "w": 135, + "ch": 6, + "s": ["300", "400", "500", "600", "700", "800"] + }, + "Mogra": { "x": 714, "y": 480, "w": 80, "ch": 6, "s": ["400"] }, + "Mohave": { + "x": 808, + "y": 480, + "w": 77, + "ch": 6, + "s": [ + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i" + ] + }, + "Moirai One": { "x": 899, "y": 480, "w": 146, "ch": 6, "s": ["400"] }, + "Molengo": { "x": 1059, "y": 480, "w": 96, "ch": 6, "s": ["400"] }, + "Momo Signature": { "x": 0, "y": 520, "w": 227, "ch": 6, "s": ["400"] }, + "Momo Trust Display": { + "x": 241, + "y": 520, + "w": 249, + "ch": 6, + "s": ["400"] + }, + "Momo Trust Sans": { + "x": 504, + "y": 520, + "w": 207, + "ch": 6, + "s": ["200", "300", "400", "500", "600", "700", "800"] + }, + "Mona Sans": { + "x": 725, + "y": 520, + "w": 136, + "ch": 6, + "s": [ + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Monda": { + "x": 875, + "y": 520, + "w": 85, + "ch": 6, + "s": ["400", "500", "600", "700"] + }, + "Monofett": { "x": 974, "y": 520, "w": 119, "ch": 6, "s": ["400"] }, + "Monomakh": { "x": 0, "y": 560, "w": 138, "ch": 6, "s": ["400"] }, + "Monomaniac One": { "x": 152, "y": 560, "w": 156, "ch": 6, "s": ["400"] }, + "Monoton": { "x": 322, "y": 560, "w": 143, "ch": 6, "s": ["400"] }, + "Monsieur La Doulaise": { + "x": 479, + "y": 560, + "w": 215, + "ch": 6, + "s": ["400"] + }, + "Montaga": { "x": 708, "y": 560, "w": 102, "ch": 6, "s": ["400"] }, + "Montagu Slab": { + "x": 824, + "y": 560, + "w": 173, + "ch": 6, + "s": ["100", "200", "300", "400", "500", "600", "700"] + }, + "MonteCarlo": { "x": 1011, "y": 560, "w": 107, "ch": 6, "s": ["400"] }, + "Montez": { "x": 1132, "y": 560, "w": 66, "ch": 6, "s": ["400"] }, + "Montserrat": { + "x": 0, + "y": 600, + "w": 141, + "ch": 6, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Montserrat Alternates": { + "x": 155, + "y": 600, + "w": 282, + "ch": 6, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Montserrat Subrayada": { + "x": 451, + "y": 600, + "w": 347, + "ch": 6, + "s": ["400", "700"] + }, + "Montserrat Underline": { + "x": 812, + "y": 600, + "w": 266, + "ch": 6, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Moo Lah Lah": { "x": 0, "y": 640, "w": 137, "ch": 6, "s": ["400"] }, + "Mooli": { "x": 151, "y": 640, "w": 77, "ch": 6, "s": ["400"] }, + "Moon Dance": { "x": 242, "y": 640, "w": 106, "ch": 6, "s": ["400"] }, + "Moul": { "x": 362, "y": 640, "w": 82, "ch": 6, "s": ["400"] }, + "Moulpali": { "x": 458, "y": 640, "w": 90, "ch": 6, "s": ["400"] }, + "Mountains of Christmas": { + "x": 562, + "y": 640, + "w": 219, + "ch": 6, + "s": ["400", "700"] + }, + "Mouse Memoirs": { "x": 795, "y": 640, "w": 116, "ch": 6, "s": ["400"] }, + "Mozilla Headline": { + "x": 925, + "y": 640, + "w": 192, + "ch": 6, + "s": ["200", "300", "400", "500", "600", "700"] + }, + "Mozilla Text": { + "x": 0, + "y": 680, + "w": 148, + "ch": 6, + "s": ["200", "300", "400", "500", "600", "700"] + }, + "Mr Bedfort": { "x": 162, "y": 680, "w": 124, "ch": 6, "s": ["400"] }, + "Mr Dafoe": { "x": 300, "y": 680, "w": 92, "ch": 6, "s": ["400"] }, + "Mr De Haviland": { "x": 406, "y": 680, "w": 115, "ch": 6, "s": ["400"] }, + "Mrs Saint Delafield": { + "x": 535, + "y": 680, + "w": 159, + "ch": 6, + "s": ["400"] + }, + "Mrs Sheppards": { "x": 708, "y": 680, "w": 139, "ch": 6, "s": ["400"] }, + "Ms Madi": { "x": 861, "y": 680, "w": 89, "ch": 6, "s": ["400"] }, + "Mukta": { + "x": 964, + "y": 680, + "w": 73, + "ch": 6, + "s": ["200", "300", "400", "500", "600", "700", "800"] + }, + "Mukta Mahee": { + "x": 1051, + "y": 680, + "w": 146, + "ch": 6, + "s": ["200", "300", "400", "500", "600", "700", "800"] + }, + "Mukta Malar": { + "x": 0, + "y": 720, + "w": 137, + "ch": 6, + "s": ["200", "300", "400", "500", "600", "700", "800"] + }, + "Mukta Vaani": { + "x": 151, + "y": 720, + "w": 133, + "ch": 6, + "s": ["200", "300", "400", "500", "600", "700", "800"] + }, + "Mulish": { + "x": 298, + "y": 720, + "w": 80, + "ch": 6, + "s": [ + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Murecho": { + "x": 392, + "y": 720, + "w": 102, + "ch": 6, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "MuseoModerno": { + "x": 508, + "y": 720, + "w": 199, + "ch": 6, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "My Soul": { "x": 721, "y": 720, "w": 107, "ch": 6, "s": ["400"] }, + "Mynerve": { "x": 842, "y": 720, "w": 104, "ch": 6, "s": ["400"] }, + "Mystery Quest": { "x": 960, "y": 720, "w": 152, "ch": 6, "s": ["400"] }, + "Nabla": { "x": 1126, "y": 720, "w": 72, "ch": 6, "s": ["400"] }, + "Namdhinggo": { + "x": 0, + "y": 760, + "w": 130, + "ch": 6, + "s": ["400", "500", "600", "700", "800"] + }, + "Nanum Brush Script": { + "x": 144, + "y": 760, + "w": 168, + "ch": 6, + "s": ["400"] + }, + "Nanum Gothic": { + "x": 326, + "y": 760, + "w": 171, + "ch": 6, + "s": ["400", "700", "800"] + }, + "Nanum Gothic Coding": { + "x": 511, + "y": 760, + "w": 236, + "ch": 6, + "s": ["400", "700"] + }, + "Nanum Myeongjo": { + "x": 761, + "y": 760, + "w": 201, + "ch": 6, + "s": ["400", "700", "800"] + }, + "Nanum Pen Script": { "x": 976, "y": 760, "w": 157, "ch": 6, "s": ["400"] }, + "Narnoor": { + "x": 0, + "y": 0, + "w": 90, + "ch": 7, + "s": ["400", "500", "600", "700", "800"] + }, + "Nata Sans": { + "x": 104, + "y": 0, + "w": 123, + "ch": 7, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "National Park": { + "x": 241, + "y": 0, + "w": 149, + "ch": 7, + "s": ["200", "300", "400", "500", "600", "700", "800"] + }, + "Neonderthaw": { "x": 404, "y": 0, "w": 139, "ch": 7, "s": ["400"] }, + "Nerko One": { "x": 557, "y": 0, "w": 107, "ch": 7, "s": ["400"] }, + "Neucha": { "x": 678, "y": 0, "w": 72, "ch": 7, "s": ["400"] }, + "Neuton": { + "x": 764, + "y": 0, + "w": 76, + "ch": 7, + "s": [ + "200", + "200i", + "300", + "300i", + "400", + "400i", + "700", + "700i", + "800", + "800i" + ] + }, + "New Amsterdam": { "x": 854, "y": 0, "w": 139, "ch": 7, "s": ["400"] }, + "New Rocker": { "x": 1007, "y": 0, "w": 133, "ch": 7, "s": ["400"] }, + "New Tegomin": { "x": 0, "y": 40, "w": 159, "ch": 7, "s": ["400"] }, + "News Cycle": { "x": 173, "y": 40, "w": 116, "ch": 7, "s": ["400", "700"] }, + "Newsreader": { + "x": 303, + "y": 40, + "w": 129, + "ch": 7, + "s": [ + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i" + ] + }, + "Niconne": { "x": 446, "y": 40, "w": 79, "ch": 7, "s": ["400"] }, + "Niramit": { + "x": 539, + "y": 40, + "w": 84, + "ch": 7, + "s": [ + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i" + ] + }, + "Nixie One": { "x": 637, "y": 40, "w": 123, "ch": 7, "s": ["400"] }, + "Nobile": { + "x": 774, + "y": 40, + "w": 82, + "ch": 7, + "s": ["400", "400i", "500", "500i", "700", "700i"] + }, + "Nokora": { + "x": 870, + "y": 40, + "w": 91, + "ch": 7, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Norican": { "x": 975, "y": 40, "w": 78, "ch": 7, "s": ["400"] }, + "Nosifer": { "x": 0, "y": 80, "w": 154, "ch": 7, "s": ["400"] }, + "Notable": { "x": 168, "y": 80, "w": 144, "ch": 7, "s": ["400"] }, + "Nothing You Could Do": { + "x": 326, + "y": 80, + "w": 248, + "ch": 7, + "s": ["400"] + }, + "Noticia Text": { + "x": 588, + "y": 80, + "w": 142, + "ch": 7, + "s": ["400", "400i", "700", "700i"] + }, + "Noto Kufi Arabic": { + "x": 744, + "y": 80, + "w": 196, + "ch": 7, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Noto Music": { "x": 954, "y": 80, "w": 136, "ch": 7, "s": ["400"] }, + "Noto Naskh Arabic": { + "x": 0, + "y": 120, + "w": 221, + "ch": 7, + "s": ["400", "500", "600", "700"] + }, + "Noto Nastaliq Urdu": { + "x": 235, + "y": 120, + "w": 222, + "ch": 7, + "s": ["400", "500", "600", "700"] + }, + "Noto Rashi Hebrew": { + "x": 471, + "y": 120, + "w": 230, + "ch": 7, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Noto Sans": { + "x": 715, + "y": 120, + "w": 124, + "ch": 7, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Noto Sans Adlam": { + "x": 853, + "y": 120, + "w": 202, + "ch": 7, + "s": ["400", "500", "600", "700"] + }, + "Noto Sans Adlam Unjoined": { + "x": 0, + "y": 160, + "w": 310, + "ch": 7, + "s": ["400", "500", "600", "700"] + }, + "Noto Sans Anatolian Hieroglyphs": { + "x": 324, + "y": 160, + "w": 380, + "ch": 7, + "s": ["400"] + }, + "Noto Sans Arabic": { + "x": 718, + "y": 160, + "w": 201, + "ch": 7, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Noto Sans Armenian": { + "x": 933, + "y": 160, + "w": 240, + "ch": 7, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Noto Sans Avestan": { "x": 0, "y": 200, "w": 219, "ch": 7, "s": ["400"] }, + "Noto Sans Balinese": { + "x": 233, + "y": 200, + "w": 225, + "ch": 7, + "s": ["400", "500", "600", "700"] + }, + "Noto Sans Bamum": { + "x": 472, + "y": 200, + "w": 219, + "ch": 7, + "s": ["400", "500", "600", "700"] + }, + "Noto Sans Bassa Vah": { + "x": 705, + "y": 200, + "w": 250, + "ch": 7, + "s": ["400", "500", "600", "700"] + }, + "Noto Sans Batak": { "x": 969, "y": 200, "w": 194, "ch": 7, "s": ["400"] }, + "Noto Sans Bengali": { + "x": 0, + "y": 240, + "w": 214, + "ch": 7, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Noto Sans Bhaiksuki": { + "x": 228, + "y": 240, + "w": 236, + "ch": 7, + "s": ["400"] + }, + "Noto Sans Brahmi": { "x": 478, "y": 240, "w": 212, "ch": 7, "s": ["400"] }, + "Noto Sans Buginese": { + "x": 704, + "y": 240, + "w": 235, + "ch": 7, + "s": ["400"] + }, + "Noto Sans Buhid": { "x": 953, "y": 240, "w": 196, "ch": 7, "s": ["400"] }, + "Noto Sans Canadian Aboriginal": { + "x": 0, + "y": 280, + "w": 361, + "ch": 7, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Noto Sans Carian": { "x": 375, "y": 280, "w": 203, "ch": 7, "s": ["400"] }, + "Noto Sans Caucasian Albanian": { + "x": 592, + "y": 280, + "w": 353, + "ch": 7, + "s": ["400"] + }, + "Noto Sans Chakma": { "x": 959, "y": 280, "w": 222, "ch": 7, "s": ["400"] }, + "Noto Sans Cham": { + "x": 0, + "y": 320, + "w": 196, + "ch": 7, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Noto Sans Cherokee": { + "x": 210, + "y": 320, + "w": 238, + "ch": 7, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Noto Sans Chorasmian": { + "x": 462, + "y": 320, + "w": 268, + "ch": 7, + "s": ["400"] + }, + "Noto Sans Coptic": { "x": 744, "y": 320, "w": 201, "ch": 7, "s": ["400"] }, + "Noto Sans Cuneiform": { + "x": 0, + "y": 360, + "w": 250, + "ch": 7, + "s": ["400"] + }, + "Noto Sans Cypriot": { + "x": 264, + "y": 360, + "w": 211, + "ch": 7, + "s": ["400"] + }, + "Noto Sans Cypro Minoan": { + "x": 489, + "y": 360, + "w": 288, + "ch": 7, + "s": ["400"] + }, + "Noto Sans Deseret": { + "x": 791, + "y": 360, + "w": 218, + "ch": 7, + "s": ["400"] + }, + "Noto Sans Devanagari": { + "x": 0, + "y": 400, + "w": 259, + "ch": 7, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Noto Sans Display": { + "x": 273, + "y": 400, + "w": 200, + "ch": 7, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Noto Sans Duployan": { + "x": 487, + "y": 400, + "w": 238, + "ch": 7, + "s": ["400", "700"] + }, + "Noto Sans Egyptian Hieroglyphs": { + "x": 739, + "y": 400, + "w": 369, + "ch": 7, + "s": ["400"] + }, + "Noto Sans Elbasan": { "x": 0, "y": 440, "w": 217, "ch": 7, "s": ["400"] }, + "Noto Sans Elymaic": { + "x": 231, + "y": 440, + "w": 222, + "ch": 7, + "s": ["400"] + }, + "Noto Sans Ethiopic": { + "x": 467, + "y": 440, + "w": 220, + "ch": 7, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Noto Sans Georgian": { + "x": 701, + "y": 440, + "w": 234, + "ch": 7, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Noto Sans Glagolitic": { + "x": 949, + "y": 440, + "w": 235, + "ch": 7, + "s": ["400"] + }, + "Noto Sans Gothic": { "x": 0, "y": 480, "w": 203, "ch": 7, "s": ["400"] }, + "Noto Sans Grantha": { + "x": 217, + "y": 480, + "w": 222, + "ch": 7, + "s": ["400"] + }, + "Noto Sans Gujarati": { + "x": 453, + "y": 480, + "w": 220, + "ch": 7, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Noto Sans Gunjala Gondi": { + "x": 687, + "y": 480, + "w": 291, + "ch": 7, + "s": ["400", "500", "600", "700"] + }, + "Noto Sans Gurmukhi": { + "x": 0, + "y": 520, + "w": 243, + "ch": 7, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Noto Sans Hanifi Rohingya": { + "x": 257, + "y": 520, + "w": 309, + "ch": 7, + "s": ["400", "500", "600", "700"] + }, + "Noto Sans Hanunoo": { + "x": 580, + "y": 520, + "w": 235, + "ch": 7, + "s": ["400"] + }, + "Noto Sans Hatran": { "x": 829, "y": 520, "w": 208, "ch": 7, "s": ["400"] }, + "Noto Sans Hebrew": { + "x": 0, + "y": 560, + "w": 217, + "ch": 7, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Noto Sans HK": { + "x": 231, + "y": 560, + "w": 160, + "ch": 7, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Noto Sans Imperial Aramaic": { + "x": 405, + "y": 560, + "w": 323, + "ch": 7, + "s": ["400"] + }, + "Noto Sans Indic Siyaq Numbers": { + "x": 742, + "y": 560, + "w": 351, + "ch": 7, + "s": ["400"] + }, + "Noto Sans Inscriptional Pahlavi": { + "x": 0, + "y": 600, + "w": 358, + "ch": 7, + "s": ["400"] + }, + "Noto Sans Inscriptional Parthian": { + "x": 372, + "y": 600, + "w": 373, + "ch": 7, + "s": ["400"] + }, + "Noto Sans Javanese": { + "x": 759, + "y": 600, + "w": 229, + "ch": 7, + "s": ["400", "500", "600", "700"] + }, + "Noto Sans JP": { + "x": 1002, + "y": 600, + "w": 155, + "ch": 7, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Noto Sans Kaithi": { "x": 0, "y": 640, "w": 195, "ch": 7, "s": ["400"] }, + "Noto Sans Kannada": { + "x": 209, + "y": 640, + "w": 231, + "ch": 7, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Noto Sans Kawi": { + "x": 454, + "y": 640, + "w": 180, + "ch": 7, + "s": ["400", "500", "600", "700"] + }, + "Noto Sans Kayah Li": { + "x": 648, + "y": 640, + "w": 224, + "ch": 7, + "s": ["400", "500", "600", "700"] + }, + "Noto Sans Kharoshthi": { + "x": 886, + "y": 640, + "w": 253, + "ch": 7, + "s": ["400"] + }, + "Noto Sans Khmer": { + "x": 0, + "y": 680, + "w": 205, + "ch": 7, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Noto Sans Khojki": { "x": 219, "y": 680, "w": 199, "ch": 7, "s": ["400"] }, + "Noto Sans Khudawadi": { + "x": 432, + "y": 680, + "w": 256, + "ch": 7, + "s": ["400"] + }, + "Noto Sans KR": { + "x": 702, + "y": 680, + "w": 158, + "ch": 7, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Noto Sans Lao": { + "x": 874, + "y": 680, + "w": 170, + "ch": 7, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Noto Sans Lao Looped": { + "x": 0, + "y": 720, + "w": 261, + "ch": 7, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Noto Sans Lepcha": { "x": 275, "y": 720, "w": 211, "ch": 7, "s": ["400"] }, + "Noto Sans Limbu": { "x": 500, "y": 720, "w": 201, "ch": 7, "s": ["400"] }, + "Noto Sans Linear A": { + "x": 715, + "y": 720, + "w": 222, + "ch": 7, + "s": ["400"] + }, + "Noto Sans Linear B": { + "x": 951, + "y": 720, + "w": 222, + "ch": 7, + "s": ["400"] + }, + "Noto Sans Lisu": { + "x": 0, + "y": 760, + "w": 175, + "ch": 7, + "s": ["400", "500", "600", "700"] + }, + "Noto Sans Lydian": { "x": 189, "y": 760, "w": 204, "ch": 7, "s": ["400"] }, + "Noto Sans Mahajani": { + "x": 407, + "y": 760, + "w": 234, + "ch": 7, + "s": ["400"] + }, + "Noto Sans Malayalam": { + "x": 655, + "y": 760, + "w": 255, + "ch": 7, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Noto Sans Mandaic": { + "x": 924, + "y": 760, + "w": 226, + "ch": 7, + "s": ["400"] + }, + "Noto Sans Manichaean": { "x": 0, "y": 0, "w": 268, "ch": 8, "s": ["400"] }, + "Noto Sans Marchen": { "x": 282, "y": 0, "w": 227, "ch": 8, "s": ["400"] }, + "Noto Sans Masaram Gondi": { + "x": 523, + "y": 0, + "w": 318, + "ch": 8, + "s": ["400"] + }, + "Noto Sans Math": { "x": 855, "y": 0, "w": 189, "ch": 8, "s": ["400"] }, + "Noto Sans Mayan Numerals": { + "x": 0, + "y": 40, + "w": 322, + "ch": 8, + "s": ["400"] + }, + "Noto Sans Medefaidrin": { + "x": 336, + "y": 40, + "w": 267, + "ch": 8, + "s": ["400", "500", "600", "700"] + }, + "Noto Sans Meetei Mayek": { + "x": 617, + "y": 40, + "w": 289, + "ch": 8, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Noto Sans Mende Kikakui": { + "x": 0, + "y": 80, + "w": 296, + "ch": 8, + "s": ["400"] + }, + "Noto Sans Meroitic": { + "x": 310, + "y": 80, + "w": 222, + "ch": 8, + "s": ["400"] + }, + "Noto Sans Miao": { "x": 546, "y": 80, "w": 186, "ch": 8, "s": ["400"] }, + "Noto Sans Modi": { "x": 746, "y": 80, "w": 187, "ch": 8, "s": ["400"] }, + "Noto Sans Mongolian": { + "x": 947, + "y": 80, + "w": 251, + "ch": 8, + "s": ["400"] + }, + "Noto Sans Mono": { + "x": 0, + "y": 120, + "w": 210, + "ch": 8, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Noto Sans Mro": { "x": 224, "y": 120, "w": 176, "ch": 8, "s": ["400"] }, + "Noto Sans Multani": { + "x": 414, + "y": 120, + "w": 216, + "ch": 8, + "s": ["400"] + }, + "Noto Sans Myanmar": { + "x": 644, + "y": 120, + "w": 238, + "ch": 8, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Noto Sans Nabataean": { + "x": 896, + "y": 120, + "w": 254, + "ch": 8, + "s": ["400"] + }, + "Noto Sans Nag Mundari": { + "x": 0, + "y": 160, + "w": 274, + "ch": 8, + "s": ["400", "500", "600", "700"] + }, + "Noto Sans Nandinagari": { + "x": 288, + "y": 160, + "w": 272, + "ch": 8, + "s": ["400"] + }, + "Noto Sans New Tai Lue": { + "x": 574, + "y": 160, + "w": 265, + "ch": 8, + "s": ["400", "500", "600", "700"] + }, + "Noto Sans Newa": { "x": 853, "y": 160, "w": 194, "ch": 8, "s": ["400"] }, + "Noto Sans NKo": { "x": 0, "y": 200, "w": 178, "ch": 8, "s": ["400"] }, + "Noto Sans NKo Unjoined": { + "x": 192, + "y": 200, + "w": 286, + "ch": 8, + "s": ["400", "500", "600", "700"] + }, + "Noto Sans Nushu": { "x": 492, "y": 200, "w": 204, "ch": 8, "s": ["400"] }, + "Noto Sans Ogham": { "x": 710, "y": 200, "w": 214, "ch": 8, "s": ["400"] }, + "Noto Sans Ol Chiki": { + "x": 938, + "y": 200, + "w": 216, + "ch": 8, + "s": ["400", "500", "600", "700"] + }, + "Noto Sans Old Hungarian": { + "x": 0, + "y": 240, + "w": 296, + "ch": 8, + "s": ["400"] + }, + "Noto Sans Old Italic": { + "x": 310, + "y": 240, + "w": 230, + "ch": 8, + "s": ["400"] + }, + "Noto Sans Old North Arabian": { + "x": 554, + "y": 240, + "w": 336, + "ch": 8, + "s": ["400"] + }, + "Noto Sans Old Permic": { + "x": 904, + "y": 240, + "w": 253, + "ch": 8, + "s": ["400"] + }, + "Noto Sans Old Persian": { + "x": 0, + "y": 280, + "w": 260, + "ch": 8, + "s": ["400"] + }, + "Noto Sans Old Sogdian": { + "x": 274, + "y": 280, + "w": 260, + "ch": 8, + "s": ["400"] + }, + "Noto Sans Old South Arabian": { + "x": 548, + "y": 280, + "w": 336, + "ch": 8, + "s": ["400"] + }, + "Noto Sans Old Turkic": { + "x": 898, + "y": 280, + "w": 243, + "ch": 8, + "s": ["400"] + }, + "Noto Sans Oriya": { + "x": 0, + "y": 320, + "w": 190, + "ch": 8, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Noto Sans Osage": { "x": 204, "y": 320, "w": 202, "ch": 8, "s": ["400"] }, + "Noto Sans Osmanya": { + "x": 420, + "y": 320, + "w": 238, + "ch": 8, + "s": ["400"] + }, + "Noto Sans Pahawh Hmong": { + "x": 672, + "y": 320, + "w": 310, + "ch": 8, + "s": ["400"] + }, + "Noto Sans Palmyrene": { + "x": 0, + "y": 360, + "w": 251, + "ch": 8, + "s": ["400"] + }, + "Noto Sans Pau Cin Hau": { + "x": 265, + "y": 360, + "w": 269, + "ch": 8, + "s": ["400"] + }, + "Noto Sans PhagsPa": { + "x": 548, + "y": 360, + "w": 227, + "ch": 8, + "s": ["400"] + }, + "Noto Sans Phoenician": { + "x": 789, + "y": 360, + "w": 254, + "ch": 8, + "s": ["400"] + }, + "Noto Sans Psalter Pahlavi": { + "x": 0, + "y": 400, + "w": 295, + "ch": 8, + "s": ["400"] + }, + "Noto Sans Rejang": { "x": 309, "y": 400, "w": 208, "ch": 8, "s": ["400"] }, + "Noto Sans Runic": { "x": 531, "y": 400, "w": 192, "ch": 8, "s": ["400"] }, + "Noto Sans Samaritan": { + "x": 737, + "y": 400, + "w": 245, + "ch": 8, + "s": ["400"] + }, + "Noto Sans Saurashtra": { + "x": 0, + "y": 440, + "w": 254, + "ch": 8, + "s": ["400"] + }, + "Noto Sans SC": { + "x": 268, + "y": 440, + "w": 157, + "ch": 8, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Noto Sans Sharada": { + "x": 439, + "y": 440, + "w": 222, + "ch": 8, + "s": ["400"] + }, + "Noto Sans Shavian": { + "x": 675, + "y": 440, + "w": 218, + "ch": 8, + "s": ["400"] + }, + "Noto Sans Siddham": { + "x": 907, + "y": 440, + "w": 227, + "ch": 8, + "s": ["400"] + }, + "Noto Sans SignWriting": { + "x": 0, + "y": 480, + "w": 297, + "ch": 8, + "s": ["400"] + }, + "Noto Sans Sinhala": { + "x": 311, + "y": 480, + "w": 224, + "ch": 8, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Noto Sans Sogdian": { + "x": 549, + "y": 480, + "w": 216, + "ch": 8, + "s": ["400"] + }, + "Noto Sans Sora Sompeng": { + "x": 779, + "y": 480, + "w": 295, + "ch": 8, + "s": ["400", "500", "600", "700"] + }, + "Noto Sans Soyombo": { "x": 0, "y": 520, "w": 236, "ch": 8, "s": ["400"] }, + "Noto Sans Sundanese": { + "x": 250, + "y": 520, + "w": 254, + "ch": 8, + "s": ["400", "500", "600", "700"] + }, + "Noto Sans Sunuwar": { + "x": 518, + "y": 520, + "w": 237, + "ch": 8, + "s": ["400"] + }, + "Noto Sans Syloti Nagri": { + "x": 769, + "y": 520, + "w": 260, + "ch": 8, + "s": ["400"] + }, + "Noto Sans Symbols": { + "x": 0, + "y": 560, + "w": 225, + "ch": 8, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Noto Sans Symbols 2": { + "x": 239, + "y": 560, + "w": 257, + "ch": 8, + "s": ["400"] + }, + "Noto Sans Syriac": { + "x": 510, + "y": 560, + "w": 196, + "ch": 8, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Noto Sans Syriac Eastern": { + "x": 720, + "y": 560, + "w": 288, + "ch": 8, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Noto Sans Syriac Western": { + "x": 0, + "y": 600, + "w": 296, + "ch": 8, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Noto Sans Tagalog": { + "x": 310, + "y": 600, + "w": 218, + "ch": 8, + "s": ["400"] + }, + "Noto Sans Tagbanwa": { + "x": 542, + "y": 600, + "w": 247, + "ch": 8, + "s": ["400"] + }, + "Noto Sans Tai Le": { "x": 803, "y": 600, "w": 193, "ch": 8, "s": ["400"] }, + "Noto Sans Tai Tham": { + "x": 0, + "y": 640, + "w": 243, + "ch": 8, + "s": ["400", "500", "600", "700"] + }, + "Noto Sans Tai Viet": { + "x": 257, + "y": 640, + "w": 210, + "ch": 8, + "s": ["400"] + }, + "Noto Sans Takri": { "x": 481, "y": 640, "w": 184, "ch": 8, "s": ["400"] }, + "Noto Sans Tamil": { + "x": 679, + "y": 640, + "w": 190, + "ch": 8, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Noto Sans Tamil Supplement": { + "x": 0, + "y": 680, + "w": 334, + "ch": 8, + "s": ["400"] + }, + "Noto Sans Tangsa": { + "x": 348, + "y": 680, + "w": 209, + "ch": 8, + "s": ["400", "500", "600", "700"] + }, + "Noto Sans TC": { + "x": 571, + "y": 680, + "w": 156, + "ch": 8, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Noto Sans Telugu": { + "x": 741, + "y": 680, + "w": 207, + "ch": 8, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Noto Sans Thaana": { + "x": 962, + "y": 680, + "w": 213, + "ch": 8, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Noto Sans Thai": { + "x": 0, + "y": 720, + "w": 178, + "ch": 8, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Noto Sans Thai Looped": { + "x": 192, + "y": 720, + "w": 269, + "ch": 8, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Noto Sans Tifinagh": { + "x": 475, + "y": 720, + "w": 222, + "ch": 8, + "s": ["400"] + }, + "Noto Sans Tirhuta": { + "x": 711, + "y": 720, + "w": 211, + "ch": 8, + "s": ["400"] + }, + "Noto Sans Ugaritic": { + "x": 936, + "y": 720, + "w": 218, + "ch": 8, + "s": ["400"] + }, + "Noto Sans Vai": { "x": 0, "y": 760, "w": 164, "ch": 8, "s": ["400"] }, + "Noto Sans Vithkuqi": { + "x": 178, + "y": 760, + "w": 223, + "ch": 8, + "s": ["400", "500", "600", "700"] + }, + "Noto Sans Wancho": { "x": 415, "y": 760, "w": 228, "ch": 8, "s": ["400"] }, + "Noto Sans Warang Citi": { + "x": 657, + "y": 760, + "w": 264, + "ch": 8, + "s": ["400"] + }, + "Noto Sans Yi": { "x": 935, "y": 760, "w": 149, "ch": 8, "s": ["400"] }, + "Noto Sans Zanabazar Square": { + "x": 0, + "y": 0, + "w": 334, + "ch": 9, + "s": ["400"] + }, + "Noto Serif": { + "x": 348, + "y": 0, + "w": 123, + "ch": 9, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Noto Serif Ahom": { "x": 485, "y": 0, "w": 196, "ch": 9, "s": ["400"] }, + "Noto Serif Armenian": { + "x": 695, + "y": 0, + "w": 245, + "ch": 9, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Noto Serif Balinese": { + "x": 954, + "y": 0, + "w": 225, + "ch": 9, + "s": ["400"] + }, + "Noto Serif Bengali": { + "x": 0, + "y": 40, + "w": 215, + "ch": 9, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Noto Serif Devanagari": { + "x": 229, + "y": 40, + "w": 261, + "ch": 9, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Noto Serif Display": { + "x": 504, + "y": 40, + "w": 212, + "ch": 9, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Noto Serif Dives Akuru": { + "x": 730, + "y": 40, + "w": 274, + "ch": 9, + "s": ["400"] + }, + "Noto Serif Dogra": { "x": 0, "y": 80, "w": 197, "ch": 9, "s": ["400"] }, + "Noto Serif Ethiopic": { + "x": 211, + "y": 80, + "w": 224, + "ch": 9, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Noto Serif Georgian": { + "x": 449, + "y": 80, + "w": 234, + "ch": 9, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Noto Serif Grantha": { + "x": 697, + "y": 80, + "w": 223, + "ch": 9, + "s": ["400"] + }, + "Noto Serif Gujarati": { + "x": 934, + "y": 80, + "w": 225, + "ch": 9, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Noto Serif Gurmukhi": { + "x": 0, + "y": 120, + "w": 248, + "ch": 9, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Noto Serif Hebrew": { + "x": 262, + "y": 120, + "w": 221, + "ch": 9, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Noto Serif Hentaigana": { + "x": 497, + "y": 120, + "w": 269, + "ch": 9, + "s": ["200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Noto Serif HK": { + "x": 780, + "y": 120, + "w": 170, + "ch": 9, + "s": ["200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Noto Serif JP": { + "x": 964, + "y": 120, + "w": 157, + "ch": 9, + "s": ["200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Noto Serif Kannada": { + "x": 0, + "y": 160, + "w": 231, + "ch": 9, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Noto Serif Khitan Small Script": { + "x": 245, + "y": 160, + "w": 383, + "ch": 9, + "s": ["400"] + }, + "Noto Serif Khmer": { + "x": 642, + "y": 160, + "w": 208, + "ch": 9, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Noto Serif Khojki": { + "x": 864, + "y": 160, + "w": 204, + "ch": 9, + "s": ["400", "500", "600", "700"] + }, + "Noto Serif KR": { + "x": 0, + "y": 200, + "w": 167, + "ch": 9, + "s": ["200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Noto Serif Lao": { + "x": 181, + "y": 200, + "w": 171, + "ch": 9, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Noto Serif Makasar": { + "x": 366, + "y": 200, + "w": 226, + "ch": 9, + "s": ["400"] + }, + "Noto Serif Malayalam": { + "x": 606, + "y": 200, + "w": 259, + "ch": 9, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Noto Serif NP Hmong": { + "x": 879, + "y": 200, + "w": 252, + "ch": 9, + "s": ["400", "500", "600", "700"] + }, + "Noto Serif Old Uyghur": { + "x": 0, + "y": 240, + "w": 257, + "ch": 9, + "s": ["400"] + }, + "Noto Serif Oriya": { + "x": 271, + "y": 240, + "w": 200, + "ch": 9, + "s": ["400", "500", "600", "700"] + }, + "Noto Serif Ottoman Siyaq": { + "x": 485, + "y": 240, + "w": 298, + "ch": 9, + "s": ["400"] + }, + "Noto Serif SC": { + "x": 797, + "y": 240, + "w": 162, + "ch": 9, + "s": ["200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Noto Serif Sinhala": { + "x": 973, + "y": 240, + "w": 227, + "ch": 9, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Noto Serif Tamil": { + "x": 0, + "y": 280, + "w": 194, + "ch": 9, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Noto Serif Tangut": { + "x": 208, + "y": 280, + "w": 220, + "ch": 9, + "s": ["400"] + }, + "Noto Serif TC": { + "x": 442, + "y": 280, + "w": 164, + "ch": 9, + "s": ["200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Noto Serif Telugu": { + "x": 620, + "y": 280, + "w": 207, + "ch": 9, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Noto Serif Thai": { + "x": 841, + "y": 280, + "w": 180, + "ch": 9, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Noto Serif Tibetan": { + "x": 0, + "y": 320, + "w": 217, + "ch": 9, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Noto Serif Todhri": { + "x": 231, + "y": 320, + "w": 201, + "ch": 9, + "s": ["400"] + }, + "Noto Serif Toto": { + "x": 446, + "y": 320, + "w": 179, + "ch": 9, + "s": ["400", "500", "600", "700"] + }, + "Noto Serif Vithkuqi": { + "x": 639, + "y": 320, + "w": 228, + "ch": 9, + "s": ["400", "500", "600", "700"] + }, + "Noto Serif Yezidi": { + "x": 881, + "y": 320, + "w": 198, + "ch": 9, + "s": ["400", "500", "600", "700"] + }, + "Noto Traditional Nushu": { + "x": 0, + "y": 360, + "w": 289, + "ch": 9, + "s": ["300", "400", "500", "600", "700"] + }, + "Noto Znamenny Musical Notation": { + "x": 303, + "y": 360, + "w": 388, + "ch": 9, + "s": ["400"] + }, + "Nova Cut": { "x": 705, "y": 360, "w": 108, "ch": 9, "s": ["400"] }, + "Nova Flat": { "x": 827, "y": 360, "w": 112, "ch": 9, "s": ["400"] }, + "Nova Mono": { "x": 953, "y": 360, "w": 130, "ch": 9, "s": ["400"] }, + "Nova Oval": { "x": 0, "y": 400, "w": 123, "ch": 9, "s": ["400"] }, + "Nova Round": { "x": 137, "y": 400, "w": 140, "ch": 9, "s": ["400"] }, + "Nova Script": { "x": 291, "y": 400, "w": 140, "ch": 9, "s": ["400"] }, + "Nova Slim": { "x": 445, "y": 400, "w": 121, "ch": 9, "s": ["400"] }, + "Nova Square": { "x": 580, "y": 400, "w": 148, "ch": 9, "s": ["400"] }, + "NTR": { "x": 742, "y": 400, "w": 49, "ch": 9, "s": ["400"] }, + "Numans": { "x": 805, "y": 400, "w": 113, "ch": 9, "s": ["400"] }, + "Nunito": { + "x": 932, + "y": 400, + "w": 81, + "ch": 9, + "s": [ + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Nunito Sans": { + "x": 1027, + "y": 400, + "w": 139, + "ch": 9, + "s": [ + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Nuosu SIL": { "x": 0, "y": 440, "w": 117, "ch": 9, "s": ["400"] }, + "Odibee Sans": { "x": 131, "y": 440, "w": 95, "ch": 9, "s": ["400"] }, + "Odor Mean Chey": { "x": 240, "y": 440, "w": 184, "ch": 9, "s": ["400"] }, + "Offside": { "x": 438, "y": 440, "w": 96, "ch": 9, "s": ["400"] }, + "Oi": { "x": 548, "y": 440, "w": 47, "ch": 9, "s": ["400"] }, + "Ojuju": { + "x": 609, + "y": 440, + "w": 62, + "ch": 9, + "s": ["200", "300", "400", "500", "600", "700", "800"] + }, + "Old Standard TT": { + "x": 685, + "y": 440, + "w": 187, + "ch": 9, + "s": ["400", "400i", "700", "700i"] + }, + "Oldenburg": { "x": 886, "y": 440, "w": 136, "ch": 9, "s": ["400"] }, + "Ole": { "x": 1036, "y": 440, "w": 33, "ch": 9, "s": ["400"] }, + "Oleo Script": { + "x": 1083, + "y": 440, + "w": 109, + "ch": 9, + "s": ["400", "700"] + }, + "Oleo Script Swash Caps": { + "x": 0, + "y": 480, + "w": 227, + "ch": 9, + "s": ["400", "700"] + }, + "Onest": { + "x": 241, + "y": 480, + "w": 76, + "ch": 9, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Oooh Baby": { "x": 331, "y": 480, "w": 109, "ch": 9, "s": ["400"] }, + "Open Sans": { + "x": 454, + "y": 480, + "w": 129, + "ch": 9, + "s": [ + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i" + ] + }, + "Oranienbaum": { "x": 597, "y": 480, "w": 137, "ch": 9, "s": ["400"] }, + "Orbit": { "x": 748, "y": 480, "w": 80, "ch": 9, "s": ["400"] }, + "Orbitron": { + "x": 842, + "y": 480, + "w": 117, + "ch": 9, + "s": ["400", "500", "600", "700", "800", "900"] + }, + "Oregano": { "x": 973, "y": 480, "w": 78, "ch": 9, "s": ["400", "400i"] }, + "Orelega One": { "x": 0, "y": 520, "w": 138, "ch": 9, "s": ["400"] }, + "Orienta": { "x": 152, "y": 520, "w": 94, "ch": 9, "s": ["400"] }, + "Original Surfer": { "x": 260, "y": 520, "w": 175, "ch": 9, "s": ["400"] }, + "Oswald": { + "x": 449, + "y": 520, + "w": 70, + "ch": 9, + "s": ["200", "300", "400", "500", "600", "700"] + }, + "Outfit": { + "x": 533, + "y": 520, + "w": 71, + "ch": 9, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Over the Rainbow": { "x": 618, "y": 520, "w": 189, "ch": 9, "s": ["400"] }, + "Overlock": { + "x": 821, + "y": 520, + "w": 95, + "ch": 9, + "s": ["400", "400i", "700", "700i", "900", "900i"] + }, + "Overlock SC": { "x": 930, "y": 520, "w": 139, "ch": 9, "s": ["400"] }, + "Overpass": { + "x": 1083, + "y": 520, + "w": 109, + "ch": 9, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Overpass Mono": { + "x": 0, + "y": 560, + "w": 201, + "ch": 9, + "s": ["300", "400", "500", "600", "700"] + }, + "Ovo": { "x": 215, "y": 560, "w": 52, "ch": 9, "s": ["400"] }, + "Oxanium": { + "x": 281, + "y": 560, + "w": 106, + "ch": 9, + "s": ["200", "300", "400", "500", "600", "700", "800"] + }, + "Oxygen": { + "x": 401, + "y": 560, + "w": 92, + "ch": 9, + "s": ["300", "400", "700"] + }, + "Oxygen Mono": { "x": 507, "y": 560, "w": 167, "ch": 9, "s": ["400"] }, + "Pacifico": { "x": 688, "y": 560, "w": 87, "ch": 9, "s": ["400"] }, + "Padauk": { "x": 789, "y": 560, "w": 79, "ch": 9, "s": ["400", "700"] }, + "Padyakke Expanded One": { + "x": 0, + "y": 600, + "w": 415, + "ch": 9, + "s": ["400"] + }, + "Palanquin": { + "x": 429, + "y": 600, + "w": 111, + "ch": 9, + "s": ["100", "200", "300", "400", "500", "600", "700"] + }, + "Palanquin Dark": { + "x": 554, + "y": 600, + "w": 172, + "ch": 9, + "s": ["400", "500", "600", "700"] + }, + "Palette Mosaic": { "x": 740, "y": 600, "w": 212, "ch": 9, "s": ["400"] }, + "Pangolin": { "x": 966, "y": 600, "w": 93, "ch": 9, "s": ["400"] }, + "Paprika": { "x": 1073, "y": 600, "w": 105, "ch": 9, "s": ["400"] }, + "Parastoo": { + "x": 0, + "y": 640, + "w": 82, + "ch": 9, + "s": ["400", "500", "600", "700"] + }, + "Parisienne": { "x": 96, "y": 640, "w": 103, "ch": 9, "s": ["400"] }, + "Parkinsans": { + "x": 213, + "y": 640, + "w": 139, + "ch": 9, + "s": ["300", "400", "500", "600", "700", "800"] + }, + "Passero One": { "x": 366, "y": 640, "w": 125, "ch": 9, "s": ["400"] }, + "Passion One": { + "x": 505, + "y": 640, + "w": 114, + "ch": 9, + "s": ["400", "700", "900"] + }, + "Passions Conflict": { + "x": 633, + "y": 640, + "w": 109, + "ch": 9, + "s": ["400"] + }, + "Pathway Extreme": { + "x": 756, + "y": 640, + "w": 214, + "ch": 9, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Pathway Gothic One": { + "x": 984, + "y": 640, + "w": 154, + "ch": 9, + "s": ["400"] + }, + "Patrick Hand": { "x": 0, "y": 680, "w": 118, "ch": 9, "s": ["400"] }, + "Patrick Hand SC": { "x": 132, "y": 680, "w": 154, "ch": 9, "s": ["400"] }, + "Pattaya": { "x": 300, "y": 680, "w": 85, "ch": 9, "s": ["400"] }, + "Patua One": { "x": 399, "y": 680, "w": 117, "ch": 9, "s": ["400"] }, + "Pavanam": { "x": 530, "y": 680, "w": 97, "ch": 9, "s": ["400"] }, + "Paytone One": { "x": 641, "y": 680, "w": 164, "ch": 9, "s": ["400"] }, + "Peddana": { "x": 819, "y": 680, "w": 67, "ch": 9, "s": ["400"] }, + "Peralta": { "x": 900, "y": 680, "w": 108, "ch": 9, "s": ["400"] }, + "Permanent Marker": { "x": 0, "y": 720, "w": 234, "ch": 9, "s": ["400"] }, + "Petemoss": { "x": 248, "y": 720, "w": 60, "ch": 9, "s": ["400"] }, + "Petit Formal Script": { + "x": 322, + "y": 720, + "w": 255, + "ch": 9, + "s": ["400"] + }, + "Petrona": { + "x": 591, + "y": 720, + "w": 89, + "ch": 9, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Philosopher": { + "x": 694, + "y": 720, + "w": 129, + "ch": 9, + "s": ["400", "400i", "700", "700i"] + }, + "Phudu": { + "x": 837, + "y": 720, + "w": 76, + "ch": 9, + "s": ["300", "400", "500", "600", "700", "800", "900"] + }, + "Piazzolla": { + "x": 927, + "y": 720, + "w": 107, + "ch": 9, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Piedra": { "x": 1048, "y": 720, "w": 68, "ch": 9, "s": ["400"] }, + "Pinyon Script": { "x": 0, "y": 760, "w": 128, "ch": 9, "s": ["400"] }, + "Pirata One": { "x": 142, "y": 760, "w": 92, "ch": 9, "s": ["400"] }, + "Pixelify Sans": { + "x": 248, + "y": 760, + "w": 153, + "ch": 9, + "s": ["400", "500", "600", "700"] + }, + "Plaster": { "x": 415, "y": 760, "w": 126, "ch": 9, "s": ["400"] }, + "Platypi": { + "x": 555, + "y": 760, + "w": 90, + "ch": 9, + "s": [ + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i" + ] + }, + "Play": { "x": 659, "y": 760, "w": 52, "ch": 9, "s": ["400", "700"] }, + "Playball": { "x": 725, "y": 760, "w": 83, "ch": 9, "s": ["400"] }, + "Playfair": { + "x": 822, + "y": 760, + "w": 90, + "ch": 9, + "s": [ + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Playfair Display": { + "x": 926, + "y": 760, + "w": 176, + "ch": 9, + "s": [ + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Playfair Display SC": { + "x": 0, + "y": 0, + "w": 247, + "ch": 10, + "s": ["400", "400i", "700", "700i", "900", "900i"] + }, + "Playpen Sans": { + "x": 261, + "y": 0, + "w": 161, + "ch": 10, + "s": ["100", "200", "300", "400", "500", "600", "700", "800"] + }, + "Playpen Sans Arabic": { + "x": 436, + "y": 0, + "w": 247, + "ch": 10, + "s": ["100", "200", "300", "400", "500", "600", "700", "800"] + }, + "Playpen Sans Deva": { + "x": 697, + "y": 0, + "w": 227, + "ch": 10, + "s": ["100", "200", "300", "400", "500", "600", "700", "800"] + }, + "Playpen Sans Hebrew": { + "x": 938, + "y": 0, + "w": 255, + "ch": 10, + "s": ["100", "200", "300", "400", "500", "600", "700", "800"] + }, + "Playpen Sans Thai": { + "x": 0, + "y": 40, + "w": 220, + "ch": 10, + "s": ["100", "200", "300", "400", "500", "600", "700", "800"] + }, + "Playwrite AR": { + "x": 234, + "y": 40, + "w": 199, + "ch": 10, + "s": ["100", "200", "300", "400"] + }, + "Playwrite AR Guides": { + "x": 447, + "y": 40, + "w": 294, + "ch": 10, + "s": ["400"] + }, + "Playwrite AT": { + "x": 755, + "y": 40, + "w": 180, + "ch": 10, + "s": ["100", "100i", "200", "200i", "300", "300i", "400", "400i"] + }, + "Playwrite AT Guides": { + "x": 0, + "y": 80, + "w": 273, + "ch": 10, + "s": ["400", "400i"] + }, + "Playwrite AU NSW": { + "x": 287, + "y": 80, + "w": 257, + "ch": 10, + "s": ["100", "200", "300", "400"] + }, + "Playwrite AU NSW Guides": { + "x": 558, + "y": 80, + "w": 350, + "ch": 10, + "s": ["400"] + }, + "Playwrite AU QLD": { + "x": 922, + "y": 80, + "w": 249, + "ch": 10, + "s": ["100", "200", "300", "400"] + }, + "Playwrite AU QLD Guides": { + "x": 0, + "y": 120, + "w": 344, + "ch": 10, + "s": ["400"] + }, + "Playwrite AU SA": { + "x": 358, + "y": 120, + "w": 217, + "ch": 10, + "s": ["100", "200", "300", "400"] + }, + "Playwrite AU SA Guides": { + "x": 589, + "y": 120, + "w": 312, + "ch": 10, + "s": ["400"] + }, + "Playwrite AU TAS": { + "x": 915, + "y": 120, + "w": 235, + "ch": 10, + "s": ["100", "200", "300", "400"] + }, + "Playwrite AU TAS Guides": { + "x": 0, + "y": 160, + "w": 329, + "ch": 10, + "s": ["400"] + }, + "Playwrite AU VIC": { + "x": 343, + "y": 160, + "w": 234, + "ch": 10, + "s": ["100", "200", "300", "400"] + }, + "Playwrite AU VIC Guides": { + "x": 591, + "y": 160, + "w": 327, + "ch": 10, + "s": ["400"] + }, + "Playwrite BE VLG": { + "x": 932, + "y": 160, + "w": 266, + "ch": 10, + "s": ["100", "200", "300", "400"] + }, + "Playwrite BE VLG Guides": { + "x": 0, + "y": 200, + "w": 359, + "ch": 10, + "s": ["400"] + }, + "Playwrite BE WAL": { + "x": 373, + "y": 200, + "w": 319, + "ch": 10, + "s": ["100", "200", "300", "400"] + }, + "Playwrite BE WAL Guides": { + "x": 706, + "y": 200, + "w": 420, + "ch": 10, + "s": ["400"] + }, + "Playwrite BR": { + "x": 0, + "y": 240, + "w": 201, + "ch": 10, + "s": ["100", "200", "300", "400"] + }, + "Playwrite BR Guides": { + "x": 215, + "y": 240, + "w": 298, + "ch": 10, + "s": ["400"] + }, + "Playwrite CA": { + "x": 527, + "y": 240, + "w": 191, + "ch": 10, + "s": ["100", "200", "300", "400"] + }, + "Playwrite CA Guides": { + "x": 732, + "y": 240, + "w": 288, + "ch": 10, + "s": ["400"] + }, + "Playwrite CL": { + "x": 0, + "y": 280, + "w": 194, + "ch": 10, + "s": ["100", "200", "300", "400"] + }, + "Playwrite CL Guides": { + "x": 208, + "y": 280, + "w": 290, + "ch": 10, + "s": ["400"] + }, + "Playwrite CO": { + "x": 512, + "y": 280, + "w": 189, + "ch": 10, + "s": ["100", "200", "300", "400"] + }, + "Playwrite CO Guides": { + "x": 715, + "y": 280, + "w": 287, + "ch": 10, + "s": ["400"] + }, + "Playwrite CU": { + "x": 0, + "y": 320, + "w": 192, + "ch": 10, + "s": ["100", "200", "300", "400"] + }, + "Playwrite CU Guides": { + "x": 206, + "y": 320, + "w": 289, + "ch": 10, + "s": ["400"] + }, + "Playwrite CZ": { + "x": 509, + "y": 320, + "w": 192, + "ch": 10, + "s": ["100", "200", "300", "400"] + }, + "Playwrite CZ Guides": { + "x": 715, + "y": 320, + "w": 287, + "ch": 10, + "s": ["400"] + }, + "Playwrite DE Grund": { + "x": 0, + "y": 360, + "w": 251, + "ch": 10, + "s": ["100", "200", "300", "400"] + }, + "Playwrite DE Grund Guides": { + "x": 265, + "y": 360, + "w": 342, + "ch": 10, + "s": ["400"] + }, + "Playwrite DE LA": { + "x": 621, + "y": 360, + "w": 232, + "ch": 10, + "s": ["100", "200", "300", "400"] + }, + "Playwrite DE LA Guides": { + "x": 867, + "y": 360, + "w": 325, + "ch": 10, + "s": ["400"] + }, + "Playwrite DE SAS": { + "x": 0, + "y": 400, + "w": 241, + "ch": 10, + "s": ["100", "200", "300", "400"] + }, + "Playwrite DE SAS Guides": { + "x": 255, + "y": 400, + "w": 333, + "ch": 10, + "s": ["400"] + }, + "Playwrite DE VA": { + "x": 602, + "y": 400, + "w": 225, + "ch": 10, + "s": ["100", "200", "300", "400"] + }, + "Playwrite DE VA Guides": { + "x": 841, + "y": 400, + "w": 320, + "ch": 10, + "s": ["400"] + }, + "Playwrite DK Loopet": { + "x": 0, + "y": 440, + "w": 254, + "ch": 10, + "s": ["100", "200", "300", "400"] + }, + "Playwrite DK Loopet Guides": { + "x": 268, + "y": 440, + "w": 345, + "ch": 10, + "s": ["400"] + }, + "Playwrite DK Uloopet": { + "x": 627, + "y": 440, + "w": 262, + "ch": 10, + "s": ["100", "200", "300", "400"] + }, + "Playwrite DK Uloopet Guides": { + "x": 0, + "y": 480, + "w": 353, + "ch": 10, + "s": ["400"] + }, + "Playwrite ES": { + "x": 367, + "y": 480, + "w": 176, + "ch": 10, + "s": ["100", "200", "300", "400"] + }, + "Playwrite ES Deco": { + "x": 557, + "y": 480, + "w": 256, + "ch": 10, + "s": ["100", "200", "300", "400"] + }, + "Playwrite ES Deco Guides": { + "x": 827, + "y": 480, + "w": 350, + "ch": 10, + "s": ["400"] + }, + "Playwrite ES Guides": { + "x": 0, + "y": 520, + "w": 268, + "ch": 10, + "s": ["400"] + }, + "Playwrite FR Moderne": { + "x": 282, + "y": 520, + "w": 287, + "ch": 10, + "s": ["100", "200", "300", "400"] + }, + "Playwrite FR Moderne Guides": { + "x": 583, + "y": 520, + "w": 383, + "ch": 10, + "s": ["400"] + }, + "Playwrite FR Trad": { + "x": 0, + "y": 560, + "w": 281, + "ch": 10, + "s": ["100", "200", "300", "400"] + }, + "Playwrite FR Trad Guides": { + "x": 295, + "y": 560, + "w": 384, + "ch": 10, + "s": ["400"] + }, + "Playwrite GB J": { + "x": 693, + "y": 560, + "w": 191, + "ch": 10, + "s": ["100", "100i", "200", "200i", "300", "300i", "400", "400i"] + }, + "Playwrite GB J Guides": { + "x": 898, + "y": 560, + "w": 284, + "ch": 10, + "s": ["400", "400i"] + }, + "Playwrite GB S": { + "x": 0, + "y": 600, + "w": 190, + "ch": 10, + "s": ["100", "100i", "200", "200i", "300", "300i", "400", "400i"] + }, + "Playwrite GB S Guides": { + "x": 204, + "y": 600, + "w": 282, + "ch": 10, + "s": ["400", "400i"] + }, + "Playwrite HR": { + "x": 500, + "y": 600, + "w": 191, + "ch": 10, + "s": ["100", "200", "300", "400"] + }, + "Playwrite HR Guides": { + "x": 705, + "y": 600, + "w": 286, + "ch": 10, + "s": ["400"] + }, + "Playwrite HR Lijeva": { + "x": 0, + "y": 640, + "w": 276, + "ch": 10, + "s": ["100", "200", "300", "400"] + }, + "Playwrite HR Lijeva Guides": { + "x": 290, + "y": 640, + "w": 370, + "ch": 10, + "s": ["400"] + }, + "Playwrite HU": { + "x": 674, + "y": 640, + "w": 190, + "ch": 10, + "s": ["100", "200", "300", "400"] + }, + "Playwrite HU Guides": { + "x": 878, + "y": 640, + "w": 285, + "ch": 10, + "s": ["400"] + }, + "Playwrite ID": { + "x": 0, + "y": 680, + "w": 194, + "ch": 10, + "s": ["100", "200", "300", "400"] + }, + "Playwrite ID Guides": { + "x": 208, + "y": 680, + "w": 296, + "ch": 10, + "s": ["400"] + }, + "Playwrite IE": { + "x": 518, + "y": 680, + "w": 175, + "ch": 10, + "s": ["100", "200", "300", "400"] + }, + "Playwrite IE Guides": { + "x": 707, + "y": 680, + "w": 271, + "ch": 10, + "s": ["400"] + }, + "Playwrite IN": { + "x": 992, + "y": 680, + "w": 189, + "ch": 10, + "s": ["100", "200", "300", "400"] + }, + "Playwrite IN Guides": { + "x": 0, + "y": 720, + "w": 285, + "ch": 10, + "s": ["400"] + }, + "Playwrite IS": { + "x": 299, + "y": 720, + "w": 156, + "ch": 10, + "s": ["100", "200", "300", "400"] + }, + "Playwrite IS Guides": { + "x": 469, + "y": 720, + "w": 249, + "ch": 10, + "s": ["400"] + }, + "Playwrite IT Moderna": { + "x": 732, + "y": 720, + "w": 273, + "ch": 10, + "s": ["100", "200", "300", "400"] + }, + "Playwrite IT Moderna Guides": { + "x": 0, + "y": 760, + "w": 365, + "ch": 10, + "s": ["400"] + }, + "Playwrite IT Trad": { + "x": 379, + "y": 760, + "w": 243, + "ch": 10, + "s": ["100", "200", "300", "400"] + }, + "Playwrite IT Trad Guides": { + "x": 636, + "y": 760, + "w": 336, + "ch": 10, + "s": ["400"] + }, + "Playwrite MX": { + "x": 986, + "y": 760, + "w": 200, + "ch": 10, + "s": ["100", "200", "300", "400"] + }, + "Playwrite MX Guides": { "x": 0, "y": 0, "w": 297, "ch": 11, "s": ["400"] }, + "Playwrite NG Modern": { + "x": 311, + "y": 0, + "w": 279, + "ch": 11, + "s": ["100", "200", "300", "400"] + }, + "Playwrite NG Modern Guides": { + "x": 604, + "y": 0, + "w": 373, + "ch": 11, + "s": ["400"] + }, + "Playwrite NL": { + "x": 991, + "y": 0, + "w": 207, + "ch": 11, + "s": ["100", "200", "300", "400"] + }, + "Playwrite NL Guides": { + "x": 0, + "y": 40, + "w": 306, + "ch": 11, + "s": ["400"] + }, + "Playwrite NO": { + "x": 320, + "y": 40, + "w": 189, + "ch": 11, + "s": ["100", "200", "300", "400"] + }, + "Playwrite NO Guides": { + "x": 523, + "y": 40, + "w": 285, + "ch": 11, + "s": ["400"] + }, + "Playwrite NZ": { + "x": 822, + "y": 40, + "w": 164, + "ch": 11, + "s": ["100", "200", "300", "400"] + }, + "Playwrite NZ Basic": { + "x": 0, + "y": 80, + "w": 231, + "ch": 11, + "s": ["100", "200", "300", "400"] + }, + "Playwrite NZ Basic Guides": { + "x": 245, + "y": 80, + "w": 319, + "ch": 11, + "s": ["400"] + }, + "Playwrite NZ Guides": { + "x": 578, + "y": 80, + "w": 256, + "ch": 11, + "s": ["400"] + }, + "Playwrite PE": { + "x": 848, + "y": 80, + "w": 185, + "ch": 11, + "s": ["100", "200", "300", "400"] + }, + "Playwrite PE Guides": { + "x": 0, + "y": 120, + "w": 282, + "ch": 11, + "s": ["400"] + }, + "Playwrite PL": { + "x": 296, + "y": 120, + "w": 178, + "ch": 11, + "s": ["100", "200", "300", "400"] + }, + "Playwrite PL Guides": { + "x": 488, + "y": 120, + "w": 272, + "ch": 11, + "s": ["400"] + }, + "Playwrite PT": { + "x": 774, + "y": 120, + "w": 188, + "ch": 11, + "s": ["100", "200", "300", "400"] + }, + "Playwrite PT Guides": { + "x": 0, + "y": 160, + "w": 284, + "ch": 11, + "s": ["400"] + }, + "Playwrite RO": { + "x": 298, + "y": 160, + "w": 197, + "ch": 11, + "s": ["100", "200", "300", "400"] + }, + "Playwrite RO Guides": { + "x": 509, + "y": 160, + "w": 286, + "ch": 11, + "s": ["400"] + }, + "Playwrite SK": { + "x": 809, + "y": 160, + "w": 195, + "ch": 11, + "s": ["100", "200", "300", "400"] + }, + "Playwrite SK Guides": { + "x": 0, + "y": 200, + "w": 289, + "ch": 11, + "s": ["400"] + }, + "Playwrite TZ": { + "x": 303, + "y": 200, + "w": 182, + "ch": 11, + "s": ["100", "200", "300", "400"] + }, + "Playwrite TZ Guides": { + "x": 499, + "y": 200, + "w": 271, + "ch": 11, + "s": ["400"] + }, + "Playwrite US Modern": { + "x": 784, + "y": 200, + "w": 277, + "ch": 11, + "s": ["100", "200", "300", "400"] + }, + "Playwrite US Modern Guides": { + "x": 0, + "y": 240, + "w": 369, + "ch": 11, + "s": ["400"] + }, + "Playwrite US Trad": { + "x": 383, + "y": 240, + "w": 258, + "ch": 11, + "s": ["100", "200", "300", "400"] + }, + "Playwrite US Trad Guides": { + "x": 655, + "y": 240, + "w": 355, + "ch": 11, + "s": ["400"] + }, + "Playwrite VN": { + "x": 0, + "y": 280, + "w": 212, + "ch": 11, + "s": ["100", "200", "300", "400"] + }, + "Playwrite VN Guides": { + "x": 226, + "y": 280, + "w": 311, + "ch": 11, + "s": ["400"] + }, + "Playwrite ZA": { + "x": 551, + "y": 280, + "w": 182, + "ch": 11, + "s": ["100", "200", "300", "400"] + }, + "Playwrite ZA Guides": { + "x": 747, + "y": 280, + "w": 276, + "ch": 11, + "s": ["400"] + }, + "Plus Jakarta Sans": { + "x": 0, + "y": 320, + "w": 199, + "ch": 11, + "s": [ + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i" + ] + }, + "Pochaevsk": { "x": 213, "y": 320, "w": 111, "ch": 11, "s": ["400"] }, + "Podkova": { + "x": 338, + "y": 320, + "w": 97, + "ch": 11, + "s": ["400", "500", "600", "700", "800"] + }, + "Poetsen One": { "x": 449, "y": 320, "w": 148, "ch": 11, "s": ["400"] }, + "Poiret One": { "x": 611, "y": 320, "w": 114, "ch": 11, "s": ["400"] }, + "Poller One": { "x": 739, "y": 320, "w": 164, "ch": 11, "s": ["400"] }, + "Poltawski Nowy": { + "x": 917, + "y": 320, + "w": 182, + "ch": 11, + "s": ["400", "400i", "500", "500i", "600", "600i", "700", "700i"] + }, + "Poly": { "x": 1113, "y": 320, "w": 54, "ch": 11, "s": ["400", "400i"] }, + "Pompiere": { "x": 0, "y": 360, "w": 75, "ch": 11, "s": ["400"] }, + "Ponnala": { "x": 89, "y": 360, "w": 70, "ch": 11, "s": ["400"] }, + "Ponomar": { "x": 173, "y": 360, "w": 97, "ch": 11, "s": ["400"] }, + "Pontano Sans": { + "x": 284, + "y": 360, + "w": 149, + "ch": 11, + "s": ["300", "400", "500", "600", "700"] + }, + "Poor Story": { "x": 447, "y": 360, "w": 99, "ch": 11, "s": ["400"] }, + "Poppins": { + "x": 560, + "y": 360, + "w": 104, + "ch": 11, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Port Lligat Sans": { + "x": 678, + "y": 360, + "w": 157, + "ch": 11, + "s": ["400"] + }, + "Port Lligat Slab": { + "x": 849, + "y": 360, + "w": 153, + "ch": 11, + "s": ["400"] + }, + "Potta One": { "x": 1016, "y": 360, "w": 143, "ch": 11, "s": ["400"] }, + "Pragati Narrow": { + "x": 0, + "y": 400, + "w": 129, + "ch": 11, + "s": ["400", "700"] + }, + "Praise": { "x": 143, "y": 400, "w": 59, "ch": 11, "s": ["400"] }, + "Prata": { "x": 216, "y": 400, "w": 71, "ch": 11, "s": ["400"] }, + "Preahvihear": { "x": 301, "y": 400, "w": 161, "ch": 11, "s": ["400"] }, + "Press Start 2P": { "x": 476, "y": 400, "w": 344, "ch": 11, "s": ["400"] }, + "Pridi": { + "x": 834, + "y": 400, + "w": 60, + "ch": 11, + "s": ["200", "300", "400", "500", "600", "700"] + }, + "Princess Sofia": { "x": 908, "y": 400, "w": 131, "ch": 11, "s": ["400"] }, + "Prociono": { "x": 1053, "y": 400, "w": 101, "ch": 11, "s": ["400"] }, + "Prompt": { + "x": 0, + "y": 440, + "w": 92, + "ch": 11, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Prosto One": { "x": 106, "y": 440, "w": 159, "ch": 11, "s": ["400"] }, + "Protest Guerrilla": { + "x": 279, + "y": 440, + "w": 177, + "ch": 11, + "s": ["400"] + }, + "Protest Revolution": { + "x": 470, + "y": 440, + "w": 200, + "ch": 11, + "s": ["400"] + }, + "Protest Riot": { "x": 684, "y": 440, "w": 131, "ch": 11, "s": ["400"] }, + "Protest Strike": { "x": 829, "y": 440, "w": 151, "ch": 11, "s": ["400"] }, + "Proza Libre": { + "x": 994, + "y": 440, + "w": 137, + "ch": 11, + "s": [ + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i" + ] + }, + "PT Mono": { "x": 0, "y": 480, "w": 109, "ch": 11, "s": ["400"] }, + "PT Sans": { + "x": 123, + "y": 480, + "w": 88, + "ch": 11, + "s": ["400", "400i", "700", "700i"] + }, + "PT Sans Caption": { + "x": 225, + "y": 480, + "w": 197, + "ch": 11, + "s": ["400", "700"] + }, + "PT Sans Narrow": { + "x": 436, + "y": 480, + "w": 138, + "ch": 11, + "s": ["400", "700"] + }, + "PT Serif": { + "x": 588, + "y": 480, + "w": 93, + "ch": 11, + "s": ["400", "400i", "700", "700i"] + }, + "PT Serif Caption": { + "x": 695, + "y": 480, + "w": 205, + "ch": 11, + "s": ["400", "400i"] + }, + "Public Sans": { + "x": 914, + "y": 480, + "w": 139, + "ch": 11, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Puppies Play": { "x": 1067, "y": 480, "w": 85, "ch": 11, "s": ["400"] }, + "Puritan": { + "x": 0, + "y": 520, + "w": 78, + "ch": 11, + "s": ["400", "400i", "700", "700i"] + }, + "Purple Purse": { "x": 92, "y": 520, "w": 150, "ch": 11, "s": ["400"] }, + "Pushster": { "x": 256, "y": 520, "w": 89, "ch": 11, "s": ["400"] }, + "Qahiri": { "x": 359, "y": 520, "w": 51, "ch": 11, "s": ["400"] }, + "Quando": { "x": 424, "y": 520, "w": 107, "ch": 11, "s": ["400"] }, + "Quantico": { + "x": 545, + "y": 520, + "w": 109, + "ch": 11, + "s": ["400", "400i", "700", "700i"] + }, + "Quattrocento": { + "x": 668, + "y": 520, + "w": 153, + "ch": 11, + "s": ["400", "700"] + }, + "Quattrocento Sans": { + "x": 835, + "y": 520, + "w": 201, + "ch": 11, + "s": ["400", "400i", "700", "700i"] + }, + "Questrial": { "x": 1050, "y": 520, "w": 105, "ch": 11, "s": ["400"] }, + "Quicksand": { + "x": 0, + "y": 560, + "w": 125, + "ch": 11, + "s": ["300", "400", "500", "600", "700"] + }, + "Quintessential": { "x": 139, "y": 560, "w": 144, "ch": 11, "s": ["400"] }, + "Qwigley": { "x": 297, "y": 560, "w": 59, "ch": 11, "s": ["400"] }, + "Qwitcher Grypen": { + "x": 370, + "y": 560, + "w": 111, + "ch": 11, + "s": ["400", "700"] + }, + "Racing Sans One": { "x": 495, "y": 560, "w": 190, "ch": 11, "s": ["400"] }, + "Radio Canada": { + "x": 699, + "y": 560, + "w": 161, + "ch": 11, + "s": [ + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i" + ] + }, + "Radio Canada Big": { + "x": 874, + "y": 560, + "w": 196, + "ch": 11, + "s": ["400", "400i", "500", "500i", "600", "600i", "700", "700i"] + }, + "Radley": { "x": 1084, "y": 560, "w": 81, "ch": 11, "s": ["400", "400i"] }, + "Rajdhani": { + "x": 0, + "y": 600, + "w": 92, + "ch": 11, + "s": ["300", "400", "500", "600", "700"] + }, + "Rakkas": { "x": 106, "y": 600, "w": 79, "ch": 11, "s": ["400"] }, + "Raleway": { + "x": 199, + "y": 600, + "w": 102, + "ch": 11, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Raleway Dots": { "x": 315, "y": 600, "w": 156, "ch": 11, "s": ["400"] }, + "Ramabhadra": { "x": 485, "y": 600, "w": 154, "ch": 11, "s": ["400"] }, + "Ramaraja": { "x": 653, "y": 600, "w": 96, "ch": 11, "s": ["400"] }, + "Rambla": { + "x": 763, + "y": 600, + "w": 83, + "ch": 11, + "s": ["400", "400i", "700", "700i"] + }, + "Rammetto One": { "x": 860, "y": 600, "w": 234, "ch": 11, "s": ["400"] }, + "Rampart One": { "x": 0, "y": 640, "w": 170, "ch": 11, "s": ["400"] }, + "Ranchers": { "x": 184, "y": 640, "w": 97, "ch": 11, "s": ["400"] }, + "Rancho": { "x": 295, "y": 640, "w": 62, "ch": 11, "s": ["400"] }, + "Ranga": { "x": 371, "y": 640, "w": 52, "ch": 11, "s": ["400", "700"] }, + "Rasa": { + "x": 437, + "y": 640, + "w": 53, + "ch": 11, + "s": [ + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i" + ] + }, + "Rationale": { "x": 504, "y": 640, "w": 90, "ch": 11, "s": ["400"] }, + "Ravi Prakash": { "x": 608, "y": 640, "w": 132, "ch": 11, "s": ["400"] }, + "Readex Pro": { + "x": 754, + "y": 640, + "w": 140, + "ch": 11, + "s": ["200", "300", "400", "500", "600", "700"] + }, + "Recursive": { + "x": 908, + "y": 640, + "w": 123, + "ch": 11, + "s": ["300", "400", "500", "600", "700", "800", "900"] + }, + "Red Hat Display": { + "x": 0, + "y": 680, + "w": 175, + "ch": 11, + "s": [ + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Red Hat Mono": { + "x": 189, + "y": 680, + "w": 181, + "ch": 11, + "s": [ + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i" + ] + }, + "Red Hat Text": { + "x": 384, + "y": 680, + "w": 146, + "ch": 11, + "s": [ + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i" + ] + }, + "Red Rose": { + "x": 544, + "y": 680, + "w": 117, + "ch": 11, + "s": ["300", "400", "500", "600", "700"] + }, + "Redacted": { "x": 675, "y": 680, "w": 80, "ch": 11, "s": ["400"] }, + "Redacted Script": { + "x": 769, + "y": 680, + "w": 201, + "ch": 11, + "s": ["300", "400", "700"] + }, + "Reddit Mono": { + "x": 984, + "y": 680, + "w": 157, + "ch": 11, + "s": ["200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Reddit Sans": { + "x": 0, + "y": 720, + "w": 134, + "ch": 11, + "s": [ + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Reddit Sans Condensed": { + "x": 148, + "y": 720, + "w": 232, + "ch": 11, + "s": ["200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Redressed": { "x": 394, "y": 720, "w": 96, "ch": 11, "s": ["400"] }, + "Reem Kufi": { + "x": 504, + "y": 720, + "w": 118, + "ch": 11, + "s": ["400", "500", "600", "700"] + }, + "Reem Kufi Fun": { + "x": 636, + "y": 720, + "w": 161, + "ch": 11, + "s": ["400", "500", "600", "700"] + }, + "Reem Kufi Ink": { "x": 811, "y": 720, "w": 154, "ch": 11, "s": ["400"] }, + "Reenie Beanie": { "x": 979, "y": 720, "w": 122, "ch": 11, "s": ["400"] }, + "Reggae One": { "x": 0, "y": 760, "w": 161, "ch": 11, "s": ["400"] }, + "REM": { + "x": 175, + "y": 760, + "w": 63, + "ch": 11, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Rethink Sans": { + "x": 252, + "y": 760, + "w": 150, + "ch": 11, + "s": [ + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i" + ] + }, + "Revalia": { "x": 416, "y": 760, "w": 121, "ch": 11, "s": ["400"] }, + "Rhodium Libre": { "x": 551, "y": 760, "w": 187, "ch": 11, "s": ["400"] }, + "Ribeye": { "x": 752, "y": 760, "w": 92, "ch": 11, "s": ["400"] }, + "Ribeye Marrow": { "x": 858, "y": 760, "w": 201, "ch": 11, "s": ["400"] }, + "Righteous": { "x": 1073, "y": 760, "w": 121, "ch": 11, "s": ["400"] }, + "Risque": { "x": 0, "y": 0, "w": 77, "ch": 12, "s": ["400"] }, + "Road Rage": { "x": 91, "y": 0, "w": 72, "ch": 12, "s": ["400"] }, + "Roboto": { + "x": 177, + "y": 0, + "w": 85, + "ch": 12, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Roboto Condensed": { + "x": 276, + "y": 0, + "w": 188, + "ch": 12, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Roboto Flex": { "x": 478, "y": 0, "w": 134, "ch": 12, "s": ["400"] }, + "Roboto Mono": { + "x": 626, + "y": 0, + "w": 167, + "ch": 12, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i" + ] + }, + "Roboto Serif": { + "x": 807, + "y": 0, + "w": 162, + "ch": 12, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Roboto Slab": { + "x": 983, + "y": 0, + "w": 141, + "ch": 12, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Rochester": { "x": 0, "y": 40, "w": 85, "ch": 12, "s": ["400"] }, + "Rock 3D": { "x": 99, "y": 40, "w": 137, "ch": 12, "s": ["400"] }, + "Rock Salt": { "x": 250, "y": 40, "w": 153, "ch": 12, "s": ["400"] }, + "RocknRoll One": { "x": 417, "y": 40, "w": 190, "ch": 12, "s": ["400"] }, + "Rokkitt": { + "x": 621, + "y": 40, + "w": 79, + "ch": 12, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Romanesco": { "x": 714, "y": 40, "w": 73, "ch": 12, "s": ["400"] }, + "Ropa Sans": { + "x": 801, + "y": 40, + "w": 102, + "ch": 12, + "s": ["400", "400i"] + }, + "Rosario": { + "x": 917, + "y": 40, + "w": 84, + "ch": 12, + "s": [ + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i" + ] + }, + "Rosarivo": { + "x": 1015, + "y": 40, + "w": 106, + "ch": 12, + "s": ["400", "400i"] + }, + "Rouge Script": { "x": 0, "y": 80, "w": 104, "ch": 12, "s": ["400"] }, + "Rowdies": { + "x": 118, + "y": 80, + "w": 106, + "ch": 12, + "s": ["300", "400", "700"] + }, + "Rozha One": { "x": 238, "y": 80, "w": 126, "ch": 12, "s": ["400"] }, + "Rubik": { + "x": 378, + "y": 80, + "w": 71, + "ch": 12, + "s": [ + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Rubik 80s Fade": { "x": 463, "y": 80, "w": 199, "ch": 12, "s": ["400"] }, + "Rubik Beastly": { "x": 676, "y": 80, "w": 183, "ch": 12, "s": ["400"] }, + "Rubik Broken Fax": { "x": 873, "y": 80, "w": 227, "ch": 12, "s": ["400"] }, + "Rubik Bubbles": { "x": 0, "y": 120, "w": 188, "ch": 12, "s": ["400"] }, + "Rubik Burned": { "x": 202, "y": 120, "w": 179, "ch": 12, "s": ["400"] }, + "Rubik Dirt": { "x": 395, "y": 120, "w": 137, "ch": 12, "s": ["400"] }, + "Rubik Distressed": { + "x": 546, + "y": 120, + "w": 225, + "ch": 12, + "s": ["400"] + }, + "Rubik Doodle Shadow": { + "x": 785, + "y": 120, + "w": 277, + "ch": 12, + "s": ["400"] + }, + "Rubik Doodle Triangles": { + "x": 0, + "y": 160, + "w": 296, + "ch": 12, + "s": ["400"] + }, + "Rubik Gemstones": { "x": 310, "y": 160, "w": 229, "ch": 12, "s": ["400"] }, + "Rubik Glitch": { "x": 553, "y": 160, "w": 165, "ch": 12, "s": ["400"] }, + "Rubik Glitch Pop": { + "x": 732, + "y": 160, + "w": 217, + "ch": 12, + "s": ["400"] + }, + "Rubik Iso": { "x": 963, "y": 160, "w": 124, "ch": 12, "s": ["400"] }, + "Rubik Lines": { "x": 0, "y": 200, "w": 155, "ch": 12, "s": ["400"] }, + "Rubik Maps": { "x": 169, "y": 200, "w": 151, "ch": 12, "s": ["400"] }, + "Rubik Marker Hatch": { + "x": 334, + "y": 200, + "w": 257, + "ch": 12, + "s": ["400"] + }, + "Rubik Maze": { "x": 605, "y": 200, "w": 150, "ch": 12, "s": ["400"] }, + "Rubik Microbe": { "x": 769, "y": 200, "w": 188, "ch": 12, "s": ["400"] }, + "Rubik Mono One": { "x": 0, "y": 240, "w": 294, "ch": 12, "s": ["400"] }, + "Rubik Moonrocks": { "x": 308, "y": 240, "w": 225, "ch": 12, "s": ["400"] }, + "Rubik Pixels": { "x": 547, "y": 240, "w": 162, "ch": 12, "s": ["400"] }, + "Rubik Puddles": { "x": 723, "y": 240, "w": 187, "ch": 12, "s": ["400"] }, + "Rubik Scribble": { "x": 924, "y": 240, "w": 192, "ch": 12, "s": ["400"] }, + "Rubik Spray Paint": { "x": 0, "y": 280, "w": 233, "ch": 12, "s": ["400"] }, + "Rubik Storm": { "x": 247, "y": 280, "w": 165, "ch": 12, "s": ["400"] }, + "Rubik Vinyl": { "x": 426, "y": 280, "w": 150, "ch": 12, "s": ["400"] }, + "Rubik Wet Paint": { "x": 590, "y": 280, "w": 207, "ch": 12, "s": ["400"] }, + "Ruda": { + "x": 811, + "y": 280, + "w": 66, + "ch": 12, + "s": ["400", "500", "600", "700", "800", "900"] + }, + "Rufina": { "x": 891, "y": 280, "w": 82, "ch": 12, "s": ["400", "700"] }, + "Ruge Boogie": { "x": 987, "y": 280, "w": 99, "ch": 12, "s": ["400"] }, + "Ruluko": { "x": 1100, "y": 280, "w": 78, "ch": 12, "s": ["400"] }, + "Rum Raisin": { "x": 0, "y": 320, "w": 100, "ch": 12, "s": ["400"] }, + "Ruslan Display": { "x": 114, "y": 320, "w": 220, "ch": 12, "s": ["400"] }, + "Russo One": { "x": 348, "y": 320, "w": 138, "ch": 12, "s": ["400"] }, + "Ruthie": { "x": 500, "y": 320, "w": 64, "ch": 12, "s": ["400"] }, + "Ruwudu": { + "x": 578, + "y": 320, + "w": 94, + "ch": 12, + "s": ["400", "500", "600", "700"] + }, + "Rye": { "x": 686, "y": 320, "w": 55, "ch": 12, "s": ["400"] }, + "Sacramento": { "x": 755, "y": 320, "w": 104, "ch": 12, "s": ["400"] }, + "Sahitya": { "x": 873, "y": 320, "w": 81, "ch": 12, "s": ["400", "700"] }, + "Sail": { "x": 968, "y": 320, "w": 50, "ch": 12, "s": ["400"] }, + "Saira": { + "x": 1032, + "y": 320, + "w": 66, + "ch": 12, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Saira Condensed": { + "x": 0, + "y": 360, + "w": 149, + "ch": 12, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Saira Extra Condensed": { + "x": 163, + "y": 360, + "w": 167, + "ch": 12, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Saira Semi Condensed": { + "x": 344, + "y": 360, + "w": 227, + "ch": 12, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Saira Stencil One": { + "x": 585, + "y": 360, + "w": 201, + "ch": 12, + "s": ["400"] + }, + "Salsa": { "x": 800, "y": 360, "w": 66, "ch": 12, "s": ["400"] }, + "Sanchez": { "x": 880, "y": 360, "w": 105, "ch": 12, "s": ["400", "400i"] }, + "Sancreek": { "x": 999, "y": 360, "w": 111, "ch": 12, "s": ["400"] }, + "Sankofa Display": { "x": 0, "y": 400, "w": 163, "ch": 12, "s": ["400"] }, + "Sansation": { + "x": 177, + "y": 400, + "w": 119, + "ch": 12, + "s": ["300", "300i", "400", "400i", "700", "700i"] + }, + "Sansita": { + "x": 310, + "y": 400, + "w": 80, + "ch": 12, + "s": ["400", "400i", "700", "700i", "800", "800i", "900", "900i"] + }, + "Sansita Swashed": { + "x": 404, + "y": 400, + "w": 177, + "ch": 12, + "s": ["300", "400", "500", "600", "700", "800", "900"] + }, + "Sarabun": { + "x": 595, + "y": 400, + "w": 94, + "ch": 12, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i" + ] + }, + "Sarala": { "x": 703, "y": 400, "w": 74, "ch": 12, "s": ["400", "700"] }, + "Sarina": { "x": 791, "y": 400, "w": 112, "ch": 12, "s": ["400"] }, + "Sarpanch": { + "x": 917, + "y": 400, + "w": 122, + "ch": 12, + "s": ["400", "500", "600", "700", "800", "900"] + }, + "Sassy Frass": { "x": 1053, "y": 400, "w": 78, "ch": 12, "s": ["400"] }, + "Satisfy": { "x": 0, "y": 440, "w": 70, "ch": 12, "s": ["400"] }, + "Savate": { + "x": 84, + "y": 440, + "w": 81, + "ch": 12, + "s": [ + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Sawarabi Gothic": { "x": 179, "y": 440, "w": 186, "ch": 12, "s": ["400"] }, + "Sawarabi Mincho": { "x": 379, "y": 440, "w": 204, "ch": 12, "s": ["400"] }, + "Scada": { + "x": 597, + "y": 440, + "w": 69, + "ch": 12, + "s": ["400", "400i", "700", "700i"] + }, + "Scheherazade New": { + "x": 680, + "y": 440, + "w": 175, + "ch": 12, + "s": ["400", "500", "600", "700"] + }, + "Schibsted Grotesk": { + "x": 869, + "y": 440, + "w": 212, + "ch": 12, + "s": [ + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Schoolbell": { "x": 1095, "y": 440, "w": 102, "ch": 12, "s": ["400"] }, + "Science Gothic": { + "x": 0, + "y": 480, + "w": 212, + "ch": 12, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Scope One": { "x": 226, "y": 480, "w": 120, "ch": 12, "s": ["400"] }, + "Seaweed Script": { "x": 360, "y": 480, "w": 137, "ch": 12, "s": ["400"] }, + "Secular One": { "x": 511, "y": 480, "w": 144, "ch": 12, "s": ["400"] }, + "Sedan": { "x": 669, "y": 480, "w": 72, "ch": 12, "s": ["400", "400i"] }, + "Sedan SC": { "x": 755, "y": 480, "w": 111, "ch": 12, "s": ["400"] }, + "Sedgwick Ave": { "x": 880, "y": 480, "w": 140, "ch": 12, "s": ["400"] }, + "Sedgwick Ave Display": { + "x": 0, + "y": 520, + "w": 224, + "ch": 12, + "s": ["400"] + }, + "Sekuya": { "x": 238, "y": 520, "w": 138, "ch": 12, "s": ["400"] }, + "Sen": { + "x": 390, + "y": 520, + "w": 48, + "ch": 12, + "s": ["400", "500", "600", "700", "800"] + }, + "Send Flowers": { "x": 452, "y": 520, "w": 129, "ch": 12, "s": ["400"] }, + "Sevillana": { "x": 595, "y": 520, "w": 96, "ch": 12, "s": ["400"] }, + "Seymour One": { "x": 705, "y": 520, "w": 241, "ch": 12, "s": ["400"] }, + "Shadows Into Light": { + "x": 960, + "y": 520, + "w": 172, + "ch": 12, + "s": ["400"] + }, + "Shadows Into Light Two": { + "x": 0, + "y": 560, + "w": 234, + "ch": 12, + "s": ["400"] + }, + "Shafarik": { "x": 248, "y": 560, "w": 89, "ch": 12, "s": ["400"] }, + "Shalimar": { "x": 351, "y": 560, "w": 59, "ch": 12, "s": ["400"] }, + "Shantell Sans": { + "x": 424, + "y": 560, + "w": 166, + "ch": 12, + "s": [ + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i" + ] + }, + "Shanti": { "x": 604, "y": 560, "w": 75, "ch": 12, "s": ["400"] }, + "Share": { + "x": 693, + "y": 560, + "w": 62, + "ch": 12, + "s": ["400", "400i", "700", "700i"] + }, + "Share Tech": { "x": 769, "y": 560, "w": 109, "ch": 12, "s": ["400"] }, + "Share Tech Mono": { "x": 892, "y": 560, "w": 203, "ch": 12, "s": ["400"] }, + "Shippori Antique": { "x": 0, "y": 600, "w": 206, "ch": 12, "s": ["400"] }, + "Shippori Antique B1": { + "x": 220, + "y": 600, + "w": 241, + "ch": 12, + "s": ["400"] + }, + "Shippori Mincho": { + "x": 475, + "y": 600, + "w": 202, + "ch": 12, + "s": ["400", "500", "600", "700", "800"] + }, + "Shippori Mincho B1": { + "x": 691, + "y": 600, + "w": 237, + "ch": 12, + "s": ["400", "500", "600", "700", "800"] + }, + "Shizuru": { "x": 942, "y": 600, "w": 82, "ch": 12, "s": ["400"] }, + "Shojumaru": { "x": 0, "y": 640, "w": 184, "ch": 12, "s": ["400"] }, + "Short Stack": { "x": 198, "y": 640, "w": 168, "ch": 12, "s": ["400"] }, + "Shrikhand": { "x": 380, "y": 640, "w": 147, "ch": 12, "s": ["400"] }, + "Sigmar": { "x": 541, "y": 640, "w": 99, "ch": 12, "s": ["400"] }, + "Sigmar One": { "x": 654, "y": 640, "w": 172, "ch": 12, "s": ["400"] }, + "Signika": { + "x": 840, + "y": 640, + "w": 85, + "ch": 12, + "s": ["300", "400", "500", "600", "700"] + }, + "Signika Negative": { + "x": 939, + "y": 640, + "w": 180, + "ch": 12, + "s": ["300", "400", "500", "600", "700"] + }, + "Silkscreen": { "x": 0, "y": 680, "w": 173, "ch": 12, "s": ["400", "700"] }, + "Simonetta": { + "x": 187, + "y": 680, + "w": 108, + "ch": 12, + "s": ["400", "400i", "900", "900i"] + }, + "Sintony": { "x": 309, "y": 680, "w": 97, "ch": 12, "s": ["400", "700"] }, + "Sirin Stencil": { "x": 420, "y": 680, "w": 122, "ch": 12, "s": ["400"] }, + "Sirivennela": { "x": 556, "y": 680, "w": 98, "ch": 12, "s": ["400"] }, + "Six Caps": { "x": 668, "y": 680, "w": 47, "ch": 12, "s": ["400"] }, + "Sixtyfour": { "x": 729, "y": 680, "w": 224, "ch": 12, "s": ["400"] }, + "Sixtyfour Convergence": { + "x": 0, + "y": 720, + "w": 512, + "ch": 12, + "s": ["400"] + }, + "Skranji": { "x": 526, "y": 720, "w": 78, "ch": 12, "s": ["400", "700"] }, + "Slabo 13px": { "x": 618, "y": 720, "w": 127, "ch": 12, "s": ["400"] }, + "Slabo 27px": { "x": 759, "y": 720, "w": 111, "ch": 12, "s": ["400"] }, + "Slackey": { "x": 884, "y": 720, "w": 124, "ch": 12, "s": ["400"] }, + "Slackside One": { "x": 1022, "y": 720, "w": 137, "ch": 12, "s": ["400"] }, + "Smokum": { "x": 0, "y": 760, "w": 76, "ch": 12, "s": ["400"] }, + "Smooch": { "x": 90, "y": 760, "w": 73, "ch": 12, "s": ["400"] }, + "Smooch Sans": { + "x": 177, + "y": 760, + "w": 110, + "ch": 12, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Smythe": { "x": 301, "y": 760, "w": 64, "ch": 12, "s": ["400"] }, + "SN Pro": { + "x": 379, + "y": 760, + "w": 84, + "ch": 12, + "s": [ + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Sniglet": { "x": 477, "y": 760, "w": 82, "ch": 12, "s": ["400", "800"] }, + "Snippet": { "x": 573, "y": 760, "w": 89, "ch": 12, "s": ["400"] }, + "Snowburst One": { "x": 676, "y": 760, "w": 197, "ch": 12, "s": ["400"] }, + "Sofadi One": { "x": 887, "y": 760, "w": 137, "ch": 12, "s": ["400"] }, + "Sofia": { "x": 1038, "y": 760, "w": 61, "ch": 12, "s": ["400"] }, + "Sofia Sans": { + "x": 0, + "y": 0, + "w": 115, + "ch": 13, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Sofia Sans Condensed": { + "x": 129, + "y": 0, + "w": 184, + "ch": 13, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Sofia Sans Extra Condensed": { + "x": 327, + "y": 0, + "w": 188, + "ch": 13, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Sofia Sans Semi Condensed": { + "x": 529, + "y": 0, + "w": 280, + "ch": 13, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Solitreo": { "x": 823, "y": 0, "w": 83, "ch": 13, "s": ["400"] }, + "Solway": { + "x": 920, + "y": 0, + "w": 90, + "ch": 13, + "s": ["300", "400", "500", "700", "800"] + }, + "Sometype Mono": { + "x": 0, + "y": 40, + "w": 189, + "ch": 13, + "s": ["400", "400i", "500", "500i", "600", "600i", "700", "700i"] + }, + "Sono": { + "x": 203, + "y": 40, + "w": 68, + "ch": 13, + "s": ["200", "300", "400", "500", "600", "700", "800"] + }, + "Sonsie One": { "x": 285, "y": 40, "w": 194, "ch": 13, "s": ["400"] }, + "Sora": { + "x": 493, + "y": 40, + "w": 65, + "ch": 13, + "s": ["100", "200", "300", "400", "500", "600", "700", "800"] + }, + "Sorts Mill Goudy": { + "x": 572, + "y": 40, + "w": 187, + "ch": 13, + "s": ["400", "400i"] + }, + "Sour Gummy": { + "x": 773, + "y": 40, + "w": 149, + "ch": 13, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Source Code Pro": { + "x": 936, + "y": 40, + "w": 224, + "ch": 13, + "s": [ + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Source Sans 3": { + "x": 0, + "y": 80, + "w": 147, + "ch": 13, + "s": [ + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Source Sans Pro": { + "x": 161, + "y": 80, + "w": 170, + "ch": 13, + "s": [ + "200", + "200i", + "300", + "300i", + "400", + "400i", + "600", + "600i", + "700", + "700i", + "900", + "900i" + ] + }, + "Source Serif 4": { + "x": 345, + "y": 80, + "w": 164, + "ch": 13, + "s": [ + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Source Serif Pro": { + "x": 523, + "y": 80, + "w": 180, + "ch": 13, + "s": [ + "200", + "200i", + "300", + "300i", + "400", + "400i", + "600", + "600i", + "700", + "700i", + "900", + "900i" + ] + }, + "Space Grotesk": { + "x": 717, + "y": 80, + "w": 176, + "ch": 13, + "s": ["300", "400", "500", "600", "700"] + }, + "Space Mono": { + "x": 907, + "y": 80, + "w": 155, + "ch": 13, + "s": ["400", "400i", "700", "700i"] + }, + "Special Elite": { "x": 0, "y": 120, "w": 177, "ch": 13, "s": ["400"] }, + "Special Gothic": { + "x": 191, + "y": 120, + "w": 156, + "ch": 13, + "s": ["400", "500", "600", "700"] + }, + "Special Gothic Condensed One": { + "x": 361, + "y": 120, + "w": 247, + "ch": 13, + "s": ["400"] + }, + "Special Gothic Expanded One": { + "x": 622, + "y": 120, + "w": 424, + "ch": 13, + "s": ["400"] + }, + "Spectral": { + "x": 1060, + "y": 120, + "w": 95, + "ch": 13, + "s": [ + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i" + ] + }, + "Spectral SC": { + "x": 0, + "y": 160, + "w": 159, + "ch": 13, + "s": [ + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i" + ] + }, + "Spicy Rice": { "x": 173, "y": 160, "w": 111, "ch": 13, "s": ["400"] }, + "Spinnaker": { "x": 298, "y": 160, "w": 128, "ch": 13, "s": ["400"] }, + "Spirax": { "x": 440, "y": 160, "w": 73, "ch": 13, "s": ["400"] }, + "Splash": { "x": 527, "y": 160, "w": 88, "ch": 13, "s": ["400"] }, + "Spline Sans": { + "x": 629, + "y": 160, + "w": 133, + "ch": 13, + "s": ["300", "400", "500", "600", "700"] + }, + "Spline Sans Mono": { + "x": 776, + "y": 160, + "w": 239, + "ch": 13, + "s": [ + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i" + ] + }, + "Squada One": { "x": 1029, "y": 160, "w": 111, "ch": 13, "s": ["400"] }, + "Square Peg": { "x": 0, "y": 200, "w": 73, "ch": 13, "s": ["400"] }, + "Sree Krushnadevaraya": { + "x": 87, + "y": 200, + "w": 222, + "ch": 13, + "s": ["400"] + }, + "Sriracha": { "x": 323, "y": 200, "w": 99, "ch": 13, "s": ["400"] }, + "Srisakdi": { "x": 436, "y": 200, "w": 90, "ch": 13, "s": ["400", "700"] }, + "Staatliches": { "x": 540, "y": 200, "w": 114, "ch": 13, "s": ["400"] }, + "Stack Sans Headline": { + "x": 668, + "y": 200, + "w": 235, + "ch": 13, + "s": ["200", "300", "400", "500", "600", "700"] + }, + "Stack Sans Notch": { + "x": 917, + "y": 200, + "w": 205, + "ch": 13, + "s": ["200", "300", "400", "500", "600", "700"] + }, + "Stack Sans Text": { + "x": 0, + "y": 240, + "w": 195, + "ch": 13, + "s": ["200", "300", "400", "500", "600", "700"] + }, + "Stalemate": { "x": 209, "y": 240, "w": 62, "ch": 13, "s": ["400"] }, + "Stalinist One": { "x": 285, "y": 240, "w": 280, "ch": 13, "s": ["400"] }, + "Stardos Stencil": { + "x": 579, + "y": 240, + "w": 168, + "ch": 13, + "s": ["400", "700"] + }, + "Stick": { "x": 761, "y": 240, "w": 65, "ch": 13, "s": ["400"] }, + "Stick No Bills": { + "x": 840, + "y": 240, + "w": 123, + "ch": 13, + "s": ["200", "300", "400", "500", "600", "700", "800"] + }, + "Stint Ultra Condensed": { + "x": 977, + "y": 240, + "w": 139, + "ch": 13, + "s": ["400"] + }, + "Stint Ultra Expanded": { + "x": 0, + "y": 280, + "w": 310, + "ch": 13, + "s": ["400"] + }, + "STIX Two Text": { + "x": 324, + "y": 280, + "w": 156, + "ch": 13, + "s": ["400", "400i", "500", "500i", "600", "600i", "700", "700i"] + }, + "Stoke": { "x": 494, "y": 280, "w": 84, "ch": 13, "s": ["300", "400"] }, + "Story Script": { "x": 592, "y": 280, "w": 113, "ch": 13, "s": ["400"] }, + "Strait": { "x": 719, "y": 280, "w": 62, "ch": 13, "s": ["400"] }, + "Style Script": { "x": 795, "y": 280, "w": 97, "ch": 13, "s": ["400"] }, + "Sue Ellen Francisco": { + "x": 906, + "y": 280, + "w": 137, + "ch": 13, + "s": ["400"] + }, + "Suez One": { "x": 1057, "y": 280, "w": 113, "ch": 13, "s": ["400"] }, + "Sulphur Point": { + "x": 0, + "y": 320, + "w": 144, + "ch": 13, + "s": ["300", "400", "700"] + }, + "Sumana": { "x": 158, "y": 320, "w": 92, "ch": 13, "s": ["400", "700"] }, + "Sunshiney": { "x": 264, "y": 320, "w": 94, "ch": 13, "s": ["400"] }, + "Supermercado One": { + "x": 372, + "y": 320, + "w": 196, + "ch": 13, + "s": ["400"] + }, + "Sura": { "x": 582, "y": 320, "w": 58, "ch": 13, "s": ["400", "700"] }, + "Suranna": { "x": 654, "y": 320, "w": 86, "ch": 13, "s": ["400"] }, + "Suravaram": { "x": 754, "y": 320, "w": 104, "ch": 13, "s": ["400"] }, + "SUSE": { + "x": 872, + "y": 320, + "w": 65, + "ch": 13, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "SUSE Mono": { + "x": 951, + "y": 320, + "w": 138, + "ch": 13, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i" + ] + }, + "Suwannaphum": { + "x": 0, + "y": 360, + "w": 184, + "ch": 13, + "s": ["100", "300", "400", "700", "900"] + }, + "Swanky and Moo Moo": { + "x": 198, + "y": 360, + "w": 203, + "ch": 13, + "s": ["400"] + }, + "Syncopate": { + "x": 415, + "y": 360, + "w": 195, + "ch": 13, + "s": ["400", "700"] + }, + "Syne": { + "x": 624, + "y": 360, + "w": 60, + "ch": 13, + "s": ["400", "500", "600", "700", "800"] + }, + "Syne Mono": { "x": 698, "y": 360, "w": 127, "ch": 13, "s": ["400"] }, + "Syne Tactile": { "x": 839, "y": 360, "w": 119, "ch": 13, "s": ["400"] }, + "Tac One": { "x": 972, "y": 360, "w": 73, "ch": 13, "s": ["400"] }, + "Tagesschrift": { "x": 0, "y": 400, "w": 142, "ch": 13, "s": ["400"] }, + "Tai Heritage Pro": { + "x": 156, + "y": 400, + "w": 171, + "ch": 13, + "s": ["400", "700"] + }, + "Tajawal": { + "x": 341, + "y": 400, + "w": 82, + "ch": 13, + "s": ["200", "300", "400", "500", "700", "800", "900"] + }, + "Tangerine": { "x": 437, "y": 400, "w": 67, "ch": 13, "s": ["400", "700"] }, + "Tapestry": { "x": 518, "y": 400, "w": 100, "ch": 13, "s": ["400"] }, + "Taprom": { "x": 632, "y": 400, "w": 70, "ch": 13, "s": ["400"] }, + "TASA Explorer": { + "x": 716, + "y": 400, + "w": 161, + "ch": 13, + "s": ["400", "500", "600", "700", "800"] + }, + "TASA Orbiter": { + "x": 891, + "y": 400, + "w": 153, + "ch": 13, + "s": ["400", "500", "600", "700", "800"] + }, + "Tauri": { "x": 1058, "y": 400, "w": 66, "ch": 13, "s": ["400"] }, + "Taviraj": { + "x": 0, + "y": 440, + "w": 84, + "ch": 13, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Teachers": { + "x": 98, + "y": 440, + "w": 100, + "ch": 13, + "s": [ + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i" + ] + }, + "Teko": { + "x": 212, + "y": 440, + "w": 42, + "ch": 13, + "s": ["300", "400", "500", "600", "700"] + }, + "Tektur": { + "x": 268, + "y": 440, + "w": 86, + "ch": 13, + "s": ["400", "500", "600", "700", "800", "900"] + }, + "Telex": { "x": 368, "y": 440, "w": 64, "ch": 13, "s": ["400"] }, + "Tenali Ramakrishna": { + "x": 446, + "y": 440, + "w": 170, + "ch": 13, + "s": ["400"] + }, + "Tenor Sans": { "x": 630, "y": 440, "w": 134, "ch": 13, "s": ["400"] }, + "Text Me One": { "x": 778, "y": 440, "w": 140, "ch": 13, "s": ["400"] }, + "Texturina": { + "x": 932, + "y": 440, + "w": 113, + "ch": 13, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Thasadith": { + "x": 1059, + "y": 440, + "w": 101, + "ch": 13, + "s": ["400", "400i", "700", "700i"] + }, + "The Girl Next Door": { + "x": 0, + "y": 480, + "w": 215, + "ch": 13, + "s": ["400"] + }, + "The Nautigal": { + "x": 229, + "y": 480, + "w": 102, + "ch": 13, + "s": ["400", "700"] + }, + "Tienne": { + "x": 345, + "y": 480, + "w": 92, + "ch": 13, + "s": ["400", "700", "900"] + }, + "TikTok Sans": { + "x": 451, + "y": 480, + "w": 141, + "ch": 13, + "s": ["300", "400", "500", "600", "700", "800", "900"] + }, + "Tillana": { + "x": 606, + "y": 480, + "w": 75, + "ch": 13, + "s": ["400", "500", "600", "700", "800"] + }, + "Tilt Neon": { "x": 695, "y": 480, "w": 101, "ch": 13, "s": ["400"] }, + "Tilt Prism": { "x": 810, "y": 480, "w": 121, "ch": 13, "s": ["400"] }, + "Tilt Warp": { "x": 945, "y": 480, "w": 109, "ch": 13, "s": ["400"] }, + "Timmana": { "x": 1068, "y": 480, "w": 90, "ch": 13, "s": ["400"] }, + "Tinos": { + "x": 0, + "y": 520, + "w": 62, + "ch": 13, + "s": ["400", "400i", "700", "700i"] + }, + "Tiny5": { "x": 76, "y": 520, "w": 62, "ch": 13, "s": ["400"] }, + "Tiro Bangla": { + "x": 152, + "y": 520, + "w": 134, + "ch": 13, + "s": ["400", "400i"] + }, + "Tiro Devanagari Hindi": { + "x": 300, + "y": 520, + "w": 251, + "ch": 13, + "s": ["400", "400i"] + }, + "Tiro Devanagari Marathi": { + "x": 565, + "y": 520, + "w": 274, + "ch": 13, + "s": ["400", "400i"] + }, + "Tiro Devanagari Sanskrit": { + "x": 853, + "y": 520, + "w": 276, + "ch": 13, + "s": ["400", "400i"] + }, + "Tiro Gurmukhi": { + "x": 0, + "y": 560, + "w": 173, + "ch": 13, + "s": ["400", "400i"] + }, + "Tiro Kannada": { + "x": 187, + "y": 560, + "w": 156, + "ch": 13, + "s": ["400", "400i"] + }, + "Tiro Tamil": { + "x": 357, + "y": 560, + "w": 122, + "ch": 13, + "s": ["400", "400i"] + }, + "Tiro Telugu": { + "x": 493, + "y": 560, + "w": 134, + "ch": 13, + "s": ["400", "400i"] + }, + "Tirra": { + "x": 641, + "y": 560, + "w": 57, + "ch": 13, + "s": ["400", "500", "600", "700", "800", "900"] + }, + "Titan One": { "x": 712, "y": 560, "w": 131, "ch": 13, "s": ["400"] }, + "Titillium Web": { + "x": 857, + "y": 560, + "w": 140, + "ch": 13, + "s": [ + "200", + "200i", + "300", + "300i", + "400", + "400i", + "600", + "600i", + "700", + "700i", + "900", + "900i" + ] + }, + "Tomorrow": { + "x": 1011, + "y": 560, + "w": 126, + "ch": 13, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Tourney": { + "x": 0, + "y": 600, + "w": 107, + "ch": 13, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Trade Winds": { "x": 121, "y": 600, "w": 160, "ch": 13, "s": ["400"] }, + "Train One": { "x": 295, "y": 600, "w": 132, "ch": 13, "s": ["400"] }, + "Triodion": { "x": 441, "y": 600, "w": 94, "ch": 13, "s": ["400"] }, + "Trirong": { + "x": 549, + "y": 600, + "w": 92, + "ch": 13, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Trispace": { + "x": 655, + "y": 600, + "w": 128, + "ch": 13, + "s": ["100", "200", "300", "400", "500", "600", "700", "800"] + }, + "Trocchi": { "x": 797, "y": 600, "w": 100, "ch": 13, "s": ["400"] }, + "Trochut": { + "x": 911, + "y": 600, + "w": 75, + "ch": 13, + "s": ["400", "400i", "700", "700i"] + }, + "Truculenta": { + "x": 1000, + "y": 600, + "w": 100, + "ch": 13, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Trykker": { "x": 0, "y": 640, "w": 98, "ch": 13, "s": ["400"] }, + "Tsukimi Rounded": { + "x": 112, + "y": 640, + "w": 209, + "ch": 13, + "s": ["300", "400", "500", "600", "700"] + }, + "Tuffy": { + "x": 335, + "y": 640, + "w": 63, + "ch": 13, + "s": ["400", "400i", "700", "700i"] + }, + "Tulpen One": { "x": 412, "y": 640, "w": 59, "ch": 13, "s": ["400"] }, + "Turret Road": { + "x": 485, + "y": 640, + "w": 142, + "ch": 13, + "s": ["200", "300", "400", "500", "700", "800"] + }, + "Twinkle Star": { "x": 641, "y": 640, "w": 137, "ch": 13, "s": ["400"] }, + "Ubuntu": { + "x": 792, + "y": 640, + "w": 90, + "ch": 13, + "s": ["300", "300i", "400", "400i", "500", "500i", "700", "700i"] + }, + "Ubuntu Condensed": { + "x": 896, + "y": 640, + "w": 177, + "ch": 13, + "s": ["400"] + }, + "Ubuntu Mono": { + "x": 0, + "y": 680, + "w": 156, + "ch": 13, + "s": ["400", "400i", "700", "700i"] + }, + "Ubuntu Sans": { + "x": 170, + "y": 680, + "w": 144, + "ch": 13, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i" + ] + }, + "Ubuntu Sans Mono": { + "x": 328, + "y": 680, + "w": 224, + "ch": 13, + "s": ["400", "400i", "500", "500i", "600", "600i", "700", "700i"] + }, + "Uchen": { "x": 566, "y": 680, "w": 75, "ch": 13, "s": ["400"] }, + "Ultra": { "x": 655, "y": 680, "w": 85, "ch": 13, "s": ["400"] }, + "Unbounded": { + "x": 754, + "y": 680, + "w": 174, + "ch": 13, + "s": ["200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Uncial Antiqua": { "x": 942, "y": 680, "w": 211, "ch": 13, "s": ["400"] }, + "Underdog": { "x": 0, "y": 720, "w": 113, "ch": 13, "s": ["400"] }, + "Unica One": { "x": 127, "y": 720, "w": 106, "ch": 13, "s": ["400"] }, + "UnifrakturMaguntia": { + "x": 247, + "y": 720, + "w": 204, + "ch": 13, + "s": ["400"] + }, + "Unkempt": { "x": 465, "y": 720, "w": 99, "ch": 13, "s": ["400", "700"] }, + "Unlock": { "x": 578, "y": 720, "w": 93, "ch": 13, "s": ["400"] }, + "Unna": { + "x": 685, + "y": 720, + "w": 60, + "ch": 13, + "s": ["400", "400i", "700", "700i"] + }, + "UoqMunThenKhung": { "x": 759, "y": 720, "w": 245, "ch": 13, "s": ["400"] }, + "Updock": { "x": 1018, "y": 720, "w": 58, "ch": 13, "s": ["400"] }, + "Urbanist": { + "x": 1090, + "y": 720, + "w": 97, + "ch": 13, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Vampiro One": { "x": 0, "y": 760, "w": 165, "ch": 13, "s": ["400"] }, + "Varela": { "x": 179, "y": 760, "w": 81, "ch": 13, "s": ["400"] }, + "Varela Round": { "x": 274, "y": 760, "w": 161, "ch": 13, "s": ["400"] }, + "Varta": { + "x": 449, + "y": 760, + "w": 61, + "ch": 13, + "s": ["300", "400", "500", "600", "700"] + }, + "Vast Shadow": { "x": 524, "y": 760, "w": 227, "ch": 13, "s": ["400"] }, + "Vazirmatn": { + "x": 765, + "y": 760, + "w": 117, + "ch": 13, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Vend Sans": { + "x": 896, + "y": 760, + "w": 121, + "ch": 13, + "s": [ + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i" + ] + }, + "Vesper Libre": { + "x": 1031, + "y": 760, + "w": 136, + "ch": 13, + "s": ["400", "500", "700", "900"] + }, + "Viaoda Libre": { "x": 0, "y": 0, "w": 126, "ch": 14, "s": ["400"] }, + "Vibes": { "x": 140, "y": 0, "w": 53, "ch": 14, "s": ["400"] }, + "Vibur": { "x": 207, "y": 0, "w": 61, "ch": 14, "s": ["400"] }, + "Victor Mono": { + "x": 282, + "y": 0, + "w": 167, + "ch": 14, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i" + ] + }, + "Vidaloka": { "x": 463, "y": 0, "w": 99, "ch": 14, "s": ["400"] }, + "Viga": { "x": 576, "y": 0, "w": 55, "ch": 14, "s": ["400"] }, + "Vina Sans": { "x": 645, "y": 0, "w": 86, "ch": 14, "s": ["400"] }, + "Voces": { "x": 745, "y": 0, "w": 72, "ch": 14, "s": ["400"] }, + "Volkhov": { + "x": 831, + "y": 0, + "w": 104, + "ch": 14, + "s": ["400", "400i", "700", "700i"] + }, + "Vollkorn": { + "x": 949, + "y": 0, + "w": 99, + "ch": 14, + "s": [ + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Vollkorn SC": { + "x": 0, + "y": 40, + "w": 166, + "ch": 14, + "s": ["400", "600", "700", "900"] + }, + "Voltaire": { "x": 180, "y": 40, "w": 73, "ch": 14, "s": ["400"] }, + "VT323": { "x": 267, "y": 40, "w": 56, "ch": 14, "s": ["400"] }, + "Vujahday Script": { "x": 337, "y": 40, "w": 151, "ch": 14, "s": ["400"] }, + "Waiting for the Sunrise": { + "x": 502, + "y": 40, + "w": 230, + "ch": 14, + "s": ["400"] + }, + "Wallpoet": { "x": 746, "y": 40, "w": 127, "ch": 14, "s": ["400"] }, + "Walter Turncoat": { "x": 887, "y": 40, "w": 206, "ch": 14, "s": ["400"] }, + "Warnes": { "x": 0, "y": 80, "w": 120, "ch": 14, "s": ["400"] }, + "Water Brush": { "x": 134, "y": 80, "w": 116, "ch": 14, "s": ["400"] }, + "Waterfall": { "x": 264, "y": 80, "w": 77, "ch": 14, "s": ["400"] }, + "Wavefont": { + "x": 355, + "y": 80, + "w": 28, + "ch": 14, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "WDXL Lubrifont JP N": { + "x": 397, + "y": 80, + "w": 185, + "ch": 14, + "s": ["400"] + }, + "WDXL Lubrifont SC": { + "x": 596, + "y": 80, + "w": 170, + "ch": 14, + "s": ["400"] + }, + "WDXL Lubrifont TC": { + "x": 780, + "y": 80, + "w": 168, + "ch": 14, + "s": ["400"] + }, + "Wellfleet": { "x": 962, "y": 80, "w": 117, "ch": 14, "s": ["400"] }, + "Wendy One": { "x": 0, "y": 120, "w": 142, "ch": 14, "s": ["400"] }, + "Whisper": { "x": 156, "y": 120, "w": 76, "ch": 14, "s": ["400"] }, + "WindSong": { "x": 246, "y": 120, "w": 133, "ch": 14, "s": ["400", "500"] }, + "Winky Rough": { + "x": 393, + "y": 120, + "w": 139, + "ch": 14, + "s": [ + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Winky Sans": { + "x": 546, + "y": 120, + "w": 120, + "ch": 14, + "s": [ + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Wire One": { "x": 680, "y": 120, "w": 57, "ch": 14, "s": ["400"] }, + "Wittgenstein": { + "x": 751, + "y": 120, + "w": 151, + "ch": 14, + "s": [ + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Wix Madefor Display": { + "x": 916, + "y": 120, + "w": 238, + "ch": 14, + "s": ["400", "500", "600", "700", "800"] + }, + "Wix Madefor Text": { + "x": 0, + "y": 160, + "w": 202, + "ch": 14, + "s": [ + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i" + ] + }, + "Work Sans": { + "x": 216, + "y": 160, + "w": 132, + "ch": 14, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Workbench": { "x": 362, "y": 160, "w": 116, "ch": 14, "s": ["400"] }, + "Xanh Mono": { + "x": 492, + "y": 160, + "w": 116, + "ch": 14, + "s": ["400", "400i"] + }, + "Yaldevi": { + "x": 622, + "y": 160, + "w": 83, + "ch": 14, + "s": ["200", "300", "400", "500", "600", "700"] + }, + "Yanone Kaffeesatz": { + "x": 719, + "y": 160, + "w": 142, + "ch": 14, + "s": ["200", "300", "400", "500", "600", "700"] + }, + "Yantramanav": { + "x": 875, + "y": 160, + "w": 138, + "ch": 14, + "s": ["100", "300", "400", "500", "700", "900"] + }, + "Yarndings 12": { "x": 0, "y": 200, "w": 226, "ch": 14, "s": ["400"] }, + "Yarndings 12 Charted": { + "x": 240, + "y": 200, + "w": 365, + "ch": 14, + "s": ["400"] + }, + "Yarndings 20": { "x": 619, "y": 200, "w": 219, "ch": 14, "s": ["400"] }, + "Yarndings 20 Charted": { + "x": 0, + "y": 240, + "w": 357, + "ch": 14, + "s": ["400"] + }, + "Yatra One": { "x": 371, "y": 240, "w": 127, "ch": 14, "s": ["400"] }, + "Yellowtail": { "x": 512, "y": 240, "w": 93, "ch": 14, "s": ["400"] }, + "Yeon Sung": { "x": 619, "y": 240, "w": 107, "ch": 14, "s": ["400"] }, + "Yeseva One": { "x": 740, "y": 240, "w": 144, "ch": 14, "s": ["400"] }, + "Yesteryear": { "x": 898, "y": 240, "w": 94, "ch": 14, "s": ["400"] }, + "Yomogi": { "x": 1006, "y": 240, "w": 80, "ch": 14, "s": ["400"] }, + "Young Serif": { "x": 0, "y": 280, "w": 149, "ch": 14, "s": ["400"] }, + "Yrsa": { + "x": 163, + "y": 280, + "w": 49, + "ch": 14, + "s": [ + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i" + ] + }, + "Ysabeau": { + "x": 226, + "y": 280, + "w": 88, + "ch": 14, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Ysabeau Infant": { + "x": 328, + "y": 280, + "w": 161, + "ch": 14, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Ysabeau Office": { + "x": 503, + "y": 280, + "w": 156, + "ch": 14, + "s": [ + "100", + "100i", + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Ysabeau SC": { + "x": 673, + "y": 280, + "w": 130, + "ch": 14, + "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"] + }, + "Yuji Boku": { "x": 817, "y": 280, "w": 135, "ch": 14, "s": ["400"] }, + "Yuji Hentaigana Akari": { + "x": 0, + "y": 320, + "w": 335, + "ch": 14, + "s": ["400"] + }, + "Yuji Hentaigana Akebono": { + "x": 349, + "y": 320, + "w": 374, + "ch": 14, + "s": ["400"] + }, + "Yuji Mai": { "x": 737, "y": 320, "w": 117, "ch": 14, "s": ["400"] }, + "Yuji Syuku": { "x": 868, "y": 320, "w": 144, "ch": 14, "s": ["400"] }, + "Yusei Magic": { "x": 1026, "y": 320, "w": 138, "ch": 14, "s": ["400"] }, + "Zain": { + "x": 0, + "y": 360, + "w": 54, + "ch": 14, + "s": [ + "200", + "200i", + "300", + "300i", + "400", + "400i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Zalando Sans": { + "x": 68, + "y": 360, + "w": 161, + "ch": 14, + "s": [ + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Zalando Sans Expanded": { + "x": 243, + "y": 360, + "w": 335, + "ch": 14, + "s": [ + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "Zalando Sans SemiExpanded": { + "x": 592, + "y": 360, + "w": 361, + "ch": 14, + "s": [ + "200", + "200i", + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i", + "800", + "800i", + "900", + "900i" + ] + }, + "ZCOOL KuaiLe": { "x": 967, "y": 360, "w": 180, "ch": 14, "s": ["400"] }, + "ZCOOL QingKe HuangYou": { + "x": 0, + "y": 400, + "w": 222, + "ch": 14, + "s": ["400"] + }, + "ZCOOL XiaoWei": { "x": 236, "y": 400, "w": 173, "ch": 14, "s": ["400"] }, + "Zen Antique": { "x": 423, "y": 400, "w": 149, "ch": 14, "s": ["400"] }, + "Zen Antique Soft": { + "x": 586, + "y": 400, + "w": 204, + "ch": 14, + "s": ["400"] + }, + "Zen Dots": { "x": 804, "y": 400, "w": 147, "ch": 14, "s": ["400"] }, + "Zen Kaku Gothic Antique": { + "x": 0, + "y": 440, + "w": 267, + "ch": 14, + "s": ["300", "400", "500", "700", "900"] + }, + "Zen Kaku Gothic New": { + "x": 281, + "y": 440, + "w": 231, + "ch": 14, + "s": ["300", "400", "500", "700", "900"] + }, + "Zen Kurenaido": { "x": 526, "y": 440, "w": 145, "ch": 14, "s": ["400"] }, + "Zen Loop": { "x": 685, "y": 440, "w": 65, "ch": 14, "s": ["400", "400i"] }, + "Zen Maru Gothic": { + "x": 764, + "y": 440, + "w": 181, + "ch": 14, + "s": ["300", "400", "500", "700", "900"] + }, + "Zen Old Mincho": { + "x": 959, + "y": 440, + "w": 173, + "ch": 14, + "s": ["400", "500", "600", "700", "900"] + }, + "Zen Tokyo Zoo": { "x": 0, "y": 480, "w": 154, "ch": 14, "s": ["400"] }, + "Zeyada": { "x": 168, "y": 480, "w": 70, "ch": 14, "s": ["400"] }, + "Zhi Mang Xing": { "x": 252, "y": 480, "w": 121, "ch": 14, "s": ["400"] }, + "Zilla Slab": { + "x": 387, + "y": 480, + "w": 102, + "ch": 14, + "s": [ + "300", + "300i", + "400", + "400i", + "500", + "500i", + "600", + "600i", + "700", + "700i" + ] + }, + "Zilla Slab Highlight": { + "x": 503, + "y": 480, + "w": 207, + "ch": 14, + "s": ["400", "700"] + } + } +} diff --git a/apps/web/public/fonts/font-chunk-0.avif b/apps/web/public/fonts/font-chunk-0.avif Binary files differnew file mode 100644 index 00000000..719b4235 --- /dev/null +++ b/apps/web/public/fonts/font-chunk-0.avif diff --git a/apps/web/public/fonts/font-chunk-1.avif b/apps/web/public/fonts/font-chunk-1.avif Binary files differnew file mode 100644 index 00000000..c276430b --- /dev/null +++ b/apps/web/public/fonts/font-chunk-1.avif diff --git a/apps/web/public/fonts/font-chunk-10.avif b/apps/web/public/fonts/font-chunk-10.avif Binary files differnew file mode 100644 index 00000000..9761e954 --- /dev/null +++ b/apps/web/public/fonts/font-chunk-10.avif diff --git a/apps/web/public/fonts/font-chunk-11.avif b/apps/web/public/fonts/font-chunk-11.avif Binary files differnew file mode 100644 index 00000000..d4f788e0 --- /dev/null +++ b/apps/web/public/fonts/font-chunk-11.avif diff --git a/apps/web/public/fonts/font-chunk-12.avif b/apps/web/public/fonts/font-chunk-12.avif Binary files differnew file mode 100644 index 00000000..91e643ae --- /dev/null +++ b/apps/web/public/fonts/font-chunk-12.avif diff --git a/apps/web/public/fonts/font-chunk-13.avif b/apps/web/public/fonts/font-chunk-13.avif Binary files differnew file mode 100644 index 00000000..6a507e8b --- /dev/null +++ b/apps/web/public/fonts/font-chunk-13.avif diff --git a/apps/web/public/fonts/font-chunk-14.avif b/apps/web/public/fonts/font-chunk-14.avif Binary files differnew file mode 100644 index 00000000..f67ab51b --- /dev/null +++ b/apps/web/public/fonts/font-chunk-14.avif diff --git a/apps/web/public/fonts/font-chunk-2.avif b/apps/web/public/fonts/font-chunk-2.avif Binary files differnew file mode 100644 index 00000000..bc3ca018 --- /dev/null +++ b/apps/web/public/fonts/font-chunk-2.avif diff --git a/apps/web/public/fonts/font-chunk-3.avif b/apps/web/public/fonts/font-chunk-3.avif Binary files differnew file mode 100644 index 00000000..a5b9dd60 --- /dev/null +++ b/apps/web/public/fonts/font-chunk-3.avif diff --git a/apps/web/public/fonts/font-chunk-4.avif b/apps/web/public/fonts/font-chunk-4.avif Binary files differnew file mode 100644 index 00000000..505fcb25 --- /dev/null +++ b/apps/web/public/fonts/font-chunk-4.avif diff --git a/apps/web/public/fonts/font-chunk-5.avif b/apps/web/public/fonts/font-chunk-5.avif Binary files differnew file mode 100644 index 00000000..7d8c0216 --- /dev/null +++ b/apps/web/public/fonts/font-chunk-5.avif diff --git a/apps/web/public/fonts/font-chunk-6.avif b/apps/web/public/fonts/font-chunk-6.avif Binary files differnew file mode 100644 index 00000000..d318967d --- /dev/null +++ b/apps/web/public/fonts/font-chunk-6.avif diff --git a/apps/web/public/fonts/font-chunk-7.avif b/apps/web/public/fonts/font-chunk-7.avif Binary files differnew file mode 100644 index 00000000..0dcb81d7 --- /dev/null +++ b/apps/web/public/fonts/font-chunk-7.avif diff --git a/apps/web/public/fonts/font-chunk-8.avif b/apps/web/public/fonts/font-chunk-8.avif Binary files differnew file mode 100644 index 00000000..63b0e35a --- /dev/null +++ b/apps/web/public/fonts/font-chunk-8.avif diff --git a/apps/web/public/fonts/font-chunk-9.avif b/apps/web/public/fonts/font-chunk-9.avif Binary files differnew file mode 100644 index 00000000..e7936bf0 --- /dev/null +++ b/apps/web/public/fonts/font-chunk-9.avif diff --git a/apps/web/public/manifest.json b/apps/web/public/manifest.json index d4a41dc0..2f00e1e9 100644 --- a/apps/web/public/manifest.json +++ b/apps/web/public/manifest.json @@ -1,44 +1,44 @@ { - "name": "OpenCut", - "description": "A simple but powerful video editor that gets the job done. In your browser.", - "display": "standalone", - "start_url": "/", - "icons": [ - { - "src": "/icons/android-icon-36x36.png", - "sizes": "36x36", - "type": "image\/png", - "density": "0.75" - }, - { - "src": "/icons/android-icon-48x48.png", - "sizes": "48x48", - "type": "image\/png", - "density": "1.0" - }, - { - "src": "/icons/android-icon-72x72.png", - "sizes": "72x72", - "type": "image\/png", - "density": "1.5" - }, - { - "src": "/icons/android-icon-96x96.png", - "sizes": "96x96", - "type": "image\/png", - "density": "2.0" - }, - { - "src": "/icons/android-icon-144x144.png", - "sizes": "144x144", - "type": "image\/png", - "density": "3.0" - }, - { - "src": "/icons/android-icon-192x192.png", - "sizes": "192x192", - "type": "image\/png", - "density": "4.0" - } - ] + "name": "OpenCut", + "description": "A simple but powerful video editor that gets the job done. In your browser.", + "display": "standalone", + "start_url": "/", + "icons": [ + { + "src": "/icons/android-icon-36x36.png", + "sizes": "36x36", + "type": "image\/png", + "density": "0.75" + }, + { + "src": "/icons/android-icon-48x48.png", + "sizes": "48x48", + "type": "image\/png", + "density": "1.0" + }, + { + "src": "/icons/android-icon-72x72.png", + "sizes": "72x72", + "type": "image\/png", + "density": "1.5" + }, + { + "src": "/icons/android-icon-96x96.png", + "sizes": "96x96", + "type": "image\/png", + "density": "2.0" + }, + { + "src": "/icons/android-icon-144x144.png", + "sizes": "144x144", + "type": "image\/png", + "density": "3.0" + }, + { + "src": "/icons/android-icon-192x192.png", + "sizes": "192x192", + "type": "image\/png", + "density": "4.0" + } + ] } diff --git a/apps/web/public/shapes/circle.svg b/apps/web/public/shapes/circle.svg new file mode 100644 index 00000000..adb837cf --- /dev/null +++ b/apps/web/public/shapes/circle.svg @@ -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> diff --git a/apps/web/public/shapes/diamond.svg b/apps/web/public/shapes/diamond.svg new file mode 100644 index 00000000..4a039b5c --- /dev/null +++ b/apps/web/public/shapes/diamond.svg @@ -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> diff --git a/apps/web/public/shapes/hexagon.svg b/apps/web/public/shapes/hexagon.svg new file mode 100644 index 00000000..e1eb087e --- /dev/null +++ b/apps/web/public/shapes/hexagon.svg @@ -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> diff --git a/apps/web/public/shapes/square.svg b/apps/web/public/shapes/square.svg new file mode 100644 index 00000000..ed9b790f --- /dev/null +++ b/apps/web/public/shapes/square.svg @@ -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> diff --git a/apps/web/public/shapes/star.svg b/apps/web/public/shapes/star.svg new file mode 100644 index 00000000..c49e0c53 --- /dev/null +++ b/apps/web/public/shapes/star.svg @@ -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> diff --git a/apps/web/public/shapes/triangle.svg b/apps/web/public/shapes/triangle.svg new file mode 100644 index 00000000..a1bd6b0c --- /dev/null +++ b/apps/web/public/shapes/triangle.svg @@ -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> diff --git a/apps/web/scripts/generate-font-sprites.ts b/apps/web/scripts/generate-font-sprites.ts new file mode 100644 index 00000000..144d68ec --- /dev/null +++ b/apps/web/scripts/generate-font-sprites.ts @@ -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); +}); diff --git a/apps/web/src/app/api/sounds/search/route.ts b/apps/web/src/app/api/sounds/search/route.ts index 3070d152..339a54da 100644 --- a/apps/web/src/app/api/sounds/search/route.ts +++ b/apps/web/src/app/api/sounds/search/route.ts @@ -1,280 +1,280 @@ -import { webEnv } from "@opencut/env/web";
-import { type NextRequest, NextResponse } from "next/server";
-import { z } from "zod";
-import { checkRateLimit } from "@/lib/rate-limit";
-
-const searchParamsSchema = z.object({
- q: z.string().max(500, "Query too long").optional(),
- type: z.enum(["songs", "effects"]).optional(),
- page: z.coerce.number().int().min(1).max(1000).default(1),
- page_size: z.coerce.number().int().min(1).max(150).default(20),
- sort: z
- .enum(["downloads", "rating", "created", "score"])
- .default("downloads"),
- min_rating: z.coerce.number().min(0).max(5).default(3),
- commercial_only: z.coerce.boolean().default(true),
-});
-
-const freesoundResultSchema = z.object({
- id: z.number(),
- name: z.string(),
- description: z.string(),
- url: z.string().url(),
- previews: z
- .object({
- "preview-hq-mp3": z.string().url(),
- "preview-lq-mp3": z.string().url(),
- "preview-hq-ogg": z.string().url(),
- "preview-lq-ogg": z.string().url(),
- })
- .optional(),
- download: z.string().url().optional(),
- duration: z.number(),
- filesize: z.number(),
- type: z.string(),
- channels: z.number(),
- bitrate: z.number(),
- bitdepth: z.number(),
- samplerate: z.number(),
- username: z.string(),
- tags: z.array(z.string()),
- license: z.string(),
- created: z.string(),
- num_downloads: z.number().optional(),
- avg_rating: z.number().optional(),
- num_ratings: z.number().optional(),
-});
-
-const freesoundResponseSchema = z.object({
- count: z.number(),
- next: z.string().url().nullable(),
- previous: z.string().url().nullable(),
- results: z.array(freesoundResultSchema),
-});
-
-const transformedResultSchema = z.object({
- id: z.number(),
- name: z.string(),
- description: z.string(),
- url: z.string(),
- previewUrl: z.string().optional(),
- downloadUrl: z.string().optional(),
- duration: z.number(),
- filesize: z.number(),
- type: z.string(),
- channels: z.number(),
- bitrate: z.number(),
- bitdepth: z.number(),
- samplerate: z.number(),
- username: z.string(),
- tags: z.array(z.string()),
- license: z.string(),
- created: z.string(),
- downloads: z.number().optional(),
- rating: z.number().optional(),
- ratingCount: z.number().optional(),
-});
-
-const apiResponseSchema = z.object({
- count: z.number(),
- next: z.string().nullable(),
- previous: z.string().nullable(),
- results: z.array(transformedResultSchema),
- query: z.string().optional(),
- type: z.string(),
- page: z.number(),
- pageSize: z.number(),
- sort: z.string(),
- minRating: z.number().optional(),
-});
-
-function buildSortParameter({ query, sort }: { query?: string; sort: string }) {
- if (!query) return `${sort}_desc`;
- return sort === "score" ? "score" : `${sort}_desc`;
-}
-
-function applyEffectsFilters({
- params,
- min_rating,
- commercial_only,
-}: {
- params: URLSearchParams;
- min_rating: number;
- commercial_only: boolean;
-}) {
- params.append("filter", "duration:[* TO 30.0]");
- params.append("filter", `avg_rating:[${min_rating} TO *]`);
-
- if (commercial_only) {
- params.append(
- "filter",
- 'license:("Attribution" OR "Creative Commons 0" OR "Attribution Noncommercial" OR "Attribution Commercial")',
- );
- }
-
- params.append(
- "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",
- );
-}
-
-function transformFreesoundResult(
- result: z.infer<typeof freesoundResultSchema>,
-) {
- return {
- id: result.id,
- name: result.name,
- description: result.description,
- url: result.url,
- previewUrl:
- result.previews?.["preview-hq-mp3"] ||
- result.previews?.["preview-lq-mp3"],
- downloadUrl: result.download,
- duration: result.duration,
- filesize: result.filesize,
- type: result.type,
- channels: result.channels,
- bitrate: result.bitrate,
- bitdepth: result.bitdepth,
- samplerate: result.samplerate,
- username: result.username,
- tags: result.tags,
- license: result.license,
- created: result.created,
- downloads: result.num_downloads || 0,
- rating: result.avg_rating || 0,
- ratingCount: result.num_ratings || 0,
- };
-}
-
-export async function GET(request: NextRequest) {
- try {
- const { limited } = await checkRateLimit({ request });
- if (limited) {
- return NextResponse.json({ error: "Too many requests" }, { status: 429 });
- }
-
- const { searchParams } = new URL(request.url);
-
- const validationResult = searchParamsSchema.safeParse({
- q: searchParams.get("q") || undefined,
- type: searchParams.get("type") || undefined,
- page: searchParams.get("page") || undefined,
- page_size: searchParams.get("page_size") || undefined,
- sort: searchParams.get("sort") || undefined,
- min_rating: searchParams.get("min_rating") || undefined,
- });
-
- if (!validationResult.success) {
- return NextResponse.json(
- {
- error: "Invalid parameters",
- details: validationResult.error.flatten().fieldErrors,
- },
- { status: 400 },
- );
- }
-
- const {
- q: query,
- type,
- page,
- page_size: pageSize,
- sort,
- min_rating,
- commercial_only,
- } = validationResult.data;
-
- if (type === "songs") {
- return NextResponse.json(
- {
- error: "Songs are not available yet",
- message:
- "Song search functionality is coming soon. Try searching for sound effects instead.",
- },
- { status: 501 },
- );
- }
-
- const baseUrl = "https://freesound.org/apiv2/search/text/";
-
- const sortParam = buildSortParameter({ query, sort });
-
- const params = new URLSearchParams({
- query: query || "",
- token: webEnv.FREESOUND_API_KEY,
- page: page.toString(),
- page_size: pageSize.toString(),
- sort: sortParam,
- fields:
- "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;
- if (isEffectsSearch) {
- applyEffectsFilters({ params, min_rating, commercial_only });
- }
-
- const response = await fetch(`${baseUrl}?${params.toString()}`);
-
- if (!response.ok) {
- const errorText = await response.text();
- console.error("Freesound API error:", response.status, errorText);
- return NextResponse.json(
- { error: "Failed to search sounds" },
- { status: response.status },
- );
- }
-
- const rawData = await response.json();
-
- const freesoundValidation = freesoundResponseSchema.safeParse(rawData);
- if (!freesoundValidation.success) {
- console.error(
- "Invalid Freesound API response:",
- freesoundValidation.error,
- );
- return NextResponse.json(
- { error: "Invalid response from Freesound API" },
- { status: 502 },
- );
- }
-
- const data = freesoundValidation.data;
-
- const transformedResults = data.results.map(transformFreesoundResult);
-
- const responseData = {
- count: data.count,
- next: data.next,
- previous: data.previous,
- results: transformedResults,
- query: query || "",
- type: type || "effects",
- page,
- pageSize,
- sort,
- minRating: min_rating,
- };
-
- const responseValidation = apiResponseSchema.safeParse(responseData);
- if (!responseValidation.success) {
- console.error(
- "Invalid API response structure:",
- responseValidation.error,
- );
- return NextResponse.json(
- { error: "Internal response formatting error" },
- { status: 500 },
- );
- }
-
- return NextResponse.json(responseValidation.data);
- } catch (error) {
- console.error("Error searching sounds:", error);
- return NextResponse.json(
- { error: "Internal server error" },
- { status: 500 },
- );
- }
-}
+import { webEnv } from "@opencut/env/web"; +import { type NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { checkRateLimit } from "@/lib/rate-limit"; + +const searchParamsSchema = z.object({ + q: z.string().max(500, "Query too long").optional(), + type: z.enum(["songs", "effects"]).optional(), + page: z.coerce.number().int().min(1).max(1000).default(1), + page_size: z.coerce.number().int().min(1).max(150).default(20), + sort: z + .enum(["downloads", "rating", "created", "score"]) + .default("downloads"), + min_rating: z.coerce.number().min(0).max(5).default(3), + commercial_only: z.coerce.boolean().default(true), +}); + +const freesoundResultSchema = z.object({ + id: z.number(), + name: z.string(), + description: z.string(), + url: z.string().url(), + previews: z + .object({ + "preview-hq-mp3": z.string().url(), + "preview-lq-mp3": z.string().url(), + "preview-hq-ogg": z.string().url(), + "preview-lq-ogg": z.string().url(), + }) + .optional(), + download: z.string().url().optional(), + duration: z.number(), + filesize: z.number(), + type: z.string(), + channels: z.number(), + bitrate: z.number(), + bitdepth: z.number(), + samplerate: z.number(), + username: z.string(), + tags: z.array(z.string()), + license: z.string(), + created: z.string(), + num_downloads: z.number().optional(), + avg_rating: z.number().optional(), + num_ratings: z.number().optional(), +}); + +const freesoundResponseSchema = z.object({ + count: z.number(), + next: z.string().url().nullable(), + previous: z.string().url().nullable(), + results: z.array(freesoundResultSchema), +}); + +const transformedResultSchema = z.object({ + id: z.number(), + name: z.string(), + description: z.string(), + url: z.string(), + previewUrl: z.string().optional(), + downloadUrl: z.string().optional(), + duration: z.number(), + filesize: z.number(), + type: z.string(), + channels: z.number(), + bitrate: z.number(), + bitdepth: z.number(), + samplerate: z.number(), + username: z.string(), + tags: z.array(z.string()), + license: z.string(), + created: z.string(), + downloads: z.number().optional(), + rating: z.number().optional(), + ratingCount: z.number().optional(), +}); + +const apiResponseSchema = z.object({ + count: z.number(), + next: z.string().nullable(), + previous: z.string().nullable(), + results: z.array(transformedResultSchema), + query: z.string().optional(), + type: z.string(), + page: z.number(), + pageSize: z.number(), + sort: z.string(), + minRating: z.number().optional(), +}); + +function buildSortParameter({ query, sort }: { query?: string; sort: string }) { + if (!query) return `${sort}_desc`; + return sort === "score" ? "score" : `${sort}_desc`; +} + +function applyEffectsFilters({ + params, + min_rating, + commercial_only, +}: { + params: URLSearchParams; + min_rating: number; + commercial_only: boolean; +}) { + params.append("filter", "duration:[* TO 30.0]"); + params.append("filter", `avg_rating:[${min_rating} TO *]`); + + if (commercial_only) { + params.append( + "filter", + 'license:("Attribution" OR "Creative Commons 0" OR "Attribution Noncommercial" OR "Attribution Commercial")', + ); + } + + params.append( + "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", + ); +} + +function transformFreesoundResult( + result: z.infer<typeof freesoundResultSchema>, +) { + return { + id: result.id, + name: result.name, + description: result.description, + url: result.url, + previewUrl: + result.previews?.["preview-hq-mp3"] || + result.previews?.["preview-lq-mp3"], + downloadUrl: result.download, + duration: result.duration, + filesize: result.filesize, + type: result.type, + channels: result.channels, + bitrate: result.bitrate, + bitdepth: result.bitdepth, + samplerate: result.samplerate, + username: result.username, + tags: result.tags, + license: result.license, + created: result.created, + downloads: result.num_downloads || 0, + rating: result.avg_rating || 0, + ratingCount: result.num_ratings || 0, + }; +} + +export async function GET(request: NextRequest) { + try { + const { limited } = await checkRateLimit({ request }); + if (limited) { + return NextResponse.json({ error: "Too many requests" }, { status: 429 }); + } + + const { searchParams } = new URL(request.url); + + const validationResult = searchParamsSchema.safeParse({ + q: searchParams.get("q") || undefined, + type: searchParams.get("type") || undefined, + page: searchParams.get("page") || undefined, + page_size: searchParams.get("page_size") || undefined, + sort: searchParams.get("sort") || undefined, + min_rating: searchParams.get("min_rating") || undefined, + }); + + if (!validationResult.success) { + return NextResponse.json( + { + error: "Invalid parameters", + details: validationResult.error.flatten().fieldErrors, + }, + { status: 400 }, + ); + } + + const { + q: query, + type, + page, + page_size: pageSize, + sort, + min_rating, + commercial_only, + } = validationResult.data; + + if (type === "songs") { + return NextResponse.json( + { + error: "Songs are not available yet", + message: + "Song search functionality is coming soon. Try searching for sound effects instead.", + }, + { status: 501 }, + ); + } + + const baseUrl = "https://freesound.org/apiv2/search/text/"; + + const sortParam = buildSortParameter({ query, sort }); + + const params = new URLSearchParams({ + query: query || "", + token: webEnv.FREESOUND_API_KEY, + page: page.toString(), + page_size: pageSize.toString(), + sort: sortParam, + fields: + "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; + if (isEffectsSearch) { + applyEffectsFilters({ params, min_rating, commercial_only }); + } + + const response = await fetch(`${baseUrl}?${params.toString()}`); + + if (!response.ok) { + const errorText = await response.text(); + console.error("Freesound API error:", response.status, errorText); + return NextResponse.json( + { error: "Failed to search sounds" }, + { status: response.status }, + ); + } + + const rawData = await response.json(); + + const freesoundValidation = freesoundResponseSchema.safeParse(rawData); + if (!freesoundValidation.success) { + console.error( + "Invalid Freesound API response:", + freesoundValidation.error, + ); + return NextResponse.json( + { error: "Invalid response from Freesound API" }, + { status: 502 }, + ); + } + + const data = freesoundValidation.data; + + const transformedResults = data.results.map(transformFreesoundResult); + + const responseData = { + count: data.count, + next: data.next, + previous: data.previous, + results: transformedResults, + query: query || "", + type: type || "effects", + page, + pageSize, + sort, + minRating: min_rating, + }; + + const responseValidation = apiResponseSchema.safeParse(responseData); + if (!responseValidation.success) { + console.error( + "Invalid API response structure:", + responseValidation.error, + ); + return NextResponse.json( + { error: "Internal response formatting error" }, + { status: 500 }, + ); + } + + return NextResponse.json(responseValidation.data); + } catch (error) { + console.error("Error searching sounds:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } +} diff --git a/apps/web/src/app/blog/[slug]/page.tsx b/apps/web/src/app/blog/[slug]/page.tsx index 2c06cb90..e1afc3a8 100644 --- a/apps/web/src/app/blog/[slug]/page.tsx +++ b/apps/web/src/app/blog/[slug]/page.tsx @@ -120,7 +120,9 @@ function PostMeta({ date, publishedAt }: { date: string; publishedAt: Date }) { function PostTitle({ title }: { title: string }) { 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> ); } diff --git a/apps/web/src/app/editor/[project_id]/page.tsx b/apps/web/src/app/editor/[project_id]/page.tsx index 97093912..987af7c6 100644 --- a/apps/web/src/app/editor/[project_id]/page.tsx +++ b/apps/web/src/app/editor/[project_id]/page.tsx @@ -15,6 +15,7 @@ import { EditorProvider } from "@/components/providers/editor-provider"; import { Onboarding } from "@/components/editor/onboarding"; import { MigrationDialog } from "@/components/editor/dialogs/migration-dialog"; import { usePanelStore } from "@/stores/panel-store"; +import { usePasteMedia } from "@/hooks/use-paste-media"; export default function Editor() { const params = useParams(); @@ -35,6 +36,7 @@ export default function Editor() { } function EditorLayout() { + usePasteMedia(); const { panels, setPanel } = usePanelStore(); return ( diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 37b5fe16..2d051c93 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -8,115 +8,115 @@ @plugin "tailwindcss-animate"; :root { - --background: hsl(0, 0%, 100%); - --foreground: hsl(0 0% 11%); - --card: hsl(0, 0%, 100%); - --card-foreground: hsl(0 0% 11%); - --popover: hsl(0, 0%, 100%); - --popover-hover: hsl(0, 0%, 96%); - --popover-foreground: hsl(0 0% 2%); - --primary: #009dff; - --primary-foreground: hsl(0, 0%, 100%); - --secondary: hsl(204, 100%, 97%); - --secondary-border: hsl(204, 100%, 94%); - --secondary-foreground: hsl(200, 98%, 39%); - --muted: hsl(0 0% 85.1%); - --muted-foreground: hsl(0 0% 50%); - --accent: hsl(0, 0%, 96%); - --accent-foreground: hsl(0 0% 2%); - --destructive: hsl(0, 83%, 50%); - --destructive-foreground: hsl(0, 0%, 100%); - --constructive: hsl(141, 71%, 48%); - --constructive-foreground: hsl(0, 0%, 100%); - --border: hsl(0 0% 91%); - --input: hsl(0 0% 85.1%); - --ring: hsl(0, 0%, 55%); - --chart-1: hsl(220 70% 50%); - --chart-2: hsl(160 60% 45%); - --chart-3: hsl(30 80% 55%); - --chart-4: hsl(280 65% 60%); - --chart-5: hsl(340 75% 55%); - --sidebar-background: hsl(0 0% 96.1%); - --sidebar-foreground: hsl(0 0% 2%); - --sidebar-primary: hsl(0 0% 2%); - --sidebar-primary-foreground: hsl(0 0% 91%); - --sidebar-accent: hsl(0 0% 85.1%); - --sidebar-accent-foreground: hsl(0 0% 2%); - --sidebar-border: hsl(0 0% 85.1%); - --sidebar-ring: hsl(0 0% 16.9%); - --sidebar: hsl(0 0% 98%); + --background: hsl(0, 0%, 100%); + --foreground: hsl(0 0% 11%); + --card: hsl(0, 0%, 100%); + --card-foreground: hsl(0 0% 11%); + --popover: hsl(0, 0%, 100%); + --popover-hover: hsl(0, 0%, 96%); + --popover-foreground: hsl(0 0% 2%); + --primary: #009dff; + --primary-foreground: hsl(0, 0%, 100%); + --secondary: hsl(204, 100%, 97%); + --secondary-border: hsl(204, 100%, 94%); + --secondary-foreground: hsl(200, 98%, 39%); + --muted: hsl(0 0% 85.1%); + --muted-foreground: hsl(0 0% 50%); + --accent: hsl(0, 0%, 96%); + --accent-foreground: hsl(0 0% 2%); + --destructive: hsl(0, 83%, 50%); + --destructive-foreground: hsl(0, 0%, 100%); + --constructive: hsl(141, 71%, 48%); + --constructive-foreground: hsl(0, 0%, 100%); + --border: hsl(0 0% 91%); + --input: hsl(0, 0%, 100%); + --ring: hsl(0, 0%, 55%); + --chart-1: hsl(220 70% 50%); + --chart-2: hsl(160 60% 45%); + --chart-3: hsl(30 80% 55%); + --chart-4: hsl(280 65% 60%); + --chart-5: hsl(340 75% 55%); + --sidebar-background: hsl(0 0% 96.1%); + --sidebar-foreground: hsl(0 0% 2%); + --sidebar-primary: hsl(0 0% 2%); + --sidebar-primary-foreground: hsl(0 0% 91%); + --sidebar-accent: hsl(0 0% 85.1%); + --sidebar-accent-foreground: hsl(0 0% 2%); + --sidebar-border: hsl(0 0% 85.1%); + --sidebar-ring: hsl(0 0% 16.9%); + --sidebar: hsl(0 0% 98%); } .panel { - --background: hsl(216 13% 98%); - --foreground: hsl(0 0% 13%); - --card: hsl(0, 0%, 98%); - --card-foreground: hsl(0 0% 13%); - --primary-foreground: hsl(0, 0%, 98%); - --secondary: hsl(204, 100%, 95%); - --secondary-border: hsl(204, 100%, 92%); - --secondary-foreground: hsl(200, 98%, 37%); - --muted: hsl(0 0% 83.1%); - --muted-foreground: hsl(0 0% 48%); - --accent: hsl(0, 0%, 93%); - --accent-foreground: hsl(0 0% 5%); - --destructive: hsl(0, 83%, 50%); - --destructive-foreground: hsl(0, 0%, 98%); - --constructive: hsl(141, 71%, 48%); - --constructive-foreground: hsl(0, 0%, 98%); - --border: hsl(0 0% 89%); - --input: hsl(0 0% 83.1%); - --ring: hsl(0, 0%, 53%); + --background: hsl(216 13% 98%); + --foreground: hsl(0 0% 13%); + --card: hsl(0, 0%, 98%); + --card-foreground: hsl(0 0% 13%); + --primary-foreground: hsl(0, 0%, 98%); + --secondary: hsl(204, 100%, 95%); + --secondary-border: hsl(204, 100%, 92%); + --secondary-foreground: hsl(200, 98%, 37%); + --muted: hsl(0 0% 83.1%); + --muted-foreground: hsl(0 0% 48%); + --accent: hsl(0, 0%, 93%); + --accent-foreground: hsl(0 0% 5%); + --destructive: hsl(0, 83%, 50%); + --destructive-foreground: hsl(0, 0%, 98%); + --constructive: hsl(141, 71%, 48%); + --constructive-foreground: hsl(0, 0%, 98%); + --border: hsl(0 0% 89%); + --input: hsl(0 0% 93%); + --ring: hsl(0, 0%, 53%); } .dark { - --background: hsl(0, 0%, 7%); - --foreground: hsl(0 0% 87%); - --card: hsl(0, 0%, 7%); - --card-foreground: hsl(0 0% 87%); - --popover: hsl(0, 0%, 16%); - --popover-hover: hsl(0, 0%, 22%); - --popover-foreground: hsl(0 0% 95%); - --secondary: hsl(204, 100%, 12%); - --secondary-border: hsl(204, 100%, 15%); - --secondary-foreground: hsl(200, 98%, 61%); - --muted: hsl(0 0% 20%); - --accent: hsl(0, 0%, 14%); - --accent-foreground: hsl(0 0% 95%); - --constructive: hsl(141, 71%, 52%); - --border: hsl(0 0% 16%); - --input: hsl(0 0% 20%); - --ring: hsl(0, 0%, 50%); - --sidebar-background: hsl(0 0% 8%); - --sidebar-foreground: hsl(0 0% 95%); - --sidebar-primary: hsl(0 0% 95%); - --sidebar-primary-foreground: hsl(0 0% 15%); - --sidebar-accent: hsl(0 0% 20%); - --sidebar-accent-foreground: hsl(0 0% 95%); - --sidebar-border: hsl(0 0% 20%); - --sidebar-ring: hsl(0 0% 83.1%); - --sidebar: hsl(0 0% 6%); + --background: hsl(0, 0%, 5%); + --foreground: hsl(0 0% 87%); + --card: hsl(0, 0%, 5%); + --card-foreground: hsl(0 0% 87%); + --popover: hsl(0, 0%, 5%); + --popover-hover: hsl(0, 0%, 22%); + --popover-foreground: hsl(0 0% 95%); + --secondary: hsl(204, 100%, 12%); + --secondary-border: hsl(204, 100%, 15%); + --secondary-foreground: hsl(200, 98%, 61%); + --muted: hsl(0 0% 20%); + --accent: hsl(0, 0%, 14%); + --accent-foreground: hsl(0 0% 95%); + --constructive: hsl(141, 71%, 52%); + --border: hsl(0 0% 16%); + --input: hsl(0 0% 5%); + --ring: hsl(0, 0%, 50%); + --sidebar-background: hsl(0 0% 8%); + --sidebar-foreground: hsl(0 0% 95%); + --sidebar-primary: hsl(0 0% 95%); + --sidebar-primary-foreground: hsl(0 0% 15%); + --sidebar-accent: hsl(0 0% 20%); + --sidebar-accent-foreground: hsl(0 0% 95%); + --sidebar-border: hsl(0 0% 20%); + --sidebar-ring: hsl(0 0% 83.1%); + --sidebar: hsl(0 0% 6%); } .dark .panel { - --background: hsl(0 0% 10%); - --foreground: hsl(0 0% 85%); - --card: hsl(0, 0%, 10%); - --card-foreground: hsl(0 0% 85%); - --secondary: hsl(204, 100%, 12%); - --secondary-border: hsl(204, 100%, 17%); - --secondary-foreground: hsl(200, 98%, 63%); - --muted: hsl(0 0% 22%); - --accent: hsl(0, 0%, 15%); - --accent-foreground: hsl(0 0% 93%); - --constructive: hsl(141, 71%, 52%); - --border: hsl(0 0% 18%); - --input: hsl(0 0% 22%); - --ring: hsl(0, 0%, 52%); + --background: hsl(0 0% 10%); + --foreground: hsl(0 0% 85%); + --card: hsl(0, 0%, 10%); + --card-foreground: hsl(0 0% 85%); + --secondary: hsl(204, 100%, 12%); + --secondary-border: hsl(204, 100%, 17%); + --secondary-foreground: hsl(200, 98%, 63%); + --muted: hsl(0 0% 22%); + --accent: hsl(0, 0%, 15%); + --accent-foreground: hsl(0 0% 93%); + --constructive: hsl(141, 71%, 52%); + --border: hsl(0 0% 18%); + --input: hsl(0 0% 22%); + --ring: hsl(0, 0%, 52%); } @layer base { - /* + /* The default border color has changed to `currentcolor` in Tailwind CSS v4, so we've added these compatibility styles to make sure everything still 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 color utility to any element that depends on these defaults. */ - *, - ::after, - ::before, - ::backdrop, - ::file-selector-button { - border-color: var(--color-gray-200, currentcolor); - } - /* Other default base styles */ - * { - @apply border-border; - } - body { - @apply bg-background text-foreground; - /* Prevent back/forward swipe */ - overscroll-behavior-x: contain; - } - ::selection { - @apply bg-primary/35 selection:text-primary-foreground; - } + *, + ::after, + ::before, + ::backdrop, + ::file-selector-button { + border-color: var(--color-gray-200, currentcolor); + } + /* Other default base styles */ + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + /* Prevent back/forward swipe */ + overscroll-behavior-x: contain; + } + ::selection { + @apply bg-primary/35 selection:text-primary-foreground; + } } @theme inline { - /* Responsive breakpoints */ - --breakpoint-xs: 30rem; + /* Responsive breakpoints */ + --breakpoint-xs: 30rem; - /* Typography */ - --font-sans: var(--font-inter), sans-serif; + /* Typography */ + --font-sans: var(--font-inter), sans-serif; - /* Font sizes */ - --text-xl: 1.2rem; - --text-base: 0.92rem; - --text-base--line-height: calc(1.5 / 0.95); - --text-xs: 0.75rem; - --text-sm: 0.85rem; - --text-xs--line-height: calc(1 / 0.8); + /* Font sizes */ + --text-xs: 0.72rem; + --text-sm: 0.79rem; + --text-base: 0.92rem; + --text-base--line-height: calc(1.5 / 0.95); + --text-xs--line-height: calc(1 / 0.8); - /* Border radius */ - --radius-lg: 0.82rem; - --radius-md: 0.65rem; - --radius-sm: 0.35rem; + /* Border radius */ + --radius-lg: 0.82rem; + --radius-md: 0.65rem; + --radius-sm: 0.35rem; - /* Palette mapped to root design tokens */ - --color-background: var(--background); - --color-foreground: var(--foreground); + /* Palette mapped to root design tokens */ + --color-background: var(--background); + --color-foreground: var(--foreground); - --color-card: var(--card); - --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); - --color-popover: var(--popover); - --color-popover-hover: var(--popover-hover); - --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-popover-hover: var(--popover-hover); + --color-popover-foreground: var(--popover-foreground); - --color-primary: var(--primary); - --color-primary-foreground: var(--primary-foreground); - --color-secondary: var(--secondary); - --color-secondary-border: var(--secondary-border); - --color-secondary-foreground: var(--secondary-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-border: var(--secondary-border); + --color-secondary-foreground: var(--secondary-foreground); - --color-muted: var(--muted); - --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); - --color-accent: var(--accent); - --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); - --color-destructive: var(--destructive); - --color-destructive-foreground: var(--destructive-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); - --color-constructive: var(--constructive); - --color-constructive-foreground: var(--constructive-foreground); + --color-constructive: var(--constructive); + --color-constructive-foreground: var(--constructive-foreground); - --color-border: var(--border); - --color-input: var(--input); - --color-ring: var(--ring); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); - /* Chart colors */ - --color-chart-1: var(--chart-1); - --color-chart-2: var(--chart-2); - --color-chart-3: var(--chart-3); - --color-chart-4: var(--chart-4); - --color-chart-5: var(--chart-5); + /* Chart colors */ + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); - /* Sidebar */ - --color-sidebar: var(--sidebar-background); - --color-sidebar-foreground: var(--sidebar-foreground); - --color-sidebar-primary: var(--sidebar-primary); - --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); - --color-sidebar-accent: var(--sidebar-accent); - --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); - --color-sidebar-border: var(--sidebar-border); - --color-sidebar-ring: var(--sidebar-ring); + /* Sidebar */ + --color-sidebar: var(--sidebar-background); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); - /* Animations */ - --animate-accordion-down: accordion-down 0.2s ease-out; - --animate-accordion-up: accordion-up 0.2s ease-out; + /* Animations */ + --animate-accordion-down: accordion-down 0.2s ease-out; + --animate-accordion-up: accordion-up 0.2s ease-out; - @keyframes accordion-down { - from { - height: 0; - } - to { - height: var(--radix-accordion-content-height); - } - } + @keyframes accordion-down { + from { + height: 0; + } + to { + height: var(--radix-accordion-content-height); + } + } - @keyframes accordion-up { - from { - height: var(--radix-accordion-content-height); - } - to { - height: 0; - } - } + @keyframes accordion-up { + from { + height: var(--radix-accordion-content-height); + } + to { + height: 0; + } + } } @utility scrollbar-hidden { - -ms-overflow-style: none; - scrollbar-width: none; - &::-webkit-scrollbar { - display: none; - } + -ms-overflow-style: none; + scrollbar-width: none; + &::-webkit-scrollbar { + display: none; + } } @utility scrollbar-x-hidden { - -ms-overflow-style: none; - scrollbar-width: none; - &::-webkit-scrollbar:horizontal { - display: none; - } + -ms-overflow-style: none; + scrollbar-width: none; + &::-webkit-scrollbar:horizontal { + display: none; + } } @utility scrollbar-y-hidden { - -ms-overflow-style: none; - scrollbar-width: none; - &::-webkit-scrollbar:vertical { - display: none; - } + -ms-overflow-style: none; + scrollbar-width: none; + &::-webkit-scrollbar:vertical { + display: none; + } } @utility scrollbar-thin { - &::-webkit-scrollbar { - width: 6px; - height: 8px; - } - &::-webkit-scrollbar-track { - background: transparent; - } - &::-webkit-scrollbar-thumb { - background: var(--border); - border-radius: 4px; - } - &::-webkit-scrollbar-thumb:hover { - background: var(--muted-foreground); - } + &::-webkit-scrollbar { + width: 6px; + height: 8px; + } + &::-webkit-scrollbar-track { + background: transparent; + } + &::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 4px; + } + &::-webkit-scrollbar-thumb:hover { + background: var(--muted-foreground); + } } @layer base { - * { - @apply border-border outline-ring/50; - } - body { - @apply bg-background text-foreground; - } + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } } diff --git a/apps/web/src/app/projects/page.tsx b/apps/web/src/app/projects/page.tsx index 492e0cbe..b855cefc 100644 --- a/apps/web/src/app/projects/page.tsx +++ b/apps/web/src/app/projects/page.tsx @@ -245,7 +245,6 @@ function ProjectsToolbar({ projectIds }: { projectIds: string[] }) { </Button> </SortDropdown> <Button - type="button" variant="text" className="text-muted-foreground" onClick={() => @@ -768,7 +767,6 @@ function ProjectMenu({ <DropdownMenu open={isOpen} onOpenChange={onOpenChange}> <DropdownMenuTrigger asChild> <Button - type="button" variant="background" className={ isGrid diff --git a/apps/web/src/components/editor/editor-header.tsx b/apps/web/src/components/editor/editor-header.tsx index e70bda37..2dea73e2 100644 --- a/apps/web/src/components/editor/editor-header.tsx +++ b/apps/web/src/components/editor/editor-header.tsx @@ -19,7 +19,7 @@ import { ThemeToggle } from "../theme-toggle"; import { DEFAULT_LOGO_URL, SOCIAL_LINKS } from "@/constants/site-constants"; import { toast } from "sonner"; 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 { ShortcutsDialog } from "./dialogs/shortcuts-dialog"; import Image from "next/image"; @@ -118,32 +118,30 @@ function ProjectDropdown() { /> </Button> </DropdownMenuTrigger> - <DropdownMenuContent align="start" className="z-100 w-52"> + <DropdownMenuContent align="start" className="z-100 w-44"> <DropdownMenuItem - className="flex items-center gap-1.5" onClick={handleExit} disabled={isExiting} + icon={<HugeiconsIcon icon={Logout05Icon} />} > - <HugeiconsIcon icon={ArrowLeft02Icon} className="size-4" /> Exit project </DropdownMenuItem> - <DropdownMenuSeparator /> <DropdownMenuItem - className="flex items-center gap-1.5" onClick={() => setOpenDialog("shortcuts")} + icon={<HugeiconsIcon icon={CommandIcon} />} > - <HugeiconsIcon icon={CommandIcon} className="size-4" /> - Keyboard shortcuts + Shortcuts </DropdownMenuItem> - <DropdownMenuItem asChild> + + <DropdownMenuSeparator /> + + <DropdownMenuItem asChild icon={<FaDiscord className="!size-4" />}> <Link href={SOCIAL_LINKS.discord} target="_blank" rel="noopener noreferrer" - className="flex items-center gap-1.5" > - <FaDiscord className="size-4" /> Discord </Link> </DropdownMenuItem> diff --git a/apps/web/src/components/editor/export-button.tsx b/apps/web/src/components/editor/export-button.tsx index f39c18e2..bc44e650 100644 --- a/apps/web/src/components/editor/export-button.tsx +++ b/apps/web/src/components/editor/export-button.tsx @@ -23,7 +23,11 @@ import { type ExportQuality, type ExportResult, } 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 { DEFAULT_EXPORT_OPTIONS } from "@/constants/export-constants"; @@ -162,78 +166,83 @@ function ExportPopover({ {!isExporting && ( <> <div className="flex flex-col"> - <PropertyGroup - title="Format" - defaultExpanded={false} - hasBorderTop={false} - > - <RadioGroup - value={format} - onValueChange={(value) => { - if (isExportFormat(value)) { - setFormat(value); - } - }} - > - <div className="flex items-center space-x-2"> - <RadioGroupItem value="mp4" id="mp4" /> - <Label htmlFor="mp4"> - MP4 (H.264) - Better compatibility - </Label> - </div> - <div className="flex items-center space-x-2"> - <RadioGroupItem value="webm" id="webm" /> - <Label htmlFor="webm"> - WebM (VP9) - Smaller file size - </Label> - </div> - </RadioGroup> - </PropertyGroup> + <Section collapsible defaultOpen={false} hasBorderTop={false}> + <SectionHeader title="Format" /> + <SectionContent> + <RadioGroup + value={format} + onValueChange={(value) => { + if (isExportFormat(value)) { + setFormat(value); + } + }} + > + <div className="flex items-center space-x-2"> + <RadioGroupItem value="mp4" id="mp4" /> + <Label htmlFor="mp4"> + MP4 (H.264) - Better compatibility + </Label> + </div> + <div className="flex items-center space-x-2"> + <RadioGroupItem value="webm" id="webm" /> + <Label htmlFor="webm"> + WebM (VP9) - Smaller file size + </Label> + </div> + </RadioGroup> + </SectionContent> + </Section> - <PropertyGroup title="Quality" defaultExpanded={false}> - <RadioGroup - value={quality} - 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> + <Section collapsible defaultOpen={false}> + <SectionHeader title="Quality" /> + <SectionContent> + <RadioGroup + value={quality} + 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"> - <RadioGroupItem value="very_high" id="very_high" /> - <Label htmlFor="very_high"> - Very High - Largest file size + <Checkbox + id="include-audio" + checked={includeAudio} + onCheckedChange={(checked) => + setIncludeAudio(!!checked) + } + /> + <Label htmlFor="include-audio"> + Include audio in export </Label> </div> - </RadioGroup> - </PropertyGroup> - - <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> + </SectionContent> + </Section> </div> <div className="p-3 pt-0"> diff --git a/apps/web/src/components/editor/panels/assets/draggable-item.tsx b/apps/web/src/components/editor/panels/assets/draggable-item.tsx index 517fdc92..be4176ce 100644 --- a/apps/web/src/components/editor/panels/assets/draggable-item.tsx +++ b/apps/web/src/components/editor/panels/assets/draggable-item.tsx @@ -163,7 +163,9 @@ export function DraggableItem({ <div className="size-6 flex-shrink-0 overflow-hidden rounded-[0.35rem]"> {preview} </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> </div> )} diff --git a/apps/web/src/components/editor/panels/assets/index.tsx b/apps/web/src/components/editor/panels/assets/index.tsx index e7bd3d4f..3600d00b 100644 --- a/apps/web/src/components/editor/panels/assets/index.tsx +++ b/apps/web/src/components/editor/panels/assets/index.tsx @@ -4,7 +4,7 @@ import { Separator } from "@/components/ui/separator"; import { type Tab, useAssetsPanelStore } from "@/stores/assets-panel-store"; import { TabBar } from "./tabbar"; import { Captions } from "./views/captions"; -import { MediaView } from "./views/media"; +import { MediaView } from "./views/assets"; import { SettingsView } from "./views/settings"; import { SoundsView } from "./views/sounds"; import { StickersView } from "./views/stickers"; diff --git a/apps/web/src/components/editor/panels/assets/tabbar.tsx b/apps/web/src/components/editor/panels/assets/tabbar.tsx index 31842306..61777a7c 100644 --- a/apps/web/src/components/editor/panels/assets/tabbar.tsx +++ b/apps/web/src/components/editor/panels/assets/tabbar.tsx @@ -61,7 +61,8 @@ export function TabBar() { aria-label={tab.label} className={cn( "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)} > diff --git a/apps/web/src/components/editor/panels/assets/views/media.tsx b/apps/web/src/components/editor/panels/assets/views/assets.tsx index 59395fd8..808961f1 100644 --- a/apps/web/src/components/editor/panels/assets/views/media.tsx +++ b/apps/web/src/components/editor/panels/assets/views/assets.tsx @@ -3,6 +3,7 @@ import Image from "next/image"; import { useMemo, useState } from "react"; import { toast } from "sonner"; +import { PanelView } from "@/components/editor/panels/assets/views/base-view"; import { MediaDragOverlay } from "@/components/editor/panels/assets/drag-overlay"; import { DraggableItem } from "@/components/editor/panels/assets/draggable-item"; import { Button } from "@/components/ui/button"; @@ -29,14 +30,9 @@ import { useEditor } from "@/hooks/use-editor"; import { useFileUpload } from "@/hooks/use-file-upload"; import { useRevealItem } from "@/hooks/use-reveal-item"; import { processMediaAssets } from "@/lib/media/processing"; -import { - buildImageElement, - buildUploadAudioElement, - buildVideoElement, -} from "@/lib/timeline/element-utils"; +import { buildElementFromMedia } from "@/lib/timeline/element-utils"; import { useAssetsPanelStore } from "@/stores/assets-panel-store"; import type { MediaAsset } from "@/types/assets"; -import type { CreateTimelineElement } from "@/types/timeline"; import { cn } from "@/utils/ui"; import { CloudUploadIcon, @@ -132,7 +128,15 @@ export function MediaView() { asset: MediaAsset; startTime: number; }): 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({ element, placement: { mode: "auto" }, @@ -194,171 +198,166 @@ export function MediaView() { const renderCompactPreview = (item: MediaAsset) => previewComponents.get(`compact-${item.id}`); - return ( - <> - <input {...fileInputProps} /> - - <div - className={`relative flex h-full flex-col gap-1 ${isDragOver ? "bg-accent/30" : ""}`} - {...dragProps} - > - <div className="bg-background h-12 px-4 pr-2 flex items-center justify-between border-b"> - <span className="text-muted-foreground text-sm">Assets</span> - <div className="flex items-center gap-0"> - <TooltipProvider> - <Tooltip> - <TooltipTrigger asChild> + 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="text" - onClick={() => - setMediaViewMode( - mediaViewMode === "grid" ? "list" : "grid", - ) - } + variant="ghost" disabled={isProcessing} className="items-center justify-center" > - {mediaViewMode === "grid" ? ( - <HugeiconsIcon icon={LeftToRightListDashIcon} /> - ) : ( - <HugeiconsIcon icon={GridViewIcon} /> - )} + <HugeiconsIcon icon={SortingOneNineIcon} /> </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="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> + </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> + ); - <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> + return ( + <> + <input {...fileInputProps} /> + + <PanelView + title="Assets" + actions={mediaActions} + className={isDragOver ? "bg-accent/30" : ""} + {...dragProps} + > + {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} + /> + )} + </PanelView> </> ); } @@ -636,40 +635,3 @@ function SortMenuItem({ </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}`); - } -} diff --git a/apps/web/src/components/editor/panels/assets/views/base-view.tsx b/apps/web/src/components/editor/panels/assets/views/base-view.tsx new file mode 100644 index 00000000..c613bc2a --- /dev/null +++ b/apps/web/src/components/editor/panels/assets/views/base-view.tsx @@ -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> + ); +} diff --git a/apps/web/src/components/editor/panels/assets/views/captions.tsx b/apps/web/src/components/editor/panels/assets/views/captions.tsx index daebf2c6..7a3b16bf 100644 --- a/apps/web/src/components/editor/panels/assets/views/captions.tsx +++ b/apps/web/src/components/editor/panels/assets/views/captions.tsx @@ -1,5 +1,5 @@ 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 { Select, SelectContent, @@ -108,10 +108,7 @@ export function Captions() { }; return ( - <BaseView - ref={containerRef} - className="flex h-full flex-col justify-between" - > + <PanelView title="Captions" ref={containerRef}> <div className="flex flex-col gap-3"> <Label>Language</Label> <Select @@ -148,6 +145,6 @@ export function Captions() { {isProcessing ? processingStep : "Generate transcript"} </Button> </div> - </BaseView> + </PanelView> ); } diff --git a/apps/web/src/components/editor/panels/assets/views/settings-legacy.tsx b/apps/web/src/components/editor/panels/assets/views/settings-legacy.tsx new file mode 100644 index 00000000..457c08a9 --- /dev/null +++ b/apps/web/src/components/editor/panels/assets/views/settings-legacy.tsx @@ -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> + ); +} diff --git a/apps/web/src/components/editor/panels/assets/views/settings.tsx b/apps/web/src/components/editor/panels/assets/views/settings.tsx index 26afe566..7e10ac9d 100644 --- a/apps/web/src/components/editor/panels/assets/views/settings.tsx +++ b/apps/web/src/components/editor/panels/assets/views/settings.tsx @@ -1,8 +1,6 @@ "use client"; -import Image from "next/image"; -import { memo, useCallback, useMemo } from "react"; -import { PanelBaseView as BaseView } from "@/components/editor/panels/panel-base-view"; +import { PanelView } from "@/components/editor/panels/assets/views/base-view"; import { Select, SelectContent, @@ -10,63 +8,54 @@ import { 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 { FPS_PRESETS } from "@/constants/project-constants"; 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/property-item"; -import { ColorPicker } from "@/components/ui/color-picker"; + Section, + SectionContent, + SectionHeader, +} from "@/components/editor/panels/properties/section"; +import { Label } from "@/components/ui/label"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { useState } from "react"; export function SettingsView() { - return <ProjectSettingsTabs />; -} + const [open, setOpen] = useState(false); -function ProjectSettingsTabs() { return ( - <BaseView - defaultTab="project-info" - tabs={[ - { - value: "project-info", - label: "Project info", - content: ( - <div className="p-5"> - <ProjectInfoView /> - </div> - ), - }, - { - value: "background", - label: "Background", - content: ( - <div className="flex h-full flex-col justify-between"> - <div className="flex-1"> - <BackgroundView /> + <PanelView contentClassName="px-0" hideHeader> + <div className="flex flex-col"> + <Section hasBorderTop={false}> + <SectionContent> + <ProjectInfoContent /> + </SectionContent> + </Section> + <Popover open={open} onOpenChange={setOpen}> + <Section className="cursor-pointer"> + <PopoverTrigger asChild> + <div> + <SectionHeader title="Background"> + <div className="size-4 rounded-sm bg-red-500" /> + </SectionHeader> </div> - </div> - ), - }, - ]} - className="flex h-full flex-col justify-between p-0" - /> + </PopoverTrigger> + </Section> + <PopoverContent> + <div className="size-4 rounded-sm bg-red-500" /> + </PopoverContent> + </Popover> + </div> + </PanelView> ); } -function ProjectInfoView() { +function ProjectInfoContent() { const editor = useEditor(); const activeProject = editor.project.getActive(); const { canvasPresets } = useEditorStore(); @@ -124,232 +113,55 @@ function ProjectInfoView() { 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} + <div className="flex flex-col gap-2"> + <Label>Name</Label> + <span className="leading-none text-sm"> + {activeProject.metadata.name} </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 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-col gap-2"> + <Label>Aspect ratio</Label> + <Select + value={selectedPresetValue} + onValueChange={(value) => handleAspectRatioChange({ value })} > - <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> - ))} + <SelectTrigger className="w-fit"> + <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> + </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> ); } diff --git a/apps/web/src/components/editor/panels/assets/views/sounds.tsx b/apps/web/src/components/editor/panels/assets/views/sounds.tsx index 30c70a83..b1c57c3d 100644 --- a/apps/web/src/components/editor/panels/assets/views/sounds.tsx +++ b/apps/web/src/components/editor/panels/assets/views/sounds.tsx @@ -235,7 +235,7 @@ function SoundEffectsView() { <div className="flex items-center gap-3"> <Input placeholder="Search sound effects" - className="bg-accent w-full" + className="w-full" containerClassName="w-full" value={searchQuery} onChange={({ currentTarget }) => diff --git a/apps/web/src/components/editor/panels/assets/views/stickers.tsx b/apps/web/src/components/editor/panels/assets/views/stickers.tsx index 68ba91d2..671effec 100644 --- a/apps/web/src/components/editor/panels/assets/views/stickers.tsx +++ b/apps/web/src/components/editor/panels/assets/views/stickers.tsx @@ -4,144 +4,104 @@ import Image from "next/image"; import type { CSSProperties } from "react"; import { useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; +import { PanelView } from "@/components/editor/panels/assets/views/base-view"; 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 { InputWithBack } from "@/components/ui/input-with-back"; -import { ScrollArea } from "@/components/ui/scroll-area"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; -import { STICKER_CATEGORIES } from "@/constants/stickers-constants"; -import { useInfiniteScroll } from "@/hooks/use-infinite-scroll"; import { - buildIconSvgUrl, - getIconSvgUrl, - ICONIFY_HOSTS, - POPULAR_COLLECTIONS, -} from "@/lib/iconify-api"; + resolveStickerId, + type StickerItem as StickerData, +} from "@/lib/stickers"; import { useStickersStore } from "@/stores/stickers-store"; -import type { StickerCategory } from "@/types/stickers"; import { cn } from "@/utils/ui"; import { - ArrowRightIcon, HappyIcon, ClockIcon, - LayoutGridIcon, MultiplicationSignIcon, - SparklesIcon, - HashtagIcon, + Search01Icon, } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { Spinner } from "@/components/ui/spinner"; - -function isStickerCategory(value: string): value is StickerCategory { - return STICKER_CATEGORIES.includes(value as StickerCategory); -} +import { + Select, + 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() { const { selectedCategory, setSelectedCategory } = useStickersStore(); return ( - <BaseView - value={selectedCategory} - onValueChange={(v) => { - if (isStickerCategory(v)) { - setSelectedCategory({ category: v }); - } - }} - tabs={[ - { - value: "all", - label: "All", - icon: <HugeiconsIcon icon={LayoutGridIcon} className="size-3" />, - content: <StickersContentView category="all" />, - }, - { - value: "general", - label: "Icons", - icon: <HugeiconsIcon icon={SparklesIcon} className="size-3" />, - content: <StickersContentView category="general" />, - }, - { - value: "brands", - label: "Brands", - icon: <HugeiconsIcon icon={HashtagIcon} className="size-3" />, - content: <StickersContentView category="brands" />, - }, - { - value: "emoji", - label: "Emoji", - icon: <HugeiconsIcon icon={HappyIcon} className="size-3" />, - content: <StickersContentView category="emoji" />, - }, - ]} - className="flex h-full flex-col overflow-hidden p-0" - /> + <PanelView + title="Stickers" + actions={ + <div className="flex items-center"> + <Select + value={selectedCategory} + onValueChange={(value: StickerCategory) => + setSelectedCategory({ category: value }) + } + > + <SelectTrigger variant="outline" size="sm" className="mr-1.5"> + <SelectValue placeholder="All" /> + </SelectTrigger> + <SelectContent> + {Object.entries(STICKER_CATEGORIES).map(([category, label]) => ( + <SelectItem key={category} value={category}> + {label} + </SelectItem> + ))} + </SelectContent> + </Select> + + <Button variant="ghost" size="icon"> + <HugeiconsIcon icon={Search01Icon} className="!size-3.5" /> + </Button> + + <Button variant="ghost" size="icon"> + <OcSlidersVerticalIcon className="!size-3.5" /> + </Button> + </div> + } + > + <StickersContentView /> + </PanelView> ); } function StickerGrid({ - icons, - onAdd, - addingSticker, - capSize = false, + items, + shouldCapSize = false, }: { - icons: string[]; - onAdd: (iconName: string) => void; - addingSticker: string | null; - capSize?: boolean; + items: StickerData[]; + shouldCapSize?: boolean; }) { const gridStyle: CSSProperties & { "--sticker-min": string; "--sticker-max"?: string; } = { - gridTemplateColumns: capSize + gridTemplateColumns: shouldCapSize ? "repeat(auto-fill, minmax(var(--sticker-min, 96px), var(--sticker-max, 160px)))" : "repeat(auto-fit, minmax(var(--sticker-min, 96px), 1fr))", "--sticker-min": "96px", - ...(capSize ? { "--sticker-max": "160px" } : {}), + ...(shouldCapSize ? { "--sticker-max": "160px" } : {}), }; return ( <div className="grid gap-2" style={gridStyle}> - {icons.map((iconName) => ( - <StickerItem - 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 })} - /> + {items.map((item) => ( + <StickerItem key={item.id} item={item} shouldCapSize={shouldCapSize} /> ))} </div> ); @@ -162,372 +122,125 @@ function EmptyView({ message }: { message: string }) { ); } -function StickersContentView({ category }: { category: StickerCategory }) { +function StickersContentView() { const { searchQuery, - selectedCollection, viewMode, - collections, - currentCollection, searchResults, recentStickers, - isLoadingCollections, - isLoadingCollection, isSearching, - setSearchQuery, - setSelectedCollection, - loadCollections, - searchStickers, - addStickerToTimeline, clearRecentStickers, - setSelectedCategory, - addingSticker, } = useStickersStore(); - const [localSearchQuery, setLocalSearchQuery] = useState(searchQuery); - const [collectionsToShow, setCollectionsToShow] = useState(20); - const [showCollectionItems, setShowCollectionItems] = useState(false); - - 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 = - POPULAR_COLLECTIONS[category as keyof typeof POPULAR_COLLECTIONS]; - if (!collectionList) return []; - - return collectionList - .map((c) => { - const collection = collections[c.prefix]; - return collection - ? { - prefix: c.prefix, - 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 () => clearTimeout(timer); - }, [localSearchQuery, searchQuery, searchStickers, setSearchQuery]); - - const handleAddSticker = async (iconName: string) => { - try { - await addStickerToTimeline({ iconName }); - } catch (error) { - console.error("Failed to add sticker:", error); - toast.error("Failed to add sticker to timeline"); - } - }; - - const iconsToDisplay = useMemo(() => { + const itemsToDisplay = useMemo(() => { 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 searchResults.items; } return []; - }, [viewMode, searchResults, currentCollection]); + }, [viewMode, searchResults]); - const isInCollection = viewMode === "collection" && !!selectedCollection; - - useEffect(() => { - if (isInCollection) { - setShowCollectionItems(false); - const timer = setTimeout(() => setShowCollectionItems(true), 350); - return () => clearTimeout(timer); - } else { - setShowCollectionItems(false); + const recentStickerItems = useMemo(() => { + const items: StickerData[] = []; + for (const stickerId of recentStickers) { + const recentStickerItem = toRecentStickerItem({ stickerId }); + if (recentStickerItem) { + items.push(recentStickerItem); + } } - }, [isInCollection]); + return items; + }, [recentStickers]); 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 + <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" /> - </> - ) : 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> - )} + </button> + </TooltipTrigger> + <TooltipContent> + <p>Clear recent stickers</p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + </div> + <StickerGrid items={recentStickerItems.slice(0, 12)} shouldCapSize /> + </div> + )} - {category === "all" && filteredCollections.length > 0 && ( - <div className="h-full"> - <CollectionGrid - collections={filteredCollections.slice( - 0, - collectionsToShow, - )} - onSelectCollection={({ prefix }) => - setSelectedCollection({ collection: prefix }) - } - /> - </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?.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> - )} - </div> - </ScrollArea> - </div> + <StickerGrid items={itemsToDisplay} shouldCapSize /> + </div> + ) : searchQuery ? ( + <EmptyView message={`No stickers found for "${searchQuery}"`} /> + ) : null} + </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; + item: StickerData; + shouldCapSize?: boolean; } -function StickerItem({ - iconName, - onAdd, - isAdding, - capSize = false, -}: StickerItemProps) { - const [imageError, setImageError] = useState(false); - const [hostIndex, setHostIndex] = useState(0); +function StickerItem({ item, shouldCapSize = false }: StickerItemProps) { + const { addingSticker, addStickerToTimeline } = useStickersStore(); + const isAdding = addingSticker === item.id; + const [hasImageError, setHasImageError] = useState(false); useEffect(() => { - if (!iconName) { + if (!item.id) { return; } - setImageError(false); - setHostIndex(0); - }, [iconName]); + setHasImageError(false); + }, [item.id]); - const displayName = iconName.split(":")[1] || iconName; - const collectionPrefix = iconName.split(":")[0]; + const displayName = item.name; + + const handleAdd = async () => { + try { + await addStickerToTimeline({ + stickerId: item.id, + name: item.name, + }); + } catch (error) { + console.error("Failed to add sticker:", error); + toast.error("Failed to add sticker to timeline"); + } + }; - const preview = imageError ? ( + const preview = hasImageError ? ( <div className="flex size-full items-center justify-center p-2"> <span className="text-muted-foreground text-center text-xs break-all"> {displayName} @@ -536,21 +249,13 @@ function StickerItem({ ) : ( <div className="flex size-full items-center justify-center p-4"> <Image - src={ - hostIndex === 0 - ? getIconSvgUrl(iconName, { width: 64, height: 64 }) - : buildIconSvgUrl( - ICONIFY_HOSTS[Math.min(hostIndex, ICONIFY_HOSTS.length - 1)], - iconName, - { width: 64, height: 64 }, - ) - } + src={item.previewUrl} alt={displayName} width={64} height={64} className="size-full object-contain" style={ - capSize + shouldCapSize ? { maxWidth: "var(--sticker-max, 160px)", maxHeight: "var(--sticker-max, 160px)", @@ -558,12 +263,7 @@ function StickerItem({ : undefined } onError={() => { - const next = hostIndex + 1; - if (next < ICONIFY_HOSTS.length) { - setHostIndex(next); - } else { - setImageError(true); - } + setHasImageError(true); }} loading="lazy" unoptimized @@ -572,44 +272,63 @@ function StickerItem({ ); return ( - <Tooltip> - <TooltipTrigger asChild> - <div - className={cn( - "relative", - isAdding && "pointer-events-none opacity-50", - )} - > - <DraggableItem - name={displayName} - preview={preview} - dragData={{ - id: iconName, - type: "sticker", - name: displayName, - iconName, - }} - onAddToTimeline={() => onAdd(iconName)} - aspectRatio={1} - shouldShowLabel={false} - isRounded={true} - variant="card" - 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> - </TooltipTrigger> - <TooltipContent> - <div className="space-y-1"> - <p className="font-medium">{displayName}</p> - <p className="text-muted-foreground text-xs">{collectionPrefix}</p> + <div + className={cn("relative", isAdding && "pointer-events-none opacity-50")} + > + <DraggableItem + name={displayName} + preview={preview} + dragData={{ + id: item.id, + type: "sticker", + name: displayName, + stickerId: item.id, + }} + onAddToTimeline={handleAdd} + aspectRatio={1} + shouldShowLabel={false} + isRounded + variant="card" + 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> - </TooltipContent> - </Tooltip> + )} + </div> + ); +} + +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; + } +} diff --git a/apps/web/src/components/editor/panels/assets/views/text.tsx b/apps/web/src/components/editor/panels/assets/views/text.tsx index 6360c363..3673acc3 100644 --- a/apps/web/src/components/editor/panels/assets/views/text.tsx +++ b/apps/web/src/components/editor/panels/assets/views/text.tsx @@ -1,5 +1,5 @@ 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 { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants"; import { buildTextElement } from "@/lib/timeline/element-utils"; @@ -23,7 +23,7 @@ export function TextView() { }; return ( - <BaseView> + <PanelView title="Text"> <DraggableItem name="Default text" preview={ @@ -41,6 +41,6 @@ export function TextView() { onAddToTimeline={handleAddToTimeline} shouldShowLabel={false} /> - </BaseView> + </PanelView> ); } diff --git a/apps/web/src/components/editor/panels/panel-base-view.tsx b/apps/web/src/components/editor/panels/panel-base-view.tsx deleted file mode 100644 index dd6151d3..00000000 --- a/apps/web/src/components/editor/panels/panel-base-view.tsx +++ /dev/null @@ -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> - ); -} diff --git a/apps/web/src/components/editor/panels/preview/bookmark-note-overlay.tsx b/apps/web/src/components/editor/panels/preview/bookmark-note-overlay.tsx new file mode 100644 index 00000000..4ace25ac --- /dev/null +++ b/apps/web/src/components/editor/panels/preview/bookmark-note-overlay.tsx @@ -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> + ); +} diff --git a/apps/web/src/components/editor/panels/preview/context-menu.tsx b/apps/web/src/components/editor/panels/preview/context-menu.tsx new file mode 100644 index 00000000..d5cf535a --- /dev/null +++ b/apps/web/src/components/editor/panels/preview/context-menu.tsx @@ -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> + ); +} diff --git a/apps/web/src/components/editor/panels/preview/index.tsx b/apps/web/src/components/editor/panels/preview/index.tsx index 902547bd..90cb11a3 100644 --- a/apps/web/src/components/editor/panels/preview/index.tsx +++ b/apps/web/src/components/editor/panels/preview/index.tsx @@ -9,18 +9,13 @@ import { useFullscreen } from "@/hooks/use-fullscreen"; import { CanvasRenderer } from "@/services/renderer/canvas-renderer"; import type { RootNode } from "@/services/renderer/nodes/root-node"; 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 { EditableTimecode } from "@/components/editable-timecode"; -import { invokeAction } from "@/lib/actions"; -import { Button } from "@/components/ui/button"; -import { - FullScreenIcon, - PauseIcon, - PlayIcon, -} from "@hugeicons/core-free-icons"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { cn } from "@/utils/ui"; +import { BookmarkNoteOverlay } from "./bookmark-note-overlay"; +import { ContextMenu, ContextMenuTrigger } from "@/components/ui/context-menu"; +import { usePreviewStore } from "@/stores/preview-store"; +import { PreviewContextMenu } from "./context-menu"; +import { PreviewToolbar } from "./toolbar"; function usePreviewSize() { 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() { const editor = useEditor(); const tracks = editor.timeline.getTracks(); @@ -58,98 +77,24 @@ function RenderTreeController() { return null; } -export function PreviewPanel() { - 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, +function PreviewCanvas({ onToggleFullscreen, + containerRef, }: { - isFullscreen: boolean; 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 containerRef = useRef<HTMLDivElement>(null); + const outerContainerRef = useRef<HTMLDivElement>(null); + const canvasBoundsRef = useRef<HTMLDivElement>(null); const lastFrameRef = useRef(-1); const lastSceneRef = useRef<RootNode | null>(null); const renderingRef = useRef(false); const { width: nativeWidth, height: nativeHeight } = usePreviewSize(); - const containerSize = useContainerSize({ containerRef }); + const containerSize = useContainerSize({ containerRef: outerContainerRef }); const editor = useEditor(); const activeProject = editor.project.getActive(); + const { overlays } = usePreviewStore(); const renderer = useMemo(() => { return new CanvasRenderer({ @@ -224,24 +169,42 @@ function PreviewCanvas() { return ( <div - ref={containerRef} - className="relative flex h-full w-full items-center justify-center" + ref={outerContainerRef} + className="relative flex size-full items-center justify-center" > - <canvas - ref={canvasRef} - width={nativeWidth} - height={nativeHeight} - className="block border" - style={{ - width: displaySize.width, - height: displaySize.height, - background: - activeProject.settings.background.type === "blur" - ? "transparent" - : activeProject?.settings.background.color, - }} - /> - <PreviewInteractionOverlay canvasRef={canvasRef} /> + <ContextMenu> + <ContextMenuTrigger asChild> + <div + ref={canvasBoundsRef} + className="relative" + style={{ width: displaySize.width, height: displaySize.height }} + > + <canvas + ref={canvasRef} + width={nativeWidth} + height={nativeHeight} + className="block border" + style={{ + width: displaySize.width, + 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> ); } diff --git a/apps/web/src/components/editor/layout-guide-overlay.tsx b/apps/web/src/components/editor/panels/preview/layout-guide-overlay.tsx index 0f02da1c..44efb4c8 100644 --- a/apps/web/src/components/editor/layout-guide-overlay.tsx +++ b/apps/web/src/components/editor/panels/preview/layout-guide-overlay.tsx @@ -1,27 +1,27 @@ -"use client";
-
-import { useEditorStore } from "@/stores/editor-store";
-import Image from "next/image";
-
-function TikTokGuide() {
- return (
- <div className="pointer-events-none absolute inset-0">
- <Image
- src="/platform-guides/tiktok-blueprint.png"
- alt="TikTok layout guide"
- className="absolute inset-0 size-full object-contain"
- draggable={false}
- fill
- />
- </div>
- );
-}
-
-export function LayoutGuideOverlay() {
- const { layoutGuide } = useEditorStore();
-
- if (layoutGuide.platform === null) return null;
- if (layoutGuide.platform === "tiktok") return <TikTokGuide />;
-
- return null;
-}
+"use client"; + +import { usePreviewStore } from "@/stores/preview-store"; +import Image from "next/image"; + +function TikTokGuide() { + return ( + <div className="pointer-events-none absolute inset-0"> + <Image + src="/platform-guides/tiktok-blueprint.png" + alt="TikTok layout guide" + className="absolute inset-0 size-full object-contain" + draggable={false} + fill + /> + </div> + ); +} + +export function LayoutGuideOverlay() { + const { layoutGuide } = usePreviewStore(); + + if (layoutGuide.platform === null) return null; + if (layoutGuide.platform === "tiktok") return <TikTokGuide />; + + return null; +} diff --git a/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx b/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx index c13379f0..4c33a1a1 100644 --- a/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx +++ b/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx @@ -1,20 +1,54 @@ 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({ canvasRef, + containerRef, }: { canvasRef: React.RefObject<HTMLCanvasElement | null>; + containerRef: React.RefObject<HTMLDivElement | null>; }) { - const { onPointerDown, onPointerMove, onPointerUp } = - usePreviewInteraction({ canvasRef }); + const { + onPointerDown, + onPointerMove, + onPointerUp, + onDoubleClick, + snapLines, + editingText, + commitTextEdit, + cancelTextEdit, + } = usePreviewInteraction({ canvasRef }); return ( - <div - className={cn("absolute inset-0 pointer-events-auto")} - onPointerDown={onPointerDown} - onPointerMove={onPointerMove} - onPointerUp={onPointerUp} - /> + <div className="absolute inset-0"> + {/* biome-ignore lint/a11y/noStaticElementInteractions: canvas overlay, pointer-only interaction */} + <div + className="absolute inset-0 pointer-events-auto" + 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> ); } diff --git a/apps/web/src/components/editor/panels/preview/snap-guides.tsx b/apps/web/src/components/editor/panels/preview/snap-guides.tsx new file mode 100644 index 00000000..4aa714a1 --- /dev/null +++ b/apps/web/src/components/editor/panels/preview/snap-guides.tsx @@ -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> + ); +} diff --git a/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx b/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx new file mode 100644 index 00000000..265e972d --- /dev/null +++ b/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx @@ -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> + ); +} diff --git a/apps/web/src/components/editor/panels/preview/toolbar.tsx b/apps/web/src/components/editor/panels/preview/toolbar.tsx new file mode 100644 index 00000000..7f5cd9bc --- /dev/null +++ b/apps/web/src/components/editor/panels/preview/toolbar.tsx @@ -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> + ); +} diff --git a/apps/web/src/components/editor/panels/preview/transform-handles.tsx b/apps/web/src/components/editor/panels/preview/transform-handles.tsx new file mode 100644 index 00000000..976c3019 --- /dev/null +++ b/apps/web/src/components/editor/panels/preview/transform-handles.tsx @@ -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> + ); +} diff --git a/apps/web/src/components/editor/panels/properties/empty-view.tsx b/apps/web/src/components/editor/panels/properties/empty-view.tsx index 8ccf07ee..c5576dda 100644 --- a/apps/web/src/components/editor/panels/properties/empty-view.tsx +++ b/apps/web/src/components/editor/panels/properties/empty-view.tsx @@ -17,4 +17,4 @@ export function EmptyView() { </div> </div> ); -}
\ No newline at end of file +} diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-property-draft.ts b/apps/web/src/components/editor/panels/properties/hooks/use-property-draft.ts new file mode 100644 index 00000000..395666cb --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/hooks/use-property-draft.ts @@ -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(); + }, + }; +} diff --git a/apps/web/src/components/editor/panels/properties/property-item.tsx b/apps/web/src/components/editor/panels/properties/property-item.tsx deleted file mode 100644 index a682097a..00000000 --- a/apps/web/src/components/editor/panels/properties/property-item.tsx +++ /dev/null @@ -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> - ); -} diff --git a/apps/web/src/components/editor/panels/properties/section.tsx b/apps/web/src/components/editor/panels/properties/section.tsx new file mode 100644 index 00000000..0b45065f --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/section.tsx @@ -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>; +} diff --git a/apps/web/src/components/editor/panels/properties/sections/blending.tsx b/apps/web/src/components/editor/panels/properties/sections/blending.tsx new file mode 100644 index 00000000..430efa25 --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/sections/blending.tsx @@ -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> + ); +} diff --git a/apps/web/src/components/editor/panels/properties/sections/index.tsx b/apps/web/src/components/editor/panels/properties/sections/index.tsx new file mode 100644 index 00000000..8be58f39 --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/sections/index.tsx @@ -0,0 +1,2 @@ +export * from "./transform"; +export * from "./blending"; diff --git a/apps/web/src/components/editor/panels/properties/sections/transform.tsx b/apps/web/src/components/editor/panels/properties/sections/transform.tsx new file mode 100644 index 00000000..17a54b4c --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/sections/transform.tsx @@ -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> + ); +} diff --git a/apps/web/src/components/editor/panels/properties/text-properties.tsx b/apps/web/src/components/editor/panels/properties/text-properties.tsx index 22e50e4e..cfca52dc 100644 --- a/apps/web/src/components/editor/panels/properties/text-properties.tsx +++ b/apps/web/src/components/editor/panels/properties/text-properties.tsx @@ -1,24 +1,31 @@ import { Textarea } from "@/components/ui/textarea"; import { FontPicker } from "@/components/ui/font-picker"; -import type { FontFamily } from "@/constants/font-constants"; import type { TextElement } from "@/types/timeline"; import { Slider } from "@/components/ui/slider"; -import { Input } from "@/components/ui/input"; -import { Button } from "@/components/ui/button"; -import { useReducer, useRef } from "react"; -import { PanelBaseView } from "@/components/editor/panels/panel-base-view"; -import { - PropertyGroup, - PropertyItem, - PropertyItemLabel, - PropertyItemValue, -} from "./property-item"; +import { NumberField } from "@/components/ui/number-field"; +import { useRef } from "react"; +import { Section, SectionContent, SectionHeader } from "./section"; import { ColorPicker } from "@/components/ui/color-picker"; import { uppercase } from "@/utils/string"; import { clamp } from "@/utils/math"; import { useEditor } from "@/hooks/use-editor"; 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({ element, @@ -27,624 +34,229 @@ export function TextProperties({ element: TextElement; trackId: string; }) { - 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 - ? fontSizeDraft.current - : element.fontSize.toString(); - const opacityDisplay = isEditingOpacity.current - ? opacityDraft.current - : 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(); + 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> + ); +} - 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, - }); - } - }; +function ContentSection({ + element, + trackId, +}: { + element: TextElement; + trackId: string; +}) { + const editor = useEditor(); - 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({ + const content = usePropertyDraft({ + displayValue: element.content, + parse: (input) => input, + onPreview: (value) => + editor.timeline.previewElements({ updates: [ - { - trackId, - elementId: element.id, - updates: { fontSize: initialFontSizeRef.current }, - }, + { trackId, elementId: element.id, updates: { content: value } }, ], - pushHistory: false, - }); - editor.timeline.updateElements({ - updates: [{ trackId, elementId: element.id, updates: { fontSize } }], - pushHistory: true, - }); - initialFontSizeRef.current = null; - } - isEditingFontSize.current = false; - fontSizeDraft.current = ""; - forceRender(); - }; + }), + onCommit: () => editor.timeline.commitPreview(), + }); - 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, - }); - } - }; + return ( + <Section collapsible sectionKey="text:content" hasBorderTop={false}> + <SectionHeader title="Content" /> + <SectionContent> + <Textarea + placeholder="Name" + value={content.displayValue} + className="min-h-20" + onFocus={content.onFocus} + onChange={content.onChange} + onBlur={content.onBlur} + /> + </SectionContent> + </Section> + ); +} - 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(); - }; +function FontSection({ + element, + trackId, +}: { + element: TextElement; + trackId: string; +}) { + const editor = useEditor(); - 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({ + 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: { backgroundColor: color }, - }, + { trackId, elementId: element.id, updates: { fontSize: value } }, ], - }); - } - }; - - 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; - } - }; + }), + onCommit: () => editor.timeline.commitPreview(), + }); return ( - <div className="flex h-full flex-col" ref={containerRef}> - <PanelBaseView className="p-0"> - <PropertyGroup title="Content" hasBorderTop={false} collapsible={false}> - <Textarea - placeholder="Name" - value={contentDisplay} - className="bg-accent min-h-20" - onFocus={() => { - isEditingContent.current = true; - contentDraft.current = element.content; - initialContentRef.current = element.content; - forceRender(); - }} - onChange={(event) => { - contentDraft.current = event.target.value; - forceRender(); - if (initialContentRef.current === null) { - initialContentRef.current = element.content; - } + <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({ updates: [ { trackId, 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({ + }) + } + /> + <Select value={element.fontWeight}> + <SelectTrigger className="w-full" icon={<OcFontWeightIcon />}> + <SelectValue placeholder="Select weight" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="100">Thin</SelectItem> + <SelectItem value="200">Extra Light</SelectItem> + <SelectItem value="300">Light</SelectItem> + <SelectItem value="400">Normal</SelectItem> + <SelectItem value="500">Medium</SelectItem> + <SelectItem value="600">Semi Bold</SelectItem> + <SelectItem value="700">Bold</SelectItem> + <SelectItem value="800">Extra Bold</SelectItem> + <SelectItem value="900">Black</SelectItem> + </SelectContent> + </Select> + + <Label>Font size</Label> + <div className="flex items-center gap-2"> + <Slider + value={[element.fontSize]} + min={MIN_FONT_SIZE} + max={MAX_FONT_SIZE} + step={1} + onValueChange={([value]) => + editor.timeline.previewElements({ updates: [ { trackId, elementId: element.id, - updates: { content: initialContentRef.current }, + updates: { fontSize: value }, }, ], - pushHistory: false, - }); + }) + } + onValueCommit={() => editor.timeline.commitPreview()} + className="w-full" + /> + <NumberField + className="w-18 shrink-0" + value={fontSize.displayValue} + min={MIN_FONT_SIZE} + max={MAX_FONT_SIZE} + onFocus={fontSize.onFocus} + onChange={fontSize.onChange} + onBlur={fontSize.onBlur} + onReset={() => editor.timeline.updateElements({ updates: [ { trackId, elementId: element.id, - updates: { content: finalContent }, + updates: { + fontSize: DEFAULT_TEXT_ELEMENT.fontSize, + }, }, ], - pushHistory: true, - }); - initialContentRef.current = null; + }) } - isEditingContent.current = false; - contentDraft.current = ""; - forceRender(); + isDefault={element.fontSize === DEFAULT_TEXT_ELEMENT.fontSize} + /> + </div> + </div> + </SectionContent> + </Section> + ); +} + +function ColorSection({ + element, + trackId, +}: { + element: TextElement; + trackId: string; +}) { + const editor = useEditor(); + const lastSelectedColor = useRef(DEFAULT_COLOR); + + return ( + <Section collapsible sectionKey="text:color" hasBorderBottom={false}> + <SectionHeader title="Color" /> + <SectionContent> + <div className="flex flex-col gap-6"> + <ColorPicker + value={uppercase({ + string: (element.color || "FFFFFF").replace("#", ""), + })} + onChange={(color) => + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { color: `#${color}` }, + }, + ], + }) + } + onChangeEnd={() => editor.timeline.commitPreview()} + /> + <ColorPicker + value={ + element.backgroundColor === "transparent" + ? lastSelectedColor.current.replace("#", "") + : element.backgroundColor.replace("#", "") + } + onChange={(color) => { + const hexColor = `#${color}`; + if (color !== "transparent") { + lastSelectedColor.current = hexColor; + } + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { backgroundColor: hexColor }, + }, + ], + }); }} + onChangeEnd={() => editor.timeline.commitPreview()} + className={ + element.backgroundColor === "transparent" + ? "pointer-events-none opacity-50" + : "" + } /> - </PropertyGroup> - <PropertyGroup title="Typography" collapsible={false}> - <div className="space-y-6"> - <PropertyItem direction="column"> - <PropertyItemLabel>Font</PropertyItemLabel> - <PropertyItemValue> - <FontPicker - defaultValue={element.fontFamily} - onValueChange={(value: FontFamily) => - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { fontFamily: value }, - }, - ], - }) - } - /> - </PropertyItemValue> - </PropertyItem> - <PropertyItem direction="column"> - <PropertyItemLabel>Style</PropertyItemLabel> - <PropertyItemValue> - <div className="flex items-center gap-2"> - <Button - variant={ - element.fontWeight === "bold" ? "default" : "outline" - } - size="sm" - onClick={() => - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { - fontWeight: - element.fontWeight === "bold" - ? "normal" - : "bold", - }, - }, - ], - }) - } - className="h-8 px-3 font-bold" - > - B - </Button> - <Button - variant={ - element.fontStyle === "italic" ? "default" : "outline" - } - size="sm" - onClick={() => - editor.timeline.updateElements({ - updates: [ - { - 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> - </PropertyGroup> - <PropertyGroup title="Appearance" collapsible={false}> - <div className="space-y-6"> - <PropertyItem direction="column"> - <PropertyItemLabel>Color</PropertyItemLabel> - <PropertyItemValue> - <ColorPicker - value={uppercase({ - string: (element.color || "FFFFFF").replace("#", ""), - })} - onChange={(color) => { - if (initialColorRef.current === null) { - initialColorRef.current = element.color || "#FFFFFF"; - } - if (initialColorRef.current !== null) { - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { color: `#${color}` }, - }, - ], - pushHistory: false, - }); - } else { - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { color: `#${color}` }, - }, - ], - }); - } - }} - onChangeEnd={(color) => { - if (initialColorRef.current !== null) { - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { color: initialColorRef.current }, - }, - ], - pushHistory: false, - }); - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { color: `#${color}` }, - }, - ], - pushHistory: true, - }); - initialColorRef.current = null; - } - }} - containerRef={containerRef} - /> - </PropertyItemValue> - </PropertyItem> - <PropertyItem direction="column"> - <PropertyItemLabel>Opacity</PropertyItemLabel> - <PropertyItemValue> - <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> + </div> + </SectionContent> + </Section> ); } diff --git a/apps/web/src/components/editor/panels/timeline/bookmarks.tsx b/apps/web/src/components/editor/panels/timeline/bookmarks.tsx index cfcb1e23..64824f9a 100644 --- a/apps/web/src/components/editor/panels/timeline/bookmarks.tsx +++ b/apps/web/src/components/editor/panels/timeline/bookmarks.tsx @@ -1,21 +1,75 @@ +"use client"; + +import { useEffect, useState } from "react"; +import type { EditorCore } from "@/core"; 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 { Bookmark02Icon } from "@hugeicons/core-free-icons"; +import { + ArrowTurnBackwardIcon, + Delete02Icon, +} from "@hugeicons/core-free-icons"; 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 { zoomLevel: number; dynamicTimelineWidth: number; - handleWheel: (e: React.WheelEvent) => void; - handleTimelineContentClick: (e: React.MouseEvent) => void; - handleRulerTrackingMouseDown: (e: React.MouseEvent) => void; - handleRulerMouseDown: (e: React.MouseEvent) => void; + dragState: BookmarkDragState; + onBookmarkMouseDown: (params: { + event: React.MouseEvent; + 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({ zoomLevel, dynamicTimelineWidth, + dragState, + onBookmarkMouseDown, handleWheel, handleTimelineContentClick, handleRulerTrackingMouseDown, @@ -34,17 +88,23 @@ export function TimelineBookmarksRow({ aria-label="Timeline ruler" type="button" onWheel={handleWheel} - onClick={handleTimelineContentClick} + onClick={(event) => { + if (!event.currentTarget.contains(event.target as Node)) return; + handleTimelineContentClick(event); + }} onMouseDown={(event) => { + if (!event.currentTarget.contains(event.target as Node)) return; handleRulerMouseDown(event); handleRulerTrackingMouseDown(event); }} > - {activeScene.bookmarks.map((time: number) => ( + {activeScene.bookmarks.map((bookmark) => ( <TimelineBookmark - key={`bookmark-row-${time}`} - time={time} + key={`bookmark-${bookmark.time}`} + bookmark={bookmark} zoomLevel={zoomLevel} + dragState={dragState} + onBookmarkMouseDown={onBookmarkMouseDown} /> ))} </button> @@ -52,60 +112,305 @@ export function TimelineBookmarksRow({ ); } -export function TimelineBookmark({ - time, +function TimelineBookmark({ + bookmark, zoomLevel, + dragState, + onBookmarkMouseDown, }: { - time: number; + bookmark: Bookmark; zoomLevel: number; + dragState: BookmarkDragState; + onBookmarkMouseDown: (params: { + event: React.MouseEvent; + bookmark: Bookmark; + }) => void; }) { const editor = useEditor(); - const activeProject = editor.project.getActive(); const duration = editor.timeline.getTotalDuration(); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + const isDragging = + dragState.isDragging && + dragState.bookmarkTime !== null && + Math.abs(dragState.bookmarkTime - bookmark.time) < BOOKMARK_TIME_EPSILON; + + 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 handleBookmarkActivate = ({ - event, - }: { - event: - | React.MouseEvent<HTMLButtonElement> - | React.KeyboardEvent<HTMLButtonElement>; - }) => { + const handleMouseDown = (event: React.MouseEvent) => { + onBookmarkMouseDown({ event, bookmark }); + event.preventDefault(); event.stopPropagation(); - const framesPerSecond = activeProject?.settings.fps ?? 30; - const snappedTime = getSnappedSeekTime({ - rawTime: time, - duration, - fps: framesPerSecond, - }); - editor.playback.seek({ time: snappedTime }); }; return ( - <button - className="absolute top-0 h-10 w-0.5 cursor-pointer border-0 bg-transparent p-0" - style={{ - left: `${time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`, - }} - aria-label={`Seek to bookmark at ${time}s`} - type="button" - onMouseDown={(event) => { - event.preventDefault(); - event.stopPropagation(); - }} - onClick={(event) => handleBookmarkActivate({ event })} - onKeyDown={(event) => { - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - handleBookmarkActivate({ event }); - }} - > - <div className="text-primary absolute top-[-1px] left-[-5px]"> - <HugeiconsIcon - icon={Bookmark02Icon} - aria-hidden="true" - className="fill-primary size-3" + <Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}> + <PopoverAnchor asChild> + <button + className={`absolute top-0 h-full min-w-0.5 border-0 bg-transparent p-0 ${isDragging ? "cursor-grabbing" : "cursor-grab"}`} + style={{ + left: `${bookmarkLeft}px`, + width: `${bookmarkWidth}px`, + }} + aria-label={`Bookmark at ${time.toFixed(1)}s`} + type="button" + onMouseDown={handleMouseDown} + onClick={handleClick} + onKeyDown={handleKeyDown} + > + {hasDurationRange ? ( + <div + className="absolute opacity-30" + style={{ + top: 1.5, + height: BOOKMARK_MARKER_HEIGHT_PX - 2.5, + left: BOOKMARK_HALF_WIDTH_PX, + width: durationWidth, + 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)} /> + </PopoverContent> + </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> - </button> + <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> + </> ); } diff --git a/apps/web/src/components/editor/panels/timeline/index.tsx b/apps/web/src/components/editor/panels/timeline/index.tsx index 94edc2d0..7e6ff326 100644 --- a/apps/web/src/components/editor/panels/timeline/index.tsx +++ b/apps/web/src/components/editor/panels/timeline/index.tsx @@ -47,6 +47,8 @@ import { useTimelineSeek } from "@/hooks/timeline/use-timeline-seek"; import { useTimelineDragDrop } from "@/hooks/timeline/use-timeline-drag-drop"; import { TimelineRuler } from "./timeline-ruler"; 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 { useEditor } from "@/hooks/use-editor"; import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead"; @@ -126,6 +128,17 @@ export function Timeline() { onSnapPointChange: handleSnapPointChange, }); + const { + dragState: bookmarkDragState, + handleBookmarkMouseDown, + lastMouseXRef: bookmarkLastMouseXRef, + } = useBookmarkDrag({ + zoomLevel, + scrollRef: tracksScrollRef, + snappingEnabled, + onSnapPointChange: handleSnapPointChange, + }); + const { handleRulerMouseDown: handlePlayheadRulerMouseDown } = useTimelinePlayhead({ zoomLevel, @@ -168,10 +181,18 @@ export function Timeline() { containerWidth, ); + useEdgeAutoScroll({ + isActive: bookmarkDragState.isDragging, + getMouseClientX: () => bookmarkLastMouseXRef.current, + rulerScrollRef: tracksScrollRef, + tracksScrollRef, + contentWidth: dynamicTimelineWidth, + }); + const showSnapIndicator = snappingEnabled && currentSnapPoint !== null && - (dragState.isDragging || isResizing); + (dragState.isDragging || bookmarkDragState.isDragging || isResizing); const { handleTracksMouseDown, @@ -351,7 +372,7 @@ export function Timeline() { > <div ref={timelineHeaderRef} - className="bg-background sticky top-0 z-30 flex flex-col" + className="bg-background sticky top-0 flex flex-col" > <TimelineRuler zoomLevel={zoomLevel} @@ -366,6 +387,8 @@ export function Timeline() { <TimelineBookmarksRow zoomLevel={zoomLevel} dynamicTimelineWidth={dynamicTimelineWidth} + dragState={bookmarkDragState} + onBookmarkMouseDown={handleBookmarkMouseDown} handleWheel={handleWheel} handleTimelineContentClick={handleRulerClick} handleRulerTrackingMouseDown={handleRulerMouseDown} @@ -433,7 +456,7 @@ export function Timeline() { /> </div> </ContextMenuTrigger> - <ContextMenuContent className="z-200 w-40"> + <ContextMenuContent className="w-40"> <ContextMenuItem icon={<HugeiconsIcon icon={TaskAdd02Icon} />} onClick={(e) => { diff --git a/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx b/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx index 914edccb..e70f0cd7 100644 --- a/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx +++ b/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx @@ -38,7 +38,7 @@ export function SnapIndicator({ return ( <div - className="pointer-events-none absolute z-90" + className="pointer-events-none absolute" style={{ left: `${leftPosition}px`, top: topPosition, diff --git a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx index 59ce88da..628855f6 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx @@ -29,6 +29,7 @@ import type { MediaAsset } from "@/types/assets"; import { mediaSupportsAudio } from "@/lib/media/media-utils"; import { getActionDefinition, type TAction, invokeAction } from "@/lib/actions"; import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; +import { resolveStickerId } from "@/lib/stickers"; import Image from "next/image"; import { ScissorIcon, @@ -139,9 +140,7 @@ export function TimelineElement({ <ContextMenu> <ContextMenuTrigger asChild> <div - className={`absolute top-0 h-full select-none ${ - isBeingDragged ? "z-30" : "z-10" - }`} + className={`absolute top-0 h-full select-none`} style={{ left: `${elementLeft}px`, width: `${elementWidth}px`, @@ -155,7 +154,6 @@ export function TimelineElement({ element={element} track={track} isSelected={isSelected} - isBeingDragged={isBeingDragged} hasAudio={hasAudio} isMuted={isMuted} mediaAssets={mediaAssets} @@ -165,7 +163,7 @@ export function TimelineElement({ /> </div> </ContextMenuTrigger> - <ContextMenuContent className="z-200 w-64"> + <ContextMenuContent className="w-64"> <ActionMenuItem action="split" icon={<HugeiconsIcon icon={ScissorIcon} />} @@ -227,7 +225,6 @@ function ElementInner({ element, track, isSelected, - isBeingDragged, hasAudio, isMuted, mediaAssets, @@ -238,7 +235,6 @@ function ElementInner({ element: TimelineElementType; track: TimelineTrack; isSelected: boolean; - isBeingDragged: boolean; hasAudio: boolean; isMuted: boolean; mediaAssets: MediaAsset[]; @@ -248,7 +244,7 @@ function ElementInner({ element: TimelineElementType, ) => void; handleResizeStart: (params: { - e: React.MouseEvent; + event: React.MouseEvent; elementId: string; side: "left" | "right"; }) => void; @@ -259,7 +255,7 @@ function ElementInner({ { type: track.type, }, - )} ${isBeingDragged ? "z-30" : "z-10"} ${canElementBeHidden(element) && element.hidden ? "opacity-50" : ""}`} + )} ${canElementBeHidden(element) && element.hidden ? "opacity-50" : ""}`} > <button type="button" @@ -321,7 +317,7 @@ function ResizeHandle({ side: "left" | "right"; elementId: string; handleResizeStart: (params: { - e: React.MouseEvent; + event: React.MouseEvent; elementId: string; side: "left" | "right"; }) => void; @@ -330,8 +326,8 @@ function ResizeHandle({ return ( <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"}`} - onMouseDown={(e) => handleResizeStart({ e, elementId, side })} + 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={(event) => handleResizeStart({ event, elementId, side })} aria-label={`${isLeft ? "Left" : "Right"} resize handle`} > <div className="bg-foreground h-[1.5rem] w-[0.2rem] rounded-full" /> @@ -362,7 +358,10 @@ function ElementContent({ return ( <div className="flex size-full items-center gap-2 pl-2"> <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} className="size-5 shrink-0" width={20} diff --git a/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx b/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx index 8af35dc9..ebb2a37e 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx @@ -72,20 +72,22 @@ export function TimelinePlayhead({ aria-valuemax={duration} aria-valuenow={playheadPosition} tabIndex={0} - className="pointer-events-auto absolute z-60" + className="pointer-events-none absolute z-5" style={{ left: `${leftPosition}px`, top: 0, height: `${totalHeight}px`, width: "2px", }} - onMouseDown={handlePlayheadMouseDown} 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 - 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"}`} + <button + 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> ); diff --git a/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx b/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx index 0b02c63f..03ce88eb 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx @@ -171,7 +171,7 @@ function SceneSelector() { <SplitButtonLeft>{currentScene?.name || "No Scene"}</SplitButtonLeft> <SplitButtonSeparator /> <ScenesView> - <SplitButtonRight onClick={() => {}} type="button"> + <SplitButtonRight onClick={() => {}}> <HugeiconsIcon icon={Layers01Icon} className="size-4" /> </SplitButtonRight> </ScenesView> @@ -222,7 +222,6 @@ function ToolbarRightSection({ <Button variant="text" size="icon" - type="button" onClick={() => onZoom({ direction: "out" })} > <HugeiconsIcon icon={SearchMinusIcon} /> @@ -240,7 +239,6 @@ function ToolbarRightSection({ <Button variant="text" size="icon" - type="button" onClick={() => onZoom({ direction: "in" })} > <HugeiconsIcon icon={SearchAddIcon} /> @@ -269,7 +267,6 @@ function ToolbarButton({ <Button variant={isActive ? "secondary" : "text"} size="icon" - type="button" onClick={(event) => onClick({ event })} className={cn( "rounded-sm", diff --git a/apps/web/src/components/gitHub-contribute-section.tsx b/apps/web/src/components/gitHub-contribute-section.tsx index f96def4a..153a662a 100644 --- a/apps/web/src/components/gitHub-contribute-section.tsx +++ b/apps/web/src/components/gitHub-contribute-section.tsx @@ -1,10 +1,7 @@ import { Button } from "./ui/button"; import Link from "next/link"; import { SOCIAL_LINKS } from "@/constants/site-constants"; -import { - GithubIcon, - Link04Icon, -} from "@hugeicons/core-free-icons"; +import { GithubIcon, Link04Icon } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; export function GitHubContributeSection({ diff --git a/apps/web/src/components/providers/editor-provider.tsx b/apps/web/src/components/providers/editor-provider.tsx index b1b5ab3a..746cfd16 100644 --- a/apps/web/src/components/providers/editor-provider.tsx +++ b/apps/web/src/components/providers/editor-provider.tsx @@ -9,6 +9,7 @@ import { useKeybindingDisabler, } from "@/hooks/use-keybindings"; import { useEditorActions } from "@/hooks/actions/use-editor-actions"; +import { prefetchFontAtlas } from "@/lib/fonts/google-fonts"; interface EditorProviderProps { projectId: string; @@ -42,6 +43,7 @@ export function EditorProvider({ projectId, children }: EditorProviderProps) { if (cancelled) return; setIsLoading(false); + prefetchFontAtlas(); } catch (err) { if (cancelled) return; diff --git a/apps/web/src/components/theme-toggle.tsx b/apps/web/src/components/theme-toggle.tsx index b6e8d274..6fd84fa3 100644 --- a/apps/web/src/components/theme-toggle.tsx +++ b/apps/web/src/components/theme-toggle.tsx @@ -1,36 +1,39 @@ -"use client";
-
-import { Button } from "./ui/button";
-import { useTheme } from "next-themes";
-import { cn } from "@/utils/ui";
-import { Sun03Icon } from "@hugeicons/core-free-icons";
-import { HugeiconsIcon } from "@hugeicons/react";
-
-interface ThemeToggleProps {
- className?: string;
- iconClassName?: string;
- onToggle?: (e: React.MouseEvent<HTMLButtonElement>) => void;
-}
-
-export function ThemeToggle({
- className,
- iconClassName,
- onToggle,
-}: ThemeToggleProps) {
- const { theme, setTheme } = useTheme();
-
- return (
- <Button
- size="icon"
- variant="ghost"
- className={cn("size-8", className)}
- onClick={(e) => {
- setTheme(theme === "dark" ? "light" : "dark");
- onToggle?.(e);
- }}
- >
- <HugeiconsIcon icon={Sun03Icon} className={cn("!size-[1.1rem]", iconClassName)} />
- <span className="sr-only">{theme === "dark" ? "Light" : "Dark"}</span>
- </Button>
- );
-}
+"use client"; + +import { Button } from "./ui/button"; +import { useTheme } from "next-themes"; +import { cn } from "@/utils/ui"; +import { Sun03Icon } from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; + +interface ThemeToggleProps { + className?: string; + iconClassName?: string; + onToggle?: (e: React.MouseEvent<HTMLButtonElement>) => void; +} + +export function ThemeToggle({ + className, + iconClassName, + onToggle, +}: ThemeToggleProps) { + const { theme, setTheme } = useTheme(); + + return ( + <Button + size="icon" + variant="ghost" + className={cn("size-8", className)} + onClick={(e) => { + setTheme(theme === "dark" ? "light" : "dark"); + onToggle?.(e); + }} + > + <HugeiconsIcon + icon={Sun03Icon} + className={cn("!size-[1.1rem]", iconClassName)} + /> + <span className="sr-only">{theme === "dark" ? "Light" : "Dark"}</span> + </Button> + ); +} diff --git a/apps/web/src/components/ui/alert.tsx b/apps/web/src/components/ui/alert.tsx index e941ca43..dcc190d3 100644 --- a/apps/web/src/components/ui/alert.tsx +++ b/apps/web/src/components/ui/alert.tsx @@ -1,6 +1,5 @@ import * as React from "react"; import { cva, type VariantProps } from "class-variance-authority"; -import { AlertTriangle } from "lucide-react"; import { cn } from "@/utils/ui"; import { HugeiconsIcon } from "@hugeicons/react"; import { Alert02Icon } from "@hugeicons/core-free-icons"; @@ -32,7 +31,10 @@ const Alert = React.forwardRef< {...props} > {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} </div> @@ -45,7 +47,10 @@ const AlertTitle = React.forwardRef< >(({ className, ...props }, ref) => ( <h5 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} /> )); diff --git a/apps/web/src/components/ui/avatar.tsx b/apps/web/src/components/ui/avatar.tsx index 0c2fb4c4..a3b59233 100644 --- a/apps/web/src/components/ui/avatar.tsx +++ b/apps/web/src/components/ui/avatar.tsx @@ -38,10 +38,10 @@ const AvatarFallback = React.forwardRef< >(({ className, ...props }, ref) => ( <AvatarPrimitive.Fallback ref={ref} - className={cn( - "bg-muted flex size-full items-center justify-center rounded-full", - className, - )} + className={cn( + "bg-muted flex size-full items-center justify-center rounded-full", + className, + )} {...props} /> )); diff --git a/apps/web/src/components/ui/breadcrumb.tsx b/apps/web/src/components/ui/breadcrumb.tsx index e95f2965..927cca89 100644 --- a/apps/web/src/components/ui/breadcrumb.tsx +++ b/apps/web/src/components/ui/breadcrumb.tsx @@ -1,108 +1,99 @@ -import * as React from "react"; -import { Slot as SlotPrimitive } from "radix-ui"; import { ChevronRight, MoreHorizontal } from "lucide-react"; +import { Slot } from "radix-ui"; import { cn } from "@/utils/ui"; -const Breadcrumb = React.forwardRef< - HTMLElement, - React.ComponentPropsWithoutRef<"nav"> & { - separator?: React.ReactNode; - } ->(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />); -Breadcrumb.displayName = "Breadcrumb"; +function Breadcrumb({ ...props }: React.ComponentProps<"nav">) { + return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />; +} -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"; +function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) { + return ( + <ol + data-slot="breadcrumb-list" + className={cn( + "text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5", + className, + )} + {...props} + /> + ); +} -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"; +function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) { + return ( + <li + data-slot="breadcrumb-item" + className={cn("inline-flex items-center gap-1.5", className)} + {...props} + /> + ); +} -const BreadcrumbLink = React.forwardRef< - HTMLAnchorElement, - React.ComponentPropsWithoutRef<"a"> & { - asChild?: boolean; - } ->(({ asChild, className, ...props }, ref) => { - const Comp = asChild ? SlotPrimitive.Slot : "a"; +function BreadcrumbLink({ + asChild, + className, + ...props +}: React.ComponentProps<"a"> & { + asChild?: boolean; +}) { + const Comp = asChild ? Slot.Root : "a"; return ( <Comp - ref={ref} - className={cn("hover:text-foreground", className)} + data-slot="breadcrumb-link" + className={cn("hover:text-foreground transition-colors", className)} {...props} /> ); -}); -BreadcrumbLink.displayName = "BreadcrumbLink"; +} -const BreadcrumbPage = React.forwardRef< - HTMLSpanElement, - React.ComponentPropsWithoutRef<"span"> ->(({ className, ...props }, ref) => ( - <span - ref={ref} - role="link" - aria-disabled="true" - aria-current="page" - className={cn("text-foreground font-normal", className)} - {...props} - /> -)); -BreadcrumbPage.displayName = "BreadcrumbPage"; +function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) { + return ( + <span + data-slot="breadcrumb-page" + aria-current="page" + className={cn("text-foreground font-normal", className)} + {...props} + /> + ); +} -const BreadcrumbSeparator = ({ +function BreadcrumbSeparator({ children, className, ...props -}: React.ComponentProps<"li">) => ( - <li - role="presentation" - aria-hidden="true" - className={cn("[&>svg]:size-3.5", className)} - {...props} - > - {children ?? <ChevronRight />} - </li> -); -BreadcrumbSeparator.displayName = "BreadcrumbSeparator"; +}: React.ComponentProps<"li">) { + return ( + <li + data-slot="breadcrumb-separator" + role="presentation" + aria-hidden="true" + className={cn("[&>svg]:size-3.5", className)} + {...props} + > + {children ?? <ChevronRight />} + </li> + ); +} -const BreadcrumbEllipsis = ({ +function BreadcrumbEllipsis({ className, ...props -}: React.ComponentProps<"span">) => ( - <span - role="presentation" - aria-hidden="true" - className={cn("flex size-9 items-center justify-center", className)} - {...props} - > - <MoreHorizontal className="size-4" /> - <span className="sr-only">More</span> - </span> -); -BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"; +}: React.ComponentProps<"span">) { + return ( + <span + data-slot="breadcrumb-ellipsis" + role="presentation" + aria-hidden="true" + className={cn("flex size-9 items-center justify-center", className)} + {...props} + > + <MoreHorizontal className="size-4" /> + <span className="sr-only">More</span> + </span> + ); +} export { Breadcrumb, diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx index 2fc2c2e6..fba3109f 100644 --- a/apps/web/src/components/ui/button.tsx +++ b/apps/web/src/components/ui/button.tsx @@ -1,7 +1,6 @@ import * as React from "react"; import { Slot as SlotPrimitive } from "radix-ui"; import { cva, type VariantProps } from "class-variance-authority"; - import { cn } from "@/utils/ui"; const buttonVariants = cva( @@ -9,18 +8,14 @@ const buttonVariants = cva( { variants: { variant: { - default: - "bg-primary text-primary-foreground hover:bg-primary/90", - background: - "bg-background text-foreground hover:bg-background/90", - foreground: - "bg-foreground text-background hover:bg-foreground/90", + default: "bg-primary text-primary-foreground hover:bg-primary/90", + background: "bg-background text-foreground hover:bg-background/90", + foreground: "bg-foreground text-background hover:bg-foreground/90", destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/80", "destructive-foreground": "border bg-background hover:bg-destructive/15 text-destructive", - outline: - "border border-border bg-transparent hover:bg-accent/50", + outline: "border border-border bg-transparent hover:bg-accent/50", secondary: "bg-secondary text-secondary-foreground border border-secondary-border", 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", }, size: { - default: "h-9.5 px-4 py-2", - sm: "h-8 p-1 px-2 text-xs rounded-sm", + default: "h-9 px-4 py-2", + sm: "h-7.5 p-1 px-2.5 text-sm rounded-sm", lg: "h-10 p-5 px-6", - icon: "size-7", + icon: "size-7 rounded-sm", text: "p-0", }, }, @@ -54,8 +49,11 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( const effectiveSize = size ?? (variant === "text" ? "text" : "default"); return ( <Comp - className={cn(buttonVariants({ variant, size: effectiveSize, className }))} + className={cn( + buttonVariants({ variant, size: effectiveSize, className }), + )} ref={ref} + type="button" {...props} /> ); diff --git a/apps/web/src/components/ui/calendar.tsx b/apps/web/src/components/ui/calendar.tsx index a0664cc3..3d088b27 100644 --- a/apps/web/src/components/ui/calendar.tsx +++ b/apps/web/src/components/ui/calendar.tsx @@ -60,8 +60,8 @@ function Calendar({ ...classNames, }} components={{ - IconLeft: ({ ...props }) => <ChevronLeft className="size-4" />, - IconRight: ({ ...props }) => <ChevronRight className="size-4" />, + IconLeft: () => <ChevronLeft className="size-4" />, + IconRight: () => <ChevronRight className="size-4" />, }} {...props} /> diff --git a/apps/web/src/components/ui/carousel.tsx b/apps/web/src/components/ui/carousel.tsx deleted file mode 100644 index 9018b9ea..00000000 --- a/apps/web/src/components/ui/carousel.tsx +++ /dev/null @@ -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, -}; diff --git a/apps/web/src/components/ui/chart.tsx b/apps/web/src/components/ui/chart.tsx deleted file mode 100644 index 2bd04b20..00000000 --- a/apps/web/src/components/ui/chart.tsx +++ /dev/null @@ -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, -}; diff --git a/apps/web/src/components/ui/color-picker.tsx b/apps/web/src/components/ui/color-picker.tsx index 0ea1a14d..d58b6144 100644 --- a/apps/web/src/components/ui/color-picker.tsx +++ b/apps/web/src/components/ui/color-picker.tsx @@ -1,133 +1,80 @@ import { forwardRef, useEffect, useRef, useState } from "react"; -import { createPortal } from "react-dom"; import { cn } from "@/utils/ui"; 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 { value?: string; onChange?: (value: string) => void; onChangeEnd?: (value: string) => void; 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>( - ({ className, value = "FFFFFF", onChange, onChangeEnd, containerRef, ...props }, ref) => { - const [isOpen, setIsOpen] = useState(false); - const [isDragging, setIsDragging] = useState<"saturation" | "hue" | null>( - null, - ); - const [pickerPosition, setPickerPosition] = useState({ - right: 0, - bottom: 0, - }); + ({ className, value = "FFFFFF", onChange, onChangeEnd, ...props }, ref) => { + const [isDragging, setIsDragging] = useState< + "saturation" | "hue" | "opacity" | null + >(null); const [internalHue, setInternalHue] = useState(0); const [inputValue, setInputValue] = useState(value); + const [colorFormat, setColorFormat] = useState<ColorFormat>("hex"); - const pickerRef = useRef<HTMLDivElement>(null); const saturationRef = 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 [h, s, v] = hexToHsv(value); - const displayHue = s > 0 ? h : internalHue; + const isEyeDropperSupported = + typeof window !== "undefined" && "EyeDropper" in window; - useEffect(() => { - setInputValue(value); - }, [value]); + const { rgb: rgbValue, alpha } = parseHexAlpha({ hex: value }); + const [h, s, v] = hexToHsv({ hex: rgbValue }); - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if ( - pickerRef.current && - !pickerRef.current.contains(event.target as Node) - ) { - setIsOpen(false); - } - }; - - if (isOpen) { - document.addEventListener("mousedown", handleClickOutside); - return () => - document.removeEventListener("mousedown", handleClickOutside); + const handleEyeDropper = async () => { + if (!isEyeDropperSupported || !EyeDropper) return; + try { + const dropper = new EyeDropper(); + const result = await dropper.open(); + const hex = result.sRGBHex.replace("#", "").toLowerCase(); + const finalHex = appendAlpha({ rgbHex: hex, alpha }); + onChange?.(finalHex); + onChangeEnd?.(finalHex); + } catch { + // user cancelled the picker } - }, [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(() => { const handleMouseMove = (e: MouseEvent) => { @@ -143,9 +90,10 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>( 0, Math.min(1, (e.clientY - rect.top) / rect.height), ); - const newS = x; - const newV = 1 - y; - const newHex = hsvToHex(displayHue, newS, newV); + const newHex = appendAlpha({ + rgbHex: hsvToHex({ h: displayHue, s: x, v: 1 - y }), + alpha, + }); latestDragColorRef.current = newHex; onChange?.(newHex); } @@ -159,11 +107,25 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>( const newH = x * 360; setInternalHue(newH); if (s > 0) { - const newHex = hsvToHex(newH, s, v); + const newHex = appendAlpha({ + rgbHex: hsvToHex({ h: newH, s, v }), + alpha, + }); latestDragColorRef.current = 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 = () => { @@ -182,7 +144,7 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>( document.removeEventListener("mouseup", handleMouseUp); }; } - }, [isDragging, displayHue, s, v, onChange]); + }, [isDragging, displayHue, s, v, alpha, rgbValue, onChange, onChangeEnd]); const handleSaturationMouseDown = (e: React.MouseEvent) => { e.preventDefault(); @@ -192,9 +154,10 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>( const rect = saturationElement.getBoundingClientRect(); 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 newS = x; - const newV = 1 - y; - const newHex = hsvToHex(displayHue, newS, newV); + const newHex = appendAlpha({ + rgbHex: hsvToHex({ h: displayHue, s: x, v: 1 - y }), + alpha, + }); latestDragColorRef.current = newHex; onChange?.(newHex); }; @@ -209,28 +172,83 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>( const newH = x * 360; setInternalHue(newH); if (s > 0) { - const newHex = hsvToHex(newH, s, v); + const newHex = appendAlpha({ + rgbHex: hsvToHex({ h: newH, s, v }), + alpha, + }); latestDragColorRef.current = 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 hex = e.target.value.replace("#", ""); - setInputValue(hex); + setInputValue( + 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 = () => { - onChange?.(inputValue); + commitInputValue(); }; const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { if (e.key === "Enter") { - onChange?.(inputValue); + commitInputValue(); 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 = { 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%)", }; + 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 ( - <div className="relative flex-1"> + <Popover> <div ref={ref} 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, )} {...props} > - <button - ref={triggerRef} - className="size-4.5 cursor-pointer border rounded-sm hover:ring-2 hover:ring-white/20" - style={{ backgroundColor: `#${value}` }} - type="button" - onClick={() => { - if (!isOpen && triggerRef.current && containerRef?.current) { - const containerRect = - containerRef.current.getBoundingClientRect(); - setPickerPosition({ - right: window.innerWidth - containerRect.left - 8, - bottom: window.innerHeight - containerRect.bottom, - }); - } - setIsOpen(!isOpen); - }} - /> + <PopoverTrigger asChild> + <button + className="size-4.5 cursor-pointer border rounded-sm hover:ring-1 hover:ring-foreground/20" + style={{ backgroundColor: `#${value}` }} + type="button" + /> + </PopoverTrigger> <div className="flex flex-1 items-center"> <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" containerClassName="w-full" value={inputValue} onChange={handleInputChange} onBlur={handleInputBlur} onKeyDown={handleInputKeyDown} + onPaste={handlePaste} /> </div> </div> + <PopoverContent + className="w-64 px-0 select-none flex flex-col gap-3 py-2" + side="left" + sideOffset={8} + onOpenAutoFocus={(event) => { + event.preventDefault(); + }} + onCloseAutoFocus={(event) => { + 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} + > + <ColorCircle + size="sm" + position={{ left: `${s * 100}%`, top: `${(1 - v) * 100}%` }} + color={`#${value}`} + /> + </button> - {isOpen && - createPortal( - <div - ref={pickerRef} - className="bg-popover border-border fixed z-50 rounded-lg border p-4 shadow-lg select-none" - style={{ - right: pickerPosition.right, - bottom: pickerPosition.bottom, - }} + <button + ref={hueRef} + className="relative h-4 w-full rounded-lg appearance-none border-0 bg-transparent p-0" + style={hueStyle} + type="button" + onMouseDown={handleHueMouseDown} > - <button - ref={saturationRef} - className="relative mb-3 h-32 w-48 cursor-crosshair appearance-none border-0 bg-transparent p-0" - style={saturationStyle} - type="button" - onMouseDown={handleSaturationMouseDown} - > - <ColorCircle - size="sm" - position={{ left: `${s * 100}%`, top: `${(1 - v) * 100}%` }} - color={`#${value}`} - /> - </button> + <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> - <button - ref={hueRef} - className="relative h-4 w-48 cursor-pointer rounded-lg appearance-none border-0 bg-transparent p-0" - style={hueStyle} - type="button" - onMouseDown={handleHueMouseDown} + <div className="flex items-center gap-2"> + <Select + value={colorFormat} + onValueChange={(value) => setColorFormat(value as ColorFormat)} > - <ColorCircle - size="md" - position={{ - left: `${(displayHue / 360) * 100}%`, - top: "50%", - }} - color={`#${value}`} - /> - </button> - </div>, - document.body, - )} - </div> + <SelectTrigger variant="outline" className="min-w-18 max-w-18"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="hex">HEX</SelectItem> + <SelectItem value="rgb">RGB</SelectItem> + <SelectItem value="hsl">HSL</SelectItem> + <SelectItem value="hsv">HSV</SelectItem> + </SelectContent> + </Select> + + <Input + 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"; position: { left: string; top: string }; - color: string; + color?: string; }) => ( <div className={`pointer-events-none absolute rounded-full border-3 border-white shadow-lg ${ diff --git a/apps/web/src/components/ui/command.tsx b/apps/web/src/components/ui/command.tsx deleted file mode 100644 index f5d96125..00000000 --- a/apps/web/src/components/ui/command.tsx +++ /dev/null @@ -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, -}; diff --git a/apps/web/src/components/ui/context-menu.tsx b/apps/web/src/components/ui/context-menu.tsx index 4af92764..a49d8a88 100644 --- a/apps/web/src/components/ui/context-menu.tsx +++ b/apps/web/src/components/ui/context-menu.tsx @@ -5,7 +5,11 @@ import { ContextMenu as ContextMenuPrimitive } from "radix-ui"; import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "@/utils/ui"; 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; @@ -20,12 +24,14 @@ const ContextMenuSub = ContextMenuPrimitive.Sub; const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup; 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: { variant: { - default: "focus:bg-accent/35 focus:text-accent-foreground [&_svg]:text-muted-foreground", - destructive: "text-destructive focus:bg-destructive/5 focus:text-destructive [&_svg]:text-destructive", + default: + "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: { @@ -41,22 +47,32 @@ const ContextMenuSubTrigger = React.forwardRef< variant?: VariantProps<typeof contextMenuItemVariants>["variant"]; icon?: React.ReactNode; } ->(({ className, inset, children, variant = "default", icon, ...props }, ref) => ( - <ContextMenuPrimitive.SubTrigger - ref={ref} - className={cn( - contextMenuItemVariants({ variant }), - "data-[state=open]:bg-accent data-[state=open]:text-accent-foreground", - inset && "pl-8", - className, - )} - {...props} - > - {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> -)); +>( + ( + { className, inset, children, variant = "default", icon, ...props }, + ref, + ) => ( + <ContextMenuPrimitive.SubTrigger + ref={ref} + className={cn( + contextMenuItemVariants({ variant }), + "data-[state=open]:bg-accent data-[state=open]:text-accent-foreground", + inset && "pl-8", + className, + )} + {...props} + > + {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; const ContextMenuSubContent = React.forwardRef< @@ -76,13 +92,15 @@ ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName; const ContextMenuContent = React.forwardRef< React.ElementRef<typeof ContextMenuPrimitive.Content>, - React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content> ->(({ className, ...props }, ref) => ( - <ContextMenuPrimitive.Portal> + React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content> & { + container?: HTMLElement | null; + } +>(({ className, container, ...props }, ref) => ( + <ContextMenuPrimitive.Portal container={container ?? undefined}> <ContextMenuPrimitive.Content ref={ref} 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, )} {...props} @@ -99,25 +117,46 @@ const ContextMenuItem = React.forwardRef< icon?: React.ReactNode; textRight?: string; } ->(({ className, inset, variant = "default", icon, children, textRight, ...props }, ref) => ( - <ContextMenuPrimitive.Item - ref={ref} - className={cn( - contextMenuItemVariants({ variant }), - inset && "pl-8", +>( + ( + { className, - )} - {...props} - > - {icon && <span className="[&_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> -)); + inset, + variant = "default", + icon, + children, + textRight, + ...props + }, + ref, + ) => { + const shouldInsetContent = inset || Boolean(icon); + + 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; const ContextMenuCheckboxItem = React.forwardRef< @@ -126,22 +165,33 @@ const ContextMenuCheckboxItem = React.forwardRef< variant?: VariantProps<typeof contextMenuItemVariants>["variant"]; icon?: React.ReactNode; } ->(({ className, children, checked, variant = "default", icon, ...props }, ref) => ( - <ContextMenuPrimitive.CheckboxItem - ref={ref} - className={cn(contextMenuItemVariants({ variant }), "pr-2 pl-8", className)} - checked={checked} - {...props} - > - <span className="absolute left-2 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> -)); +>( + ( + { className, children, checked, variant = "default", icon, ...props }, + ref, + ) => ( + <ContextMenuPrimitive.CheckboxItem + ref={ref} + className={cn( + contextMenuItemVariants({ variant }), + "pr-2 pl-8", + className, + )} + checked={checked} + {...props} + > + <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 = ContextMenuPrimitive.CheckboxItem.displayName; @@ -162,7 +212,9 @@ const ContextMenuRadioItem = React.forwardRef< <HugeiconsIcon icon={CircleIcon} className="size-2 fill-current" /> </ContextMenuPrimitive.ItemIndicator> </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} </ContextMenuPrimitive.RadioItem> )); @@ -184,7 +236,9 @@ const ContextMenuLabel = React.forwardRef< )} {...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} </ContextMenuPrimitive.Label> )); @@ -208,7 +262,10 @@ const ContextMenuShortcut = ({ }: React.HTMLAttributes<HTMLSpanElement>) => { return ( <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} /> ); diff --git a/apps/web/src/components/ui/drawer.tsx b/apps/web/src/components/ui/drawer.tsx deleted file mode 100644 index d5752d62..00000000 --- a/apps/web/src/components/ui/drawer.tsx +++ /dev/null @@ -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, -}; diff --git a/apps/web/src/components/ui/dropdown-menu.tsx b/apps/web/src/components/ui/dropdown-menu.tsx index d8a9f594..2d6541b8 100644 --- a/apps/web/src/components/ui/dropdown-menu.tsx +++ b/apps/web/src/components/ui/dropdown-menu.tsx @@ -20,7 +20,7 @@ const DropdownMenuSub = DropdownMenuPrimitive.Sub; const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; 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: { variant: { @@ -66,7 +66,7 @@ const DropdownMenuSubContent = React.forwardRef< <DropdownMenuPrimitive.SubContent ref={ref} 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, )} {...props} @@ -88,7 +88,7 @@ const DropdownMenuContent = React.forwardRef< e.preventDefault(); }} 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, )} {...props} @@ -101,19 +101,61 @@ const DropdownMenuItem = React.forwardRef< React.ElementRef<typeof DropdownMenuPrimitive.Item>, React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { inset?: boolean; + icon?: React.ReactNode; variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"]; } ->(({ className, inset, variant = "default", ...props }, ref) => ( - <DropdownMenuPrimitive.Item - ref={ref} - className={cn( - dropdownMenuItemVariants({ variant }), - inset && "pl-8", +>( + ( + { className, - )} - {...props} - /> -)); + inset, + 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; const DropdownMenuCheckboxItem = React.forwardRef< diff --git a/apps/web/src/components/ui/font-picker.tsx b/apps/web/src/components/ui/font-picker.tsx index 4b8e364a..d5d56255 100644 --- a/apps/web/src/components/ui/font-picker.tsx +++ b/apps/web/src/components/ui/font-picker.tsx @@ -1,16 +1,51 @@ +"use client"; + +import { + useState, + useMemo, + useRef, + useEffect, + useCallback, + type CSSProperties, +} from "react"; +import { List, type RowComponentProps } from "react-window"; import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { FONT_OPTIONS, type FontFamily } from "@/constants/font-constants"; + 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 { 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 { - defaultValue?: FontFamily; - onValueChange?: (value: FontFamily) => void; + defaultValue?: string; + onValueChange?: (value: string) => void; className?: string; } @@ -19,24 +54,256 @@ export function FontPicker({ onValueChange, className, }: 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 ( - <Select defaultValue={defaultValue} onValueChange={onValueChange}> - <SelectTrigger - className={cn("w-full", className)} + <Popover open={open} onOpenChange={setOpen}> + <PopoverTrigger + 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" /> - </SelectTrigger> - <SelectContent> - {FONT_OPTIONS.map((font) => ( - <SelectItem - key={font.value} - value={font.value} - style={{ fontFamily: font.value }} + <div className="flex min-w-0 items-center gap-1.5"> + <span className="text-muted-foreground [&_svg]:size-3.5 shrink-0"> + <HugeiconsIcon icon={TextFontIcon} /> + </span> + <span className="truncate" style={{ fontFamily: defaultValue }}> + {defaultValue ?? "Select a font"} + </span> + </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} - </SelectItem> - ))} - </SelectContent> - </Select> + <Upload className="!size-3.5" /> + Load local fonts + </Button> + </div> + </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> ); } diff --git a/apps/web/src/components/ui/input-otp.tsx b/apps/web/src/components/ui/input-otp.tsx deleted file mode 100644 index ec71f96a..00000000 --- a/apps/web/src/components/ui/input-otp.tsx +++ /dev/null @@ -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 }; diff --git a/apps/web/src/components/ui/input-with-back.tsx b/apps/web/src/components/ui/input-with-back.tsx index f8265779..1c22fd2e 100644 --- a/apps/web/src/components/ui/input-with-back.tsx +++ b/apps/web/src/components/ui/input-with-back.tsx @@ -1,88 +1,85 @@ -"use client";
-
-import { Button } from "@/components/ui/button";
-import { Input } from "@/components/ui/input";
-import { ArrowLeft, Search } from "lucide-react";
-import { motion } from "motion/react";
-import { useState, useEffect } from "react";
-
-interface InputWithBackProps {
- isExpanded: boolean;
- setIsExpanded: (isExpanded: boolean) => void;
- placeholder?: string;
- value?: string;
- onChange?: (value: string) => void;
- disableAnimation?: boolean;
-}
-
-export function InputWithBack({
- isExpanded,
- setIsExpanded,
- placeholder = "Search anything",
- value,
- onChange,
- disableAnimation = false,
-}: InputWithBackProps) {
- const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null);
- const [buttonOffset, setButtonOffset] = useState(-60);
-
- const smoothTransition = {
- duration: disableAnimation ? 0 : 0.35,
- ease: [0.25, 0.1, 0.25, 1] as const,
- };
-
- useEffect(() => {
- if (containerRef) {
- const rect = containerRef.getBoundingClientRect();
- setButtonOffset(-rect.left - 48);
- }
- }, [containerRef]);
-
- return (
- <div ref={setContainerRef} className="relative w-full">
- <motion.div
- className="absolute top-1/2 left-0 z-10 -translate-y-1/2 cursor-pointer hover:opacity-75"
- initial={{
- x: isExpanded ? 0 : buttonOffset,
- opacity: isExpanded ? 1 : 0.5,
- }}
- animate={{
- x: isExpanded ? 0 : buttonOffset,
- opacity: isExpanded ? 1 : 0.5,
- }}
- transition={smoothTransition}
- onClick={() => setIsExpanded(!isExpanded)}
- >
- <Button
- variant="outline"
- className="bg-accent !size-9 rounded-full"
- >
- <ArrowLeft />
- </Button>
- </motion.div>
- <div
- className="relative flex-1"
- style={{ marginLeft: "0px", paddingLeft: "0px" }}
- >
- <motion.div
- className="relative"
- initial={{
- marginLeft: isExpanded ? 50 : 0,
- }}
- animate={{
- marginLeft: isExpanded ? 50 : 0,
- }}
- transition={smoothTransition}
- >
- <Search className="text-muted-foreground absolute top-1/2 left-3 size-4 -translate-y-1/2" />
- <Input
- placeholder={placeholder}
- className="bg-accent w-full pl-9"
- value={value}
- onChange={(e) => onChange?.(e.target.value)}
- />
- </motion.div>
- </div>
- </div>
- );
-}
+"use client"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { ArrowLeft, Search } from "lucide-react"; +import { motion } from "motion/react"; +import { useState, useEffect } from "react"; + +interface InputWithBackProps { + isExpanded: boolean; + setIsExpanded: (isExpanded: boolean) => void; + placeholder?: string; + value?: string; + onChange?: (value: string) => void; + disableAnimation?: boolean; +} + +export function InputWithBack({ + isExpanded, + setIsExpanded, + placeholder = "Search anything", + value, + onChange, + disableAnimation = false, +}: InputWithBackProps) { + const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null); + const [buttonOffset, setButtonOffset] = useState(-60); + + const smoothTransition = { + duration: disableAnimation ? 0 : 0.35, + ease: [0.25, 0.1, 0.25, 1] as const, + }; + + useEffect(() => { + if (containerRef) { + const rect = containerRef.getBoundingClientRect(); + setButtonOffset(-rect.left - 48); + } + }, [containerRef]); + + return ( + <div ref={setContainerRef} className="relative w-full"> + <motion.div + className="absolute top-1/2 left-0 z-10 -translate-y-1/2 cursor-pointer hover:opacity-75" + initial={{ + x: isExpanded ? 0 : buttonOffset, + opacity: isExpanded ? 1 : 0.5, + }} + animate={{ + x: isExpanded ? 0 : buttonOffset, + opacity: isExpanded ? 1 : 0.5, + }} + transition={smoothTransition} + onClick={() => setIsExpanded(!isExpanded)} + > + <Button variant="outline" className="bg-accent !size-9 rounded-full"> + <ArrowLeft /> + </Button> + </motion.div> + <div + className="relative flex-1" + style={{ marginLeft: "0px", paddingLeft: "0px" }} + > + <motion.div + className="relative" + initial={{ + marginLeft: isExpanded ? 50 : 0, + }} + animate={{ + marginLeft: isExpanded ? 50 : 0, + }} + transition={smoothTransition} + > + <Search className="text-muted-foreground absolute top-1/2 left-3 size-4 -translate-y-1/2" /> + <Input + placeholder={placeholder} + className="bg-accent w-full pl-9" + value={value} + onChange={(e) => onChange?.(e.target.value)} + /> + </motion.div> + </div> + </div> + ); +} diff --git a/apps/web/src/components/ui/input.tsx b/apps/web/src/components/ui/input.tsx index 9be4bca2..762cc675 100644 --- a/apps/web/src/components/ui/input.tsx +++ b/apps/web/src/components/ui/input.tsx @@ -8,17 +8,18 @@ import { forwardRef, type ComponentProps } from "react"; import { useState } from "react"; 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: { variant: { - default: "selection:bg-primary selection:text-primary-foreground focus-visible:border-primary focus-visible:ring-primary/10", - destructive: "selection:bg-destructive selection:text-destructive-foreground focus-visible:border-destructive focus-visible:ring-destructive/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", }, size: { - default: - "h-9 px-3 py-1 text-base file:h-7 file:text-sm md:text-sm", - sm: "h-8 px-3 text-xs file:h-6 file:text-xs", + default: "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-sm file:h-6 file:text-xs", 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 && ( <Button - type="button" variant="text" size="icon" onMouseDown={(e) => { @@ -117,7 +117,6 @@ const Input = forwardRef<HTMLInputElement, InputProps>( )} {showPasswordToggle && ( <Button - type="button" variant="text" size="icon" onClick={() => onShowPasswordChange?.(!showPassword)} diff --git a/apps/web/src/components/ui/number-field.tsx b/apps/web/src/components/ui/number-field.tsx new file mode 100644 index 00000000..35cce81b --- /dev/null +++ b/apps/web/src/components/ui/number-field.tsx @@ -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 }; diff --git a/apps/web/src/components/ui/pagination.tsx b/apps/web/src/components/ui/pagination.tsx deleted file mode 100644 index 96eec969..00000000 --- a/apps/web/src/components/ui/pagination.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import * as React from "react"; -import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react"; - -import { cn } from "@/utils/ui"; -import { type ButtonProps, buttonVariants } from "./button"; - -const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => ( - <nav - role="navigation" - aria-label="pagination" - className={cn("mx-auto flex w-full justify-center", className)} - {...props} - /> -); -Pagination.displayName = "Pagination"; - -const PaginationContent = React.forwardRef< - HTMLUListElement, - React.ComponentProps<"ul"> ->(({ className, ...props }, ref) => ( - <ul - ref={ref} - className={cn("flex flex-row items-center gap-1", className)} - {...props} - /> -)); -PaginationContent.displayName = "PaginationContent"; - -const PaginationItem = React.forwardRef< - HTMLLIElement, - React.ComponentProps<"li"> ->(({ className, ...props }, ref) => ( - <li ref={ref} className={cn("", className)} {...props} /> -)); -PaginationItem.displayName = "PaginationItem"; - -type PaginationLinkProps = { - isActive?: boolean; -} & Pick<ButtonProps, "size"> & - React.ComponentProps<"a">; - -const PaginationLink = ({ - className, - isActive, - size = "icon", - ...props -}: PaginationLinkProps) => ( - <a - aria-current={isActive ? "page" : undefined} - className={cn( - buttonVariants({ - variant: isActive ? "outline" : "text", - size, - }), - className, - )} - {...props} - /> -); -PaginationLink.displayName = "PaginationLink"; - -const PaginationPrevious = ({ - className, - ...props -}: React.ComponentProps<typeof PaginationLink>) => ( - <PaginationLink - aria-label="Go to previous page" - size="default" - className={cn("gap-1 pl-2.5", className)} - {...props} - > - <ChevronLeft className="size-4" /> - <span>Previous</span> - </PaginationLink> -); -PaginationPrevious.displayName = "PaginationPrevious"; - -const PaginationNext = ({ - className, - ...props -}: React.ComponentProps<typeof PaginationLink>) => ( - <PaginationLink - aria-label="Go to next page" - size="default" - className={cn("gap-1 pr-2.5", className)} - {...props} - > - <span>Next</span> - <ChevronRight className="size-4" /> - </PaginationLink> -); -PaginationNext.displayName = "PaginationNext"; - -const PaginationEllipsis = ({ - className, - ...props -}: React.ComponentProps<"span">) => ( - <span - aria-hidden - className={cn("flex size-9 items-center justify-center", className)} - {...props} - > - <MoreHorizontal className="size-4" /> - <span className="sr-only">More pages</span> - </span> -); -PaginationEllipsis.displayName = "PaginationEllipsis"; - -export { - Pagination, - PaginationContent, - PaginationLink, - PaginationItem, - PaginationPrevious, - PaginationNext, - PaginationEllipsis, -}; diff --git a/apps/web/src/components/ui/phone-input.tsx b/apps/web/src/components/ui/phone-input.tsx deleted file mode 100644 index 48b002ca..00000000 --- a/apps/web/src/components/ui/phone-input.tsx +++ /dev/null @@ -1,167 +0,0 @@ -import * as React from "react"; -import { CheckIcon, ChevronsUpDown } from "lucide-react"; -import * as RPNInput from "react-phone-number-input"; -import flags from "react-phone-number-input/flags"; - -import { Button } from "./button"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from "./command"; -import { Input } from "./input"; -import { Popover, PopoverContent, PopoverTrigger } from "./popover"; -import { ScrollArea } from "./scroll-area"; -import { cn } from "@/utils/ui"; - -type PhoneInputProps = Omit< - React.ComponentProps<"input">, - "onChange" | "value" | "ref" -> & - Omit<RPNInput.Props<typeof RPNInput.default>, "onChange"> & { - onChange?: (value: RPNInput.Value) => void; - }; - -const PhoneInput: React.ForwardRefExoticComponent<PhoneInputProps> = - React.forwardRef<React.ElementRef<typeof RPNInput.default>, PhoneInputProps>( - ({ className, onChange, defaultCountry = "US", ...props }, ref) => { - return ( - <RPNInput.default - ref={ref} - className={cn("flex", className)} - flagComponent={FlagComponent} - countrySelectComponent={CountrySelect} - inputComponent={InputComponent} - defaultCountry={defaultCountry} - international - addInternationalOption - smartCaret={false} - /** - * Handles the onChange event. - * - * react-phone-number-input might trigger the onChange event as undefined - * when a valid phone number is not entered. To prevent this, - * the value is coerced to an empty string. - * - * @param {E164Number | undefined} value - The entered value - */ - onChange={(value) => onChange?.(value || ("" as RPNInput.Value))} - {...props} - /> - ); - }, - ); -PhoneInput.displayName = "PhoneInput"; - -const InputComponent = React.forwardRef< - HTMLInputElement, - React.ComponentProps<"input"> ->(({ className, size: _size, ...props }, ref) => ( - <Input - className={cn("rounded-s-none rounded-e-lg", className)} - {...props} - ref={ref} - /> -)); -InputComponent.displayName = "InputComponent"; - -type CountryEntry = { label: string; value: RPNInput.Country | undefined }; - -type CountrySelectProps = { - disabled?: boolean; - value: RPNInput.Country; - options: CountryEntry[]; - onChange: (country: RPNInput.Country) => void; -}; - -const CountrySelect = ({ - disabled, - value: selectedCountry, - options: countryList, - onChange, -}: CountrySelectProps) => { - return ( - <Popover> - <PopoverTrigger asChild> - <Button - type="button" - variant="outline" - className="flex gap-1 rounded-s-lg rounded-e-none border-r-0 px-3 focus:z-10" - disabled={disabled} - > - <FlagComponent - country={selectedCountry} - countryName={selectedCountry} - /> - <ChevronsUpDown - className={cn( - "-mr-2 opacity-50", - disabled ? "hidden" : "opacity-100", - )} - /> - </Button> - </PopoverTrigger> - <PopoverContent className="w-[300px] p-0"> - <Command> - <CommandInput placeholder="Search country..." /> - <CommandList> - <ScrollArea className="h-72"> - <CommandEmpty>No country found.</CommandEmpty> - <CommandGroup> - {countryList.map(({ value, label }) => - value ? ( - <CountrySelectOption - key={value} - country={value} - countryName={label} - selectedCountry={selectedCountry} - onChange={onChange} - /> - ) : null, - )} - </CommandGroup> - </ScrollArea> - </CommandList> - </Command> - </PopoverContent> - </Popover> - ); -}; - -interface CountrySelectOptionProps extends RPNInput.FlagProps { - selectedCountry: RPNInput.Country; - onChange: (country: RPNInput.Country) => void; -} - -const CountrySelectOption = ({ - country, - countryName, - selectedCountry, - onChange, -}: CountrySelectOptionProps) => { - return ( - <CommandItem className="gap-2" onSelect={() => onChange(country)}> - <FlagComponent country={country} countryName={countryName} /> - <span className="flex-1 text-sm">{countryName}</span> - <span className="text-foreground/50 text-sm">{`+${RPNInput.getCountryCallingCode(country)}`}</span> - <CheckIcon - className={`ml-auto size-4 ${country === selectedCountry ? "opacity-100" : "opacity-0"}`} - /> - </CommandItem> - ); -}; - -const FlagComponent = ({ country, countryName }: RPNInput.FlagProps) => { - const Flag = flags[country]; - - return ( - <span className="bg-foreground/20 flex h-4 w-6 overflow-hidden rounded-sm [&_svg]:size-full"> - {Flag && <Flag title={countryName} />} - </span> - ); -}; - -export { PhoneInput }; diff --git a/apps/web/src/components/ui/popover.tsx b/apps/web/src/components/ui/popover.tsx index 36ae5a69..8fd511a3 100644 --- a/apps/web/src/components/ui/popover.tsx +++ b/apps/web/src/components/ui/popover.tsx @@ -10,6 +10,8 @@ const PopoverTrigger = PopoverPrimitive.Trigger; const PopoverAnchor = PopoverPrimitive.Anchor; +const PopoverClose = PopoverPrimitive.Close; + const PopoverContent = React.forwardRef< React.ElementRef<typeof PopoverPrimitive.Content>, React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> @@ -20,7 +22,7 @@ const PopoverContent = React.forwardRef< align={align} sideOffset={sideOffset} className={cn( - "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 rounded-md border p-4 shadow-[0_0_10px_rgba(0,0,0,0.15)] outline-hidden", + "bg-popover text-popover-foreground z-50 w-72 rounded-md border p-4 shadow-[0_0_10px_rgba(0,0,0,0.15)] outline-hidden", className, )} {...props} @@ -29,4 +31,4 @@ const PopoverContent = React.forwardRef< )); PopoverContent.displayName = PopoverPrimitive.Content.displayName; -export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }; +export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor, PopoverClose }; diff --git a/apps/web/src/components/ui/prose.tsx b/apps/web/src/components/ui/prose.tsx index 4e526b7e..88d017fe 100644 --- a/apps/web/src/components/ui/prose.tsx +++ b/apps/web/src/components/ui/prose.tsx @@ -1,4 +1,7 @@ import { cn } from "@/utils/ui"; +import rehypeParse from "rehype-parse"; +import { unified } from "unified"; +import { createElement } from "react"; import type React from "react"; type ProseProps = React.HTMLAttributes<HTMLElement> & { @@ -6,6 +9,92 @@ type ProseProps = React.HTMLAttributes<HTMLElement> & { html: string; }; +type HastTextNode = { + type: "text"; + value: string; +}; + +type HastElementNode = { + type: "element"; + tagName: string; + properties?: Record<string, unknown>; + children?: HastNode[]; +}; + +type HastRootNode = { + type: "root"; + children?: HastNode[]; +}; + +type HastNode = HastTextNode | HastElementNode | HastRootNode; + +function toReactProps({ + properties, +}: { + properties: HastElementNode["properties"]; +}): Record<string, unknown> { + if (!properties) { + return {}; + } + + const props: Record<string, unknown> = {}; + + for (const [propertyName, propertyValue] of Object.entries(properties)) { + if (propertyValue === null || propertyValue === undefined) { + continue; + } + + if (propertyName.startsWith("on")) { + continue; + } + + if (propertyName === "class") { + props.className = Array.isArray(propertyValue) + ? propertyValue.join(" ") + : propertyValue; + continue; + } + + if (propertyName === "for") { + props.htmlFor = propertyValue; + continue; + } + + props[propertyName] = propertyValue; + } + + return props; +} + +function renderHastNode({ + node, + key, +}: { + node: HastNode; + key: string; +}): React.ReactNode { + if (node.type === "text") { + return node.value; + } + + if (node.type !== "element") { + return null; + } + + const children = (node.children ?? []).map((childNode, index) => + renderHastNode({ node: childNode, key: `${key}-${index}` }), + ); + + return createElement(node.tagName, { ...toReactProps({ properties: node.properties }), key }, children); +} + +function renderHtmlNodes({ html }: { html: string }): React.ReactNode { + const rootNode = unified().use(rehypeParse, { fragment: true }).parse(html) as HastRootNode; + return (rootNode.children ?? []).map((childNode, index) => + renderHastNode({ node: childNode, key: `prose-node-${index}` }), + ); +} + function Prose({ children, html, className }: ProseProps) { return ( <article @@ -14,7 +103,7 @@ function Prose({ children, html, className }: ProseProps) { className, )} > - {html ? <div dangerouslySetInnerHTML={{ __html: html }} /> : children} + {html ? renderHtmlNodes({ html }) : children} </article> ); } diff --git a/apps/web/src/components/ui/select.tsx b/apps/web/src/components/ui/select.tsx index fd9c7ac5..311f6edb 100644 --- a/apps/web/src/components/ui/select.tsx +++ b/apps/web/src/components/ui/select.tsx @@ -2,10 +2,11 @@ import * as React from "react"; import { Select as SelectPrimitive } from "radix-ui"; -import { Check, ChevronDown, ChevronUp } from "lucide-react"; +import { Check } from "lucide-react"; +import { ArrowUpIcon, ArrowDownIcon } from "@hugeicons/core-free-icons"; import { cva, type VariantProps } from "class-variance-authority"; - import { cn } from "@/utils/ui"; +import { HugeiconsIcon } from "@hugeicons/react"; const Select = SelectPrimitive.Root; @@ -14,36 +15,63 @@ const SelectGroup = SelectPrimitive.Group; const SelectValue = SelectPrimitive.Value; const selectItemVariants = cva( - "relative flex cursor-pointer select-none items-center gap-2 rounded-xl 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-1.5 rounded-sm px-2 py-1 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", + { + variants: { + variant: { + default: "", + destructive: + "text-destructive data-[highlighted]:bg-destructive/5 data-[highlighted]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +const selectTriggerVariants = cva( + "border-border ring-offset-background placeholder:text-muted-foreground flex h-7 w-auto cursor-pointer items-center justify-between gap-1 rounded-md border px-2.5 text-sm whitespace-nowrap transition-none focus:border-primary focus:ring-0 focus:ring-primary/10 focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1", { variants: { variant: { + default: "bg-accent", + outline: "bg-background hover:bg-accent/50", + }, + size: { default: "", - destructive: "text-destructive data-[highlighted]:bg-destructive/5 data-[highlighted]:text-destructive", + sm: "", }, }, defaultVariants: { variant: "default", + size: "default", }, }, ); const SelectTrigger = React.forwardRef< React.ElementRef<typeof SelectPrimitive.Trigger>, - React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> ->(({ className, children, ...props }, ref) => ( + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> & + VariantProps<typeof selectTriggerVariants> & { + icon?: React.ReactNode; + } +>(({ className, children, icon, variant, size, ...props }, ref) => ( <SelectPrimitive.Trigger ref={ref} - className={cn( - "bg-accent ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-8 w-auto cursor-pointer items-center justify-between gap-1 rounded-md px-3 py-2 text-sm whitespace-nowrap focus:ring-1 focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1", - "focus:border-primary focus:ring-4 focus:ring-primary/10 border-transparent transition-none", - className, - )} + className={cn(selectTriggerVariants({ variant, size }), className)} {...props} > - {children} + <div className="flex items-center gap-1.5"> + {icon && ( + <span className="text-muted-foreground [&_svg]:size-3.5 shrink-0"> + {icon} + </span> + )} + {children} + </div> <SelectPrimitive.Icon asChild> - <ChevronDown className="size-3 opacity-50" /> + <HugeiconsIcon icon={ArrowDownIcon} className="size-4" /> </SelectPrimitive.Icon> </SelectPrimitive.Trigger> )); @@ -61,7 +89,7 @@ const SelectScrollUpButton = React.forwardRef< )} {...props} > - <ChevronUp className="size-4" /> + <HugeiconsIcon icon={ArrowUpIcon} className="size-4" /> </SelectPrimitive.ScrollUpButton> )); SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName; @@ -78,7 +106,7 @@ const SelectScrollDownButton = React.forwardRef< )} {...props} > - <ChevronDown className="size-4" /> + <HugeiconsIcon icon={ArrowDownIcon} className="size-4" /> </SelectPrimitive.ScrollDownButton> )); SelectScrollDownButton.displayName = @@ -92,7 +120,7 @@ const SelectContent = React.forwardRef< <SelectPrimitive.Content ref={ref} className={cn( - "bg-popover text-popover-foreground z-50 max-h-96 min-w-32 overflow-hidden rounded-2xl border p-2 shadow-lg", + "bg-popover text-popover-foreground z-50 max-h-(--radix-select-content-available-height) min-w-32 overflow-hidden rounded-md border p-1 shadow-lg", className, )} position={position} @@ -124,7 +152,7 @@ const SelectLabel = React.forwardRef< <SelectPrimitive.Label ref={ref} className={cn( - "px-3 pb-2 pt-1 text-[11px] font-bold uppercase tracking-wider text-muted-foreground", + "px-2 pb-1 pt-0.5 text-[11px] font-bold uppercase tracking-wider text-muted-foreground", className, )} {...props} @@ -140,12 +168,12 @@ const SelectItem = React.forwardRef< >(({ className, children, variant = "default", ...props }, ref) => ( <SelectPrimitive.Item ref={ref} - className={cn(selectItemVariants({ variant }), "pr-8 pl-2", className)} + className={cn(selectItemVariants({ variant }), "pl-6 pr-2", className)} {...props} > - <span className="absolute right-2 flex size-3.5 items-center justify-center"> + <span className="absolute left-1.5 flex size-3.5 items-center justify-center"> <SelectPrimitive.ItemIndicator> - <Check className="size-4" /> + <Check className="size-3.5" /> </SelectPrimitive.ItemIndicator> </span> <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> @@ -159,7 +187,7 @@ const SelectSeparator = React.forwardRef< >(({ className, ...props }, ref) => ( <SelectPrimitive.Separator ref={ref} - className={cn("bg-border mx-1 my-2 h-px", className)} + className={cn("bg-border mx-1 my-1 h-px", className)} {...props} /> )); diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx deleted file mode 100644 index a41e25c7..00000000 --- a/apps/web/src/components/ui/sidebar.tsx +++ /dev/null @@ -1,725 +0,0 @@ -"use client"; - -import { Slot } from "@radix-ui/react-slot"; -import { cva, type VariantProps } from "class-variance-authority"; -import { PanelLeftIcon } from "lucide-react"; -import * as React from "react"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Separator } from "@/components/ui/separator"; -import { - Sheet, - SheetContent, - SheetDescription, - SheetHeader, - SheetTitle, -} from "@/components/ui/sheet"; -import { Skeleton } from "@/components/ui/skeleton"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { useIsMobile } from "@/hooks/use-mobile"; -import { cn } from "@/utils/ui"; - -const SIDEBAR_COOKIE_NAME = "sidebar_state"; -const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; -const SIDEBAR_WIDTH = "16rem"; -const SIDEBAR_WIDTH_MOBILE = "18rem"; -const SIDEBAR_WIDTH_ICON = "3rem"; -const SIDEBAR_KEYBOARD_SHORTCUT = "b"; - -type SidebarContextProps = { - state: "expanded" | "collapsed"; - open: boolean; - setOpen: (open: boolean) => void; - openMobile: boolean; - setOpenMobile: (open: boolean) => void; - isMobile: boolean; - toggleSidebar: () => void; -}; - -const SidebarContext = React.createContext<SidebarContextProps | null>(null); - -function useSidebar() { - const context = React.useContext(SidebarContext); - if (!context) { - throw new Error("useSidebar must be used within a SidebarProvider."); - } - - return context; -} - -function SidebarProvider({ - defaultOpen = true, - open: openProp, - onOpenChange: setOpenProp, - className, - style, - children, - ...props -}: React.ComponentProps<"div"> & { - defaultOpen?: boolean; - open?: boolean; - onOpenChange?: (open: boolean) => void; -}) { - const isMobile = useIsMobile(); - const [openMobile, setOpenMobile] = React.useState(false); - - // This is the internal state of the sidebar. - // We use openProp and setOpenProp for control from outside the component. - const [_open, _setOpen] = React.useState(defaultOpen); - const open = openProp ?? _open; - const setOpen = React.useCallback( - (value: boolean | ((value: boolean) => boolean)) => { - const openState = typeof value === "function" ? value(open) : value; - if (setOpenProp) { - setOpenProp(openState); - } else { - _setOpen(openState); - } - - // This sets the cookie to keep the sidebar state. - document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`; - }, - [setOpenProp, open], - ); - - // Helper to toggle the sidebar. - const toggleSidebar = React.useCallback(() => { - return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open); - }, [isMobile, setOpen, setOpenMobile]); - - // Adds a keyboard shortcut to toggle the sidebar. - React.useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - if ( - event.key === SIDEBAR_KEYBOARD_SHORTCUT && - (event.metaKey || event.ctrlKey) - ) { - event.preventDefault(); - toggleSidebar(); - } - }; - - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [toggleSidebar]); - - // We add a state so that we can do data-state="expanded" or "collapsed". - // This makes it easier to style the sidebar with Tailwind classes. - const state = open ? "expanded" : "collapsed"; - - const contextValue = React.useMemo<SidebarContextProps>( - () => ({ - state, - open, - setOpen, - isMobile, - openMobile, - setOpenMobile, - toggleSidebar, - }), - [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar], - ); - - return ( - <SidebarContext.Provider value={contextValue}> - <TooltipProvider delayDuration={0}> - <div - data-slot="sidebar-wrapper" - style={ - { - "--sidebar-width": SIDEBAR_WIDTH, - "--sidebar-width-icon": SIDEBAR_WIDTH_ICON, - ...style, - } as React.CSSProperties - } - className={cn( - "group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full", - className, - )} - {...props} - > - {children} - </div> - </TooltipProvider> - </SidebarContext.Provider> - ); -} - -function Sidebar({ - side = "left", - variant = "sidebar", - collapsible = "offcanvas", - className, - children, - ...props -}: React.ComponentProps<"div"> & { - side?: "left" | "right"; - variant?: "sidebar" | "floating" | "inset"; - collapsible?: "offcanvas" | "icon" | "none"; -}) { - const { isMobile, state, openMobile, setOpenMobile } = useSidebar(); - - if (collapsible === "none") { - return ( - <div - data-slot="sidebar" - className={cn( - "bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col", - className, - )} - {...props} - > - {children} - </div> - ); - } - - if (isMobile) { - return ( - <Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}> - <SheetContent - data-sidebar="sidebar" - data-slot="sidebar" - data-mobile="true" - className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden" - style={ - { - "--sidebar-width": SIDEBAR_WIDTH_MOBILE, - } as React.CSSProperties - } - side={side} - > - <SheetHeader className="sr-only"> - <SheetTitle>Sidebar</SheetTitle> - <SheetDescription>Displays the mobile sidebar.</SheetDescription> - </SheetHeader> - <div className="flex size-full flex-col">{children}</div> - </SheetContent> - </Sheet> - ); - } - - return ( - <div - className="group peer text-sidebar-foreground hidden md:block" - data-state={state} - data-collapsible={state === "collapsed" ? collapsible : ""} - data-variant={variant} - data-side={side} - data-slot="sidebar" - > - {/* This is what handles the sidebar gap on desktop */} - <div - data-slot="sidebar-gap" - className={cn( - "relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear", - "group-data-[collapsible=offcanvas]:w-0", - "group-data-[side=right]:rotate-180", - variant === "floating" || variant === "inset" - ? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]" - : "group-data-[collapsible=icon]:w-(--sidebar-width-icon)", - )} - /> - <div - data-slot="sidebar-container" - className={cn( - "fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex", - side === "left" - ? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]" - : "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]", - // Adjust the padding for floating and inset variants. - variant === "floating" || variant === "inset" - ? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]" - : "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l", - className, - )} - {...props} - > - <div - data-sidebar="sidebar" - data-slot="sidebar-inner" - className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex size-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm" - > - {children} - </div> - </div> - </div> - ); -} - -function SidebarTrigger({ - className, - onClick, - ...props -}: React.ComponentProps<typeof Button>) { - const { toggleSidebar } = useSidebar(); - - return ( - <Button - data-sidebar="trigger" - data-slot="sidebar-trigger" - variant="text" - size="icon" - className={cn("size-7", className)} - onClick={(event) => { - onClick?.(event); - toggleSidebar(); - }} - {...props} - > - <PanelLeftIcon /> - <span className="sr-only">Toggle Sidebar</span> - </Button> - ); -} - -function SidebarRail({ className, ...props }: React.ComponentProps<"button">) { - const { toggleSidebar } = useSidebar(); - - return ( - <button - data-sidebar="rail" - data-slot="sidebar-rail" - aria-label="Toggle Sidebar" - tabIndex={-1} - onClick={toggleSidebar} - title="Toggle Sidebar" - className={cn( - "hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex", - "in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize", - "[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize", - "hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full", - "[[data-side=left][data-collapsible=offcanvas]_&]:-right-2", - "[[data-side=right][data-collapsible=offcanvas]_&]:-left-2", - className, - )} - {...props} - /> - ); -} - -function SidebarInset({ className, ...props }: React.ComponentProps<"main">) { - return ( - <main - data-slot="sidebar-inset" - className={cn( - "bg-background relative flex w-full flex-1 flex-col", - "md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2", - className, - )} - {...props} - /> - ); -} - -function SidebarInput({ - className, - ...props -}: React.ComponentProps<typeof Input>) { - return ( - <Input - data-slot="sidebar-input" - data-sidebar="input" - className={cn("bg-background h-8 w-full shadow-none", className)} - {...props} - /> - ); -} - -function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) { - return ( - <div - data-slot="sidebar-header" - data-sidebar="header" - className={cn("flex flex-col gap-2 p-2", className)} - {...props} - /> - ); -} - -function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) { - return ( - <div - data-slot="sidebar-footer" - data-sidebar="footer" - className={cn("flex flex-col gap-2 p-2", className)} - {...props} - /> - ); -} - -function SidebarSeparator({ - className, - ...props -}: React.ComponentProps<typeof Separator>) { - return ( - <Separator - data-slot="sidebar-separator" - data-sidebar="separator" - className={cn("bg-sidebar-border mx-2 w-auto", className)} - {...props} - /> - ); -} - -function SidebarContent({ className, ...props }: React.ComponentProps<"div">) { - return ( - <div - data-slot="sidebar-content" - data-sidebar="content" - className={cn( - "flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden", - className, - )} - {...props} - /> - ); -} - -function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) { - return ( - <div - data-slot="sidebar-group" - data-sidebar="group" - className={cn("relative flex w-full min-w-0 flex-col p-2", className)} - {...props} - /> - ); -} - -function SidebarGroupLabel({ - className, - asChild = false, - ...props -}: React.ComponentProps<"div"> & { asChild?: boolean }) { - const Comp = asChild ? Slot : "div"; - - return ( - <Comp - data-slot="sidebar-group-label" - data-sidebar="group-label" - className={cn( - "text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", - "group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0", - className, - )} - {...props} - /> - ); -} - -function SidebarGroupAction({ - className, - asChild = false, - ...props -}: React.ComponentProps<"button"> & { asChild?: boolean }) { - const Comp = asChild ? Slot : "button"; - - return ( - <Comp - data-slot="sidebar-group-action" - data-sidebar="group-action" - className={cn( - "text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", - // Increases the hit area of the button on mobile. - "after:absolute after:-inset-2 md:after:hidden", - "group-data-[collapsible=icon]:hidden", - className, - )} - {...props} - /> - ); -} - -function SidebarGroupContent({ - className, - ...props -}: React.ComponentProps<"div">) { - return ( - <div - data-slot="sidebar-group-content" - data-sidebar="group-content" - className={cn("w-full text-sm", className)} - {...props} - /> - ); -} - -function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) { - return ( - <ul - data-slot="sidebar-menu" - data-sidebar="menu" - className={cn("flex w-full min-w-0 flex-col gap-1", className)} - {...props} - /> - ); -} - -function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) { - return ( - <li - data-slot="sidebar-menu-item" - data-sidebar="menu-item" - className={cn("group/menu-item relative", className)} - {...props} - /> - ); -} - -const sidebarMenuButtonVariants = cva( - "peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0", - { - variants: { - variant: { - default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground", - outline: - "bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]", - }, - size: { - default: "h-8 text-sm", - sm: "h-7 text-xs", - lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!", - }, - }, - defaultVariants: { - variant: "default", - size: "default", - }, - }, -); - -function SidebarMenuButton({ - asChild = false, - isActive = false, - variant = "default", - size = "default", - tooltip, - className, - ...props -}: React.ComponentProps<"button"> & { - asChild?: boolean; - isActive?: boolean; - tooltip?: string | React.ComponentProps<typeof TooltipContent>; -} & VariantProps<typeof sidebarMenuButtonVariants>) { - const Comp = asChild ? Slot : "button"; - const { isMobile, state } = useSidebar(); - - const button = ( - <Comp - data-slot="sidebar-menu-button" - data-sidebar="menu-button" - data-size={size} - data-active={isActive} - className={cn(sidebarMenuButtonVariants({ variant, size }), className)} - {...props} - /> - ); - - if (!tooltip) { - return button; - } - - if (typeof tooltip === "string") { - tooltip = { - children: tooltip, - }; - } - - return ( - <Tooltip> - <TooltipTrigger asChild>{button}</TooltipTrigger> - <TooltipContent - side="right" - align="center" - hidden={state !== "collapsed" || isMobile} - {...tooltip} - /> - </Tooltip> - ); -} - -function SidebarMenuAction({ - className, - asChild = false, - showOnHover = false, - ...props -}: React.ComponentProps<"button"> & { - asChild?: boolean; - showOnHover?: boolean; -}) { - const Comp = asChild ? Slot : "button"; - - return ( - <Comp - data-slot="sidebar-menu-action" - data-sidebar="menu-action" - className={cn( - "text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", - // Increases the hit area of the button on mobile. - "after:absolute after:-inset-2 md:after:hidden", - "peer-data-[size=sm]/menu-button:top-1", - "peer-data-[size=default]/menu-button:top-1.5", - "peer-data-[size=lg]/menu-button:top-2.5", - "group-data-[collapsible=icon]:hidden", - showOnHover && - "peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0", - className, - )} - {...props} - /> - ); -} - -function SidebarMenuBadge({ - className, - ...props -}: React.ComponentProps<"div">) { - return ( - <div - data-slot="sidebar-menu-badge" - data-sidebar="menu-badge" - className={cn( - "text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none", - "peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground", - "peer-data-[size=sm]/menu-button:top-1", - "peer-data-[size=default]/menu-button:top-1.5", - "peer-data-[size=lg]/menu-button:top-2.5", - "group-data-[collapsible=icon]:hidden", - className, - )} - {...props} - /> - ); -} - -function SidebarMenuSkeleton({ - className, - showIcon = false, - ...props -}: React.ComponentProps<"div"> & { - showIcon?: boolean; -}) { - // Random width between 50 to 90%. - const width = React.useMemo(() => { - return `${Math.floor(Math.random() * 40) + 50}%`; - }, []); - - return ( - <div - data-slot="sidebar-menu-skeleton" - data-sidebar="menu-skeleton" - className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)} - {...props} - > - {showIcon && ( - <Skeleton - className="size-4 rounded-md" - data-sidebar="menu-skeleton-icon" - /> - )} - <Skeleton - className="h-4 max-w-(--skeleton-width) flex-1" - data-sidebar="menu-skeleton-text" - style={ - { - "--skeleton-width": width, - } as React.CSSProperties - } - /> - </div> - ); -} - -function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) { - return ( - <ul - data-slot="sidebar-menu-sub" - data-sidebar="menu-sub" - className={cn( - "border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5", - "group-data-[collapsible=icon]:hidden", - className, - )} - {...props} - /> - ); -} - -function SidebarMenuSubItem({ - className, - ...props -}: React.ComponentProps<"li">) { - return ( - <li - data-slot="sidebar-menu-sub-item" - data-sidebar="menu-sub-item" - className={cn("group/menu-sub-item relative", className)} - {...props} - /> - ); -} - -function SidebarMenuSubButton({ - asChild = false, - size = "md", - isActive = false, - className, - ...props -}: React.ComponentProps<"a"> & { - asChild?: boolean; - size?: "sm" | "md"; - isActive?: boolean; -}) { - const Comp = asChild ? Slot : "a"; - - return ( - <Comp - data-slot="sidebar-menu-sub-button" - data-sidebar="menu-sub-button" - data-size={size} - data-active={isActive} - className={cn( - "text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0", - "data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground", - size === "sm" && "text-xs", - size === "md" && "text-sm", - "group-data-[collapsible=icon]:hidden", - className, - )} - {...props} - /> - ); -} - -export { - Sidebar, - SidebarContent, - SidebarFooter, - SidebarGroup, - SidebarGroupAction, - SidebarGroupContent, - SidebarGroupLabel, - SidebarHeader, - SidebarInput, - SidebarInset, - SidebarMenu, - SidebarMenuAction, - SidebarMenuBadge, - SidebarMenuButton, - SidebarMenuItem, - SidebarMenuSkeleton, - SidebarMenuSub, - SidebarMenuSubButton, - SidebarMenuSubItem, - SidebarProvider, - SidebarRail, - SidebarSeparator, - SidebarTrigger, - useSidebar, -}; diff --git a/apps/web/src/components/ui/spinner.tsx b/apps/web/src/components/ui/spinner.tsx index a46792f7..5546d539 100644 --- a/apps/web/src/components/ui/spinner.tsx +++ b/apps/web/src/components/ui/spinner.tsx @@ -3,15 +3,15 @@ import { Loading03Icon } from "@hugeicons/core-free-icons"; import { cn } from "@/utils/ui"; function Spinner({ className, ...props }: Omit<HugeiconsIconProps, "icon">) { - return ( - <HugeiconsIcon - icon={Loading03Icon} - role="status" - aria-label="Loading" - className={cn("size-4 animate-spin", className)} - {...props} - /> - ) + return ( + <HugeiconsIcon + icon={Loading03Icon} + role="status" + aria-label="Loading" + className={cn("size-4 animate-spin", className)} + {...props} + /> + ); } -export { Spinner } +export { Spinner }; diff --git a/apps/web/src/components/ui/split-button.tsx b/apps/web/src/components/ui/split-button.tsx index aa8b331a..dfc4cdfe 100644 --- a/apps/web/src/components/ui/split-button.tsx +++ b/apps/web/src/components/ui/split-button.tsx @@ -1,90 +1,90 @@ -import { Button, type ButtonProps } from "@/components/ui/button";
-import { Separator } from "@/components/ui/separator";
-import { type ReactNode, forwardRef } from "react";
-import { cn } from "@/utils/ui";
-
-interface SplitButtonProps {
- children: ReactNode;
- className?: string;
-}
-
-interface SplitButtonSideProps extends Omit<ButtonProps, "variant" | "size"> {
- children: ReactNode;
-}
-
-const SplitButton = forwardRef<HTMLDivElement, SplitButtonProps>(
- ({ children, className, ...props }, ref) => {
- return (
- <div
- ref={ref}
- className={cn(
- "border-input bg-accent inline-flex h-7 overflow-hidden rounded-lg border",
- className,
- )}
- {...props}
- >
- {children}
- </div>
- );
- },
-);
-SplitButton.displayName = "SplitButton";
-
-const SplitButtonSide = forwardRef<
- HTMLButtonElement,
- SplitButtonSideProps & { paddingClass: string }
->(({ children, className, paddingClass, onClick, ...props }, ref) => {
- return (
- <Button
- ref={ref}
- variant="text"
- className={cn(
- "bg-accent disabled:text-muted-foreground h-full gap-0 rounded-none border-0 font-normal !opacity-100",
- onClick
- ? "hover:bg-foreground/10 cursor-pointer hover:opacity-100"
- : "cursor-default select-text",
- paddingClass,
- className,
- )}
- onClick={onClick}
- {...props}
- >
- {typeof children === "string" ? (
- <span className="cursor-text font-normal">{children}</span>
- ) : (
- children
- )}
- </Button>
- );
-});
-SplitButtonSide.displayName = "SplitButtonSide";
-
-const SplitButtonLeft = forwardRef<HTMLButtonElement, SplitButtonSideProps>(
- ({ ...props }, ref) => {
- return <SplitButtonSide ref={ref} paddingClass="pl-3 pr-2" {...props} />;
- },
-);
-SplitButtonLeft.displayName = "SplitButtonLeft";
-
-const SplitButtonRight = forwardRef<HTMLButtonElement, SplitButtonSideProps>(
- ({ ...props }, ref) => {
- return <SplitButtonSide ref={ref} paddingClass="pl-2 pr-3" {...props} />;
- },
-);
-SplitButtonRight.displayName = "SplitButtonRight";
-
-const SplitButtonSeparator = forwardRef<HTMLDivElement, { className?: string }>(
- ({ className, ...props }, ref) => {
- return (
- <Separator
- ref={ref}
- orientation="vertical"
- className={cn("bg-foreground/15 h-full", className)}
- {...props}
- />
- );
- },
-);
-SplitButtonSeparator.displayName = "SplitButtonSeparator";
-
-export { SplitButton, SplitButtonLeft, SplitButtonRight, SplitButtonSeparator };
+import { Button, type ButtonProps } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; +import { type ReactNode, forwardRef } from "react"; +import { cn } from "@/utils/ui"; + +interface SplitButtonProps { + children: ReactNode; + className?: string; +} + +interface SplitButtonSideProps extends Omit<ButtonProps, "variant" | "size"> { + children: ReactNode; +} + +const SplitButton = forwardRef<HTMLDivElement, SplitButtonProps>( + ({ children, className, ...props }, ref) => { + return ( + <div + ref={ref} + className={cn( + "border-input bg-accent inline-flex h-7 overflow-hidden rounded-lg border", + className, + )} + {...props} + > + {children} + </div> + ); + }, +); +SplitButton.displayName = "SplitButton"; + +const SplitButtonSide = forwardRef< + HTMLButtonElement, + SplitButtonSideProps & { paddingClass: string } +>(({ children, className, paddingClass, onClick, ...props }, ref) => { + return ( + <Button + ref={ref} + variant="text" + className={cn( + "bg-accent disabled:text-muted-foreground h-full gap-0 rounded-none border-0 font-normal !opacity-100", + onClick + ? "hover:bg-foreground/10 cursor-pointer hover:opacity-100" + : "cursor-default select-text", + paddingClass, + className, + )} + onClick={onClick} + {...props} + > + {typeof children === "string" ? ( + <span className="cursor-text font-normal">{children}</span> + ) : ( + children + )} + </Button> + ); +}); +SplitButtonSide.displayName = "SplitButtonSide"; + +const SplitButtonLeft = forwardRef<HTMLButtonElement, SplitButtonSideProps>( + ({ ...props }, ref) => { + return <SplitButtonSide ref={ref} paddingClass="pl-3 pr-2" {...props} />; + }, +); +SplitButtonLeft.displayName = "SplitButtonLeft"; + +const SplitButtonRight = forwardRef<HTMLButtonElement, SplitButtonSideProps>( + ({ ...props }, ref) => { + return <SplitButtonSide ref={ref} paddingClass="pl-2 pr-3" {...props} />; + }, +); +SplitButtonRight.displayName = "SplitButtonRight"; + +const SplitButtonSeparator = forwardRef<HTMLDivElement, { className?: string }>( + ({ className, ...props }, ref) => { + return ( + <Separator + ref={ref} + orientation="vertical" + className={cn("bg-foreground/15 h-full", className)} + {...props} + /> + ); + }, +); +SplitButtonSeparator.displayName = "SplitButtonSeparator"; + +export { SplitButton, SplitButtonLeft, SplitButtonRight, SplitButtonSeparator }; diff --git a/apps/web/src/components/ui/textarea.tsx b/apps/web/src/components/ui/textarea.tsx index ca60a809..2ae59697 100644 --- a/apps/web/src/components/ui/textarea.tsx +++ b/apps/web/src/components/ui/textarea.tsx @@ -9,8 +9,8 @@ const Textarea = React.forwardRef< return ( <textarea className={cn( - "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input bg-accent/50 flex min-h-[60px] w-full rounded-md border px-3 py-2 resize-none text-base shadow-xs outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", - "focus-visible:border-primary focus-visible:ring-4 focus-visible:ring-primary/10", + "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input bg-accent flex min-h-[60px] w-full rounded-md border px-3 py-2 resize-none text-base shadow-xs outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", + "focus-visible:border-primary focus-visible:ring-0 focus-visible:ring-primary/10", "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", className, )} diff --git a/apps/web/src/components/ui/tooltip.tsx b/apps/web/src/components/ui/tooltip.tsx index 769ac360..067042e6 100644 --- a/apps/web/src/components/ui/tooltip.tsx +++ b/apps/web/src/components/ui/tooltip.tsx @@ -60,6 +60,7 @@ const TooltipContent = React.forwardRef< fill="none" xmlns="http://www.w3.org/2000/svg" className="absolute top-1/2 left-[-6px] -translate-y-1/2" + aria-hidden="true" > <path d="M6 0L0 5L6 10V0Z" diff --git a/apps/web/src/constants/editor-constants.ts b/apps/web/src/constants/editor-constants.ts index 213ea875..00e8f19b 100644 --- a/apps/web/src/constants/editor-constants.ts +++ b/apps/web/src/constants/editor-constants.ts @@ -1,9 +1,3 @@ -import type { TPlatformLayout } from "@/types/editor"; - -export const PLATFORM_LAYOUTS: Record<TPlatformLayout, string> = { - tiktok: "TikTok", -}; - export const PANEL_CONFIG = { panels: { tools: 25, diff --git a/apps/web/src/constants/font-constants.ts b/apps/web/src/constants/font-constants.ts index 46f84848..d115d09b 100644 --- a/apps/web/src/constants/font-constants.ts +++ b/apps/web/src/constants/font-constants.ts @@ -1,79 +1,13 @@ -export interface FontOption { - value: string; - label: string; - category: "system" | "google" | "custom"; - weights?: number[]; - hasClassName?: boolean; -} - -export const FONT_OPTIONS: FontOption[] = [ - // System fonts (always available) - { value: "Arial", label: "Arial", category: "system", hasClassName: false }, - { - value: "Helvetica", - label: "Helvetica", - category: "system", - hasClassName: false, - }, - { - value: "Times New Roman", - label: "Times New Roman", - category: "system", - hasClassName: false, - }, - { - value: "Georgia", - label: "Georgia", - category: "system", - hasClassName: false, - }, - - // Google Fonts (loaded in layout.tsx) - { - value: "Inter", - label: "Inter", - category: "google", - weights: [400, 700], - hasClassName: true, - }, - { - value: "Roboto", - label: "Roboto", - category: "google", - weights: [400, 700], - hasClassName: true, - }, - { - value: "Open Sans", - label: "Open Sans", - category: "google", - hasClassName: true, - }, - { - value: "Playfair Display", - label: "Playfair Display", - category: "google", - hasClassName: true, - }, - { - value: "Comic Neue", - label: "Comic Neue", - category: "google", - hasClassName: false, - }, -] as const; - export const DEFAULT_FONT = "Arial"; -// Type-safe font family union -export type FontFamily = (typeof FONT_OPTIONS)[number]["value"]; - -// Helper functions -export const getFontByValue = (value: string): FontOption | undefined => - FONT_OPTIONS.find((font) => font.value === value); - -export const getGoogleFonts = (): FontOption[] => - FONT_OPTIONS.filter((font) => font.category === "google"); - -export const getSystemFonts = (): FontOption[] => - FONT_OPTIONS.filter((font) => font.category === "system"); +export const SYSTEM_FONTS = new Set([ + "Arial", + "Helvetica", + "Times New Roman", + "Courier New", + "Verdana", + "Georgia", + "monospace", + "sans-serif", + "serif", +]); diff --git a/apps/web/src/constants/site-constants.ts b/apps/web/src/constants/site-constants.ts index ac6cd2cd..10981727 100644 --- a/apps/web/src/constants/site-constants.ts +++ b/apps/web/src/constants/site-constants.ts @@ -1,4 +1,4 @@ -import { OcDataBuddyIcon, OcMarbleIcon, } from "@opencut/ui/icons"; +import { OcDataBuddyIcon, OcMarbleIcon } from "@opencut/ui/icons"; export const SITE_URL = "https://opencut.app"; diff --git a/apps/web/src/constants/sticker-constants.ts b/apps/web/src/constants/sticker-constants.ts new file mode 100644 index 00000000..506e552d --- /dev/null +++ b/apps/web/src/constants/sticker-constants.ts @@ -0,0 +1,8 @@ +export const STICKER_CATEGORIES = { + all: "All", + logos: "Logos", + icons: "Icons", + emoji: "Emoji", + flags: "Flags", + shapes: "Shapes", +}; diff --git a/apps/web/src/constants/stickers-constants.ts b/apps/web/src/constants/stickers-constants.ts deleted file mode 100644 index 3c98d4f7..00000000 --- a/apps/web/src/constants/stickers-constants.ts +++ /dev/null @@ -1,16 +0,0 @@ -export const STICKER_CATEGORIES = [ - "all", - "general", - "brands", - "emoji", -] as const; - -export const STICKER_CATEGORY_CONFIG: Record< - (typeof STICKER_CATEGORIES)[number], - string | undefined -> = { - all: undefined, - general: "General", - brands: "Brands / Social", - emoji: "Emoji", -}; diff --git a/apps/web/src/constants/text-constants.ts b/apps/web/src/constants/text-constants.ts index 83dd251f..f0b2143d 100644 --- a/apps/web/src/constants/text-constants.ts +++ b/apps/web/src/constants/text-constants.ts @@ -1,38 +1,40 @@ -import type { TextElement } from "@/types/timeline";
-import { TIMELINE_CONSTANTS } from "./timeline-constants";
-
-export const MIN_FONT_SIZE = 5;
-export const MAX_FONT_SIZE = 300;
-
-/**
- * higher value: smaller font size
- * lower value: larger font size
- */
-export const FONT_SIZE_SCALE_REFERENCE = 90;
-
-export const DEFAULT_TEXT_ELEMENT: Omit<TextElement, "id"> = {
- type: "text",
- name: "Text",
- content: "Default text",
- fontSize: 15,
- fontFamily: "Arial",
- color: "#ffffff",
- backgroundColor: "transparent",
- textAlign: "center",
- fontWeight: "normal",
- fontStyle: "normal",
- textDecoration: "none",
- duration: TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION,
- startTime: 0,
- trimStart: 0,
- trimEnd: 0,
- transform: {
- scale: 1,
- position: {
- x: 0,
- y: 0,
- },
- rotate: 0,
- },
- opacity: 1,
-};
+import type { TextElement } from "@/types/timeline"; +import { + DEFAULT_OPACITY, + DEFAULT_TRANSFORM, + TIMELINE_CONSTANTS, +} from "./timeline-constants"; + +export const MIN_FONT_SIZE = 5; +export const MAX_FONT_SIZE = 300; + +/** + * higher value: smaller font size + * lower value: larger font size + */ +export const FONT_SIZE_SCALE_REFERENCE = 90; + +export const DEFAULT_LETTER_SPACING = 0; +export const DEFAULT_LINE_HEIGHT = 1.2; + +export const DEFAULT_TEXT_ELEMENT: Omit<TextElement, "id"> = { + type: "text", + name: "Text", + content: "Default text", + fontSize: 15, + fontFamily: "Arial", + color: "#ffffff", + backgroundColor: "#000000", + textAlign: "center", + fontWeight: "normal", + fontStyle: "normal", + textDecoration: "none", + letterSpacing: DEFAULT_LETTER_SPACING, + lineHeight: DEFAULT_LINE_HEIGHT, + duration: TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION, + startTime: 0, + trimStart: 0, + trimEnd: 0, + transform: DEFAULT_TRANSFORM, + opacity: DEFAULT_OPACITY, +}; diff --git a/apps/web/src/constants/timeline-constants.tsx b/apps/web/src/constants/timeline-constants.tsx index d6af3c3a..5ac0913a 100644 --- a/apps/web/src/constants/timeline-constants.tsx +++ b/apps/web/src/constants/timeline-constants.tsx @@ -1,5 +1,6 @@ import type { TTimelineViewState } from "@/types/project"; -import type { TrackType } from "@/types/timeline"; +import type { BlendMode } from "@/types/rendering"; +import type { TrackType, Transform } from "@/types/timeline"; import { Happy01Icon, MusicNote03Icon, @@ -8,6 +9,16 @@ import { import { HugeiconsIcon } from "@hugeicons/react"; import { OcVideoIcon } from "@opencut/ui/icons"; +export const DEFAULT_TRANSFORM: Transform = { + scale: 1, + position: { x: 0, y: 0 }, + rotate: 0, +}; + +export const DEFAULT_OPACITY = 1; +export const DEFAULT_BLEND_MODE: BlendMode = "normal"; +export const DEFAULT_BOOKMARK_COLOR = "#009dff"; + export const TRACK_COLORS: Record<TrackType, { background: string }> = { video: { background: "transparent", @@ -32,6 +43,8 @@ export const TRACK_HEIGHTS: Record<TrackType, number> = { export const TRACK_GAP = 4; +export const DRAG_THRESHOLD_PX = 5; + export const TIMELINE_CONSTANTS = { PIXELS_PER_SECOND: 50, DEFAULT_ELEMENT_DURATION: 5, diff --git a/apps/web/src/core/managers/audio-manager.ts b/apps/web/src/core/managers/audio-manager.ts index 3a83be48..457e49d6 100644 --- a/apps/web/src/core/managers/audio-manager.ts +++ b/apps/web/src/core/managers/audio-manager.ts @@ -125,7 +125,8 @@ export class AudioManager { private getPlaybackTime(): number { if (!this.audioContext) return this.playbackStartTime; - const elapsed = this.audioContext.currentTime - this.playbackStartContextTime; + const elapsed = + this.audioContext.currentTime - this.playbackStartContextTime; return this.playbackStartTime + elapsed; } @@ -176,7 +177,11 @@ export class AudioManager { if (clip.startTime > windowEnd) continue; this.activeClipIds.add(clip.id); - void this.runClipIterator({ clip, startTime: currentTime, sessionId: this.playbackSessionId }); + void this.runClipIterator({ + clip, + startTime: currentTime, + sessionId: this.playbackSessionId, + }); } } @@ -239,8 +244,7 @@ export class AudioManager { node.connect(this.masterGain ?? audioContext.destination); const startTimestamp = - this.playbackStartContextTime + - (timelineTime - this.playbackStartTime); + this.playbackStartContextTime + (timelineTime - this.playbackStartTime); if (startTimestamp >= audioContext.currentTime) { node.start(startTimestamp); diff --git a/apps/web/src/core/managers/commands.ts b/apps/web/src/core/managers/commands.ts index d996a30e..46ee643a 100644 --- a/apps/web/src/core/managers/commands.ts +++ b/apps/web/src/core/managers/commands.ts @@ -11,6 +11,11 @@ export class CommandManager { return command; } + push({ command }: { command: Command }): void { + this.history.push(command); + this.redoStack = []; + } + undo(): void { if (this.history.length === 0) return; const command = this.history.pop(); diff --git a/apps/web/src/core/managers/project-manager.ts b/apps/web/src/core/managers/project-manager.ts index c7497ef0..2608b8c0 100644 --- a/apps/web/src/core/managers/project-manager.ts +++ b/apps/web/src/core/managers/project-manager.ts @@ -27,6 +27,8 @@ import { type MigrationProgress, } from "@/services/storage/migrations"; import { DEFAULT_TIMELINE_VIEW_STATE } from "@/constants/timeline-constants"; +import { loadFonts } from "@/lib/fonts/google-fonts"; +import { collectFontFamilies } from "@/lib/timeline/element-utils"; export interface MigrationState { isMigrating: boolean; @@ -146,6 +148,9 @@ export class ProjectManager { await this.editor.media.loadProjectMedia({ projectId: id }); + const allTracks = (project.scenes ?? []).flatMap((scene) => scene.tracks); + await loadFonts({ families: collectFontFamilies({ tracks: allTracks }) }); + if (!project.metadata.thumbnail) { const didUpdateThumbnail = await this.updateThumbnailFromTimeline(); if (didUpdateThumbnail) { diff --git a/apps/web/src/core/managers/scenes-manager.ts b/apps/web/src/core/managers/scenes-manager.ts index 8c761f9b..c844f448 100644 --- a/apps/web/src/core/managers/scenes-manager.ts +++ b/apps/web/src/core/managers/scenes-manager.ts @@ -7,14 +7,20 @@ import { canDeleteScene, findCurrentScene, } from "@/lib/scenes"; -import { getFrameTime, isBookmarkAtTime } from "@/lib/timeline/bookmarks"; +import { + getBookmarkAtTime, + getFrameTime, + isBookmarkAtTime, +} from "@/lib/timeline/bookmarks"; import { ensureMainTrack } from "@/lib/timeline/track-utils"; import { CreateSceneCommand, DeleteSceneCommand, + MoveBookmarkCommand, RemoveBookmarkCommand, RenameSceneCommand, ToggleBookmarkCommand, + UpdateBookmarkCommand, } from "@/lib/commands/scene"; export class ScenesManager { @@ -125,6 +131,45 @@ export class ScenesManager { this.editor.command.execute({ command }); } + async updateBookmark({ + time, + updates, + }: { + time: number; + updates: Partial<{ note: string; color: string; duration: number }>; + }): Promise<void> { + const command = new UpdateBookmarkCommand(time, updates); + this.editor.command.execute({ command }); + } + + async moveBookmark({ + fromTime, + toTime, + }: { + fromTime: number; + toTime: number; + }): Promise<void> { + const command = new MoveBookmarkCommand(fromTime, toTime); + this.editor.command.execute({ command }); + } + + getBookmarkAtTime({ time }: { time: number }) { + const activeScene = this.active; + const activeProject = this.editor.project.getActive(); + + if (!activeScene || !activeProject) return null; + + const frameTime = getFrameTime({ + time, + fps: activeProject.settings.fps, + }); + + return getBookmarkAtTime({ + bookmarks: activeScene.bookmarks, + frameTime, + }); + } + async loadProjectScenes({ projectId }: { projectId: string }): Promise<void> { try { const result = await storageService.loadProject({ id: projectId }); diff --git a/apps/web/src/core/managers/timeline-manager.ts b/apps/web/src/core/managers/timeline-manager.ts index ad9f7792..c8ac5a79 100644 --- a/apps/web/src/core/managers/timeline-manager.ts +++ b/apps/web/src/core/managers/timeline-manager.ts @@ -23,12 +23,14 @@ import { PasteCommand, UpdateElementStartTimeCommand, MoveElementCommand, + TracksSnapshotCommand, } from "@/lib/commands/timeline"; -import { BatchCommand } from "@/lib/commands"; +import { BatchCommand, PreviewTracker } from "@/lib/commands"; import type { InsertElementParams } from "@/lib/commands/timeline/element/insert-element"; export class TimelineManager { private listeners = new Set<() => void>(); + private previewTracker = new PreviewTracker<TimelineTrack[]>(); constructor(private editor: EditorCore) {} @@ -214,7 +216,8 @@ export class TimelineManager { ({ trackId, elementId, updates: elementUpdates }) => new UpdateElementCommand(trackId, elementId, elementUpdates), ); - const command = commands.length === 1 ? commands[0] : new BatchCommand(commands); + const command = + commands.length === 1 ? commands[0] : new BatchCommand(commands); if (pushHistory) { this.editor.command.execute({ command }); } else { @@ -222,6 +225,52 @@ export class TimelineManager { } } + isPreviewActive(): boolean { + return this.previewTracker.isActive(); + } + + previewElements({ + updates, + }: { + updates: Array<{ + trackId: string; + elementId: string; + updates: Partial<Record<string, unknown>>; + }>; + }): void { + const tracks = this.getTracks(); + this.previewTracker.begin({ state: tracks }); + + let updatedTracks = tracks; + for (const { trackId, elementId, updates: elementUpdates } of updates) { + updatedTracks = updatedTracks.map((track) => { + if (track.id !== trackId) return track; + const newElements = track.elements.map((element) => + element.id === elementId + ? { ...element, ...elementUpdates } + : element, + ); + return { ...track, elements: newElements } as TimelineTrack; + }); + } + this.updateTracks(updatedTracks); + } + + commitPreview(): void { + const snapshot = this.previewTracker.end(); + if (snapshot === null) return; + const currentTracks = this.getTracks(); + const command = new TracksSnapshotCommand(snapshot, currentTracks); + this.editor.command.push({ command }); + } + + discardPreview(): void { + const snapshot = this.previewTracker.end(); + if (snapshot !== null) { + this.updateTracks(snapshot); + } + } + duplicateElements({ elements, }: { diff --git a/apps/web/src/hooks/timeline/element/use-element-interaction.ts b/apps/web/src/hooks/timeline/element/use-element-interaction.ts index c92d6722..79f1272c 100644 --- a/apps/web/src/hooks/timeline/element/use-element-interaction.ts +++ b/apps/web/src/hooks/timeline/element/use-element-interaction.ts @@ -7,10 +7,15 @@ import { type RefObject, } from "react"; import { useEditor } from "@/hooks/use-editor"; +import { useShiftKey } from "@/hooks/use-shift-key"; import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { + DRAG_THRESHOLD_PX, + TIMELINE_CONSTANTS, +} from "@/constants/timeline-constants"; import { snapTimeToFrame } from "@/lib/time"; import { computeDropTarget } from "@/lib/timeline/drop-utils"; +import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils"; import { generateUUID } from "@/utils/id"; import { useTimelineSnapping } from "@/hooks/timeline/use-timeline-snapping"; import type { @@ -21,8 +26,6 @@ import type { } from "@/types/timeline"; import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping"; -const DRAG_THRESHOLD_PX = 5; - interface UseElementInteractionProps { zoomLevel: number; timelineRef: RefObject<HTMLDivElement | null>; @@ -54,24 +57,6 @@ interface PendingDragState { clickOffsetTime: number; } -function getMouseTimeFromClientX({ - clientX, - containerRect, - zoomLevel, - scrollLeft, -}: { - clientX: number; - containerRect: DOMRect; - zoomLevel: number; - scrollLeft: number; -}): number { - const mouseX = clientX - containerRect.left + scrollLeft; - return Math.max( - 0, - mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel), - ); -} - function getClickOffsetTime({ clientX, elementRect, @@ -175,6 +160,7 @@ export function useElementInteraction({ onSnapPointChange, }: UseElementInteractionProps) { const editor = useEditor(); + const isShiftHeldRef = useShiftKey(); const tracks = editor.timeline.getTracks(); const { snapElementEdge } = useTimelineSnapping(); const { @@ -230,7 +216,8 @@ export function useElementInteraction({ frameSnappedTime: number; movingElement: TimelineElement | null | undefined; }) => { - if (!snappingEnabled || !movingElement) { + const shouldSnap = snappingEnabled && !isShiftHeldRef.current; + if (!shouldSnap || !movingElement) { return { snappedTime: frameSnappedTime, snapPoint: null }; } @@ -268,7 +255,14 @@ export function useElementInteraction({ snapPoint: snapResult.snapPoint, }; }, - [snappingEnabled, editor.playback, snapElementEdge, tracks, zoomLevel], + [ + snappingEnabled, + editor.playback, + snapElementEdge, + tracks, + zoomLevel, + isShiftHeldRef, + ], ); useEffect(() => { diff --git a/apps/web/src/hooks/timeline/element/use-element-resize.ts b/apps/web/src/hooks/timeline/element/use-element-resize.ts index 803d9788..14693fff 100644 --- a/apps/web/src/hooks/timeline/element/use-element-resize.ts +++ b/apps/web/src/hooks/timeline/element/use-element-resize.ts @@ -1,7 +1,9 @@ import { useState, useEffect, useRef, useCallback } from "react"; -import type { TimelineElement, TimelineTrack } from "@/types/timeline"; +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import { snapTimeToFrame } from "@/lib/time"; -import { EditorCore } from "@/core"; +import type { TimelineElement, TimelineTrack } from "@/types/timeline"; +import { useEditor } from "@/hooks/use-editor"; +import { useShiftKey } from "@/hooks/use-shift-key"; import { useTimelineSnapping, type SnapPoint, @@ -33,8 +35,9 @@ export function useTimelineElementResize({ onSnapPointChange, onResizeStateChange, }: UseTimelineElementResizeProps) { - const editor = EditorCore.getInstance(); + const editor = useEditor(); const activeProject = editor.project.getActive(); + const isShiftHeldRef = useShiftKey(); const snappingEnabled = useTimelineStore((state) => state.snappingEnabled); const { findSnapPoints, snapToNearestPoint } = useTimelineSnapping(); @@ -49,21 +52,21 @@ export function useTimelineElementResize({ const currentDurationRef = useRef(element.duration); const handleResizeStart = ({ - e, + event, elementId, side, }: { - e: React.MouseEvent; + event: React.MouseEvent; elementId: string; side: "left" | "right"; }) => { - e.stopPropagation(); - e.preventDefault(); + event.stopPropagation(); + event.preventDefault(); setResizing({ elementId, side, - startX: e.clientX, + startX: event.clientX, initialTrimStart: element.trimStart, initialTrimEnd: element.trimEnd, initialStartTime: element.startTime, @@ -89,104 +92,69 @@ export function useTimelineElementResize({ return false; }, [element.type]); - const updateTrimFromMouseMove = useCallback(({ clientX }: { clientX: number }) => { - if (!resizing) return; + const updateTrimFromMouseMove = useCallback( + ({ clientX }: { clientX: number }) => { + if (!resizing) return; - const deltaX = clientX - resizing.startX; - let deltaTime = deltaX / (50 * zoomLevel); - let resizeSnapPoint: SnapPoint | null = null; + const deltaX = clientX - resizing.startX; + let deltaTime = + deltaX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel); + let resizeSnapPoint: SnapPoint | null = null; - const projectFps = activeProject.settings.fps; - const minDurationSeconds = 1 / projectFps; - const canSnap = snappingEnabled; - if (canSnap) { - const tracks = editor.timeline.getTracks(); - const playheadTime = editor.playback.getCurrentTime(); - const snapPoints = findSnapPoints({ - tracks, - playheadTime, - excludeElementId: element.id, - }); - if (resizing.side === "left") { - const targetStartTime = resizing.initialStartTime + deltaTime; - const snapResult = snapToNearestPoint({ - targetTime: targetStartTime, - snapPoints, - zoomLevel, - }); - resizeSnapPoint = snapResult.snapPoint; - if (snapResult.snapPoint) { - deltaTime = snapResult.snappedTime - resizing.initialStartTime; - } - } else { - const baseEndTime = - resizing.initialStartTime + resizing.initialDuration; - const targetEndTime = baseEndTime + deltaTime; - const snapResult = snapToNearestPoint({ - targetTime: targetEndTime, - snapPoints, - zoomLevel, + const projectFps = activeProject.settings.fps; + const minDurationSeconds = 1 / projectFps; + const shouldSnap = snappingEnabled && !isShiftHeldRef.current; + if (shouldSnap) { + const tracks = editor.timeline.getTracks(); + const playheadTime = editor.playback.getCurrentTime(); + const snapPoints = findSnapPoints({ + tracks, + playheadTime, + excludeElementId: element.id, }); - resizeSnapPoint = snapResult.snapPoint; - if (snapResult.snapPoint) { - deltaTime = snapResult.snappedTime - baseEndTime; + if (resizing.side === "left") { + const targetStartTime = resizing.initialStartTime + deltaTime; + const snapResult = snapToNearestPoint({ + targetTime: targetStartTime, + snapPoints, + zoomLevel, + }); + resizeSnapPoint = snapResult.snapPoint; + if (snapResult.snapPoint) { + deltaTime = snapResult.snappedTime - resizing.initialStartTime; + } + } else { + const baseEndTime = + resizing.initialStartTime + resizing.initialDuration; + const targetEndTime = baseEndTime + deltaTime; + const snapResult = snapToNearestPoint({ + targetTime: targetEndTime, + snapPoints, + zoomLevel, + }); + resizeSnapPoint = snapResult.snapPoint; + if (snapResult.snapPoint) { + deltaTime = snapResult.snappedTime - baseEndTime; + } } } - } - onSnapPointChange?.(resizeSnapPoint); + onSnapPointChange?.(resizeSnapPoint); - if (resizing.side === "left") { - const sourceDuration = - resizing.initialTrimStart + - resizing.initialDuration + - resizing.initialTrimEnd; - const maxAllowed = - sourceDuration - resizing.initialTrimEnd - minDurationSeconds; - const calculated = resizing.initialTrimStart + deltaTime; - - if (calculated >= 0 && calculated <= maxAllowed) { - const newTrimStart = snapTimeToFrame({ - time: Math.min(maxAllowed, calculated), - fps: projectFps, - }); - const trimDelta = newTrimStart - resizing.initialTrimStart; - const newStartTime = snapTimeToFrame({ - time: resizing.initialStartTime + trimDelta, - fps: projectFps, - }); - const newDuration = snapTimeToFrame({ - time: resizing.initialDuration - trimDelta, - fps: projectFps, - }); + if (resizing.side === "left") { + const sourceDuration = + resizing.initialTrimStart + + resizing.initialDuration + + resizing.initialTrimEnd; + const maxAllowed = + sourceDuration - resizing.initialTrimEnd - minDurationSeconds; + const calculated = resizing.initialTrimStart + deltaTime; - setCurrentTrimStart(newTrimStart); - setCurrentStartTime(newStartTime); - setCurrentDuration(newDuration); - currentTrimStartRef.current = newTrimStart; - currentStartTimeRef.current = newStartTime; - currentDurationRef.current = newDuration; - } else if (calculated < 0) { - if (canExtendElementDuration()) { - const extensionAmount = Math.abs(calculated); - const maxExtension = resizing.initialStartTime; - const actualExtension = Math.min(extensionAmount, maxExtension); - const newStartTime = snapTimeToFrame({ - time: resizing.initialStartTime - actualExtension, + if (calculated >= 0 && calculated <= maxAllowed) { + const newTrimStart = snapTimeToFrame({ + time: Math.min(maxAllowed, calculated), fps: projectFps, }); - const newDuration = snapTimeToFrame({ - time: resizing.initialDuration + actualExtension, - fps: projectFps, - }); - - setCurrentTrimStart(0); - setCurrentStartTime(newStartTime); - setCurrentDuration(newDuration); - currentTrimStartRef.current = 0; - currentStartTimeRef.current = newStartTime; - currentDurationRef.current = newDuration; - } else { - const trimDelta = 0 - resizing.initialTrimStart; + const trimDelta = newTrimStart - resizing.initialTrimStart; const newStartTime = snapTimeToFrame({ time: resizing.initialStartTime + trimDelta, fps: projectFps, @@ -196,68 +164,119 @@ export function useTimelineElementResize({ fps: projectFps, }); - setCurrentTrimStart(0); + setCurrentTrimStart(newTrimStart); setCurrentStartTime(newStartTime); setCurrentDuration(newDuration); - currentTrimStartRef.current = 0; + currentTrimStartRef.current = newTrimStart; currentStartTimeRef.current = newStartTime; currentDurationRef.current = newDuration; + } else if (calculated < 0) { + if (canExtendElementDuration()) { + const extensionAmount = Math.abs(calculated); + const maxExtension = resizing.initialStartTime; + const actualExtension = Math.min(extensionAmount, maxExtension); + const newStartTime = snapTimeToFrame({ + time: resizing.initialStartTime - actualExtension, + fps: projectFps, + }); + const newDuration = snapTimeToFrame({ + time: resizing.initialDuration + actualExtension, + fps: projectFps, + }); + + setCurrentTrimStart(0); + setCurrentStartTime(newStartTime); + setCurrentDuration(newDuration); + currentTrimStartRef.current = 0; + currentStartTimeRef.current = newStartTime; + currentDurationRef.current = newDuration; + } else { + const trimDelta = 0 - resizing.initialTrimStart; + const newStartTime = snapTimeToFrame({ + time: resizing.initialStartTime + trimDelta, + fps: projectFps, + }); + const newDuration = snapTimeToFrame({ + time: resizing.initialDuration - trimDelta, + fps: projectFps, + }); + + setCurrentTrimStart(0); + setCurrentStartTime(newStartTime); + setCurrentDuration(newDuration); + currentTrimStartRef.current = 0; + currentStartTimeRef.current = newStartTime; + currentDurationRef.current = newDuration; + } } - } - } else { - const sourceDuration = - resizing.initialTrimStart + - resizing.initialDuration + - resizing.initialTrimEnd; - const newTrimEnd = resizing.initialTrimEnd - deltaTime; + } else { + const sourceDuration = + resizing.initialTrimStart + + resizing.initialDuration + + resizing.initialTrimEnd; + const newTrimEnd = resizing.initialTrimEnd - deltaTime; - if (newTrimEnd < 0) { - if (canExtendElementDuration()) { - const extensionNeeded = Math.abs(newTrimEnd); - const baseDuration = - resizing.initialDuration + resizing.initialTrimEnd; - const newDuration = snapTimeToFrame({ - time: baseDuration + extensionNeeded, - fps: projectFps, - }); + if (newTrimEnd < 0) { + if (canExtendElementDuration()) { + const extensionNeeded = Math.abs(newTrimEnd); + const baseDuration = + resizing.initialDuration + resizing.initialTrimEnd; + const newDuration = snapTimeToFrame({ + time: baseDuration + extensionNeeded, + fps: projectFps, + }); - setCurrentDuration(newDuration); - setCurrentTrimEnd(0); - currentDurationRef.current = newDuration; - currentTrimEndRef.current = 0; + setCurrentDuration(newDuration); + setCurrentTrimEnd(0); + currentDurationRef.current = newDuration; + currentTrimEndRef.current = 0; + } else { + const extensionToLimit = resizing.initialTrimEnd; + const newDuration = snapTimeToFrame({ + time: resizing.initialDuration + extensionToLimit, + fps: projectFps, + }); + + setCurrentDuration(newDuration); + setCurrentTrimEnd(0); + currentDurationRef.current = newDuration; + currentTrimEndRef.current = 0; + } } else { - const extensionToLimit = resizing.initialTrimEnd; + const maxTrimEnd = + sourceDuration - resizing.initialTrimStart - minDurationSeconds; + const clampedTrimEnd = Math.min(maxTrimEnd, Math.max(0, newTrimEnd)); + const finalTrimEnd = snapTimeToFrame({ + time: clampedTrimEnd, + fps: projectFps, + }); + const trimDelta = finalTrimEnd - resizing.initialTrimEnd; const newDuration = snapTimeToFrame({ - time: resizing.initialDuration + extensionToLimit, + time: resizing.initialDuration - trimDelta, fps: projectFps, }); + setCurrentTrimEnd(finalTrimEnd); setCurrentDuration(newDuration); - setCurrentTrimEnd(0); + currentTrimEndRef.current = finalTrimEnd; currentDurationRef.current = newDuration; - currentTrimEndRef.current = 0; } - } else { - const maxTrimEnd = - sourceDuration - resizing.initialTrimStart - minDurationSeconds; - const clampedTrimEnd = Math.min(maxTrimEnd, Math.max(0, newTrimEnd)); - const finalTrimEnd = snapTimeToFrame({ - time: clampedTrimEnd, - fps: projectFps, - }); - const trimDelta = finalTrimEnd - resizing.initialTrimEnd; - const newDuration = snapTimeToFrame({ - time: resizing.initialDuration - trimDelta, - fps: projectFps, - }); - - setCurrentTrimEnd(finalTrimEnd); - setCurrentDuration(newDuration); - currentTrimEndRef.current = finalTrimEnd; - currentDurationRef.current = newDuration; } - } - }, [resizing, zoomLevel, activeProject.settings.fps, snappingEnabled, editor, findSnapPoints, snapToNearestPoint, element.id, onSnapPointChange, canExtendElementDuration]); + }, + [ + resizing, + zoomLevel, + activeProject.settings.fps, + snappingEnabled, + editor, + findSnapPoints, + snapToNearestPoint, + element.id, + onSnapPointChange, + canExtendElementDuration, + isShiftHeldRef, + ], + ); const handleResizeEnd = useCallback(() => { if (!resizing) return; @@ -297,7 +316,14 @@ export function useTimelineElementResize({ setResizing(null); onResizeStateChange?.({ isResizing: false }); onSnapPointChange?.(null); - }, [resizing, editor.timeline, element.id, track.id, onResizeStateChange, onSnapPointChange]); + }, [ + resizing, + editor.timeline, + element.id, + track.id, + onResizeStateChange, + onSnapPointChange, + ]); useEffect(() => { if (!resizing) return; diff --git a/apps/web/src/hooks/timeline/element/use-element-selection.ts b/apps/web/src/hooks/timeline/element/use-element-selection.ts index 38694c8b..c1e5b53f 100644 --- a/apps/web/src/hooks/timeline/element/use-element-selection.ts +++ b/apps/web/src/hooks/timeline/element/use-element-selection.ts @@ -21,7 +21,9 @@ export function useElementSelection() { const selectElement = useCallback( ({ trackId, elementId }: ElementRef) => { - editor.selection.setSelectedElements({ elements: [{ trackId, elementId }] }); + editor.selection.setSelectedElements({ + elements: [{ trackId, elementId }], + }); }, [editor], ); diff --git a/apps/web/src/hooks/timeline/use-bookmark-drag.ts b/apps/web/src/hooks/timeline/use-bookmark-drag.ts new file mode 100644 index 00000000..faf28e40 --- /dev/null +++ b/apps/web/src/hooks/timeline/use-bookmark-drag.ts @@ -0,0 +1,289 @@ +import { + useState, + useCallback, + useEffect, + useRef, + type RefObject, +} from "react"; +import { useEditor } from "@/hooks/use-editor"; +import { useShiftKey } from "@/hooks/use-shift-key"; +import { DRAG_THRESHOLD_PX } from "@/constants/timeline-constants"; +import { snapTimeToFrame } from "@/lib/time"; +import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils"; +import { useTimelineSnapping } from "@/hooks/timeline/use-timeline-snapping"; +import type { Bookmark } from "@/types/timeline"; +import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping"; + +export interface BookmarkDragState { + isDragging: boolean; + bookmarkTime: number | null; + currentTime: number; +} + +interface PendingBookmarkDrag { + bookmarkTime: number; + startMouseX: number; + startMouseY: number; +} + +interface UseBookmarkDragProps { + zoomLevel: number; + scrollRef: RefObject<HTMLElement | null>; + snappingEnabled: boolean; + onSnapPointChange?: (snapPoint: SnapPoint | null) => void; +} + +export function useBookmarkDrag({ + zoomLevel, + scrollRef, + snappingEnabled, + onSnapPointChange, +}: UseBookmarkDragProps) { + const editor = useEditor(); + const isShiftHeldRef = useShiftKey(); + const tracks = editor.timeline.getTracks(); + const activeScene = editor.scenes.getActiveScene(); + const bookmarks = activeScene?.bookmarks ?? []; + const playheadTime = editor.playback.getCurrentTime(); + const duration = editor.timeline.getTotalDuration(); + + const { findSnapPoints, snapToNearestPoint } = useTimelineSnapping(); + + const [dragState, setDragState] = useState<BookmarkDragState>({ + isDragging: false, + bookmarkTime: null, + currentTime: 0, + }); + const [isPendingDrag, setIsPendingDrag] = useState(false); + const pendingDragRef = useRef<PendingBookmarkDrag | null>(null); + const lastMouseXRef = useRef(0); + + const startDrag = useCallback( + ({ + bookmarkTime, + initialCurrentTime, + }: { + bookmarkTime: number; + initialCurrentTime: number; + }) => { + setDragState({ + isDragging: true, + bookmarkTime, + currentTime: initialCurrentTime, + }); + }, + [], + ); + + const endDrag = useCallback(() => { + setDragState({ + isDragging: false, + bookmarkTime: null, + currentTime: 0, + }); + }, []); + + const getSnapResult = useCallback( + ({ + rawTime, + excludeBookmarkTime, + }: { + rawTime: number; + excludeBookmarkTime: number; + }): { snappedTime: number; snapPoint: SnapPoint | null } => { + const shouldSnap = snappingEnabled && !isShiftHeldRef.current; + if (!shouldSnap) { + return { snappedTime: rawTime, snapPoint: null }; + } + + const snapPoints = findSnapPoints({ + tracks, + playheadTime, + bookmarks, + excludeBookmarkTime, + }); + const result = snapToNearestPoint({ + targetTime: rawTime, + snapPoints, + zoomLevel, + }); + return { + snappedTime: result.snappedTime, + snapPoint: result.snapPoint, + }; + }, + [ + snappingEnabled, + findSnapPoints, + snapToNearestPoint, + tracks, + playheadTime, + bookmarks, + zoomLevel, + isShiftHeldRef, + ], + ); + + useEffect(() => { + if (!dragState.isDragging && !isPendingDrag) return; + + const handleMouseMove = (event: MouseEvent) => { + lastMouseXRef.current = event.clientX; + + const scrollContainer = scrollRef.current; + if (!scrollContainer) return; + + if (isPendingDrag && pendingDragRef.current) { + const { startMouseX, startMouseY, bookmarkTime } = + pendingDragRef.current; + const deltaX = Math.abs(event.clientX - startMouseX); + const deltaY = Math.abs(event.clientY - startMouseY); + + if (deltaX <= DRAG_THRESHOLD_PX && deltaY <= DRAG_THRESHOLD_PX) { + return; + } + + const activeProject = editor.project.getActive(); + if (!activeProject) return; + + const scrollLeft = scrollContainer.scrollLeft; + const mouseTime = getMouseTimeFromClientX({ + clientX: event.clientX, + containerRect: scrollContainer.getBoundingClientRect(), + zoomLevel, + scrollLeft, + }); + const frameSnappedTime = snapTimeToFrame({ + time: Math.max(0, Math.min(mouseTime, duration)), + fps: activeProject.settings.fps, + }); + const { snappedTime: initialTime } = getSnapResult({ + rawTime: frameSnappedTime, + excludeBookmarkTime: bookmarkTime, + }); + + startDrag({ + bookmarkTime, + initialCurrentTime: initialTime, + }); + pendingDragRef.current = null; + setIsPendingDrag(false); + return; + } + + if (!dragState.isDragging || dragState.bookmarkTime === null) return; + + const activeProject = editor.project.getActive(); + if (!activeProject) return; + + const scrollLeft = scrollContainer.scrollLeft; + const mouseTime = getMouseTimeFromClientX({ + clientX: event.clientX, + containerRect: scrollContainer.getBoundingClientRect(), + zoomLevel, + scrollLeft, + }); + const clampedTime = Math.max(0, Math.min(mouseTime, duration)); + const frameSnappedTime = snapTimeToFrame({ + time: clampedTime, + fps: activeProject.settings.fps, + }); + const snapResult = getSnapResult({ + rawTime: frameSnappedTime, + excludeBookmarkTime: dragState.bookmarkTime, + }); + + setDragState((previousDragState) => ({ + ...previousDragState, + currentTime: snapResult.snappedTime, + })); + onSnapPointChange?.(snapResult.snapPoint); + }; + + document.addEventListener("mousemove", handleMouseMove); + return () => document.removeEventListener("mousemove", handleMouseMove); + }, [ + dragState.isDragging, + dragState.bookmarkTime, + zoomLevel, + duration, + editor.project, + scrollRef, + isPendingDrag, + startDrag, + getSnapResult, + onSnapPointChange, + ]); + + useEffect(() => { + if (!dragState.isDragging) return; + + const handleMouseUp = () => { + if (dragState.bookmarkTime === null) { + endDrag(); + onSnapPointChange?.(null); + return; + } + + const clampedTime = Math.max( + 0, + Math.min(dragState.currentTime, duration), + ); + + editor.scenes.moveBookmark({ + fromTime: dragState.bookmarkTime, + toTime: clampedTime, + }); + + endDrag(); + onSnapPointChange?.(null); + }; + + document.addEventListener("mouseup", handleMouseUp); + return () => document.removeEventListener("mouseup", handleMouseUp); + }, [ + dragState.isDragging, + dragState.bookmarkTime, + dragState.currentTime, + duration, + endDrag, + onSnapPointChange, + editor.scenes, + ]); + + useEffect(() => { + if (!isPendingDrag) return; + + const handleMouseUp = () => { + pendingDragRef.current = null; + setIsPendingDrag(false); + onSnapPointChange?.(null); + }; + + document.addEventListener("mouseup", handleMouseUp); + return () => document.removeEventListener("mouseup", handleMouseUp); + }, [isPendingDrag, onSnapPointChange]); + + const handleBookmarkMouseDown = useCallback( + ({ event, bookmark }: { event: React.MouseEvent; bookmark: Bookmark }) => { + if (event.button !== 0) return; + + event.preventDefault(); + event.stopPropagation(); + + pendingDragRef.current = { + bookmarkTime: bookmark.time, + startMouseX: event.clientX, + startMouseY: event.clientY, + }; + setIsPendingDrag(true); + }, + [], + ); + + return { + dragState, + handleBookmarkMouseDown, + lastMouseXRef, + }; +} diff --git a/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts b/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts index 01af9362..632d2c78 100644 --- a/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts +++ b/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts @@ -7,10 +7,12 @@ import { snapTimeToFrame } from "@/lib/time"; import { buildTextElement, buildStickerElement, - buildUploadAudioElement, - buildVideoElement, - buildImageElement, + buildElementFromMedia, } from "@/lib/timeline/element-utils"; +import type { Command } from "@/lib/commands/base-command"; +import { AddMediaAssetCommand } from "@/lib/commands/media"; +import { AddTrackCommand, InsertElementCommand } from "@/lib/commands/timeline"; +import { BatchCommand } from "@/lib/commands"; import { computeDropTarget } from "@/lib/timeline/drop-utils"; import { getDragData, hasDragData } from "@/lib/drag-data"; import type { TrackType, DropTarget, ElementType } from "@/types/timeline"; @@ -231,7 +233,8 @@ export function useTimelineDragDrop({ } const element = buildStickerElement({ - iconName: dragData.iconName, + stickerId: dragData.stickerId, + name: dragData.name, startTime: target.xPosition, }); @@ -265,38 +268,18 @@ export function useTimelineDragDrop({ const duration = mediaAsset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION; + const element = buildElementFromMedia({ + mediaId: mediaAsset.id, + mediaType: mediaAsset.type, + name: mediaAsset.name, + duration, + startTime: target.xPosition, + }); - if (dragData.mediaType === "audio") { - editor.timeline.insertElement({ - placement: { mode: "explicit", trackId }, - element: buildUploadAudioElement({ - mediaId: mediaAsset.id, - name: mediaAsset.name, - duration, - startTime: target.xPosition, - }), - }); - } else if (dragData.mediaType === "video") { - editor.timeline.insertElement({ - placement: { mode: "explicit", trackId }, - element: buildVideoElement({ - mediaId: mediaAsset.id, - name: mediaAsset.name, - duration, - startTime: target.xPosition, - }), - }); - } else { - editor.timeline.insertElement({ - placement: { mode: "explicit", trackId }, - element: buildImageElement({ - mediaId: mediaAsset.id, - name: mediaAsset.name, - duration, - startTime: target.xPosition, - }), - }); - } + editor.timeline.insertElement({ + placement: { mode: "explicit", trackId }, + element, + }); }, [editor.timeline, mediaAssets, tracks], ); @@ -314,80 +297,67 @@ export function useTimelineDragDrop({ if (!activeProject) return; const processedAssets = await processMediaAssets({ files }); + const projectId = activeProject.metadata.id; for (const asset of processedAssets) { - await editor.media.addMediaAsset({ - projectId: activeProject.metadata.id, - asset, + const duration = + asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION; + const currentTracks = editor.timeline.getTracks(); + const dropTarget = computeDropTarget({ + elementType: asset.type, + mouseX, + mouseY, + tracks: currentTracks, + playheadTime: currentTime, + isExternalDrop: true, + elementDuration: duration, + pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND, + zoomLevel, }); - const added = editor.media - .getAssets() - .find((m) => m.name === asset.name && m.url === asset.url); + const trackType: TrackType = asset.type === "audio" ? "audio" : "video"; + const addMediaCmd = new AddMediaAssetCommand(projectId, asset); + const assetId = addMediaCmd.getAssetId(); - if (added) { - const duration = - added.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION; - const currentTracks = editor.timeline.getTracks(); - const dropTarget = computeDropTarget({ - elementType: added.type, - mouseX, - mouseY, - tracks: currentTracks, - playheadTime: currentTime, - isExternalDrop: true, - elementDuration: duration, - pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND, - zoomLevel, - }); + const commands: Command[] = [addMediaCmd]; - const trackType: TrackType = - added.type === "audio" ? "audio" : "video"; - const trackId = dropTarget.isNewTrack - ? editor.timeline.addTrack({ - type: trackType, - index: dropTarget.trackIndex, - }) - : currentTracks[dropTarget.trackIndex]?.id; + let trackId: string | undefined; + if (dropTarget.isNewTrack) { + const addTrackCmd = new AddTrackCommand( + trackType, + dropTarget.trackIndex, + ); + trackId = addTrackCmd.getTrackId(); + commands.unshift(addTrackCmd); + } else { + trackId = currentTracks[dropTarget.trackIndex]?.id; + } - if (!trackId) return; + if (!trackId) return; - if (added.type === "audio") { - editor.timeline.insertElement({ - placement: { mode: "explicit", trackId }, - element: buildUploadAudioElement({ - mediaId: added.id, - name: added.name, - duration, - startTime: dropTarget.xPosition, - buffer: new AudioBuffer({ length: 1, sampleRate: 44100 }), - }), - }); - } else if (added.type === "video") { - editor.timeline.insertElement({ - placement: { mode: "explicit", trackId }, - element: buildVideoElement({ - mediaId: added.id, - name: added.name, - duration, - startTime: dropTarget.xPosition, - }), - }); - } else { - editor.timeline.insertElement({ - placement: { mode: "explicit", trackId }, - element: buildImageElement({ - mediaId: added.id, - name: added.name, - duration, - startTime: dropTarget.xPosition, - }), - }); - } - } + const element = buildElementFromMedia({ + mediaId: assetId, + mediaType: asset.type, + name: asset.name, + duration, + startTime: dropTarget.xPosition, + buffer: + asset.type === "audio" + ? new AudioBuffer({ length: 1, sampleRate: 44100 }) + : undefined, + }); + + const insertCmd = new InsertElementCommand({ + element, + placement: { mode: "explicit", trackId }, + }); + commands.push(insertCmd); + + const batchCmd = new BatchCommand(commands); + editor.command.execute({ command: batchCmd }); } }, - [activeProject, editor.media, editor.timeline, currentTime, zoomLevel], + [activeProject, editor.command, editor.timeline, currentTime, zoomLevel], ); const handleDrop = useCallback( diff --git a/apps/web/src/hooks/timeline/use-timeline-playhead.ts b/apps/web/src/hooks/timeline/use-timeline-playhead.ts index addef433..44e12ab1 100644 --- a/apps/web/src/hooks/timeline/use-timeline-playhead.ts +++ b/apps/web/src/hooks/timeline/use-timeline-playhead.ts @@ -2,6 +2,11 @@ import { getSnappedSeekTime } from "@/lib/time"; import { useState, useEffect, useCallback, useRef } from "react"; import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll"; import { useEditor } from "../use-editor"; +import { useShiftKey } from "@/hooks/use-shift-key"; +import { + useTimelineSnapping, + type SnapPoint, +} from "@/hooks/timeline/use-timeline-snapping"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; interface UseTimelinePlayheadProps { @@ -25,6 +30,11 @@ export function useTimelinePlayhead({ const duration = editor.timeline.getTotalDuration(); const isPlaying = editor.playback.getIsPlaying(); const isScrubbing = editor.playback.getIsScrubbing(); + const isShiftHeldRef = useShiftKey(); + const { snapToNearestPoint } = useTimelineSnapping({ + enableElementSnapping: false, + enablePlayheadSnapping: false, + }); const seek = useCallback( ({ time }: { time: number }) => editor.playback.seek({ time }), @@ -62,19 +72,45 @@ export function useTimelinePlayhead({ clampedMouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel), ), ); + const framesPerSecond = activeProject.settings.fps; - const time = getSnappedSeekTime({ + const frameTime = getSnappedSeekTime({ rawTime, duration, fps: framesPerSecond, }); + const bookmarks = editor.scenes.getActiveScene()?.bookmarks ?? []; + const bookmarkSnapPoints: SnapPoint[] = bookmarks.map((bookmark) => ({ + time: bookmark.time, + type: "bookmark", + })); + const shouldSnapToBookmark = + !isShiftHeldRef.current && bookmarkSnapPoints.length > 0; + const snapResult = shouldSnapToBookmark + ? snapToNearestPoint({ + targetTime: frameTime, + snapPoints: bookmarkSnapPoints, + zoomLevel, + }) + : null; + const time = snapResult?.snapPoint ? snapResult.snappedTime : frameTime; + setScrubTime(time); seek({ time }); lastMouseXRef.current = event.clientX; }, - [duration, zoomLevel, seek, rulerRef, activeProject.settings.fps], + [ + duration, + zoomLevel, + seek, + rulerRef, + activeProject.settings.fps, + isShiftHeldRef, + editor.scenes, + snapToNearestPoint, + ], ); const handlePlayheadMouseDown = useCallback( diff --git a/apps/web/src/hooks/timeline/use-timeline-snapping.ts b/apps/web/src/hooks/timeline/use-timeline-snapping.ts index 4cd6f775..5de75448 100644 --- a/apps/web/src/hooks/timeline/use-timeline-snapping.ts +++ b/apps/web/src/hooks/timeline/use-timeline-snapping.ts @@ -1,10 +1,11 @@ import { useCallback } from "react"; -import type { TimelineTrack } from "@/types/timeline"; +import type { Bookmark, TimelineTrack } from "@/types/timeline"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { BOOKMARK_TIME_EPSILON } from "@/lib/timeline/bookmarks"; export interface SnapPoint { time: number; - type: "element-start" | "element-end" | "playhead"; + type: "element-start" | "element-end" | "playhead" | "bookmark"; elementId?: string; trackId?: string; } @@ -19,22 +20,28 @@ export interface UseTimelineSnappingOptions { snapThreshold?: number; enableElementSnapping?: boolean; enablePlayheadSnapping?: boolean; + enableBookmarkSnapping?: boolean; } export function useTimelineSnapping({ snapThreshold = 10, enableElementSnapping = true, enablePlayheadSnapping = true, + enableBookmarkSnapping = true, }: UseTimelineSnappingOptions = {}) { const findSnapPoints = useCallback( ({ tracks, playheadTime, excludeElementId, + bookmarks = [], + excludeBookmarkTime, }: { tracks: Array<TimelineTrack>; playheadTime: number; excludeElementId?: string; + bookmarks?: Array<Bookmark>; + excludeBookmarkTime?: number; }): SnapPoint[] => { const snapPoints: SnapPoint[] = []; @@ -71,9 +78,25 @@ export function useTimelineSnapping({ }); } + if (enableBookmarkSnapping) { + for (const bookmark of bookmarks) { + if ( + excludeBookmarkTime != null && + Math.abs(bookmark.time - excludeBookmarkTime) < + BOOKMARK_TIME_EPSILON + ) { + continue; + } + snapPoints.push({ + time: bookmark.time, + type: "bookmark", + }); + } + } + return snapPoints; }, - [enableElementSnapping, enablePlayheadSnapping], + [enableElementSnapping, enablePlayheadSnapping, enableBookmarkSnapping], ); const snapToNearestPoint = useCallback( @@ -118,6 +141,7 @@ export function useTimelineSnapping({ zoomLevel, excludeElementId, snapToStart = true, + bookmarks = [], }: { targetTime: number; elementDuration: number; @@ -126,11 +150,13 @@ export function useTimelineSnapping({ zoomLevel: number; excludeElementId?: string; snapToStart?: boolean; + bookmarks?: Array<Bookmark>; }): SnapResult => { const snapPoints = findSnapPoints({ tracks, playheadTime, excludeElementId, + bookmarks, }); const effectiveTargetTime = snapToStart diff --git a/apps/web/src/hooks/timeline/use-timeline-zoom.ts b/apps/web/src/hooks/timeline/use-timeline-zoom.ts index 10563a05..095c64dc 100644 --- a/apps/web/src/hooks/timeline/use-timeline-zoom.ts +++ b/apps/web/src/hooks/timeline/use-timeline-zoom.ts @@ -57,6 +57,8 @@ export function useTimelineZoom({ const previousZoomRef = useRef(zoomLevel); const hasRestoredScrollRef = useRef(false); const preZoomScrollLeftRef = useRef(0); + const prePlayheadAnchorScrollLeftRef = useRef(0); + const isInPlayheadAnchorModeRef = useRef(false); const setZoomLevel = useCallback( (updater: number | ((prev: number) => number)) => { @@ -89,8 +91,6 @@ export function useTimelineZoom({ ); return nextZoom; }); - // for horizontal scrolling (when shift is held or horizontal wheel movement), - // let the event bubble up to allow ScrollArea to handle it return; } }, @@ -143,6 +143,36 @@ export function useTimelineZoom({ const currentScrollLeft = preZoomScrollLeftRef.current; const playheadTime = editor.playback.getCurrentTime(); const sliderPercent = zoomToSlider({ zoomLevel, minZoom }); + const previousSliderPercent = zoomToSlider({ + zoomLevel: previousZoom, + minZoom, + }); + const isCrossingThresholdUp = + previousSliderPercent < + TIMELINE_CONSTANTS.ZOOM_ANCHOR_PLAYHEAD_THRESHOLD && + sliderPercent >= TIMELINE_CONSTANTS.ZOOM_ANCHOR_PLAYHEAD_THRESHOLD; + const isCrossingThresholdDown = + previousSliderPercent >= + TIMELINE_CONSTANTS.ZOOM_ANCHOR_PLAYHEAD_THRESHOLD && + sliderPercent < TIMELINE_CONSTANTS.ZOOM_ANCHOR_PLAYHEAD_THRESHOLD; + + const syncScroll = (scrollLeft: number) => { + scrollElement.scrollLeft = scrollLeft; + if (rulerScrollRef.current) { + rulerScrollRef.current.scrollLeft = scrollLeft; + } + }; + + const clampScrollLeft = (scrollLeft: number) => { + const maxScrollLeft = + scrollElement.scrollWidth - scrollElement.clientWidth; + return Math.max(0, Math.min(maxScrollLeft, scrollLeft)); + }; + + if (isCrossingThresholdUp) { + prePlayheadAnchorScrollLeftRef.current = currentScrollLeft; + isInPlayheadAnchorModeRef.current = true; + } if (sliderPercent >= TIMELINE_CONSTANTS.ZOOM_ANCHOR_PLAYHEAD_THRESHOLD) { const playheadPixelsBefore = @@ -153,17 +183,10 @@ export function useTimelineZoom({ const viewportOffset = playheadPixelsBefore - currentScrollLeft; const newScrollLeft = playheadPixelsAfter - viewportOffset; - const maxScrollLeft = - scrollElement.scrollWidth - scrollElement.clientWidth; - const clampedScrollLeft = Math.max( - 0, - Math.min(maxScrollLeft, newScrollLeft), - ); - - scrollElement.scrollLeft = clampedScrollLeft; - if (rulerScrollRef.current) { - rulerScrollRef.current.scrollLeft = clampedScrollLeft; - } + syncScroll(clampScrollLeft(newScrollLeft)); + } else if (isCrossingThresholdDown && isInPlayheadAnchorModeRef.current) { + syncScroll(clampScrollLeft(prePlayheadAnchorScrollLeftRef.current)); + isInPlayheadAnchorModeRef.current = false; } previousZoomRef.current = zoomLevel; @@ -217,7 +240,6 @@ export function useTimelineZoom({ const observer = new ResizeObserver(() => { if (scrollElement.scrollWidth > 0) { restoreScroll(); - hasRestoredScrollRef.current = true; observer.disconnect(); } }); diff --git a/apps/web/src/hooks/use-focus-lock.ts b/apps/web/src/hooks/use-focus-lock.ts new file mode 100644 index 00000000..80baebdb --- /dev/null +++ b/apps/web/src/hooks/use-focus-lock.ts @@ -0,0 +1,79 @@ +import { useEffect, useRef } from "react"; + +type FocusLockCursor = "text" | "default" | "pointer" | "crosshair"; + +const DATA_ATTR = "data-focus-locked"; + +function buildFocusLockCSS({ + cursor, + allowSelector, +}: { + cursor: FocusLockCursor; + allowSelector?: string; +}) { + const rules = [ + `*, *::before, *::after { pointer-events: none !important; cursor: ${cursor} !important; }`, + `[${DATA_ATTR}], [${DATA_ATTR}] * { pointer-events: auto !important; cursor: auto !important; }`, + ]; + + if (allowSelector) { + rules.push( + `${allowSelector} { pointer-events: auto !important; cursor: auto !important; }`, + ); + } + + return rules.join("\n"); +} + +export function useFocusLock<T extends HTMLElement = HTMLElement>({ + isActive, + onDismiss, + cursor = "default", + allowSelector, +}: { + isActive: boolean; + onDismiss: () => void; + cursor?: FocusLockCursor; + allowSelector?: string; +}) { + const containerRef = useRef<T>(null); + const onDismissRef = useRef(onDismiss); + onDismissRef.current = onDismiss; + + useEffect(() => { + if (!isActive) return; + const container = containerRef.current; + if (!container) return; + + container.setAttribute(DATA_ATTR, ""); + + const focusLockStyle = document.createElement("style"); + focusLockStyle.textContent = buildFocusLockCSS({ cursor, allowSelector }); + document.head.appendChild(focusLockStyle); + + const handleOutsidePointerDown = (event: PointerEvent) => { + if (event.button !== 0) return; + if (container.contains(event.target as Node)) return; + + const target = event.target as Element | null; + const isAllowedTarget = allowSelector && target?.closest(allowSelector); + if (isAllowedTarget) return; + + onDismissRef.current(); + }; + + document.addEventListener("pointerdown", handleOutsidePointerDown, true); + + return () => { + document.removeEventListener( + "pointerdown", + handleOutsidePointerDown, + true, + ); + container.removeAttribute(DATA_ATTR); + focusLockStyle.remove(); + }; + }, [isActive, cursor, allowSelector]); + + return { containerRef }; +} diff --git a/apps/web/src/hooks/use-infinite-scroll.ts b/apps/web/src/hooks/use-infinite-scroll.ts index 4f823834..8d6e5fcc 100644 --- a/apps/web/src/hooks/use-infinite-scroll.ts +++ b/apps/web/src/hooks/use-infinite-scroll.ts @@ -1,35 +1,35 @@ -import { useRef, useCallback } from "react";
-
-interface UseInfiniteScrollOptions {
- onLoadMore: () => void;
- hasMore: boolean;
- isLoading: boolean;
- threshold?: number;
- enabled?: boolean;
-}
-
-export function useInfiniteScroll({
- onLoadMore,
- hasMore,
- isLoading,
- threshold = 200,
- enabled = true,
-}: UseInfiniteScrollOptions) {
- const scrollAreaRef = useRef<HTMLDivElement>(null);
-
- const handleScroll = useCallback(
- (event: React.UIEvent<HTMLDivElement>) => {
- if (!enabled) return;
-
- const { scrollTop, scrollHeight, clientHeight } = event.currentTarget;
- const nearBottom = scrollTop + clientHeight >= scrollHeight - threshold;
-
- if (nearBottom && hasMore && !isLoading) {
- onLoadMore();
- }
- },
- [onLoadMore, hasMore, isLoading, threshold, enabled],
- );
-
- return { scrollAreaRef, handleScroll };
-}
+import { useRef, useCallback } from "react"; + +interface UseInfiniteScrollOptions { + onLoadMore: () => void; + hasMore: boolean; + isLoading: boolean; + threshold?: number; + enabled?: boolean; +} + +export function useInfiniteScroll({ + onLoadMore, + hasMore, + isLoading, + threshold = 200, + enabled = true, +}: UseInfiniteScrollOptions) { + const scrollAreaRef = useRef<HTMLDivElement>(null); + + const handleScroll = useCallback( + (event: React.UIEvent<HTMLDivElement>) => { + if (!enabled) return; + + const { scrollTop, scrollHeight, clientHeight } = event.currentTarget; + const nearBottom = scrollTop + clientHeight >= scrollHeight - threshold; + + if (nearBottom && hasMore && !isLoading) { + onLoadMore(); + } + }, + [onLoadMore, hasMore, isLoading, threshold, enabled], + ); + + return { scrollAreaRef, handleScroll }; +} diff --git a/apps/web/src/hooks/use-paste-media.ts b/apps/web/src/hooks/use-paste-media.ts new file mode 100644 index 00000000..4533ab28 --- /dev/null +++ b/apps/web/src/hooks/use-paste-media.ts @@ -0,0 +1,99 @@ +import { useEffect } from "react"; +import { toast } from "sonner"; +import { useEditor } from "@/hooks/use-editor"; +import { processMediaAssets } from "@/lib/media/processing"; +import { buildElementFromMedia } from "@/lib/timeline/element-utils"; +import { AddMediaAssetCommand } from "@/lib/commands/media"; +import { InsertElementCommand } from "@/lib/commands/timeline"; +import { BatchCommand } from "@/lib/commands"; +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { isTypableDOMElement } from "@/utils/browser"; +import type { MediaType } from "@/types/assets"; + +const MEDIA_MIME_PREFIXES: MediaType[] = ["image", "video", "audio"]; + +function isMediaMimeType({ type }: { type: string }): boolean { + return MEDIA_MIME_PREFIXES.some((prefix) => type.startsWith(`${prefix}/`)); +} + +function extractMediaFilesFromClipboard({ + clipboardData, +}: { + clipboardData: DataTransfer | null; +}): File[] { + if (!clipboardData?.items) return []; + + const files: File[] = []; + for (const item of clipboardData.items) { + if (item.kind !== "file") continue; + if (!isMediaMimeType({ type: item.type })) continue; + + const file = item.getAsFile(); + if (file) files.push(file); + } + return files; +} + +export function usePasteMedia() { + const editor = useEditor(); + + useEffect(() => { + const handlePaste = async (event: ClipboardEvent) => { + const activeElement = document.activeElement as HTMLElement; + if (activeElement && isTypableDOMElement({ element: activeElement })) { + return; + } + + const files = extractMediaFilesFromClipboard({ + clipboardData: event.clipboardData, + }); + if (files.length === 0) return; + + event.preventDefault(); + + const activeProject = editor.project.getActive(); + if (!activeProject) return; + + try { + const processedAssets = await processMediaAssets({ files }); + const startTime = editor.playback.getCurrentTime(); + + for (const asset of processedAssets) { + const addMediaCmd = new AddMediaAssetCommand( + activeProject.metadata.id, + asset, + ); + const assetId = addMediaCmd.getAssetId(); + const duration = + asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION; + const trackType = asset.type === "audio" ? "audio" : "video"; + + const element = buildElementFromMedia({ + mediaId: assetId, + mediaType: asset.type, + name: asset.name, + duration, + startTime, + buffer: + asset.type === "audio" + ? new AudioBuffer({ length: 1, sampleRate: 44100 }) + : undefined, + }); + + const insertCmd = new InsertElementCommand({ + element, + placement: { mode: "auto", trackType }, + }); + const batchCmd = new BatchCommand([addMediaCmd, insertCmd]); + editor.command.execute({ command: batchCmd }); + } + } catch (error) { + console.error("Failed to paste media:", error); + toast.error("Failed to paste media"); + } + }; + + window.addEventListener("paste", handlePaste); + return () => window.removeEventListener("paste", handlePaste); + }, [editor]); +} diff --git a/apps/web/src/hooks/use-preview-interaction.ts b/apps/web/src/hooks/use-preview-interaction.ts index 31b33504..2bb33f21 100644 --- a/apps/web/src/hooks/use-preview-interaction.ts +++ b/apps/web/src/hooks/use-preview-interaction.ts @@ -1,12 +1,22 @@ -import { useCallback, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useEditor } from "@/hooks/use-editor"; -import { useSyncExternalStore } from "react"; -import type { Transform, TimelineTrack } from "@/types/timeline"; +import { useShiftKey } from "@/hooks/use-shift-key"; +import type { TextElement, Transform } from "@/types/timeline"; +import { getVisibleElementsWithBounds } from "@/lib/preview/element-bounds"; +import { hitTest } from "@/lib/preview/hit-test"; +import { screenToCanvas } from "@/lib/preview/preview-coords"; +import { isVisualElement } from "@/lib/timeline/element-utils"; +import { snapPosition, type SnapLine } from "@/lib/preview/preview-snap"; + +const MIN_DRAG_DISTANCE = 0.5; interface DragState { startX: number; startY: number; - tracksSnapshot: TimelineTrack[]; + bounds: { + width: number; + height: number; + }; elements: Array<{ trackId: string; elementId: string; @@ -20,59 +30,164 @@ export function usePreviewInteraction({ canvasRef: React.RefObject<HTMLCanvasElement | null>; }) { const editor = useEditor(); + const isShiftHeldRef = useShiftKey(); const [isDragging, setIsDragging] = useState(false); + const [snapLines, setSnapLines] = useState<SnapLine[]>([]); + const [editingText, setEditingText] = useState<{ + trackId: string; + elementId: string; + element: TextElement; + originalOpacity: number; + } | null>(null); const dragStateRef = useRef<DragState | null>(null); + const wasPlayingRef = useRef(editor.playback.getIsPlaying()); + const editingTextRef = useRef(editingText); + editingTextRef.current = editingText; - const selectedElements = useSyncExternalStore( - (listener) => editor.selection.subscribe(listener), - () => editor.selection.getSelectedElements(), - ); + const commitTextEdit = useCallback(() => { + const current = editingTextRef.current; + if (!current) return; + editor.timeline.previewElements({ + updates: [ + { + trackId: current.trackId, + elementId: current.elementId, + updates: { opacity: current.originalOpacity }, + }, + ], + }); + editor.timeline.commitPreview(); + setEditingText(null); + }, [editor.timeline]); - const getCanvasCoordinates = useCallback( - ({ clientX, clientY }: { clientX: number; clientY: number }) => { - if (!canvasRef.current) return { x: 0, y: 0 }; + const cancelTextEdit = useCallback(() => { + editor.timeline.discardPreview(); + setEditingText(null); + }, [editor.timeline]); + + useEffect(() => { + const unsubscribe = editor.playback.subscribe(() => { + const isPlaying = editor.playback.getIsPlaying(); + if (isPlaying && !wasPlayingRef.current && editingTextRef.current) { + commitTextEdit(); + } + wasPlayingRef.current = isPlaying; + }); + return unsubscribe; + }, [editor.playback, commitTextEdit]); - const rect = canvasRef.current.getBoundingClientRect(); - const logicalWidth = canvasRef.current.width; - const logicalHeight = canvasRef.current.height; - const scaleX = logicalWidth / rect.width; - const scaleY = logicalHeight / rect.height; + const handleDoubleClick = useCallback( + ({ clientX, clientY }: React.MouseEvent) => { + if (!canvasRef.current || editingText) return; + + const tracks = editor.timeline.getTracks(); + const currentTime = editor.playback.getCurrentTime(); + const mediaAssets = editor.media.getAssets(); + const canvasSize = editor.project.getActive().settings.canvasSize; + + const startPos = screenToCanvas({ + clientX, + clientY, + canvas: canvasRef.current, + }); + + const elementsWithBounds = getVisibleElementsWithBounds({ + tracks, + currentTime, + canvasSize, + mediaAssets, + }); + + const hit = hitTest({ + canvasX: startPos.x, + canvasY: startPos.y, + elementsWithBounds, + }); - const canvasX = (clientX - rect.left) * scaleX; - const canvasY = (clientY - rect.top) * scaleY; + if (!hit || hit.element.type !== "text") return; - return { x: canvasX, y: canvasY }; + const textElement = hit.element as TextElement; + editor.timeline.previewElements({ + updates: [ + { + trackId: hit.trackId, + elementId: hit.elementId, + updates: { opacity: 0 }, + }, + ], + }); + setEditingText({ + trackId: hit.trackId, + elementId: hit.elementId, + element: textElement, + originalOpacity: textElement.opacity, + }); }, - [canvasRef], + [canvasRef, editor, editingText], ); const handlePointerDown = useCallback( - (event: React.PointerEvent) => { - if (selectedElements.length === 0) return; + ({ + clientX, + clientY, + currentTarget, + pointerId, + button, + }: React.PointerEvent) => { + if (!canvasRef.current) return; + if (editingText) return; + if (button !== 0) return; + + const tracks = editor.timeline.getTracks(); + const currentTime = editor.playback.getCurrentTime(); + const mediaAssets = editor.media.getAssets(); + const canvasSize = editor.project.getActive().settings.canvasSize; + + const startPos = screenToCanvas({ + clientX, + clientY, + canvas: canvasRef.current, + }); + + const elementsWithBounds = getVisibleElementsWithBounds({ + tracks, + currentTime, + canvasSize, + mediaAssets, + }); + + const hit = hitTest({ + canvasX: startPos.x, + canvasY: startPos.y, + elementsWithBounds, + }); + + if (!hit) { + editor.selection.clearSelection(); + return; + } + + editor.selection.setSelectedElements({ + elements: [{ trackId: hit.trackId, elementId: hit.elementId }], + }); const elementsWithTracks = editor.timeline.getElementsWithTracks({ - elements: selectedElements, + elements: [{ trackId: hit.trackId, elementId: hit.elementId }], }); - const draggableElements = elementsWithTracks.filter( - ({ element }) => - element.type === "video" || - element.type === "image" || - element.type === "text" || - element.type === "sticker", + const draggableElements = elementsWithTracks.filter(({ element }) => + isVisualElement(element), ); if (draggableElements.length === 0) return; - const startPos = getCanvasCoordinates({ - clientX: event.clientX, - clientY: event.clientY, - }); - dragStateRef.current = { startX: startPos.x, startY: startPos.y, - tracksSnapshot: editor.timeline.getTracks(), + bounds: { + width: hit.bounds.width, + height: hit.bounds.height, + }, elements: draggableElements.map(({ track, element }) => ({ trackId: track.id, elementId: element.id, @@ -81,107 +196,118 @@ export function usePreviewInteraction({ }; setIsDragging(true); - event.currentTarget.setPointerCapture(event.pointerId); + currentTarget.setPointerCapture(pointerId); }, - [selectedElements, editor, getCanvasCoordinates], + [editor, canvasRef, editingText], ); const handlePointerMove = useCallback( - (event: React.PointerEvent) => { - if (!dragStateRef.current || !isDragging) return; + ({ clientX, clientY }: React.PointerEvent) => { + if (!dragStateRef.current || !isDragging || !canvasRef.current) return; + + const canvasSize = editor.project.getActive().settings.canvasSize; - const currentPos = getCanvasCoordinates({ - clientX: event.clientX, - clientY: event.clientY, + const currentPos = screenToCanvas({ + clientX, + clientY, + canvas: canvasRef.current, }); const deltaX = currentPos.x - dragStateRef.current.startX; const deltaY = currentPos.y - dragStateRef.current.startY; + const hasMovement = + Math.abs(deltaX) > MIN_DRAG_DISTANCE || + Math.abs(deltaY) > MIN_DRAG_DISTANCE; + if (!hasMovement) { + setSnapLines([]); + return; + } - for (const { trackId, elementId, initialTransform } of dragStateRef - .current.elements) { - const newPosition = { - x: initialTransform.position.x + deltaX, - y: initialTransform.position.y + deltaY, - }; + const firstElement = dragStateRef.current.elements[0]; + const proposedPosition = { + x: firstElement.initialTransform.position.x + deltaX, + y: firstElement.initialTransform.position.y + deltaY, + }; - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId, - updates: { - transform: { - ...initialTransform, - position: newPosition, - }, + const shouldSnap = !isShiftHeldRef.current; + const { snappedPosition, activeLines } = shouldSnap + ? snapPosition({ + proposedPosition, + canvasSize, + elementSize: dragStateRef.current.bounds, + }) + : { + snappedPosition: proposedPosition, + activeLines: [] as SnapLine[], + }; + + setSnapLines(activeLines); + + const deltaSnappedX = + snappedPosition.x - firstElement.initialTransform.position.x; + const deltaSnappedY = + snappedPosition.y - firstElement.initialTransform.position.y; + + const updates = dragStateRef.current.elements.map( + ({ trackId, elementId, initialTransform }) => ({ + trackId, + elementId, + updates: { + transform: { + ...initialTransform, + position: { + x: initialTransform.position.x + deltaSnappedX, + y: initialTransform.position.y + deltaSnappedY, }, }, - ], - pushHistory: false, - }); - } + }, + }), + ); + + editor.timeline.previewElements({ updates }); }, - [isDragging, getCanvasCoordinates, editor], + [isDragging, canvasRef, editor, isShiftHeldRef], ); const handlePointerUp = useCallback( - (event: React.PointerEvent) => { - if (!dragStateRef.current || !isDragging) return; + ({ clientX, clientY, currentTarget, pointerId }: React.PointerEvent) => { + if (!dragStateRef.current || !isDragging || !canvasRef.current) return; - const currentPos = getCanvasCoordinates({ - clientX: event.clientX, - clientY: event.clientY, + const currentPos = screenToCanvas({ + clientX, + clientY, + canvas: canvasRef.current, }); const deltaX = currentPos.x - dragStateRef.current.startX; const deltaY = currentPos.y - dragStateRef.current.startY; - const hasMovement = Math.abs(deltaX) > 0.5 || Math.abs(deltaY) > 0.5; - + const hasMovement = + Math.abs(deltaX) > MIN_DRAG_DISTANCE || + Math.abs(deltaY) > MIN_DRAG_DISTANCE; if (!hasMovement) { - dragStateRef.current = null; - setIsDragging(false); - event.currentTarget.releasePointerCapture(event.pointerId); - return; + editor.timeline.discardPreview(); + } else { + editor.timeline.commitPreview(); } - // revert to pre-drag state so the command captures the correct undo snapshot - editor.timeline.updateTracks(dragStateRef.current.tracksSnapshot); - - const updates = dragStateRef.current.elements.map( - ({ trackId, elementId, initialTransform }) => { - const newPosition = { - x: initialTransform.position.x + deltaX, - y: initialTransform.position.y + deltaY, - }; - - return { - trackId, - elementId, - updates: { - transform: { - ...initialTransform, - position: newPosition, - }, - }, - }; - }, - ); - - editor.timeline.updateElements({ updates }); - dragStateRef.current = null; setIsDragging(false); - event.currentTarget.releasePointerCapture(event.pointerId); + setSnapLines([]); + currentTarget.releasePointerCapture(pointerId); }, - [isDragging, getCanvasCoordinates, editor], + [isDragging, canvasRef, editor], ); return { onPointerDown: handlePointerDown, onPointerMove: handlePointerMove, onPointerUp: handlePointerUp, + onDoubleClick: handleDoubleClick, + snapLines, + editingText, + commitTextEdit, + cancelTextEdit, }; } diff --git a/apps/web/src/hooks/use-shift-key.ts b/apps/web/src/hooks/use-shift-key.ts new file mode 100644 index 00000000..4f9fe67c --- /dev/null +++ b/apps/web/src/hooks/use-shift-key.ts @@ -0,0 +1,35 @@ +import { useEffect, useRef, type RefObject } from "react"; + +export function useShiftKey(): RefObject<boolean> { + const isShiftHeldRef = useRef(false); + + useEffect(() => { + const handleKeyDown = ({ key }: KeyboardEvent) => { + if (key === "Shift") { + isShiftHeldRef.current = true; + } + }; + + const handleKeyUp = ({ key }: KeyboardEvent) => { + if (key === "Shift") { + isShiftHeldRef.current = false; + } + }; + + const handleBlur = () => { + isShiftHeldRef.current = false; + }; + + document.addEventListener("keydown", handleKeyDown); + document.addEventListener("keyup", handleKeyUp); + window.addEventListener("blur", handleBlur); + + return () => { + document.removeEventListener("keydown", handleKeyDown); + document.removeEventListener("keyup", handleKeyUp); + window.removeEventListener("blur", handleBlur); + }; + }, []); + + return isShiftHeldRef; +} diff --git a/apps/web/src/hooks/use-sound-search.ts b/apps/web/src/hooks/use-sound-search.ts index 263a01e2..f3c0aab4 100644 --- a/apps/web/src/hooks/use-sound-search.ts +++ b/apps/web/src/hooks/use-sound-search.ts @@ -1,154 +1,154 @@ -import { useEffect } from "react";
-import { useSoundsStore } from "@/stores/sounds-store";
-
-export function useSoundSearch({
- query,
- commercialOnly,
-}: {
- query: string;
- commercialOnly: boolean;
-}) {
- const {
- searchResults,
- isSearching,
- searchError,
- lastSearchQuery,
- currentPage,
- hasNextPage,
- isLoadingMore,
- totalCount,
- setSearchResults,
- setSearching,
- setSearchError,
- setLastSearchQuery,
- setCurrentPage,
- setHasNextPage,
- setTotalCount,
- setLoadingMore,
- appendSearchResults,
- appendTopSounds,
- resetPagination,
- } = useSoundsStore();
-
- const loadMore = async () => {
- if (isLoadingMore || !hasNextPage) return;
-
- try {
- setLoadingMore({ loading: true });
- const nextPage = currentPage + 1;
-
- const searchParams = new URLSearchParams({
- page: nextPage.toString(),
- type: "effects",
- });
-
- if (query.trim()) {
- searchParams.set("q", query);
- }
-
- searchParams.set("commercial_only", commercialOnly.toString());
- const response = await fetch(
- `/api/sounds/search?${searchParams.toString()}`,
- );
-
- if (response.ok) {
- const data = await response.json();
-
- if (query.trim()) {
- appendSearchResults(data.results);
- } else {
- appendTopSounds(data.results);
- }
-
- setCurrentPage({ page: nextPage });
- setHasNextPage({ hasNext: !!data.next });
- setTotalCount(data.count);
- } else {
- setSearchError({ error: `Load more failed: ${response.status}` });
- }
- } catch (err) {
- setSearchError({
- error: err instanceof Error ? err.message : "Load more failed",
- });
- } finally {
- setLoadingMore({ loading: false });
- }
- };
-
- useEffect(() => {
- if (!query.trim()) {
- setSearchResults({ results: [] });
- setSearchError({ error: null });
- setLastSearchQuery({ query: "" });
- return;
- }
-
- if (query === lastSearchQuery && searchResults.length > 0) {
- return;
- }
-
- let ignore = false;
-
- const timeoutId = setTimeout(async () => {
- try {
- setSearching({ searching: true });
- setSearchError({ error: null });
- resetPagination();
-
- const response = await fetch(
- `/api/sounds/search?q=${encodeURIComponent(query)}&type=effects&page=1`,
- );
-
- if (!ignore) {
- if (response.ok) {
- const data = await response.json();
- setSearchResults({ results: data.results });
- setLastSearchQuery({ query: query });
- setHasNextPage({ hasNext: !!data.next });
- setTotalCount({ count: data.count });
- setCurrentPage({ page: 1 });
- } else {
- setSearchError({ error: `Search failed: ${response.status}` });
- }
- }
- } catch (err) {
- if (!ignore) {
- setSearchError({
- error: err instanceof Error ? err.message : "Search failed",
- });
- }
- } finally {
- if (!ignore) {
- setSearching({ searching: false });
- }
- }
- }, 300);
-
- return () => {
- clearTimeout(timeoutId);
- ignore = true;
- };
- }, [
- query,
- lastSearchQuery,
- searchResults.length,
- setSearchResults,
- setSearching,
- setSearchError,
- setLastSearchQuery,
- setCurrentPage,
- setHasNextPage,
- setTotalCount,
- resetPagination,
- ]);
-
- return {
- results: searchResults,
- isLoading: isSearching,
- error: searchError,
- loadMore,
- hasNextPage,
- isLoadingMore,
- totalCount,
- };
-}
+import { useEffect } from "react"; +import { useSoundsStore } from "@/stores/sounds-store"; + +export function useSoundSearch({ + query, + commercialOnly, +}: { + query: string; + commercialOnly: boolean; +}) { + const { + searchResults, + isSearching, + searchError, + lastSearchQuery, + currentPage, + hasNextPage, + isLoadingMore, + totalCount, + setSearchResults, + setSearching, + setSearchError, + setLastSearchQuery, + setCurrentPage, + setHasNextPage, + setTotalCount, + setLoadingMore, + appendSearchResults, + appendTopSounds, + resetPagination, + } = useSoundsStore(); + + const loadMore = async () => { + if (isLoadingMore || !hasNextPage) return; + + try { + setLoadingMore({ loading: true }); + const nextPage = currentPage + 1; + + const searchParams = new URLSearchParams({ + page: nextPage.toString(), + type: "effects", + }); + + if (query.trim()) { + searchParams.set("q", query); + } + + searchParams.set("commercial_only", commercialOnly.toString()); + const response = await fetch( + `/api/sounds/search?${searchParams.toString()}`, + ); + + if (response.ok) { + const data = await response.json(); + + if (query.trim()) { + appendSearchResults(data.results); + } else { + appendTopSounds(data.results); + } + + setCurrentPage({ page: nextPage }); + setHasNextPage({ hasNext: !!data.next }); + setTotalCount(data.count); + } else { + setSearchError({ error: `Load more failed: ${response.status}` }); + } + } catch (err) { + setSearchError({ + error: err instanceof Error ? err.message : "Load more failed", + }); + } finally { + setLoadingMore({ loading: false }); + } + }; + + useEffect(() => { + if (!query.trim()) { + setSearchResults({ results: [] }); + setSearchError({ error: null }); + setLastSearchQuery({ query: "" }); + return; + } + + if (query === lastSearchQuery && searchResults.length > 0) { + return; + } + + let ignore = false; + + const timeoutId = setTimeout(async () => { + try { + setSearching({ searching: true }); + setSearchError({ error: null }); + resetPagination(); + + const response = await fetch( + `/api/sounds/search?q=${encodeURIComponent(query)}&type=effects&page=1`, + ); + + if (!ignore) { + if (response.ok) { + const data = await response.json(); + setSearchResults({ results: data.results }); + setLastSearchQuery({ query: query }); + setHasNextPage({ hasNext: !!data.next }); + setTotalCount({ count: data.count }); + setCurrentPage({ page: 1 }); + } else { + setSearchError({ error: `Search failed: ${response.status}` }); + } + } + } catch (err) { + if (!ignore) { + setSearchError({ + error: err instanceof Error ? err.message : "Search failed", + }); + } + } finally { + if (!ignore) { + setSearching({ searching: false }); + } + } + }, 300); + + return () => { + clearTimeout(timeoutId); + ignore = true; + }; + }, [ + query, + lastSearchQuery, + searchResults.length, + setSearchResults, + setSearching, + setSearchError, + setLastSearchQuery, + setCurrentPage, + setHasNextPage, + setTotalCount, + resetPagination, + ]); + + return { + results: searchResults, + isLoading: isSearching, + error: searchError, + loadMore, + hasNextPage, + isLoadingMore, + totalCount, + }; +} diff --git a/apps/web/src/hooks/use-transform-handles.ts b/apps/web/src/hooks/use-transform-handles.ts new file mode 100644 index 00000000..37ef6d5f --- /dev/null +++ b/apps/web/src/hooks/use-transform-handles.ts @@ -0,0 +1,331 @@ +import { useCallback, useRef, useState } from "react"; +import { useEditor } from "@/hooks/use-editor"; +import { useShiftKey } from "@/hooks/use-shift-key"; +import { useSyncExternalStore } from "react"; +import { + getVisibleElementsWithBounds, + type ElementWithBounds, +} from "@/lib/preview/element-bounds"; +import { screenToCanvas } from "@/lib/preview/preview-coords"; +import { + MIN_SCALE, + snapRotation, + snapScale, + type SnapLine, +} from "@/lib/preview/preview-snap"; +import { isVisualElement } from "@/lib/timeline/element-utils"; +import type { Transform } from "@/types/timeline"; + +type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right"; +type HandleType = Corner | "rotation"; + +interface ScaleState { + trackId: string; + elementId: string; + initialTransform: Transform; + initialDistance: number; + initialBoundsCx: number; + initialBoundsCy: number; + baseWidth: number; + baseHeight: number; +} + +interface RotationState { + trackId: string; + elementId: string; + initialTransform: Transform; + initialAngle: number; + initialBoundsCx: number; + initialBoundsCy: number; +} + +function areSnapLinesEqual({ + previousLines, + nextLines, +}: { + previousLines: SnapLine[]; + nextLines: SnapLine[]; +}): boolean { + if (previousLines.length !== nextLines.length) { + return false; + } + for (const [index, line] of previousLines.entries()) { + const nextLine = nextLines[index]; + if (!nextLine) { + return false; + } + if (line.type !== nextLine.type || line.position !== nextLine.position) { + return false; + } + } + return true; +} + +function getCornerDistance({ + bounds, + corner, +}: { + bounds: { + cx: number; + cy: number; + width: number; + height: number; + rotation: number; + }; + corner: Corner; +}): 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; + + const rotatedX = localX * cos - localY * sin; + const rotatedY = localX * sin + localY * cos; + return Math.sqrt(rotatedX * rotatedX + rotatedY * rotatedY) || 1; +} + +export function useTransformHandles({ + canvasRef, +}: { + canvasRef: React.RefObject<HTMLCanvasElement | null>; +}) { + const editor = useEditor(); + const isShiftHeldRef = useShiftKey(); + const [activeHandle, setActiveHandle] = useState<HandleType | null>(null); + const [snapLines, setSnapLines] = useState<SnapLine[]>([]); + const snapLinesRef = useRef<SnapLine[]>([]); + const scaleStateRef = useRef<ScaleState | null>(null); + const rotationStateRef = useRef<RotationState | null>(null); + + const selectedElements = useSyncExternalStore( + (listener) => editor.selection.subscribe(listener), + () => editor.selection.getSelectedElements(), + ); + + const tracks = editor.timeline.getTracks(); + const currentTime = editor.playback.getCurrentTime(); + const mediaAssets = editor.media.getAssets(); + const canvasSize = editor.project.getActive().settings.canvasSize; + + const elementsWithBounds = getVisibleElementsWithBounds({ + tracks, + currentTime, + canvasSize, + mediaAssets, + }); + + const selectedWithBounds: ElementWithBounds | null = + selectedElements.length === 1 + ? (elementsWithBounds.find( + (entry) => + entry.trackId === selectedElements[0].trackId && + entry.elementId === selectedElements[0].elementId, + ) ?? null) + : null; + + const hasVisualSelection = + selectedWithBounds !== null && isVisualElement(selectedWithBounds.element); + + const handleCornerPointerDown = useCallback( + ({ event, corner }: { event: React.PointerEvent; corner: Corner }) => { + if (!selectedWithBounds) return; + event.stopPropagation(); + + const { bounds, trackId, elementId, element } = selectedWithBounds; + if (!isVisualElement(element)) return; + + const initialDistance = getCornerDistance({ bounds, corner }); + const baseWidth = bounds.width / element.transform.scale; + const baseHeight = bounds.height / element.transform.scale; + + scaleStateRef.current = { + trackId, + elementId, + initialTransform: element.transform, + initialDistance, + initialBoundsCx: bounds.cx, + initialBoundsCy: bounds.cy, + baseWidth, + baseHeight, + }; + setActiveHandle(corner); + (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId); + }, + [selectedWithBounds], + ); + + const handleRotationPointerDown = useCallback( + ({ event }: { event: React.PointerEvent }) => { + if (!selectedWithBounds || !canvasRef.current) return; + event.stopPropagation(); + + const { bounds, trackId, elementId, element } = selectedWithBounds; + if (!isVisualElement(element)) return; + + const position = screenToCanvas({ + clientX: event.clientX, + clientY: event.clientY, + canvas: canvasRef.current, + }); + const dx = position.x - bounds.cx; + const dy = position.y - bounds.cy; + const initialAngle = (Math.atan2(dy, dx) * 180) / Math.PI; + + rotationStateRef.current = { + trackId, + elementId, + initialTransform: element.transform, + initialAngle, + initialBoundsCx: bounds.cx, + initialBoundsCy: bounds.cy, + }; + setActiveHandle("rotation"); + (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId); + }, + [selectedWithBounds, canvasRef], + ); + + const handlePointerMove = useCallback( + ({ event }: { event: React.PointerEvent }) => { + if (!canvasRef.current) return; + if (!scaleStateRef.current && !rotationStateRef.current) return; + + const position = screenToCanvas({ + clientX: event.clientX, + clientY: event.clientY, + canvas: canvasRef.current, + }); + + if ( + scaleStateRef.current && + activeHandle && + activeHandle !== "rotation" + ) { + const { + trackId, + elementId, + initialTransform, + initialDistance, + initialBoundsCx, + initialBoundsCy, + baseWidth, + baseHeight, + } = scaleStateRef.current; + + const dx = position.x - initialBoundsCx; + const dy = position.y - initialBoundsCy; + const currentDistance = Math.sqrt(dx * dx + dy * dy) || 1; + const scaleFactor = currentDistance / initialDistance; + const proposedScale = Math.max( + MIN_SCALE, + initialTransform.scale * scaleFactor, + ); + + const canvasSize = editor.project.getActive().settings.canvasSize; + const shouldSnap = !isShiftHeldRef.current; + const { snappedScale, activeLines } = shouldSnap + ? snapScale({ + proposedScale, + position: initialTransform.position, + baseWidth, + baseHeight, + canvasSize, + }) + : { snappedScale: proposedScale, activeLines: [] as SnapLine[] }; + + const isSameLines = areSnapLinesEqual({ + previousLines: snapLinesRef.current, + nextLines: activeLines, + }); + + if (!isSameLines) { + snapLinesRef.current = activeLines; + setSnapLines(activeLines); + } + + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId, + updates: { + transform: { ...initialTransform, scale: snappedScale }, + }, + }, + ], + }); + return; + } + + if (rotationStateRef.current && activeHandle === "rotation") { + const { + trackId, + elementId, + initialTransform, + initialAngle, + initialBoundsCx, + initialBoundsCy, + } = rotationStateRef.current; + + const dx = position.x - initialBoundsCx; + const dy = position.y - initialBoundsCy; + const currentAngle = (Math.atan2(dy, dx) * 180) / Math.PI; + let deltaAngle = currentAngle - initialAngle; + if (deltaAngle > 180) deltaAngle -= 360; + if (deltaAngle < -180) deltaAngle += 360; + const newRotate = initialTransform.rotate + deltaAngle; + const shouldSnapRotation = !isShiftHeldRef.current; + const { snappedRotation } = shouldSnapRotation + ? snapRotation({ proposedRotation: newRotate }) + : { snappedRotation: newRotate }; + + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId, + updates: { + transform: { ...initialTransform, rotate: snappedRotation }, + }, + }, + ], + }); + } + }, + [activeHandle, canvasRef, editor, isShiftHeldRef], + ); + + const handlePointerUp = useCallback( + ({ event }: { event: React.PointerEvent }) => { + if (scaleStateRef.current || rotationStateRef.current) { + editor.timeline.commitPreview(); + scaleStateRef.current = null; + rotationStateRef.current = null; + setActiveHandle(null); + snapLinesRef.current = []; + setSnapLines([]); + } + (event.currentTarget as HTMLElement).releasePointerCapture( + event.pointerId, + ); + }, + [editor], + ); + + return { + selectedWithBounds, + hasVisualSelection, + activeHandle, + snapLines, + handleCornerPointerDown, + handleRotationPointerDown, + handlePointerMove, + handlePointerUp, + }; +} diff --git a/apps/web/src/lib/commands/index.ts b/apps/web/src/lib/commands/index.ts index 03de07c4..a2119fc1 100644 --- a/apps/web/src/lib/commands/index.ts +++ b/apps/web/src/lib/commands/index.ts @@ -1,5 +1,6 @@ export { Command } from "./base-command"; export { BatchCommand } from "./batch-command"; +export { PreviewTracker } from "./preview-tracker"; export * from "./timeline"; export * from "./media"; diff --git a/apps/web/src/lib/commands/preview-tracker.ts b/apps/web/src/lib/commands/preview-tracker.ts new file mode 100644 index 00000000..68c8dee3 --- /dev/null +++ b/apps/web/src/lib/commands/preview-tracker.ts @@ -0,0 +1,23 @@ +export class PreviewTracker<T> { + private snapshot: T | null = null; + + begin({ state }: { state: T }): void { + if (this.snapshot === null) { + this.snapshot = structuredClone(state); + } + } + + isActive(): boolean { + return this.snapshot !== null; + } + + getSnapshot(): T | null { + return this.snapshot; + } + + end(): T | null { + const snapshot = this.snapshot; + this.snapshot = null; + return snapshot; + } +} diff --git a/apps/web/src/lib/commands/scene/index.ts b/apps/web/src/lib/commands/scene/index.ts index 5bf9406a..2b843c99 100644 --- a/apps/web/src/lib/commands/scene/index.ts +++ b/apps/web/src/lib/commands/scene/index.ts @@ -3,3 +3,5 @@ export { DeleteSceneCommand } from "./delete-scene"; export { RenameSceneCommand } from "./rename-scene"; export { ToggleBookmarkCommand } from "./toggle-bookmark"; export { RemoveBookmarkCommand } from "./remove-bookmark"; +export { UpdateBookmarkCommand } from "./update-bookmark"; +export { MoveBookmarkCommand } from "./move-bookmark"; diff --git a/apps/web/src/lib/commands/scene/move-bookmark.ts b/apps/web/src/lib/commands/scene/move-bookmark.ts new file mode 100644 index 00000000..64382f03 --- /dev/null +++ b/apps/web/src/lib/commands/scene/move-bookmark.ts @@ -0,0 +1,59 @@ +import { Command } from "@/lib/commands/base-command"; +import { EditorCore } from "@/core"; +import type { TScene } from "@/types/timeline"; +import { updateSceneInArray } from "@/lib/scenes"; +import { getFrameTime, moveBookmarkInArray } from "@/lib/timeline/bookmarks"; + +export class MoveBookmarkCommand extends Command { + private savedScenes: TScene[] | null = null; + + constructor( + private fromTime: number, + private toTime: number, + ) { + super(); + } + + execute(): void { + const editor = EditorCore.getInstance(); + const activeScene = editor.scenes.getActiveScene(); + const activeProject = editor.project.getActive(); + + if (!activeScene || !activeProject) { + return; + } + + const scenes = editor.scenes.getScenes(); + this.savedScenes = [...scenes]; + + const fromFrameTime = getFrameTime({ + time: this.fromTime, + fps: activeProject.settings.fps, + }); + const toFrameTime = getFrameTime({ + time: this.toTime, + fps: activeProject.settings.fps, + }); + + const updatedBookmarks = moveBookmarkInArray({ + bookmarks: activeScene.bookmarks, + fromTime: fromFrameTime, + toTime: toFrameTime, + }); + + const updatedScenes = updateSceneInArray({ + scenes, + sceneId: activeScene.id, + updates: { bookmarks: updatedBookmarks }, + }); + + editor.scenes.setScenes({ scenes: updatedScenes }); + } + + undo(): void { + if (this.savedScenes) { + const editor = EditorCore.getInstance(); + editor.scenes.setScenes({ scenes: this.savedScenes }); + } + } +} diff --git a/apps/web/src/lib/commands/scene/update-bookmark.ts b/apps/web/src/lib/commands/scene/update-bookmark.ts new file mode 100644 index 00000000..5ce91a36 --- /dev/null +++ b/apps/web/src/lib/commands/scene/update-bookmark.ts @@ -0,0 +1,55 @@ +import { Command } from "@/lib/commands/base-command"; +import { EditorCore } from "@/core"; +import type { Bookmark, TScene } from "@/types/timeline"; +import { updateSceneInArray } from "@/lib/scenes"; +import { getFrameTime, updateBookmarkInArray } from "@/lib/timeline/bookmarks"; + +export class UpdateBookmarkCommand extends Command { + private savedScenes: TScene[] | null = null; + + constructor( + private time: number, + private updates: Partial<Omit<Bookmark, "time">>, + ) { + super(); + } + + execute(): void { + const editor = EditorCore.getInstance(); + const activeScene = editor.scenes.getActiveScene(); + const activeProject = editor.project.getActive(); + + if (!activeScene || !activeProject) { + return; + } + + const scenes = editor.scenes.getScenes(); + this.savedScenes = [...scenes]; + + const frameTime = getFrameTime({ + time: this.time, + fps: activeProject.settings.fps, + }); + + const updatedBookmarks = updateBookmarkInArray({ + bookmarks: activeScene.bookmarks, + frameTime, + updates: this.updates, + }); + + const updatedScenes = updateSceneInArray({ + scenes, + sceneId: activeScene.id, + updates: { bookmarks: updatedBookmarks }, + }); + + editor.scenes.setScenes({ scenes: updatedScenes }); + } + + undo(): void { + if (this.savedScenes) { + const editor = EditorCore.getInstance(); + editor.scenes.setScenes({ scenes: this.savedScenes }); + } + } +} diff --git a/apps/web/src/lib/commands/timeline/clipboard/paste.ts b/apps/web/src/lib/commands/timeline/clipboard/paste.ts index 39feb8ce..49554e0e 100644 --- a/apps/web/src/lib/commands/timeline/clipboard/paste.ts +++ b/apps/web/src/lib/commands/timeline/clipboard/paste.ts @@ -130,7 +130,9 @@ export class PasteCommand extends Command { if (this.savedState) { const editor = EditorCore.getInstance(); editor.timeline.updateTracks(this.savedState); - editor.selection.setSelectedElements({ elements: this.previousSelection }); + editor.selection.setSelectedElements({ + elements: this.previousSelection, + }); } } diff --git a/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts b/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts index 4bec6444..cf1bd94c 100644 --- a/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts +++ b/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts @@ -84,7 +84,9 @@ export class DuplicateElementsCommand extends Command { editor.timeline.updateTracks(updatedTracks); if (this.duplicatedElements.length > 0) { - editor.selection.setSelectedElements({ elements: this.duplicatedElements }); + editor.selection.setSelectedElements({ + elements: this.duplicatedElements, + }); } } @@ -92,7 +94,9 @@ export class DuplicateElementsCommand extends Command { if (this.savedState) { const editor = EditorCore.getInstance(); editor.timeline.updateTracks(this.savedState); - editor.selection.setSelectedElements({ elements: this.previousSelection }); + editor.selection.setSelectedElements({ + elements: this.previousSelection, + }); } } diff --git a/apps/web/src/lib/commands/timeline/element/insert-element.ts b/apps/web/src/lib/commands/timeline/element/insert-element.ts index 6067d72b..e1551471 100644 --- a/apps/web/src/lib/commands/timeline/element/insert-element.ts +++ b/apps/web/src/lib/commands/timeline/element/insert-element.ts @@ -163,8 +163,8 @@ export class InsertElementCommand extends Command { return false; } - if (element.type === "sticker" && !element.iconName) { - console.error("Sticker element must have iconName"); + if (element.type === "sticker" && !element.stickerId) { + console.error("Sticker element must have stickerId"); return false; } diff --git a/apps/web/src/lib/commands/timeline/element/split-elements.ts b/apps/web/src/lib/commands/timeline/element/split-elements.ts index 13d4d730..abe133c9 100644 --- a/apps/web/src/lib/commands/timeline/element/split-elements.ts +++ b/apps/web/src/lib/commands/timeline/element/split-elements.ts @@ -119,7 +119,9 @@ export class SplitElementsCommand extends Command { editor.timeline.updateTracks(updatedTracks); if (this.rightSideElements.length > 0) { - editor.selection.setSelectedElements({ elements: this.rightSideElements }); + editor.selection.setSelectedElements({ + elements: this.rightSideElements, + }); } } @@ -127,7 +129,9 @@ export class SplitElementsCommand extends Command { if (this.savedState) { const editor = EditorCore.getInstance(); editor.timeline.updateTracks(this.savedState); - editor.selection.setSelectedElements({ elements: this.previousSelection }); + editor.selection.setSelectedElements({ + elements: this.previousSelection, + }); } } } diff --git a/apps/web/src/lib/commands/timeline/index.ts b/apps/web/src/lib/commands/timeline/index.ts index 30bc81ef..cb285d94 100644 --- a/apps/web/src/lib/commands/timeline/index.ts +++ b/apps/web/src/lib/commands/timeline/index.ts @@ -1,3 +1,5 @@ export * from "./track"; export * from "./element"; export * from "./clipboard"; + +export { TracksSnapshotCommand } from "./tracks-snapshot"; diff --git a/apps/web/src/lib/commands/timeline/tracks-snapshot.ts b/apps/web/src/lib/commands/timeline/tracks-snapshot.ts new file mode 100644 index 00000000..2ba059c7 --- /dev/null +++ b/apps/web/src/lib/commands/timeline/tracks-snapshot.ts @@ -0,0 +1,20 @@ +import { Command } from "@/lib/commands/base-command"; +import type { TimelineTrack } from "@/types/timeline"; +import { EditorCore } from "@/core"; + +export class TracksSnapshotCommand extends Command { + constructor( + private before: TimelineTrack[], + private after: TimelineTrack[], + ) { + super(); + } + + execute(): void { + EditorCore.getInstance().timeline.updateTracks(this.after); + } + + undo(): void { + EditorCore.getInstance().timeline.updateTracks(this.before); + } +} diff --git a/apps/web/src/lib/fonts/google-fonts.ts b/apps/web/src/lib/fonts/google-fonts.ts new file mode 100644 index 00000000..7e55dffd --- /dev/null +++ b/apps/web/src/lib/fonts/google-fonts.ts @@ -0,0 +1,90 @@ +import type { FontAtlas } from "@/types/fonts"; +import { SYSTEM_FONTS } from "@/constants/font-constants"; + +const GOOGLE_FONTS_CSS = "https://fonts.googleapis.com/css2"; + +const fullLoaded = new Set<string>(); + +let cachedAtlas: FontAtlas | null = null; +let atlasFetchPromise: Promise<FontAtlas | null> | null = null; + +function encodeFamily(family: string): string { + return family.replace(/ /g, "+"); +} + +export function getCachedFontAtlas(): FontAtlas | null { + return cachedAtlas; +} + +export function clearFontAtlasCache(): void { + cachedAtlas = null; + atlasFetchPromise = null; +} + +async function fetchAtlas(): Promise<FontAtlas | null> { + if (cachedAtlas) return cachedAtlas; + if (atlasFetchPromise) return atlasFetchPromise; + + atlasFetchPromise = fetch("/fonts/font-atlas.json") + .then(async (response) => { + if (!response.ok) return null; + const data: FontAtlas = await response.json(); + cachedAtlas = data; + return data; + }) + .catch(() => null); + + return atlasFetchPromise; +} + +function preloadChunkImages({ atlas }: { atlas: FontAtlas }): void { + const maxChunk = Math.max( + ...Object.values(atlas.fonts).map((entry) => entry.ch), + ); + for (let i = 0; i <= maxChunk; i++) { + const img = new Image(); + img.src = `/fonts/font-chunk-${i}.avif`; + } +} + +export function prefetchFontAtlas(): Promise<FontAtlas | null> { + return fetchAtlas().then((atlas) => { + if (atlas) preloadChunkImages({ atlas }); + return atlas; + }); +} + +export async function loadFullFont({ + family, + weights = [400, 700], +}: { + family: string; + weights?: number[]; +}): Promise<void> { + if (fullLoaded.has(family)) return; + + const url = `${GOOGLE_FONTS_CSS}?family=${encodeFamily(family)}:wght@${weights.join(";")}&display=swap`; + const link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = url; + document.head.appendChild(link); + await new Promise<void>((resolve) => { + link.addEventListener("load", () => resolve(), { once: true }); + link.addEventListener("error", () => resolve(), { once: true }); + }); + await Promise.all( + weights.map((weight) => + document.fonts.load(`${weight} 16px "${family.replace(/"/g, '\\"')}"`), + ), + ); + fullLoaded.add(family); +} + +export async function loadFonts({ + families, +}: { + families: string[]; +}): Promise<void> { + const googleFonts = families.filter((family) => !SYSTEM_FONTS.has(family)); + await Promise.all(googleFonts.map((family) => loadFullFont({ family }))); +} diff --git a/apps/web/src/lib/preview/element-bounds.ts b/apps/web/src/lib/preview/element-bounds.ts new file mode 100644 index 00000000..ec26c14d --- /dev/null +++ b/apps/web/src/lib/preview/element-bounds.ts @@ -0,0 +1,219 @@ +import type { TimelineTrack, TimelineElement } from "@/types/timeline"; +import type { MediaAsset } from "@/types/assets"; +import { isMainTrack } from "@/lib/timeline"; +import { + DEFAULT_LINE_HEIGHT, + FONT_SIZE_SCALE_REFERENCE, +} from "@/constants/text-constants"; + +export interface ElementBounds { + cx: number; + cy: number; + width: number; + height: number; + rotation: number; +} + +export interface ElementWithBounds { + trackId: string; + elementId: string; + element: TimelineElement; + bounds: ElementBounds; +} + +function getVisualElementBounds({ + canvasWidth, + canvasHeight, + sourceWidth, + sourceHeight, + transform, +}: { + canvasWidth: number; + canvasHeight: number; + sourceWidth: number; + sourceHeight: number; + transform: { + scale: number; + position: { x: number; y: number }; + rotate: number; + }; +}): ElementBounds { + const containScale = Math.min( + canvasWidth / sourceWidth, + canvasHeight / sourceHeight, + ); + const scaledWidth = sourceWidth * containScale * transform.scale; + const scaledHeight = sourceHeight * containScale * transform.scale; + const cx = canvasWidth / 2 + transform.position.x; + const cy = canvasHeight / 2 + transform.position.y; + + return { + cx, + cy, + width: scaledWidth, + height: scaledHeight, + rotation: transform.rotate, + }; +} + +export function getElementBounds({ + element, + canvasSize, + mediaAsset, +}: { + element: TimelineElement; + canvasSize: { width: number; height: number }; + mediaAsset?: MediaAsset | null; +}): ElementBounds | null { + if (element.type === "audio") return null; + if ("hidden" in element && element.hidden) return null; + + const { width: canvasWidth, height: canvasHeight } = canvasSize; + + if (element.type === "video" || element.type === "image") { + const sourceWidth = mediaAsset?.width ?? canvasWidth; + const sourceHeight = mediaAsset?.height ?? canvasHeight; + return getVisualElementBounds({ + canvasWidth, + canvasHeight, + sourceWidth, + sourceHeight, + transform: element.transform, + }); + } + + if (element.type === "sticker") { + return getVisualElementBounds({ + canvasWidth, + canvasHeight, + sourceWidth: 200, + sourceHeight: 200, + transform: element.transform, + }); + } + + if (element.type === "text") { + const scaledFontSize = + element.fontSize * (canvasHeight / FONT_SIZE_SCALE_REFERENCE); + const letterSpacing = element.letterSpacing ?? 0; + const lineHeight = element.lineHeight ?? DEFAULT_LINE_HEIGHT; + const lineHeightPx = scaledFontSize * lineHeight; + + let measuredWidth = 100; + let measuredHeight = scaledFontSize; + + const canvas = document.createElement("canvas"); + canvas.width = 4096; + canvas.height = 4096; + const ctx = canvas.getContext("2d"); + + if (ctx) { + const fontWeight = element.fontWeight === "bold" ? "bold" : "normal"; + const fontStyle = element.fontStyle === "italic" ? "italic" : "normal"; + const fontFamily = `"${element.fontFamily.replace(/"/g, '\\"')}"`; + ctx.font = `${fontStyle} ${fontWeight} ${scaledFontSize}px ${fontFamily}, sans-serif`; + ctx.textAlign = element.textAlign as CanvasTextAlign; + if ("letterSpacing" in ctx) { + ( + ctx as CanvasRenderingContext2D & { letterSpacing: string } + ).letterSpacing = `${letterSpacing}px`; + } + + const lines = element.content.split("\n"); + const lineMetrics = lines.map((line) => ctx.measureText(line)); + + let top = Number.POSITIVE_INFINITY; + let bottom = Number.NEGATIVE_INFINITY; + let maxWidth = 0; + + for (let i = 0; i < lineMetrics.length; i++) { + const metrics = lineMetrics[i]; + const y = i * lineHeightPx; + top = Math.min( + top, + y - (metrics.actualBoundingBoxAscent ?? scaledFontSize * 0.8), + ); + bottom = Math.max( + bottom, + y + (metrics.actualBoundingBoxDescent ?? scaledFontSize * 0.2), + ); + maxWidth = Math.max(maxWidth, metrics.width); + } + + measuredWidth = maxWidth; + measuredHeight = bottom - top; + } + + const width = measuredWidth * element.transform.scale; + const height = measuredHeight * element.transform.scale; + return { + cx: canvasWidth / 2 + element.transform.position.x, + cy: canvasHeight / 2 + element.transform.position.y, + width, + height, + rotation: element.transform.rotate, + }; + } + + return null; +} + +export function getVisibleElementsWithBounds({ + tracks, + currentTime, + canvasSize, + mediaAssets, +}: { + tracks: TimelineTrack[]; + currentTime: number; + canvasSize: { width: number; height: number }; + mediaAssets: MediaAsset[]; +}): ElementWithBounds[] { + const mediaMap = new Map(mediaAssets.map((m) => [m.id, m])); + const visibleTracks = tracks.filter( + (track) => !("hidden" in track && track.hidden), + ); + const orderedTracks = [ + ...visibleTracks.filter((track) => !isMainTrack(track)), + ...visibleTracks.filter((track) => isMainTrack(track)), + ].reverse(); + + const result: ElementWithBounds[] = []; + + for (const track of orderedTracks) { + const elements = track.elements + .filter((element) => !("hidden" in element && element.hidden)) + .filter( + (element) => + currentTime >= element.startTime && + currentTime < element.startTime + element.duration, + ) + .slice() + .sort((a, b) => { + if (a.startTime !== b.startTime) return a.startTime - b.startTime; + return a.id.localeCompare(b.id); + }); + + for (const element of elements) { + const mediaAsset = + element.type === "video" || element.type === "image" + ? mediaMap.get(element.mediaId) + : undefined; + const bounds = getElementBounds({ + element, + canvasSize, + mediaAsset, + }); + if (bounds) { + result.push({ + trackId: track.id, + elementId: element.id, + element, + bounds, + }); + } + } + } + + return result; +} diff --git a/apps/web/src/lib/preview/hit-test.ts b/apps/web/src/lib/preview/hit-test.ts new file mode 100644 index 00000000..c2fde87b --- /dev/null +++ b/apps/web/src/lib/preview/hit-test.ts @@ -0,0 +1,60 @@ +import type { ElementWithBounds } from "./element-bounds"; + +function pointInRotatedRect({ + px, + py, + cx, + cy, + width, + height, + rotation, +}: { + px: number; + py: number; + cx: number; + cy: number; + width: number; + height: number; + rotation: number; +}): boolean { + const angleRad = (rotation * Math.PI) / 180; + const cos = Math.cos(-angleRad); + const sin = Math.sin(-angleRad); + const dx = px - cx; + const dy = py - cy; + const localX = dx * cos - dy * sin; + const localY = dx * sin + dy * cos; + const halfW = width / 2; + const halfH = height / 2; + return ( + localX >= -halfW && localX <= halfW && localY >= -halfH && localY <= halfH + ); +} + +export function hitTest({ + canvasX, + canvasY, + elementsWithBounds, +}: { + canvasX: number; + canvasY: number; + elementsWithBounds: ElementWithBounds[]; +}): ElementWithBounds | null { + for (let i = elementsWithBounds.length - 1; i >= 0; i--) { + const { bounds } = elementsWithBounds[i]; + if ( + pointInRotatedRect({ + px: canvasX, + py: canvasY, + cx: bounds.cx, + cy: bounds.cy, + width: bounds.width, + height: bounds.height, + rotation: bounds.rotation, + }) + ) { + return elementsWithBounds[i]; + } + } + return null; +} diff --git a/apps/web/src/lib/preview/preview-coords.ts b/apps/web/src/lib/preview/preview-coords.ts new file mode 100644 index 00000000..08c16735 --- /dev/null +++ b/apps/web/src/lib/preview/preview-coords.ts @@ -0,0 +1,76 @@ +export function screenToCanvas({ + clientX, + clientY, + canvas, +}: { + clientX: number; + clientY: number; + canvas: HTMLCanvasElement; +}): { x: number; y: number } { + const rect = canvas.getBoundingClientRect(); + const scaleX = canvas.width / rect.width; + const scaleY = canvas.height / rect.height; + return { + x: (clientX - rect.left) * scaleX, + y: (clientY - rect.top) * scaleY, + }; +} + +export function canvasToOverlay({ + canvasX, + canvasY, + canvasRect, + containerRect, + canvasSize, +}: { + canvasX: number; + canvasY: number; + canvasRect: DOMRect; + containerRect: DOMRect; + canvasSize: { width: number; height: number }; +}): { x: number; y: number } { + const scaleX = canvasRect.width / canvasSize.width; + const scaleY = canvasRect.height / canvasSize.height; + return { + x: canvasRect.left - containerRect.left + canvasX * scaleX, + y: canvasRect.top - containerRect.top + canvasY * scaleY, + }; +} + +export function positionToOverlay({ + positionX, + positionY, + canvasRect, + containerRect, + canvasSize, +}: { + positionX: number; + positionY: number; + canvasRect: DOMRect; + containerRect: DOMRect; + canvasSize: { width: number; height: number }; +}): { x: number; y: number } { + const scaleX = canvasRect.width / canvasSize.width; + const scaleY = canvasRect.height / canvasSize.height; + const centerScreenX = + canvasRect.left - containerRect.left + (canvasSize.width / 2) * scaleX; + const centerScreenY = + canvasRect.top - containerRect.top + (canvasSize.height / 2) * scaleY; + return { + x: centerScreenX + positionX * scaleX, + y: centerScreenY + positionY * scaleY, + }; +} + +export function getDisplayScale({ + canvasRect, + canvasSize, +}: { + canvasRect: DOMRect; + canvasSize: { width: number; height: number }; +}): { x: number; y: number } { + return { + x: canvasRect.width / canvasSize.width, + y: canvasRect.height / canvasSize.height, + }; +} diff --git a/apps/web/src/lib/preview/preview-snap.ts b/apps/web/src/lib/preview/preview-snap.ts new file mode 100644 index 00000000..bd53f0ca --- /dev/null +++ b/apps/web/src/lib/preview/preview-snap.ts @@ -0,0 +1,292 @@ +export interface SnapLine { + type: "horizontal" | "vertical"; + position: number; +} + +const SNAP_THRESHOLD = 10; +const ROTATION_SNAP_STEP_DEGREES = 90; +const ROTATION_SNAP_THRESHOLD_DEGREES = 5; +export const MIN_SCALE = 0.01; + +export interface SnapResult { + snappedPosition: { x: number; y: number }; + activeLines: SnapLine[]; +} + +export function snapPosition({ + proposedPosition, + canvasSize, + elementSize, +}: { + proposedPosition: { x: number; y: number }; + canvasSize: { width: number; height: number }; + elementSize: { width: number; height: number }; +}): SnapResult { + const centerX = 0; + const centerY = 0; + const left = -canvasSize.width / 2; + const right = canvasSize.width / 2; + const top = -canvasSize.height / 2; + const bottom = canvasSize.height / 2; + + const halfWidth = elementSize.width / 2; + const halfHeight = elementSize.height / 2; + const activeLines: SnapLine[] = []; + + type AxisSnapCandidate = { + snappedPosition: number; + line: SnapLine; + distance: number; + }; + + function getClosestAxisSnap({ + candidates, + }: { + candidates: AxisSnapCandidate[]; + }): AxisSnapCandidate | null { + const snapCandidatesWithinThreshold = candidates.filter( + (candidate) => candidate.distance <= SNAP_THRESHOLD, + ); + if (snapCandidatesWithinThreshold.length === 0) { + return null; + } + return snapCandidatesWithinThreshold.reduce((closest, current) => + current.distance < closest.distance ? current : closest, + ); + } + + const verticalTargets = [left, centerX, right]; + const horizontalTargets = [top, centerY, bottom]; + + const xCandidates: AxisSnapCandidate[] = []; + for (const targetX of verticalTargets) { + xCandidates.push({ + snappedPosition: targetX, + line: { type: "vertical", position: targetX }, + distance: Math.abs(proposedPosition.x - targetX), + }); + xCandidates.push({ + snappedPosition: targetX + halfWidth, + line: { type: "vertical", position: targetX }, + distance: Math.abs(proposedPosition.x - halfWidth - targetX), + }); + xCandidates.push({ + snappedPosition: targetX - halfWidth, + line: { type: "vertical", position: targetX }, + distance: Math.abs(proposedPosition.x + halfWidth - targetX), + }); + } + const yCandidates: AxisSnapCandidate[] = []; + for (const targetY of horizontalTargets) { + yCandidates.push({ + snappedPosition: targetY, + line: { type: "horizontal", position: targetY }, + distance: Math.abs(proposedPosition.y - targetY), + }); + yCandidates.push({ + snappedPosition: targetY + halfHeight, + line: { type: "horizontal", position: targetY }, + distance: Math.abs(proposedPosition.y - halfHeight - targetY), + }); + yCandidates.push({ + snappedPosition: targetY - halfHeight, + line: { type: "horizontal", position: targetY }, + distance: Math.abs(proposedPosition.y + halfHeight - targetY), + }); + } + + const closestX = getClosestAxisSnap({ candidates: xCandidates }); + const closestY = getClosestAxisSnap({ candidates: yCandidates }); + + const x = closestX?.snappedPosition ?? proposedPosition.x; + const y = closestY?.snappedPosition ?? proposedPosition.y; + if (closestX) { + activeLines.push(closestX.line); + } + if (closestY) { + activeLines.push(closestY.line); + } + + return { + snappedPosition: { x, y }, + activeLines, + }; +} + +export interface ScaleSnapResult { + snappedScale: number; + activeLines: SnapLine[]; +} + +export function snapScale({ + proposedScale, + position, + baseWidth, + baseHeight, + canvasSize, +}: { + proposedScale: number; + position: { x: number; y: number }; + baseWidth: number; + baseHeight: number; + canvasSize: { width: number; height: number }; +}): ScaleSnapResult { + const centerX = 0; + const centerY = 0; + const left = -canvasSize.width / 2; + const right = canvasSize.width / 2; + const top = -canvasSize.height / 2; + const bottom = canvasSize.height / 2; + + const leftEdge = position.x - (baseWidth * proposedScale) / 2; + const rightEdge = position.x + (baseWidth * proposedScale) / 2; + const topEdge = position.y - (baseHeight * proposedScale) / 2; + const bottomEdge = position.y + (baseHeight * proposedScale) / 2; + + interface SnapCandidate { + scale: number; + distance: number; + lines: SnapLine[]; + } + + const candidates: SnapCandidate[] = []; + + const verticalTargets = [ + { position: left, line: { type: "vertical" as const, position: left } }, + { + position: centerX, + line: { type: "vertical" as const, position: centerX }, + }, + { position: right, line: { type: "vertical" as const, position: right } }, + ]; + + for (const target of verticalTargets) { + const distanceLeft = Math.abs(leftEdge - target.position); + if (distanceLeft <= SNAP_THRESHOLD) { + const scale = (2 * (position.x - target.position)) / baseWidth; + if (scale > MIN_SCALE) { + candidates.push({ + scale, + distance: distanceLeft, + lines: [target.line], + }); + } + } + const distanceRight = Math.abs(rightEdge - target.position); + if (distanceRight <= SNAP_THRESHOLD) { + const scale = (2 * (target.position - position.x)) / baseWidth; + if (scale > MIN_SCALE) { + candidates.push({ + scale, + distance: distanceRight, + lines: [target.line], + }); + } + } + } + + const horizontalTargets = [ + { position: top, line: { type: "horizontal" as const, position: top } }, + { + position: centerY, + line: { type: "horizontal" as const, position: centerY }, + }, + { + position: bottom, + line: { type: "horizontal" as const, position: bottom }, + }, + ]; + + for (const target of horizontalTargets) { + const distanceTop = Math.abs(topEdge - target.position); + if (distanceTop <= SNAP_THRESHOLD) { + const scale = (2 * (position.y - target.position)) / baseHeight; + if (scale > MIN_SCALE) { + candidates.push({ + scale, + distance: distanceTop, + lines: [target.line], + }); + } + } + const distanceBottom = Math.abs(bottomEdge - target.position); + if (distanceBottom <= SNAP_THRESHOLD) { + const scale = (2 * (target.position - position.y)) / baseHeight; + if (scale > MIN_SCALE) { + candidates.push({ + scale, + distance: distanceBottom, + lines: [target.line], + }); + } + } + } + + if (candidates.length === 0) { + return { snappedScale: proposedScale, activeLines: [] }; + } + + const best = candidates.reduce((acc, candidate) => + candidate.distance < acc.distance ? candidate : acc, + ); + + const snappedLeft = position.x - (baseWidth * best.scale) / 2; + const snappedRight = position.x + (baseWidth * best.scale) / 2; + const snappedTop = position.y - (baseHeight * best.scale) / 2; + const snappedBottom = position.y + (baseHeight * best.scale) / 2; + + const activeLines: SnapLine[] = []; + const seenKeys = new Set<string>(); + + function addLine({ line }: { line: SnapLine }) { + const key = `${line.type}-${line.position}`; + if (!seenKeys.has(key)) { + seenKeys.add(key); + activeLines.push(line); + } + } + + for (const target of verticalTargets) { + if ( + Math.abs(snappedLeft - target.position) <= 1 || + Math.abs(snappedRight - target.position) <= 1 + ) { + addLine({ line: target.line }); + } + } + for (const target of horizontalTargets) { + if ( + Math.abs(snappedTop - target.position) <= 1 || + Math.abs(snappedBottom - target.position) <= 1 + ) { + addLine({ line: target.line }); + } + } + + return { + snappedScale: best.scale, + activeLines, + }; +} + +export interface RotationSnapResult { + snappedRotation: number; + isSnapped: boolean; +} + +export function snapRotation({ + proposedRotation, +}: { + proposedRotation: number; +}): RotationSnapResult { + const nearestRotationSnap = + Math.round(proposedRotation / ROTATION_SNAP_STEP_DEGREES) * + ROTATION_SNAP_STEP_DEGREES; + const distanceToNearestSnap = Math.abs( + proposedRotation - nearestRotationSnap, + ); + if (distanceToNearestSnap <= ROTATION_SNAP_THRESHOLD_DEGREES) { + return { snappedRotation: nearestRotationSnap, isSnapped: true }; + } + return { snappedRotation: proposedRotation, isSnapped: false }; +} diff --git a/apps/web/src/lib/stickers/__tests__/sticker-id.test.ts b/apps/web/src/lib/stickers/__tests__/sticker-id.test.ts new file mode 100644 index 00000000..573dc251 --- /dev/null +++ b/apps/web/src/lib/stickers/__tests__/sticker-id.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "bun:test"; +import { buildStickerId, parseStickerId } from "../sticker-id"; + +describe("sticker-id strict mode", () => { + test("parses provider-prefixed IDs", () => { + expect(parseStickerId({ stickerId: "icons:mdi:home" })).toEqual({ + providerId: "icons", + providerValue: "mdi:home", + }); + expect(parseStickerId({ stickerId: "emoji:noto:grinning-face" })).toEqual({ + providerId: "emoji", + providerValue: "noto:grinning-face", + }); + }); + + test("throws for IDs without provider prefix", () => { + expect(() => parseStickerId({ stickerId: "home" })).toThrow(); + }); + + test("throws for malformed IDs", () => { + expect(() => parseStickerId({ stickerId: "" })).toThrow(); + expect(() => parseStickerId({ stickerId: "icons:" })).toThrow(); + expect(() => parseStickerId({ stickerId: ":mdi:home" })).toThrow(); + }); + + test("builds sticker IDs unchanged", () => { + expect( + buildStickerId({ + providerId: "flags", + providerValue: "US", + }), + ).toBe("flags:US"); + }); +}); diff --git a/apps/web/src/lib/stickers/index.ts b/apps/web/src/lib/stickers/index.ts new file mode 100644 index 00000000..1f924c6d --- /dev/null +++ b/apps/web/src/lib/stickers/index.ts @@ -0,0 +1,175 @@ +import { STICKER_CATEGORIES } from "@/constants/sticker-constants"; +import type { StickerCategory } from "@/types/stickers"; +import { getAllProviders, getProvider } from "./registry"; +import { resolveStickerId } from "./resolver"; +import { registerDefaultStickerProviders } from "./providers"; +import type { StickerProvider, StickerSearchResult } from "./types"; + +const DEFAULT_SEARCH_LIMIT = 100; + +function mergeSearchResults({ + results, +}: { + results: StickerSearchResult[]; +}): StickerSearchResult { + const deduplicatedItems = new Map< + string, + StickerSearchResult["items"][number] + >(); + let total = 0; + let hasMore = false; + + for (const result of results) { + total += result.total; + hasMore = hasMore || result.hasMore; + for (const item of result.items) { + if (!deduplicatedItems.has(item.id)) { + deduplicatedItems.set(item.id, item); + } + } + } + + return { + items: Array.from(deduplicatedItems.values()), + total, + hasMore, + }; +} + +function getProviderByCategory({ + category, +}: { + category: StickerCategory; +}): StickerProvider | null { + if (category === "all") { + return null; + } + + try { + return getProvider({ providerId: category }); + } catch { + return null; + } +} + +export async function searchStickers({ + query, + category, + limit = DEFAULT_SEARCH_LIMIT, +}: { + query: string; + category: StickerCategory; + limit?: number; +}): Promise<StickerSearchResult> { + registerDefaultStickerProviders({}); + + const effectiveCategory = category in STICKER_CATEGORIES ? category : "all"; + if (effectiveCategory !== "all") { + const provider = getProviderByCategory({ category: effectiveCategory }); + if (!provider) { + return { + items: [], + total: 0, + hasMore: false, + }; + } + return provider.search({ + query, + options: { limit }, + }); + } + + const providers = getAllProviders(); + if (providers.length === 0) { + return { + items: [], + total: 0, + hasMore: false, + }; + } + + const perProviderLimit = Math.max(1, Math.ceil(limit / providers.length)); + const settledResults = await Promise.allSettled( + providers.map((provider) => + provider.search({ + query, + options: { limit: perProviderLimit }, + }), + ), + ); + + const fulfilledResults = settledResults + .filter( + (result): result is PromiseFulfilledResult<StickerSearchResult> => + result.status === "fulfilled", + ) + .map((result) => result.value); + + return mergeSearchResults({ + results: fulfilledResults, + }); +} + +export async function browseStickers({ + category, + page = 1, + limit = DEFAULT_SEARCH_LIMIT, +}: { + category: StickerCategory; + page?: number; + limit?: number; +}): Promise<StickerSearchResult> { + registerDefaultStickerProviders({}); + + const effectiveCategory = category in STICKER_CATEGORIES ? category : "all"; + if (effectiveCategory !== "all") { + const provider = getProviderByCategory({ category: effectiveCategory }); + if (!provider) { + return { + items: [], + total: 0, + hasMore: false, + }; + } + return provider.browse({ + options: { page, limit }, + }); + } + + const providers = getAllProviders(); + if (providers.length === 0) { + return { + items: [], + total: 0, + hasMore: false, + }; + } + + const perProviderLimit = Math.max(1, Math.ceil(limit / providers.length)); + const settledResults = await Promise.allSettled( + providers.map((provider) => + provider.browse({ + options: { page, limit: perProviderLimit }, + }), + ), + ); + + const fulfilledResults = settledResults + .filter( + (result): result is PromiseFulfilledResult<StickerSearchResult> => + result.status === "fulfilled", + ) + .map((result) => result.value); + + return mergeSearchResults({ + results: fulfilledResults, + }); +} + +export { resolveStickerId }; +export type { + StickerItem, + StickerProvider, + StickerResolveOptions, + StickerSearchResult, +} from "./types"; diff --git a/apps/web/src/lib/stickers/providers/emoji.ts b/apps/web/src/lib/stickers/providers/emoji.ts new file mode 100644 index 00000000..cc198236 --- /dev/null +++ b/apps/web/src/lib/stickers/providers/emoji.ts @@ -0,0 +1,119 @@ +import { + POPULAR_COLLECTIONS, + getIconSvgUrl, + searchIcons, +} from "@/lib/iconify-api"; +import { buildStickerId, parseStickerId } from "../sticker-id"; +import type { + StickerItem, + StickerProvider, + StickerSearchResult, +} from "../types"; + +const EMOJI_PROVIDER_ID = "emoji"; +const DEFAULT_SEARCH_LIMIT = 100; + +const EMOJI_PREFIXES = POPULAR_COLLECTIONS.emoji.map( + (collection) => collection.prefix, +); + +const DEFAULT_EMOJI_BROWSE = [ + "noto:grinning-face", + "noto:smiling-face-with-heart-eyes", + "noto:fire", + "noto:rocket", + "noto:party-popper", + "noto:clapping-hands", + "noto:sparkles", + "noto:red-heart", + "noto:thumbs-up", + "noto:eyes", + "noto:thinking-face", + "noto:hundred-points", +]; + +function getDisplayNameFromIconName({ + iconName, +}: { + iconName: string; +}): string { + const parts = iconName.split(":"); + const rawName = parts[parts.length - 1] ?? iconName; + return rawName.replaceAll("-", " ").replaceAll("_", " "); +} + +function toStickerItem({ iconName }: { iconName: string }): StickerItem { + return { + id: buildStickerId({ + providerId: EMOJI_PROVIDER_ID, + providerValue: iconName, + }), + provider: EMOJI_PROVIDER_ID, + name: getDisplayNameFromIconName({ iconName }), + previewUrl: getIconSvgUrl(iconName, { width: 64, height: 64 }), + metadata: { iconName }, + }; +} + +function computeHasMore({ + total, + limit, + start = 0, +}: { + total: number; + limit: number; + start?: number; +}): boolean { + return start + limit < total; +} + +export const emojiProvider: StickerProvider = { + id: EMOJI_PROVIDER_ID, + async search({ + query, + options, + }: { + query: string; + options?: { limit?: number }; + }): Promise<StickerSearchResult> { + const limit = options?.limit ?? DEFAULT_SEARCH_LIMIT; + const searchResult = await searchIcons(query, limit, EMOJI_PREFIXES); + return { + items: searchResult.icons.map((iconName) => toStickerItem({ iconName })), + total: searchResult.total, + hasMore: computeHasMore({ + total: searchResult.total, + limit: searchResult.limit, + start: searchResult.start, + }), + }; + }, + async browse({ + options, + }: { + options?: { page?: number; limit?: number }; + }): Promise<StickerSearchResult> { + const limit = options?.limit ?? DEFAULT_EMOJI_BROWSE.length; + const items = DEFAULT_EMOJI_BROWSE.slice(0, limit).map((iconName) => + toStickerItem({ iconName }), + ); + return { + items, + total: items.length, + hasMore: false, + }; + }, + resolveUrl({ + stickerId, + options, + }: { + stickerId: string; + options?: { width?: number; height?: number }; + }): string { + const { providerValue } = parseStickerId({ stickerId }); + return getIconSvgUrl(providerValue, { + width: options?.width, + height: options?.height, + }); + }, +}; diff --git a/apps/web/src/lib/stickers/providers/flags.ts b/apps/web/src/lib/stickers/providers/flags.ts new file mode 100644 index 00000000..91cc5489 --- /dev/null +++ b/apps/web/src/lib/stickers/providers/flags.ts @@ -0,0 +1,167 @@ +import { buildStickerId, parseStickerId } from "../sticker-id"; +import type { + StickerItem, + StickerProvider, + StickerSearchResult, +} from "../types"; + +const FLAGS_PROVIDER_ID = "flags"; +const FLAGS_DATASET_URL = "/countries.json"; +const DEFAULT_SEARCH_LIMIT = 100; +const DEFAULT_FLAGS_BASE_URL = "/flags"; + +type CountryRecord = { + name: string; + code: string; + languages?: string[]; + flag_colors?: string[]; + region?: string; +}; + +let countriesPromise: Promise<CountryRecord[]> | null = null; + +function getFlagsBaseUrl(): string { + return DEFAULT_FLAGS_BASE_URL.replace(/\/$/, ""); +} + +function buildFlagUrl({ code }: { code: string }): string { + const normalizedCode = code.toUpperCase(); + return `${getFlagsBaseUrl()}/${encodeURIComponent(normalizedCode)}.svg`; +} + +async function loadCountries(): Promise<CountryRecord[]> { + if (countriesPromise) { + return countriesPromise; + } + + countriesPromise = fetch(FLAGS_DATASET_URL) + .then(async (response) => { + if (!response.ok) { + throw new Error(`Failed to load countries: ${response.status}`); + } + return (await response.json()) as CountryRecord[]; + }) + .catch((error) => { + console.error("Failed to load countries dataset:", error); + return []; + }); + + return countriesPromise; +} + +function toStickerItem({ country }: { country: CountryRecord }): StickerItem { + const normalizedCode = country.code.toUpperCase(); + return { + id: buildStickerId({ + providerId: FLAGS_PROVIDER_ID, + providerValue: normalizedCode, + }), + provider: FLAGS_PROVIDER_ID, + name: country.name, + previewUrl: buildFlagUrl({ code: normalizedCode }), + metadata: { + code: normalizedCode, + region: country.region ?? null, + languages: country.languages ?? [], + flagColors: country.flag_colors ?? [], + }, + }; +} + +function normalizeQuery({ query }: { query: string }): string { + return query.trim().toLowerCase(); +} + +function filterCountriesByQuery({ + countries, + query, +}: { + countries: CountryRecord[]; + query: string; +}): CountryRecord[] { + if (!query) { + return countries; + } + + return countries.filter((country) => { + const normalizedName = country.name.toLowerCase(); + const normalizedCode = country.code.toLowerCase(); + const normalizedRegion = country.region?.toLowerCase() ?? ""; + return ( + normalizedName.includes(query) || + normalizedCode.includes(query) || + normalizedRegion.includes(query) + ); + }); +} + +function paginateCountries({ + countries, + options, +}: { + countries: CountryRecord[]; + options?: { page?: number; limit?: number }; +}): { items: CountryRecord[]; hasMore: boolean; total: number } { + const page = Math.max(1, options?.page ?? 1); + const limit = Math.max(1, options?.limit ?? DEFAULT_SEARCH_LIMIT); + const startIndex = (page - 1) * limit; + const endIndex = startIndex + limit; + const pagedItems = countries.slice(startIndex, endIndex); + return { + items: pagedItems, + hasMore: endIndex < countries.length, + total: countries.length, + }; +} + +export const flagsProvider: StickerProvider = { + id: FLAGS_PROVIDER_ID, + async search({ + query, + options, + }: { + query: string; + options?: { limit?: number }; + }): Promise<StickerSearchResult> { + const countries = await loadCountries(); + const normalizedQuery = normalizeQuery({ query }); + const filteredCountries = filterCountriesByQuery({ + countries, + query: normalizedQuery, + }); + const paged = paginateCountries({ + countries: filteredCountries, + options: { + page: 1, + limit: options?.limit ?? DEFAULT_SEARCH_LIMIT, + }, + }); + return { + items: paged.items.map((country) => toStickerItem({ country })), + total: paged.total, + hasMore: paged.hasMore, + }; + }, + async browse({ + options, + }: { + options?: { page?: number; limit?: number }; + }): Promise<StickerSearchResult> { + const countries = await loadCountries(); + const paged = paginateCountries({ countries, options }); + return { + items: paged.items.map((country) => toStickerItem({ country })), + total: paged.total, + hasMore: paged.hasMore, + }; + }, + resolveUrl({ + stickerId, + }: { + stickerId: string; + options?: { width?: number; height?: number }; + }): string { + const { providerValue } = parseStickerId({ stickerId }); + return buildFlagUrl({ code: providerValue }); + }, +}; diff --git a/apps/web/src/lib/stickers/providers/icons.ts b/apps/web/src/lib/stickers/providers/icons.ts new file mode 100644 index 00000000..06b2a4c2 --- /dev/null +++ b/apps/web/src/lib/stickers/providers/icons.ts @@ -0,0 +1,122 @@ +import { + POPULAR_COLLECTIONS, + getIconSvgUrl, + searchIcons, +} from "@/lib/iconify-api"; +import { buildStickerId, parseStickerId } from "../sticker-id"; +import type { + StickerItem, + StickerProvider, + StickerSearchResult, +} from "../types"; + +const ICONS_PROVIDER_ID = "icons"; +const DEFAULT_SEARCH_LIMIT = 100; + +const ICONS_PREFIXES = Array.from( + new Set( + [...POPULAR_COLLECTIONS.general, ...POPULAR_COLLECTIONS.brands].map( + (collection) => collection.prefix, + ), + ), +); + +const DEFAULT_ICONS_BROWSE = [ + "mdi:home", + "mdi:star", + "mdi:heart", + "mdi:check-circle", + "mdi:account", + "mdi:camera", + "mdi:music", + "mdi:map-marker", + "mdi:calendar", + "mdi:lightning-bolt", + "mdi:cog", + "mdi:rocket", +]; + +function getDisplayNameFromIconName({ + iconName, +}: { + iconName: string; +}): string { + const [, rawName = iconName] = iconName.split(":"); + return rawName.replaceAll("-", " ").replaceAll("_", " "); +} + +function toStickerItem({ iconName }: { iconName: string }): StickerItem { + return { + id: buildStickerId({ + providerId: ICONS_PROVIDER_ID, + providerValue: iconName, + }), + provider: ICONS_PROVIDER_ID, + name: getDisplayNameFromIconName({ iconName }), + previewUrl: getIconSvgUrl(iconName, { width: 64, height: 64 }), + metadata: { iconName }, + }; +} + +function computeHasMore({ + total, + limit, + start = 0, +}: { + total: number; + limit: number; + start?: number; +}): boolean { + return start + limit < total; +} + +export const iconsProvider: StickerProvider = { + id: ICONS_PROVIDER_ID, + async search({ + query, + options, + }: { + query: string; + options?: { limit?: number }; + }): Promise<StickerSearchResult> { + const limit = options?.limit ?? DEFAULT_SEARCH_LIMIT; + const searchResult = await searchIcons(query, limit, ICONS_PREFIXES); + return { + items: searchResult.icons.map((iconName) => toStickerItem({ iconName })), + total: searchResult.total, + hasMore: computeHasMore({ + total: searchResult.total, + limit: searchResult.limit, + start: searchResult.start, + }), + }; + }, + async browse({ + options, + }: { + options?: { page?: number; limit?: number }; + }): Promise<StickerSearchResult> { + const limit = options?.limit ?? DEFAULT_ICONS_BROWSE.length; + const items = DEFAULT_ICONS_BROWSE.slice(0, limit).map((iconName) => + toStickerItem({ iconName }), + ); + return { + items, + total: items.length, + hasMore: false, + }; + }, + resolveUrl({ + stickerId, + options, + }: { + stickerId: string; + options?: { width?: number; height?: number }; + }): string { + const { providerValue } = parseStickerId({ stickerId }); + return getIconSvgUrl(providerValue, { + width: options?.width, + height: options?.height, + }); + }, +}; diff --git a/apps/web/src/lib/stickers/providers/index.ts b/apps/web/src/lib/stickers/providers/index.ts new file mode 100644 index 00000000..3c4faee9 --- /dev/null +++ b/apps/web/src/lib/stickers/providers/index.ts @@ -0,0 +1,26 @@ +import { hasProvider, registerProvider } from "../registry"; +import type { StickerProvider } from "@/types/stickers"; +import { emojiProvider } from "./emoji"; +import { flagsProvider } from "./flags"; +import { iconsProvider } from "./icons"; +import { shapesProvider } from "./shapes"; + +const defaultProviders: StickerProvider[] = [ + iconsProvider, + emojiProvider, + flagsProvider, + shapesProvider, +]; + +export function registerDefaultStickerProviders({ + providersToRegister = defaultProviders, +}: { + providersToRegister?: StickerProvider[]; +} = {}): void { + for (const provider of providersToRegister) { + if (hasProvider({ providerId: provider.id })) { + continue; + } + registerProvider({ provider }); + } +} diff --git a/apps/web/src/lib/stickers/providers/shapes.ts b/apps/web/src/lib/stickers/providers/shapes.ts new file mode 100644 index 00000000..81873dcb --- /dev/null +++ b/apps/web/src/lib/stickers/providers/shapes.ts @@ -0,0 +1,118 @@ +import { buildStickerId, parseStickerId } from "../sticker-id"; +import type { + StickerItem, + StickerProvider, + StickerSearchResult, +} from "../types"; + +const SHAPES_PROVIDER_ID = "shapes"; + +const SHAPES = [ + { key: "circle", name: "Circle" }, + { key: "square", name: "Square" }, + { key: "triangle", name: "Triangle" }, + { key: "star", name: "Star" }, + { key: "hexagon", name: "Hexagon" }, + { key: "diamond", name: "Diamond" }, +] as const; + +function buildShapeUrl({ shapeKey }: { shapeKey: string }): string { + return `/shapes/${shapeKey}.svg`; +} + +function toStickerItem({ + shape, +}: { + shape: (typeof SHAPES)[number]; +}): StickerItem { + return { + id: buildStickerId({ + providerId: SHAPES_PROVIDER_ID, + providerValue: shape.key, + }), + provider: SHAPES_PROVIDER_ID, + name: shape.name, + previewUrl: buildShapeUrl({ shapeKey: shape.key }), + metadata: { shape: shape.key }, + }; +} + +function filterShapesByQuery({ + query, +}: { + query: string; +}): Array<(typeof SHAPES)[number]> { + const normalizedQuery = query.trim().toLowerCase(); + if (!normalizedQuery) { + return [...SHAPES]; + } + + return SHAPES.filter((shape) => + shape.name.toLowerCase().includes(normalizedQuery), + ); +} + +function paginateShapes({ + shapes, + options, +}: { + shapes: Array<(typeof SHAPES)[number]>; + options?: { page?: number; limit?: number }; +}): { items: Array<(typeof SHAPES)[number]>; hasMore: boolean; total: number } { + const page = Math.max(1, options?.page ?? 1); + const limit = Math.max(1, options?.limit ?? SHAPES.length); + const startIndex = (page - 1) * limit; + const endIndex = startIndex + limit; + const pagedItems = shapes.slice(startIndex, endIndex); + return { + items: pagedItems, + hasMore: endIndex < shapes.length, + total: shapes.length, + }; +} + +export const shapesProvider: StickerProvider = { + id: SHAPES_PROVIDER_ID, + async search({ + query, + options, + }: { + query: string; + options?: { limit?: number }; + }): Promise<StickerSearchResult> { + const filteredShapes = filterShapesByQuery({ query }); + const paged = paginateShapes({ + shapes: filteredShapes, + options: { page: 1, limit: options?.limit ?? SHAPES.length }, + }); + return { + items: paged.items.map((shape) => toStickerItem({ shape })), + total: paged.total, + hasMore: paged.hasMore, + }; + }, + async browse({ + options, + }: { + options?: { page?: number; limit?: number }; + }): Promise<StickerSearchResult> { + const paged = paginateShapes({ + shapes: [...SHAPES], + options, + }); + return { + items: paged.items.map((shape) => toStickerItem({ shape })), + total: paged.total, + hasMore: paged.hasMore, + }; + }, + resolveUrl({ + stickerId, + }: { + stickerId: string; + options?: { width?: number; height?: number }; + }): string { + const { providerValue } = parseStickerId({ stickerId }); + return buildShapeUrl({ shapeKey: providerValue }); + }, +}; diff --git a/apps/web/src/lib/stickers/registry.ts b/apps/web/src/lib/stickers/registry.ts new file mode 100644 index 00000000..0fbad594 --- /dev/null +++ b/apps/web/src/lib/stickers/registry.ts @@ -0,0 +1,31 @@ +import type { StickerProvider } from "@/types/stickers"; + +const providers = new Map<string, StickerProvider>(); + +export function registerProvider({ + provider, +}: { + provider: StickerProvider; +}): void { + providers.set(provider.id, provider); +} + +export function hasProvider({ providerId }: { providerId: string }): boolean { + return providers.has(providerId); +} + +export function getProvider({ + providerId, +}: { + providerId: string; +}): StickerProvider { + const provider = providers.get(providerId); + if (!provider) { + throw new Error(`Unknown sticker provider: ${providerId}`); + } + return provider; +} + +export function getAllProviders(): StickerProvider[] { + return Array.from(providers.values()); +} diff --git a/apps/web/src/lib/stickers/resolver.ts b/apps/web/src/lib/stickers/resolver.ts new file mode 100644 index 00000000..3252cd60 --- /dev/null +++ b/apps/web/src/lib/stickers/resolver.ts @@ -0,0 +1,22 @@ +import { getProvider } from "./registry"; +import { parseStickerId } from "./sticker-id"; +import { registerDefaultStickerProviders } from "./providers"; +import type { StickerResolveOptions } from "@/types/stickers"; + +export function resolveStickerId({ + stickerId, + options, +}: { + stickerId: string; + options?: StickerResolveOptions; +}): string { + registerDefaultStickerProviders(); + + const parsedStickerId = parseStickerId({ stickerId }); + return getProvider({ + providerId: parsedStickerId.providerId, + }).resolveUrl({ + stickerId, + options, + }); +} diff --git a/apps/web/src/lib/stickers/sticker-id.ts b/apps/web/src/lib/stickers/sticker-id.ts new file mode 100644 index 00000000..8cb9364b --- /dev/null +++ b/apps/web/src/lib/stickers/sticker-id.ts @@ -0,0 +1,34 @@ +export function parseStickerId({ stickerId }: { stickerId: string }): { + providerId: string; + providerValue: string; +} { + const normalizedStickerId = stickerId.trim(); + if (!normalizedStickerId) { + throw new Error("Sticker ID must be a non-empty string"); + } + + const separatorIndex = normalizedStickerId.indexOf(":"); + if ( + separatorIndex <= 0 || + separatorIndex === normalizedStickerId.length - 1 + ) { + throw new Error( + `Invalid sticker ID format: "${stickerId}". Expected "provider:value".`, + ); + } + + const providerId = normalizedStickerId.slice(0, separatorIndex).trim(); + const providerValue = normalizedStickerId.slice(separatorIndex + 1).trim(); + + return { providerId, providerValue }; +} + +export function buildStickerId({ + providerId, + providerValue, +}: { + providerId: string; + providerValue: string; +}): string { + return `${providerId}:${providerValue}`; +} diff --git a/apps/web/src/lib/stickers/types.ts b/apps/web/src/lib/stickers/types.ts new file mode 100644 index 00000000..91cbcaae --- /dev/null +++ b/apps/web/src/lib/stickers/types.ts @@ -0,0 +1,8 @@ +export type { + StickerItem, + StickerProvider, + StickerProviderBrowseOptions, + StickerProviderSearchOptions, + StickerResolveOptions, + StickerSearchResult, +} from "@/types/stickers"; diff --git a/apps/web/src/lib/timeline/bookmarks.ts b/apps/web/src/lib/timeline/bookmarks.ts index 0b06c2f6..efaa8f74 100644 --- a/apps/web/src/lib/timeline/bookmarks.ts +++ b/apps/web/src/lib/timeline/bookmarks.ts @@ -1,14 +1,27 @@ +import type { Bookmark } from "@/types/timeline"; import { roundToFrame } from "@/lib/time"; +export const BOOKMARK_TIME_EPSILON = 0.001; + +function bookmarkTimeEqual({ + bookmarkTime, + frameTime, +}: { + bookmarkTime: number; + frameTime: number; +}): boolean { + return Math.abs(bookmarkTime - frameTime) < BOOKMARK_TIME_EPSILON; +} + export function findBookmarkIndex({ bookmarks, frameTime, }: { - bookmarks: number[]; + bookmarks: Bookmark[]; frameTime: number; }): number { - return bookmarks.findIndex( - (bookmark) => Math.abs(bookmark - frameTime) < 0.001, + return bookmarks.findIndex((bookmark) => + bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }), ); } @@ -16,40 +29,84 @@ export function isBookmarkAtTime({ bookmarks, frameTime, }: { - bookmarks: number[]; + bookmarks: Bookmark[]; frameTime: number; }): boolean { - return bookmarks.some((bookmark) => Math.abs(bookmark - frameTime) < 0.001); + return bookmarks.some((bookmark) => + bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }), + ); } export function toggleBookmarkInArray({ bookmarks, frameTime, }: { - bookmarks: number[]; + bookmarks: Bookmark[]; frameTime: number; -}): number[] { +}): Bookmark[] { const bookmarkIndex = findBookmarkIndex({ bookmarks, frameTime }); if (bookmarkIndex !== -1) { - return bookmarks.filter((_, i) => i !== bookmarkIndex); + return bookmarks.filter((_, index) => index !== bookmarkIndex); } - return [...bookmarks, frameTime].sort((a, b) => a - b); + const newBookmarks = [...bookmarks, { time: frameTime }]; + return newBookmarks.slice().sort((a, b) => a.time - b.time); } export function removeBookmarkFromArray({ bookmarks, frameTime, }: { - bookmarks: number[]; + bookmarks: Bookmark[]; frameTime: number; -}): number[] { +}): Bookmark[] { return bookmarks.filter( - (bookmark) => Math.abs(bookmark - frameTime) >= 0.001, + (bookmark) => + !bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }), ); } +export function updateBookmarkInArray({ + bookmarks, + frameTime, + updates, +}: { + bookmarks: Bookmark[]; + frameTime: number; + updates: Partial<Omit<Bookmark, "time">>; +}): Bookmark[] { + const index = findBookmarkIndex({ bookmarks, frameTime }); + if (index === -1) { + return bookmarks; + } + + const updated = { ...bookmarks[index], ...updates }; + const result = [...bookmarks]; + result[index] = updated; + return result; +} + +export function moveBookmarkInArray({ + bookmarks, + fromTime, + toTime, +}: { + bookmarks: Bookmark[]; + fromTime: number; + toTime: number; +}): Bookmark[] { + const index = findBookmarkIndex({ bookmarks, frameTime: fromTime }); + if (index === -1) { + return bookmarks; + } + + const updated = { ...bookmarks[index], time: toTime }; + const result = [...bookmarks]; + result[index] = updated; + return result.slice().sort((a, b) => a.time - b.time); +} + export function getFrameTime({ time, fps, @@ -59,3 +116,34 @@ export function getFrameTime({ }): number { return roundToFrame({ time, fps }); } + +export function getBookmarkAtTime({ + bookmarks, + frameTime, +}: { + bookmarks: Bookmark[]; + frameTime: number; +}): Bookmark | null { + const index = findBookmarkIndex({ bookmarks, frameTime }); + return index === -1 ? null : bookmarks[index]; +} + +export function getBookmarksActiveAtTime({ + bookmarks, + time, +}: { + bookmarks: Bookmark[]; + time: number; +}): Bookmark[] { + return bookmarks.filter((bookmark) => { + const start = bookmark.time; + const end = + bookmark.duration != null && bookmark.duration > 0 + ? start + bookmark.duration + : start; + return ( + time >= start - BOOKMARK_TIME_EPSILON && + time <= end + BOOKMARK_TIME_EPSILON + ); + }); +} diff --git a/apps/web/src/lib/timeline/drag-utils.ts b/apps/web/src/lib/timeline/drag-utils.ts new file mode 100644 index 00000000..43d896ae --- /dev/null +++ b/apps/web/src/lib/timeline/drag-utils.ts @@ -0,0 +1,19 @@ +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; + +export function getMouseTimeFromClientX({ + clientX, + containerRect, + zoomLevel, + scrollLeft, +}: { + clientX: number; + containerRect: DOMRect; + zoomLevel: number; + scrollLeft: number; +}): number { + const mouseX = clientX - containerRect.left + scrollLeft; + return Math.max( + 0, + mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel), + ); +} diff --git a/apps/web/src/lib/timeline/element-utils.ts b/apps/web/src/lib/timeline/element-utils.ts index a167e548..fa74b14e 100644 --- a/apps/web/src/lib/timeline/element-utils.ts +++ b/apps/web/src/lib/timeline/element-utils.ts @@ -1,5 +1,10 @@ import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants"; -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { + DEFAULT_BLEND_MODE, + DEFAULT_OPACITY, + DEFAULT_TRANSFORM, + TIMELINE_CONSTANTS, +} from "@/constants/timeline-constants"; import type { CreateTimelineElement, CreateVideoElement, @@ -16,6 +21,7 @@ import type { StickerElement, UploadAudioElement, } from "@/types/timeline"; +import type { MediaType } from "@/types/assets"; export function canElementHaveAudio( element: TimelineElement, @@ -23,6 +29,17 @@ export function canElementHaveAudio( return element.type === "audio" || element.type === "video"; } +export function isVisualElement( + element: TimelineElement, +): element is VideoElement | ImageElement | TextElement | StickerElement { + return ( + element.type === "video" || + element.type === "image" || + element.type === "text" || + element.type === "sticker" + ); +} + export function canElementBeHidden( element: TimelineElement, ): element is VideoElement | ImageElement | TextElement | StickerElement { @@ -142,28 +159,36 @@ export function buildTextElement({ fontWeight: t.fontWeight ?? DEFAULT_TEXT_ELEMENT.fontWeight, fontStyle: t.fontStyle ?? DEFAULT_TEXT_ELEMENT.fontStyle, textDecoration: t.textDecoration ?? DEFAULT_TEXT_ELEMENT.textDecoration, + letterSpacing: t.letterSpacing ?? DEFAULT_TEXT_ELEMENT.letterSpacing, + lineHeight: t.lineHeight ?? DEFAULT_TEXT_ELEMENT.lineHeight, transform: t.transform ?? DEFAULT_TEXT_ELEMENT.transform, opacity: t.opacity ?? DEFAULT_TEXT_ELEMENT.opacity, + blendMode: t.blendMode ?? DEFAULT_BLEND_MODE, }; } export function buildStickerElement({ - iconName, + stickerId, + name, startTime, }: { - iconName: string; + stickerId: string; + name?: string; startTime: number; }): CreateStickerElement { + const stickerNameFromId = + stickerId.split(":").slice(1).pop()?.replaceAll("-", " ") ?? stickerId; return { type: "sticker", - name: iconName.split(":")[1] || iconName, - iconName, + name: name ?? stickerNameFromId, + stickerId, duration: TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION, startTime, trimStart: 0, trimEnd: 0, - transform: { scale: 1, position: { x: 0, y: 0 }, rotate: 0 }, - opacity: 1, + transform: { ...DEFAULT_TRANSFORM }, + opacity: DEFAULT_OPACITY, + blendMode: DEFAULT_BLEND_MODE, }; } @@ -188,8 +213,9 @@ export function buildVideoElement({ trimEnd: 0, muted: false, hidden: false, - transform: { scale: 1, position: { x: 0, y: 0 }, rotate: 0 }, - opacity: 1, + transform: { ...DEFAULT_TRANSFORM }, + opacity: DEFAULT_OPACITY, + blendMode: DEFAULT_BLEND_MODE, }; } @@ -213,8 +239,9 @@ export function buildImageElement({ trimStart: 0, trimEnd: 0, hidden: false, - transform: { scale: 1, position: { x: 0, y: 0 }, rotate: 0 }, - opacity: 1, + transform: { ...DEFAULT_TRANSFORM }, + opacity: DEFAULT_OPACITY, + blendMode: DEFAULT_BLEND_MODE, }; } @@ -249,6 +276,37 @@ export function buildUploadAudioElement({ return element; } +export function buildElementFromMedia({ + mediaId, + mediaType, + name, + duration, + startTime, + buffer, +}: { + mediaId: string; + mediaType: MediaType; + name: string; + duration: number; + startTime: number; + buffer?: AudioBuffer; +}): CreateTimelineElement { + switch (mediaType) { + case "audio": + return buildUploadAudioElement({ + mediaId, + name, + duration, + startTime, + buffer, + }); + case "video": + return buildVideoElement({ mediaId, name, duration, startTime }); + case "image": + return buildImageElement({ mediaId, name, duration, startTime }); + } +} + export function buildLibraryAudioElement({ sourceUrl, name, @@ -302,3 +360,19 @@ export function getElementsAtTime({ return result; } + +export function collectFontFamilies({ + tracks, +}: { + tracks: TimelineTrack[]; +}): string[] { + const families = new Set<string>(); + for (const track of tracks) { + for (const element of track.elements) { + if (element.type === "text" && element.fontFamily) { + families.add(element.fontFamily); + } + } + } + return [...families]; +} diff --git a/apps/web/src/middleware.ts b/apps/web/src/proxy.ts index c7f28025..dfe8eb84 100644 --- a/apps/web/src/middleware.ts +++ b/apps/web/src/proxy.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; -export async function middleware() { +export async function proxy() { return NextResponse.next(); } diff --git a/apps/web/src/services/renderer/nodes/sticker-node.ts b/apps/web/src/services/renderer/nodes/sticker-node.ts index 9842320e..a2fb0170 100644 --- a/apps/web/src/services/renderer/nodes/sticker-node.ts +++ b/apps/web/src/services/renderer/nodes/sticker-node.ts @@ -1,9 +1,9 @@ import type { CanvasRenderer } from "../canvas-renderer"; +import { resolveStickerId } from "@/lib/stickers"; import { VisualNode, type VisualNodeParams } from "./visual-node"; export interface StickerNodeParams extends VisualNodeParams { - iconName: string; - color?: string; + stickerId: string; } export class StickerNode extends VisualNode<StickerNodeParams> { @@ -18,15 +18,15 @@ export class StickerNode extends VisualNode<StickerNodeParams> { private async load() { const image = new Image(); this.image = image; - const color = this.params.color - ? `&color=${encodeURIComponent(this.params.color)}` - : ""; - const url = `https://api.iconify.design/${this.params.iconName}.svg?width=200&height=200${color}`; + const url = resolveStickerId({ + stickerId: this.params.stickerId, + options: { width: 200, height: 200 }, + }); await new Promise<void>((resolve, reject) => { image.onload = () => resolve(); image.onerror = () => - reject(new Error(`Failed to load sticker: ${this.params.iconName}`)); + reject(new Error(`Failed to load sticker: ${this.params.stickerId}`)); image.src = url; }); } diff --git a/apps/web/src/services/renderer/nodes/text-node.ts b/apps/web/src/services/renderer/nodes/text-node.ts index 121ba834..b7e36d64 100644 --- a/apps/web/src/services/renderer/nodes/text-node.ts +++ b/apps/web/src/services/renderer/nodes/text-node.ts @@ -1,7 +1,10 @@ import type { CanvasRenderer } from "../canvas-renderer"; import { BaseNode } from "./base-node"; import type { TextElement } from "@/types/timeline"; -import { FONT_SIZE_SCALE_REFERENCE } from "@/constants/text-constants"; +import { + DEFAULT_LINE_HEIGHT, + FONT_SIZE_SCALE_REFERENCE, +} from "@/constants/text-constants"; function scaleFontSize({ fontSize, @@ -13,6 +16,107 @@ function scaleFontSize({ return fontSize * (canvasHeight / FONT_SIZE_SCALE_REFERENCE); } +function quoteFontFamily({ fontFamily }: { fontFamily: string }): string { + return `"${fontFamily.replace(/"/g, '\\"')}"`; +} + +function getMetricAscent({ + metrics, + fallback, +}: { + metrics: TextMetrics; + fallback: number; +}): number { + return metrics.actualBoundingBoxAscent ?? fallback * 0.8; +} + +function getMetricDescent({ + metrics, + fallback, +}: { + metrics: TextMetrics; + fallback: number; +}): number { + return metrics.actualBoundingBoxDescent ?? fallback * 0.2; +} + +interface TextBlockMeasurement { + visualCenterOffset: number; + height: number; + maxWidth: number; +} + +function measureTextBlock({ + lineMetrics, + lineHeightPx, + fallbackFontSize, +}: { + lineMetrics: TextMetrics[]; + lineHeightPx: number; + fallbackFontSize: number; +}): TextBlockMeasurement { + let top = Number.POSITIVE_INFINITY; + let bottom = Number.NEGATIVE_INFINITY; + let maxWidth = 0; + + for (let i = 0; i < lineMetrics.length; i++) { + const metrics = lineMetrics[i]; + const y = i * lineHeightPx; + top = Math.min( + top, + y - getMetricAscent({ metrics, fallback: fallbackFontSize }), + ); + bottom = Math.max( + bottom, + y + getMetricDescent({ metrics, fallback: fallbackFontSize }), + ); + maxWidth = Math.max(maxWidth, metrics.width); + } + + const height = bottom - top; + const visualCenterOffset = (top + bottom) / 2; + + return { visualCenterOffset, height, maxWidth }; +} + +function drawTextDecoration({ + ctx, + textDecoration, + lineWidth, + lineY, + metrics, + scaledFontSize, + textAlign, +}: { + ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D; + textDecoration: string; + lineWidth: number; + lineY: number; + metrics: TextMetrics; + scaledFontSize: number; + textAlign: CanvasTextAlign; +}): void { + if (textDecoration === "none" || !textDecoration) return; + + const thickness = Math.max(1, scaledFontSize * 0.07); + const ascent = getMetricAscent({ metrics, fallback: scaledFontSize }); + const descent = getMetricDescent({ metrics, fallback: scaledFontSize }); + + let xStart = -lineWidth / 2; + if (textAlign === "left") xStart = 0; + if (textAlign === "right") xStart = -lineWidth; + + if (textDecoration === "underline") { + const underlineY = lineY + descent + thickness; + ctx.fillRect(xStart, underlineY, lineWidth, thickness); + } + + if (textDecoration === "line-through") { + const strikeY = lineY - (ascent - descent) * 0.35; + ctx.fillRect(xStart, strikeY, lineWidth, thickness); + } +} + export type TextNodeParams = TextElement & { canvasCenter: { x: number; y: number }; canvasHeight: number; @@ -38,6 +142,10 @@ export class TextNode extends BaseNode<TextNodeParams> { const y = this.params.transform.position.y + this.params.canvasCenter.y; renderer.context.translate(x, y); + renderer.context.scale( + this.params.transform.scale, + this.params.transform.scale, + ); if (this.params.transform.rotate) { renderer.context.rotate((this.params.transform.rotate * Math.PI) / 180); } @@ -48,39 +156,72 @@ export class TextNode extends BaseNode<TextNodeParams> { fontSize: this.params.fontSize, canvasHeight: this.params.canvasHeight, }); - renderer.context.font = `${fontStyle} ${fontWeight} ${scaledFontSize}px ${this.params.fontFamily}`; + const fontFamily = quoteFontFamily({ fontFamily: this.params.fontFamily }); + renderer.context.font = `${fontStyle} ${fontWeight} ${scaledFontSize}px ${fontFamily}, sans-serif`; renderer.context.textAlign = this.params.textAlign; - renderer.context.textBaseline = this.params.textBaseline || "middle"; renderer.context.fillStyle = this.params.color; + const letterSpacing = this.params.letterSpacing ?? 0; + const lineHeight = this.params.lineHeight ?? DEFAULT_LINE_HEIGHT; + if ("letterSpacing" in renderer.context) { + ( + renderer.context as CanvasRenderingContext2D & { letterSpacing: string } + ).letterSpacing = `${letterSpacing}px`; + } + + const lines = this.params.content.split("\n"); + const lineHeightPx = scaledFontSize * lineHeight; + const baseline = this.params.textBaseline ?? "middle"; + + renderer.context.textBaseline = baseline; + const lineMetrics = lines.map((line) => renderer.context.measureText(line)); + const lineCount = lines.length; + + const block = measureTextBlock({ + lineMetrics, + lineHeightPx, + fallbackFontSize: scaledFontSize, + }); + const prevAlpha = renderer.context.globalAlpha; + renderer.context.globalCompositeOperation = ( + this.params.blendMode && this.params.blendMode !== "normal" + ? this.params.blendMode + : "source-over" + ) as GlobalCompositeOperation; renderer.context.globalAlpha = this.params.opacity; - if (this.params.backgroundColor) { - const metrics = renderer.context.measureText(this.params.content); - const ascent = metrics.actualBoundingBoxAscent ?? scaledFontSize * 0.8; - const descent = metrics.actualBoundingBoxDescent ?? scaledFontSize * 0.2; - const textW = metrics.width; - const textH = ascent + descent; + if (this.params.backgroundColor && lineCount > 0) { const padX = 8; const padY = 4; - renderer.context.fillStyle = this.params.backgroundColor; - let bgLeft = -textW / 2; + let bgLeft = -block.maxWidth / 2; if (renderer.context.textAlign === "left") bgLeft = 0; - if (renderer.context.textAlign === "right") bgLeft = -textW; + if (renderer.context.textAlign === "right") bgLeft = -block.maxWidth; renderer.context.fillRect( bgLeft - padX, - -textH / 2 - padY, - textW + padX * 2, - textH + padY * 2, + -block.height / 2 - padY, + block.maxWidth + padX * 2, + block.height + padY * 2, ); renderer.context.fillStyle = this.params.color; } - renderer.context.fillText(this.params.content, 0, 0); + for (let i = 0; i < lineCount; i++) { + const y = i * lineHeightPx - block.visualCenterOffset; + renderer.context.fillText(lines[i], 0, y); + drawTextDecoration({ + ctx: renderer.context, + textDecoration: this.params.textDecoration ?? "none", + lineWidth: lineMetrics[i].width, + lineY: y, + metrics: lineMetrics[i], + scaledFontSize, + textAlign: this.params.textAlign, + }); + } renderer.context.globalAlpha = prevAlpha; renderer.context.restore(); diff --git a/apps/web/src/services/renderer/nodes/visual-node.ts b/apps/web/src/services/renderer/nodes/visual-node.ts index 9c6c88ca..7179369e 100644 --- a/apps/web/src/services/renderer/nodes/visual-node.ts +++ b/apps/web/src/services/renderer/nodes/visual-node.ts @@ -1,5 +1,6 @@ import type { CanvasRenderer } from "../canvas-renderer"; import { BaseNode } from "./base-node"; +import type { BlendMode } from "@/types/rendering"; import type { Transform } from "@/types/timeline"; const VISUAL_EPSILON = 1 / 1000; @@ -11,6 +12,7 @@ export interface VisualNodeParams { trimEnd: number; transform: Transform; opacity: number; + blendMode?: BlendMode; } export abstract class VisualNode< @@ -51,6 +53,11 @@ export abstract class VisualNode< const x = renderer.width / 2 + transform.position.x - scaledWidth / 2; const y = renderer.height / 2 + transform.position.y - scaledHeight / 2; + renderer.context.globalCompositeOperation = ( + this.params.blendMode && this.params.blendMode !== "normal" + ? this.params.blendMode + : "source-over" + ) as GlobalCompositeOperation; renderer.context.globalAlpha = opacity; if (transform.rotate !== 0) { diff --git a/apps/web/src/services/renderer/scene-builder.ts b/apps/web/src/services/renderer/scene-builder.ts index d1b8217e..ee7f3d6c 100644 --- a/apps/web/src/services/renderer/scene-builder.ts +++ b/apps/web/src/services/renderer/scene-builder.ts @@ -66,6 +66,7 @@ export function buildScene(params: BuildSceneParams) { trimEnd: element.trimEnd, transform: element.transform, opacity: element.opacity, + blendMode: element.blendMode, }), ); } @@ -79,6 +80,7 @@ export function buildScene(params: BuildSceneParams) { trimEnd: element.trimEnd, transform: element.transform, opacity: element.opacity, + blendMode: element.blendMode, }), ); } @@ -98,14 +100,14 @@ export function buildScene(params: BuildSceneParams) { if (element.type === "sticker") { contentNodes.push( new StickerNode({ - iconName: element.iconName, + stickerId: element.stickerId, duration: element.duration, timeOffset: element.startTime, trimStart: element.trimStart, trimEnd: element.trimEnd, transform: element.transform, opacity: element.opacity, - color: element.color, + blendMode: element.blendMode, }), ); } diff --git a/apps/web/src/services/storage/migrations/__tests__/fixtures/index.ts b/apps/web/src/services/storage/migrations/__tests__/fixtures/index.ts index ee1485ba..5f82e14a 100644 --- a/apps/web/src/services/storage/migrations/__tests__/fixtures/index.ts +++ b/apps/web/src/services/storage/migrations/__tests__/fixtures/index.ts @@ -7,3 +7,4 @@ export * from "./v0"; export * from "./v1"; export * from "./v2"; export * from "./v3"; +export * from "./v5"; diff --git a/apps/web/src/services/storage/migrations/__tests__/fixtures/v5.ts b/apps/web/src/services/storage/migrations/__tests__/fixtures/v5.ts new file mode 100644 index 00000000..5e181c76 --- /dev/null +++ b/apps/web/src/services/storage/migrations/__tests__/fixtures/v5.ts @@ -0,0 +1,54 @@ +export const v5Project = { + id: "project-v5-456", + version: 5, + metadata: { + id: "project-v5-456", + name: "My V5 Project", + thumbnail: "data:image/png;base64,abc123", + duration: 30, + createdAt: "2024-06-01T10:00:00.000Z", + updatedAt: "2024-06-01T14:00:00.000Z", + }, + settings: { + fps: 30, + canvasSize: { width: 1920, height: 1080 }, + background: { type: "color", color: "#000000" }, + }, + currentSceneId: "scene-main", + scenes: [ + { + id: "scene-main", + name: "Main scene", + isMain: true, + tracks: [ + { + id: "track-1", + type: "video", + name: "Video Track", + isMain: true, + elements: [], + }, + ], + bookmarks: [2.0, 5.5, 12.0], + createdAt: "2024-06-01T10:00:00.000Z", + updatedAt: "2024-06-01T14:00:00.000Z", + }, + { + id: "scene-intro", + name: "Intro", + isMain: false, + tracks: [ + { + id: "track-2", + type: "video", + name: "Video Track", + isMain: true, + elements: [], + }, + ], + bookmarks: [], + createdAt: "2024-06-01T10:00:00.000Z", + updatedAt: "2024-06-01T14:00:00.000Z", + }, + ], +}; diff --git a/apps/web/src/services/storage/migrations/__tests__/v3-to-v4.test.ts b/apps/web/src/services/storage/migrations/__tests__/v3-to-v4.test.ts new file mode 100644 index 00000000..81d03147 --- /dev/null +++ b/apps/web/src/services/storage/migrations/__tests__/v3-to-v4.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, test } from "bun:test"; +import { transformProjectV3ToV4 } from "../transformers/v3-to-v4"; +import { v3Project } from "./fixtures"; + +describe("V3 to V4 Migration", () => { + test("normalizes legacy text fontWeight values", () => { + const projectWithLegacyTextWeight = { + ...v3Project, + scenes: [ + { + ...v3Project.scenes[0], + tracks: [ + { + id: "track-text", + type: "text", + name: "Text Track", + hidden: false, + elements: [ + { + id: "text-1", + type: "text", + name: "Title", + content: "Hello", + duration: 5, + startTime: 0, + trimStart: 0, + trimEnd: 0, + fontSize: 64, + fontFamily: "Inter", + color: "#ffffff", + backgroundColor: "transparent", + textAlign: "center", + fontWeight: "bold", + fontStyle: "normal", + textDecoration: "none", + transform: { + scale: 1, + position: { x: 0, y: 0 }, + rotate: 0, + }, + opacity: 1, + }, + ], + }, + ], + }, + ], + }; + + const result = transformProjectV3ToV4({ + project: projectWithLegacyTextWeight, + }); + + expect(result.skipped).toBe(false); + expect(result.project.version).toBe(4); + + const migratedScene = ( + result.project.scenes as Array<Record<string, unknown>> + )[0]; + const migratedTrack = ( + migratedScene.tracks as Array<Record<string, unknown>> + )[0]; + const migratedElement = ( + migratedTrack.elements as Array<Record<string, unknown>> + )[0]; + + expect(migratedElement.fontWeight).toBe("700"); + }); + + test("does not mutate non-text tracks", () => { + const projectWithoutTextTrack = { + ...v3Project, + scenes: [ + { + ...v3Project.scenes[0], + tracks: [ + { + id: "track-sticker", + type: "sticker", + name: "Sticker Track", + hidden: false, + elements: [ + { + id: "sticker-1", + type: "sticker", + name: "Flag", + iconName: "mdi:home", + duration: 5, + startTime: 0, + trimStart: 0, + trimEnd: 0, + transform: { + scale: 1, + position: { x: 0, y: 0 }, + rotate: 0, + }, + opacity: 1, + }, + ], + }, + ], + }, + ], + }; + + const result = transformProjectV3ToV4({ project: projectWithoutTextTrack }); + const migratedScene = ( + result.project.scenes as Array<Record<string, unknown>> + )[0]; + const migratedTrack = ( + migratedScene.tracks as Array<Record<string, unknown>> + )[0]; + const migratedElement = ( + migratedTrack.elements as Array<Record<string, unknown>> + )[0]; + + expect(migratedElement.iconName).toBe("mdi:home"); + }); + + test("skips projects that are already v4", () => { + const result = transformProjectV3ToV4({ + project: { ...v3Project, version: 4 }, + }); + + expect(result.skipped).toBe(true); + expect(result.reason).toBe("already v4"); + }); + + test("skips projects with no id", () => { + const result = transformProjectV3ToV4({ + project: { + version: 3, + scenes: [], + }, + }); + + expect(result.skipped).toBe(true); + expect(result.reason).toBe("no project id"); + }); +}); diff --git a/apps/web/src/services/storage/migrations/__tests__/v4-to-v5.test.ts b/apps/web/src/services/storage/migrations/__tests__/v4-to-v5.test.ts new file mode 100644 index 00000000..ce0c3019 --- /dev/null +++ b/apps/web/src/services/storage/migrations/__tests__/v4-to-v5.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, test } from "bun:test"; +import { transformProjectV4ToV5 } from "../transformers/v4-to-v5"; +import { v3Project } from "./fixtures"; + +describe("V4 to V5 Migration", () => { + test("migrates sticker iconName to stickerId and removes legacy color", () => { + const projectWithLegacySticker = { + ...v3Project, + version: 4, + scenes: [ + { + ...v3Project.scenes[0], + tracks: [ + { + id: "track-sticker", + type: "sticker", + name: "Sticker Track", + hidden: false, + elements: [ + { + id: "sticker-1", + type: "sticker", + name: "Home", + iconName: "mdi:home", + color: "#ff0000", + duration: 5, + startTime: 0, + trimStart: 0, + trimEnd: 0, + transform: { + scale: 1, + position: { x: 0, y: 0 }, + rotate: 0, + }, + opacity: 1, + }, + ], + }, + ], + }, + ], + }; + + const result = transformProjectV4ToV5({ + project: projectWithLegacySticker, + }); + + expect(result.skipped).toBe(false); + expect(result.project.version).toBe(5); + + const migratedScene = ( + result.project.scenes as Array<Record<string, unknown>> + )[0]; + const migratedTrack = ( + migratedScene.tracks as Array<Record<string, unknown>> + )[0]; + const migratedElement = ( + migratedTrack.elements as Array<Record<string, unknown>> + )[0]; + + expect(migratedElement.stickerId).toBe("icons:mdi:home"); + expect("iconName" in migratedElement).toBe(false); + expect("color" in migratedElement).toBe(false); + }); + + test("keeps provider-prefixed stickerId values unchanged", () => { + const projectWithStickerId = { + ...v3Project, + version: 4, + scenes: [ + { + ...v3Project.scenes[0], + tracks: [ + { + id: "track-sticker", + type: "sticker", + name: "Sticker Track", + hidden: false, + elements: [ + { + id: "sticker-1", + type: "sticker", + name: "Flag", + stickerId: "flags:AD", + duration: 5, + startTime: 0, + trimStart: 0, + trimEnd: 0, + transform: { + scale: 1, + position: { x: 0, y: 0 }, + rotate: 0, + }, + opacity: 1, + }, + ], + }, + ], + }, + ], + }; + + const result = transformProjectV4ToV5({ project: projectWithStickerId }); + const migratedScene = ( + result.project.scenes as Array<Record<string, unknown>> + )[0]; + const migratedTrack = ( + migratedScene.tracks as Array<Record<string, unknown>> + )[0]; + const migratedElement = ( + migratedTrack.elements as Array<Record<string, unknown>> + )[0]; + + expect(migratedElement.stickerId).toBe("flags:AD"); + }); + + test("skips projects that are already v5", () => { + const result = transformProjectV4ToV5({ + project: { ...v3Project, version: 5 }, + }); + + expect(result.skipped).toBe(true); + expect(result.reason).toBe("already v5"); + }); + + test("skips projects with no id", () => { + const result = transformProjectV4ToV5({ + project: { + version: 4, + scenes: [], + }, + }); + + expect(result.skipped).toBe(true); + expect(result.reason).toBe("no project id"); + }); +}); diff --git a/apps/web/src/services/storage/migrations/__tests__/v5-to-v6.test.ts b/apps/web/src/services/storage/migrations/__tests__/v5-to-v6.test.ts new file mode 100644 index 00000000..6bbd5634 --- /dev/null +++ b/apps/web/src/services/storage/migrations/__tests__/v5-to-v6.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, test } from "bun:test"; +import { transformProjectV5ToV6 } from "../transformers/v5-to-v6"; +import { v5Project } from "./fixtures"; + +describe("V5 to V6 Migration", () => { + test("converts number bookmarks to Bookmark objects", async () => { + const result = transformProjectV5ToV6({ + project: v5Project as Parameters< + typeof transformProjectV5ToV6 + >[0]["project"], + }); + + expect(result.skipped).toBe(false); + expect(result.project.version).toBe(6); + + const mainScene = ( + result.project.scenes as Array<{ bookmarks: unknown[] }> + )[0]; + expect(mainScene.bookmarks).toEqual([ + { time: 2.0 }, + { time: 5.5 }, + { time: 12.0 }, + ]); + + const introScene = ( + result.project.scenes as Array<{ bookmarks: unknown[] }> + )[1]; + expect(introScene.bookmarks).toEqual([]); + }); + + test("skips projects that are already v6", () => { + const result = transformProjectV5ToV6({ + project: { + ...v5Project, + version: 6, + scenes: [ + { + ...(v5Project as { scenes: unknown[] }).scenes[0], + bookmarks: [{ time: 2 }, { time: 5 }], + }, + ], + } as Parameters<typeof transformProjectV5ToV6>[0]["project"], + }); + + expect(result.skipped).toBe(true); + expect(result.reason).toBe("already v6"); + }); + + test("skips projects with no id", () => { + const result = transformProjectV5ToV6({ + project: { + version: 5, + scenes: [], + } as Parameters<typeof transformProjectV5ToV6>[0]["project"], + }); + + expect(result.skipped).toBe(true); + expect(result.reason).toBe("no project id"); + }); + + test("preserves existing Bookmark objects with note, color, duration", () => { + const projectWithRichBookmarks = { + ...v5Project, + version: 5, + scenes: [ + { + ...(v5Project as { scenes: Array<Record<string, unknown>> }) + .scenes[0], + bookmarks: [ + { time: 1, note: "Intro", color: "#ef4444" }, + { time: 5.5, duration: 2 }, + ], + }, + ], + }; + + const result = transformProjectV5ToV6({ + project: projectWithRichBookmarks as Parameters< + typeof transformProjectV5ToV6 + >[0]["project"], + }); + + expect(result.skipped).toBe(false); + const mainScene = ( + result.project.scenes as Array<{ bookmarks: unknown[] }> + )[0]; + expect(mainScene.bookmarks).toEqual([ + { time: 1, note: "Intro", color: "#ef4444" }, + { time: 5.5, duration: 2 }, + ]); + }); +}); diff --git a/apps/web/src/services/storage/migrations/index.ts b/apps/web/src/services/storage/migrations/index.ts index df0b8007..00c009c7 100644 --- a/apps/web/src/services/storage/migrations/index.ts +++ b/apps/web/src/services/storage/migrations/index.ts @@ -2,13 +2,19 @@ export { StorageMigration } from "./base"; import { V0toV1Migration } from "./v0-to-v1"; import { V1toV2Migration } from "./v1-to-v2"; import { V2toV3Migration } from "./v2-to-v3"; +import { V3toV4Migration } from "./v3-to-v4"; +import { V4toV5Migration } from "./v4-to-v5"; +import { V5toV6Migration } from "./v5-to-v6"; export { runStorageMigrations } from "./runner"; export type { MigrationProgress } from "./runner"; -export const CURRENT_PROJECT_VERSION = 3; +export const CURRENT_PROJECT_VERSION = 6; export const migrations = [ new V0toV1Migration(), new V1toV2Migration(), new V2toV3Migration(), + new V3toV4Migration(), + new V4toV5Migration(), + new V5toV6Migration(), ]; diff --git a/apps/web/src/services/storage/migrations/runner.ts b/apps/web/src/services/storage/migrations/runner.ts index b261252b..e1c2d09a 100644 --- a/apps/web/src/services/storage/migrations/runner.ts +++ b/apps/web/src/services/storage/migrations/runner.ts @@ -136,7 +136,11 @@ function getProjectVersion({ project }: { project: ProjectRecord }): number { return 0; } -function getProjectName({ project }: { project: ProjectRecord }): string | null { +function getProjectName({ + project, +}: { + project: ProjectRecord; +}): string | null { const metadata = project.metadata; if (isRecord(metadata) && typeof metadata.name === "string") { return metadata.name; diff --git a/apps/web/src/services/storage/migrations/transformers/index.ts b/apps/web/src/services/storage/migrations/transformers/index.ts index 74cf46a9..8878d6ba 100644 --- a/apps/web/src/services/storage/migrations/transformers/index.ts +++ b/apps/web/src/services/storage/migrations/transformers/index.ts @@ -1,4 +1,6 @@ export { transformProjectV0ToV1 } from "./v0-to-v1"; export { transformProjectV1ToV2 } from "./v1-to-v2"; export { transformProjectV2ToV3 } from "./v2-to-v3"; +export { transformProjectV3ToV4 } from "./v3-to-v4"; +export { transformProjectV4ToV5 } from "./v4-to-v5"; export type { MigrationResult, ProjectRecord } from "./types"; diff --git a/apps/web/src/services/storage/migrations/transformers/v3-to-v4.ts b/apps/web/src/services/storage/migrations/transformers/v3-to-v4.ts new file mode 100644 index 00000000..d6e1efee --- /dev/null +++ b/apps/web/src/services/storage/migrations/transformers/v3-to-v4.ts @@ -0,0 +1,189 @@ +import type { MigrationResult, ProjectRecord } from "./types"; +import { getProjectId, isRecord } from "./utils"; + +const LEGACY_FONT_WEIGHT_MAP = { + normal: "400", + bold: "700", +} as const; + +const VALID_NUMERIC_FONT_WEIGHTS = new Set([ + "100", + "200", + "300", + "400", + "500", + "600", + "700", + "800", + "900", +]); + +export function transformProjectV3ToV4({ + project, +}: { + project: ProjectRecord; +}): MigrationResult<ProjectRecord> { + const projectId = getProjectId({ project }); + if (!projectId) { + return { project, skipped: true, reason: "no project id" }; + } + + if (isV4Project({ project })) { + return { project, skipped: true, reason: "already v4" }; + } + + const migratedProject = normalizeProjectTextFontWeights({ project }); + + return { + project: { + ...migratedProject, + version: 4, + }, + skipped: false, + }; +} + +function normalizeProjectTextFontWeights({ + project, +}: { + project: ProjectRecord; +}): ProjectRecord { + const scenesValue = project.scenes; + if (!Array.isArray(scenesValue)) { + return project; + } + + let hasSceneChanges = false; + const normalizedScenes = scenesValue.map((scene) => { + const normalizedScene = normalizeSceneTextFontWeights({ scene }); + if (normalizedScene !== scene) { + hasSceneChanges = true; + } + return normalizedScene; + }); + + if (!hasSceneChanges) { + return project; + } + + return { + ...project, + scenes: normalizedScenes, + }; +} + +function normalizeSceneTextFontWeights({ scene }: { scene: unknown }): unknown { + if (!isRecord(scene)) { + return scene; + } + + const tracksValue = scene.tracks; + if (!Array.isArray(tracksValue)) { + return scene; + } + + let hasTrackChanges = false; + const normalizedTracks = tracksValue.map((track) => { + const normalizedTrack = normalizeTrackTextFontWeights({ track }); + if (normalizedTrack !== track) { + hasTrackChanges = true; + } + return normalizedTrack; + }); + + if (!hasTrackChanges) { + return scene; + } + + return { + ...scene, + tracks: normalizedTracks, + }; +} + +function normalizeTrackTextFontWeights({ track }: { track: unknown }): unknown { + if (!isRecord(track)) { + return track; + } + + if (track.type !== "text") { + return track; + } + + const elementsValue = track.elements; + if (!Array.isArray(elementsValue)) { + return track; + } + + let hasElementChanges = false; + const normalizedElements = elementsValue.map((element) => { + const normalizedElement = normalizeTextElementFontWeight({ element }); + if (normalizedElement !== element) { + hasElementChanges = true; + } + return normalizedElement; + }); + + if (!hasElementChanges) { + return track; + } + + return { + ...track, + elements: normalizedElements, + }; +} + +function normalizeTextElementFontWeight({ + element, +}: { + element: unknown; +}): unknown { + if (!isRecord(element) || element.type !== "text") { + return element; + } + + const normalizedWeight = normalizeFontWeight({ value: element.fontWeight }); + if (normalizedWeight === element.fontWeight) { + return element; + } + + return { + ...element, + fontWeight: normalizedWeight, + }; +} + +function normalizeFontWeight({ value }: { value: unknown }): unknown { + if (typeof value === "number") { + const numericWeight = String(value); + if (VALID_NUMERIC_FONT_WEIGHTS.has(numericWeight)) { + return numericWeight; + } + return value; + } + + if (typeof value !== "string") { + return value; + } + + const normalized = value.trim().toLowerCase(); + if (normalized in LEGACY_FONT_WEIGHT_MAP) { + return LEGACY_FONT_WEIGHT_MAP[ + normalized as keyof typeof LEGACY_FONT_WEIGHT_MAP + ]; + } + + if (VALID_NUMERIC_FONT_WEIGHTS.has(normalized)) { + return normalized; + } + + return value; +} + +export { getProjectId } from "./utils"; + +function isV4Project({ project }: { project: ProjectRecord }): boolean { + const versionValue = project.version; + return typeof versionValue === "number" && versionValue >= 4; +} diff --git a/apps/web/src/services/storage/migrations/transformers/v4-to-v5.ts b/apps/web/src/services/storage/migrations/transformers/v4-to-v5.ts new file mode 100644 index 00000000..971e3510 --- /dev/null +++ b/apps/web/src/services/storage/migrations/transformers/v4-to-v5.ts @@ -0,0 +1,177 @@ +import type { MigrationResult, ProjectRecord } from "./types"; +import { getProjectId, isRecord } from "./utils"; + +const KNOWN_STICKER_PROVIDER_IDS = new Set([ + "icons", + "emoji", + "flags", + "shapes", +]); + +export function transformProjectV4ToV5({ + project, +}: { + project: ProjectRecord; +}): MigrationResult<ProjectRecord> { + const projectId = getProjectId({ project }); + if (!projectId) { + return { project, skipped: true, reason: "no project id" }; + } + + if (isV5Project({ project })) { + return { project, skipped: true, reason: "already v5" }; + } + + const migratedProject = migrateProjectStickerElements({ project }); + + return { + project: { + ...migratedProject, + version: 5, + }, + skipped: false, + }; +} + +function migrateProjectStickerElements({ + project, +}: { + project: ProjectRecord; +}): ProjectRecord { + const scenesValue = project.scenes; + if (!Array.isArray(scenesValue)) { + return project; + } + + let hasSceneChanges = false; + const migratedScenes = scenesValue.map((scene) => { + const migratedScene = migrateSceneStickerElements({ scene }); + if (migratedScene !== scene) { + hasSceneChanges = true; + } + return migratedScene; + }); + + if (!hasSceneChanges) { + return project; + } + + return { + ...project, + scenes: migratedScenes, + }; +} + +function migrateSceneStickerElements({ scene }: { scene: unknown }): unknown { + if (!isRecord(scene)) { + return scene; + } + + const tracksValue = scene.tracks; + if (!Array.isArray(tracksValue)) { + return scene; + } + + let hasTrackChanges = false; + const migratedTracks = tracksValue.map((track) => { + const migratedTrack = migrateTrackStickerElements({ track }); + if (migratedTrack !== track) { + hasTrackChanges = true; + } + return migratedTrack; + }); + + if (!hasTrackChanges) { + return scene; + } + + return { + ...scene, + tracks: migratedTracks, + }; +} + +function migrateTrackStickerElements({ track }: { track: unknown }): unknown { + if (!isRecord(track)) { + return track; + } + + const elementsValue = track.elements; + if (!Array.isArray(elementsValue)) { + return track; + } + + let hasElementChanges = false; + const migratedElements = elementsValue.map((element) => { + const migratedElement = migrateStickerElement({ element }); + if (migratedElement !== element) { + hasElementChanges = true; + } + return migratedElement; + }); + + if (!hasElementChanges) { + return track; + } + + return { + ...track, + elements: migratedElements, + }; +} + +function migrateStickerElement({ element }: { element: unknown }): unknown { + if (!isRecord(element) || element.type !== "sticker") { + return element; + } + + const existingStickerId = + typeof element.stickerId === "string" ? element.stickerId : null; + const legacyIconName = + typeof element.iconName === "string" ? element.iconName : null; + + const normalizedStickerId = + normalizeStickerId({ + value: existingStickerId ?? legacyIconName, + }) ?? null; + + const hasLegacyIconName = "iconName" in element; + const hasLegacyColor = "color" in element; + const shouldUpdateStickerId = normalizedStickerId !== existingStickerId; + + if (!hasLegacyIconName && !hasLegacyColor && !shouldUpdateStickerId) { + return element; + } + + const { + iconName: _legacyIconName, + color: _legacyColor, + ...remaining + } = element; + return normalizedStickerId + ? { ...remaining, stickerId: normalizedStickerId } + : remaining; +} + +function normalizeStickerId({ value }: { value: unknown }): string | null { + if (typeof value !== "string" || value.length === 0) { + return null; + } + + const separatorIndex = value.indexOf(":"); + if (separatorIndex === -1) { + return `icons:${value}`; + } + + const maybeProvider = value.slice(0, separatorIndex); + if (KNOWN_STICKER_PROVIDER_IDS.has(maybeProvider)) { + return value; + } + + return `icons:${value}`; +} + +function isV5Project({ project }: { project: ProjectRecord }): boolean { + const versionValue = project.version; + return typeof versionValue === "number" && versionValue >= 5; +} diff --git a/apps/web/src/services/storage/migrations/transformers/v5-to-v6.ts b/apps/web/src/services/storage/migrations/transformers/v5-to-v6.ts new file mode 100644 index 00000000..ca855149 --- /dev/null +++ b/apps/web/src/services/storage/migrations/transformers/v5-to-v6.ts @@ -0,0 +1,88 @@ +import type { MigrationResult, ProjectRecord } from "./types"; +import { getProjectId, isRecord } from "./utils"; + +export function transformProjectV5ToV6({ + project, +}: { + project: ProjectRecord; +}): MigrationResult<ProjectRecord> { + const projectId = getProjectId({ project }); + if (!projectId) { + return { project, skipped: true, reason: "no project id" }; + } + + if (isV6Project({ project })) { + return { project, skipped: true, reason: "already v6" }; + } + + const migratedProject = migrateProjectBookmarks({ project }); + + return { + project: { + ...migratedProject, + version: 6, + }, + skipped: false, + }; +} + +function migrateProjectBookmarks({ + project, +}: { + project: ProjectRecord; +}): ProjectRecord { + const scenesValue = project.scenes; + if (!Array.isArray(scenesValue)) { + return project; + } + + let hasSceneChanges = false; + const migratedScenes = scenesValue.map((scene) => { + const migratedScene = migrateSceneBookmarks({ scene }); + if (migratedScene !== scene) { + hasSceneChanges = true; + } + return migratedScene; + }); + + if (!hasSceneChanges) { + return project; + } + + return { + ...project, + scenes: migratedScenes, + }; +} + +function migrateSceneBookmarks({ scene }: { scene: unknown }): unknown { + if (!isRecord(scene)) { + return scene; + } + + const bookmarksValue = scene.bookmarks; + if (!Array.isArray(bookmarksValue)) { + return scene; + } + + const needsMigration = bookmarksValue.some( + (bookmark) => typeof bookmark === "number", + ); + if (!needsMigration) { + return scene; + } + + const migratedBookmarks = bookmarksValue.map((bookmark) => + typeof bookmark === "number" ? { time: bookmark } : bookmark, + ); + + return { + ...scene, + bookmarks: migratedBookmarks, + }; +} + +function isV6Project({ project }: { project: ProjectRecord }): boolean { + const versionValue = project.version; + return typeof versionValue === "number" && versionValue >= 6; +} diff --git a/apps/web/src/services/storage/migrations/v3-to-v4.ts b/apps/web/src/services/storage/migrations/v3-to-v4.ts new file mode 100644 index 00000000..67b2fdf7 --- /dev/null +++ b/apps/web/src/services/storage/migrations/v3-to-v4.ts @@ -0,0 +1,16 @@ +import { StorageMigration } from "./base"; +import type { ProjectRecord } from "./transformers/types"; +import { transformProjectV3ToV4 } from "./transformers/v3-to-v4"; + +export class V3toV4Migration extends StorageMigration { + from = 3; + to = 4; + + async transform(project: ProjectRecord): Promise<{ + project: ProjectRecord; + skipped: boolean; + reason?: string; + }> { + return transformProjectV3ToV4({ project }); + } +} diff --git a/apps/web/src/services/storage/migrations/v4-to-v5.ts b/apps/web/src/services/storage/migrations/v4-to-v5.ts new file mode 100644 index 00000000..829a746d --- /dev/null +++ b/apps/web/src/services/storage/migrations/v4-to-v5.ts @@ -0,0 +1,16 @@ +import { StorageMigration } from "./base"; +import type { ProjectRecord } from "./transformers/types"; +import { transformProjectV4ToV5 } from "./transformers/v4-to-v5"; + +export class V4toV5Migration extends StorageMigration { + from = 4; + to = 5; + + async transform(project: ProjectRecord): Promise<{ + project: ProjectRecord; + skipped: boolean; + reason?: string; + }> { + return transformProjectV4ToV5({ project }); + } +} diff --git a/apps/web/src/services/storage/migrations/v5-to-v6.ts b/apps/web/src/services/storage/migrations/v5-to-v6.ts new file mode 100644 index 00000000..b621bd72 --- /dev/null +++ b/apps/web/src/services/storage/migrations/v5-to-v6.ts @@ -0,0 +1,16 @@ +import { StorageMigration } from "./base"; +import type { ProjectRecord } from "./transformers/types"; +import { transformProjectV5ToV6 } from "./transformers/v5-to-v6"; + +export class V5toV6Migration extends StorageMigration { + from = 5; + to = 6; + + async transform(project: ProjectRecord): Promise<{ + project: ProjectRecord; + skipped: boolean; + reason?: string; + }> { + return transformProjectV5ToV6({ project }); + } +} diff --git a/apps/web/src/services/storage/service.ts b/apps/web/src/services/storage/service.ts index 02f8d290..aac152da 100644 --- a/apps/web/src/services/storage/service.ts +++ b/apps/web/src/services/storage/service.ts @@ -14,7 +14,30 @@ import { migrations, runStorageMigrations, } from "@/services/storage/migrations"; -import type { TimelineTrack, TScene } from "@/types/timeline"; +import type { Bookmark, TimelineTrack, TScene } from "@/types/timeline"; + +function normalizeBookmarks({ raw }: { raw: unknown }): Bookmark[] { + if (!Array.isArray(raw)) return []; + return raw + .map((item): Bookmark | null => { + if (typeof item === "number") return { time: item }; + const obj = item as Record<string, unknown>; + if ( + typeof obj !== "object" || + obj === null || + typeof obj.time !== "number" + ) { + return null; + } + return { + time: obj.time, + ...(typeof obj.note === "string" && { note: obj.note }), + ...(typeof obj.color === "string" && { color: obj.color }), + ...(typeof obj.duration === "number" && { duration: obj.duration }), + }; + }) + .filter((b): b is Bookmark => b !== null); +} class StorageService { private projectsAdapter: IndexedDBAdapter<SerializedProject>; @@ -137,7 +160,7 @@ class StorageService { ? { ...track, isMain: track.isMain ?? false } // legacy: isMain was optional : track, ), - bookmarks: scene.bookmarks ?? [], + bookmarks: normalizeBookmarks({ raw: scene.bookmarks }), createdAt: new Date(scene.createdAt), updatedAt: new Date(scene.updatedAt), })) ?? []; diff --git a/apps/web/src/stores/editor-store.ts b/apps/web/src/stores/editor-store.ts index 5c4856d3..ad351bf9 100644 --- a/apps/web/src/stores/editor-store.ts +++ b/apps/web/src/stores/editor-store.ts @@ -1,70 +1,24 @@ import { create } from "zustand"; -import { persist } from "zustand/middleware"; -import type { TPlatformLayout } from "@/types/editor"; import { DEFAULT_CANVAS_PRESETS } from "@/constants/project-constants"; import type { TCanvasSize } from "@/types/project"; -interface LayoutGuideSettings { - platform: TPlatformLayout | null; -} - interface EditorState { isInitializing: boolean; isPanelsReady: boolean; canvasPresets: TCanvasSize[]; - layoutGuide: LayoutGuideSettings; setInitializing: (loading: boolean) => void; setPanelsReady: (ready: boolean) => void; initializeApp: () => Promise<void>; - setLayoutGuide: (settings: Partial<LayoutGuideSettings>) => void; - toggleLayoutGuide: (platform: TPlatformLayout) => void; } -export const useEditorStore = create<EditorState>()( - persist( - (set) => ({ - isInitializing: true, - isPanelsReady: false, - canvasPresets: DEFAULT_CANVAS_PRESETS, - layoutGuide: { - platform: null, - }, - setInitializing: (loading) => { - set({ isInitializing: loading }); - }, - - setPanelsReady: (ready) => { - set({ isPanelsReady: ready }); - }, - - initializeApp: async () => { - set({ isInitializing: true, isPanelsReady: false }); - - set({ isPanelsReady: true, isInitializing: false }); - }, - - setLayoutGuide: (settings) => { - set((state) => ({ - layoutGuide: { - ...state.layoutGuide, - ...settings, - }, - })); - }, - - toggleLayoutGuide: (platform) => { - set((state) => ({ - layoutGuide: { - platform: state.layoutGuide.platform === platform ? null : platform, - }, - })); - }, - }), - { - name: "editor-settings", - partialize: (state) => ({ - layoutGuide: state.layoutGuide, - }), - }, - ), -); +export const useEditorStore = create<EditorState>()((set) => ({ + isInitializing: true, + isPanelsReady: false, + canvasPresets: DEFAULT_CANVAS_PRESETS, + setInitializing: (loading) => set({ isInitializing: loading }), + setPanelsReady: (ready) => set({ isPanelsReady: ready }), + initializeApp: async () => { + set({ isInitializing: true, isPanelsReady: false }); + set({ isPanelsReady: true, isInitializing: false }); + }, +})); diff --git a/apps/web/src/stores/keybindings-store.ts b/apps/web/src/stores/keybindings-store.ts index 5ff48ffc..fbbf169e 100644 --- a/apps/web/src/stores/keybindings-store.ts +++ b/apps/web/src/stores/keybindings-store.ts @@ -7,10 +7,7 @@ import { getDefaultShortcuts } from "@/lib/actions"; import { isTypableDOMElement } from "@/utils/browser"; import { isAppleDevice } from "@/utils/platform"; import type { KeybindingConfig, ShortcutKey } from "@/types/keybinding"; -import { - runMigrations, - CURRENT_VERSION, -} from "./keybindings/migrations"; +import { runMigrations, CURRENT_VERSION } from "./keybindings/migrations"; export const defaultKeybindings: KeybindingConfig = getDefaultShortcuts(); diff --git a/apps/web/src/stores/keybindings/migrations/index.ts b/apps/web/src/stores/keybindings/migrations/index.ts index 1689802e..64d86ffa 100644 --- a/apps/web/src/stores/keybindings/migrations/index.ts +++ b/apps/web/src/stores/keybindings/migrations/index.ts @@ -1,4 +1,5 @@ -import { v2ToV3 } from './v2-to-v3'; +import { v2ToV3 } from "./v2-to-v3"; +import { v3ToV4 } from "./v3-to-v4"; type MigrationFn = ({ state }: { state: unknown }) => unknown; @@ -8,9 +9,10 @@ type MigrationFn = ({ state }: { state: unknown }) => unknown; */ const migrations: Record<number, MigrationFn> = { 2: v2ToV3, + 3: v3ToV4, }; -export const CURRENT_VERSION = 3; +export const CURRENT_VERSION = 4; export function runMigrations({ state, diff --git a/apps/web/src/stores/keybindings/migrations/v3-to-v4.ts b/apps/web/src/stores/keybindings/migrations/v3-to-v4.ts new file mode 100644 index 00000000..b53e082f --- /dev/null +++ b/apps/web/src/stores/keybindings/migrations/v3-to-v4.ts @@ -0,0 +1,25 @@ +import type { TActionWithOptionalArgs } from "@/lib/actions"; +import type { ShortcutKey } from "@/types/keybinding"; +import type { KeybindingConfig } from "@/types/keybinding"; + +interface V3State { + keybindings: KeybindingConfig; + isCustomized: boolean; +} + +export function v3ToV4({ state }: { state: unknown }): unknown { + const v3 = state as V3State; + + const renames: Record<string, string> = { + "paste-selected": "paste-copied", + }; + + const migrated = { ...v3.keybindings }; + for (const [key, action] of Object.entries(migrated)) { + if (action && renames[action]) { + migrated[key as ShortcutKey] = renames[action] as TActionWithOptionalArgs; + } + } + + return { ...v3, keybindings: migrated }; +} diff --git a/apps/web/src/stores/preview-store.ts b/apps/web/src/stores/preview-store.ts new file mode 100644 index 00000000..03b28fe2 --- /dev/null +++ b/apps/web/src/stores/preview-store.ts @@ -0,0 +1,94 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; +import type { TPlatformLayout } from "@/types/editor"; + +interface LayoutGuideSettings { + platform: TPlatformLayout | null; +} + +interface PreviewOverlaysState { + bookmarks: boolean; +} + +interface PreviewState { + layoutGuide: LayoutGuideSettings; + overlays: PreviewOverlaysState; + setLayoutGuide: (settings: Partial<LayoutGuideSettings>) => void; + toggleLayoutGuide: (platform: TPlatformLayout) => void; + setOverlayVisibility: ({ + overlay, + isVisible, + }: { + overlay: keyof PreviewOverlaysState; + isVisible: boolean; + }) => void; + toggleOverlayVisibility: ({ + overlay, + }: { + overlay: keyof PreviewOverlaysState; + }) => void; +} + +const DEFAULT_PREVIEW_OVERLAYS: PreviewOverlaysState = { + bookmarks: true, +}; + +export const usePreviewStore = create<PreviewState>()( + persist( + (set) => ({ + layoutGuide: { platform: null }, + overlays: DEFAULT_PREVIEW_OVERLAYS, + setLayoutGuide: (settings) => { + set((state) => ({ + layoutGuide: { + ...state.layoutGuide, + ...settings, + }, + })); + }, + toggleLayoutGuide: (platform) => { + set((state) => ({ + layoutGuide: { + platform: state.layoutGuide.platform === platform ? null : platform, + }, + })); + }, + setOverlayVisibility: ({ overlay, isVisible }) => { + set((state) => ({ + overlays: { + ...state.overlays, + [overlay]: isVisible, + }, + })); + }, + toggleOverlayVisibility: ({ overlay }) => { + set((state) => ({ + overlays: { + ...state.overlays, + [overlay]: !state.overlays[overlay], + }, + })); + }, + }), + { + name: "preview-settings", + version: 2, + migrate: (persistedState) => { + const state = persistedState as + | { + layoutGuide?: LayoutGuideSettings; + overlays?: PreviewOverlaysState; + } + | undefined; + return { + layoutGuide: state?.layoutGuide ?? { platform: null }, + overlays: state?.overlays ?? DEFAULT_PREVIEW_OVERLAYS, + }; + }, + partialize: (state) => ({ + layoutGuide: state.layoutGuide, + overlays: state.overlays, + }), + }, + ), +); diff --git a/apps/web/src/stores/sounds-store.ts b/apps/web/src/stores/sounds-store.ts index f393416a..8edbcc97 100644 --- a/apps/web/src/stores/sounds-store.ts +++ b/apps/web/src/stores/sounds-store.ts @@ -1,261 +1,261 @@ -import { create } from "zustand";
-import type { SoundEffect, SavedSound } from "@/types/sounds";
-import { storageService } from "@/services/storage/service";
-import { toast } from "sonner";
-import { EditorCore } from "@/core";
-import { buildLibraryAudioElement } from "@/lib/timeline/element-utils";
-
-interface SoundsStore {
- topSoundEffects: SoundEffect[];
- isLoading: boolean;
- error: string | null;
- hasLoaded: boolean;
- showCommercialOnly: boolean;
- toggleCommercialFilter: () => void;
- searchQuery: string;
- searchResults: SoundEffect[];
- isSearching: boolean;
- searchError: string | null;
- lastSearchQuery: string;
- scrollPosition: number;
- currentPage: number;
- hasNextPage: boolean;
- totalCount: number;
- isLoadingMore: boolean;
- savedSounds: SavedSound[];
- isSavedSoundsLoaded: boolean;
- isLoadingSavedSounds: boolean;
- savedSoundsError: string | null;
-
- addSoundToTimeline: ({ sound }: { sound: SoundEffect }) => Promise<boolean>;
- setTopSoundEffects: ({ sounds }: { sounds: SoundEffect[] }) => void;
- setLoading: ({ loading }: { loading: boolean }) => void;
- setError: ({ error }: { error: string | null }) => void;
- setHasLoaded: ({ loaded }: { loaded: boolean }) => void;
- setSearchQuery: ({ query }: { query: string }) => void;
- setSearchResults: ({ results }: { results: SoundEffect[] }) => void;
- setSearching: ({ searching }: { searching: boolean }) => void;
- setSearchError: ({ error }: { error: string | null }) => void;
- setLastSearchQuery: ({ query }: { query: string }) => void;
- setScrollPosition: ({ position }: { position: number }) => void;
- setCurrentPage: ({ page }: { page: number }) => void;
- setHasNextPage: ({ hasNext }: { hasNext: boolean }) => void;
- setTotalCount: ({ count }: { count: number }) => void;
- setLoadingMore: ({ loading }: { loading: boolean }) => void;
- appendSearchResults: ({ results }: { results: SoundEffect[] }) => void;
- appendTopSounds: ({ results }: { results: SoundEffect[] }) => void;
- resetPagination: () => void;
- loadSavedSounds: () => Promise<void>;
- saveSoundEffect: ({
- soundEffect,
- }: {
- soundEffect: SoundEffect;
- }) => Promise<void>;
- removeSavedSound: ({ soundId }: { soundId: number }) => Promise<void>;
- isSoundSaved: ({ soundId }: { soundId: number }) => boolean;
- toggleSavedSound: ({
- soundEffect,
- }: {
- soundEffect: SoundEffect;
- }) => Promise<void>;
- clearSavedSounds: () => Promise<void>;
-}
-
-export const useSoundsStore = create<SoundsStore>((set, get) => ({
- topSoundEffects: [],
- isLoading: false,
- error: null,
- hasLoaded: false,
- showCommercialOnly: true,
-
- toggleCommercialFilter: () => {
- set((state) => ({ showCommercialOnly: !state.showCommercialOnly }));
- },
-
- searchQuery: "",
- searchResults: [],
- isSearching: false,
- searchError: null,
- lastSearchQuery: "",
- scrollPosition: 0,
- currentPage: 1,
- hasNextPage: false,
- totalCount: 0,
- isLoadingMore: false,
- savedSounds: [],
- isSavedSoundsLoaded: false,
- isLoadingSavedSounds: false,
- savedSoundsError: null,
-
- setTopSoundEffects: ({ sounds }) => set({ topSoundEffects: sounds }),
- setLoading: ({ loading }) => set({ isLoading: loading }),
- setError: ({ error }) => set({ error }),
- setHasLoaded: ({ loaded }) => set({ hasLoaded: loaded }),
- setSearchQuery: ({ query }) => set({ searchQuery: query }),
- setSearchResults: ({ results }) =>
- set({ searchResults: results, currentPage: 1 }),
- setSearching: ({ searching }) => set({ isSearching: searching }),
- setSearchError: ({ error }) => set({ searchError: error }),
- setLastSearchQuery: ({ query }) => set({ lastSearchQuery: query }),
- setScrollPosition: ({ position }) => set({ scrollPosition: position }),
- setCurrentPage: ({ page }) => set({ currentPage: page }),
- setHasNextPage: ({ hasNext }) => set({ hasNextPage: hasNext }),
- setTotalCount: ({ count }) => set({ totalCount: count }),
- setLoadingMore: ({ loading }) => set({ isLoadingMore: loading }),
-
- appendSearchResults: ({ results }) =>
- set((state) => ({
- searchResults: [...state.searchResults, ...results],
- })),
-
- appendTopSounds: ({ results }) =>
- set((state) => ({
- topSoundEffects: [...state.topSoundEffects, ...results],
- })),
-
- resetPagination: () =>
- set({
- currentPage: 1,
- hasNextPage: false,
- totalCount: 0,
- isLoadingMore: false,
- }),
-
- loadSavedSounds: async () => {
- if (get().isSavedSoundsLoaded) return;
-
- try {
- set({ isLoadingSavedSounds: true, savedSoundsError: null });
- const savedSoundsData = await storageService.loadSavedSounds();
- set({
- savedSounds: savedSoundsData.sounds,
- isSavedSoundsLoaded: true,
- isLoadingSavedSounds: false,
- });
- } catch (error) {
- const errorMessage =
- error instanceof Error ? error.message : "Failed to load saved sounds";
- set({
- savedSoundsError: errorMessage,
- isLoadingSavedSounds: false,
- });
- console.error("Failed to load saved sounds:", error);
- }
- },
-
- saveSoundEffect: async ({ soundEffect }) => {
- try {
- await storageService.saveSoundEffect({ soundEffect });
-
- const savedSoundsData = await storageService.loadSavedSounds();
- set({ savedSounds: savedSoundsData.sounds });
- } catch (error) {
- const errorMessage =
- error instanceof Error ? error.message : "Failed to save sound";
- set({ savedSoundsError: errorMessage });
- toast.error("Failed to save sound");
- console.error("Failed to save sound:", error);
- }
- },
-
- removeSavedSound: async ({ soundId }) => {
- try {
- await storageService.removeSavedSound({ soundId });
-
- set((state) => ({
- savedSounds: state.savedSounds.filter((sound) => sound.id !== soundId),
- }));
- } catch (error) {
- const errorMessage =
- error instanceof Error ? error.message : "Failed to remove sound";
- set({ savedSoundsError: errorMessage });
- toast.error("Failed to remove sound");
- console.error("Failed to remove sound:", error);
- }
- },
-
- isSoundSaved: ({ soundId }) => {
- const { savedSounds } = get();
- return savedSounds.some((sound) => sound.id === soundId);
- },
-
- toggleSavedSound: async ({ soundEffect }) => {
- const { isSoundSaved, saveSoundEffect, removeSavedSound } = get();
-
- if (isSoundSaved({ soundId: soundEffect.id })) {
- await removeSavedSound({ soundId: soundEffect.id });
- } else {
- await saveSoundEffect({ soundEffect });
- }
- },
-
- clearSavedSounds: async () => {
- try {
- await storageService.clearSavedSounds();
- set({
- savedSounds: [],
- savedSoundsError: null,
- });
- } catch (error) {
- const errorMessage =
- error instanceof Error ? error.message : "Failed to clear saved sounds";
- set({ savedSoundsError: errorMessage });
- toast.error("Failed to clear saved sounds");
- console.error("Failed to clear saved sounds:", error);
- }
- },
-
- addSoundToTimeline: async ({ sound }) => {
- const audioUrl = sound.previewUrl;
- if (!audioUrl) {
- toast.error("Sound file not available");
- return false;
- }
-
- try {
- const editor = EditorCore.getInstance();
- const currentTime = editor.playback.getCurrentTime();
- const tracks = editor.timeline.getTracks();
-
- const response = await fetch(audioUrl);
- if (!response.ok)
- throw new Error(`Failed to download audio: ${response.statusText}`);
-
- const arrayBuffer = await response.arrayBuffer();
- const audioContext = new AudioContext();
- const buffer = await audioContext.decodeAudioData(arrayBuffer);
-
- const audioTrack = tracks.find((t) => t.type === "audio");
- let trackId: string;
-
- if (audioTrack) {
- trackId = audioTrack.id;
- } else {
- trackId = editor.timeline.addTrack({ type: "audio" });
- }
-
- const element = buildLibraryAudioElement({
- sourceUrl: audioUrl,
- name: sound.name,
- duration: sound.duration,
- startTime: currentTime,
- buffer,
- });
-
- editor.timeline.insertElement({
- placement: { mode: "explicit", trackId },
- element,
- });
- return true;
- } catch (error) {
- console.error("Failed to add sound to timeline:", error);
- toast.error(
- error instanceof Error
- ? error.message
- : "Failed to add sound to timeline",
- { id: `sound-${sound.id}` },
- );
- return false;
- }
- },
-}));
+import { create } from "zustand"; +import type { SoundEffect, SavedSound } from "@/types/sounds"; +import { storageService } from "@/services/storage/service"; +import { toast } from "sonner"; +import { EditorCore } from "@/core"; +import { buildLibraryAudioElement } from "@/lib/timeline/element-utils"; + +interface SoundsStore { + topSoundEffects: SoundEffect[]; + isLoading: boolean; + error: string | null; + hasLoaded: boolean; + showCommercialOnly: boolean; + toggleCommercialFilter: () => void; + searchQuery: string; + searchResults: SoundEffect[]; + isSearching: boolean; + searchError: string | null; + lastSearchQuery: string; + scrollPosition: number; + currentPage: number; + hasNextPage: boolean; + totalCount: number; + isLoadingMore: boolean; + savedSounds: SavedSound[]; + isSavedSoundsLoaded: boolean; + isLoadingSavedSounds: boolean; + savedSoundsError: string | null; + + addSoundToTimeline: ({ sound }: { sound: SoundEffect }) => Promise<boolean>; + setTopSoundEffects: ({ sounds }: { sounds: SoundEffect[] }) => void; + setLoading: ({ loading }: { loading: boolean }) => void; + setError: ({ error }: { error: string | null }) => void; + setHasLoaded: ({ loaded }: { loaded: boolean }) => void; + setSearchQuery: ({ query }: { query: string }) => void; + setSearchResults: ({ results }: { results: SoundEffect[] }) => void; + setSearching: ({ searching }: { searching: boolean }) => void; + setSearchError: ({ error }: { error: string | null }) => void; + setLastSearchQuery: ({ query }: { query: string }) => void; + setScrollPosition: ({ position }: { position: number }) => void; + setCurrentPage: ({ page }: { page: number }) => void; + setHasNextPage: ({ hasNext }: { hasNext: boolean }) => void; + setTotalCount: ({ count }: { count: number }) => void; + setLoadingMore: ({ loading }: { loading: boolean }) => void; + appendSearchResults: ({ results }: { results: SoundEffect[] }) => void; + appendTopSounds: ({ results }: { results: SoundEffect[] }) => void; + resetPagination: () => void; + loadSavedSounds: () => Promise<void>; + saveSoundEffect: ({ + soundEffect, + }: { + soundEffect: SoundEffect; + }) => Promise<void>; + removeSavedSound: ({ soundId }: { soundId: number }) => Promise<void>; + isSoundSaved: ({ soundId }: { soundId: number }) => boolean; + toggleSavedSound: ({ + soundEffect, + }: { + soundEffect: SoundEffect; + }) => Promise<void>; + clearSavedSounds: () => Promise<void>; +} + +export const useSoundsStore = create<SoundsStore>((set, get) => ({ + topSoundEffects: [], + isLoading: false, + error: null, + hasLoaded: false, + showCommercialOnly: true, + + toggleCommercialFilter: () => { + set((state) => ({ showCommercialOnly: !state.showCommercialOnly })); + }, + + searchQuery: "", + searchResults: [], + isSearching: false, + searchError: null, + lastSearchQuery: "", + scrollPosition: 0, + currentPage: 1, + hasNextPage: false, + totalCount: 0, + isLoadingMore: false, + savedSounds: [], + isSavedSoundsLoaded: false, + isLoadingSavedSounds: false, + savedSoundsError: null, + + setTopSoundEffects: ({ sounds }) => set({ topSoundEffects: sounds }), + setLoading: ({ loading }) => set({ isLoading: loading }), + setError: ({ error }) => set({ error }), + setHasLoaded: ({ loaded }) => set({ hasLoaded: loaded }), + setSearchQuery: ({ query }) => set({ searchQuery: query }), + setSearchResults: ({ results }) => + set({ searchResults: results, currentPage: 1 }), + setSearching: ({ searching }) => set({ isSearching: searching }), + setSearchError: ({ error }) => set({ searchError: error }), + setLastSearchQuery: ({ query }) => set({ lastSearchQuery: query }), + setScrollPosition: ({ position }) => set({ scrollPosition: position }), + setCurrentPage: ({ page }) => set({ currentPage: page }), + setHasNextPage: ({ hasNext }) => set({ hasNextPage: hasNext }), + setTotalCount: ({ count }) => set({ totalCount: count }), + setLoadingMore: ({ loading }) => set({ isLoadingMore: loading }), + + appendSearchResults: ({ results }) => + set((state) => ({ + searchResults: [...state.searchResults, ...results], + })), + + appendTopSounds: ({ results }) => + set((state) => ({ + topSoundEffects: [...state.topSoundEffects, ...results], + })), + + resetPagination: () => + set({ + currentPage: 1, + hasNextPage: false, + totalCount: 0, + isLoadingMore: false, + }), + + loadSavedSounds: async () => { + if (get().isSavedSoundsLoaded) return; + + try { + set({ isLoadingSavedSounds: true, savedSoundsError: null }); + const savedSoundsData = await storageService.loadSavedSounds(); + set({ + savedSounds: savedSoundsData.sounds, + isSavedSoundsLoaded: true, + isLoadingSavedSounds: false, + }); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Failed to load saved sounds"; + set({ + savedSoundsError: errorMessage, + isLoadingSavedSounds: false, + }); + console.error("Failed to load saved sounds:", error); + } + }, + + saveSoundEffect: async ({ soundEffect }) => { + try { + await storageService.saveSoundEffect({ soundEffect }); + + const savedSoundsData = await storageService.loadSavedSounds(); + set({ savedSounds: savedSoundsData.sounds }); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Failed to save sound"; + set({ savedSoundsError: errorMessage }); + toast.error("Failed to save sound"); + console.error("Failed to save sound:", error); + } + }, + + removeSavedSound: async ({ soundId }) => { + try { + await storageService.removeSavedSound({ soundId }); + + set((state) => ({ + savedSounds: state.savedSounds.filter((sound) => sound.id !== soundId), + })); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Failed to remove sound"; + set({ savedSoundsError: errorMessage }); + toast.error("Failed to remove sound"); + console.error("Failed to remove sound:", error); + } + }, + + isSoundSaved: ({ soundId }) => { + const { savedSounds } = get(); + return savedSounds.some((sound) => sound.id === soundId); + }, + + toggleSavedSound: async ({ soundEffect }) => { + const { isSoundSaved, saveSoundEffect, removeSavedSound } = get(); + + if (isSoundSaved({ soundId: soundEffect.id })) { + await removeSavedSound({ soundId: soundEffect.id }); + } else { + await saveSoundEffect({ soundEffect }); + } + }, + + clearSavedSounds: async () => { + try { + await storageService.clearSavedSounds(); + set({ + savedSounds: [], + savedSoundsError: null, + }); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Failed to clear saved sounds"; + set({ savedSoundsError: errorMessage }); + toast.error("Failed to clear saved sounds"); + console.error("Failed to clear saved sounds:", error); + } + }, + + addSoundToTimeline: async ({ sound }) => { + const audioUrl = sound.previewUrl; + if (!audioUrl) { + toast.error("Sound file not available"); + return false; + } + + try { + const editor = EditorCore.getInstance(); + const currentTime = editor.playback.getCurrentTime(); + const tracks = editor.timeline.getTracks(); + + const response = await fetch(audioUrl); + if (!response.ok) + throw new Error(`Failed to download audio: ${response.statusText}`); + + const arrayBuffer = await response.arrayBuffer(); + const audioContext = new AudioContext(); + const buffer = await audioContext.decodeAudioData(arrayBuffer); + + const audioTrack = tracks.find((t) => t.type === "audio"); + let trackId: string; + + if (audioTrack) { + trackId = audioTrack.id; + } else { + trackId = editor.timeline.addTrack({ type: "audio" }); + } + + const element = buildLibraryAudioElement({ + sourceUrl: audioUrl, + name: sound.name, + duration: sound.duration, + startTime: currentTime, + buffer, + }); + + editor.timeline.insertElement({ + placement: { mode: "explicit", trackId }, + element, + }); + return true; + } catch (error) { + console.error("Failed to add sound to timeline:", error); + toast.error( + error instanceof Error + ? error.message + : "Failed to add sound to timeline", + { id: `sound-${sound.id}` }, + ); + return false; + } + }, +})); diff --git a/apps/web/src/stores/stickers-store.ts b/apps/web/src/stores/stickers-store.ts index edc78220..3f713b3d 100644 --- a/apps/web/src/stores/stickers-store.ts +++ b/apps/web/src/stores/stickers-store.ts @@ -1,176 +1,219 @@ import { create } from "zustand"; -import { - getCollections, - getCollection, - searchIcons, - type IconSet, - type CollectionInfo, - type IconSearchResult, -} from "@/lib/iconify-api"; +import { persist } from "zustand/middleware"; import { EditorCore } from "@/core"; +import { searchStickers as searchStickersFromProviders } from "@/lib/stickers"; +import type { StickerSearchResult } from "@/lib/stickers"; import { buildStickerElement } from "@/lib/timeline/element-utils"; -import { STICKER_CATEGORY_CONFIG } from "@/constants/stickers-constants"; +import { STICKER_CATEGORIES } from "@/constants/sticker-constants"; import type { StickerCategory } from "@/types/stickers"; +import { registerDefaultStickerProviders } from "@/lib/stickers/providers"; +import { hasProvider } from "@/lib/stickers/registry"; +import { parseStickerId } from "@/lib/stickers/sticker-id"; -type ViewMode = "search" | "browse" | "collection"; +const MAX_RECENT_STICKERS = 50; + +function isValidStickerId(value: unknown): value is string { + if (typeof value !== "string") { + return false; + } + + try { + const parsed = parseStickerId({ stickerId: value }); + return hasProvider({ providerId: parsed.providerId }); + } catch { + return false; + } +} + +function sanitizeRecentStickers({ + recentStickers, +}: { + recentStickers: unknown; +}): string[] { + registerDefaultStickerProviders({}); + + if (!Array.isArray(recentStickers)) { + return []; + } + + const sanitized: string[] = []; + for (const stickerId of recentStickers) { + if (!isValidStickerId(stickerId)) { + continue; + } + if (sanitized.includes(stickerId)) { + continue; + } + sanitized.push(stickerId); + if (sanitized.length >= MAX_RECENT_STICKERS) { + break; + } + } + + return sanitized; +} + +type ViewMode = "search" | "browse"; interface StickersStore { searchQuery: string; selectedCategory: StickerCategory; - selectedCollection: string | null; viewMode: ViewMode; - collections: Record<string, IconSet>; - currentCollection: CollectionInfo | null; - searchResults: IconSearchResult | null; + searchResults: StickerSearchResult | null; recentStickers: string[]; - isLoadingCollections: boolean; - isLoadingCollection: boolean; isSearching: boolean; addingSticker: string | null; setSearchQuery: ({ query }: { query: string }) => void; setSelectedCategory: ({ category }: { category: StickerCategory }) => void; - setSelectedCollection: ({ - collection, + searchStickers: ({ query }: { query: string }) => Promise<void>; + addStickerToTimeline: ({ + stickerId, + name, }: { - collection: string | null; + stickerId: string; + name?: string; }) => void; - setViewMode: ({ mode }: { mode: ViewMode }) => void; - loadCollections: () => Promise<void>; - loadCollection: ({ prefix }: { prefix: string }) => Promise<void>; - searchStickers: ({ query }: { query: string }) => Promise<void>; - addStickerToTimeline: ({ iconName }: { iconName: string }) => void; - addToRecentStickers: ({ iconName }: { iconName: string }) => void; + addToRecentStickers: ({ stickerId }: { stickerId: string }) => void; clearRecentStickers: () => void; } -const MAX_RECENT_STICKERS = 50; - -export const useStickersStore = create<StickersStore>((set, get) => ({ - searchQuery: "", - selectedCategory: "all", - selectedCollection: null, - viewMode: "browse", - - collections: {}, - currentCollection: null, - searchResults: null, - recentStickers: [], - - isLoadingCollections: false, - isLoadingCollection: false, - isSearching: false, - addingSticker: null, - - setSearchQuery: ({ query }) => set({ searchQuery: query }), - - setSelectedCategory: ({ category }) => - set({ - selectedCategory: category, +export const useStickersStore = create<StickersStore>()( + persist( + (set, get) => ({ + searchQuery: "", + selectedCategory: "all", viewMode: "browse", - selectedCollection: null, - currentCollection: null, - }), - setSelectedCollection: ({ collection }) => { - set({ - selectedCollection: collection, - viewMode: collection ? "collection" : "browse", - currentCollection: null, - }); + searchResults: null, + recentStickers: [], - if (collection) { - get().loadCollection({ prefix: collection }); - } - }, + isSearching: false, + addingSticker: null, - setViewMode: ({ mode }) => set({ viewMode: mode }), + setSearchQuery: ({ query }) => set({ searchQuery: query }), - loadCollections: async () => { - set({ isLoadingCollections: true }); - try { - const collections = await getCollections(); - set({ collections }); - } catch (error) { - console.error("Failed to load collections:", error); - } finally { - set({ isLoadingCollections: false }); - } - }, + setSelectedCategory: ({ category }) => + set({ + selectedCategory: category in STICKER_CATEGORIES ? category : "all", + viewMode: "browse", + }), - loadCollection: async ({ prefix }: { prefix: string }) => { - set({ isLoadingCollection: true }); - try { - const collection = await getCollection(prefix); - set({ currentCollection: collection }); - } catch (error) { - console.error(`Failed to load collection ${prefix}:`, error); - set({ currentCollection: null }); - } finally { - set({ isLoadingCollection: false }); - } - }, + searchStickers: async ({ query }: { query: string }) => { + if (!query.trim()) { + set({ searchResults: null, viewMode: "browse" }); + return; + } - searchStickers: async ({ query }: { query: string }) => { - if (!query.trim()) { - set({ searchResults: null, viewMode: "browse" }); - return; - } + const category = get().selectedCategory; + const selectedCategory = + category in STICKER_CATEGORIES ? category : "all"; - const { selectedCategory } = get(); + set({ isSearching: true, viewMode: "search" }); + try { + const results = await searchStickersFromProviders({ + query, + category: selectedCategory, + limit: 100, + }); + set({ searchResults: results }); + } catch (error) { + console.error("Search failed:", error); + set({ searchResults: null }); + } finally { + set({ isSearching: false }); + } + }, - set({ isSearching: true, viewMode: "search" }); - try { - const category = STICKER_CATEGORY_CONFIG[selectedCategory]; - const results = await searchIcons(query, 100, undefined, category); - set({ searchResults: results }); - } catch (error) { - console.error("Search failed:", error); - set({ searchResults: null }); - } finally { - set({ isSearching: false }); - } - }, + addStickerToTimeline: ({ + stickerId, + name, + }: { + stickerId: string; + name?: string; + }) => { + set({ addingSticker: stickerId }); + try { + const editor = EditorCore.getInstance(); + const currentTime = editor.playback.getCurrentTime(); + const tracks = editor.timeline.getTracks(); - addStickerToTimeline: ({ iconName }: { iconName: string }) => { - set({ addingSticker: iconName }); - try { - const editor = EditorCore.getInstance(); - const currentTime = editor.playback.getCurrentTime(); - const tracks = editor.timeline.getTracks(); + const stickerTrack = tracks.find((t) => t.type === "sticker"); + let trackId: string; - const stickerTrack = tracks.find((t) => t.type === "sticker"); - let trackId: string; + if (stickerTrack) { + trackId = stickerTrack.id; + } else { + trackId = editor.timeline.addTrack({ type: "sticker" }); + } - if (stickerTrack) { - trackId = stickerTrack.id; - } else { - trackId = editor.timeline.addTrack({ type: "sticker" }); - } + const element = buildStickerElement({ + stickerId, + name, + startTime: currentTime, + }); + editor.timeline.insertElement({ + placement: { mode: "explicit", trackId }, + element, + }); - const element = buildStickerElement({ iconName, startTime: currentTime }); - editor.timeline.insertElement({ - placement: { mode: "explicit", trackId }, - element, - }); + get().addToRecentStickers({ stickerId }); + } finally { + set({ addingSticker: null }); + } + }, - get().addToRecentStickers({ iconName }); - } finally { - set({ addingSticker: null }); - } - }, + addToRecentStickers: ({ stickerId }: { stickerId: string }) => { + const sanitizedStickerIds = sanitizeRecentStickers({ + recentStickers: [stickerId], + }); + if (sanitizedStickerIds.length === 0) { + return; + } - addToRecentStickers: ({ iconName }: { iconName: string }) => { - set((state) => { - const recent = [ - iconName, - ...state.recentStickers.filter((s) => s !== iconName), - ]; - return { - recentStickers: recent.slice(0, MAX_RECENT_STICKERS), - }; - }); - }, + set((state) => { + const recent = [ + sanitizedStickerIds[0], + ...state.recentStickers.filter((s) => s !== sanitizedStickerIds[0]), + ]; + return { + recentStickers: recent.slice(0, MAX_RECENT_STICKERS), + }; + }); + }, - clearRecentStickers: () => set({ recentStickers: [] }), -})); + clearRecentStickers: () => set({ recentStickers: [] }), + }), + { + name: "stickers-settings", + migrate: (persistedState) => { + if ( + typeof persistedState === "object" && + persistedState !== null && + "selectedCategory" in persistedState + ) { + const typedState = persistedState as { + selectedCategory?: string; + recentStickers?: string[]; + }; + const category = typedState.selectedCategory ?? "all"; + return { + ...typedState, + selectedCategory: + category in STICKER_CATEGORIES + ? (category as StickerCategory) + : "all", + recentStickers: sanitizeRecentStickers({ + recentStickers: typedState.recentStickers ?? [], + }), + }; + } + return persistedState; + }, + partialize: (state) => ({ + selectedCategory: state.selectedCategory, + recentStickers: state.recentStickers, + }), + }, + ), +); diff --git a/apps/web/src/types/drag.ts b/apps/web/src/types/drag.ts index e6e5daba..61aba9b5 100644 --- a/apps/web/src/types/drag.ts +++ b/apps/web/src/types/drag.ts @@ -15,7 +15,7 @@ export interface TextDragData extends BaseDragData { export interface StickerDragData extends BaseDragData { type: "sticker"; - iconName: string; + stickerId: string; } export type TimelineDragData = MediaDragData | TextDragData | StickerDragData; diff --git a/apps/web/src/types/eyedropper.d.ts b/apps/web/src/types/eyedropper.d.ts new file mode 100644 index 00000000..2d0e17e8 --- /dev/null +++ b/apps/web/src/types/eyedropper.d.ts @@ -0,0 +1,13 @@ +interface EyeDropperResult { + sRGBHex: string; +} + +interface EyeDropper { + open(options?: { signal?: AbortSignal }): Promise<EyeDropperResult>; +} + +declare const EyeDropper: + | { + new (): EyeDropper; + } + | undefined; diff --git a/apps/web/src/types/fonts.ts b/apps/web/src/types/fonts.ts new file mode 100644 index 00000000..a2db6f91 --- /dev/null +++ b/apps/web/src/types/fonts.ts @@ -0,0 +1,24 @@ +export interface FontOption { + value: string; + label: string; + category: "system" | "google" | "custom"; + weights?: number[]; + hasClassName?: boolean; +} + +export interface GoogleFontMeta { + family: string; + category: string; +} + +export interface FontAtlasEntry { + x: number; + y: number; + w: number; + ch: number; + s: string[]; +} + +export interface FontAtlas { + fonts: Record<string, FontAtlasEntry>; +} diff --git a/apps/web/src/types/rendering.ts b/apps/web/src/types/rendering.ts new file mode 100644 index 00000000..f5e18502 --- /dev/null +++ b/apps/web/src/types/rendering.ts @@ -0,0 +1,18 @@ +export type BlendMode = + | "normal" + | "darken" + | "multiply" + | "color-burn" + | "lighten" + | "screen" + | "plus-lighter" + | "color-dodge" + | "overlay" + | "soft-light" + | "hard-light" + | "difference" + | "exclusion" + | "hue" + | "saturation" + | "color" + | "luminosity"; diff --git a/apps/web/src/types/sounds.ts b/apps/web/src/types/sounds.ts index f7526846..eecf7701 100644 --- a/apps/web/src/types/sounds.ts +++ b/apps/web/src/types/sounds.ts @@ -1,39 +1,39 @@ -export interface SoundEffect {
- id: number;
- name: string;
- description: string;
- url: string;
- previewUrl?: string;
- downloadUrl?: string;
- duration: number;
- filesize: number;
- type: string;
- channels: number;
- bitrate: number;
- bitdepth: number;
- samplerate: number;
- username: string;
- tags: string[];
- license: string;
- created: string;
- downloads: number;
- rating: number;
- ratingCount: number;
-}
-
-export interface SavedSound {
- id: number; // freesound id
- name: string;
- username: string;
- previewUrl?: string;
- downloadUrl?: string;
- duration: number;
- tags: string[];
- license: string;
- savedAt: string; // iso date string
-}
-
-export interface SavedSoundsData {
- sounds: SavedSound[];
- lastModified: string;
-}
+export interface SoundEffect { + id: number; + name: string; + description: string; + url: string; + previewUrl?: string; + downloadUrl?: string; + duration: number; + filesize: number; + type: string; + channels: number; + bitrate: number; + bitdepth: number; + samplerate: number; + username: string; + tags: string[]; + license: string; + created: string; + downloads: number; + rating: number; + ratingCount: number; +} + +export interface SavedSound { + id: number; // freesound id + name: string; + username: string; + previewUrl?: string; + downloadUrl?: string; + duration: number; + tags: string[]; + license: string; + savedAt: string; // iso date string +} + +export interface SavedSoundsData { + sounds: SavedSound[]; + lastModified: string; +} diff --git a/apps/web/src/types/stickers.ts b/apps/web/src/types/stickers.ts index c6adfd7a..65c9aeb2 100644 --- a/apps/web/src/types/stickers.ts +++ b/apps/web/src/types/stickers.ts @@ -1,3 +1,54 @@ -import type { STICKER_CATEGORIES } from "@/constants/stickers-constants"; +import type { STICKER_CATEGORIES } from "@/constants/sticker-constants"; -export type StickerCategory = (typeof STICKER_CATEGORIES)[number]; +export type StickerCategory = keyof typeof STICKER_CATEGORIES; + +export interface StickerItem { + id: string; + provider: string; + name: string; + previewUrl: string; + metadata: Record<string, unknown>; +} + +export interface StickerSearchResult { + items: StickerItem[]; + total: number; + hasMore: boolean; +} + +export interface StickerProviderSearchOptions { + limit?: number; +} + +export interface StickerProviderBrowseOptions { + page?: number; + limit?: number; +} + +export interface StickerResolveOptions { + width?: number; + height?: number; +} + +export interface StickerProvider { + id: string; + search({ + query, + options, + }: { + query: string; + options?: StickerProviderSearchOptions; + }): Promise<StickerSearchResult>; + browse({ + options, + }: { + options?: StickerProviderBrowseOptions; + }): Promise<StickerSearchResult>; + resolveUrl({ + stickerId, + options, + }: { + stickerId: string; + options?: StickerResolveOptions; + }): string; +} diff --git a/apps/web/src/types/timeline.ts b/apps/web/src/types/timeline.ts index 69d40518..da96c7de 100644 --- a/apps/web/src/types/timeline.ts +++ b/apps/web/src/types/timeline.ts @@ -1,9 +1,18 @@ +import type { BlendMode } from "./rendering"; + +export interface Bookmark { + time: number; + note?: string; + color?: string; + duration?: number; +} + export interface TScene { id: string; name: string; isMain: boolean; tracks: TimelineTrack[]; - bookmarks: number[]; + bookmarks: Bookmark[]; createdAt: Date; updatedAt: Date; } @@ -87,6 +96,7 @@ export interface VideoElement extends BaseTimelineElement { hidden?: boolean; transform: Transform; opacity: number; + blendMode?: BlendMode; } export interface ImageElement extends BaseTimelineElement { @@ -95,6 +105,7 @@ export interface ImageElement extends BaseTimelineElement { hidden?: boolean; transform: Transform; opacity: number; + blendMode?: BlendMode; } export interface TextElement extends BaseTimelineElement { @@ -108,18 +119,21 @@ export interface TextElement extends BaseTimelineElement { fontWeight: "normal" | "bold"; fontStyle: "normal" | "italic"; textDecoration: "none" | "underline" | "line-through"; + letterSpacing?: number; + lineHeight?: number; hidden?: boolean; transform: Transform; opacity: number; + blendMode?: BlendMode; } export interface StickerElement extends BaseTimelineElement { type: "sticker"; - iconName: string; + stickerId: string; hidden?: boolean; transform: Transform; opacity: number; - color?: string; + blendMode?: BlendMode; } export type TimelineElement = @@ -147,8 +161,6 @@ export type CreateTimelineElement = | CreateTextElement | CreateStickerElement; -// ---- Drag State ---- - export interface ElementDragState { isDragging: boolean; elementId: string | null; diff --git a/apps/web/src/utils/color.ts b/apps/web/src/utils/color.ts new file mode 100644 index 00000000..7b01a92e --- /dev/null +++ b/apps/web/src/utils/color.ts @@ -0,0 +1,182 @@ +import { converter, formatHex, formatHex8, parse, type Rgb } from "culori"; + +export type ColorFormat = "hex" | "rgb" | "hsl" | "hsv"; + +const toRgb = converter("rgb"); +const toHsv = converter("hsv"); +const toHsl = converter("hsl"); + +export function hexToHsv({ hex }: { hex: string }): [number, number, number] { + const color = toHsv(`#${hex}`); + if (!color) return [0, 0, 0]; + return [color.h ?? 0, color.s ?? 0, color.v ?? 0]; +} + +export function hsvToHex({ + h, + s, + v, +}: { + h: number; + s: number; + v: number; +}): string { + const hex = formatHex({ mode: "hsv", h, s, v }); + return hex.slice(1); +} + +export function parseHexAlpha({ hex }: { hex: string }): { + rgb: string; + alpha: number; +} { + const color = parse(`#${hex}`); + const rgbHex = color + ? formatHex(color).slice(1) + : hex.slice(0, 6).toLowerCase(); + return { + rgb: rgbHex, + alpha: color?.alpha ?? 1, + }; +} + +export function appendAlpha({ + rgbHex, + alpha, +}: { + rgbHex: string; + alpha: number; +}): string { + if (alpha >= 1) return rgbHex; + const hex8 = formatHex8({ mode: "rgb", r: 0, g: 0, b: 0, alpha }); + const alphaHex = hex8.slice(7, 9); + return rgbHex + alphaHex; +} + +function stripCssNoise({ text }: { text: string }): string { + let cleaned = text.trim(); + cleaned = cleaned + .replace(/\s*!important\s*/gi, "") + .replace(/;+\s*$/, "") + .trim(); + + const colonIndex = cleaned.indexOf(":"); + const parenIndex = cleaned.indexOf("("); + if (colonIndex !== -1 && (parenIndex === -1 || colonIndex < parenIndex)) { + cleaned = cleaned.slice(colonIndex + 1).trim(); + } + + return cleaned; +} + +function colorToHexWithAlpha({ color }: { color: Rgb }): string { + const hex = formatHex(color).slice(1); + if (color.alpha !== undefined && color.alpha < 1) { + const hex8 = formatHex8(color); + return hex8.slice(1); + } + return hex; +} + +export function extractColorFromText({ + text, +}: { + text: string; +}): string | null { + const cleaned = stripCssNoise({ text }); + + const color = toRgb(cleaned); + if (color) return colorToHexWithAlpha({ color }); + + // bare hex without # (culori needs the prefix) + const bareHexMatch = cleaned.match(/^([0-9a-fA-F]{3,8})$/); + if (bareHexMatch) { + const withHash = toRgb(`#${bareHexMatch[1]}`); + if (withHash) return colorToHexWithAlpha({ color: withHash }); + } + + // fallback: find #hex anywhere in the original text + const embeddedHexMatch = text.match(/#([0-9a-fA-F]{3,8})\b/); + if (embeddedHexMatch) { + const embedded = toRgb(`#${embeddedHexMatch[1]}`); + if (embedded) return colorToHexWithAlpha({ color: embedded }); + } + + return null; +} + +export function formatColorValue({ + hex, + format, +}: { + hex: string; + format: ColorFormat; +}): string { + switch (format) { + case "hex": + return hex; + case "rgb": { + const color = toRgb(`#${hex}`); + if (!color) return hex; + return `${Math.round(color.r * 255)}, ${Math.round(color.g * 255)}, ${Math.round(color.b * 255)}`; + } + case "hsl": { + const color = toHsl(`#${hex}`); + if (!color) return hex; + return `${Math.round(color.h ?? 0)}, ${Math.round((color.s ?? 0) * 100)}%, ${Math.round((color.l ?? 0) * 100)}%`; + } + case "hsv": { + const color = toHsv(`#${hex}`); + if (!color) return hex; + return `${Math.round(color.h ?? 0)}, ${Math.round((color.s ?? 0) * 100)}%, ${Math.round((color.v ?? 0) * 100)}%`; + } + } +} + +export function parseColorInput({ + input, + format, +}: { + input: string; + format: ColorFormat; +}): string | null { + switch (format) { + case "hex": { + const cleaned = input.replace("#", ""); + const isValidHex = /^[0-9a-fA-F]{3,8}$/.test(cleaned); + return isValidHex ? cleaned : null; + } + case "rgb": { + const parts = input.split(",").map((part) => parseInt(part.trim(), 10)); + if (parts.length < 3 || parts.some(Number.isNaN)) return null; + const color = { + mode: "rgb" as const, + r: parts[0] / 255, + g: parts[1] / 255, + b: parts[2] / 255, + }; + return formatHex(color).slice(1); + } + case "hsl": { + const parts = input.split(",").map((part) => parseFloat(part.trim())); + if (parts.length < 3 || parts.some(Number.isNaN)) return null; + const color = { + mode: "hsl" as const, + h: parts[0], + s: parts[1] / 100, + l: parts[2] / 100, + }; + return formatHex(color).slice(1); + } + case "hsv": { + const parts = input.split(",").map((part) => parseFloat(part.trim())); + if (parts.length < 3 || parts.some(Number.isNaN)) return null; + const color = { + mode: "hsv" as const, + h: parts[0], + s: parts[1] / 100, + v: parts[2] / 100, + }; + return formatHex(color).slice(1); + } + } +} diff --git a/apps/web/src/utils/math.ts b/apps/web/src/utils/math.ts index 012ac747..ac82ca17 100644 --- a/apps/web/src/utils/math.ts +++ b/apps/web/src/utils/math.ts @@ -9,3 +9,19 @@ export function clamp({ }): number { return Math.max(min, Math.min(max, value)); } + +export function evaluateMathExpression({ + input, +}: { + input: string; +}): number | null { + const sanitized = input.trim(); + if (!/^[\d.\s+\-*/()]+$/.test(sanitized)) return null; + try { + const result = new Function(`return (${sanitized})`)(); + if (typeof result !== "number" || !Number.isFinite(result)) return null; + return result; + } catch { + return null; + } +} diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index ee05eade..a8b420f7 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -1,38 +1,38 @@ { - "compilerOptions": { - "target": "ES2017", - "lib": ["dom", "dom.iterable", "esnext"], - "allowJs": true, - "skipLibCheck": true, - "strict": true, - "noEmit": true, - "esModuleInterop": true, - "module": "esnext", - "moduleResolution": "bundler", - "resolveJsonModule": true, - "isolatedModules": true, - "jsx": "react-jsx", - "incremental": true, - "plugins": [ - { - "name": "next" - } - ], - "paths": { - "@/*": ["./src/*"] - }, - "forceConsistentCasingInFileNames": true - }, - "include": [ - "**/*.ts", - "**/*.tsx", - "apps/web/.next/types/**/*.ts", - "next-env.d.ts", - ".next/types/**/*.ts", - "src/types/**/*.d.ts", - "../../use-frame-cache.ts", - "src/stores/timeline-store.ts", - ".next/dev/types/**/*.ts" - ], - "exclude": ["node_modules"] + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./src/*"] + }, + "forceConsistentCasingInFileNames": true + }, + "include": [ + "**/*.ts", + "**/*.tsx", + "apps/web/.next/types/**/*.ts", + "next-env.d.ts", + ".next/types/**/*.ts", + "src/types/**/*.d.ts", + "../../use-frame-cache.ts", + "src/stores/timeline-store.ts", + ".next/dev/types/**/*.ts" + ], + "exclude": ["node_modules"] } |
