summaryrefslogtreecommitdiff
path: root/apps/web/src/lib/timeline
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/src/lib/timeline
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/src/lib/timeline')
-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
3 files changed, 204 insertions, 23 deletions
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];
+}