summaryrefslogtreecommitdiff
path: root/apps/web/src/components/editor/panels/preview
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/components/editor/panels/preview
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/components/editor/panels/preview')
-rw-r--r--apps/web/src/components/editor/panels/preview/bookmark-note-overlay.tsx59
-rw-r--r--apps/web/src/components/editor/panels/preview/context-menu.tsx35
-rw-r--r--apps/web/src/components/editor/panels/preview/index.tsx181
-rw-r--r--apps/web/src/components/editor/panels/preview/layout-guide-overlay.tsx27
-rw-r--r--apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx52
-rw-r--r--apps/web/src/components/editor/panels/preview/snap-guides.tsx65
-rw-r--r--apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx149
-rw-r--r--apps/web/src/components/editor/panels/preview/toolbar.tsx80
-rw-r--r--apps/web/src/components/editor/panels/preview/transform-handles.tsx283
9 files changed, 813 insertions, 118 deletions
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 (
+ <div
+ className="pointer-events-none absolute top-2 left-2 flex flex-col gap-1.5"
+ aria-live="polite"
+ >
+ {bookmarksWithNotes.map((bookmark) => (
+ <div
+ key={bookmark.time}
+ className="flex max-w-[min(200px,50vw)] px-2.5 py-1.5 text-left text-white text-xs shadow-md backdrop-blur-sm"
+ style={{
+ backgroundColor: "rgb(0 0 0 / 0.5)",
+ borderLeft: bookmark.color
+ ? `3px solid ${bookmark.color}`
+ : "3px solid var(--primary)",
+ }}
+ >
+ {bookmark.note}
+ </div>
+ ))}
+ </div>
+ );
+}
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<HTMLElement | null>;
+}) {
+ const { overlays, setOverlayVisibility } = usePreviewStore();
+
+ return (
+ <ContextMenuContent className="w-56" container={containerRef.current}>
+ <ContextMenuItem onClick={onToggleFullscreen} inset>
+ Full screen
+ </ContextMenuItem>
+ <ContextMenuCheckboxItem
+ checked={overlays.bookmarks}
+ onCheckedChange={(checked) =>
+ setOverlayVisibility({ overlay: "bookmarks", isVisible: !!checked })
+ }
+ >
+ Show bookmarks
+ </ContextMenuCheckboxItem>
+ <ContextMenuItem inset>Show grid</ContextMenuItem>
+ </ContextMenuContent>
+ );
+}
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<HTMLDivElement>(null);
+ const { isFullscreen, toggleFullscreen } = useFullscreen({ containerRef });
+
+ return (
+ <div
+ ref={containerRef}
+ className="panel bg-background relative flex size-full min-h-0 min-w-0 flex-col rounded-sm border"
+ >
+ <div className="flex min-h-0 min-w-0 flex-1 items-center justify-center p-2 pb-0">
+ <PreviewCanvas
+ onToggleFullscreen={toggleFullscreen}
+ containerRef={containerRef}
+ />
+ <RenderTreeController />
+ </div>
+ <PreviewToolbar
+ isFullscreen={isFullscreen}
+ onToggleFullscreen={toggleFullscreen}
+ />
+ </div>
+ );
+}
+
function RenderTreeController() {
const editor = useEditor();
const tracks = editor.timeline.getTracks();
@@ -58,98 +77,24 @@ function RenderTreeController() {
return null;
}
-export function PreviewPanel() {
- const containerRef = useRef<HTMLDivElement>(null);
- const { isFullscreen, toggleFullscreen } = useFullscreen({ containerRef });
-
- return (
- <div
- ref={containerRef}
- className={cn(
- "panel bg-background relative flex h-full min-h-0 w-full min-w-0 flex-col rounded-sm border",
- isFullscreen && "bg-background",
- )}
- >
- <div className="flex min-h-0 min-w-0 flex-1 items-center justify-center p-2 pb-0">
- <PreviewCanvas />
- <RenderTreeController />
- </div>
- <PreviewToolbar
- isFullscreen={isFullscreen}
- onToggleFullscreen={toggleFullscreen}
- />
- </div>
- );
-}
-
-function PreviewToolbar({
- isFullscreen,
+function PreviewCanvas({
onToggleFullscreen,
+ containerRef,
}: {
- isFullscreen: boolean;
onToggleFullscreen: () => void;
+ containerRef: React.RefObject<HTMLElement | null>;
}) {
- 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 (
- <div className="grid grid-cols-[1fr_auto_1fr] items-center pb-3 pt-5 px-5">
- <div className="flex items-center mt-1">
- <EditableTimecode
- time={currentTime}
- duration={totalDuration}
- format="HH:MM:SS:FF"
- fps={fps}
- onTimeChange={({ time }) => editor.playback.seek({ time })}
- className="text-center"
- />
- <span className="text-muted-foreground px-2 font-mono text-xs">/</span>
- <span className="text-muted-foreground font-mono text-xs">
- {formatTimeCode({
- timeInSeconds: totalDuration,
- format: "HH:MM:SS:FF",
- fps,
- })}
- </span>
- </div>
-
- <Button
- variant="text"
- size="icon"
- type="button"
- onClick={() => invokeAction("toggle-play")}
- >
- <HugeiconsIcon icon={isPlaying ? PauseIcon : PlayIcon} />
- </Button>
-
- <div className="justify-self-end">
- <Button
- variant="text"
- size="icon"
- type="button"
- onClick={onToggleFullscreen}
- title={isFullscreen ? "Exit fullscreen" : "Enter fullscreen"}
- >
- <HugeiconsIcon icon={FullScreenIcon} />
- </Button>
- </div>
- </div>
- );
-}
-
-function PreviewCanvas() {
const canvasRef = useRef<HTMLCanvasElement>(null);
- const containerRef = useRef<HTMLDivElement>(null);
+ const outerContainerRef = useRef<HTMLDivElement>(null);
+ const canvasBoundsRef = useRef<HTMLDivElement>(null);
const lastFrameRef = useRef(-1);
const lastSceneRef = useRef<RootNode | null>(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 (
<div
- ref={containerRef}
- className="relative flex h-full w-full items-center justify-center"
+ ref={outerContainerRef}
+ className="relative flex size-full items-center justify-center"
>
- <canvas
- ref={canvasRef}
- width={nativeWidth}
- height={nativeHeight}
- className="block border"
- style={{
- width: displaySize.width,
- height: displaySize.height,
- background:
- activeProject.settings.background.type === "blur"
- ? "transparent"
- : activeProject?.settings.background.color,
- }}
- />
- <PreviewInteractionOverlay canvasRef={canvasRef} />
+ <ContextMenu>
+ <ContextMenuTrigger asChild>
+ <div
+ ref={canvasBoundsRef}
+ className="relative"
+ style={{ width: displaySize.width, height: displaySize.height }}
+ >
+ <canvas
+ ref={canvasRef}
+ width={nativeWidth}
+ height={nativeHeight}
+ className="block border"
+ style={{
+ width: displaySize.width,
+ height: displaySize.height,
+ background:
+ activeProject.settings.background.type === "blur"
+ ? "transparent"
+ : activeProject?.settings.background.color,
+ }}
+ />
+ <PreviewInteractionOverlay
+ canvasRef={canvasRef}
+ containerRef={canvasBoundsRef}
+ />
+ {overlays.bookmarks && <BookmarkNoteOverlay />}
+ </div>
+ </ContextMenuTrigger>
+ <PreviewContextMenu
+ onToggleFullscreen={onToggleFullscreen}
+ containerRef={containerRef}
+ />
+ </ContextMenu>
</div>
);
}
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 (
+ <div className="pointer-events-none absolute inset-0">
+ <Image
+ src="/platform-guides/tiktok-blueprint.png"
+ alt="TikTok layout guide"
+ className="absolute inset-0 size-full object-contain"
+ draggable={false}
+ fill
+ />
+ </div>
+ );
+}
+
+export function LayoutGuideOverlay() {
+ const { layoutGuide } = usePreviewStore();
+
+ if (layoutGuide.platform === null) return null;
+ if (layoutGuide.platform === "tiktok") return <TikTokGuide />;
+
+ 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<HTMLCanvasElement | null>;
+ containerRef: React.RefObject<HTMLDivElement | null>;
}) {
- const { onPointerDown, onPointerMove, onPointerUp } =
- usePreviewInteraction({ canvasRef });
+ const {
+ onPointerDown,
+ onPointerMove,
+ onPointerUp,
+ onDoubleClick,
+ snapLines,
+ editingText,
+ commitTextEdit,
+ cancelTextEdit,
+ } = usePreviewInteraction({ canvasRef });
return (
- <div
- className={cn("absolute inset-0 pointer-events-auto")}
- onPointerDown={onPointerDown}
- onPointerMove={onPointerMove}
- onPointerUp={onPointerUp}
- />
+ <div className="absolute inset-0">
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: canvas overlay, pointer-only interaction */}
+ <div
+ className="absolute inset-0 pointer-events-auto"
+ onPointerDown={onPointerDown}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ onDoubleClick={onDoubleClick}
+ />
+ {editingText ? (
+ <TextEditOverlay
+ canvasRef={canvasRef}
+ containerRef={containerRef}
+ trackId={editingText.trackId}
+ elementId={editingText.elementId}
+ element={editingText.element}
+ onCommit={commitTextEdit}
+ onCancel={cancelTextEdit}
+ />
+ ) : (
+ <TransformHandles canvasRef={canvasRef} containerRef={containerRef} />
+ )}
+ <SnapGuides
+ lines={snapLines}
+ canvasRef={canvasRef}
+ containerRef={containerRef}
+ />
+ </div>
);
}
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<HTMLCanvasElement | null>;
+ containerRef: React.RefObject<HTMLDivElement | null>;
+}) {
+ 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 (
+ <div className="pointer-events-none absolute inset-0" aria-hidden>
+ {lines.map((line) => {
+ if (line.type === "vertical") {
+ return (
+ <div
+ key={`vertical-${line.position}`}
+ className="absolute top-0 bottom-0 w-px bg-white/70"
+ style={{ left: toOverlayX(line.position) }}
+ />
+ );
+ }
+ return (
+ <div
+ key={`horizontal-${line.position}`}
+ className="absolute left-0 right-0 h-px bg-white/70"
+ style={{ top: toOverlayY(line.position) }}
+ />
+ );
+ })}
+ </div>
+ );
+}
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<HTMLCanvasElement | null>;
+ containerRef: React.RefObject<HTMLDivElement | null>;
+ trackId: string;
+ elementId: string;
+ element: TextElement;
+ onCommit: () => void;
+ onCancel: () => void;
+}) {
+ const editor = useEditor();
+ const divRef = useRef<HTMLDivElement>(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 (
+ <div
+ className="absolute"
+ style={{
+ left: posX,
+ top: posY - verticalAlignmentOffset,
+ transform: `translate(-50%, -50%) scale(${element.transform.scale}) rotate(${element.transform.rotate}deg)`,
+ transformOrigin: "center center",
+ }}
+ >
+ {/* biome-ignore lint/a11y/useSemanticElements: contenteditable required for multiline, IME, paste */}
+ <div
+ ref={divRef}
+ contentEditable
+ suppressContentEditableWarning
+ tabIndex={0}
+ role="textbox"
+ aria-label="Edit text"
+ className="cursor-text select-text outline-none whitespace-pre"
+ style={{
+ fontSize: displayFontSize,
+ fontFamily: element.fontFamily,
+ fontWeight,
+ fontStyle,
+ textAlign: element.textAlign,
+ letterSpacing: `${letterSpacing}px`,
+ lineHeight,
+ color: element.color,
+ backgroundColor,
+ minHeight: displayFontSize * lineHeight,
+ textDecoration: element.textDecoration ?? "none",
+ padding:
+ backgroundColor === "transparent" ? 0 : TEXT_BACKGROUND_PADDING,
+ minWidth: 1,
+ }}
+ onInput={handleInput}
+ onBlur={onCommit}
+ onKeyDown={handleKeyDown}
+ >
+ {element.content || ""}
+ </div>
+ </div>
+ );
+}
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 (
+ <div className="grid grid-cols-[1fr_auto_1fr] items-center pb-3 pt-5 px-5">
+ <div className="flex items-center">
+ <EditableTimecode
+ time={currentTime}
+ duration={totalDuration}
+ format="HH:MM:SS:FF"
+ fps={fps}
+ onTimeChange={({ time }) => editor.playback.seek({ time })}
+ className="text-center"
+ />
+ <span className="text-muted-foreground px-2 font-mono text-xs">/</span>
+ <span className="text-muted-foreground font-mono text-xs">
+ {formatTimeCode({
+ timeInSeconds: totalDuration,
+ format: "HH:MM:SS:FF",
+ fps,
+ })}
+ </span>
+ </div>
+
+ <Button
+ variant="text"
+ size="icon"
+ onClick={() => invokeAction("toggle-play")}
+ >
+ <HugeiconsIcon icon={isPlaying ? PauseIcon : PlayIcon} />
+ </Button>
+
+ <div className="justify-self-end flex items-center gap-2.5">
+ <Button
+ variant="secondary"
+ size="sm"
+ className="[&_svg]:size-auto px-1 h-7"
+ onClick={onToggleFullscreen}
+ title={isFullscreen ? "Exit fullscreen" : "Enter fullscreen"}
+ >
+ <OcSocialIcon size={20} />
+ </Button>
+ <Separator orientation="vertical" className="h-4" />
+ <Button
+ variant="text"
+ onClick={onToggleFullscreen}
+ title={isFullscreen ? "Exit fullscreen" : "Enter fullscreen"}
+ >
+ <HugeiconsIcon icon={FullScreenIcon} />
+ </Button>
+ </div>
+ </div>
+ );
+}
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<HTMLCanvasElement | null>;
+ containerRef: React.RefObject<HTMLDivElement | null>;
+}): {
+ 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<HTMLCanvasElement | null>;
+ containerRef: React.RefObject<HTMLDivElement | null>;
+}) {
+ 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 (
+ <div className="pointer-events-none absolute inset-0" aria-hidden>
+ <SnapGuides
+ lines={snapLines}
+ canvasRef={canvasRef}
+ containerRef={containerRef}
+ />
+ <BoundingBoxOutline
+ center={center}
+ outlineWidth={outlineWidth}
+ outlineHeight={outlineHeight}
+ rotation={bounds.rotation}
+ />
+ {CORNERS.map((corner) => {
+ const cornerPosition = getCornerPosition({ bounds, corner });
+ const screen = toOverlay({
+ canvasX: cornerPosition.x,
+ canvasY: cornerPosition.y,
+ });
+ return (
+ <CornerHandle
+ key={corner}
+ corner={corner}
+ screen={screen}
+ onPointerDown={(event) =>
+ handleCornerPointerDown({ event, corner })
+ }
+ onPointerMove={(event) => handlePointerMove({ event })}
+ onPointerUp={(event) => handlePointerUp({ event })}
+ />
+ );
+ })}
+ <RotationHandle
+ screen={rotationHandleScreen}
+ onPointerDown={(event) => handleRotationPointerDown({ event })}
+ onPointerMove={(event) => handlePointerMove({ event })}
+ onPointerUp={(event) => handlePointerUp({ event })}
+ />
+ </div>
+ );
+}
+
+function BoundingBoxOutline({
+ center,
+ outlineWidth,
+ outlineHeight,
+ rotation,
+}: {
+ center: { x: number; y: number };
+ outlineWidth: number;
+ outlineHeight: number;
+ rotation: number;
+}) {
+ return (
+ <div
+ className="pointer-events-none absolute"
+ style={{
+ left: center.x - outlineWidth / 2,
+ top: center.y - outlineHeight / 2,
+ width: outlineWidth,
+ height: outlineHeight,
+ transform: `rotate(${rotation}deg)`,
+ transformOrigin: "center center",
+ border: "1px solid white",
+ boxSizing: "border-box",
+ opacity: 0.75,
+ }}
+ />
+ );
+}
+
+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 (
+ <button
+ type="button"
+ className={cn(
+ "absolute flex items-center justify-center outline-none",
+ isNesw ? "cursor-nesw-resize" : "cursor-nwse-resize",
+ )}
+ style={{
+ left: screen.x - CORNER_HIT_AREA_SIZE / 2,
+ top: screen.y - CORNER_HIT_AREA_SIZE / 2,
+ width: CORNER_HIT_AREA_SIZE,
+ height: CORNER_HIT_AREA_SIZE,
+ pointerEvents: "auto",
+ }}
+ onPointerDown={onPointerDown}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ onPointerLeave={onPointerUp}
+ onKeyDown={(event) => event.key === "Enter" && event.preventDefault()}
+ onKeyUp={(event) => event.key === "Enter" && event.preventDefault()}
+ >
+ <div
+ className="rounded-sm bg-white"
+ style={{ width: HANDLE_SIZE, height: HANDLE_SIZE }}
+ />
+ </button>
+ );
+}
+
+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 (
+ <button
+ type="button"
+ className="absolute flex items-center justify-center rounded-full bg-white text-black shadow-sm outline-none"
+ style={{
+ left: screen.x - ROTATION_HANDLE_RADIUS,
+ top: screen.y - ROTATION_HANDLE_RADIUS,
+ width: ROTATION_HANDLE_RADIUS * 2,
+ height: ROTATION_HANDLE_RADIUS * 2,
+ pointerEvents: "auto",
+ }}
+ onPointerDown={onPointerDown}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ onPointerLeave={onPointerUp}
+ onKeyDown={(event) => event.key === "Enter" && event.preventDefault()}
+ onKeyUp={(event) => event.key === "Enter" && event.preventDefault()}
+ >
+ <HugeiconsIcon icon={Rotate01Icon} className="size-3" strokeWidth={2.5} />
+ </button>
+ );
+}