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/hooks/use-preview-interaction.ts | 330 ++++++++++++++++++-------- 1 file changed, 228 insertions(+), 102 deletions(-) (limited to 'apps/web/src/hooks/use-preview-interaction.ts') diff --git a/apps/web/src/hooks/use-preview-interaction.ts b/apps/web/src/hooks/use-preview-interaction.ts index 31b33504..2bb33f21 100644 --- a/apps/web/src/hooks/use-preview-interaction.ts +++ b/apps/web/src/hooks/use-preview-interaction.ts @@ -1,12 +1,22 @@ -import { useCallback, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useEditor } from "@/hooks/use-editor"; -import { useSyncExternalStore } from "react"; -import type { Transform, TimelineTrack } from "@/types/timeline"; +import { useShiftKey } from "@/hooks/use-shift-key"; +import type { TextElement, Transform } from "@/types/timeline"; +import { getVisibleElementsWithBounds } from "@/lib/preview/element-bounds"; +import { hitTest } from "@/lib/preview/hit-test"; +import { screenToCanvas } from "@/lib/preview/preview-coords"; +import { isVisualElement } from "@/lib/timeline/element-utils"; +import { snapPosition, type SnapLine } from "@/lib/preview/preview-snap"; + +const MIN_DRAG_DISTANCE = 0.5; interface DragState { startX: number; startY: number; - tracksSnapshot: TimelineTrack[]; + bounds: { + width: number; + height: number; + }; elements: Array<{ trackId: string; elementId: string; @@ -20,59 +30,164 @@ export function usePreviewInteraction({ canvasRef: React.RefObject; }) { const editor = useEditor(); + const isShiftHeldRef = useShiftKey(); const [isDragging, setIsDragging] = useState(false); + const [snapLines, setSnapLines] = useState([]); + const [editingText, setEditingText] = useState<{ + trackId: string; + elementId: string; + element: TextElement; + originalOpacity: number; + } | null>(null); const dragStateRef = useRef(null); + const wasPlayingRef = useRef(editor.playback.getIsPlaying()); + const editingTextRef = useRef(editingText); + editingTextRef.current = editingText; - const selectedElements = useSyncExternalStore( - (listener) => editor.selection.subscribe(listener), - () => editor.selection.getSelectedElements(), - ); + const commitTextEdit = useCallback(() => { + const current = editingTextRef.current; + if (!current) return; + editor.timeline.previewElements({ + updates: [ + { + trackId: current.trackId, + elementId: current.elementId, + updates: { opacity: current.originalOpacity }, + }, + ], + }); + editor.timeline.commitPreview(); + setEditingText(null); + }, [editor.timeline]); - const getCanvasCoordinates = useCallback( - ({ clientX, clientY }: { clientX: number; clientY: number }) => { - if (!canvasRef.current) return { x: 0, y: 0 }; + const cancelTextEdit = useCallback(() => { + editor.timeline.discardPreview(); + setEditingText(null); + }, [editor.timeline]); + + useEffect(() => { + const unsubscribe = editor.playback.subscribe(() => { + const isPlaying = editor.playback.getIsPlaying(); + if (isPlaying && !wasPlayingRef.current && editingTextRef.current) { + commitTextEdit(); + } + wasPlayingRef.current = isPlaying; + }); + return unsubscribe; + }, [editor.playback, commitTextEdit]); - const rect = canvasRef.current.getBoundingClientRect(); - const logicalWidth = canvasRef.current.width; - const logicalHeight = canvasRef.current.height; - const scaleX = logicalWidth / rect.width; - const scaleY = logicalHeight / rect.height; + const handleDoubleClick = useCallback( + ({ clientX, clientY }: React.MouseEvent) => { + if (!canvasRef.current || editingText) return; + + const tracks = editor.timeline.getTracks(); + const currentTime = editor.playback.getCurrentTime(); + const mediaAssets = editor.media.getAssets(); + const canvasSize = editor.project.getActive().settings.canvasSize; + + const startPos = screenToCanvas({ + clientX, + clientY, + canvas: canvasRef.current, + }); + + const elementsWithBounds = getVisibleElementsWithBounds({ + tracks, + currentTime, + canvasSize, + mediaAssets, + }); + + const hit = hitTest({ + canvasX: startPos.x, + canvasY: startPos.y, + elementsWithBounds, + }); - const canvasX = (clientX - rect.left) * scaleX; - const canvasY = (clientY - rect.top) * scaleY; + if (!hit || hit.element.type !== "text") return; - return { x: canvasX, y: canvasY }; + const textElement = hit.element as TextElement; + editor.timeline.previewElements({ + updates: [ + { + trackId: hit.trackId, + elementId: hit.elementId, + updates: { opacity: 0 }, + }, + ], + }); + setEditingText({ + trackId: hit.trackId, + elementId: hit.elementId, + element: textElement, + originalOpacity: textElement.opacity, + }); }, - [canvasRef], + [canvasRef, editor, editingText], ); const handlePointerDown = useCallback( - (event: React.PointerEvent) => { - if (selectedElements.length === 0) return; + ({ + clientX, + clientY, + currentTarget, + pointerId, + button, + }: React.PointerEvent) => { + if (!canvasRef.current) return; + if (editingText) return; + if (button !== 0) return; + + const tracks = editor.timeline.getTracks(); + const currentTime = editor.playback.getCurrentTime(); + const mediaAssets = editor.media.getAssets(); + const canvasSize = editor.project.getActive().settings.canvasSize; + + const startPos = screenToCanvas({ + clientX, + clientY, + canvas: canvasRef.current, + }); + + const elementsWithBounds = getVisibleElementsWithBounds({ + tracks, + currentTime, + canvasSize, + mediaAssets, + }); + + const hit = hitTest({ + canvasX: startPos.x, + canvasY: startPos.y, + elementsWithBounds, + }); + + if (!hit) { + editor.selection.clearSelection(); + return; + } + + editor.selection.setSelectedElements({ + elements: [{ trackId: hit.trackId, elementId: hit.elementId }], + }); const elementsWithTracks = editor.timeline.getElementsWithTracks({ - elements: selectedElements, + elements: [{ trackId: hit.trackId, elementId: hit.elementId }], }); - const draggableElements = elementsWithTracks.filter( - ({ element }) => - element.type === "video" || - element.type === "image" || - element.type === "text" || - element.type === "sticker", + const draggableElements = elementsWithTracks.filter(({ element }) => + isVisualElement(element), ); if (draggableElements.length === 0) return; - const startPos = getCanvasCoordinates({ - clientX: event.clientX, - clientY: event.clientY, - }); - dragStateRef.current = { startX: startPos.x, startY: startPos.y, - tracksSnapshot: editor.timeline.getTracks(), + bounds: { + width: hit.bounds.width, + height: hit.bounds.height, + }, elements: draggableElements.map(({ track, element }) => ({ trackId: track.id, elementId: element.id, @@ -81,107 +196,118 @@ export function usePreviewInteraction({ }; setIsDragging(true); - event.currentTarget.setPointerCapture(event.pointerId); + currentTarget.setPointerCapture(pointerId); }, - [selectedElements, editor, getCanvasCoordinates], + [editor, canvasRef, editingText], ); const handlePointerMove = useCallback( - (event: React.PointerEvent) => { - if (!dragStateRef.current || !isDragging) return; + ({ clientX, clientY }: React.PointerEvent) => { + if (!dragStateRef.current || !isDragging || !canvasRef.current) return; - const currentPos = getCanvasCoordinates({ - clientX: event.clientX, - clientY: event.clientY, + const canvasSize = editor.project.getActive().settings.canvasSize; + + const currentPos = screenToCanvas({ + clientX, + clientY, + canvas: canvasRef.current, }); const deltaX = currentPos.x - dragStateRef.current.startX; const deltaY = currentPos.y - dragStateRef.current.startY; + const hasMovement = + Math.abs(deltaX) > MIN_DRAG_DISTANCE || + Math.abs(deltaY) > MIN_DRAG_DISTANCE; + if (!hasMovement) { + setSnapLines([]); + return; + } + + const firstElement = dragStateRef.current.elements[0]; + const proposedPosition = { + x: firstElement.initialTransform.position.x + deltaX, + y: firstElement.initialTransform.position.y + deltaY, + }; - for (const { trackId, elementId, initialTransform } of dragStateRef - .current.elements) { - const newPosition = { - x: initialTransform.position.x + deltaX, - y: initialTransform.position.y + deltaY, - }; - - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId, - updates: { - transform: { - ...initialTransform, - position: newPosition, - }, + const shouldSnap = !isShiftHeldRef.current; + const { snappedPosition, activeLines } = shouldSnap + ? snapPosition({ + proposedPosition, + canvasSize, + elementSize: dragStateRef.current.bounds, + }) + : { + snappedPosition: proposedPosition, + activeLines: [] as SnapLine[], + }; + + setSnapLines(activeLines); + + const deltaSnappedX = + snappedPosition.x - firstElement.initialTransform.position.x; + const deltaSnappedY = + snappedPosition.y - firstElement.initialTransform.position.y; + + const updates = dragStateRef.current.elements.map( + ({ trackId, elementId, initialTransform }) => ({ + trackId, + elementId, + updates: { + transform: { + ...initialTransform, + position: { + x: initialTransform.position.x + deltaSnappedX, + y: initialTransform.position.y + deltaSnappedY, }, }, - ], - pushHistory: false, - }); - } + }, + }), + ); + + editor.timeline.previewElements({ updates }); }, - [isDragging, getCanvasCoordinates, editor], + [isDragging, canvasRef, editor, isShiftHeldRef], ); const handlePointerUp = useCallback( - (event: React.PointerEvent) => { - if (!dragStateRef.current || !isDragging) return; + ({ clientX, clientY, currentTarget, pointerId }: React.PointerEvent) => { + if (!dragStateRef.current || !isDragging || !canvasRef.current) return; - const currentPos = getCanvasCoordinates({ - clientX: event.clientX, - clientY: event.clientY, + const currentPos = screenToCanvas({ + clientX, + clientY, + canvas: canvasRef.current, }); const deltaX = currentPos.x - dragStateRef.current.startX; const deltaY = currentPos.y - dragStateRef.current.startY; - const hasMovement = Math.abs(deltaX) > 0.5 || Math.abs(deltaY) > 0.5; - + const hasMovement = + Math.abs(deltaX) > MIN_DRAG_DISTANCE || + Math.abs(deltaY) > MIN_DRAG_DISTANCE; if (!hasMovement) { - dragStateRef.current = null; - setIsDragging(false); - event.currentTarget.releasePointerCapture(event.pointerId); - return; + editor.timeline.discardPreview(); + } else { + editor.timeline.commitPreview(); } - // revert to pre-drag state so the command captures the correct undo snapshot - editor.timeline.updateTracks(dragStateRef.current.tracksSnapshot); - - const updates = dragStateRef.current.elements.map( - ({ trackId, elementId, initialTransform }) => { - const newPosition = { - x: initialTransform.position.x + deltaX, - y: initialTransform.position.y + deltaY, - }; - - return { - trackId, - elementId, - updates: { - transform: { - ...initialTransform, - position: newPosition, - }, - }, - }; - }, - ); - - editor.timeline.updateElements({ updates }); - dragStateRef.current = null; setIsDragging(false); - event.currentTarget.releasePointerCapture(event.pointerId); + setSnapLines([]); + currentTarget.releasePointerCapture(pointerId); }, - [isDragging, getCanvasCoordinates, editor], + [isDragging, canvasRef, editor], ); return { onPointerDown: handlePointerDown, onPointerMove: handlePointerMove, onPointerUp: handlePointerUp, + onDoubleClick: handleDoubleClick, + snapLines, + editingText, + commitTextEdit, + cancelTextEdit, }; } -- cgit v1.3.1