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 --- .../panels/preview/bookmark-note-overlay.tsx | 59 +++++ .../editor/panels/preview/context-menu.tsx | 35 +++ .../src/components/editor/panels/preview/index.tsx | 181 ++++++------- .../editor/panels/preview/layout-guide-overlay.tsx | 27 ++ .../panels/preview/preview-interaction-overlay.tsx | 52 +++- .../editor/panels/preview/snap-guides.tsx | 65 +++++ .../editor/panels/preview/text-edit-overlay.tsx | 149 +++++++++++ .../components/editor/panels/preview/toolbar.tsx | 80 ++++++ .../editor/panels/preview/transform-handles.tsx | 283 +++++++++++++++++++++ 9 files changed, 813 insertions(+), 118 deletions(-) create mode 100644 apps/web/src/components/editor/panels/preview/bookmark-note-overlay.tsx create mode 100644 apps/web/src/components/editor/panels/preview/context-menu.tsx create mode 100644 apps/web/src/components/editor/panels/preview/layout-guide-overlay.tsx create mode 100644 apps/web/src/components/editor/panels/preview/snap-guides.tsx create mode 100644 apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx create mode 100644 apps/web/src/components/editor/panels/preview/toolbar.tsx create mode 100644 apps/web/src/components/editor/panels/preview/transform-handles.tsx (limited to 'apps/web/src/components/editor/panels/preview') diff --git a/apps/web/src/components/editor/panels/preview/bookmark-note-overlay.tsx b/apps/web/src/components/editor/panels/preview/bookmark-note-overlay.tsx new file mode 100644 index 00000000..4ace25ac --- /dev/null +++ b/apps/web/src/components/editor/panels/preview/bookmark-note-overlay.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { useEditor } from "@/hooks/use-editor"; +import { findCurrentScene } from "@/lib/scenes"; +import { getBookmarksActiveAtTime } from "@/lib/timeline/bookmarks"; + +export function BookmarkNoteOverlay() { + const editor = useEditor(); + const currentTime = editor.playback.getCurrentTime(); + const activeProject = editor.project.getActive(); + + if (!activeProject) { + return null; + } + + const activeScene = findCurrentScene({ + scenes: activeProject.scenes, + currentSceneId: activeProject.currentSceneId, + }); + + if (!activeScene) { + return null; + } + + const bookmarks = activeScene.bookmarks; + const activeBookmarks = getBookmarksActiveAtTime({ + bookmarks, + time: currentTime, + }); + const bookmarksWithNotes = activeBookmarks.filter( + (bookmark) => bookmark.note != null && bookmark.note.trim() !== "", + ); + + if (bookmarksWithNotes.length === 0) { + return null; + } + + return ( +
+ {bookmarksWithNotes.map((bookmark) => ( +
+ {bookmark.note} +
+ ))} +
+ ); +} diff --git a/apps/web/src/components/editor/panels/preview/context-menu.tsx b/apps/web/src/components/editor/panels/preview/context-menu.tsx new file mode 100644 index 00000000..d5cf535a --- /dev/null +++ b/apps/web/src/components/editor/panels/preview/context-menu.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { + ContextMenuCheckboxItem, + ContextMenuContent, + ContextMenuItem, +} from "@/components/ui/context-menu"; +import { usePreviewStore } from "@/stores/preview-store"; + +export function PreviewContextMenu({ + onToggleFullscreen, + containerRef, +}: { + onToggleFullscreen: () => void; + containerRef: React.RefObject; +}) { + const { overlays, setOverlayVisibility } = usePreviewStore(); + + return ( + + + Full screen + + + setOverlayVisibility({ overlay: "bookmarks", isVisible: !!checked }) + } + > + Show bookmarks + + Show grid + + ); +} diff --git a/apps/web/src/components/editor/panels/preview/index.tsx b/apps/web/src/components/editor/panels/preview/index.tsx index 902547bd..90cb11a3 100644 --- a/apps/web/src/components/editor/panels/preview/index.tsx +++ b/apps/web/src/components/editor/panels/preview/index.tsx @@ -9,18 +9,13 @@ import { useFullscreen } from "@/hooks/use-fullscreen"; import { CanvasRenderer } from "@/services/renderer/canvas-renderer"; import type { RootNode } from "@/services/renderer/nodes/root-node"; import { buildScene } from "@/services/renderer/scene-builder"; -import { formatTimeCode, getLastFrameTime } from "@/lib/time"; +import { getLastFrameTime } from "@/lib/time"; import { PreviewInteractionOverlay } from "./preview-interaction-overlay"; -import { EditableTimecode } from "@/components/editable-timecode"; -import { invokeAction } from "@/lib/actions"; -import { Button } from "@/components/ui/button"; -import { - FullScreenIcon, - PauseIcon, - PlayIcon, -} from "@hugeicons/core-free-icons"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { cn } from "@/utils/ui"; +import { BookmarkNoteOverlay } from "./bookmark-note-overlay"; +import { ContextMenu, ContextMenuTrigger } from "@/components/ui/context-menu"; +import { usePreviewStore } from "@/stores/preview-store"; +import { PreviewContextMenu } from "./context-menu"; +import { PreviewToolbar } from "./toolbar"; function usePreviewSize() { const editor = useEditor(); @@ -32,6 +27,30 @@ function usePreviewSize() { }; } +export function PreviewPanel() { + const containerRef = useRef(null); + const { isFullscreen, toggleFullscreen } = useFullscreen({ containerRef }); + + return ( +
+
+ + +
+ +
+ ); +} + function RenderTreeController() { const editor = useEditor(); const tracks = editor.timeline.getTracks(); @@ -58,98 +77,24 @@ function RenderTreeController() { return null; } -export function PreviewPanel() { - const containerRef = useRef(null); - const { isFullscreen, toggleFullscreen } = useFullscreen({ containerRef }); - - return ( -
-
- - -
- -
- ); -} - -function PreviewToolbar({ - isFullscreen, +function PreviewCanvas({ onToggleFullscreen, + containerRef, }: { - isFullscreen: boolean; onToggleFullscreen: () => void; + containerRef: React.RefObject; }) { - const editor = useEditor(); - const isPlaying = editor.playback.getIsPlaying(); - const currentTime = editor.playback.getCurrentTime(); - const totalDuration = editor.timeline.getTotalDuration(); - const fps = editor.project.getActive().settings.fps; - - return ( -
-
- editor.playback.seek({ time })} - className="text-center" - /> - / - - {formatTimeCode({ - timeInSeconds: totalDuration, - format: "HH:MM:SS:FF", - fps, - })} - -
- - - -
- -
-
- ); -} - -function PreviewCanvas() { const canvasRef = useRef(null); - const containerRef = useRef(null); + const outerContainerRef = useRef(null); + const canvasBoundsRef = useRef(null); const lastFrameRef = useRef(-1); const lastSceneRef = useRef(null); const renderingRef = useRef(false); const { width: nativeWidth, height: nativeHeight } = usePreviewSize(); - const containerSize = useContainerSize({ containerRef }); + const containerSize = useContainerSize({ containerRef: outerContainerRef }); const editor = useEditor(); const activeProject = editor.project.getActive(); + const { overlays } = usePreviewStore(); const renderer = useMemo(() => { return new CanvasRenderer({ @@ -224,24 +169,42 @@ function PreviewCanvas() { return (
- - + + +
+ + + {overlays.bookmarks && } +
+
+ +
); } diff --git a/apps/web/src/components/editor/panels/preview/layout-guide-overlay.tsx b/apps/web/src/components/editor/panels/preview/layout-guide-overlay.tsx new file mode 100644 index 00000000..44efb4c8 --- /dev/null +++ b/apps/web/src/components/editor/panels/preview/layout-guide-overlay.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { usePreviewStore } from "@/stores/preview-store"; +import Image from "next/image"; + +function TikTokGuide() { + return ( +
+ TikTok layout guide +
+ ); +} + +export function LayoutGuideOverlay() { + const { layoutGuide } = usePreviewStore(); + + if (layoutGuide.platform === null) return null; + if (layoutGuide.platform === "tiktok") return ; + + return null; +} diff --git a/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx b/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx index c13379f0..4c33a1a1 100644 --- a/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx +++ b/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx @@ -1,20 +1,54 @@ import { usePreviewInteraction } from "@/hooks/use-preview-interaction"; -import { cn } from "@/utils/ui"; +import { TransformHandles } from "./transform-handles"; +import { SnapGuides } from "./snap-guides"; +import { TextEditOverlay } from "./text-edit-overlay"; export function PreviewInteractionOverlay({ canvasRef, + containerRef, }: { canvasRef: React.RefObject; + containerRef: React.RefObject; }) { - const { onPointerDown, onPointerMove, onPointerUp } = - usePreviewInteraction({ canvasRef }); + const { + onPointerDown, + onPointerMove, + onPointerUp, + onDoubleClick, + snapLines, + editingText, + commitTextEdit, + cancelTextEdit, + } = usePreviewInteraction({ canvasRef }); return ( -
+
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: canvas overlay, pointer-only interaction */} +
+ {editingText ? ( + + ) : ( + + )} + +
); } diff --git a/apps/web/src/components/editor/panels/preview/snap-guides.tsx b/apps/web/src/components/editor/panels/preview/snap-guides.tsx new file mode 100644 index 00000000..4aa714a1 --- /dev/null +++ b/apps/web/src/components/editor/panels/preview/snap-guides.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { useEditor } from "@/hooks/use-editor"; +import type { SnapLine } from "@/lib/preview/preview-snap"; +import { positionToOverlay } from "@/lib/preview/preview-coords"; + +export function SnapGuides({ + lines, + canvasRef, + containerRef, +}: { + lines: SnapLine[]; + canvasRef: React.RefObject; + containerRef: React.RefObject; +}) { + const editor = useEditor(); + const canvasSize = editor.project.getActive().settings.canvasSize; + const canvasRect = canvasRef.current?.getBoundingClientRect(); + const containerRect = containerRef.current?.getBoundingClientRect(); + + if (!canvasRect || !containerRect || lines.length === 0) { + return null; + } + + const toOverlayX = (logicalX: number) => + positionToOverlay({ + positionX: logicalX, + positionY: 0, + canvasRect, + containerRect, + canvasSize, + }).x; + + const toOverlayY = (logicalY: number) => + positionToOverlay({ + positionX: 0, + positionY: logicalY, + canvasRect, + containerRect, + canvasSize, + }).y; + + return ( +
+ {lines.map((line) => { + if (line.type === "vertical") { + return ( +
+ ); + } + return ( +
+ ); + })} +
+ ); +} diff --git a/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx b/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx new file mode 100644 index 00000000..265e972d --- /dev/null +++ b/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx @@ -0,0 +1,149 @@ +"use client"; + +import { useCallback, useEffect, useRef } from "react"; +import { useEditor } from "@/hooks/use-editor"; +import type { TextElement } from "@/types/timeline"; +import { + positionToOverlay, + getDisplayScale, +} from "@/lib/preview/preview-coords"; +import { + DEFAULT_LINE_HEIGHT, + FONT_SIZE_SCALE_REFERENCE, +} from "@/constants/text-constants"; + +const TEXT_BACKGROUND_PADDING = "4px 8px"; +const TEXT_EDIT_VERTICAL_OFFSET_EM = 0.06; + +export function TextEditOverlay({ + canvasRef, + containerRef, + trackId, + elementId, + element, + onCommit, + onCancel, +}: { + canvasRef: React.RefObject; + containerRef: React.RefObject; + trackId: string; + elementId: string; + element: TextElement; + onCommit: () => void; + onCancel: () => void; +}) { + const editor = useEditor(); + const divRef = useRef(null); + + useEffect(() => { + const div = divRef.current; + if (!div) return; + div.focus(); + const range = document.createRange(); + range.selectNodeContents(div); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + }, []); + + const handleInput = useCallback(() => { + const div = divRef.current; + if (!div) return; + const text = div.innerText; + editor.timeline.previewElements({ + updates: [{ trackId, elementId, updates: { content: text } }], + }); + }, [editor.timeline, trackId, elementId]); + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + const { key } = event; + if (key === "Escape") { + event.preventDefault(); + onCancel(); + return; + } + }, + [onCancel], + ); + + const canvasRect = canvasRef.current?.getBoundingClientRect(); + const containerRect = containerRef.current?.getBoundingClientRect(); + const canvasSize = editor.project.getActive().settings.canvasSize; + + if (!canvasRect || !containerRect || !canvasSize) return null; + + const { x: posX, y: posY } = positionToOverlay({ + positionX: element.transform.position.x, + positionY: element.transform.position.y, + canvasRect, + containerRect, + canvasSize, + }); + + const { x: displayScaleX } = getDisplayScale({ + canvasRect, + canvasSize, + }); + + const displayFontSize = + element.fontSize * + (canvasSize.height / FONT_SIZE_SCALE_REFERENCE) * + displayScaleX; + + const verticalAlignmentOffset = + displayFontSize * TEXT_EDIT_VERTICAL_OFFSET_EM; + + const lineHeight = element.lineHeight ?? DEFAULT_LINE_HEIGHT; + const fontWeight = element.fontWeight === "bold" ? "bold" : "normal"; + const fontStyle = element.fontStyle === "italic" ? "italic" : "normal"; + const letterSpacing = element.letterSpacing ?? 0; + const backgroundColor = + element.backgroundColor === "transparent" + ? "transparent" + : element.backgroundColor; + + return ( +
+ {/* biome-ignore lint/a11y/useSemanticElements: contenteditable required for multiline, IME, paste */} +
+ {element.content || ""} +
+
+ ); +} diff --git a/apps/web/src/components/editor/panels/preview/toolbar.tsx b/apps/web/src/components/editor/panels/preview/toolbar.tsx new file mode 100644 index 00000000..7f5cd9bc --- /dev/null +++ b/apps/web/src/components/editor/panels/preview/toolbar.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { useEditor } from "@/hooks/use-editor"; +import { formatTimeCode } from "@/lib/time"; +import { invokeAction } from "@/lib/actions"; +import { EditableTimecode } from "@/components/editable-timecode"; +import { Button } from "@/components/ui/button"; +import { + FullScreenIcon, + PauseIcon, + PlayIcon, +} from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { OcSocialIcon } from "@opencut/ui/icons"; +import { Separator } from "@/components/ui/separator"; + +export function PreviewToolbar({ + isFullscreen, + onToggleFullscreen, +}: { + isFullscreen: boolean; + onToggleFullscreen: () => void; +}) { + const editor = useEditor(); + const isPlaying = editor.playback.getIsPlaying(); + const currentTime = editor.playback.getCurrentTime(); + const totalDuration = editor.timeline.getTotalDuration(); + const fps = editor.project.getActive().settings.fps; + + return ( +
+
+ editor.playback.seek({ time })} + className="text-center" + /> + / + + {formatTimeCode({ + timeInSeconds: totalDuration, + format: "HH:MM:SS:FF", + fps, + })} + +
+ + + +
+ + + +
+
+ ); +} diff --git a/apps/web/src/components/editor/panels/preview/transform-handles.tsx b/apps/web/src/components/editor/panels/preview/transform-handles.tsx new file mode 100644 index 00000000..976c3019 --- /dev/null +++ b/apps/web/src/components/editor/panels/preview/transform-handles.tsx @@ -0,0 +1,283 @@ +"use client"; + +import { useTransformHandles } from "@/hooks/use-transform-handles"; +import { useEditor } from "@/hooks/use-editor"; +import { isVisualElement } from "@/lib/timeline/element-utils"; +import { SnapGuides } from "./snap-guides"; +import { canvasToOverlay, getDisplayScale } from "@/lib/preview/preview-coords"; +import type { ElementBounds } from "@/lib/preview/element-bounds"; +import { cn } from "@/utils/ui"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { Rotate01Icon } from "@hugeicons/core-free-icons"; + +const HANDLE_SIZE = 10; +const ROTATION_HANDLE_OFFSET = 24; +const ROTATION_HANDLE_RADIUS = 10; +const CORNER_HIT_AREA_SIZE = 18; + +type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right"; +const CORNERS: Corner[] = [ + "top-left", + "top-right", + "bottom-left", + "bottom-right", +]; + +function getCornerPosition({ + bounds, + corner, +}: { + bounds: ElementBounds; + corner: Corner; +}): { x: number; y: 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; + + return { + x: bounds.cx + (localX * cos - localY * sin), + y: bounds.cy + (localX * sin + localY * cos), + }; +} + +function getRotationHandlePosition({ bounds }: { bounds: ElementBounds }): { + x: number; + y: number; +} { + const angleRad = (bounds.rotation * Math.PI) / 180; + const cos = Math.cos(angleRad); + const sin = Math.sin(angleRad); + const localY = -bounds.height / 2 - ROTATION_HANDLE_OFFSET; + return { + x: bounds.cx - localY * sin, + y: bounds.cy + localY * cos, + }; +} + +function getOverlayContext({ + canvasRef, + containerRef, +}: { + canvasRef: React.RefObject; + containerRef: React.RefObject; +}): { + canvasRect: DOMRect; + containerRect: DOMRect; +} | null { + const canvasRect = canvasRef.current?.getBoundingClientRect(); + const containerRect = containerRef.current?.getBoundingClientRect(); + if (!canvasRect || !containerRect) return null; + return { canvasRect, containerRect }; +} + +export function TransformHandles({ + canvasRef, + containerRef, +}: { + canvasRef: React.RefObject; + containerRef: React.RefObject; +}) { + const { + selectedWithBounds, + hasVisualSelection, + snapLines, + handleCornerPointerDown, + handleRotationPointerDown, + handlePointerMove, + handlePointerUp, + } = useTransformHandles({ canvasRef }); + + const editor = useEditor(); + const canvasSize = editor.project.getActive().settings.canvasSize; + + if (!hasVisualSelection || !selectedWithBounds) return null; + + const overlayContext = getOverlayContext({ canvasRef, containerRef }); + if (!overlayContext) return null; + + const { bounds, element } = selectedWithBounds; + if (!isVisualElement(element)) return null; + + const { canvasRect, containerRect } = overlayContext; + const displayScale = getDisplayScale({ canvasRect, canvasSize }); + + const toOverlay = ({ + canvasX, + canvasY, + }: { + canvasX: number; + canvasY: number; + }) => + canvasToOverlay({ + canvasX, + canvasY, + canvasRect, + containerRect, + canvasSize, + }); + + const center = toOverlay({ canvasX: bounds.cx, canvasY: bounds.cy }); + const outlineWidth = bounds.width * displayScale.x; + const outlineHeight = bounds.height * displayScale.y; + + const rotationHandle = getRotationHandlePosition({ bounds }); + const rotationHandleScreen = toOverlay({ + canvasX: rotationHandle.x, + canvasY: rotationHandle.y, + }); + + return ( +
+ + + {CORNERS.map((corner) => { + const cornerPosition = getCornerPosition({ bounds, corner }); + const screen = toOverlay({ + canvasX: cornerPosition.x, + canvasY: cornerPosition.y, + }); + return ( + + handleCornerPointerDown({ event, corner }) + } + onPointerMove={(event) => handlePointerMove({ event })} + onPointerUp={(event) => handlePointerUp({ event })} + /> + ); + })} + handleRotationPointerDown({ event })} + onPointerMove={(event) => handlePointerMove({ event })} + onPointerUp={(event) => handlePointerUp({ event })} + /> +
+ ); +} + +function BoundingBoxOutline({ + center, + outlineWidth, + outlineHeight, + rotation, +}: { + center: { x: number; y: number }; + outlineWidth: number; + outlineHeight: number; + rotation: number; +}) { + return ( +
+ ); +} + +function CornerHandle({ + corner, + screen, + onPointerDown, + onPointerMove, + onPointerUp, +}: { + corner: Corner; + screen: { x: number; y: number }; + onPointerDown: (event: React.PointerEvent) => void; + onPointerMove: (event: React.PointerEvent) => void; + onPointerUp: (event: React.PointerEvent) => void; +}) { + const isNesw = corner === "top-right" || corner === "bottom-left"; + + return ( + + ); +} + +function RotationHandle({ + screen, + onPointerDown, + onPointerMove, + onPointerUp, +}: { + screen: { x: number; y: number }; + onPointerDown: (event: React.PointerEvent) => void; + onPointerMove: (event: React.PointerEvent) => void; + onPointerUp: (event: React.PointerEvent) => void; +}) { + return ( + + ); +} -- cgit v1.3.1