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

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

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

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

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

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

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

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

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

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

* deleted shadcn components with errors

* formatting

* fix linter issues

* migrate from next middleware to proxy

* add missing component back

* add breadcrumb back

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

* chore: more deps

* chore: add missing env vars to bun-ci

* next env
This commit is contained in:
Maze
2026-02-23 03:24:02 +01:00
committed by GitHub
parent fca99d6126
commit 93d1e3383c
215 changed files with 26980 additions and 8364 deletions
@@ -7,3 +7,4 @@ export * from "./v0";
export * from "./v1";
export * from "./v2";
export * from "./v3";
export * from "./v5";
@@ -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",
},
],
};
@@ -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");
});
});
@@ -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");
});
});
@@ -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 },
]);
});
});
@@ -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(),
];
@@ -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;
@@ -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";
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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 });
}
}
@@ -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 });
}
}
@@ -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 });
}
}