summaryrefslogtreecommitdiff
path: root/apps/web/src/hooks/use-preview-interaction.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-preview-interaction.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-preview-interaction.ts')
-rw-r--r--apps/web/src/hooks/use-preview-interaction.ts328
1 files changed, 227 insertions, 101 deletions
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<HTMLCanvasElement | null>;
}) {
const editor = useEditor();
+ const isShiftHeldRef = useShiftKey();
const [isDragging, setIsDragging] = useState(false);
+ const [snapLines, setSnapLines] = useState<SnapLine[]>([]);
+ const [editingText, setEditingText] = useState<{
+ trackId: string;
+ elementId: string;
+ element: TextElement;
+ originalOpacity: number;
+ } | null>(null);
const dragStateRef = useRef<DragState | null>(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 canvasSize = editor.project.getActive().settings.canvasSize;
- 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) > MIN_DRAG_DISTANCE ||
+ Math.abs(deltaY) > MIN_DRAG_DISTANCE;
+ if (!hasMovement) {
+ setSnapLines([]);
+ return;
+ }
- for (const { trackId, elementId, initialTransform } of dragStateRef
- .current.elements) {
- const newPosition = {
- x: initialTransform.position.x + deltaX,
- y: initialTransform.position.y + deltaY,
- };
+ const firstElement = dragStateRef.current.elements[0];
+ const proposedPosition = {
+ x: firstElement.initialTransform.position.x + deltaX,
+ y: firstElement.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,
};
}