diff options
Diffstat (limited to 'apps/web/src/components')
15 files changed, 594 insertions, 600 deletions
diff --git a/apps/web/src/components/editable-timecode.tsx b/apps/web/src/components/editable-timecode.tsx index 276cb490..aaae79a9 100644 --- a/apps/web/src/components/editable-timecode.tsx +++ b/apps/web/src/components/editable-timecode.tsx @@ -1,15 +1,24 @@ "use client"; import { useEffect, useRef, useState } from "react"; -import { formatTimecode, parseTimecode, snappedSeekTime, type FrameRate, type TimeCodeFormat } from "opencut-wasm"; +import { + formatTimecode, + type FrameRate, + type TimeCodeFormat, +} from "opencut-wasm"; import { cn } from "@/utils/ui"; +import { + parseMediaTimecode, + snapSeekMediaTime, + type MediaTime, +} from "@/wasm"; interface EditableTimecodeProps { - time: number; - duration: number; + time: MediaTime; + duration: MediaTime; format?: TimeCodeFormat; fps: FrameRate; - onTimeChange?: ({ time }: { time: number }) => void; + onTimeChange?: ({ time }: { time: MediaTime }) => void; className?: string; disabled?: boolean; } @@ -46,7 +55,11 @@ export function EditableTimecode({ }; const applyEdit = () => { - const parsedTime = parseTimecode({ timeCode: inputValue, format, rate: fps }); + const parsedTime = parseMediaTimecode({ + timeCode: inputValue, + format, + fps, + }); if (parsedTime == null) { setHasError(true); @@ -54,7 +67,7 @@ export function EditableTimecode({ } const clampedTime = duration - ? (snappedSeekTime({ time: parsedTime, duration, rate: fps }) ?? parsedTime) + ? snapSeekMediaTime({ time: parsedTime, duration, fps }) : parsedTime; onTimeChange?.({ time: clampedTime }); diff --git a/apps/web/src/components/editor/panels/assets/assets-panel-store.tsx b/apps/web/src/components/editor/panels/assets/assets-panel-store.tsx index 1539d305..b412de7b 100644 --- a/apps/web/src/components/editor/panels/assets/assets-panel-store.tsx +++ b/apps/web/src/components/editor/panels/assets/assets-panel-store.tsx @@ -93,7 +93,7 @@ interface AssetsPanelStore { setMediaViewMode: (mode: MediaViewMode) => void; mediaSortBy: MediaSortKey; mediaSortOrder: MediaSortOrder; - setMediaSort: (key: MediaSortKey, order: MediaSortOrder) => void; + setMediaSort: (args: { key: MediaSortKey; order: MediaSortOrder }) => void; } export const useAssetsPanelStore = create<AssetsPanelStore>()( @@ -109,7 +109,7 @@ export const useAssetsPanelStore = create<AssetsPanelStore>()( setMediaViewMode: (mode) => set({ mediaViewMode: mode }), mediaSortBy: "name", mediaSortOrder: "asc", - setMediaSort: (key, order) => + setMediaSort: ({ key, order }) => set({ mediaSortBy: key, mediaSortOrder: order }), }), { diff --git a/apps/web/src/components/editor/panels/assets/draggable-item.tsx b/apps/web/src/components/editor/panels/assets/draggable-item.tsx index 7cf89fbe..8cfb99c3 100644 --- a/apps/web/src/components/editor/panels/assets/draggable-item.tsx +++ b/apps/web/src/components/editor/panels/assets/draggable-item.tsx @@ -1,240 +1,241 @@ -"use client"; - -import { Plus } from "lucide-react"; -import { type ReactNode, useEffect, useRef, useState } from "react"; -import { createPortal } from "react-dom"; -import { AspectRatio } from "@/components/ui/aspect-ratio"; -import { Button } from "@/components/ui/button"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { useEditor } from "@/editor/use-editor"; -import type { TimelineDragData } from "@/timeline/drag"; -import { cn } from "@/utils/ui"; - -export interface DraggableItemProps { - name: string; - preview: ReactNode; - dragData: TimelineDragData; - onDragStart?: ({ e }: { e: React.DragEvent }) => void; - onAddToTimeline?: ({ currentTime }: { currentTime: number }) => void; - aspectRatio?: number; - className?: string; - containerClassName?: string; - shouldShowPlusOnDrag?: boolean; - shouldShowLabel?: boolean; - isRounded?: boolean; - variant?: "card" | "compact"; - isDraggable?: boolean; -} - -export function DraggableItem({ - name, - preview, - dragData, - onDragStart, - onAddToTimeline, - aspectRatio = 16 / 9, - className = "", - containerClassName, - shouldShowPlusOnDrag = true, - shouldShowLabel = true, - isRounded = true, - variant = "card", - isDraggable = true, -}: DraggableItemProps) { - const [isDragging, setIsDragging] = useState(false); - const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 }); - const dragRef = useRef<HTMLDivElement>(null); - const editor = useEditor(); - - const handleAddToTimeline = () => { - onAddToTimeline?.({ currentTime: editor.playback.getCurrentTime() }); - }; - - const emptyImg = new window.Image(); - emptyImg.src = - "data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs="; - - useEffect(() => { - if (!isDragging) return; - - const handleDragOver = (e: DragEvent) => { - setDragPosition({ x: e.clientX, y: e.clientY }); - }; - - document.addEventListener("dragover", handleDragOver); - - return () => { - document.removeEventListener("dragover", handleDragOver); - }; - }, [isDragging]); - - const handleDragStart = (event: React.DragEvent) => { - event.dataTransfer.setDragImage(emptyImg, 0, 0); - - editor.timeline.dragSource.begin({ - dataTransfer: event.dataTransfer, - dragData, - }); - - setDragPosition({ x: event.clientX, y: event.clientY }); - setIsDragging(true); - - onDragStart?.({ e: event }); - }; - - const handleDragEnd = () => { - setIsDragging(false); - editor.timeline.dragSource.end(); - }; - - return ( - <> - {variant === "card" ? ( - <div - ref={dragRef} - className={cn("group relative", containerClassName ?? "w-28")} - > - <div - className={cn( - "relative flex h-auto w-full cursor-default flex-col gap-1 p-", - className, - )} - > - <AspectRatio - ratio={aspectRatio} - className={cn( - "bg-accent relative overflow-hidden", - isRounded && "rounded-sm", - isDraggable && "[&::-webkit-drag-ghost]:opacity-0", - )} - draggable={isDraggable} - onDragStart={isDraggable ? handleDragStart : undefined} - onDragEnd={isDraggable ? handleDragEnd : undefined} - > - {preview} - {!isDragging && ( - <PlusButton - className="opacity-0 group-hover:opacity-100" - onClick={handleAddToTimeline} - /> - )} - </AspectRatio> - {shouldShowLabel && ( - <span - className="text-muted-foreground w-full truncate text-left text-[0.7rem]" - title={name} - > - <span className="sr-only">{name}</span> - <span aria-hidden="true"> - {name.length > 8 - ? `${name.slice(0, 16)}...${name.slice(-3)}` - : name} - </span> - </span> - )} - </div> - </div> - ) : ( - <div - ref={dragRef} - className={cn("group relative w-full", containerClassName)} - > - <button - type="button" - className={cn( - "flex h-8 w-full cursor-default items-center gap-3 px-1 outline-none", - isDraggable && "[&::-webkit-drag-ghost]:opacity-0", - className, - )} - draggable={isDraggable} - onDragStart={isDraggable ? handleDragStart : undefined} - onDragEnd={isDraggable ? handleDragEnd : undefined} - > - <div className="size-6 shrink-0 overflow-hidden rounded-sm"> - {preview} - </div> - <span className="w-full flex-1 truncate text-sm text-left"> - {name} - </span> - </button> - </div> - )} - - {isDraggable && - isDragging && - typeof document !== "undefined" && - createPortal( - <div - className="pointer-events-none fixed z-9999" - style={{ - left: dragPosition.x - 40, - top: dragPosition.y - 40, - }} - > - <div className="w-[80px]"> - <AspectRatio - ratio={1} - className="ring-primary relative overflow-hidden rounded-md shadow-2xl ring-3" - > - <div className="size-full [&_img]:size-full [&_img]:rounded-none [&_img]:object-cover"> - {preview} - </div> - {shouldShowPlusOnDrag && ( - <PlusButton - onClick={handleAddToTimeline} - tooltipText="Add to timeline or drag to position" - /> - )} - </AspectRatio> - </div> - </div>, - document.body, - )} - </> - ); -} - -function PlusButton({ - className, - onClick, - tooltipText, -}: { - className?: string; - onClick?: () => void; - tooltipText?: string; -}) { - const button = ( - <Button - size="icon" - className={cn( - "bg-background hover:bg-background text-foreground absolute right-2 bottom-2 size-5", - className, - )} - onClick={(e) => { - e.preventDefault(); - e.stopPropagation(); - onClick?.(); - }} - title={tooltipText} - > - <Plus /> - </Button> - ); - - if (tooltipText) { - return ( - <Tooltip> - <TooltipTrigger asChild>{button}</TooltipTrigger> - <TooltipContent> - <p>{tooltipText}</p> - </TooltipContent> - </Tooltip> - ); - } - - return button; -} +"use client";
+
+import { Plus } from "lucide-react";
+import { type ReactNode, useEffect, useRef, useState } from "react";
+import { createPortal } from "react-dom";
+import { AspectRatio } from "@/components/ui/aspect-ratio";
+import { Button } from "@/components/ui/button";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { useEditor } from "@/editor/use-editor";
+import type { TimelineDragData } from "@/timeline/drag";
+import { cn } from "@/utils/ui";
+import type { MediaTime } from "@/wasm";
+
+export interface DraggableItemProps {
+ name: string;
+ preview: ReactNode;
+ dragData: TimelineDragData;
+ onDragStart?: ({ e }: { e: React.DragEvent }) => void;
+ onAddToTimeline?: ({ currentTime }: { currentTime: MediaTime }) => void;
+ aspectRatio?: number;
+ className?: string;
+ containerClassName?: string;
+ shouldShowPlusOnDrag?: boolean;
+ shouldShowLabel?: boolean;
+ isRounded?: boolean;
+ variant?: "card" | "compact";
+ isDraggable?: boolean;
+}
+
+export function DraggableItem({
+ name,
+ preview,
+ dragData,
+ onDragStart,
+ onAddToTimeline,
+ aspectRatio = 16 / 9,
+ className = "",
+ containerClassName,
+ shouldShowPlusOnDrag = true,
+ shouldShowLabel = true,
+ isRounded = true,
+ variant = "card",
+ isDraggable = true,
+}: DraggableItemProps) {
+ const [isDragging, setIsDragging] = useState(false);
+ const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 });
+ const dragRef = useRef<HTMLDivElement>(null);
+ const editor = useEditor();
+
+ const handleAddToTimeline = () => {
+ onAddToTimeline?.({ currentTime: editor.playback.getCurrentTime() });
+ };
+
+ const emptyImg = new window.Image();
+ emptyImg.src =
+ "data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs=";
+
+ useEffect(() => {
+ if (!isDragging) return;
+
+ const handleDragOver = (e: DragEvent) => {
+ setDragPosition({ x: e.clientX, y: e.clientY });
+ };
+
+ document.addEventListener("dragover", handleDragOver);
+
+ return () => {
+ document.removeEventListener("dragover", handleDragOver);
+ };
+ }, [isDragging]);
+
+ const handleDragStart = (event: React.DragEvent) => {
+ event.dataTransfer.setDragImage(emptyImg, 0, 0);
+
+ editor.timeline.dragSource.begin({
+ dataTransfer: event.dataTransfer,
+ dragData,
+ });
+
+ setDragPosition({ x: event.clientX, y: event.clientY });
+ setIsDragging(true);
+
+ onDragStart?.({ e: event });
+ };
+
+ const handleDragEnd = () => {
+ setIsDragging(false);
+ editor.timeline.dragSource.end();
+ };
+
+ return (
+ <>
+ {variant === "card" ? (
+ <div
+ ref={dragRef}
+ className={cn("group relative", containerClassName ?? "w-28")}
+ >
+ <div
+ className={cn(
+ "relative flex h-auto w-full cursor-default flex-col gap-1 p-",
+ className,
+ )}
+ >
+ <AspectRatio
+ ratio={aspectRatio}
+ className={cn(
+ "bg-accent relative overflow-hidden",
+ isRounded && "rounded-sm",
+ isDraggable && "[&::-webkit-drag-ghost]:opacity-0",
+ )}
+ draggable={isDraggable}
+ onDragStart={isDraggable ? handleDragStart : undefined}
+ onDragEnd={isDraggable ? handleDragEnd : undefined}
+ >
+ {preview}
+ {!isDragging && (
+ <PlusButton
+ className="opacity-0 group-hover:opacity-100"
+ onClick={handleAddToTimeline}
+ />
+ )}
+ </AspectRatio>
+ {shouldShowLabel && (
+ <span
+ className="text-muted-foreground w-full truncate text-left text-[0.7rem]"
+ title={name}
+ >
+ <span className="sr-only">{name}</span>
+ <span aria-hidden="true">
+ {name.length > 8
+ ? `${name.slice(0, 16)}...${name.slice(-3)}`
+ : name}
+ </span>
+ </span>
+ )}
+ </div>
+ </div>
+ ) : (
+ <div
+ ref={dragRef}
+ className={cn("group relative w-full", containerClassName)}
+ >
+ <button
+ type="button"
+ className={cn(
+ "flex h-8 w-full cursor-default items-center gap-3 px-1 outline-none",
+ isDraggable && "[&::-webkit-drag-ghost]:opacity-0",
+ className,
+ )}
+ draggable={isDraggable}
+ onDragStart={isDraggable ? handleDragStart : undefined}
+ onDragEnd={isDraggable ? handleDragEnd : undefined}
+ >
+ <div className="size-6 shrink-0 overflow-hidden rounded-sm">
+ {preview}
+ </div>
+ <span className="w-full flex-1 truncate text-sm text-left">
+ {name}
+ </span>
+ </button>
+ </div>
+ )}
+
+ {isDraggable &&
+ isDragging &&
+ typeof document !== "undefined" &&
+ createPortal(
+ <div
+ className="pointer-events-none fixed z-9999"
+ style={{
+ left: dragPosition.x - 40,
+ top: dragPosition.y - 40,
+ }}
+ >
+ <div className="w-[80px]">
+ <AspectRatio
+ ratio={1}
+ className="ring-primary relative overflow-hidden rounded-md shadow-2xl ring-3"
+ >
+ <div className="size-full [&_img]:size-full [&_img]:rounded-none [&_img]:object-cover">
+ {preview}
+ </div>
+ {shouldShowPlusOnDrag && (
+ <PlusButton
+ onClick={handleAddToTimeline}
+ tooltipText="Add to timeline or drag to position"
+ />
+ )}
+ </AspectRatio>
+ </div>
+ </div>,
+ document.body,
+ )}
+ </>
+ );
+}
+
+function PlusButton({
+ className,
+ onClick,
+ tooltipText,
+}: {
+ className?: string;
+ onClick?: () => void;
+ tooltipText?: string;
+}) {
+ const button = (
+ <Button
+ size="icon"
+ className={cn(
+ "bg-background hover:bg-background text-foreground absolute right-2 bottom-2 size-5",
+ className,
+ )}
+ onClick={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ onClick?.();
+ }}
+ title={tooltipText}
+ >
+ <Plus />
+ </Button>
+ );
+
+ if (tooltipText) {
+ return (
+ <Tooltip>
+ <TooltipTrigger asChild>{button}</TooltipTrigger>
+ <TooltipContent>
+ <p>{tooltipText}</p>
+ </TooltipContent>
+ </Tooltip>
+ );
+ }
+
+ return button;
+}
diff --git a/apps/web/src/components/editor/panels/assets/views/assets.tsx b/apps/web/src/components/editor/panels/assets/views/assets.tsx index 4afd22b7..00cb015f 100644 --- a/apps/web/src/components/editor/panels/assets/views/assets.tsx +++ b/apps/web/src/components/editor/panels/assets/views/assets.tsx @@ -26,7 +26,7 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { DEFAULT_NEW_ELEMENT_DURATION } from "@/timeline/creation"; -import { TICKS_PER_SECOND } from "@/wasm"; +import { mediaTimeFromSeconds, type MediaTime } from "@/wasm"; import { useEditor } from "@/editor/use-editor"; import { useFileUpload } from "@/media/use-file-upload"; import { invokeAction } from "@/actions"; @@ -139,9 +139,12 @@ export function MediaView() { const handleSort = ({ key }: { key: MediaSortKey }) => { if (mediaSortBy === key) { - setMediaSort(key, mediaSortOrder === "asc" ? "desc" : "asc"); + setMediaSort({ + key, + order: mediaSortOrder === "asc" ? "desc" : "asc", + }); } else { - setMediaSort(key, "asc"); + setMediaSort({ key, order: "asc" }); } }; @@ -255,11 +258,11 @@ function MediaAssetDraggable({ startTime, }: { asset: MediaAsset; - startTime: number; + startTime: MediaTime; }) => { const duration = asset.duration != null - ? Math.round(asset.duration * TICKS_PER_SECOND) + ? mediaTimeFromSeconds({ seconds: asset.duration }) : DEFAULT_NEW_ELEMENT_DURATION; const element = buildElementFromMedia({ mediaId: asset.id, diff --git a/apps/web/src/components/editor/panels/assets/views/settings/index.tsx b/apps/web/src/components/editor/panels/assets/views/settings/index.tsx index bf3a1c16..5edbeab3 100644 --- a/apps/web/src/components/editor/panels/assets/views/settings/index.tsx +++ b/apps/web/src/components/editor/panels/assets/views/settings/index.tsx @@ -1,6 +1,6 @@ "use client"; -import { useRef, useState } from "react"; +import { useState } from "react"; import { PanelView } from "@/components/editor/panels/assets/views/base-panel"; import { Select, @@ -34,6 +34,10 @@ import type { TCanvasSize } from "@/project/types"; type SettingsView = "project-info" | "background"; +function isSettingsView(value: string): value is SettingsView { + return value === "project-info" || value === "background"; +} + const PRESET_LABELS: Record<string, string> = { "1:1": "1:1", "16:9": "16:9", @@ -73,23 +77,20 @@ function useCanvasDimensionDraft({ value: number; onCommit: (value: number) => void; }) { - const pendingValueRef = useRef(value); - const syncedValueRef = useRef(value); - - if (syncedValueRef.current !== value) { - syncedValueRef.current = value; - pendingValueRef.current = value; - } + const [pendingValue, setPendingValue] = useState(value); return usePropertyDraft({ displayValue: formatCanvasDimension({ value }), parse: (input) => parseCanvasDimension({ input }), + onStartEditing: () => { + setPendingValue(value); + }, onPreview: (nextValue) => { - pendingValueRef.current = nextValue; + setPendingValue(nextValue); }, onCommit: () => { - if (pendingValueRef.current !== value) { - onCommit(pendingValueRef.current); + if (pendingValue !== value) { + onCommit(pendingValue); } }, }); @@ -212,7 +213,14 @@ export function SettingsView() { contentClassName="px-0" scrollClassName="pt-0" actions={ - <Tabs value={view} onValueChange={(v) => setView(v as SettingsView)}> + <Tabs + value={view} + onValueChange={(value) => { + if (isSettingsView(value)) { + setView(value); + } + }} + > <TabsList> <TabsTrigger value="project-info">Project info</TabsTrigger> <TabsTrigger value="background">Background</TabsTrigger> diff --git a/apps/web/src/components/editor/panels/properties/components/element-params-tab.tsx b/apps/web/src/components/editor/panels/properties/components/element-params-tab.tsx new file mode 100644 index 00000000..2befcef4 --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/components/element-params-tab.tsx @@ -0,0 +1,154 @@ +"use client"; + +import { resolveAnimationPathValueAtTime } from "@/animation"; +import { Section, SectionContent, SectionFields } from "@/components/section"; +import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead"; +import { useKeyframedParamProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-param-property"; +import { PropertyParamField } from "@/components/editor/panels/properties/components/property-param-field"; +import type { ParamValue, ParamValues } from "@/params"; +import { + getElementParams, + readElementParamValue, + writeElementParamValue, + type ElementParamDefinition, +} from "@/params/registry"; +import type { TimelineElement } from "@/timeline"; +import type { MediaTime } from "@/wasm"; + +export function ElementParamsTab({ + element, + trackId, + paramKeys, + sectionKey, +}: { + element: TimelineElement; + trackId: string; + paramKeys?: readonly string[]; + sectionKey: string; +}) { + const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ + startTime: element.startTime, + duration: element.duration, + }); + const params = getElementParams({ element }).filter( + (param) => !paramKeys || paramKeys.includes(param.key), + ); + const baseValues = buildValues({ element, params }); + + return ( + <Section sectionKey={`${element.id}:${sectionKey}`}> + <SectionContent className="pt-4"> + <SectionFields> + {params + .filter((param) => isVisible({ param, values: baseValues })) + .map((param) => ( + <ElementParamField + key={param.key} + element={element} + trackId={trackId} + param={param} + baseValue={baseValues[param.key] ?? param.default} + localTime={localTime} + isPlayheadWithinElementRange={isPlayheadWithinElementRange} + /> + ))} + </SectionFields> + </SectionContent> + </Section> + ); +} + +function ElementParamField({ + element, + trackId, + param, + baseValue, + localTime, + isPlayheadWithinElementRange, +}: { + element: TimelineElement; + trackId: string; + param: ElementParamDefinition; + baseValue: ParamValue; + localTime: MediaTime; + isPlayheadWithinElementRange: boolean; +}) { + const resolvedValue = resolveAnimationPathValueAtTime({ + animations: element.animations, + propertyPath: param.key, + localTime, + fallbackValue: baseValue, + }); + const animatedParam = useKeyframedParamProperty({ + param, + trackId, + elementId: element.id, + animations: element.animations, + propertyPath: param.key, + localTime, + isPlayheadWithinElementRange, + resolvedValue, + buildBaseUpdates: ({ value }) => + writeElementParamValue({ element, param, value }), + }); + + return ( + <PropertyParamField + param={param} + value={resolvedValue} + onPreview={animatedParam.onPreview} + onCommit={animatedParam.onCommit} + keyframe={ + param.keyframable === false + ? undefined + : { + isActive: animatedParam.isKeyframedAtTime, + isDisabled: !isPlayheadWithinElementRange, + onToggle: animatedParam.toggleKeyframe, + } + } + /> + ); +} + +function buildValues({ + element, + params, +}: { + element: TimelineElement; + params: readonly ElementParamDefinition[]; +}): ParamValues { + const values: ParamValues = {}; + for (const param of params) { + const value = readElementParamValue({ element, param }); + if (value !== null) { + values[param.key] = value; + } + } + return values; +} + +function isVisible({ + param, + values, +}: { + param: ElementParamDefinition; + values: ParamValues; +}): boolean { + return (param.dependencies ?? []).every((dependency) => + areParamValuesEqual({ + left: values[dependency.param], + right: dependency.equals, + }), + ); +} + +function areParamValuesEqual({ + left, + right, +}: { + left: ParamValue | undefined; + right: ParamValue; +}): boolean { + return left === right; +} diff --git a/apps/web/src/components/editor/panels/properties/components/property-param-field.tsx b/apps/web/src/components/editor/panels/properties/components/property-param-field.tsx index c9a9c22e..66225903 100644 --- a/apps/web/src/components/editor/panels/properties/components/property-param-field.tsx +++ b/apps/web/src/components/editor/panels/properties/components/property-param-field.tsx @@ -1,6 +1,10 @@ "use client"; -import type { ParamDefinition, NumberParamDefinition } from "@/params"; +import type { + ParamDefinition, + NumberParamDefinition, + ParamValue, +} from "@/params"; import { formatNumberForDisplay, getFractionDigitsForStep, @@ -19,6 +23,7 @@ import { } from "@/components/ui/select"; import { usePropertyDraft } from "../hooks/use-property-draft"; import { KeyframeToggle } from "./keyframe-toggle"; +import { Textarea } from "@/components/ui/textarea"; export function PropertyParamField({ param, @@ -28,8 +33,8 @@ export function PropertyParamField({ keyframe, }: { param: ParamDefinition; - value: number | string | boolean; - onPreview: (value: number | string | boolean) => void; + value: ParamValue; + onPreview: (value: ParamValue) => void; onCommit: () => void; keyframe?: { isActive: boolean; @@ -41,7 +46,7 @@ export function PropertyParamField({ <SectionField label={param.label} beforeLabel={ - keyframe ? ( + keyframe && param.keyframable !== false ? ( <KeyframeToggle isActive={keyframe.isActive} isDisabled={keyframe.isDisabled} @@ -68,8 +73,8 @@ function ParamInput({ onCommit, }: { param: ParamDefinition; - value: number | string | boolean; - onPreview: (value: number | string | boolean) => void; + value: ParamValue; + onPreview: (value: ParamValue) => void; onCommit: () => void; }) { if (param.type === "number") { @@ -131,6 +136,27 @@ function ParamInput({ ); } + if (param.type === "text") { + return ( + <Textarea + value={String(value)} + onChange={(event) => onPreview(event.currentTarget.value)} + onBlur={onCommit} + /> + ); + } + + if (param.type === "font") { + return ( + <input + className="border-input bg-accent h-9 w-full rounded-md border px-3 text-sm outline-none" + value={String(value)} + onChange={(event) => onPreview(event.currentTarget.value)} + onBlur={onCommit} + /> + ); + } + return null; } diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts b/apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts index 967156b3..83e31aec 100644 --- a/apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts +++ b/apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts @@ -1,22 +1,25 @@ import { useEditor } from "@/editor/use-editor"; import { getElementLocalTime } from "@/animation"; +import { addMediaTime, mediaTime, type MediaTime } from "@/wasm"; export function useElementPlayhead({ startTime, duration, }: { - startTime: number; - duration: number; + startTime: MediaTime; + duration: MediaTime; }) { const playheadTime = useEditor((editor) => editor.playback.getCurrentTime()); - const localTime = getElementLocalTime({ - timelineTime: playheadTime, - elementStartTime: startTime, - elementDuration: duration, + const localTime = mediaTime({ + ticks: getElementLocalTime({ + timelineTime: playheadTime, + elementStartTime: startTime, + elementDuration: duration, + }), }); const isPlayheadWithinElementRange = playheadTime >= startTime && - playheadTime <= startTime + duration; + playheadTime <= addMediaTime({ a: startTime, b: duration }); return { localTime, isPlayheadWithinElementRange }; } diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts deleted file mode 100644 index 80fc171b..00000000 --- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { useEditor } from "@/editor/use-editor"; -import { - getKeyframeAtTime, - hasKeyframesForPath, -} from "@/animation"; -import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types"; -import type { TimelineElement } from "@/timeline"; -import { upsertElementKeyframe } from "@/timeline/animation-properties"; - -export function useKeyframedColorProperty({ - trackId, - elementId, - animations, - propertyPath, - localTime, - isPlayheadWithinElementRange, - resolvedColor, - buildBaseUpdates, -}: { - trackId: string; - elementId: string; - animations: ElementAnimations | undefined; - propertyPath: AnimationPropertyPath; - localTime: number; - isPlayheadWithinElementRange: boolean; - resolvedColor: string; - buildBaseUpdates: ({ value }: { value: string }) => Partial<TimelineElement>; -}) { - const editor = useEditor(); - - const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath }); - const keyframeAtTime = isPlayheadWithinElementRange - ? getKeyframeAtTime({ animations, propertyPath, time: localTime }) - : null; - const keyframeIdAtTime = keyframeAtTime?.id ?? null; - const isKeyframedAtTime = keyframeAtTime !== null; - const shouldUseAnimatedChannel = - hasAnimatedKeyframes && isPlayheadWithinElementRange; - - const onChange = ({ color }: { color: string }) => { - if (shouldUseAnimatedChannel) { - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId, - updates: { - animations: upsertElementKeyframe({ - animations, - propertyPath, - time: localTime, - value: color, - }), - }, - }, - ], - }); - return; - } - - editor.timeline.previewElements({ - updates: [{ trackId, elementId, updates: buildBaseUpdates({ value: color }) }], - }); - }; - - const onChangeEnd = () => editor.timeline.commitPreview(); - - const toggleKeyframe = () => { - if (!isPlayheadWithinElementRange) { - return; - } - - if (keyframeIdAtTime) { - editor.timeline.removeKeyframes({ - keyframes: [{ trackId, elementId, propertyPath, keyframeId: keyframeIdAtTime }], - }); - return; - } - - editor.timeline.upsertKeyframes({ - keyframes: [ - { trackId, elementId, propertyPath, time: localTime, value: resolvedColor }, - ], - }); - }; - - return { - isKeyframedAtTime, - hasAnimatedKeyframes, - keyframeIdAtTime, - onChange, - onChangeEnd, - toggleKeyframe, - }; -} diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts deleted file mode 100644 index 32222b7a..00000000 --- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { useEditor } from "@/editor/use-editor"; -import { - getKeyframeAtTime, - hasKeyframesForPath, -} from "@/animation"; -import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types"; -import type { TimelineElement } from "@/timeline"; -import { upsertElementKeyframe } from "@/timeline/animation-properties"; -import { snapToStep } from "@/utils/math"; -import { usePropertyDraft } from "./use-property-draft"; - -export function useKeyframedNumberProperty({ - trackId, - elementId, - animations, - propertyPath, - localTime, - isPlayheadWithinElementRange, - displayValue, - parse, - valueAtPlayhead, - step, - buildBaseUpdates, - buildAdditionalKeyframes, -}: { - trackId: string; - elementId: string; - animations: ElementAnimations | undefined; - propertyPath: AnimationPropertyPath; - localTime: number; - isPlayheadWithinElementRange: boolean; - displayValue: string; - parse: (input: string) => number | null; - valueAtPlayhead: number; - step?: number; - buildBaseUpdates: ({ value }: { value: number }) => Partial<TimelineElement>; - buildAdditionalKeyframes?: ({ - value, - }: { value: number }) => Array<{ propertyPath: AnimationPropertyPath; value: number }>; -}) { - const editor = useEditor(); - const snapValue = (value: number) => - step != null ? snapToStep({ value, step }) : value; - - const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath }); - const keyframeAtTime = isPlayheadWithinElementRange - ? getKeyframeAtTime({ animations, propertyPath, time: localTime }) - : null; - const keyframeIdAtTime = keyframeAtTime?.id ?? null; - const isKeyframedAtTime = keyframeAtTime !== null; - const shouldUseAnimatedChannel = - hasAnimatedKeyframes && isPlayheadWithinElementRange; - - const previewValue = ({ value }: { value: number }) => { - const nextValue = snapValue(value); - if (shouldUseAnimatedChannel) { - const additionalKeyframes = buildAdditionalKeyframes?.({ value: nextValue }) ?? []; - const updatedAnimations = [ - { propertyPath, value: nextValue }, - ...additionalKeyframes, - ].reduce( - (currentAnimations, keyframe) => - upsertElementKeyframe({ - animations: currentAnimations, - propertyPath: keyframe.propertyPath, - time: localTime, - value: keyframe.value, - }), - animations, - ); - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId, - updates: { animations: updatedAnimations }, - }, - ], - }); - return; - } - - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId, - updates: buildBaseUpdates({ value: nextValue }), - }, - ], - }); - }; - - const propertyDraft = usePropertyDraft({ - displayValue, - parse: (input) => { - const parsedValue = parse(input); - return parsedValue === null ? null : snapValue(parsedValue); - }, - onPreview: (value) => previewValue({ value }), - onCommit: () => editor.timeline.commitPreview(), - }); - - const toggleKeyframe = () => { - if (!isPlayheadWithinElementRange) { - return; - } - - if (keyframeIdAtTime) { - editor.timeline.removeKeyframes({ - keyframes: [ - { - trackId, - elementId, - propertyPath, - keyframeId: keyframeIdAtTime, - }, - ], - }); - return; - } - - editor.timeline.upsertKeyframes({ - keyframes: [ - { - trackId, - elementId, - propertyPath, - time: localTime, - value: snapValue(valueAtPlayhead), - }, - ], - }); - }; - - const commitValue = ({ value }: { value: number }) => { - const nextValue = snapValue(value); - if (shouldUseAnimatedChannel) { - const additionalKeyframes = buildAdditionalKeyframes?.({ value: nextValue }) ?? []; - editor.timeline.upsertKeyframes({ - keyframes: [ - { trackId, elementId, propertyPath, time: localTime, value: nextValue }, - ...additionalKeyframes.map((keyframe) => ({ - trackId, - elementId, - propertyPath: keyframe.propertyPath, - time: localTime, - value: keyframe.value, - })), - ], - }); - return; - } - - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId, - patch: buildBaseUpdates({ value: nextValue }), - }, - ], - }); - }; - - return { - ...propertyDraft, - hasAnimatedKeyframes, - isKeyframedAtTime, - keyframeIdAtTime, - toggleKeyframe, - commitValue, - }; -} diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts index 18e027c7..0a0f7db0 100644 --- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts +++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts @@ -8,15 +8,16 @@ import { upsertPathKeyframe, } from "@/animation"; import type { + AnimationPath, ElementAnimations, } from "@/animation/types"; import { - coerceAnimationParamValue, - getAnimationParamDefaultInterpolation, - getAnimationParamValueKind, -} from "@/animation/animated-params"; -import type { ParamDefinition } from "@/params"; + coerceParamValue, + getParamChannelLayout, + type ParamDefinition, +} from "@/params"; import type { TimelineElement } from "@/timeline"; +import type { MediaTime } from "@/wasm"; export interface KeyframedParamPropertyResult { hasAnimatedKeyframes: boolean; @@ -32,6 +33,7 @@ export function useKeyframedParamProperty({ trackId, elementId, animations, + propertyPath, localTime, isPlayheadWithinElementRange, resolvedValue, @@ -41,7 +43,8 @@ export function useKeyframedParamProperty({ trackId: string; elementId: string; animations: ElementAnimations | undefined; - localTime: number; + propertyPath?: AnimationPath; + localTime: MediaTime; isPlayheadWithinElementRange: boolean; resolvedValue: number | string | boolean; buildBaseUpdates: ({ @@ -51,15 +54,16 @@ export function useKeyframedParamProperty({ }) => Partial<TimelineElement>; }): KeyframedParamPropertyResult { const editor = useEditor(); - const propertyPath = buildGraphicParamPath({ paramKey: param.key }); + const resolvedPropertyPath = + propertyPath ?? buildGraphicParamPath({ paramKey: param.key }); const hasAnimatedKeyframes = hasKeyframesForPath({ animations, - propertyPath, + propertyPath: resolvedPropertyPath, }); const keyframeAtTime = isPlayheadWithinElementRange ? getKeyframeAtTime({ animations, - propertyPath, + propertyPath: resolvedPropertyPath, time: localTime, }) : null; @@ -78,15 +82,12 @@ export function useKeyframedParamProperty({ updates: { animations: upsertPathKeyframe({ animations, - propertyPath, + propertyPath: resolvedPropertyPath, time: localTime, value, - kind: getAnimationParamValueKind({ param }), - defaultInterpolation: getAnimationParamDefaultInterpolation({ - param, - }), + channelLayout: getParamChannelLayout({ param }), coerceValue: ({ value: nextValue }) => - coerceAnimationParamValue({ + coerceParamValue({ param, value: nextValue, }), @@ -120,7 +121,7 @@ export function useKeyframedParamProperty({ { trackId, elementId, - propertyPath, + propertyPath: resolvedPropertyPath, keyframeId: keyframeIdAtTime, }, ], @@ -133,7 +134,7 @@ export function useKeyframedParamProperty({ { trackId, elementId, - propertyPath, + propertyPath: resolvedPropertyPath, time: localTime, value: resolvedValue, }, diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-property-draft.ts b/apps/web/src/components/editor/panels/properties/hooks/use-property-draft.ts index 0e7ae6a7..7e8f6e40 100644 --- a/apps/web/src/components/editor/panels/properties/hooks/use-property-draft.ts +++ b/apps/web/src/components/editor/panels/properties/hooks/use-property-draft.ts @@ -1,4 +1,4 @@ -import { useReducer, useRef } from "react";
+import { useState } from "react";
import { evaluateMathExpression } from "@/utils/math";
function looksLikeExpression({ input }: { input: string }): boolean {
@@ -14,59 +14,56 @@ export function usePropertyDraft<T>({ parse,
onPreview,
onCommit,
+ onStartEditing,
supportsExpressions = true,
}: {
displayValue: string;
parse: (input: string) => T | null;
onPreview: (value: T) => void;
onCommit: () => void;
+ onStartEditing?: () => void;
supportsExpressions?: boolean;
}) {
- const [, forceRender] = useReducer(
- (renderVersion: number) => renderVersion + 1,
- 0,
- );
- const isEditing = useRef(false);
- const draft = useRef("");
+ const [isEditing, setIsEditing] = useState(false);
+ const [draft, setDraft] = useState("");
return {
- displayValue: isEditing.current ? draft.current : sourceDisplay,
+ displayValue: isEditing ? draft : sourceDisplay,
scrubTo: (value: number) => {
const parsed = parse(String(value));
if (parsed !== null) onPreview(parsed);
},
commitScrub: onCommit,
onFocus: () => {
- isEditing.current = true;
- draft.current = sourceDisplay;
- forceRender();
+ setIsEditing(true);
+ setDraft(sourceDisplay);
+ onStartEditing?.();
},
onChange: (
event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) => {
- draft.current = event.target.value;
- forceRender();
+ const nextDraft = event.target.value;
+ setDraft(nextDraft);
- const parsed = parse(event.target.value);
+ const parsed = parse(nextDraft);
if (parsed !== null) {
onPreview(parsed);
}
},
- onBlur: () => {
- if (
- supportsExpressions &&
- looksLikeExpression({ input: draft.current })
- ) {
- const evaluated = evaluateMathExpression({ input: draft.current });
+ onBlur: (
+ event: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>,
+ ) => {
+ const nextDraft = event.target.value;
+ if (supportsExpressions && looksLikeExpression({ input: nextDraft })) {
+ const evaluated = evaluateMathExpression({ input: nextDraft });
if (evaluated !== null) {
const parsed = parse(String(evaluated));
if (parsed !== null) onPreview(parsed);
}
}
onCommit();
- isEditing.current = false;
- draft.current = "";
- forceRender();
+ setIsEditing(false);
+ setDraft("");
},
};
}
diff --git a/apps/web/src/components/editor/panels/properties/index.tsx b/apps/web/src/components/editor/panels/properties/index.tsx index 52fa42b5..d946027d 100644 --- a/apps/web/src/components/editor/panels/properties/index.tsx +++ b/apps/web/src/components/editor/panels/properties/index.tsx @@ -71,7 +71,12 @@ export function PropertiesPanel() { <Button variant={tab.id === activeTab.id ? "secondary" : "ghost"} size="icon" - onClick={() => setActiveTab(element.type, tab.id)} + onClick={() => + setActiveTab({ + elementType: element.type, + tabId: tab.id, + }) + } aria-label={tab.label} className={cn( "shrink-0", diff --git a/apps/web/src/components/editor/panels/properties/registry.tsx b/apps/web/src/components/editor/panels/properties/registry.tsx index f9e40ab3..1eda6575 100644 --- a/apps/web/src/components/editor/panels/properties/registry.tsx +++ b/apps/web/src/components/editor/panels/properties/registry.tsx @@ -22,16 +22,43 @@ import { MagicWand05Icon, DashboardSpeed02Icon, } from "@hugeicons/core-free-icons"; -import { TransformTab } from "@/rendering/components/transform-tab"; -import { BlendingTab } from "@/rendering/components/blending-tab"; -import { AudioTab } from "@/timeline/components/audio-tab"; -import { TextTab } from "@/text/components/text-tab"; +import { ElementParamsTab } from "./components/element-params-tab"; import { ClipEffectsTab, StandaloneEffectTab } from "@/effects/components/effects-tab"; import { MasksTab } from "@/masks/components/masks-tab"; import { SpeedTab } from "@/speed/components/speed-tab"; import { GraphicTab } from "@/graphics/components/graphic-tab"; import { OcShapesIcon } from "@/components/icons"; +const TRANSFORM_PARAM_KEYS = [ + "transform.positionX", + "transform.positionY", + "transform.scaleX", + "transform.scaleY", + "transform.rotate", +] as const; + +const BLENDING_PARAM_KEYS = ["opacity", "blendMode"] as const; +const AUDIO_PARAM_KEYS = ["volume", "muted"] as const; +const TEXT_PARAM_KEYS = [ + "content", + "fontFamily", + "fontSize", + "color", + "textAlign", + "fontWeight", + "fontStyle", + "textDecoration", + "letterSpacing", + "lineHeight", + "background.enabled", + "background.color", + "background.cornerRadius", + "background.paddingX", + "background.paddingY", + "background.offsetX", + "background.offsetY", +] as const; + export type TabContentProps = { trackId: string; }; @@ -58,7 +85,12 @@ function buildTransformTab({ label: "Transform", icon: <HugeiconsIcon icon={ArrowExpandIcon} size={16} />, content: ({ trackId }) => ( - <TransformTab element={element} trackId={trackId} /> + <ElementParamsTab + element={element} + trackId={trackId} + paramKeys={TRANSFORM_PARAM_KEYS} + sectionKey="transform" + /> ), }; } @@ -73,7 +105,12 @@ function buildBlendingTab({ label: "Blending", icon: <HugeiconsIcon icon={RainDropIcon} size={16} />, content: ({ trackId }) => ( - <BlendingTab element={element} trackId={trackId} /> + <ElementParamsTab + element={element} + trackId={trackId} + paramKeys={BLENDING_PARAM_KEYS} + sectionKey="blending" + /> ), }; } @@ -87,7 +124,14 @@ function buildAudioTab({ id: "audio", label: "Audio", icon: <HugeiconsIcon icon={MusicNote03Icon} size={16} />, - content: ({ trackId }) => <AudioTab element={element} trackId={trackId} />, + content: ({ trackId }) => ( + <ElementParamsTab + element={element} + trackId={trackId} + paramKeys={AUDIO_PARAM_KEYS} + sectionKey="audio" + /> + ), }; } @@ -137,7 +181,14 @@ function buildTextTab({ element }: { element: TextElement }): PropertiesTabDef { id: "text", label: "Text", icon: <HugeiconsIcon icon={TextFontIcon} size={16} />, - content: ({ trackId }) => <TextTab element={element} trackId={trackId} />, + content: ({ trackId }) => ( + <ElementParamsTab + element={element} + trackId={trackId} + paramKeys={TEXT_PARAM_KEYS} + sectionKey="text" + /> + ), }; } diff --git a/apps/web/src/components/editor/panels/properties/stores/properties-store.ts b/apps/web/src/components/editor/panels/properties/stores/properties-store.ts index bd57d4b0..f6304349 100644 --- a/apps/web/src/components/editor/panels/properties/stores/properties-store.ts +++ b/apps/web/src/components/editor/panels/properties/stores/properties-store.ts @@ -2,17 +2,18 @@ import { create } from "zustand"; interface PropertiesState {
activeTabPerType: Record<string, string>;
- setActiveTab: (elementType: string, tabId: string) => void;
+ setActiveTab: (args: { elementType: string; tabId: string }) => void;
isTransformScaleLocked: boolean;
- setTransformScaleLocked: (locked: boolean) => void;
+ setTransformScaleLocked: (args: { locked: boolean }) => void;
}
export const usePropertiesStore = create<PropertiesState>()((set) => ({
activeTabPerType: {},
- setActiveTab: (elementType, tabId) =>
+ setActiveTab: ({ elementType, tabId }) =>
set((state) => ({
activeTabPerType: { ...state.activeTabPerType, [elementType]: tabId },
})),
isTransformScaleLocked: false,
- setTransformScaleLocked: (locked) => set({ isTransformScaleLocked: locked }),
+ setTransformScaleLocked: ({ locked }) =>
+ set({ isTransformScaleLocked: locked }),
}));
|
