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 --- .../timeline/element/use-element-interaction.ts | 40 ++- .../hooks/timeline/element/use-element-resize.ts | 322 +++++++++++---------- .../timeline/element/use-element-selection.ts | 4 +- 3 files changed, 194 insertions(+), 172 deletions(-) (limited to 'apps/web/src/hooks/timeline/element') diff --git a/apps/web/src/hooks/timeline/element/use-element-interaction.ts b/apps/web/src/hooks/timeline/element/use-element-interaction.ts index c92d6722..79f1272c 100644 --- a/apps/web/src/hooks/timeline/element/use-element-interaction.ts +++ b/apps/web/src/hooks/timeline/element/use-element-interaction.ts @@ -7,10 +7,15 @@ import { type RefObject, } from "react"; import { useEditor } from "@/hooks/use-editor"; +import { useShiftKey } from "@/hooks/use-shift-key"; import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { + DRAG_THRESHOLD_PX, + TIMELINE_CONSTANTS, +} from "@/constants/timeline-constants"; import { snapTimeToFrame } from "@/lib/time"; import { computeDropTarget } from "@/lib/timeline/drop-utils"; +import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils"; import { generateUUID } from "@/utils/id"; import { useTimelineSnapping } from "@/hooks/timeline/use-timeline-snapping"; import type { @@ -21,8 +26,6 @@ import type { } from "@/types/timeline"; import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping"; -const DRAG_THRESHOLD_PX = 5; - interface UseElementInteractionProps { zoomLevel: number; timelineRef: RefObject; @@ -54,24 +57,6 @@ interface PendingDragState { clickOffsetTime: number; } -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), - ); -} - function getClickOffsetTime({ clientX, elementRect, @@ -175,6 +160,7 @@ export function useElementInteraction({ onSnapPointChange, }: UseElementInteractionProps) { const editor = useEditor(); + const isShiftHeldRef = useShiftKey(); const tracks = editor.timeline.getTracks(); const { snapElementEdge } = useTimelineSnapping(); const { @@ -230,7 +216,8 @@ export function useElementInteraction({ frameSnappedTime: number; movingElement: TimelineElement | null | undefined; }) => { - if (!snappingEnabled || !movingElement) { + const shouldSnap = snappingEnabled && !isShiftHeldRef.current; + if (!shouldSnap || !movingElement) { return { snappedTime: frameSnappedTime, snapPoint: null }; } @@ -268,7 +255,14 @@ export function useElementInteraction({ snapPoint: snapResult.snapPoint, }; }, - [snappingEnabled, editor.playback, snapElementEdge, tracks, zoomLevel], + [ + snappingEnabled, + editor.playback, + snapElementEdge, + tracks, + zoomLevel, + isShiftHeldRef, + ], ); useEffect(() => { diff --git a/apps/web/src/hooks/timeline/element/use-element-resize.ts b/apps/web/src/hooks/timeline/element/use-element-resize.ts index 803d9788..14693fff 100644 --- a/apps/web/src/hooks/timeline/element/use-element-resize.ts +++ b/apps/web/src/hooks/timeline/element/use-element-resize.ts @@ -1,7 +1,9 @@ import { useState, useEffect, useRef, useCallback } from "react"; -import type { TimelineElement, TimelineTrack } from "@/types/timeline"; +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import { snapTimeToFrame } from "@/lib/time"; -import { EditorCore } from "@/core"; +import type { TimelineElement, TimelineTrack } from "@/types/timeline"; +import { useEditor } from "@/hooks/use-editor"; +import { useShiftKey } from "@/hooks/use-shift-key"; import { useTimelineSnapping, type SnapPoint, @@ -33,8 +35,9 @@ export function useTimelineElementResize({ onSnapPointChange, onResizeStateChange, }: UseTimelineElementResizeProps) { - const editor = EditorCore.getInstance(); + const editor = useEditor(); const activeProject = editor.project.getActive(); + const isShiftHeldRef = useShiftKey(); const snappingEnabled = useTimelineStore((state) => state.snappingEnabled); const { findSnapPoints, snapToNearestPoint } = useTimelineSnapping(); @@ -49,21 +52,21 @@ export function useTimelineElementResize({ const currentDurationRef = useRef(element.duration); const handleResizeStart = ({ - e, + event, elementId, side, }: { - e: React.MouseEvent; + event: React.MouseEvent; elementId: string; side: "left" | "right"; }) => { - e.stopPropagation(); - e.preventDefault(); + event.stopPropagation(); + event.preventDefault(); setResizing({ elementId, side, - startX: e.clientX, + startX: event.clientX, initialTrimStart: element.trimStart, initialTrimEnd: element.trimEnd, initialStartTime: element.startTime, @@ -89,104 +92,69 @@ export function useTimelineElementResize({ return false; }, [element.type]); - const updateTrimFromMouseMove = useCallback(({ clientX }: { clientX: number }) => { - if (!resizing) return; - - const deltaX = clientX - resizing.startX; - let deltaTime = deltaX / (50 * zoomLevel); - let resizeSnapPoint: SnapPoint | null = null; - - const projectFps = activeProject.settings.fps; - const minDurationSeconds = 1 / projectFps; - const canSnap = snappingEnabled; - if (canSnap) { - const tracks = editor.timeline.getTracks(); - const playheadTime = editor.playback.getCurrentTime(); - const snapPoints = findSnapPoints({ - tracks, - playheadTime, - excludeElementId: element.id, - }); - if (resizing.side === "left") { - const targetStartTime = resizing.initialStartTime + deltaTime; - const snapResult = snapToNearestPoint({ - targetTime: targetStartTime, - snapPoints, - zoomLevel, + const updateTrimFromMouseMove = useCallback( + ({ clientX }: { clientX: number }) => { + if (!resizing) return; + + const deltaX = clientX - resizing.startX; + let deltaTime = + deltaX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel); + let resizeSnapPoint: SnapPoint | null = null; + + const projectFps = activeProject.settings.fps; + const minDurationSeconds = 1 / projectFps; + const shouldSnap = snappingEnabled && !isShiftHeldRef.current; + if (shouldSnap) { + const tracks = editor.timeline.getTracks(); + const playheadTime = editor.playback.getCurrentTime(); + const snapPoints = findSnapPoints({ + tracks, + playheadTime, + excludeElementId: element.id, }); - resizeSnapPoint = snapResult.snapPoint; - if (snapResult.snapPoint) { - deltaTime = snapResult.snappedTime - resizing.initialStartTime; - } - } else { - const baseEndTime = - resizing.initialStartTime + resizing.initialDuration; - const targetEndTime = baseEndTime + deltaTime; - const snapResult = snapToNearestPoint({ - targetTime: targetEndTime, - snapPoints, - zoomLevel, - }); - resizeSnapPoint = snapResult.snapPoint; - if (snapResult.snapPoint) { - deltaTime = snapResult.snappedTime - baseEndTime; + if (resizing.side === "left") { + const targetStartTime = resizing.initialStartTime + deltaTime; + const snapResult = snapToNearestPoint({ + targetTime: targetStartTime, + snapPoints, + zoomLevel, + }); + resizeSnapPoint = snapResult.snapPoint; + if (snapResult.snapPoint) { + deltaTime = snapResult.snappedTime - resizing.initialStartTime; + } + } else { + const baseEndTime = + resizing.initialStartTime + resizing.initialDuration; + const targetEndTime = baseEndTime + deltaTime; + const snapResult = snapToNearestPoint({ + targetTime: targetEndTime, + snapPoints, + zoomLevel, + }); + resizeSnapPoint = snapResult.snapPoint; + if (snapResult.snapPoint) { + deltaTime = snapResult.snappedTime - baseEndTime; + } } } - } - onSnapPointChange?.(resizeSnapPoint); - - if (resizing.side === "left") { - const sourceDuration = - resizing.initialTrimStart + - resizing.initialDuration + - resizing.initialTrimEnd; - const maxAllowed = - sourceDuration - resizing.initialTrimEnd - minDurationSeconds; - const calculated = resizing.initialTrimStart + deltaTime; - - if (calculated >= 0 && calculated <= maxAllowed) { - const newTrimStart = snapTimeToFrame({ - time: Math.min(maxAllowed, calculated), - fps: projectFps, - }); - const trimDelta = newTrimStart - resizing.initialTrimStart; - const newStartTime = snapTimeToFrame({ - time: resizing.initialStartTime + trimDelta, - fps: projectFps, - }); - const newDuration = snapTimeToFrame({ - time: resizing.initialDuration - trimDelta, - fps: projectFps, - }); + onSnapPointChange?.(resizeSnapPoint); - setCurrentTrimStart(newTrimStart); - setCurrentStartTime(newStartTime); - setCurrentDuration(newDuration); - currentTrimStartRef.current = newTrimStart; - currentStartTimeRef.current = newStartTime; - currentDurationRef.current = newDuration; - } else if (calculated < 0) { - if (canExtendElementDuration()) { - const extensionAmount = Math.abs(calculated); - const maxExtension = resizing.initialStartTime; - const actualExtension = Math.min(extensionAmount, maxExtension); - const newStartTime = snapTimeToFrame({ - time: resizing.initialStartTime - actualExtension, - fps: projectFps, - }); - const newDuration = snapTimeToFrame({ - time: resizing.initialDuration + actualExtension, + if (resizing.side === "left") { + const sourceDuration = + resizing.initialTrimStart + + resizing.initialDuration + + resizing.initialTrimEnd; + const maxAllowed = + sourceDuration - resizing.initialTrimEnd - minDurationSeconds; + const calculated = resizing.initialTrimStart + deltaTime; + + if (calculated >= 0 && calculated <= maxAllowed) { + const newTrimStart = snapTimeToFrame({ + time: Math.min(maxAllowed, calculated), fps: projectFps, }); - - setCurrentTrimStart(0); - setCurrentStartTime(newStartTime); - setCurrentDuration(newDuration); - currentTrimStartRef.current = 0; - currentStartTimeRef.current = newStartTime; - currentDurationRef.current = newDuration; - } else { - const trimDelta = 0 - resizing.initialTrimStart; + const trimDelta = newTrimStart - resizing.initialTrimStart; const newStartTime = snapTimeToFrame({ time: resizing.initialStartTime + trimDelta, fps: projectFps, @@ -196,68 +164,119 @@ export function useTimelineElementResize({ fps: projectFps, }); - setCurrentTrimStart(0); + setCurrentTrimStart(newTrimStart); setCurrentStartTime(newStartTime); setCurrentDuration(newDuration); - currentTrimStartRef.current = 0; + currentTrimStartRef.current = newTrimStart; currentStartTimeRef.current = newStartTime; currentDurationRef.current = newDuration; + } else if (calculated < 0) { + if (canExtendElementDuration()) { + const extensionAmount = Math.abs(calculated); + const maxExtension = resizing.initialStartTime; + const actualExtension = Math.min(extensionAmount, maxExtension); + const newStartTime = snapTimeToFrame({ + time: resizing.initialStartTime - actualExtension, + fps: projectFps, + }); + const newDuration = snapTimeToFrame({ + time: resizing.initialDuration + actualExtension, + fps: projectFps, + }); + + setCurrentTrimStart(0); + setCurrentStartTime(newStartTime); + setCurrentDuration(newDuration); + currentTrimStartRef.current = 0; + currentStartTimeRef.current = newStartTime; + currentDurationRef.current = newDuration; + } else { + const trimDelta = 0 - resizing.initialTrimStart; + const newStartTime = snapTimeToFrame({ + time: resizing.initialStartTime + trimDelta, + fps: projectFps, + }); + const newDuration = snapTimeToFrame({ + time: resizing.initialDuration - trimDelta, + fps: projectFps, + }); + + setCurrentTrimStart(0); + setCurrentStartTime(newStartTime); + setCurrentDuration(newDuration); + currentTrimStartRef.current = 0; + currentStartTimeRef.current = newStartTime; + currentDurationRef.current = newDuration; + } } - } - } else { - const sourceDuration = - resizing.initialTrimStart + - resizing.initialDuration + - resizing.initialTrimEnd; - const newTrimEnd = resizing.initialTrimEnd - deltaTime; - - if (newTrimEnd < 0) { - if (canExtendElementDuration()) { - const extensionNeeded = Math.abs(newTrimEnd); - const baseDuration = - resizing.initialDuration + resizing.initialTrimEnd; - const newDuration = snapTimeToFrame({ - time: baseDuration + extensionNeeded, + } else { + const sourceDuration = + resizing.initialTrimStart + + resizing.initialDuration + + resizing.initialTrimEnd; + const newTrimEnd = resizing.initialTrimEnd - deltaTime; + + if (newTrimEnd < 0) { + if (canExtendElementDuration()) { + const extensionNeeded = Math.abs(newTrimEnd); + const baseDuration = + resizing.initialDuration + resizing.initialTrimEnd; + const newDuration = snapTimeToFrame({ + time: baseDuration + extensionNeeded, + fps: projectFps, + }); + + setCurrentDuration(newDuration); + setCurrentTrimEnd(0); + currentDurationRef.current = newDuration; + currentTrimEndRef.current = 0; + } else { + const extensionToLimit = resizing.initialTrimEnd; + const newDuration = snapTimeToFrame({ + time: resizing.initialDuration + extensionToLimit, + fps: projectFps, + }); + + setCurrentDuration(newDuration); + setCurrentTrimEnd(0); + currentDurationRef.current = newDuration; + currentTrimEndRef.current = 0; + } + } else { + const maxTrimEnd = + sourceDuration - resizing.initialTrimStart - minDurationSeconds; + const clampedTrimEnd = Math.min(maxTrimEnd, Math.max(0, newTrimEnd)); + const finalTrimEnd = snapTimeToFrame({ + time: clampedTrimEnd, fps: projectFps, }); - - setCurrentDuration(newDuration); - setCurrentTrimEnd(0); - currentDurationRef.current = newDuration; - currentTrimEndRef.current = 0; - } else { - const extensionToLimit = resizing.initialTrimEnd; + const trimDelta = finalTrimEnd - resizing.initialTrimEnd; const newDuration = snapTimeToFrame({ - time: resizing.initialDuration + extensionToLimit, + time: resizing.initialDuration - trimDelta, fps: projectFps, }); + setCurrentTrimEnd(finalTrimEnd); setCurrentDuration(newDuration); - setCurrentTrimEnd(0); + currentTrimEndRef.current = finalTrimEnd; currentDurationRef.current = newDuration; - currentTrimEndRef.current = 0; } - } else { - const maxTrimEnd = - sourceDuration - resizing.initialTrimStart - minDurationSeconds; - const clampedTrimEnd = Math.min(maxTrimEnd, Math.max(0, newTrimEnd)); - const finalTrimEnd = snapTimeToFrame({ - time: clampedTrimEnd, - fps: projectFps, - }); - const trimDelta = finalTrimEnd - resizing.initialTrimEnd; - const newDuration = snapTimeToFrame({ - time: resizing.initialDuration - trimDelta, - fps: projectFps, - }); - - setCurrentTrimEnd(finalTrimEnd); - setCurrentDuration(newDuration); - currentTrimEndRef.current = finalTrimEnd; - currentDurationRef.current = newDuration; } - } - }, [resizing, zoomLevel, activeProject.settings.fps, snappingEnabled, editor, findSnapPoints, snapToNearestPoint, element.id, onSnapPointChange, canExtendElementDuration]); + }, + [ + resizing, + zoomLevel, + activeProject.settings.fps, + snappingEnabled, + editor, + findSnapPoints, + snapToNearestPoint, + element.id, + onSnapPointChange, + canExtendElementDuration, + isShiftHeldRef, + ], + ); const handleResizeEnd = useCallback(() => { if (!resizing) return; @@ -297,7 +316,14 @@ export function useTimelineElementResize({ setResizing(null); onResizeStateChange?.({ isResizing: false }); onSnapPointChange?.(null); - }, [resizing, editor.timeline, element.id, track.id, onResizeStateChange, onSnapPointChange]); + }, [ + resizing, + editor.timeline, + element.id, + track.id, + onResizeStateChange, + onSnapPointChange, + ]); useEffect(() => { if (!resizing) return; diff --git a/apps/web/src/hooks/timeline/element/use-element-selection.ts b/apps/web/src/hooks/timeline/element/use-element-selection.ts index 38694c8b..c1e5b53f 100644 --- a/apps/web/src/hooks/timeline/element/use-element-selection.ts +++ b/apps/web/src/hooks/timeline/element/use-element-selection.ts @@ -21,7 +21,9 @@ export function useElementSelection() { const selectElement = useCallback( ({ trackId, elementId }: ElementRef) => { - editor.selection.setSelectedElements({ elements: [{ trackId, elementId }] }); + editor.selection.setSelectedElements({ + elements: [{ trackId, elementId }], + }); }, [editor], ); -- cgit v1.3.1