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-transform-handles.ts | 331 ++++++++++++++++++++++++++++ 1 file changed, 331 insertions(+) create mode 100644 apps/web/src/hooks/use-transform-handles.ts (limited to 'apps/web/src/hooks/use-transform-handles.ts') diff --git a/apps/web/src/hooks/use-transform-handles.ts b/apps/web/src/hooks/use-transform-handles.ts new file mode 100644 index 00000000..37ef6d5f --- /dev/null +++ b/apps/web/src/hooks/use-transform-handles.ts @@ -0,0 +1,331 @@ +import { useCallback, useRef, useState } from "react"; +import { useEditor } from "@/hooks/use-editor"; +import { useShiftKey } from "@/hooks/use-shift-key"; +import { useSyncExternalStore } from "react"; +import { + getVisibleElementsWithBounds, + type ElementWithBounds, +} from "@/lib/preview/element-bounds"; +import { screenToCanvas } from "@/lib/preview/preview-coords"; +import { + MIN_SCALE, + snapRotation, + snapScale, + type SnapLine, +} from "@/lib/preview/preview-snap"; +import { isVisualElement } from "@/lib/timeline/element-utils"; +import type { Transform } from "@/types/timeline"; + +type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right"; +type HandleType = Corner | "rotation"; + +interface ScaleState { + trackId: string; + elementId: string; + initialTransform: Transform; + initialDistance: number; + initialBoundsCx: number; + initialBoundsCy: number; + baseWidth: number; + baseHeight: number; +} + +interface RotationState { + trackId: string; + elementId: string; + initialTransform: Transform; + initialAngle: number; + initialBoundsCx: number; + initialBoundsCy: number; +} + +function areSnapLinesEqual({ + previousLines, + nextLines, +}: { + previousLines: SnapLine[]; + nextLines: SnapLine[]; +}): boolean { + if (previousLines.length !== nextLines.length) { + return false; + } + for (const [index, line] of previousLines.entries()) { + const nextLine = nextLines[index]; + if (!nextLine) { + return false; + } + if (line.type !== nextLine.type || line.position !== nextLine.position) { + return false; + } + } + return true; +} + +function getCornerDistance({ + bounds, + corner, +}: { + bounds: { + cx: number; + cy: number; + width: number; + height: number; + rotation: number; + }; + corner: Corner; +}): number { + const halfW = bounds.width / 2; + const halfH = bounds.height / 2; + const angleRad = (bounds.rotation * Math.PI) / 180; + const cos = Math.cos(angleRad); + const sin = Math.sin(angleRad); + + const localX = + corner === "top-left" || corner === "bottom-left" ? -halfW : halfW; + const localY = + corner === "top-left" || corner === "top-right" ? -halfH : halfH; + + const rotatedX = localX * cos - localY * sin; + const rotatedY = localX * sin + localY * cos; + return Math.sqrt(rotatedX * rotatedX + rotatedY * rotatedY) || 1; +} + +export function useTransformHandles({ + canvasRef, +}: { + canvasRef: React.RefObject; +}) { + const editor = useEditor(); + const isShiftHeldRef = useShiftKey(); + const [activeHandle, setActiveHandle] = useState(null); + const [snapLines, setSnapLines] = useState([]); + const snapLinesRef = useRef([]); + const scaleStateRef = useRef(null); + const rotationStateRef = useRef(null); + + const selectedElements = useSyncExternalStore( + (listener) => editor.selection.subscribe(listener), + () => editor.selection.getSelectedElements(), + ); + + const tracks = editor.timeline.getTracks(); + const currentTime = editor.playback.getCurrentTime(); + const mediaAssets = editor.media.getAssets(); + const canvasSize = editor.project.getActive().settings.canvasSize; + + const elementsWithBounds = getVisibleElementsWithBounds({ + tracks, + currentTime, + canvasSize, + mediaAssets, + }); + + const selectedWithBounds: ElementWithBounds | null = + selectedElements.length === 1 + ? (elementsWithBounds.find( + (entry) => + entry.trackId === selectedElements[0].trackId && + entry.elementId === selectedElements[0].elementId, + ) ?? null) + : null; + + const hasVisualSelection = + selectedWithBounds !== null && isVisualElement(selectedWithBounds.element); + + const handleCornerPointerDown = useCallback( + ({ event, corner }: { event: React.PointerEvent; corner: Corner }) => { + if (!selectedWithBounds) return; + event.stopPropagation(); + + const { bounds, trackId, elementId, element } = selectedWithBounds; + if (!isVisualElement(element)) return; + + const initialDistance = getCornerDistance({ bounds, corner }); + const baseWidth = bounds.width / element.transform.scale; + const baseHeight = bounds.height / element.transform.scale; + + scaleStateRef.current = { + trackId, + elementId, + initialTransform: element.transform, + initialDistance, + initialBoundsCx: bounds.cx, + initialBoundsCy: bounds.cy, + baseWidth, + baseHeight, + }; + setActiveHandle(corner); + (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId); + }, + [selectedWithBounds], + ); + + const handleRotationPointerDown = useCallback( + ({ event }: { event: React.PointerEvent }) => { + if (!selectedWithBounds || !canvasRef.current) return; + event.stopPropagation(); + + const { bounds, trackId, elementId, element } = selectedWithBounds; + if (!isVisualElement(element)) return; + + const position = screenToCanvas({ + clientX: event.clientX, + clientY: event.clientY, + canvas: canvasRef.current, + }); + const dx = position.x - bounds.cx; + const dy = position.y - bounds.cy; + const initialAngle = (Math.atan2(dy, dx) * 180) / Math.PI; + + rotationStateRef.current = { + trackId, + elementId, + initialTransform: element.transform, + initialAngle, + initialBoundsCx: bounds.cx, + initialBoundsCy: bounds.cy, + }; + setActiveHandle("rotation"); + (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId); + }, + [selectedWithBounds, canvasRef], + ); + + const handlePointerMove = useCallback( + ({ event }: { event: React.PointerEvent }) => { + if (!canvasRef.current) return; + if (!scaleStateRef.current && !rotationStateRef.current) return; + + const position = screenToCanvas({ + clientX: event.clientX, + clientY: event.clientY, + canvas: canvasRef.current, + }); + + if ( + scaleStateRef.current && + activeHandle && + activeHandle !== "rotation" + ) { + const { + trackId, + elementId, + initialTransform, + initialDistance, + initialBoundsCx, + initialBoundsCy, + baseWidth, + baseHeight, + } = scaleStateRef.current; + + const dx = position.x - initialBoundsCx; + const dy = position.y - initialBoundsCy; + const currentDistance = Math.sqrt(dx * dx + dy * dy) || 1; + const scaleFactor = currentDistance / initialDistance; + const proposedScale = Math.max( + MIN_SCALE, + initialTransform.scale * scaleFactor, + ); + + const canvasSize = editor.project.getActive().settings.canvasSize; + const shouldSnap = !isShiftHeldRef.current; + const { snappedScale, activeLines } = shouldSnap + ? snapScale({ + proposedScale, + position: initialTransform.position, + baseWidth, + baseHeight, + canvasSize, + }) + : { snappedScale: proposedScale, activeLines: [] as SnapLine[] }; + + const isSameLines = areSnapLinesEqual({ + previousLines: snapLinesRef.current, + nextLines: activeLines, + }); + + if (!isSameLines) { + snapLinesRef.current = activeLines; + setSnapLines(activeLines); + } + + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId, + updates: { + transform: { ...initialTransform, scale: snappedScale }, + }, + }, + ], + }); + return; + } + + if (rotationStateRef.current && activeHandle === "rotation") { + const { + trackId, + elementId, + initialTransform, + initialAngle, + initialBoundsCx, + initialBoundsCy, + } = rotationStateRef.current; + + const dx = position.x - initialBoundsCx; + const dy = position.y - initialBoundsCy; + const currentAngle = (Math.atan2(dy, dx) * 180) / Math.PI; + let deltaAngle = currentAngle - initialAngle; + if (deltaAngle > 180) deltaAngle -= 360; + if (deltaAngle < -180) deltaAngle += 360; + const newRotate = initialTransform.rotate + deltaAngle; + const shouldSnapRotation = !isShiftHeldRef.current; + const { snappedRotation } = shouldSnapRotation + ? snapRotation({ proposedRotation: newRotate }) + : { snappedRotation: newRotate }; + + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId, + updates: { + transform: { ...initialTransform, rotate: snappedRotation }, + }, + }, + ], + }); + } + }, + [activeHandle, canvasRef, editor, isShiftHeldRef], + ); + + const handlePointerUp = useCallback( + ({ event }: { event: React.PointerEvent }) => { + if (scaleStateRef.current || rotationStateRef.current) { + editor.timeline.commitPreview(); + scaleStateRef.current = null; + rotationStateRef.current = null; + setActiveHandle(null); + snapLinesRef.current = []; + setSnapLines([]); + } + (event.currentTarget as HTMLElement).releasePointerCapture( + event.pointerId, + ); + }, + [editor], + ); + + return { + selectedWithBounds, + hasVisualSelection, + activeHandle, + snapLines, + handleCornerPointerDown, + handleRotationPointerDown, + handlePointerMove, + handlePointerUp, + }; +} -- cgit v1.3.1