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 --- apps/web/src/core/managers/audio-manager.ts | 12 ++++-- apps/web/src/core/managers/commands.ts | 5 +++ apps/web/src/core/managers/project-manager.ts | 5 +++ apps/web/src/core/managers/scenes-manager.ts | 47 ++++++++++++++++++++++- apps/web/src/core/managers/timeline-manager.ts | 53 +++++++++++++++++++++++++- 5 files changed, 115 insertions(+), 7 deletions(-) (limited to 'apps/web/src/core/managers') 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 { + const command = new UpdateBookmarkCommand(time, updates); + this.editor.command.execute({ command }); + } + + async moveBookmark({ + fromTime, + toTime, + }: { + fromTime: number; + toTime: number; + }): Promise { + 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 { 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(); 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>; + }>; + }): 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, }: { -- cgit v1.3.1