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 --- .../hooks/timeline/element/use-element-resize.ts | 322 +++++++++++---------- 1 file changed, 174 insertions(+), 148 deletions(-) (limited to 'apps/web/src/hooks/timeline/element/use-element-resize.ts') 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; -- cgit v1.3.1