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>
);
}
+308
View File
@@ -0,0 +1,308 @@
import type { SceneTracks, TimelineElement } from "@/timeline";
import type { MediaAsset } from "@/media/types";
import { STICKER_INTRINSIC_SIZE_FALLBACK } from "@/stickers/intrinsic-size";
import { DEFAULT_GRAPHIC_SOURCE_SIZE } from "@/graphics";
import { measureTextElement } from "@/text/measure-element";
import {
getElementLocalTime,
resolveTransformAtTime,
} from "@/animation";
export interface ElementBounds {
cx: number;
cy: number;
width: number;
height: number;
rotation: number;
}
export interface ElementWithBounds {
trackId: string;
elementId: string;
element: TimelineElement;
bounds: ElementBounds;
}
function getVisualElementBounds({
canvasWidth,
canvasHeight,
sourceWidth,
sourceHeight,
transform,
}: {
canvasWidth: number;
canvasHeight: number;
sourceWidth: number;
sourceHeight: number;
transform: {
scaleX: number;
scaleY: number;
position: { x: number; y: number };
rotate: number;
};
}): ElementBounds {
const containScale = Math.min(
canvasWidth / sourceWidth,
canvasHeight / sourceHeight,
);
const scaledWidth = sourceWidth * containScale * transform.scaleX;
const scaledHeight = sourceHeight * containScale * transform.scaleY;
const cx = canvasWidth / 2 + transform.position.x;
const cy = canvasHeight / 2 + transform.position.y;
return {
cx,
cy,
width: scaledWidth,
height: scaledHeight,
rotation: transform.rotate,
};
}
function getTransformedRectBounds({
canvasWidth,
canvasHeight,
rect,
transform,
}: {
canvasWidth: number;
canvasHeight: number;
rect: { left: number; top: number; width: number; height: number };
transform: {
scaleX: number;
scaleY: number;
position: { x: number; y: number };
rotate: number;
};
}): ElementBounds {
const localCenterX = rect.left + rect.width / 2;
const localCenterY = rect.top + rect.height / 2;
const scaledCenterX = localCenterX * transform.scaleX;
const scaledCenterY = localCenterY * transform.scaleY;
const rotationRad = (transform.rotate * Math.PI) / 180;
const cos = Math.cos(rotationRad);
const sin = Math.sin(rotationRad);
return {
cx:
canvasWidth / 2 +
transform.position.x +
scaledCenterX * cos -
scaledCenterY * sin,
cy:
canvasHeight / 2 +
transform.position.y +
scaledCenterX * sin +
scaledCenterY * cos,
width: rect.width * transform.scaleX,
height: rect.height * transform.scaleY,
rotation: transform.rotate,
};
}
/**
* Bounds policy: bounds reflect base content geometry (text glyphs + background,
* sticker/image/video content area) and base transform. Post-effect spill (blur,
* glow) and mask-clipped regions are intentionally excluded — handles manipulate
* the canonical element geometry, not visual effect output.
*/
function getElementBounds({
element,
canvasSize,
mediaAsset,
localTime,
}: {
element: TimelineElement;
canvasSize: { width: number; height: number };
mediaAsset?: MediaAsset | null;
localTime: number;
}): ElementBounds | null {
if (element.type === "audio" || element.type === "effect") return null;
if ("hidden" in element && element.hidden) return null;
const { width: canvasWidth, height: canvasHeight } = canvasSize;
if (element.type === "video" || element.type === "image") {
const transform = resolveTransformAtTime({
baseTransform: element.transform,
animations: element.animations,
localTime,
});
const sourceWidth = mediaAsset?.width ?? canvasWidth;
const sourceHeight = mediaAsset?.height ?? canvasHeight;
return getVisualElementBounds({
canvasWidth,
canvasHeight,
sourceWidth,
sourceHeight,
transform,
});
}
if (element.type === "sticker") {
const transform = resolveTransformAtTime({
baseTransform: element.transform,
animations: element.animations,
localTime,
});
return getVisualElementBounds({
canvasWidth,
canvasHeight,
sourceWidth: element.intrinsicWidth ?? STICKER_INTRINSIC_SIZE_FALLBACK,
sourceHeight: element.intrinsicHeight ?? STICKER_INTRINSIC_SIZE_FALLBACK,
transform,
});
}
if (element.type === "graphic") {
const transform = resolveTransformAtTime({
baseTransform: element.transform,
animations: element.animations,
localTime,
});
return getVisualElementBounds({
canvasWidth,
canvasHeight,
sourceWidth: DEFAULT_GRAPHIC_SOURCE_SIZE,
sourceHeight: DEFAULT_GRAPHIC_SOURCE_SIZE,
transform,
});
}
if (element.type === "text") {
const transform = resolveTransformAtTime({
baseTransform: element.transform,
animations: element.animations,
localTime,
});
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
if (!ctx) return null;
const measured = measureTextElement({
element,
canvasHeight,
localTime,
ctx,
});
return getTransformedRectBounds({
canvasWidth,
canvasHeight,
rect: measured.visualRect,
transform,
});
}
return null;
}
export const ROTATION_HANDLE_OFFSET = 24;
export type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
export type Edge = "right" | "left" | "bottom";
export 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),
};
}
export function getEdgeHandlePosition({
bounds,
edge,
}: {
bounds: ElementBounds;
edge: Edge;
}): { x: number; y: number } {
const halfWidth = bounds.width / 2;
const halfHeight = bounds.height / 2;
const angleRad = (bounds.rotation * Math.PI) / 180;
const cos = Math.cos(angleRad);
const sin = Math.sin(angleRad);
const localX = edge === "right" ? halfWidth : edge === "left" ? -halfWidth : 0;
const localY = edge === "bottom" ? halfHeight : 0;
return {
x: bounds.cx + (localX * cos - localY * sin),
y: bounds.cy + (localX * sin + localY * cos),
};
}
export function getVisibleElementsWithBounds({
tracks,
currentTime,
canvasSize,
mediaAssets,
}: {
tracks: SceneTracks;
currentTime: number;
canvasSize: { width: number; height: number };
mediaAssets: MediaAsset[];
}): ElementWithBounds[] {
const mediaMap = new Map(mediaAssets.map((m) => [m.id, m]));
const orderedTracks = [
...tracks.overlay.filter((track) => !("hidden" in track && track.hidden)),
...(!tracks.main.hidden ? [tracks.main] : []),
].reverse();
const result: ElementWithBounds[] = [];
for (const track of orderedTracks) {
const elements = track.elements
.filter((element) => !("hidden" in element && element.hidden))
.filter(
(element) =>
currentTime >= element.startTime &&
currentTime < element.startTime + element.duration,
)
.slice()
.sort((a, b) => {
if (a.startTime !== b.startTime) return a.startTime - b.startTime;
return a.id.localeCompare(b.id);
});
for (const element of elements) {
const localTime = getElementLocalTime({
timelineTime: currentTime,
elementStartTime: element.startTime,
elementDuration: element.duration,
});
const mediaAsset =
element.type === "video" || element.type === "image"
? mediaMap.get(element.mediaId)
: undefined;
const bounds = getElementBounds({
element,
canvasSize,
mediaAsset,
localTime,
});
if (bounds) {
result.push({
trackId: track.id,
elementId: element.id,
element,
bounds,
});
}
}
}
return result;
}
+102
View File
@@ -0,0 +1,102 @@
import type { ElementWithBounds } from "./element-bounds";
import type { ElementRef } from "@/timeline/types";
function pointInRotatedRect({
px,
py,
cx,
cy,
width,
height,
rotation,
}: {
px: number;
py: number;
cx: number;
cy: number;
width: number;
height: number;
rotation: number;
}): boolean {
const angleRad = (rotation * Math.PI) / 180;
const cos = Math.cos(-angleRad);
const sin = Math.sin(-angleRad);
const dx = px - cx;
const dy = py - cy;
const localX = dx * cos - dy * sin;
const localY = dx * sin + dy * cos;
const halfW = Math.abs(width) / 2;
const halfH = Math.abs(height) / 2;
return (
localX >= -halfW && localX <= halfW && localY >= -halfH && localY <= halfH
);
}
export function getHitElements({
canvasX,
canvasY,
elementsWithBounds,
}: {
canvasX: number;
canvasY: number;
elementsWithBounds: ElementWithBounds[];
}): ElementWithBounds[] {
const hits: ElementWithBounds[] = [];
for (let i = elementsWithBounds.length - 1; i >= 0; i--) {
const { bounds } = elementsWithBounds[i];
if (
pointInRotatedRect({
px: canvasX,
py: canvasY,
cx: bounds.cx,
cy: bounds.cy,
width: bounds.width,
height: bounds.height,
rotation: bounds.rotation,
})
) {
hits.push(elementsWithBounds[i]);
}
}
return hits;
}
export function hitTest({
canvasX,
canvasY,
elementsWithBounds,
}: {
canvasX: number;
canvasY: number;
elementsWithBounds: ElementWithBounds[];
}): ElementWithBounds | null {
return (
getHitElements({
canvasX,
canvasY,
elementsWithBounds,
})[0] ?? null
);
}
export function resolvePreferredHit({
hits,
preferredElements,
}: {
hits: ElementWithBounds[];
preferredElements: ElementRef[];
}): ElementWithBounds | null {
if (preferredElements.length === 0) return null;
return (
hits.find((hit) =>
preferredElements.some(
(preferredElement) =>
preferredElement.trackId === hit.trackId &&
preferredElement.elementId === hit.elementId,
),
) ?? null
);
}
@@ -0,0 +1,456 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useEditor } from "@/editor/use-editor";
import { useShiftKey } from "@/hooks/use-shift-key";
import { usePreviewViewport } from "@/preview/components/preview-viewport";
import type { Transform } from "@/rendering";
import type { ElementRef, TextElement } from "@/timeline";
import {
getVisibleElementsWithBounds,
type ElementWithBounds,
} from "@/preview/element-bounds";
import {
getHitElements,
hitTest,
resolvePreferredHit,
} from "@/preview/hit-test";
import { isVisualElement } from "@/timeline/element-utils";
import {
SNAP_THRESHOLD_SCREEN_PIXELS,
snapPosition,
type SnapLine,
} from "@/preview/preview-snap";
import { registerCanceller } from "@/editor/cancel-interaction";
export type OnSnapLinesChange = (lines: SnapLine[]) => void;
const MIN_DRAG_DISTANCE = 0.5;
interface CapturedPointerState {
pointerId: number;
captureTarget: HTMLElement;
}
interface PendingGestureState extends CapturedPointerState {
startX: number;
startY: number;
topmostHit: ElementWithBounds | null;
selectedHit: ElementWithBounds | null;
selectedElements: ElementRef[];
}
interface DragState extends CapturedPointerState {
startX: number;
startY: number;
bounds: {
width: number;
height: number;
rotation: number;
};
elements: Array<{
trackId: string;
elementId: string;
initialTransform: Transform;
}>;
}
function isSameElementRef({
left,
right,
}: {
left: ElementRef;
right: ElementRef;
}): boolean {
return left.trackId === right.trackId && left.elementId === right.elementId;
}
function buildDragSelection({
selectedElements,
dragTarget,
}: {
selectedElements: ElementRef[];
dragTarget: ElementWithBounds;
}): ElementRef[] {
const dragTargetRef = {
trackId: dragTarget.trackId,
elementId: dragTarget.elementId,
};
if (
!selectedElements.some((selectedElement) =>
isSameElementRef({ left: selectedElement, right: dragTargetRef }),
)
) {
return [dragTargetRef];
}
return [
dragTargetRef,
...selectedElements.filter(
(selectedElement) =>
!isSameElementRef({ left: selectedElement, right: dragTargetRef }),
),
];
}
export function usePreviewInteraction({
onSnapLinesChange,
isMaskMode = false,
}: {
onSnapLinesChange?: OnSnapLinesChange;
isMaskMode?: boolean;
}) {
const editor = useEditor();
const isShiftHeldRef = useShiftKey();
const viewport = usePreviewViewport();
const [isDragging, setIsDragging] = useState(false);
const [editingText, setEditingText] = useState<{
trackId: string;
elementId: string;
element: TextElement;
originalOpacity: number;
} | null>(null);
const dragStateRef = useRef<DragState | null>(null);
const pendingGestureRef = useRef<PendingGestureState | null>(null);
const wasPlayingRef = useRef(editor.playback.getIsPlaying());
const editingTextRef = useRef(editingText);
editingTextRef.current = editingText;
const releaseCapturedPointer = useCallback(
(pointerState: CapturedPointerState | null) => {
if (!pointerState) return;
if (
!pointerState.captureTarget.hasPointerCapture(pointerState.pointerId)
) {
return;
}
pointerState.captureTarget.releasePointerCapture(pointerState.pointerId);
},
[],
);
const commitTextEdit = useCallback(() => {
const current = editingTextRef.current;
if (!current) return;
editingTextRef.current = null;
editor.timeline.commitPreview();
setEditingText(null);
}, [editor.timeline]);
useEffect(() => {
const unsubscribe = editor.playback.subscribe(() => {
const isPlaying = editor.playback.getIsPlaying();
if (isPlaying && !wasPlayingRef.current && editingTextRef.current) {
commitTextEdit();
}
wasPlayingRef.current = isPlaying;
});
return unsubscribe;
}, [editor.playback, commitTextEdit]);
useEffect(() => {
if (!isDragging) return;
return registerCanceller({
fn: () => {
const dragState = dragStateRef.current;
if (!dragState) return;
editor.timeline.discardPreview();
dragStateRef.current = null;
pendingGestureRef.current = null;
setIsDragging(false);
onSnapLinesChange?.([]);
releaseCapturedPointer(dragState);
},
});
}, [editor.timeline, isDragging, onSnapLinesChange, releaseCapturedPointer]);
const handleDoubleClick = useCallback(
({ clientX, clientY }: React.MouseEvent) => {
if (editingText || isMaskMode) return;
const tracks = editor.scenes.getActiveScene().tracks;
const currentTime = editor.playback.getCurrentTime();
const mediaAssets = editor.media.getAssets();
const canvasSize = editor.project.getActive().settings.canvasSize;
const startPos = viewport.screenToCanvas({
clientX,
clientY,
});
if (!startPos) return;
const elementsWithBounds = getVisibleElementsWithBounds({
tracks,
currentTime,
canvasSize,
mediaAssets,
});
const hit = hitTest({
canvasX: startPos.x,
canvasY: startPos.y,
elementsWithBounds,
});
if (!hit || hit.element.type !== "text") return;
const textElement = hit.element as TextElement;
setEditingText({
trackId: hit.trackId,
elementId: hit.elementId,
element: textElement,
originalOpacity: textElement.opacity,
});
},
[editor, editingText, isMaskMode, viewport],
);
const handlePointerDown = useCallback(
({
clientX,
clientY,
currentTarget,
pointerId,
button,
}: React.PointerEvent) => {
if (editingText) return;
if (isMaskMode) return;
if (button !== 0) return;
const tracks = editor.scenes.getActiveScene().tracks;
const currentTime = editor.playback.getCurrentTime();
const mediaAssets = editor.media.getAssets();
const canvasSize = editor.project.getActive().settings.canvasSize;
const startPos = viewport.screenToCanvas({
clientX,
clientY,
});
if (!startPos) return;
const elementsWithBounds = getVisibleElementsWithBounds({
tracks,
currentTime,
canvasSize,
mediaAssets,
});
const hits = getHitElements({
canvasX: startPos.x,
canvasY: startPos.y,
elementsWithBounds,
});
const selectedElements = editor.selection.getSelectedElements();
const topmostHit = hits[0] ?? null;
pendingGestureRef.current = {
startX: startPos.x,
startY: startPos.y,
pointerId,
captureTarget: currentTarget as HTMLElement,
topmostHit,
selectedHit: resolvePreferredHit({
hits,
preferredElements: selectedElements,
}),
selectedElements,
};
currentTarget.setPointerCapture(pointerId);
},
[editor, editingText, isMaskMode, viewport],
);
const handlePointerMove = useCallback(
({ clientX, clientY }: React.PointerEvent) => {
const canvasSize = editor.project.getActive().settings.canvasSize;
const currentPos = viewport.screenToCanvas({
clientX,
clientY,
});
if (!currentPos) return;
let dragState = dragStateRef.current;
if (!dragState) {
const pendingGesture = pendingGestureRef.current;
if (!pendingGesture) return;
const deltaX = currentPos.x - pendingGesture.startX;
const deltaY = currentPos.y - pendingGesture.startY;
const hasMovement =
Math.abs(deltaX) > MIN_DRAG_DISTANCE ||
Math.abs(deltaY) > MIN_DRAG_DISTANCE;
if (!hasMovement) {
onSnapLinesChange?.([]);
return;
}
const dragTarget = pendingGesture.selectedHit ?? pendingGesture.topmostHit;
if (!dragTarget) {
pendingGestureRef.current = null;
onSnapLinesChange?.([]);
releaseCapturedPointer(pendingGesture);
return;
}
const dragSelection = buildDragSelection({
selectedElements: pendingGesture.selectedElements,
dragTarget,
});
const elementsWithTracks = editor.timeline.getElementsWithTracks({
elements: dragSelection,
});
const draggableElements = elementsWithTracks.filter(({ element }) =>
isVisualElement(element),
);
if (draggableElements.length === 0) {
pendingGestureRef.current = null;
onSnapLinesChange?.([]);
releaseCapturedPointer(pendingGesture);
return;
}
if (pendingGesture.selectedHit === null) {
editor.selection.setSelectedElements({
elements: [
{
trackId: dragTarget.trackId,
elementId: dragTarget.elementId,
},
],
});
}
dragState = {
startX: pendingGesture.startX,
startY: pendingGesture.startY,
pointerId: pendingGesture.pointerId,
captureTarget: pendingGesture.captureTarget,
bounds: {
width: dragTarget.bounds.width,
height: dragTarget.bounds.height,
rotation: dragTarget.bounds.rotation,
},
elements: draggableElements.map(({ track, element }) => ({
trackId: track.id,
elementId: element.id,
initialTransform: (element as { transform: Transform }).transform,
})),
};
dragStateRef.current = dragState;
pendingGestureRef.current = null;
setIsDragging(true);
}
const deltaX = currentPos.x - dragState.startX;
const deltaY = currentPos.y - dragState.startY;
const firstElement = dragState.elements[0];
const proposedPosition = {
x: firstElement.initialTransform.position.x + deltaX,
y: firstElement.initialTransform.position.y + deltaY,
};
const shouldSnap = !isShiftHeldRef.current;
const snapThreshold = viewport.screenPixelsToLogicalThreshold({
screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS,
});
const { snappedPosition, activeLines } = shouldSnap
? snapPosition({
proposedPosition,
canvasSize,
elementSize: dragState.bounds,
rotation: dragState.bounds.rotation,
snapThreshold,
})
: {
snappedPosition: proposedPosition,
activeLines: [] as SnapLine[],
};
onSnapLinesChange?.(activeLines);
const deltaSnappedX =
snappedPosition.x - firstElement.initialTransform.position.x;
const deltaSnappedY =
snappedPosition.y - firstElement.initialTransform.position.y;
const updates = dragState.elements.map(
({ trackId, elementId, initialTransform }) => ({
trackId,
elementId,
updates: {
transform: {
...initialTransform,
position: {
x: initialTransform.position.x + deltaSnappedX,
y: initialTransform.position.y + deltaSnappedY,
},
},
},
}),
);
editor.timeline.previewElements({ updates });
},
[editor, isShiftHeldRef, onSnapLinesChange, releaseCapturedPointer, viewport],
);
const handlePointerUp = useCallback(
({ type }: React.PointerEvent) => {
const dragState = dragStateRef.current;
if (dragState) {
if (type === "pointercancel") {
editor.timeline.discardPreview();
} else {
editor.timeline.commitPreview();
}
dragStateRef.current = null;
pendingGestureRef.current = null;
setIsDragging(false);
onSnapLinesChange?.([]);
releaseCapturedPointer(dragState);
return;
}
const pendingGesture = pendingGestureRef.current;
if (!pendingGesture) return;
if (type !== "pointercancel") {
const clickTarget = pendingGesture.topmostHit;
if (!clickTarget) {
editor.selection.clearSelection();
} else {
editor.selection.setSelectedElements({
elements: [
{
trackId: clickTarget.trackId,
elementId: clickTarget.elementId,
},
],
});
}
}
pendingGestureRef.current = null;
onSnapLinesChange?.([]);
releaseCapturedPointer(pendingGesture);
},
[editor, onSnapLinesChange, releaseCapturedPointer],
);
return {
onPointerDown: handlePointerDown,
onPointerMove: handlePointerMove,
onPointerUp: handlePointerUp,
onDoubleClick: handleDoubleClick,
editingText,
commitTextEdit,
};
}
@@ -0,0 +1,629 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { usePreviewViewport } from "@/preview/components/preview-viewport";
import type { OnSnapLinesChange } from "@/preview/hooks/use-preview-interaction";
import { useEditor } from "@/editor/use-editor";
import { useShiftKey } from "@/hooks/use-shift-key";
import {
getVisibleElementsWithBounds,
type ElementWithBounds,
} from "@/preview/element-bounds";
import {
MIN_SCALE,
SNAP_THRESHOLD_SCREEN_PIXELS,
snapRotation,
snapScale,
snapScaleAxes,
type ScaleEdgePreference,
type SnapLine,
} from "@/preview/preview-snap";
import { isVisualElement } from "@/timeline/element-utils";
import {
getElementLocalTime,
hasKeyframesForPath,
resolveTransformAtTime,
setChannel,
} from "@/animation";
import type { Transform } from "@/rendering";
import type { ElementAnimations } from "@/animation/types";
import { registerCanceller } from "@/editor/cancel-interaction";
type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
type Edge = "right" | "left" | "bottom";
type HandleType = Corner | Edge | "rotation";
function getPreferredEdge({ edge }: { edge: Edge }): ScaleEdgePreference {
return edge === "right"
? { right: true }
: edge === "left"
? { left: true }
: { bottom: true };
}
interface ScaleState {
trackId: string;
elementId: string;
initialTransform: Transform;
initialDistance: number;
initialBoundsCx: number;
initialBoundsCy: number;
baseWidth: number;
baseHeight: number;
shouldClearScaleAnimation: boolean;
animationsWithoutScale: ElementAnimations | undefined;
}
interface RotationState {
trackId: string;
elementId: string;
initialTransform: Transform;
initialAngle: number;
initialBoundsCx: number;
initialBoundsCy: number;
}
interface EdgeScaleState {
trackId: string;
elementId: string;
initialTransform: Transform;
initialBoundsCx: number;
initialBoundsCy: number;
baseWidth: number;
baseHeight: number;
edge: Edge;
rotationRad: number;
shouldClearScaleAnimation: boolean;
animationsWithoutScale: ElementAnimations | undefined;
}
function clampScaleNonZero(scale: number): number {
if (Math.abs(scale) < MIN_SCALE) {
return scale < 0 ? -MIN_SCALE : MIN_SCALE;
}
return scale;
}
function getCornerDistance({
bounds,
corner,
}: {
bounds: {
cx: number;
cy: number;
width: number;
height: number;
rotation: number;
};
corner: Corner;
}): number {
const halfWidth = bounds.width / 2;
const halfHeight = 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" ? -halfWidth : halfWidth;
const localY =
corner === "top-left" || corner === "top-right" ? -halfHeight : halfHeight;
const rotatedX = localX * cos - localY * sin;
const rotatedY = localX * sin + localY * cos;
return Math.sqrt(rotatedX * rotatedX + rotatedY * rotatedY) || 1;
}
export function useTransformHandles({
onSnapLinesChange,
}: {
onSnapLinesChange?: OnSnapLinesChange;
}) {
const editor = useEditor();
const isShiftHeldRef = useShiftKey();
const viewport = usePreviewViewport();
const [activeHandle, setActiveHandle] = useState<HandleType | null>(null);
const scaleStateRef = useRef<ScaleState | null>(null);
const rotationStateRef = useRef<RotationState | null>(null);
const edgeScaleStateRef = useRef<EdgeScaleState | null>(null);
const captureRef = useRef<{ element: HTMLElement; pointerId: number } | null>(
null,
);
const selectedElements = useEditor((e) => e.selection.getSelectedElements());
const tracks = useEditor(
(e) => e.timeline.getPreviewTracks() ?? e.scenes.getActiveScene().tracks,
);
const currentTime = useEditor((e) => e.playback.getCurrentTime());
const currentTimeRef = useRef(currentTime);
currentTimeRef.current = currentTime;
const mediaAssets = useEditor((e) => e.media.getAssets());
const canvasSize = useEditor(
(e) => e.project.getActive().settings.canvasSize,
);
const elementsWithBounds = getVisibleElementsWithBounds({
tracks,
currentTime,
canvasSize,
mediaAssets,
});
const selectedWithBounds: ElementWithBounds | null =
selectedElements.length === 1
? (elementsWithBounds.find(
(entry) =>
entry.trackId === selectedElements[0].trackId &&
entry.elementId === selectedElements[0].elementId,
) ?? null)
: null;
const hasVisualSelection =
selectedWithBounds !== null && isVisualElement(selectedWithBounds.element);
const clearActiveHandleState = useCallback(() => {
scaleStateRef.current = null;
rotationStateRef.current = null;
edgeScaleStateRef.current = null;
setActiveHandle(null);
onSnapLinesChange?.([]);
}, [onSnapLinesChange]);
const releaseCapturedPointer = useCallback(() => {
const capture = captureRef.current;
if (!capture) return;
if (capture.element.hasPointerCapture(capture.pointerId)) {
capture.element.releasePointerCapture(capture.pointerId);
}
captureRef.current = null;
}, []);
useEffect(() => {
if (!activeHandle) return;
return registerCanceller({
fn: () => {
editor.timeline.discardPreview();
clearActiveHandleState();
releaseCapturedPointer();
},
});
}, [
activeHandle,
clearActiveHandleState,
editor.timeline,
releaseCapturedPointer,
]);
const handleCornerPointerDown = useCallback(
({ event, corner }: { event: React.PointerEvent; corner: Corner }) => {
if (!selectedWithBounds) return;
event.stopPropagation();
const { bounds, trackId, elementId, element } = selectedWithBounds;
if (!isVisualElement(element)) return;
const localTime = getElementLocalTime({
timelineTime: currentTimeRef.current,
elementStartTime: element.startTime,
elementDuration: element.duration,
});
const resolvedTransform = resolveTransformAtTime({
baseTransform: element.transform,
animations: element.animations,
localTime,
});
const initialDistance = getCornerDistance({ bounds, corner });
const baseWidth = bounds.width / resolvedTransform.scaleX;
const baseHeight = bounds.height / resolvedTransform.scaleY;
const shouldClearScaleAnimation =
hasKeyframesForPath({
animations: element.animations,
propertyPath: "transform.scaleX",
}) ||
hasKeyframesForPath({
animations: element.animations,
propertyPath: "transform.scaleY",
});
const animationsWithoutScale = shouldClearScaleAnimation
? setChannel({
animations: setChannel({
animations: element.animations,
propertyPath: "transform.scaleX",
channel: undefined,
}),
propertyPath: "transform.scaleY",
channel: undefined,
})
: element.animations;
scaleStateRef.current = {
trackId,
elementId,
initialTransform: resolvedTransform,
initialDistance,
initialBoundsCx: bounds.cx,
initialBoundsCy: bounds.cy,
baseWidth,
baseHeight,
shouldClearScaleAnimation,
animationsWithoutScale,
};
setActiveHandle(corner);
const captureTarget = event.currentTarget as HTMLElement;
captureTarget.setPointerCapture(event.pointerId);
captureRef.current = {
element: captureTarget,
pointerId: event.pointerId,
};
},
[selectedWithBounds],
);
const handleRotationPointerDown = useCallback(
({ event }: { event: React.PointerEvent }) => {
if (!selectedWithBounds) return;
event.stopPropagation();
const { bounds, trackId, elementId, element } = selectedWithBounds;
if (!isVisualElement(element)) return;
const localTime = getElementLocalTime({
timelineTime: currentTimeRef.current,
elementStartTime: element.startTime,
elementDuration: element.duration,
});
const resolvedTransform = resolveTransformAtTime({
baseTransform: element.transform,
animations: element.animations,
localTime,
});
const position = viewport.screenToCanvas({
clientX: event.clientX,
clientY: event.clientY,
});
if (!position) return;
const deltaX = position.x - bounds.cx;
const deltaY = position.y - bounds.cy;
const initialAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI;
rotationStateRef.current = {
trackId,
elementId,
initialTransform: resolvedTransform,
initialAngle,
initialBoundsCx: bounds.cx,
initialBoundsCy: bounds.cy,
};
setActiveHandle("rotation");
const captureTarget = event.currentTarget as HTMLElement;
captureTarget.setPointerCapture(event.pointerId);
captureRef.current = {
element: captureTarget,
pointerId: event.pointerId,
};
},
[selectedWithBounds, viewport],
);
const handleEdgePointerDown = useCallback(
({ event, edge }: { event: React.PointerEvent; edge: Edge }) => {
if (!selectedWithBounds) return;
event.stopPropagation();
const { bounds, trackId, elementId, element } = selectedWithBounds;
if (!isVisualElement(element)) return;
const localTime = getElementLocalTime({
timelineTime: currentTimeRef.current,
elementStartTime: element.startTime,
elementDuration: element.duration,
});
const resolvedTransform = resolveTransformAtTime({
baseTransform: element.transform,
animations: element.animations,
localTime,
});
const baseWidth = bounds.width / resolvedTransform.scaleX;
const baseHeight = bounds.height / resolvedTransform.scaleY;
const rotationRad = (bounds.rotation * Math.PI) / 180;
const propertyPath =
edge === "right" || edge === "left"
? "transform.scaleX"
: "transform.scaleY";
const shouldClearScaleAnimation = hasKeyframesForPath({
animations: element.animations,
propertyPath,
});
const animationsWithoutScale = shouldClearScaleAnimation
? setChannel({
animations: element.animations,
propertyPath,
channel: undefined,
})
: element.animations;
edgeScaleStateRef.current = {
trackId,
elementId,
initialTransform: resolvedTransform,
initialBoundsCx: bounds.cx,
initialBoundsCy: bounds.cy,
baseWidth,
baseHeight,
edge,
rotationRad,
shouldClearScaleAnimation,
animationsWithoutScale,
};
setActiveHandle(edge);
const captureTarget = event.currentTarget as HTMLElement;
captureTarget.setPointerCapture(event.pointerId);
captureRef.current = {
element: captureTarget,
pointerId: event.pointerId,
};
},
[selectedWithBounds],
);
const handlePointerMove = useCallback(
({ event }: { event: React.PointerEvent }) => {
if (
!scaleStateRef.current &&
!rotationStateRef.current &&
!edgeScaleStateRef.current
)
return;
const position = viewport.screenToCanvas({
clientX: event.clientX,
clientY: event.clientY,
});
if (!position) return;
if (
scaleStateRef.current &&
activeHandle &&
activeHandle !== "rotation"
) {
const {
trackId,
elementId,
initialTransform,
initialDistance,
initialBoundsCx,
initialBoundsCy,
baseWidth,
baseHeight,
shouldClearScaleAnimation,
animationsWithoutScale,
} = scaleStateRef.current;
const deltaX = position.x - initialBoundsCx;
const deltaY = position.y - initialBoundsCy;
const currentDistance =
Math.sqrt(deltaX * deltaX + deltaY * deltaY) || 1;
const scaleFactor = currentDistance / initialDistance;
// Use actual element dimensions (base * current scale) so snap
// computes the correct edges when scaleX ≠ scaleY
const effectiveWidth = baseWidth * initialTransform.scaleX;
const effectiveHeight = baseHeight * initialTransform.scaleY;
const snapThreshold = viewport.screenPixelsToLogicalThreshold({
screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS,
});
const { snappedScale: snappedFactor, activeLines } =
isShiftHeldRef.current
? { snappedScale: scaleFactor, activeLines: [] as SnapLine[] }
: snapScale({
proposedScale: scaleFactor,
position: initialTransform.position,
baseWidth: effectiveWidth,
baseHeight: effectiveHeight,
rotation: initialTransform.rotate,
canvasSize,
snapThreshold,
});
onSnapLinesChange?.(activeLines);
editor.timeline.previewElements({
updates: [
{
trackId,
elementId,
updates: {
transform: {
...initialTransform,
scaleX: clampScaleNonZero(
initialTransform.scaleX * snappedFactor,
),
scaleY: clampScaleNonZero(
initialTransform.scaleY * snappedFactor,
),
},
...(shouldClearScaleAnimation && {
animations: animationsWithoutScale,
}),
},
},
],
});
return;
}
if (
edgeScaleStateRef.current &&
(activeHandle === "right" ||
activeHandle === "left" ||
activeHandle === "bottom")
) {
const {
trackId,
elementId,
initialTransform,
initialBoundsCx,
initialBoundsCy,
baseWidth,
baseHeight,
edge,
rotationRad,
shouldClearScaleAnimation,
animationsWithoutScale,
} = edgeScaleStateRef.current;
const deltaX = position.x - initialBoundsCx;
const deltaY = position.y - initialBoundsCy;
const xProjection =
deltaX * Math.cos(rotationRad) + deltaY * Math.sin(rotationRad);
const yProjection =
-deltaX * Math.sin(rotationRad) + deltaY * Math.cos(rotationRad);
const projection =
edge === "right"
? xProjection
: edge === "left"
? -xProjection
: yProjection;
const baseAxisHalf =
edge === "right" || edge === "left" ? baseWidth / 2 : baseHeight / 2;
const proposedScale = clampScaleNonZero(projection / baseAxisHalf);
const proposedScaleX =
edge === "right" || edge === "left"
? proposedScale
: initialTransform.scaleX;
const proposedScaleY =
edge === "bottom" ? proposedScale : initialTransform.scaleY;
const snapThreshold = viewport.screenPixelsToLogicalThreshold({
screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS,
});
const { x: xSnap, y: ySnap } = isShiftHeldRef.current
? {
x: {
snappedScale: proposedScaleX,
snapDistance: Infinity,
activeLines: [] as SnapLine[],
},
y: {
snappedScale: proposedScaleY,
snapDistance: Infinity,
activeLines: [] as SnapLine[],
},
}
: snapScaleAxes({
proposedScaleX,
proposedScaleY,
position: initialTransform.position,
baseWidth,
baseHeight,
rotation: initialTransform.rotate,
canvasSize,
snapThreshold,
preferredEdges: getPreferredEdge({ edge }),
});
const relevantSnap =
edge === "right" || edge === "left" ? xSnap : ySnap;
onSnapLinesChange?.(relevantSnap.activeLines);
editor.timeline.previewElements({
updates: [
{
trackId,
elementId,
updates: {
transform: {
...initialTransform,
scaleX:
edge === "right" || edge === "left"
? xSnap.snappedScale
: initialTransform.scaleX,
scaleY:
edge === "bottom"
? ySnap.snappedScale
: initialTransform.scaleY,
},
...(shouldClearScaleAnimation && {
animations: animationsWithoutScale,
}),
},
},
],
});
return;
}
if (rotationStateRef.current && activeHandle === "rotation") {
const {
trackId,
elementId,
initialTransform,
initialAngle,
initialBoundsCx,
initialBoundsCy,
} = rotationStateRef.current;
const deltaX = position.x - initialBoundsCx;
const deltaY = position.y - initialBoundsCy;
const currentAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI;
let deltaAngle = currentAngle - initialAngle;
if (deltaAngle > 180) deltaAngle -= 360;
if (deltaAngle < -180) deltaAngle += 360;
const newRotate = initialTransform.rotate + deltaAngle;
const { snappedRotation } = isShiftHeldRef.current
? { snappedRotation: newRotate }
: snapRotation({ proposedRotation: newRotate });
editor.timeline.previewElements({
updates: [
{
trackId,
elementId,
updates: {
transform: { ...initialTransform, rotate: snappedRotation },
},
},
],
});
}
},
[
activeHandle,
canvasSize,
editor,
isShiftHeldRef,
onSnapLinesChange,
viewport,
],
);
const handlePointerUp = useCallback(() => {
if (
scaleStateRef.current ||
rotationStateRef.current ||
edgeScaleStateRef.current
) {
editor.timeline.commitPreview();
clearActiveHandleState();
}
releaseCapturedPointer();
}, [clearActiveHandleState, editor, releaseCapturedPointer]);
return {
selectedWithBounds,
hasVisualSelection,
activeHandle,
handleCornerPointerDown,
handleEdgePointerDown,
handleRotationPointerDown,
handlePointerMove,
handlePointerUp,
};
}
+108
View File
@@ -0,0 +1,108 @@
import type { ReactNode } from "react";
export type PreviewOverlayHudAnchor =
| "top-left"
| "top-right"
| "bottom-left"
| "bottom-right";
export type PreviewOverlayMount =
| {
kind: "hud";
anchor: PreviewOverlayHudAnchor;
order?: number;
}
| {
kind: "scene";
x?: number;
y?: number;
width?: number;
height?: number;
}
| {
kind: "viewport";
x?: number;
y?: number;
width?: number;
height?: number;
};
export type PreviewOverlayPlane = "under-interaction" | "over-interaction";
export interface PreviewOverlayRenderContext {
sceneHeight: number;
sceneWidth: number;
}
export interface PreviewOverlayInstance {
id: string;
mount: PreviewOverlayMount;
plane?: PreviewOverlayPlane;
pointerEvents?: "none" | "auto";
zIndex?: number;
render: (context: PreviewOverlayRenderContext) => ReactNode;
}
export interface PreviewOverlayDefinition {
id: string;
label: string;
defaultVisible?: boolean;
}
export interface PreviewOverlayControl extends PreviewOverlayDefinition {
isVisible: boolean;
}
export interface PreviewOverlaySourceResult {
definitions: PreviewOverlayDefinition[];
instances: PreviewOverlayInstance[];
}
export const EMPTY_PREVIEW_OVERLAY_SOURCE_RESULT: PreviewOverlaySourceResult = {
definitions: [],
instances: [],
};
export function isPreviewOverlayVisible({
overlay,
overlays,
}: {
overlay: PreviewOverlayDefinition;
overlays: Record<string, boolean>;
}): boolean {
return overlays[overlay.id] ?? overlay.defaultVisible ?? true;
}
export function createPreviewOverlayControl({
overlay,
overlays,
}: {
overlay: PreviewOverlayDefinition;
overlays: Record<string, boolean>;
}): PreviewOverlayControl {
return {
...overlay,
isVisible: isPreviewOverlayVisible({ overlay, overlays }),
};
}
export function mergePreviewOverlaySources({
sources,
}: {
sources: PreviewOverlaySourceResult[];
}): PreviewOverlaySourceResult {
const definitionsById = new Map<string, PreviewOverlayDefinition>();
const instances: PreviewOverlayInstance[] = [];
for (const source of sources) {
for (const definition of source.definitions) {
definitionsById.set(definition.id, definition);
}
instances.push(...source.instances);
}
return {
definitions: [...definitionsById.values()],
instances,
};
}
+101
View File
@@ -0,0 +1,101 @@
export interface PreviewViewportGeometry {
canvasHeight: number;
canvasWidth: number;
centerX: number;
centerY: number;
scale: number;
viewportHeight: number;
viewportWidth: number;
}
function getCanvasOrigin({
geometry,
}: {
geometry: PreviewViewportGeometry;
}): { x: number; y: number } {
return {
x: geometry.viewportWidth / 2 - geometry.centerX * geometry.scale,
y: geometry.viewportHeight / 2 - geometry.centerY * geometry.scale,
};
}
export function screenToCanvas({
clientX,
clientY,
geometry,
viewportRect,
}: {
clientX: number;
clientY: number;
geometry: PreviewViewportGeometry;
viewportRect: DOMRect;
}): { x: number; y: number } {
const overlayX = clientX - viewportRect.left;
const overlayY = clientY - viewportRect.top;
return {
x:
geometry.centerX +
(overlayX - geometry.viewportWidth / 2) / geometry.scale,
y:
geometry.centerY +
(overlayY - geometry.viewportHeight / 2) / geometry.scale,
};
}
export function canvasToOverlay({
canvasX,
canvasY,
geometry,
}: {
canvasX: number;
canvasY: number;
geometry: PreviewViewportGeometry;
}): { x: number; y: number } {
const canvasOrigin = getCanvasOrigin({ geometry });
return {
x: canvasOrigin.x + canvasX * geometry.scale,
y: canvasOrigin.y + canvasY * geometry.scale,
};
}
export function positionToOverlay({
positionX,
positionY,
geometry,
}: {
positionX: number;
positionY: number;
geometry: PreviewViewportGeometry;
}): { x: number; y: number } {
return canvasToOverlay({
canvasX: geometry.canvasWidth / 2 + positionX,
canvasY: geometry.canvasHeight / 2 + positionY,
geometry,
});
}
export function getDisplayScale({
geometry,
}: {
geometry: PreviewViewportGeometry;
}): { x: number; y: number } {
return {
x: geometry.scale,
y: geometry.scale,
};
}
export function screenPixelsToLogicalThreshold({
geometry,
screenPixels,
}: {
geometry: PreviewViewportGeometry;
screenPixels: number;
}): { x: number; y: number } {
return {
x: screenPixels / geometry.scale,
y: screenPixels / geometry.scale,
};
}
+539
View File
@@ -0,0 +1,539 @@
export interface SnapLine {
type: "horizontal" | "vertical";
position: number;
}
const ROTATION_SNAP_STEP_DEGREES = 90;
const ROTATION_SNAP_THRESHOLD_DEGREES = 5;
export const MIN_SCALE = 0.01;
export const SNAP_THRESHOLD_SCREEN_PIXELS = 8;
export interface SnapResult {
snappedPosition: { x: number; y: number };
activeLines: SnapLine[];
}
type ScaleEdge = "left" | "right" | "top" | "bottom";
export interface ScaleEdgePreference {
left?: boolean;
right?: boolean;
top?: boolean;
bottom?: boolean;
}
function hasPreferredEdge({
preferredEdges,
edge,
}: {
preferredEdges?: ScaleEdgePreference;
edge: ScaleEdge;
}): boolean {
return preferredEdges?.[edge] === true;
}
function pickClosestScaleCandidate<T extends { distance: number; edge: ScaleEdge }>({
candidates,
preferredEdges,
}: {
candidates: T[];
preferredEdges?: ScaleEdgePreference;
}): T | null {
if (candidates.length === 0) {
return null;
}
return candidates.reduce((bestCandidate, candidate) => {
if (candidate.distance < bestCandidate.distance) {
return candidate;
}
if (candidate.distance > bestCandidate.distance) {
return bestCandidate;
}
const shouldPreferCandidate = hasPreferredEdge({
preferredEdges,
edge: candidate.edge,
});
const shouldPreferBestCandidate = hasPreferredEdge({
preferredEdges,
edge: bestCandidate.edge,
});
return shouldPreferCandidate && !shouldPreferBestCandidate
? candidate
: bestCandidate;
});
}
export function snapPosition({
proposedPosition,
canvasSize,
elementSize,
rotation = 0,
snapThreshold,
}: {
proposedPosition: { x: number; y: number };
canvasSize: { width: number; height: number };
elementSize: { width: number; height: number };
rotation?: number;
snapThreshold: { x: number; y: number };
}): SnapResult {
const centerX = 0;
const centerY = 0;
const left = -canvasSize.width / 2;
const right = canvasSize.width / 2;
const top = -canvasSize.height / 2;
const bottom = canvasSize.height / 2;
const rotRad = (rotation * Math.PI) / 180;
const cosR = Math.abs(Math.cos(rotRad));
const sinR = Math.abs(Math.sin(rotRad));
const halfWidth = (elementSize.width * cosR + elementSize.height * sinR) / 2;
const halfHeight = (elementSize.width * sinR + elementSize.height * cosR) / 2;
const activeLines: SnapLine[] = [];
type AxisSnapCandidate = {
snappedPosition: number;
line: SnapLine;
distance: number;
};
function getClosestAxisSnap({
candidates,
threshold,
}: {
candidates: AxisSnapCandidate[];
threshold: number;
}): AxisSnapCandidate | null {
const snapCandidatesWithinThreshold = candidates.filter(
(candidate) => candidate.distance <= threshold,
);
if (snapCandidatesWithinThreshold.length === 0) {
return null;
}
return snapCandidatesWithinThreshold.reduce((closest, current) =>
current.distance < closest.distance ? current : closest,
);
}
const verticalTargets = [centerX, left, right];
const horizontalTargets = [centerY, top, bottom];
const xCandidates: AxisSnapCandidate[] = [];
for (const targetX of verticalTargets) {
xCandidates.push({
snappedPosition: targetX,
line: { type: "vertical", position: targetX },
distance: Math.abs(proposedPosition.x - targetX),
});
xCandidates.push({
snappedPosition: targetX + halfWidth,
line: { type: "vertical", position: targetX },
distance: Math.abs(proposedPosition.x - halfWidth - targetX),
});
xCandidates.push({
snappedPosition: targetX - halfWidth,
line: { type: "vertical", position: targetX },
distance: Math.abs(proposedPosition.x + halfWidth - targetX),
});
}
const yCandidates: AxisSnapCandidate[] = [];
for (const targetY of horizontalTargets) {
yCandidates.push({
snappedPosition: targetY,
line: { type: "horizontal", position: targetY },
distance: Math.abs(proposedPosition.y - targetY),
});
yCandidates.push({
snappedPosition: targetY + halfHeight,
line: { type: "horizontal", position: targetY },
distance: Math.abs(proposedPosition.y - halfHeight - targetY),
});
yCandidates.push({
snappedPosition: targetY - halfHeight,
line: { type: "horizontal", position: targetY },
distance: Math.abs(proposedPosition.y + halfHeight - targetY),
});
}
const closestX = getClosestAxisSnap({
candidates: xCandidates,
threshold: snapThreshold.x,
});
const closestY = getClosestAxisSnap({
candidates: yCandidates,
threshold: snapThreshold.y,
});
const x = closestX?.snappedPosition ?? proposedPosition.x;
const y = closestY?.snappedPosition ?? proposedPosition.y;
if (closestX) {
activeLines.push(closestX.line);
}
if (closestY) {
activeLines.push(closestY.line);
}
return {
snappedPosition: { x, y },
activeLines,
};
}
export interface ScaleSnapResult {
snappedScale: number;
activeLines: SnapLine[];
}
export function snapScale({
proposedScale,
position,
baseWidth,
baseHeight,
rotation = 0,
canvasSize,
snapThreshold,
preferredEdges,
}: {
proposedScale: number;
position: { x: number; y: number };
baseWidth: number;
baseHeight: number;
rotation?: number;
canvasSize: { width: number; height: number };
snapThreshold: { x: number; y: number };
preferredEdges?: ScaleEdgePreference;
}): ScaleSnapResult {
const centerX = 0;
const centerY = 0;
const left = -canvasSize.width / 2;
const right = canvasSize.width / 2;
const top = -canvasSize.height / 2;
const bottom = canvasSize.height / 2;
const rotRad = (rotation * Math.PI) / 180;
const cosR = Math.abs(Math.cos(rotRad));
const sinR = Math.abs(Math.sin(rotRad));
const aabbBaseHalfW = (baseWidth * cosR + baseHeight * sinR) / 2;
const aabbBaseHalfH = (baseWidth * sinR + baseHeight * cosR) / 2;
const leftEdge = position.x - aabbBaseHalfW * proposedScale;
const rightEdge = position.x + aabbBaseHalfW * proposedScale;
const topEdge = position.y - aabbBaseHalfH * proposedScale;
const bottomEdge = position.y + aabbBaseHalfH * proposedScale;
interface SnapCandidate {
scale: number;
distance: number;
lines: SnapLine[];
edge: ScaleEdge;
}
const candidates: SnapCandidate[] = [];
const verticalTargets = [
{ position: left, line: { type: "vertical" as const, position: left } },
{
position: centerX,
line: { type: "vertical" as const, position: centerX },
},
{ position: right, line: { type: "vertical" as const, position: right } },
];
for (const target of verticalTargets) {
const distanceLeft = Math.abs(leftEdge - target.position);
if (distanceLeft <= snapThreshold.x) {
const scale = (position.x - target.position) / aabbBaseHalfW;
if (Math.abs(scale) > MIN_SCALE) {
candidates.push({
scale,
distance: distanceLeft,
lines: [target.line],
edge: "left",
});
}
}
const distanceRight = Math.abs(rightEdge - target.position);
if (distanceRight <= snapThreshold.x) {
const scale = (target.position - position.x) / aabbBaseHalfW;
if (Math.abs(scale) > MIN_SCALE) {
candidates.push({
scale,
distance: distanceRight,
lines: [target.line],
edge: "right",
});
}
}
}
const horizontalTargets = [
{ position: top, line: { type: "horizontal" as const, position: top } },
{
position: centerY,
line: { type: "horizontal" as const, position: centerY },
},
{
position: bottom,
line: { type: "horizontal" as const, position: bottom },
},
];
for (const target of horizontalTargets) {
const distanceTop = Math.abs(topEdge - target.position);
if (distanceTop <= snapThreshold.y) {
const scale = (position.y - target.position) / aabbBaseHalfH;
if (Math.abs(scale) > MIN_SCALE) {
candidates.push({
scale,
distance: distanceTop,
lines: [target.line],
edge: "top",
});
}
}
const distanceBottom = Math.abs(bottomEdge - target.position);
if (distanceBottom <= snapThreshold.y) {
const scale = (target.position - position.y) / aabbBaseHalfH;
if (Math.abs(scale) > MIN_SCALE) {
candidates.push({
scale,
distance: distanceBottom,
lines: [target.line],
edge: "bottom",
});
}
}
}
const best = pickClosestScaleCandidate({
candidates,
preferredEdges,
});
if (!best) {
return { snappedScale: proposedScale, activeLines: [] };
}
const snappedLeft = position.x - aabbBaseHalfW * best.scale;
const snappedRight = position.x + aabbBaseHalfW * best.scale;
const snappedTop = position.y - aabbBaseHalfH * best.scale;
const snappedBottom = position.y + aabbBaseHalfH * best.scale;
const activeLines: SnapLine[] = [];
const seenKeys = new Set<string>();
function addLine({ line }: { line: SnapLine }) {
const key = `${line.type}-${line.position}`;
if (!seenKeys.has(key)) {
seenKeys.add(key);
activeLines.push(line);
}
}
for (const target of verticalTargets) {
if (
(hasPreferredEdge({ preferredEdges, edge: "left" }) &&
Math.abs(snappedLeft - target.position) <= 1) ||
(hasPreferredEdge({ preferredEdges, edge: "right" }) &&
Math.abs(snappedRight - target.position) <= 1) ||
(!preferredEdges &&
(Math.abs(snappedLeft - target.position) <= 1 ||
Math.abs(snappedRight - target.position) <= 1))
) {
addLine({ line: target.line });
}
}
for (const target of horizontalTargets) {
if (
(hasPreferredEdge({ preferredEdges, edge: "top" }) &&
Math.abs(snappedTop - target.position) <= 1) ||
(hasPreferredEdge({ preferredEdges, edge: "bottom" }) &&
Math.abs(snappedBottom - target.position) <= 1) ||
(!preferredEdges &&
(Math.abs(snappedTop - target.position) <= 1 ||
Math.abs(snappedBottom - target.position) <= 1))
) {
addLine({ line: target.line });
}
}
return {
snappedScale: best.scale,
activeLines,
};
}
export interface AxisSnapResult {
snappedScale: number;
/** Infinity when no snap candidate was within threshold */
snapDistance: number;
activeLines: SnapLine[];
}
export function snapScaleAxes({
proposedScaleX,
proposedScaleY,
position,
baseWidth,
baseHeight,
rotation = 0,
canvasSize,
snapThreshold,
preferredEdges,
}: {
proposedScaleX: number;
proposedScaleY: number;
position: { x: number; y: number };
baseWidth: number;
baseHeight: number;
rotation?: number;
canvasSize: { width: number; height: number };
snapThreshold: { x: number; y: number };
preferredEdges?: ScaleEdgePreference;
}): { x: AxisSnapResult; y: AxisSnapResult } {
const canvasLeft = -canvasSize.width / 2;
const canvasRight = canvasSize.width / 2;
const canvasTop = -canvasSize.height / 2;
const canvasBottom = canvasSize.height / 2;
const rotRad = (rotation * Math.PI) / 180;
const cosR = Math.abs(Math.cos(rotRad));
const sinR = Math.abs(Math.sin(rotRad));
const EPSILON = 1e-6;
// Current AABB edges at proposed scales
const currentAabbHalfW = (baseWidth * proposedScaleX * cosR + baseHeight * proposedScaleY * sinR) / 2;
const currentAabbHalfH = (baseWidth * proposedScaleX * sinR + baseHeight * proposedScaleY * cosR) / 2;
const currentLeftEdge = position.x - currentAabbHalfW;
const currentRightEdge = position.x + currentAabbHalfW;
const currentTopEdge = position.y - currentAabbHalfH;
const currentBottomEdge = position.y + currentAabbHalfH;
interface Candidate {
scale: number;
distance: number;
line: SnapLine;
edge: ScaleEdge;
}
function bestCandidate({
candidates,
proposedScale,
}: {
candidates: Candidate[];
proposedScale: number;
}): AxisSnapResult {
const best = pickClosestScaleCandidate({
candidates,
preferredEdges,
});
if (!best) {
return { snappedScale: proposedScale, snapDistance: Infinity, activeLines: [] };
}
return { snappedScale: best.scale, snapDistance: best.distance, activeLines: [best.line] };
}
// sX candidates: snap via vertical targets (left/right AABB edges) — only valid when cosR ≠ 0
// snap via horizontal targets (top/bottom AABB edges) — only valid when sinR ≠ 0
const xCandidates: Candidate[] = [];
const yContribW = baseHeight * proposedScaleY * sinR;
const yContribH = baseHeight * proposedScaleY * cosR;
if (cosR > EPSILON) {
for (const T of [canvasLeft, 0, canvasRight]) {
const line: SnapLine = { type: "vertical", position: T };
const distLeft = Math.abs(currentLeftEdge - T);
if (distLeft <= snapThreshold.x) {
const scale = (2 * (position.x - T) - yContribW) / (baseWidth * cosR);
if (Math.abs(scale) > MIN_SCALE) xCandidates.push({ scale, distance: distLeft, line, edge: "left" });
}
const distRight = Math.abs(currentRightEdge - T);
if (distRight <= snapThreshold.x) {
const scale = (2 * (T - position.x) - yContribW) / (baseWidth * cosR);
if (Math.abs(scale) > MIN_SCALE) xCandidates.push({ scale, distance: distRight, line, edge: "right" });
}
}
}
if (sinR > EPSILON) {
for (const T of [canvasTop, 0, canvasBottom]) {
const line: SnapLine = { type: "horizontal", position: T };
const distTop = Math.abs(currentTopEdge - T);
if (distTop <= snapThreshold.y) {
const scale = (2 * (position.y - T) - yContribH) / (baseWidth * sinR);
if (Math.abs(scale) > MIN_SCALE) xCandidates.push({ scale, distance: distTop, line, edge: "top" });
}
const distBottom = Math.abs(currentBottomEdge - T);
if (distBottom <= snapThreshold.y) {
const scale = (2 * (T - position.y) - yContribH) / (baseWidth * sinR);
if (Math.abs(scale) > MIN_SCALE) xCandidates.push({ scale, distance: distBottom, line, edge: "bottom" });
}
}
}
// sY candidates: snap via vertical targets — only valid when sinR ≠ 0
// snap via horizontal targets — only valid when cosR ≠ 0
const yCandidates: Candidate[] = [];
const xContribW = baseWidth * proposedScaleX * cosR;
const xContribH = baseWidth * proposedScaleX * sinR;
if (sinR > EPSILON) {
for (const T of [canvasLeft, 0, canvasRight]) {
const line: SnapLine = { type: "vertical", position: T };
const distLeft = Math.abs(currentLeftEdge - T);
if (distLeft <= snapThreshold.x) {
const scale = (2 * (position.x - T) - xContribW) / (baseHeight * sinR);
if (Math.abs(scale) > MIN_SCALE) yCandidates.push({ scale, distance: distLeft, line, edge: "left" });
}
const distRight = Math.abs(currentRightEdge - T);
if (distRight <= snapThreshold.x) {
const scale = (2 * (T - position.x) - xContribW) / (baseHeight * sinR);
if (Math.abs(scale) > MIN_SCALE) yCandidates.push({ scale, distance: distRight, line, edge: "right" });
}
}
}
if (cosR > EPSILON) {
for (const T of [canvasTop, 0, canvasBottom]) {
const line: SnapLine = { type: "horizontal", position: T };
const distTop = Math.abs(currentTopEdge - T);
if (distTop <= snapThreshold.y) {
const scale = (2 * (position.y - T) - xContribH) / (baseHeight * cosR);
if (Math.abs(scale) > MIN_SCALE) yCandidates.push({ scale, distance: distTop, line, edge: "top" });
}
const distBottom = Math.abs(currentBottomEdge - T);
if (distBottom <= snapThreshold.y) {
const scale = (2 * (T - position.y) - xContribH) / (baseHeight * cosR);
if (Math.abs(scale) > MIN_SCALE) yCandidates.push({ scale, distance: distBottom, line, edge: "bottom" });
}
}
}
return {
x: bestCandidate({ candidates: xCandidates, proposedScale: proposedScaleX }),
y: bestCandidate({ candidates: yCandidates, proposedScale: proposedScaleY }),
};
}
export interface RotationSnapResult {
snappedRotation: number;
isSnapped: boolean;
}
export function snapRotation({
proposedRotation,
}: {
proposedRotation: number;
}): RotationSnapResult {
const nearestRotationSnap =
Math.round(proposedRotation / ROTATION_SNAP_STEP_DEGREES) *
ROTATION_SNAP_STEP_DEGREES;
const distanceToNearestSnap = Math.abs(
proposedRotation - nearestRotationSnap,
);
if (distanceToNearestSnap <= ROTATION_SNAP_THRESHOLD_DEGREES) {
return { snappedRotation: nearestRotationSnap, isSnapped: true };
}
return { snappedRotation: proposedRotation, isSnapped: false };
}
+104
View File
@@ -0,0 +1,104 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { isGuideId, type GuideId } from "@/guides";
import { DEFAULT_GRID_CONFIG } from "@/guides/grid";
import type { GridConfig } from "@/guides/types";
type PreviewOverlaysState = Record<string, boolean>;
interface PersistedPreviewState {
activeGuide?: string | null;
layoutGuide?: {
platform?: string | null;
};
overlays?: PreviewOverlaysState;
gridConfig?: GridConfig;
}
interface PreviewState {
activeGuide: GuideId | null;
overlays: PreviewOverlaysState;
gridConfig: GridConfig;
toggleGuide: (guideId: GuideId) => void;
setGridConfig: (config: Partial<GridConfig>) => void;
setOverlayVisibility: ({
overlayId,
isVisible,
}: {
overlayId: string;
isVisible: boolean;
}) => void;
toggleOverlayVisibility: ({ overlayId }: { overlayId: string }) => void;
}
const DEFAULT_PREVIEW_OVERLAYS: PreviewOverlaysState = {};
function getPersistedActiveGuide(
state: PersistedPreviewState | undefined,
): GuideId | null {
const persistedGuide =
state?.activeGuide ?? state?.layoutGuide?.platform ?? null;
if (typeof persistedGuide !== "string") {
return null;
}
return isGuideId(persistedGuide) ? persistedGuide : null;
}
export const usePreviewStore = create<PreviewState>()(
persist(
(set) => ({
activeGuide: null,
overlays: DEFAULT_PREVIEW_OVERLAYS,
gridConfig: DEFAULT_GRID_CONFIG,
toggleGuide: (guideId) => {
set((state) => ({
activeGuide: state.activeGuide === guideId ? null : guideId,
}));
},
setGridConfig: (config) => {
set((state) => ({
gridConfig: { ...state.gridConfig, ...config },
}));
},
setOverlayVisibility: ({ overlayId, isVisible }) => {
set((state) => ({
overlays: {
...state.overlays,
[overlayId]: isVisible,
},
}));
},
toggleOverlayVisibility: ({ overlayId }) => {
set((state) => ({
overlays: {
...state.overlays,
[overlayId]: !state.overlays[overlayId],
},
}));
},
}),
{
name: "preview-settings",
version: 6,
migrate: (persistedState) => {
const state = persistedState as PersistedPreviewState | undefined;
return {
activeGuide: getPersistedActiveGuide(state),
overlays: DEFAULT_PREVIEW_OVERLAYS,
gridConfig: {
rows: state?.gridConfig?.rows ?? DEFAULT_GRID_CONFIG.rows,
cols: state?.gridConfig?.cols ?? DEFAULT_GRID_CONFIG.cols,
},
};
},
partialize: (state) => ({
activeGuide: state.activeGuide,
overlays: state.overlays,
gridConfig: state.gridConfig,
}),
},
),
);
+7
View File
@@ -0,0 +1,7 @@
export const PREVIEW_ZOOM_PRESETS = [25, 50, 75, 100, 150, 200];
export const PREVIEW_ZOOM = {
min: 0.25,
max: 16,
step: 1.25,
} as const;