From 93d1e3383ce47e45157d70f9ba6251239dae6bc2 Mon Sep 17 00:00:00 2001 From: Maze <167211895+mazeincoding@users.noreply.github.com> Date: Mon, 23 Feb 2026 03:24:02 +0100 Subject: 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 --- .../storage/migrations/__tests__/fixtures/index.ts | 1 + .../storage/migrations/__tests__/fixtures/v5.ts | 54 ++++++++ .../storage/migrations/__tests__/v3-to-v4.test.ts | 140 +++++++++++++++++++++ .../storage/migrations/__tests__/v4-to-v5.test.ts | 137 ++++++++++++++++++++ .../storage/migrations/__tests__/v5-to-v6.test.ts | 92 ++++++++++++++ 5 files changed, 424 insertions(+) create mode 100644 apps/web/src/services/storage/migrations/__tests__/fixtures/v5.ts create mode 100644 apps/web/src/services/storage/migrations/__tests__/v3-to-v4.test.ts create mode 100644 apps/web/src/services/storage/migrations/__tests__/v4-to-v5.test.ts create mode 100644 apps/web/src/services/storage/migrations/__tests__/v5-to-v6.test.ts (limited to 'apps/web/src/services/storage/migrations/__tests__') 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> + )[0]; + const migratedTrack = ( + migratedScene.tracks as Array> + )[0]; + const migratedElement = ( + migratedTrack.elements as Array> + )[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> + )[0]; + const migratedTrack = ( + migratedScene.tracks as Array> + )[0]; + const migratedElement = ( + migratedTrack.elements as Array> + )[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> + )[0]; + const migratedTrack = ( + migratedScene.tracks as Array> + )[0]; + const migratedElement = ( + migratedTrack.elements as Array> + )[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> + )[0]; + const migratedTrack = ( + migratedScene.tracks as Array> + )[0]; + const migratedElement = ( + migratedTrack.elements as Array> + )[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[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[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> }) + .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 }, + ]); + }); +}); -- cgit v1.3.1