summaryrefslogtreecommitdiff
path: root/apps/web
diff options
context:
space:
mode:
authorMaze <167211895+mazeincoding@users.noreply.github.com>2026-02-23 03:24:02 +0100
committerGitHub <noreply@github.com>2026-02-23 03:24:02 +0100
commit93d1e3383ce47e45157d70f9ba6251239dae6bc2 (patch)
tree1e33a2d7533b65f0e7c8927f03905a1a99937bd1 /apps/web
parentfca99d6126c31fbb18ed9f1034cee6f940b040e8 (diff)
downloadopencut-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')
-rw-r--r--apps/web/.gitignore4
-rw-r--r--apps/web/components.json38
-rw-r--r--apps/web/drizzle.config.ts24
-rw-r--r--apps/web/migrations/meta/0000_snapshot.json684
-rw-r--r--apps/web/migrations/meta/_journal.json22
-rw-r--r--apps/web/next.config.ts84
-rw-r--r--apps/web/package.json201
-rw-r--r--apps/web/postcss.config.mjs6
-rw-r--r--apps/web/public/countries.json1794
-rw-r--r--apps/web/public/fonts/font-atlas.json11348
-rw-r--r--apps/web/public/fonts/font-chunk-0.avifbin0 -> 140054 bytes
-rw-r--r--apps/web/public/fonts/font-chunk-1.avifbin0 -> 178357 bytes
-rw-r--r--apps/web/public/fonts/font-chunk-10.avifbin0 -> 183944 bytes
-rw-r--r--apps/web/public/fonts/font-chunk-11.avifbin0 -> 155514 bytes
-rw-r--r--apps/web/public/fonts/font-chunk-12.avifbin0 -> 159098 bytes
-rw-r--r--apps/web/public/fonts/font-chunk-13.avifbin0 -> 141771 bytes
-rw-r--r--apps/web/public/fonts/font-chunk-14.avifbin0 -> 91326 bytes
-rw-r--r--apps/web/public/fonts/font-chunk-2.avifbin0 -> 150129 bytes
-rw-r--r--apps/web/public/fonts/font-chunk-3.avifbin0 -> 138705 bytes
-rw-r--r--apps/web/public/fonts/font-chunk-4.avifbin0 -> 141217 bytes
-rw-r--r--apps/web/public/fonts/font-chunk-5.avifbin0 -> 132681 bytes
-rw-r--r--apps/web/public/fonts/font-chunk-6.avifbin0 -> 126973 bytes
-rw-r--r--apps/web/public/fonts/font-chunk-7.avifbin0 -> 141286 bytes
-rw-r--r--apps/web/public/fonts/font-chunk-8.avifbin0 -> 136232 bytes
-rw-r--r--apps/web/public/fonts/font-chunk-9.avifbin0 -> 144794 bytes
-rw-r--r--apps/web/public/manifest.json84
-rw-r--r--apps/web/public/shapes/circle.svg3
-rw-r--r--apps/web/public/shapes/diamond.svg3
-rw-r--r--apps/web/public/shapes/hexagon.svg3
-rw-r--r--apps/web/public/shapes/square.svg3
-rw-r--r--apps/web/public/shapes/star.svg3
-rw-r--r--apps/web/public/shapes/triangle.svg3
-rw-r--r--apps/web/scripts/generate-font-sprites.ts281
-rw-r--r--apps/web/src/app/api/sounds/search/route.ts560
-rw-r--r--apps/web/src/app/blog/[slug]/page.tsx4
-rw-r--r--apps/web/src/app/editor/[project_id]/page.tsx2
-rw-r--r--apps/web/src/app/globals.css447
-rw-r--r--apps/web/src/app/projects/page.tsx2
-rw-r--r--apps/web/src/components/editor/editor-header.tsx20
-rw-r--r--apps/web/src/components/editor/export-button.tsx147
-rw-r--r--apps/web/src/components/editor/panels/assets/draggable-item.tsx4
-rw-r--r--apps/web/src/components/editor/panels/assets/index.tsx2
-rw-r--r--apps/web/src/components/editor/panels/assets/tabbar.tsx3
-rw-r--r--apps/web/src/components/editor/panels/assets/views/assets.tsx (renamed from apps/web/src/components/editor/panels/assets/views/media.tsx)366
-rw-r--r--apps/web/src/components/editor/panels/assets/views/base-view.tsx52
-rw-r--r--apps/web/src/components/editor/panels/assets/views/captions.tsx9
-rw-r--r--apps/web/src/components/editor/panels/assets/views/settings-legacy.tsx332
-rw-r--r--apps/web/src/components/editor/panels/assets/views/settings.tsx354
-rw-r--r--apps/web/src/components/editor/panels/assets/views/sounds.tsx2
-rw-r--r--apps/web/src/components/editor/panels/assets/views/stickers.tsx689
-rw-r--r--apps/web/src/components/editor/panels/assets/views/text.tsx6
-rw-r--r--apps/web/src/components/editor/panels/panel-base-view.tsx85
-rw-r--r--apps/web/src/components/editor/panels/preview/bookmark-note-overlay.tsx59
-rw-r--r--apps/web/src/components/editor/panels/preview/context-menu.tsx35
-rw-r--r--apps/web/src/components/editor/panels/preview/index.tsx181
-rw-r--r--apps/web/src/components/editor/panels/preview/layout-guide-overlay.tsx (renamed from apps/web/src/components/editor/layout-guide-overlay.tsx)54
-rw-r--r--apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx52
-rw-r--r--apps/web/src/components/editor/panels/preview/snap-guides.tsx65
-rw-r--r--apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx149
-rw-r--r--apps/web/src/components/editor/panels/preview/toolbar.tsx80
-rw-r--r--apps/web/src/components/editor/panels/preview/transform-handles.tsx283
-rw-r--r--apps/web/src/components/editor/panels/properties/empty-view.tsx2
-rw-r--r--apps/web/src/components/editor/panels/properties/hooks/use-property-draft.ts72
-rw-r--r--apps/web/src/components/editor/panels/properties/property-item.tsx132
-rw-r--r--apps/web/src/components/editor/panels/properties/section.tsx161
-rw-r--r--apps/web/src/components/editor/panels/properties/sections/blending.tsx196
-rw-r--r--apps/web/src/components/editor/panels/properties/sections/index.tsx2
-rw-r--r--apps/web/src/components/editor/panels/properties/sections/transform.tsx248
-rw-r--r--apps/web/src/components/editor/panels/properties/text-properties.tsx806
-rw-r--r--apps/web/src/components/editor/panels/timeline/bookmarks.tsx409
-rw-r--r--apps/web/src/components/editor/panels/timeline/index.tsx29
-rw-r--r--apps/web/src/components/editor/panels/timeline/snap-indicator.tsx2
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-element.tsx25
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx12
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx5
-rw-r--r--apps/web/src/components/gitHub-contribute-section.tsx5
-rw-r--r--apps/web/src/components/providers/editor-provider.tsx2
-rw-r--r--apps/web/src/components/theme-toggle.tsx75
-rw-r--r--apps/web/src/components/ui/alert.tsx11
-rw-r--r--apps/web/src/components/ui/avatar.tsx8
-rw-r--r--apps/web/src/components/ui/breadcrumb.tsx159
-rw-r--r--apps/web/src/components/ui/button.tsx24
-rw-r--r--apps/web/src/components/ui/calendar.tsx4
-rw-r--r--apps/web/src/components/ui/carousel.tsx262
-rw-r--r--apps/web/src/components/ui/chart.tsx370
-rw-r--r--apps/web/src/components/ui/color-picker.tsx472
-rw-r--r--apps/web/src/components/ui/command.tsx152
-rw-r--r--apps/web/src/components/ui/context-menu.tsx179
-rw-r--r--apps/web/src/components/ui/drawer.tsx118
-rw-r--r--apps/web/src/components/ui/dropdown-menu.tsx68
-rw-r--r--apps/web/src/components/ui/font-picker.tsx317
-rw-r--r--apps/web/src/components/ui/input-otp.tsx75
-rw-r--r--apps/web/src/components/ui/input-with-back.tsx173
-rw-r--r--apps/web/src/components/ui/input.tsx15
-rw-r--r--apps/web/src/components/ui/number-field.tsx169
-rw-r--r--apps/web/src/components/ui/pagination.tsx117
-rw-r--r--apps/web/src/components/ui/phone-input.tsx167
-rw-r--r--apps/web/src/components/ui/popover.tsx6
-rw-r--r--apps/web/src/components/ui/prose.tsx91
-rw-r--r--apps/web/src/components/ui/select.tsx70
-rw-r--r--apps/web/src/components/ui/sidebar.tsx725
-rw-r--r--apps/web/src/components/ui/spinner.tsx20
-rw-r--r--apps/web/src/components/ui/split-button.tsx180
-rw-r--r--apps/web/src/components/ui/textarea.tsx4
-rw-r--r--apps/web/src/components/ui/tooltip.tsx1
-rw-r--r--apps/web/src/constants/editor-constants.ts6
-rw-r--r--apps/web/src/constants/font-constants.ts88
-rw-r--r--apps/web/src/constants/site-constants.ts2
-rw-r--r--apps/web/src/constants/sticker-constants.ts8
-rw-r--r--apps/web/src/constants/stickers-constants.ts16
-rw-r--r--apps/web/src/constants/text-constants.ts78
-rw-r--r--apps/web/src/constants/timeline-constants.tsx15
-rw-r--r--apps/web/src/core/managers/audio-manager.ts12
-rw-r--r--apps/web/src/core/managers/commands.ts5
-rw-r--r--apps/web/src/core/managers/project-manager.ts5
-rw-r--r--apps/web/src/core/managers/scenes-manager.ts47
-rw-r--r--apps/web/src/core/managers/timeline-manager.ts53
-rw-r--r--apps/web/src/hooks/timeline/element/use-element-interaction.ts40
-rw-r--r--apps/web/src/hooks/timeline/element/use-element-resize.ts314
-rw-r--r--apps/web/src/hooks/timeline/element/use-element-selection.ts4
-rw-r--r--apps/web/src/hooks/timeline/use-bookmark-drag.ts289
-rw-r--r--apps/web/src/hooks/timeline/use-timeline-drag-drop.ts168
-rw-r--r--apps/web/src/hooks/timeline/use-timeline-playhead.ts40
-rw-r--r--apps/web/src/hooks/timeline/use-timeline-snapping.ts32
-rw-r--r--apps/web/src/hooks/timeline/use-timeline-zoom.ts50
-rw-r--r--apps/web/src/hooks/use-focus-lock.ts79
-rw-r--r--apps/web/src/hooks/use-infinite-scroll.ts70
-rw-r--r--apps/web/src/hooks/use-paste-media.ts99
-rw-r--r--apps/web/src/hooks/use-preview-interaction.ts328
-rw-r--r--apps/web/src/hooks/use-shift-key.ts35
-rw-r--r--apps/web/src/hooks/use-sound-search.ts308
-rw-r--r--apps/web/src/hooks/use-transform-handles.ts331
-rw-r--r--apps/web/src/lib/commands/index.ts1
-rw-r--r--apps/web/src/lib/commands/preview-tracker.ts23
-rw-r--r--apps/web/src/lib/commands/scene/index.ts2
-rw-r--r--apps/web/src/lib/commands/scene/move-bookmark.ts59
-rw-r--r--apps/web/src/lib/commands/scene/update-bookmark.ts55
-rw-r--r--apps/web/src/lib/commands/timeline/clipboard/paste.ts4
-rw-r--r--apps/web/src/lib/commands/timeline/element/duplicate-elements.ts8
-rw-r--r--apps/web/src/lib/commands/timeline/element/insert-element.ts4
-rw-r--r--apps/web/src/lib/commands/timeline/element/split-elements.ts8
-rw-r--r--apps/web/src/lib/commands/timeline/index.ts2
-rw-r--r--apps/web/src/lib/commands/timeline/tracks-snapshot.ts20
-rw-r--r--apps/web/src/lib/fonts/google-fonts.ts90
-rw-r--r--apps/web/src/lib/preview/element-bounds.ts219
-rw-r--r--apps/web/src/lib/preview/hit-test.ts60
-rw-r--r--apps/web/src/lib/preview/preview-coords.ts76
-rw-r--r--apps/web/src/lib/preview/preview-snap.ts292
-rw-r--r--apps/web/src/lib/stickers/__tests__/sticker-id.test.ts34
-rw-r--r--apps/web/src/lib/stickers/index.ts175
-rw-r--r--apps/web/src/lib/stickers/providers/emoji.ts119
-rw-r--r--apps/web/src/lib/stickers/providers/flags.ts167
-rw-r--r--apps/web/src/lib/stickers/providers/icons.ts122
-rw-r--r--apps/web/src/lib/stickers/providers/index.ts26
-rw-r--r--apps/web/src/lib/stickers/providers/shapes.ts118
-rw-r--r--apps/web/src/lib/stickers/registry.ts31
-rw-r--r--apps/web/src/lib/stickers/resolver.ts22
-rw-r--r--apps/web/src/lib/stickers/sticker-id.ts34
-rw-r--r--apps/web/src/lib/stickers/types.ts8
-rw-r--r--apps/web/src/lib/timeline/bookmarks.ts112
-rw-r--r--apps/web/src/lib/timeline/drag-utils.ts19
-rw-r--r--apps/web/src/lib/timeline/element-utils.ts96
-rw-r--r--apps/web/src/proxy.ts (renamed from apps/web/src/middleware.ts)2
-rw-r--r--apps/web/src/services/renderer/nodes/sticker-node.ts14
-rw-r--r--apps/web/src/services/renderer/nodes/text-node.ts173
-rw-r--r--apps/web/src/services/renderer/nodes/visual-node.ts7
-rw-r--r--apps/web/src/services/renderer/scene-builder.ts6
-rw-r--r--apps/web/src/services/storage/migrations/__tests__/fixtures/index.ts1
-rw-r--r--apps/web/src/services/storage/migrations/__tests__/fixtures/v5.ts54
-rw-r--r--apps/web/src/services/storage/migrations/__tests__/v3-to-v4.test.ts140
-rw-r--r--apps/web/src/services/storage/migrations/__tests__/v4-to-v5.test.ts137
-rw-r--r--apps/web/src/services/storage/migrations/__tests__/v5-to-v6.test.ts92
-rw-r--r--apps/web/src/services/storage/migrations/index.ts8
-rw-r--r--apps/web/src/services/storage/migrations/runner.ts6
-rw-r--r--apps/web/src/services/storage/migrations/transformers/index.ts2
-rw-r--r--apps/web/src/services/storage/migrations/transformers/v3-to-v4.ts189
-rw-r--r--apps/web/src/services/storage/migrations/transformers/v4-to-v5.ts177
-rw-r--r--apps/web/src/services/storage/migrations/transformers/v5-to-v6.ts88
-rw-r--r--apps/web/src/services/storage/migrations/v3-to-v4.ts16
-rw-r--r--apps/web/src/services/storage/migrations/v4-to-v5.ts16
-rw-r--r--apps/web/src/services/storage/migrations/v5-to-v6.ts16
-rw-r--r--apps/web/src/services/storage/service.ts27
-rw-r--r--apps/web/src/stores/editor-store.ts68
-rw-r--r--apps/web/src/stores/keybindings-store.ts5
-rw-r--r--apps/web/src/stores/keybindings/migrations/index.ts6
-rw-r--r--apps/web/src/stores/keybindings/migrations/v3-to-v4.ts25
-rw-r--r--apps/web/src/stores/preview-store.ts94
-rw-r--r--apps/web/src/stores/sounds-store.ts522
-rw-r--r--apps/web/src/stores/stickers-store.ts321
-rw-r--r--apps/web/src/types/drag.ts2
-rw-r--r--apps/web/src/types/eyedropper.d.ts13
-rw-r--r--apps/web/src/types/fonts.ts24
-rw-r--r--apps/web/src/types/rendering.ts18
-rw-r--r--apps/web/src/types/sounds.ts78
-rw-r--r--apps/web/src/types/stickers.ts55
-rw-r--r--apps/web/src/types/timeline.ts22
-rw-r--r--apps/web/src/utils/color.ts182
-rw-r--r--apps/web/src/utils/math.ts16
-rw-r--r--apps/web/tsconfig.json72
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
new file mode 100644
index 00000000..719b4235
--- /dev/null
+++ b/apps/web/public/fonts/font-chunk-0.avif
Binary files differ
diff --git a/apps/web/public/fonts/font-chunk-1.avif b/apps/web/public/fonts/font-chunk-1.avif
new file mode 100644
index 00000000..c276430b
--- /dev/null
+++ b/apps/web/public/fonts/font-chunk-1.avif
Binary files differ
diff --git a/apps/web/public/fonts/font-chunk-10.avif b/apps/web/public/fonts/font-chunk-10.avif
new file mode 100644
index 00000000..9761e954
--- /dev/null
+++ b/apps/web/public/fonts/font-chunk-10.avif
Binary files differ
diff --git a/apps/web/public/fonts/font-chunk-11.avif b/apps/web/public/fonts/font-chunk-11.avif
new file mode 100644
index 00000000..d4f788e0
--- /dev/null
+++ b/apps/web/public/fonts/font-chunk-11.avif
Binary files differ
diff --git a/apps/web/public/fonts/font-chunk-12.avif b/apps/web/public/fonts/font-chunk-12.avif
new file mode 100644
index 00000000..91e643ae
--- /dev/null
+++ b/apps/web/public/fonts/font-chunk-12.avif
Binary files differ
diff --git a/apps/web/public/fonts/font-chunk-13.avif b/apps/web/public/fonts/font-chunk-13.avif
new file mode 100644
index 00000000..6a507e8b
--- /dev/null
+++ b/apps/web/public/fonts/font-chunk-13.avif
Binary files differ
diff --git a/apps/web/public/fonts/font-chunk-14.avif b/apps/web/public/fonts/font-chunk-14.avif
new file mode 100644
index 00000000..f67ab51b
--- /dev/null
+++ b/apps/web/public/fonts/font-chunk-14.avif
Binary files differ
diff --git a/apps/web/public/fonts/font-chunk-2.avif b/apps/web/public/fonts/font-chunk-2.avif
new file mode 100644
index 00000000..bc3ca018
--- /dev/null
+++ b/apps/web/public/fonts/font-chunk-2.avif
Binary files differ
diff --git a/apps/web/public/fonts/font-chunk-3.avif b/apps/web/public/fonts/font-chunk-3.avif
new file mode 100644
index 00000000..a5b9dd60
--- /dev/null
+++ b/apps/web/public/fonts/font-chunk-3.avif
Binary files differ
diff --git a/apps/web/public/fonts/font-chunk-4.avif b/apps/web/public/fonts/font-chunk-4.avif
new file mode 100644
index 00000000..505fcb25
--- /dev/null
+++ b/apps/web/public/fonts/font-chunk-4.avif
Binary files differ
diff --git a/apps/web/public/fonts/font-chunk-5.avif b/apps/web/public/fonts/font-chunk-5.avif
new file mode 100644
index 00000000..7d8c0216
--- /dev/null
+++ b/apps/web/public/fonts/font-chunk-5.avif
Binary files differ
diff --git a/apps/web/public/fonts/font-chunk-6.avif b/apps/web/public/fonts/font-chunk-6.avif
new file mode 100644
index 00000000..d318967d
--- /dev/null
+++ b/apps/web/public/fonts/font-chunk-6.avif
Binary files differ
diff --git a/apps/web/public/fonts/font-chunk-7.avif b/apps/web/public/fonts/font-chunk-7.avif
new file mode 100644
index 00000000..0dcb81d7
--- /dev/null
+++ b/apps/web/public/fonts/font-chunk-7.avif
Binary files differ
diff --git a/apps/web/public/fonts/font-chunk-8.avif b/apps/web/public/fonts/font-chunk-8.avif
new file mode 100644
index 00000000..63b0e35a
--- /dev/null
+++ b/apps/web/public/fonts/font-chunk-8.avif
Binary files differ
diff --git a/apps/web/public/fonts/font-chunk-9.avif b/apps/web/public/fonts/font-chunk-9.avif
new file mode 100644
index 00000000..e7936bf0
--- /dev/null
+++ b/apps/web/public/fonts/font-chunk-9.avif
Binary files differ
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"]
}