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
@@ -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;
});
}
+157 -16
View File
@@ -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();
@@ -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) {
@@ -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,
}),
);
}
@@ -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 });
}
}
+25 -2
View File
@@ -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),
})) ?? [];