mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
refactor: restructure files to their domains, new preview overlay system, and dep graph
This commit is contained in:
@@ -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,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user