refactor: restructure files to their domains, new preview overlay system, and dep graph

This commit is contained in:
Maze Winther
2026-04-20 11:31:17 +02:00
parent 729d10592f
commit 3e89d29985
491 changed files with 3565 additions and 2372 deletions
@@ -0,0 +1,85 @@
"use client";
import {
ContextMenuCheckboxItem,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
} from "@/components/ui/context-menu";
import { usePreviewViewport } from "@/preview/components/preview-viewport";
import { useEditor } from "@/editor/use-editor";
import type { PreviewOverlayControl } from "@/preview/overlays";
import { toast } from "sonner";
export function PreviewContextMenu({
onToggleFullscreen,
containerRef,
overlayControls,
onOverlayVisibilityChange,
}: {
onToggleFullscreen: () => void;
containerRef: React.RefObject<HTMLElement | null>;
overlayControls: PreviewOverlayControl[];
onOverlayVisibilityChange: (params: {
overlayId: string;
isVisible: boolean;
}) => void;
}) {
const editor = useEditor();
const viewport = usePreviewViewport();
const handleCopySnapshot = async () => {
const result = await editor.renderer.copySnapshot();
if (!result.success) {
toast.error("Failed to copy snapshot", {
description: result.error ?? "Please try again",
});
return;
}
};
const handleSaveSnapshot = async () => {
const result = await editor.renderer.saveSnapshot();
if (!result.success) {
toast.error("Failed to save snapshot", {
description: result.error ?? "Please try again",
});
return;
}
};
return (
<ContextMenuContent className="w-56" container={containerRef.current}>
<ContextMenuItem onClick={viewport.fitToScreen} inset>
Fit to screen
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={onToggleFullscreen} inset>
Full screen
</ContextMenuItem>
<ContextMenuItem onClick={handleSaveSnapshot} inset>
Save snapshot
</ContextMenuItem>
<ContextMenuItem onClick={handleCopySnapshot} inset>
Copy snapshot
</ContextMenuItem>
{overlayControls.length > 0 ? <ContextMenuSeparator /> : null}
{overlayControls.map((overlayControl) => (
<ContextMenuCheckboxItem
key={overlayControl.id}
checked={overlayControl.isVisible}
onCheckedChange={(checked) =>
onOverlayVisibilityChange({
overlayId: overlayControl.id,
isVisible: !!checked,
})
}
>
{overlayControl.label}
</ContextMenuCheckboxItem>
))}
</ContextMenuContent>
);
}
@@ -0,0 +1,21 @@
function svgCursor({
svg,
hotspotX,
hotspotY,
}: {
svg: string;
hotspotX: number;
hotspotY: number;
}): string {
return `url("data:image/svg+xml,${encodeURIComponent(svg)}") ${hotspotX} ${hotspotY}, crosshair`;
}
/** Hotspot is at the nib tip, which is where anchor points land. */
export const PEN_CURSOR = svgCursor({
svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M 1 1 L 5 2 L 13 10 L 10 13 L 2 5 Z" fill="white" stroke="#111" stroke-width="1" stroke-linejoin="round"/>
<path d="M 1 1 L 5 2 L 2 5 Z" fill="#111"/>
</svg>`,
hotspotX: 1,
hotspotY: 1,
});
@@ -0,0 +1,100 @@
"use client";
import { AnimatePresence, motion } from "motion/react";
import { GUIDE_REGISTRY, getGuideById } from "@/guides";
import { usePreviewStore } from "@/preview/preview-store";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/utils/ui";
export function GridPopover({ children }: { children: React.ReactNode }) {
const activeGuide = usePreviewStore((state) => state.activeGuide);
const toggleGuide = usePreviewStore((state) => state.toggleGuide);
const activeGuideDef = getGuideById(activeGuide);
const options = activeGuideDef?.renderOptions?.();
return (
<Popover>
<PopoverTrigger>{children}</PopoverTrigger>
<PopoverContent sideOffset={8} className="w-60 px-0">
<div className="flex flex-col gap-2 px-4">
<Label>Guides</Label>
<div className="grid grid-cols-3 gap-1">
{GUIDE_REGISTRY.map((guide) => (
<GridItem
key={guide.id}
label={guide.label}
preview={guide.renderPreview()}
isSelected={activeGuide === guide.id}
onClick={() => toggleGuide(guide.id)}
/>
))}
</div>
</div>
<AnimatePresence>
{options && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{
height: { type: "spring", duration: 0.2, bounce: 0 },
opacity: { duration: 0.2 },
}}
className="overflow-hidden"
>
<Separator className="my-3" />
<div className="px-4">{options}</div>
</motion.div>
)}
</AnimatePresence>
</PopoverContent>
</Popover>
);
}
function GridItem({
label,
preview,
isSelected,
onClick,
}: {
label: string;
preview: React.ReactNode;
isSelected?: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={isSelected}
className={cn(
"flex cursor-pointer rounded-sm flex-col gap-2 px-1.5 py-1.5 hover:bg-foreground/5",
isSelected && "bg-primary/5! text-primary",
)}
>
<div
className={cn(
"aspect-video bg-foreground/5 flex items-center justify-center rounded-sm",
isSelected && "bg-primary/5!",
)}
>
{preview}
</div>
<span
className={cn(
"text-xs",
isSelected ? "text-primary" : "text-muted-foreground",
)}
>
{label}
</span>
</button>
);
}
@@ -0,0 +1,427 @@
"use client";
import { cn } from "@/utils/ui";
import { HugeiconsIcon } from "@hugeicons/react";
import type { IconSvgElement } from "@hugeicons/react";
export const HANDLE_SIZE = 10;
export const HANDLE_HIT_AREA_SIZE = 18;
export const ICON_HANDLE_RADIUS = 10;
export const EDGE_HANDLE_THIN_SIZE = 6;
export const EDGE_HANDLE_THICK_SIZE = 14;
export const LINE_HIT_AREA_SIZE = 48;
export function getResizeCursor({ angleDeg }: { angleDeg: number }): string {
const normalized = ((angleDeg % 180) + 180) % 180;
if (normalized < 22.5 || normalized >= 157.5) return "ew-resize";
if (normalized < 67.5) return "nwse-resize";
if (normalized < 112.5) return "ns-resize";
return "nesw-resize";
}
export function HandleButton({
screen,
cursor,
hitAreaSize,
className,
onPointerDown,
onPointerMove,
onPointerUp,
children,
}: {
screen: { x: number; y: number };
cursor?: string;
hitAreaSize: number;
className?: string;
onPointerDown: (event: React.PointerEvent) => void;
onPointerMove: (event: React.PointerEvent) => void;
onPointerUp: (event: React.PointerEvent) => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
className={cn(
"absolute flex items-center justify-center outline-none",
className,
)}
style={{
left: screen.x - hitAreaSize / 2,
top: screen.y - hitAreaSize / 2,
width: hitAreaSize,
height: hitAreaSize,
pointerEvents: "auto",
cursor,
}}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerLeave={onPointerUp}
onKeyDown={(event) => event.key === "Enter" && event.preventDefault()}
onKeyUp={(event) => event.key === "Enter" && event.preventDefault()}
>
{children}
</button>
);
}
export function CornerHandle({
cursor,
screen,
onPointerDown,
onPointerMove,
onPointerUp,
}: {
cursor?: string;
screen: { x: number; y: number };
onPointerDown: (event: React.PointerEvent) => void;
onPointerMove: (event: React.PointerEvent) => void;
onPointerUp: (event: React.PointerEvent) => void;
}) {
return (
<HandleButton
screen={screen}
cursor={cursor}
hitAreaSize={HANDLE_HIT_AREA_SIZE}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
>
<div
className="rounded-sm bg-white"
style={{ width: HANDLE_SIZE, height: HANDLE_SIZE }}
/>
</HandleButton>
);
}
export function CircleHandle({
cursor,
screen,
size = HANDLE_SIZE,
isSelected = false,
onPointerDown,
onPointerMove,
onPointerUp,
}: {
cursor?: string;
screen: { x: number; y: number };
size?: number;
isSelected?: boolean;
onPointerDown: (event: React.PointerEvent) => void;
onPointerMove: (event: React.PointerEvent) => void;
onPointerUp: (event: React.PointerEvent) => void;
}) {
return (
<HandleButton
screen={screen}
cursor={cursor}
hitAreaSize={HANDLE_HIT_AREA_SIZE}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
>
<div
className={cn("rounded-full", isSelected ? "bg-primary" : "bg-white")}
style={{ width: size, height: size }}
/>
</HandleButton>
);
}
export function EdgeHandle({
edge,
screen,
rotation,
onPointerDown,
onPointerMove,
onPointerUp,
}: {
edge: "right" | "left" | "bottom";
screen: { x: number; y: number };
rotation: number;
onPointerDown: (event: React.PointerEvent) => void;
onPointerMove: (event: React.PointerEvent) => void;
onPointerUp: (event: React.PointerEvent) => void;
}) {
const isHorizontalEdge = edge === "right" || edge === "left";
const width = isHorizontalEdge
? EDGE_HANDLE_THIN_SIZE
: EDGE_HANDLE_THICK_SIZE;
const height = isHorizontalEdge
? EDGE_HANDLE_THICK_SIZE
: EDGE_HANDLE_THIN_SIZE;
const cursor = getResizeCursor({
angleDeg: isHorizontalEdge ? rotation : rotation + 90,
});
return (
<HandleButton
screen={screen}
cursor={cursor}
hitAreaSize={HANDLE_HIT_AREA_SIZE}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
>
<div
className="rounded-sm bg-white"
style={{ width, height, transform: `rotate(${rotation}deg)` }}
/>
</HandleButton>
);
}
export function IconHandle({
icon,
screen,
onPointerDown,
onPointerMove,
onPointerUp,
}: {
icon: IconSvgElement;
screen: { x: number; y: number };
onPointerDown: (event: React.PointerEvent) => void;
onPointerMove: (event: React.PointerEvent) => void;
onPointerUp: (event: React.PointerEvent) => void;
}) {
return (
<HandleButton
screen={screen}
hitAreaSize={ICON_HANDLE_RADIUS * 2}
className="rounded-full bg-white text-black shadow-sm"
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
>
<HugeiconsIcon icon={icon} className="size-3" strokeWidth={2.5} />
</HandleButton>
);
}
export function BoundingBoxOutline({
center,
outlineWidth,
outlineHeight,
rotation,
cursor,
dashed = false,
onPointerDown,
onPointerMove,
onPointerUp,
}: {
center: { x: number; y: number };
outlineWidth: number;
outlineHeight: number;
rotation: number;
cursor?: string;
dashed?: boolean;
onPointerDown?: (event: React.PointerEvent) => void;
onPointerMove?: (event: React.PointerEvent) => void;
onPointerUp?: (event: React.PointerEvent) => void;
}) {
return (
<svg
className="absolute overflow-visible"
aria-hidden="true"
focusable="false"
style={{
left: center.x - outlineWidth / 2,
top: center.y - outlineHeight / 2,
width: outlineWidth,
height: outlineHeight,
transform: `rotate(${rotation}deg)`,
transformOrigin: "center center",
pointerEvents: onPointerDown ? "auto" : "none",
cursor,
}}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerLeave={onPointerUp}
>
<rect
x={0.5}
y={0.5}
width={Math.max(outlineWidth - 1, 0)}
height={Math.max(outlineHeight - 1, 0)}
fill="transparent"
stroke="white"
strokeDasharray={dashed ? "4 4" : undefined}
strokeOpacity={0.75}
vectorEffect="non-scaling-stroke"
style={{ pointerEvents: onPointerDown ? "all" : "none" }}
/>
</svg>
);
}
export function ShapeOutline({
center,
outlineWidth,
outlineHeight,
rotation,
pathData,
cursor,
onPointerDown,
onPointerMove,
onPointerUp,
}: {
center: { x: number; y: number };
outlineWidth: number;
outlineHeight: number;
rotation: number;
pathData: string;
cursor?: string;
onPointerDown?: (event: React.PointerEvent) => void;
onPointerMove?: (event: React.PointerEvent) => void;
onPointerUp?: (event: React.PointerEvent) => void;
}) {
return (
<svg
className="absolute overflow-visible"
aria-hidden="true"
focusable="false"
style={{
left: center.x - outlineWidth / 2,
top: center.y - outlineHeight / 2,
width: outlineWidth,
height: outlineHeight,
transform: `rotate(${rotation}deg)`,
transformOrigin: "center center",
pointerEvents: onPointerDown ? "auto" : "none",
cursor,
}}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerLeave={onPointerUp}
>
<path
d={pathData}
fill="transparent"
stroke="white"
strokeOpacity={0.75}
vectorEffect="non-scaling-stroke"
style={{ pointerEvents: onPointerDown ? "all" : "none" }}
/>
</svg>
);
}
export function CanvasPathOutline({
pathData,
translateX = 0,
translateY = 0,
scaleX = 1,
scaleY = 1,
cursor,
strokeWidth = 1,
strokeOpacity = 0.75,
onPointerDown,
onPointerMove,
onPointerUp,
}: {
pathData: string;
translateX?: number;
translateY?: number;
scaleX?: number;
scaleY?: number;
cursor?: string;
strokeWidth?: number;
strokeOpacity?: number;
onPointerDown?: (event: React.PointerEvent) => void;
onPointerMove?: (event: React.PointerEvent) => void;
onPointerUp?: (event: React.PointerEvent) => void;
}) {
return (
<svg
className="absolute inset-0 overflow-visible"
aria-hidden="true"
focusable="false"
style={{
pointerEvents: onPointerDown ? "auto" : "none",
cursor,
}}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerLeave={onPointerUp}
>
<g
transform={`translate(${translateX} ${translateY}) scale(${scaleX} ${scaleY})`}
>
<path
d={pathData}
fill="transparent"
stroke="white"
strokeWidth={strokeWidth}
strokeOpacity={strokeOpacity}
vectorEffect="non-scaling-stroke"
style={{ pointerEvents: onPointerDown ? "stroke" : "none" }}
/>
</g>
</svg>
);
}
export function LineOverlay({
start,
end,
cursor,
onPointerDown,
onPointerMove,
onPointerUp,
}: {
start: { x: number; y: number };
end: { x: number; y: number };
cursor?: string;
onPointerDown?: (event: React.PointerEvent) => void;
onPointerMove?: (event: React.PointerEvent) => void;
onPointerUp?: (event: React.PointerEvent) => void;
}) {
const dx = end.x - start.x;
const dy = end.y - start.y;
const length = Math.sqrt(dx * dx + dy * dy);
const angleDeg = (Math.atan2(dy, dx) * 180) / Math.PI;
const cx = (start.x + end.x) / 2;
const cy = (start.y + end.y) / 2;
const sharedStyle = {
left: cx - length / 2,
width: length,
transform: `rotate(${angleDeg}deg)`,
transformOrigin: "center center",
};
return (
<>
{onPointerDown && (
<div
className="absolute"
style={{
...sharedStyle,
top: cy - LINE_HIT_AREA_SIZE / 2,
height: LINE_HIT_AREA_SIZE,
pointerEvents: "auto",
cursor,
}}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerLeave={onPointerUp}
/>
)}
<div
className="pointer-events-none absolute"
style={{
...sharedStyle,
top: cy - 0.5,
height: 1,
backgroundColor: "white",
opacity: 0.75,
}}
/>
</>
);
}
+328
View File
@@ -0,0 +1,328 @@
"use client";
import { useCallback, useEffect, useMemo, useRef } from "react";
import useDeepCompareEffect from "use-deep-compare-effect";
import { useEditor } from "@/editor/use-editor";
import { useRafLoop } from "@/hooks/use-raf-loop";
import { useContainerSize } from "@/hooks/use-container-size";
import { useFullscreen } from "@/hooks/use-fullscreen";
import { CanvasRenderer } from "@/services/renderer/canvas-renderer";
import { TICKS_PER_SECOND } from "@/wasm";
import type { RootNode } from "@/services/renderer/nodes/root-node";
import { buildScene } from "@/services/renderer/scene-builder";
import { PreviewOverlayLayer } from "./overlay-layer";
import { PreviewInteractionOverlay } from "./preview-interaction-overlay";
import { ContextMenu, ContextMenuTrigger } from "@/components/ui/context-menu";
import type {
PreviewOverlayControl,
PreviewOverlayInstance,
} from "@/preview/overlays";
import { PreviewContextMenu } from "./context-menu";
import { PreviewToolbar } from "./toolbar";
import {
PreviewViewportProvider,
usePreviewViewportState,
} from "./preview-viewport";
function usePreviewSize() {
const canvasSize = useEditor(
(e) => e.project.getActive()?.settings.canvasSize,
);
return {
width: canvasSize?.width,
height: canvasSize?.height,
};
}
function normalizeWheelDelta({
delta,
deltaMode,
pageSize,
}: {
delta: number;
deltaMode: number;
pageSize: number;
}): number {
if (deltaMode === WheelEvent.DOM_DELTA_LINE) {
return delta * 16;
}
if (deltaMode === WheelEvent.DOM_DELTA_PAGE) {
return delta * pageSize;
}
return delta;
}
export function PreviewPanel({
overlayControls,
overlayInstances,
onOverlayVisibilityChange,
}: {
overlayControls: PreviewOverlayControl[];
overlayInstances: PreviewOverlayInstance[];
onOverlayVisibilityChange: (params: {
overlayId: string;
isVisible: boolean;
}) => void;
}) {
const containerRef = useRef<HTMLDivElement>(null);
const { 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"
>
<PreviewCanvas
containerRef={containerRef}
onToggleFullscreen={toggleFullscreen}
overlayControls={overlayControls}
overlayInstances={overlayInstances}
onOverlayVisibilityChange={onOverlayVisibilityChange}
/>
<RenderTreeController />
</div>
);
}
function RenderTreeController() {
const editor = useEditor();
const tracks = useEditor(
(e) => e.timeline.getPreviewTracks() ?? e.scenes.getActiveScene().tracks,
);
const mediaAssets = useEditor((e) => e.media.getAssets());
const activeProject = useEditor((e) => e.project.getActive());
const { width, height } = usePreviewSize();
useDeepCompareEffect(() => {
if (!activeProject) return;
const duration = editor.timeline.getTotalDuration();
const renderTree = buildScene({
tracks,
mediaAssets,
duration,
canvasSize: { width, height },
background: activeProject.settings.background,
isPreview: true,
});
editor.renderer.setRenderTree({ renderTree });
}, [tracks, mediaAssets, activeProject?.settings.background, width, height]);
return null;
}
function PreviewCanvas({
containerRef,
onToggleFullscreen,
overlayControls,
overlayInstances,
onOverlayVisibilityChange,
}: {
containerRef: React.RefObject<HTMLElement | null>;
onToggleFullscreen: () => void;
overlayControls: PreviewOverlayControl[];
overlayInstances: PreviewOverlayInstance[];
onOverlayVisibilityChange: (params: {
overlayId: string;
isVisible: boolean;
}) => void;
}) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const viewportRef = useRef<HTMLDivElement>(null);
const lastFrameRef = useRef(-1);
const lastSceneRef = useRef<RootNode | null>(null);
const renderingRef = useRef(false);
const { width: nativeWidth, height: nativeHeight } = usePreviewSize();
const viewportSize = useContainerSize({ containerRef: viewportRef });
const editor = useEditor();
const activeProject = useEditor((e) => e.project.getActive());
const renderTree = useEditor((e) => e.renderer.getRenderTree());
const viewport = usePreviewViewportState({
canvasHeight: nativeHeight,
canvasWidth: nativeWidth,
viewportHeight: viewportSize.height,
viewportRef,
viewportWidth: viewportSize.width,
});
const renderer = useMemo(() => {
return new CanvasRenderer({
width: nativeWidth,
height: nativeHeight,
fps: activeProject.settings.fps,
});
}, [nativeWidth, nativeHeight, activeProject.settings.fps]);
const render = useCallback(() => {
if (canvasRef.current && renderTree && !renderingRef.current) {
const renderTime = Math.min(
editor.playback.getCurrentTime(),
editor.timeline.getLastFrameTime(),
);
const ticksPerFrame = Math.round(
(TICKS_PER_SECOND * renderer.fps.denominator) / renderer.fps.numerator,
);
const frame = Math.floor(renderTime / ticksPerFrame);
if (
frame !== lastFrameRef.current ||
renderTree !== lastSceneRef.current
) {
renderingRef.current = true;
lastSceneRef.current = renderTree;
lastFrameRef.current = frame;
renderer
.renderToCanvas({
node: renderTree,
time: renderTime,
targetCanvas: canvasRef.current,
})
.then(() => {
renderingRef.current = false;
});
}
}
}, [renderer, renderTree, editor.playback, editor.timeline]);
useRafLoop(render);
useEffect(() => {
const container = viewportRef.current;
if (!container) return;
let pendingZoomDelta = 0;
let pendingPanDeltaX = 0;
let pendingPanDeltaY = 0;
let zoomRafId: ReturnType<typeof requestAnimationFrame> | null = null;
let panRafId: ReturnType<typeof requestAnimationFrame> | null = null;
const onWheel = (event: WheelEvent) => {
const normalizedDeltaX = normalizeWheelDelta({
delta: event.deltaX,
deltaMode: event.deltaMode,
pageSize: container.clientWidth,
});
const normalizedDeltaY = normalizeWheelDelta({
delta: event.deltaY,
deltaMode: event.deltaMode,
pageSize: container.clientHeight,
});
const isZoomGesture = event.ctrlKey || event.metaKey;
if (isZoomGesture) {
event.preventDefault();
pendingZoomDelta += normalizedDeltaY;
if (zoomRafId === null) {
zoomRafId = requestAnimationFrame(() => {
const cappedDelta =
Math.sign(pendingZoomDelta) *
Math.min(Math.abs(pendingZoomDelta), 30);
const zoomFactor = Math.exp(-cappedDelta / 300);
viewport.scaleZoom({ factor: zoomFactor });
pendingZoomDelta = 0;
zoomRafId = null;
});
}
return;
}
if (!viewport.canPan) {
return;
}
if (normalizedDeltaX === 0 && normalizedDeltaY === 0) {
return;
}
event.preventDefault();
pendingPanDeltaX += normalizedDeltaX;
pendingPanDeltaY += normalizedDeltaY;
if (panRafId === null) {
panRafId = requestAnimationFrame(() => {
viewport.panByScreenDelta({
deltaX: pendingPanDeltaX,
deltaY: pendingPanDeltaY,
});
pendingPanDeltaX = 0;
pendingPanDeltaY = 0;
panRafId = null;
});
}
};
container.addEventListener("wheel", onWheel, {
capture: true,
passive: false,
});
return () => {
container.removeEventListener("wheel", onWheel, {
capture: true,
});
if (zoomRafId !== null) {
cancelAnimationFrame(zoomRafId);
}
if (panRafId !== null) {
cancelAnimationFrame(panRafId);
}
};
}, [viewport.canPan, viewport.panByScreenDelta, viewport.scaleZoom]);
return (
<PreviewViewportProvider value={viewport}>
<div className="flex size-full min-h-0 min-w-0 flex-col">
<div className="flex min-h-0 min-w-0 flex-1 p-2 pb-0">
<ContextMenu>
<ContextMenuTrigger asChild>
<div
ref={viewportRef}
className="relative flex size-full min-h-0 min-w-0 items-center justify-center overflow-hidden"
>
<canvas
ref={canvasRef}
width={nativeWidth}
height={nativeHeight}
className="absolute block border"
style={{
left: viewport.sceneLeft,
top: viewport.sceneTop,
width: viewport.sceneWidth,
height: viewport.sceneHeight,
background:
activeProject.settings.background.type === "blur"
? "transparent"
: activeProject?.settings.background.color,
}}
/>
<PreviewOverlayLayer
instances={overlayInstances}
plane="under-interaction"
/>
<PreviewInteractionOverlay />
<PreviewOverlayLayer
instances={overlayInstances}
plane="over-interaction"
/>
</div>
</ContextMenuTrigger>
<PreviewContextMenu
onToggleFullscreen={onToggleFullscreen}
containerRef={containerRef}
overlayControls={overlayControls}
onOverlayVisibilityChange={onOverlayVisibilityChange}
/>
</ContextMenu>
</div>
<PreviewToolbar onToggleFullscreen={onToggleFullscreen} />
</div>
</PreviewViewportProvider>
);
}
@@ -0,0 +1,308 @@
"use client";
import { PEN_CURSOR } from "@/preview/components/cursors";
import { usePreviewViewport } from "@/preview/components/preview-viewport";
import { useMaskHandles } from "@/masks/use-mask-handles";
import type { SnapLine } from "@/preview/preview-snap";
import {
CornerHandle,
CircleHandle,
CanvasPathOutline,
EdgeHandle,
IconHandle,
LineOverlay,
BoundingBoxOutline,
ShapeOutline,
} from "./handle-primitives";
const CUSTOM_MASK_ANCHOR_SIZE = 7;
const CUSTOM_MASK_TANGENT_SIZE = 6;
import { Rotate01Icon, FeatherIcon } from "@hugeicons/core-free-icons";
export function MaskHandles({
onSnapLinesChange,
}: {
onSnapLinesChange?: (lines: SnapLine[]) => void;
}) {
const viewport = usePreviewViewport();
const {
selectedWithMask,
handlePositions,
overlays,
isCreatingCustomMask,
handleCanvasPointerDown,
handlePointerDown,
handlePointerMove,
handlePointerUp,
} = useMaskHandles({ onSnapLinesChange });
if (!selectedWithMask) return null;
const toOverlay = ({
canvasX,
canvasY,
}: {
canvasX: number;
canvasY: number;
}) =>
viewport.canvasToOverlay({
canvasX,
canvasY,
});
const { x: scaleX, y: scaleY } = viewport.getDisplayScale();
const canvasOrigin = toOverlay({ canvasX: 0, canvasY: 0 });
const onPointerMove = (event: React.PointerEvent) => {
if (viewport.handlePanPointerMove({ event })) {
return;
}
handlePointerMove({ event });
};
const onPointerUp = (event: React.PointerEvent) => {
if (viewport.handlePanPointerUp({ event })) {
return;
}
handlePointerUp();
};
const handleMaskPointerDown = ({
event,
handleId,
}: {
event: React.PointerEvent;
handleId: string;
}) => {
if (viewport.handlePanPointerDown({ event })) {
return;
}
handlePointerDown({ event, handleId });
};
const handleCanvasOverlayPointerDown = (event: React.PointerEvent) => {
if (viewport.handlePanPointerDown({ event })) {
return;
}
handleCanvasPointerDown({ event });
};
return (
<div
className="pointer-events-none absolute inset-0 overflow-hidden"
aria-hidden
>
{isCreatingCustomMask ? (
<div
className="absolute inset-0 pointer-events-auto"
style={{ cursor: PEN_CURSOR }}
onPointerDown={handleCanvasOverlayPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
/>
) : null}
{overlays.map((overlay) => {
const overlayHandleId = overlay.handleId;
const pointerHandlers = overlayHandleId
? {
onPointerDown: (event: React.PointerEvent) =>
handleMaskPointerDown({ event, handleId: overlayHandleId }),
onPointerMove,
onPointerUp,
}
: {};
if (overlay.type === "line") {
return (
<LineOverlay
key={overlay.id}
cursor={overlay.cursor}
start={toOverlay({
canvasX: overlay.start.x,
canvasY: overlay.start.y,
})}
end={toOverlay({
canvasX: overlay.end.x,
canvasY: overlay.end.y,
})}
{...pointerHandlers}
/>
);
}
if (overlay.type === "rect") {
return (
<BoundingBoxOutline
key={overlay.id}
center={toOverlay({
canvasX: overlay.center.x,
canvasY: overlay.center.y,
})}
outlineWidth={overlay.width * scaleX}
outlineHeight={overlay.height * scaleY}
rotation={overlay.rotation}
cursor={overlay.cursor}
dashed={overlay.dashed}
{...pointerHandlers}
/>
);
}
if (overlay.type === "shape") {
return (
<ShapeOutline
key={overlay.id}
center={toOverlay({
canvasX: overlay.center.x,
canvasY: overlay.center.y,
})}
outlineWidth={overlay.width * scaleX}
outlineHeight={overlay.height * scaleY}
rotation={overlay.rotation}
pathData={overlay.pathData}
cursor={overlay.cursor}
{...pointerHandlers}
/>
);
}
if (overlay.type === "canvas-path") {
return (
<CanvasPathOutline
key={overlay.id}
pathData={overlay.pathData}
translateX={
overlay.coordinateSpace === "canvas" ? canvasOrigin.x : 0
}
translateY={
overlay.coordinateSpace === "canvas" ? canvasOrigin.y : 0
}
scaleX={overlay.coordinateSpace === "canvas" ? scaleX : 1}
scaleY={overlay.coordinateSpace === "canvas" ? scaleY : 1}
cursor={overlay.cursor}
strokeWidth={overlay.strokeWidth}
strokeOpacity={overlay.strokeOpacity}
{...pointerHandlers}
/>
);
}
return null;
})}
{handlePositions.map((handle) => {
const screen = toOverlay({ canvasX: handle.x, canvasY: handle.y });
if (handle.kind === "icon" && handle.icon === "rotate") {
return (
<IconHandle
key={handle.id}
icon={Rotate01Icon}
screen={screen}
onPointerDown={(event) =>
handleMaskPointerDown({ event, handleId: handle.id })
}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
/>
);
}
if (handle.kind === "icon" && handle.icon === "feather") {
return (
<IconHandle
key={handle.id}
icon={FeatherIcon}
screen={screen}
onPointerDown={(event) =>
handleMaskPointerDown({ event, handleId: handle.id })
}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
/>
);
}
if (handle.kind === "edge" && handle.edgeAxis === "horizontal") {
return (
<EdgeHandle
key={handle.id}
edge="right"
screen={screen}
rotation={handle.rotation ?? 0}
onPointerDown={(event) =>
handleMaskPointerDown({ event, handleId: handle.id })
}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
/>
);
}
if (handle.kind === "edge" && handle.edgeAxis === "vertical") {
return (
<EdgeHandle
key={handle.id}
edge="bottom"
screen={screen}
rotation={handle.rotation ?? 0}
onPointerDown={(event) =>
handleMaskPointerDown({ event, handleId: handle.id })
}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
/>
);
}
if (handle.kind === "point" || handle.kind === "tangent") {
return (
<CircleHandle
key={handle.id}
screen={screen}
size={
handle.kind === "tangent"
? CUSTOM_MASK_TANGENT_SIZE
: CUSTOM_MASK_ANCHOR_SIZE
}
isSelected={handle.isSelected}
onPointerDown={(event) =>
handleMaskPointerDown({ event, handleId: handle.id })
}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
/>
);
}
if (handle.kind === "corner") {
return (
<CornerHandle
key={handle.id}
screen={screen}
onPointerDown={(event) =>
handleMaskPointerDown({ event, handleId: handle.id })
}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
/>
);
}
return (
<CornerHandle
key={handle.id}
cursor={handle.cursor}
screen={screen}
onPointerDown={(event) =>
handleMaskPointerDown({ event, handleId: handle.id })
}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
/>
);
})}
</div>
);
}
@@ -0,0 +1,197 @@
"use client";
import type {
PreviewOverlayHudAnchor,
PreviewOverlayInstance,
PreviewOverlayPlane,
} from "@/preview/overlays";
import { usePreviewViewport } from "./preview-viewport";
const HUD_ANCHOR_CLASS_NAMES: Record<PreviewOverlayHudAnchor, string> = {
"top-left": "absolute top-2 left-2 flex flex-col items-start gap-1.5",
"top-right": "absolute top-2 right-2 flex flex-col items-end gap-1.5",
"bottom-left":
"absolute bottom-2 left-2 flex flex-col-reverse items-start gap-1.5",
"bottom-right":
"absolute right-2 bottom-2 flex flex-col-reverse items-end gap-1.5",
};
function getPositionedMountStyle({
instance,
baseStyle,
sceneLeft,
sceneTop,
sceneWidth,
sceneHeight,
}: {
instance: PreviewOverlayInstance;
baseStyle: {
pointerEvents: "none" | "auto";
zIndex: number | undefined;
};
sceneLeft: number;
sceneTop: number;
sceneWidth: number;
sceneHeight: number;
}) {
if (instance.mount.kind === "hud") {
return baseStyle;
}
const isSceneMount = instance.mount.kind === "scene";
const mount = instance.mount;
const hasExplicitBounds =
mount.x != null ||
mount.y != null ||
mount.width != null ||
mount.height != null;
return {
...baseStyle,
left: (isSceneMount ? sceneLeft : 0) + (mount.x ?? 0),
top: (isSceneMount ? sceneTop : 0) + (mount.y ?? 0),
...(hasExplicitBounds
? {
...(mount.width != null ? { width: mount.width } : {}),
...(mount.height != null ? { height: mount.height } : {}),
}
: {
width: isSceneMount ? sceneWidth : "100%",
height: isSceneMount ? sceneHeight : "100%",
}),
} as const;
}
export function PreviewOverlayLayer({
instances,
plane,
}: {
instances: PreviewOverlayInstance[];
plane: PreviewOverlayPlane;
}) {
const viewport = usePreviewViewport();
const visibleInstances = instances
.filter((instance) => (instance.plane ?? "over-interaction") === plane)
.sort((left, right) => (left.zIndex ?? 0) - (right.zIndex ?? 0));
if (visibleInstances.length === 0) {
return null;
}
const hudInstancesByAnchor = visibleInstances.reduce<
Record<PreviewOverlayHudAnchor, PreviewOverlayInstance[]>
>(
(groups, instance) => {
if (instance.mount.kind === "hud") {
groups[instance.mount.anchor].push(instance);
}
return groups;
},
{
"top-left": [],
"top-right": [],
"bottom-left": [],
"bottom-right": [],
},
);
return (
<>
{visibleInstances.map((instance) => {
if (instance.mount.kind === "hud") {
return null;
}
const pointerEvents = instance.pointerEvents ?? "none";
const baseStyle = {
pointerEvents,
zIndex: instance.zIndex,
} as const;
const content = instance.render({
sceneHeight: viewport.sceneHeight,
sceneWidth: viewport.sceneWidth,
});
switch (instance.mount.kind) {
case "scene":
return (
<div
key={instance.id}
className="absolute"
style={getPositionedMountStyle({
instance,
baseStyle,
sceneLeft: viewport.sceneLeft,
sceneTop: viewport.sceneTop,
sceneWidth: viewport.sceneWidth,
sceneHeight: viewport.sceneHeight,
})}
>
{content}
</div>
);
case "viewport":
return (
<div
key={instance.id}
className="absolute"
style={getPositionedMountStyle({
instance,
baseStyle,
sceneLeft: viewport.sceneLeft,
sceneTop: viewport.sceneTop,
sceneWidth: viewport.sceneWidth,
sceneHeight: viewport.sceneHeight,
})}
>
{content}
</div>
);
default:
return null;
}
})}
{(
Object.entries(hudInstancesByAnchor) as Array<
[PreviewOverlayHudAnchor, PreviewOverlayInstance[]]
>
).map(([anchor, anchorInstances]) => {
if (anchorInstances.length === 0) {
return null;
}
const sortedAnchorInstances = [...anchorInstances].sort(
(left, right) =>
(left.mount.kind === "hud" ? (left.mount.order ?? 0) : 0) -
(right.mount.kind === "hud" ? (right.mount.order ?? 0) : 0),
);
return (
<div key={anchor} className={HUD_ANCHOR_CLASS_NAMES[anchor]}>
{sortedAnchorInstances.map((instance) => {
const pointerEvents = instance.pointerEvents ?? "none";
const content = instance.render({
sceneHeight: viewport.sceneHeight,
sceneWidth: viewport.sceneWidth,
});
return (
<div
key={instance.id}
style={{
pointerEvents,
zIndex: instance.zIndex,
}}
>
{content}
</div>
);
})}
</div>
);
})}
</>
);
}
@@ -0,0 +1,103 @@
import { useState } from "react";
import { usePreviewViewport } from "@/preview/components/preview-viewport";
import { usePreviewInteraction } from "@/preview/hooks/use-preview-interaction";
import type { SnapLine } from "@/preview/preview-snap";
import { TransformHandles } from "./transform-handles";
import { MaskHandles } from "./mask-handles";
import { SnapGuides } from "./snap-guides";
import { TextEditOverlay } from "./text-edit-overlay";
import { usePropertiesStore } from "@/components/editor/panels/properties/stores/properties-store";
import { useEditor } from "@/editor/use-editor";
export function PreviewInteractionOverlay() {
const [snapLines, setSnapLines] = useState<SnapLine[]>([]);
const editor = useEditor();
const viewport = usePreviewViewport();
const selectedElements = useEditor((e) => e.selection.getSelectedElements());
const activeTabPerType = usePropertiesStore((s) => s.activeTabPerType);
const selectedRef =
selectedElements.length === 1 ? selectedElements[0] : null;
const activeTrack = selectedRef
? editor.timeline.getTrackById({ trackId: selectedRef.trackId })
: null;
const activeElement =
activeTrack?.elements.find(
(element) => element.id === selectedRef?.elementId,
) ?? null;
const isMaskMode = activeElement
? activeTabPerType[activeElement.type] === "masks"
: false;
const {
onPointerDown,
onPointerMove,
onPointerUp,
onDoubleClick,
editingText,
commitTextEdit,
} = usePreviewInteraction({
onSnapLinesChange: setSnapLines,
isMaskMode,
});
const handlePointerDown = (event: React.PointerEvent) => {
if (viewport.handlePanPointerDown({ event })) {
return;
}
onPointerDown(event);
};
const handlePointerMove = (event: React.PointerEvent) => {
if (viewport.handlePanPointerMove({ event })) {
return;
}
onPointerMove(event);
};
const handlePointerUp = (event: React.PointerEvent) => {
if (viewport.handlePanPointerUp({ event })) {
return;
}
onPointerUp(event);
};
return (
<div className="absolute inset-0">
<div
className="absolute inset-0 pointer-events-auto"
role="application"
aria-label="Preview canvas"
style={{
cursor: viewport.isPanning
? "grabbing"
: viewport.canPan
? "default"
: undefined,
}}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
onDoubleClick={onDoubleClick}
onDragStart={(e) => e.preventDefault()}
/>
{editingText ? (
<TextEditOverlay
trackId={editingText.trackId}
elementId={editingText.elementId}
element={editingText.element}
onCommit={commitTextEdit}
/>
) : isMaskMode ? (
<MaskHandles onSnapLinesChange={setSnapLines} />
) : (
<TransformHandles onSnapLinesChange={setSnapLines} />
)}
<SnapGuides lines={snapLines} />
</div>
);
}
@@ -0,0 +1,576 @@
"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
canvasToOverlay,
getDisplayScale,
positionToOverlay,
screenPixelsToLogicalThreshold,
screenToCanvas,
} from "@/preview/preview-coords";
import { clamp, isNearlyEqual } from "@/utils/math";
import { PREVIEW_ZOOM } from "@/preview/zoom";
const MIDDLE_MOUSE_BUTTON = 1;
const IS_AT_FIT_EPSILON = 0.001;
const IS_AT_ACTUAL_SIZE_EPSILON = 0.01;
interface PreviewViewportContextValue {
canPan: boolean;
isAtFit: boolean;
isAtActualSize: boolean;
isPanning: boolean;
sceneHeight: number;
sceneLeft: number;
sceneTop: number;
sceneWidth: number;
zoomPercent: number;
canvasToOverlay: ({
canvasX,
canvasY,
}: {
canvasX: number;
canvasY: number;
}) => { x: number; y: number };
fitToScreen: () => void;
getDisplayScale: () => { x: number; y: number };
handlePanPointerDown: ({ event }: { event: React.PointerEvent }) => boolean;
handlePanPointerMove: ({ event }: { event: React.PointerEvent }) => boolean;
handlePanPointerUp: ({ event }: { event: React.PointerEvent }) => boolean;
positionToOverlay: ({
positionX,
positionY,
}: {
positionX: number;
positionY: number;
}) => { x: number; y: number };
panByScreenDelta: ({
deltaX,
deltaY,
}: {
deltaX: number;
deltaY: number;
}) => void;
resetPan: () => void;
scaleZoom: ({ factor }: { factor: number }) => void;
screenPixelsToLogicalThreshold: ({
screenPixels,
}: {
screenPixels: number;
}) => { x: number; y: number };
screenToCanvas: ({
clientX,
clientY,
}: {
clientX: number;
clientY: number;
}) => { x: number; y: number } | null;
setActualSize: () => void;
setViewportPercent: ({ percent }: { percent: number }) => void;
zoomIn: () => void;
zoomOut: () => void;
}
const PreviewViewportContext =
createContext<PreviewViewportContextValue | null>(null);
interface PreviewViewportProviderProps {
children: React.ReactNode;
value: PreviewViewportContextValue;
}
interface PreviewViewportStateOptions {
canvasHeight: number;
canvasWidth: number;
viewportHeight: number;
viewportRef: React.RefObject<HTMLDivElement | null>;
viewportWidth: number;
}
interface PanSession {
centerX: number;
centerY: number;
clientX: number;
clientY: number;
pointerId: number;
}
function getClampedCenterAxis({
axisSize,
center,
scale,
viewportSize,
}: {
axisSize: number;
center: number;
scale: number;
viewportSize: number;
}): number {
if (axisSize <= 0 || scale <= 0 || viewportSize <= 0) {
return axisSize / 2;
}
const visibleHalfSpan = viewportSize / (2 * scale);
if (visibleHalfSpan >= axisSize / 2) {
return axisSize / 2;
}
return clamp({
value: center,
min: visibleHalfSpan,
max: axisSize - visibleHalfSpan,
});
}
function clampViewportCenter({
canvasHeight,
canvasWidth,
centerX,
centerY,
scale,
viewportHeight,
viewportWidth,
}: {
canvasHeight: number;
canvasWidth: number;
centerX: number;
centerY: number;
scale: number;
viewportHeight: number;
viewportWidth: number;
}): { x: number; y: number } {
return {
x: getClampedCenterAxis({
center: centerX,
axisSize: canvasWidth,
scale,
viewportSize: viewportWidth,
}),
y: getClampedCenterAxis({
center: centerY,
axisSize: canvasHeight,
scale,
viewportSize: viewportHeight,
}),
};
}
function getFitScale({
canvasHeight,
canvasWidth,
viewportHeight,
viewportWidth,
}: {
canvasHeight: number;
canvasWidth: number;
viewportHeight: number;
viewportWidth: number;
}): number {
if (
canvasHeight <= 0 ||
canvasWidth <= 0 ||
viewportHeight <= 0 ||
viewportWidth <= 0
) {
return 1;
}
return Math.min(viewportWidth / canvasWidth, viewportHeight / canvasHeight);
}
function getClampedZoom({ zoom }: { zoom: number }): number {
return clamp({
value: zoom,
min: PREVIEW_ZOOM.min,
max: PREVIEW_ZOOM.max,
});
}
export function PreviewViewportProvider({
children,
value,
}: PreviewViewportProviderProps) {
return (
<PreviewViewportContext.Provider value={value}>
{children}
</PreviewViewportContext.Provider>
);
}
export function usePreviewViewportState({
canvasHeight,
canvasWidth,
viewportHeight,
viewportRef,
viewportWidth,
}: PreviewViewportStateOptions): PreviewViewportContextValue {
const [zoom, setZoomState] = useState(1);
const [center, setCenter] = useState(() => ({
x: canvasWidth / 2,
y: canvasHeight / 2,
}));
const [isPanning, setIsPanning] = useState(false);
const panSessionRef = useRef<PanSession | null>(null);
const centerRef = useRef(center);
centerRef.current = center;
const fitScale = useMemo(
() =>
getFitScale({
canvasHeight,
canvasWidth,
viewportHeight,
viewportWidth,
}),
[canvasHeight, canvasWidth, viewportHeight, viewportWidth],
);
const viewportScale = fitScale * zoom;
const geometry = useMemo(
() => ({
canvasHeight,
canvasWidth,
centerX: center.x,
centerY: center.y,
scale: viewportScale,
viewportHeight,
viewportWidth,
}),
[
canvasHeight,
canvasWidth,
center.x,
center.y,
viewportHeight,
viewportScale,
viewportWidth,
],
);
const scaleZoom = useCallback(({ factor }: { factor: number }) => {
setZoomState((previousZoom) =>
getClampedZoom({
zoom: previousZoom * factor,
}),
);
}, []);
const panByScreenDelta = useCallback(
({ deltaX, deltaY }: { deltaX: number; deltaY: number }) => {
if (zoom <= 1 || (deltaX === 0 && deltaY === 0)) {
return;
}
setCenter((previousCenter) =>
clampViewportCenter({
canvasHeight,
canvasWidth,
centerX: previousCenter.x + deltaX / viewportScale,
centerY: previousCenter.y + deltaY / viewportScale,
scale: viewportScale,
viewportHeight,
viewportWidth,
}),
);
},
[
canvasHeight,
canvasWidth,
viewportHeight,
viewportScale,
viewportWidth,
zoom,
],
);
const resetPan = useCallback(() => {
setCenter({
x: canvasWidth / 2,
y: canvasHeight / 2,
});
}, [canvasHeight, canvasWidth]);
const fitToScreen = useCallback(() => {
setZoomState(1);
setCenter({
x: canvasWidth / 2,
y: canvasHeight / 2,
});
}, [canvasHeight, canvasWidth]);
const zoomIn = useCallback(() => {
setZoomState((previousZoom) =>
getClampedZoom({
zoom: previousZoom * PREVIEW_ZOOM.step,
}),
);
}, []);
const zoomOut = useCallback(() => {
setZoomState((previousZoom) =>
getClampedZoom({
zoom: previousZoom / PREVIEW_ZOOM.step,
}),
);
}, []);
const setActualSize = useCallback(() => {
const actualSizeZoom = fitScale > 0 ? 1 / fitScale : 1;
setZoomState(
getClampedZoom({
zoom: actualSizeZoom,
}),
);
}, [fitScale]);
const setViewportPercent = useCallback(
({ percent }: { percent: number }) => {
const targetZoom = fitScale > 0 ? percent / 100 / fitScale : 1;
setZoomState(getClampedZoom({ zoom: targetZoom }));
},
[fitScale],
);
const getViewportRect = useCallback(
() => viewportRef.current?.getBoundingClientRect() ?? null,
[viewportRef],
);
const screenToCanvasWithViewport = useCallback(
({ clientX, clientY }: { clientX: number; clientY: number }) => {
const viewportRect = getViewportRect();
if (!viewportRect) return null;
return screenToCanvas({
clientX,
clientY,
geometry,
viewportRect,
});
},
[getViewportRect, geometry],
);
const canvasToOverlayWithViewport = useCallback(
({ canvasX, canvasY }: { canvasX: number; canvasY: number }) =>
canvasToOverlay({
canvasX,
canvasY,
geometry,
}),
[geometry],
);
const positionToOverlayWithViewport = useCallback(
({ positionX, positionY }: { positionX: number; positionY: number }) =>
positionToOverlay({
geometry,
positionX,
positionY,
}),
[geometry],
);
const getDisplayScaleWithViewport = useCallback(
() => getDisplayScale({ geometry }),
[geometry],
);
const getLogicalThresholdForScreenPixels = useCallback(
({ screenPixels }: { screenPixels: number }) =>
screenPixelsToLogicalThreshold({
geometry,
screenPixels,
}),
[geometry],
);
const handlePanPointerDown = useCallback(
({ event }: { event: React.PointerEvent }) => {
if (event.button !== MIDDLE_MOUSE_BUTTON || zoom <= 1) {
return false;
}
event.preventDefault();
event.stopPropagation();
panSessionRef.current = {
centerX: centerRef.current.x,
centerY: centerRef.current.y,
clientX: event.clientX,
clientY: event.clientY,
pointerId: event.pointerId,
};
setIsPanning(true);
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
return true;
},
[zoom],
);
const handlePanPointerMove = useCallback(
({ event }: { event: React.PointerEvent }) => {
const panSession = panSessionRef.current;
if (!panSession) {
return false;
}
event.preventDefault();
event.stopPropagation();
const deltaX = event.clientX - panSession.clientX;
const deltaY = event.clientY - panSession.clientY;
const nextCenter = clampViewportCenter({
canvasHeight,
canvasWidth,
centerX: panSession.centerX - deltaX / viewportScale,
centerY: panSession.centerY - deltaY / viewportScale,
scale: viewportScale,
viewportHeight,
viewportWidth,
});
setCenter(nextCenter);
return true;
},
[canvasHeight, canvasWidth, viewportHeight, viewportScale, viewportWidth],
);
const handlePanPointerUp = useCallback(
({ event }: { event: React.PointerEvent }) => {
const panSession = panSessionRef.current;
if (!panSession) {
return false;
}
if (
(event.currentTarget as HTMLElement).hasPointerCapture(
panSession.pointerId,
)
) {
(event.currentTarget as HTMLElement).releasePointerCapture(
panSession.pointerId,
);
}
panSessionRef.current = null;
setIsPanning(false);
return true;
},
[],
);
useEffect(() => {
setZoomState(1);
setCenter({
x: canvasWidth / 2,
y: canvasHeight / 2,
});
panSessionRef.current = null;
setIsPanning(false);
}, [canvasHeight, canvasWidth]);
useEffect(() => {
setCenter((previousCenter) =>
clampViewportCenter({
canvasHeight,
canvasWidth,
centerX: previousCenter.x,
centerY: previousCenter.y,
scale: viewportScale,
viewportHeight,
viewportWidth,
}),
);
}, [canvasHeight, canvasWidth, viewportHeight, viewportScale, viewportWidth]);
const sceneWidth = canvasWidth * viewportScale;
const sceneHeight = canvasHeight * viewportScale;
const sceneLeft = viewportWidth / 2 - center.x * viewportScale;
const sceneTop = viewportHeight / 2 - center.y * viewportScale;
const canPan = zoom > 1;
const zoomPercent = Math.round(viewportScale * 100);
return useMemo(
() => ({
canPan,
isAtActualSize: isNearlyEqual({
leftValue: viewportScale,
rightValue: 1,
epsilon: IS_AT_ACTUAL_SIZE_EPSILON,
}),
isAtFit: isNearlyEqual({
leftValue: zoom,
rightValue: 1,
epsilon: IS_AT_FIT_EPSILON,
}),
isPanning,
sceneHeight,
sceneLeft,
sceneTop,
sceneWidth,
zoomPercent,
canvasToOverlay: canvasToOverlayWithViewport,
fitToScreen,
getDisplayScale: getDisplayScaleWithViewport,
handlePanPointerDown,
handlePanPointerMove,
handlePanPointerUp,
panByScreenDelta,
positionToOverlay: positionToOverlayWithViewport,
resetPan,
scaleZoom,
screenPixelsToLogicalThreshold: getLogicalThresholdForScreenPixels,
screenToCanvas: screenToCanvasWithViewport,
setActualSize,
setViewportPercent,
zoomIn,
zoomOut,
}),
[
canPan,
viewportScale,
zoom,
isPanning,
sceneHeight,
sceneLeft,
sceneTop,
sceneWidth,
zoomPercent,
canvasToOverlayWithViewport,
fitToScreen,
getDisplayScaleWithViewport,
handlePanPointerDown,
handlePanPointerMove,
handlePanPointerUp,
panByScreenDelta,
positionToOverlayWithViewport,
resetPan,
scaleZoom,
getLogicalThresholdForScreenPixels,
screenToCanvasWithViewport,
setActualSize,
setViewportPercent,
zoomIn,
zoomOut,
],
);
}
export function usePreviewViewport() {
const context = useContext(PreviewViewportContext);
if (!context) {
throw new Error(
"usePreviewViewport must be used within PreviewViewportProvider",
);
}
return context;
}
@@ -0,0 +1,47 @@
"use client";
import { usePreviewViewport } from "@/preview/components/preview-viewport";
import type { SnapLine } from "@/preview/preview-snap";
export function SnapGuides({ lines }: { lines: SnapLine[] }) {
const viewport = usePreviewViewport();
if (lines.length === 0) {
return null;
}
const toOverlayX = (logicalX: number) =>
viewport.positionToOverlay({
positionX: logicalX,
positionY: 0,
}).x;
const toOverlayY = (logicalY: number) =>
viewport.positionToOverlay({
positionX: 0,
positionY: logicalY,
}).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>
);
}
@@ -0,0 +1,158 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
import { usePreviewViewport } from "@/preview/components/preview-viewport";
import { useEditor } from "@/editor/use-editor";
import type { TextElement } from "@/timeline";
import { DEFAULTS } from "@/timeline/defaults";
import {
getElementLocalTime,
resolveTransformAtTime,
} from "@/animation";
import { resolveTextLayout } from "@/text/primitives";
export function TextEditOverlay({
trackId,
elementId,
element,
onCommit,
}: {
trackId: string;
elementId: string;
element: TextElement;
onCommit: () => void;
}) {
const editor = useEditor();
const viewport = usePreviewViewport();
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 }: { event: React.KeyboardEvent }) => {
const { key } = event;
if (key === "Escape") {
event.preventDefault();
onCommit();
return;
}
},
[onCommit],
);
const canvasSize = editor.project.getActive().settings.canvasSize;
if (!canvasSize) return null;
const currentTime = editor.playback.getCurrentTime();
const localTime = getElementLocalTime({
timelineTime: currentTime,
elementStartTime: element.startTime,
elementDuration: element.duration,
});
const transform = resolveTransformAtTime({
baseTransform: element.transform,
animations: element.animations,
localTime,
});
const { x: posX, y: posY } = viewport.positionToOverlay({
positionX: transform.position.x,
positionY: transform.position.y,
});
const { x: displayScaleX } = viewport.getDisplayScale();
const resolvedTextLayout = resolveTextLayout({
text: {
content: element.content,
fontSize: element.fontSize,
fontFamily: element.fontFamily,
fontWeight: element.fontWeight,
fontStyle: element.fontStyle,
textAlign: element.textAlign,
textDecoration: element.textDecoration,
letterSpacing: element.letterSpacing,
lineHeight: element.lineHeight,
},
canvasHeight: canvasSize.height,
});
const lineHeight = element.lineHeight ?? DEFAULTS.text.lineHeight;
const canvasLetterSpacing = element.letterSpacing ?? 0;
const lineHeightPx = resolvedTextLayout.lineHeightPx;
const bg = element.background;
const shouldShowBackground =
bg.enabled && bg.color && bg.color !== "transparent";
const fontSizeRatio = resolvedTextLayout.fontSizeRatio;
const canvasPaddingX = shouldShowBackground
? (bg.paddingX ?? DEFAULTS.text.background.paddingX) * fontSizeRatio
: 0;
const canvasPaddingY = shouldShowBackground
? (bg.paddingY ?? DEFAULTS.text.background.paddingY) * fontSizeRatio
: 0;
return (
<div
className="absolute"
style={{
left: posX,
top: posY,
transform: `translate(-50%, -50%) scale(${transform.scaleX * displayScaleX}, ${transform.scaleY * displayScaleX}) rotate(${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: resolvedTextLayout.scaledFontSize,
fontFamily: element.fontFamily,
fontWeight: element.fontWeight === "bold" ? "bold" : "normal",
fontStyle: element.fontStyle === "italic" ? "italic" : "normal",
textAlign: element.textAlign,
letterSpacing: `${canvasLetterSpacing}px`,
lineHeight,
color: "transparent",
caretColor: element.color,
backgroundColor: shouldShowBackground ? bg.color : "transparent",
minHeight: lineHeightPx,
textDecoration: element.textDecoration ?? "none",
padding: shouldShowBackground
? `${canvasPaddingY}px ${canvasPaddingX}px`
: 0,
minWidth: 1,
}}
onInput={handleInput}
onBlur={onCommit}
onKeyDown={(event) => handleKeyDown({ event })}
>
{element.content || ""}
</div>
</div>
);
}
+148
View File
@@ -0,0 +1,148 @@
"use client";
import { useState, useEffect } from "react";
import { useEditor } from "@/editor/use-editor";
import { formatTimecode } from "opencut-wasm";
import { invokeAction } from "@/actions";
import { EditableTimecode } from "@/components/editable-timecode";
import { Button } from "@/components/ui/button";
import {
FullScreenIcon,
GridTableIcon,
PauseIcon,
PlayIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { getGuideById } from "@/guides";
import { Separator } from "@/components/ui/separator";
import {
Select,
SelectTrigger,
SelectContent,
SelectItem,
SelectSeparator,
} from "@/components/ui/select";
import { PREVIEW_ZOOM_PRESETS } from "@/preview/zoom";
import { usePreviewViewport } from "./preview-viewport";
import { GridPopover } from "./guide-popover";
import { usePreviewStore } from "@/preview/preview-store";
export function PreviewToolbar({
onToggleFullscreen,
}: {
onToggleFullscreen: () => void;
}) {
const activeGuide = usePreviewStore((state) => state.activeGuide);
const activeGuideDefinition = getGuideById(activeGuide);
return (
<div className="grid grid-cols-[1fr_auto_1fr] items-center pb-3 pt-5 px-5">
<TimecodeDisplay />
<PlayPauseButton />
<div className="justify-self-end flex items-center gap-2.5">
<ZoomSelect />
<Separator orientation="vertical" className="h-4" />
{/* v0.4.0 */}
{/* <GridPopover>
<Button
variant={activeGuideDefinition ? "secondary" : "text"}
size="icon"
>
{activeGuideDefinition ? (
activeGuideDefinition.renderTriggerIcon()
) : (
<HugeiconsIcon icon={GridTableIcon} />
)}
</Button>
</GridPopover> */}
<Button variant="text" onClick={onToggleFullscreen}>
<HugeiconsIcon icon={FullScreenIcon} />
</Button>
</div>
</div>
);
}
function TimecodeDisplay() {
const editor = useEditor();
const totalDuration = useEditor((e) => e.timeline.getTotalDuration());
const fps = useEditor((e) => e.project.getActive().settings.fps);
const [currentTime, setCurrentTime] = useState(() =>
editor.playback.getCurrentTime(),
);
useEffect(() => {
const handler = (e: Event) =>
setCurrentTime((e as CustomEvent<{ time: number }>).detail.time);
window.addEventListener("playback-update", handler);
window.addEventListener("playback-seek", handler);
return () => {
window.removeEventListener("playback-update", handler);
window.removeEventListener("playback-seek", handler);
};
}, []);
return (
<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({ time: totalDuration, format: "HH:MM:SS:FF", rate: fps })}
</span>
</div>
);
}
function ZoomSelect() {
const { isAtFit, zoomPercent, fitToScreen, setViewportPercent } =
usePreviewViewport();
const displayLabel = isAtFit ? "Fit" : `${zoomPercent}%`;
const onValueChange = (value: string) => {
if (value === "fit") {
fitToScreen();
} else {
setViewportPercent({ percent: Number(value) });
}
};
return (
<Select
value={isAtFit ? "fit" : String(zoomPercent)}
onValueChange={onValueChange}
>
<SelectTrigger className="tabular-nums">{displayLabel}</SelectTrigger>
<SelectContent>
<SelectItem value="fit">Fit</SelectItem>
<SelectSeparator />
{PREVIEW_ZOOM_PRESETS.map((preset) => (
<SelectItem key={preset} value={String(preset)}>
{preset}%
</SelectItem>
))}
</SelectContent>
</Select>
);
}
function PlayPauseButton() {
const isPlaying = useEditor((e) => e.playback.getIsPlaying());
return (
<Button
variant="text"
size="icon"
onClick={() => invokeAction("toggle-play")}
>
<HugeiconsIcon icon={isPlaying ? PauseIcon : PlayIcon} />
</Button>
);
}
@@ -0,0 +1,145 @@
"use client";
import { usePreviewViewport } from "@/preview/components/preview-viewport";
import { useTransformHandles } from "@/preview/hooks/use-transform-handles";
import { isVisualElement } from "@/timeline/element-utils";
import {
getCornerPosition,
getEdgeHandlePosition,
ROTATION_HANDLE_OFFSET,
type Corner,
type Edge,
} from "@/preview/element-bounds";
import type { OnSnapLinesChange } from "@/preview/hooks/use-preview-interaction";
import {
BoundingBoxOutline,
CornerHandle,
EdgeHandle,
IconHandle,
getResizeCursor,
} from "./handle-primitives";
import { Rotate01Icon } from "@hugeicons/core-free-icons";
const CORNERS: Corner[] = [
"top-left",
"top-right",
"bottom-left",
"bottom-right",
];
const EDGES: Edge[] = ["right", "left", "bottom"];
export function TransformHandles({
onSnapLinesChange,
}: {
onSnapLinesChange?: OnSnapLinesChange;
}) {
const viewport = usePreviewViewport();
const {
selectedWithBounds,
hasVisualSelection,
handleCornerPointerDown,
handleEdgePointerDown,
handleRotationPointerDown,
handlePointerMove,
handlePointerUp,
} = useTransformHandles({ onSnapLinesChange });
if (!hasVisualSelection || !selectedWithBounds) return null;
const { bounds, element } = selectedWithBounds;
if (!isVisualElement(element)) return null;
const displayScale = viewport.getDisplayScale();
const toOverlay = ({
canvasX,
canvasY,
}: {
canvasX: number;
canvasY: number;
}) =>
viewport.canvasToOverlay({
canvasX,
canvasY,
});
const center = toOverlay({ canvasX: bounds.cx, canvasY: bounds.cy });
const outlineWidth = Math.abs(bounds.width) * displayScale.x;
const outlineHeight = Math.abs(bounds.height) * displayScale.y;
const rotationAngleRad = (bounds.rotation * Math.PI) / 180;
const topCenterLocalY = -bounds.height / 2;
const topCenterScreen = toOverlay({
canvasX: bounds.cx - topCenterLocalY * Math.sin(rotationAngleRad),
canvasY: bounds.cy + topCenterLocalY * Math.cos(rotationAngleRad),
});
const rotationHandleScreen = {
x: topCenterScreen.x + Math.sin(rotationAngleRad) * ROTATION_HANDLE_OFFSET,
y: topCenterScreen.y - Math.cos(rotationAngleRad) * ROTATION_HANDLE_OFFSET,
};
const onPointerMove = (event: React.PointerEvent) =>
handlePointerMove({ event });
const onPointerUp = () => handlePointerUp();
return (
<div
className="pointer-events-none absolute inset-0 overflow-hidden"
aria-hidden
>
<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,
});
const angleDeg =
Math.atan2(screen.y - center.y, screen.x - center.x) *
(180 / Math.PI);
return (
<CornerHandle
key={corner}
cursor={getResizeCursor({ angleDeg })}
screen={screen}
onPointerDown={(event) =>
handleCornerPointerDown({ event, corner })
}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
/>
);
})}
{EDGES.map((edge) => {
const edgePosition = getEdgeHandlePosition({ bounds, edge });
const screen = toOverlay({
canvasX: edgePosition.x,
canvasY: edgePosition.y,
});
return (
<EdgeHandle
key={edge}
edge={edge}
screen={screen}
rotation={bounds.rotation}
onPointerDown={(event) => handleEdgePointerDown({ event, edge })}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
/>
);
})}
<IconHandle
icon={Rotate01Icon}
screen={rotationHandleScreen}
onPointerDown={(event) => handleRotationPointerDown({ event })}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
/>
</div>
);
}