summaryrefslogtreecommitdiff
path: root/apps/web/src/hooks/use-transform-handles.ts
diff options
context:
space:
mode:
authorMaze <167211895+mazeincoding@users.noreply.github.com>2026-02-23 03:24:02 +0100
committerGitHub <noreply@github.com>2026-02-23 03:24:02 +0100
commit93d1e3383ce47e45157d70f9ba6251239dae6bc2 (patch)
tree1e33a2d7533b65f0e7c8927f03905a1a99937bd1 /apps/web/src/hooks/use-transform-handles.ts
parentfca99d6126c31fbb18ed9f1034cee6f940b040e8 (diff)
downloadopencut-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.ts331
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,
+ };
+}