diff options
| author | Maze <167211895+mazeincoding@users.noreply.github.com> | 2026-02-23 03:24:02 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-02-23 03:24:02 +0100 |
| commit | 93d1e3383ce47e45157d70f9ba6251239dae6bc2 (patch) | |
| tree | 1e33a2d7533b65f0e7c8927f03905a1a99937bd1 /apps/web/src/hooks/use-transform-handles.ts | |
| parent | fca99d6126c31fbb18ed9f1034cee6f940b040e8 (diff) | |
| download | opencut-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/hooks/use-transform-handles.ts')
| -rw-r--r-- | apps/web/src/hooks/use-transform-handles.ts | 331 |
1 files changed, 331 insertions, 0 deletions
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<HTMLCanvasElement | null>; +}) { + const editor = useEditor(); + const isShiftHeldRef = useShiftKey(); + const [activeHandle, setActiveHandle] = useState<HandleType | null>(null); + const [snapLines, setSnapLines] = useState<SnapLine[]>([]); + const snapLinesRef = useRef<SnapLine[]>([]); + const scaleStateRef = useRef<ScaleState | null>(null); + const rotationStateRef = useRef<RotationState | null>(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, + }; +} |
