summaryrefslogtreecommitdiff
path: root/apps/web/src/components/editor/panels/properties/tabs
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/components/editor/panels/properties/tabs')
-rw-r--r--apps/web/src/components/editor/panels/properties/tabs/audio-tab.tsx151
-rw-r--r--apps/web/src/components/editor/panels/properties/tabs/blending-tab.tsx230
-rw-r--r--apps/web/src/components/editor/panels/properties/tabs/effects-tab.tsx309
-rw-r--r--apps/web/src/components/editor/panels/properties/tabs/graphic-tab.tsx238
-rw-r--r--apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx826
-rw-r--r--apps/web/src/components/editor/panels/properties/tabs/speed-tab.tsx156
-rw-r--r--apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx767
-rw-r--r--apps/web/src/components/editor/panels/properties/tabs/transform-tab.tsx462
8 files changed, 0 insertions, 3139 deletions
diff --git a/apps/web/src/components/editor/panels/properties/tabs/audio-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/audio-tab.tsx
deleted file mode 100644
index 5ac0fbb7..00000000
--- a/apps/web/src/components/editor/panels/properties/tabs/audio-tab.tsx
+++ /dev/null
@@ -1,151 +0,0 @@
-import { Button } from "@/components/ui/button";
-import { NumberField } from "@/components/ui/number-field";
-import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/lib/timeline/audio-constants";
-import { isSourceAudioSeparated } from "@/lib/timeline/audio-separation";
-import { DEFAULTS } from "@/lib/timeline/defaults";
-import {
- clamp,
- formatNumberForDisplay,
- getFractionDigitsForStep,
- isNearlyEqual,
- snapToStep,
-} from "@/utils/math";
-import type { AudioElement, VideoElement } from "@/lib/timeline";
-import { resolveNumberAtTime } from "@/lib/animation";
-import { useEditor } from "@/hooks/use-editor";
-import { useElementPlayhead } from "../hooks/use-element-playhead";
-import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
-import { KeyframeToggle } from "../components/keyframe-toggle";
-import { HugeiconsIcon } from "@hugeicons/react";
-import { VolumeHighIcon } from "@hugeicons/core-free-icons";
-import {
- Section,
- SectionContent,
- SectionField,
- SectionFields,
- SectionHeader,
- SectionTitle,
-} from "@/components/section";
-
-const VOLUME_STEP = 0.1;
-const VOLUME_FRACTION_DIGITS = getFractionDigitsForStep({ step: VOLUME_STEP });
-
-export function AudioTab({
- element,
- trackId,
-}: {
- element: AudioElement | VideoElement;
- trackId: string;
-}) {
- const editor = useEditor();
- const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
- startTime: element.startTime,
- duration: element.duration,
- });
- const resolvedVolume = resolveNumberAtTime({
- baseValue: element.volume ?? DEFAULTS.element.volume,
- animations: element.animations,
- propertyPath: "volume",
- localTime,
- });
- const volume = useKeyframedNumberProperty({
- trackId,
- elementId: element.id,
- animations: element.animations,
- propertyPath: "volume",
- localTime,
- isPlayheadWithinElementRange,
- displayValue: formatNumberForDisplay({
- value: resolvedVolume,
- fractionDigits: VOLUME_FRACTION_DIGITS,
- }),
- parse: (input) => {
- const parsed = parseFloat(input);
- if (Number.isNaN(parsed)) {
- return null;
- }
-
- return clamp({
- value: snapToStep({ value: parsed, step: VOLUME_STEP }),
- min: VOLUME_DB_MIN,
- max: VOLUME_DB_MAX,
- });
- },
- valueAtPlayhead: resolvedVolume,
- step: VOLUME_STEP,
- buildBaseUpdates: ({ value }) => ({
- volume: value,
- }),
- });
- const isDefault =
- volume.hasAnimatedKeyframes && isPlayheadWithinElementRange
- ? isNearlyEqual({
- leftValue: resolvedVolume,
- rightValue: DEFAULTS.element.volume,
- })
- : (element.volume ?? DEFAULTS.element.volume) === DEFAULTS.element.volume;
- const isSeparated =
- element.type === "video" && isSourceAudioSeparated({ element });
-
- return (
- <>
- {isSeparated && (
- <div className="mx-4 mt-4 rounded-md border bg-muted/30 p-3">
- <p className="text-sm">Audio has been separated.</p>
- <Button
- className="mt-3"
- size="sm"
- variant="secondary"
- onClick={() =>
- editor.timeline.toggleSourceAudioSeparation({
- trackId,
- elementId: element.id,
- })
- }
- >
- Recover audio
- </Button>
- </div>
- )}
- <Section collapsible sectionKey={`${element.id}:audio`}>
- <SectionHeader>
- <SectionTitle>Audio</SectionTitle>
- </SectionHeader>
- <SectionContent>
- <SectionFields>
- <SectionField
- label="Volume"
- beforeLabel={
- <KeyframeToggle
- isActive={volume.isKeyframedAtTime}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle volume keyframe"
- onToggle={volume.toggleKeyframe}
- />
- }
- >
- <NumberField
- icon={<HugeiconsIcon icon={VolumeHighIcon} />}
- value={volume.displayValue}
- onFocus={volume.onFocus}
- onChange={volume.onChange}
- onBlur={volume.onBlur}
- dragSensitivity="slow"
- scrubClamp={{ min: VOLUME_DB_MIN, max: VOLUME_DB_MAX }}
- onScrub={volume.scrubTo}
- onScrubEnd={volume.commitScrub}
- onReset={() =>
- volume.commitValue({
- value: DEFAULTS.element.volume,
- })
- }
- isDefault={isDefault}
- suffix="dB"
- />
- </SectionField>
- </SectionFields>
- </SectionContent>
- </Section>
- </>
- );
-}
diff --git a/apps/web/src/components/editor/panels/properties/tabs/blending-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/blending-tab.tsx
deleted file mode 100644
index f8e5397d..00000000
--- a/apps/web/src/components/editor/panels/properties/tabs/blending-tab.tsx
+++ /dev/null
@@ -1,230 +0,0 @@
-import { useEditor } from "@/hooks/use-editor";
-import { clamp } from "@/utils/math";
-import { NumberField } from "@/components/ui/number-field";
-import { OcCheckerboardIcon } from "@/components/icons";
-import { Fragment, useRef } from "react";
-import { useMenuPreview } from "@/hooks/use-menu-preview";
-import {
- Section,
- SectionContent,
- SectionField,
- SectionHeader,
- SectionTitle,
-} from "@/components/section";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectSeparator,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
-import type { BlendMode } from "@/lib/rendering";
-import type { ElementType } from "@/lib/timeline";
-import type { ElementAnimations } from "@/lib/animation/types";
-import { HugeiconsIcon } from "@hugeicons/react";
-import { RainDropIcon } from "@hugeicons/core-free-icons";
-import { KeyframeToggle } from "../components/keyframe-toggle";
-import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
-import { useElementPlayhead } from "../hooks/use-element-playhead";
-import { resolveOpacityAtTime } from "@/lib/animation";
-import { DEFAULTS } from "@/lib/timeline/defaults";
-import { isPropertyAtDefault } from "./transform-tab";
-
-type BlendingElement = {
- id: string;
- opacity: number;
- type: ElementType;
- blendMode?: BlendMode;
- startTime: number;
- duration: number;
- animations?: ElementAnimations;
-};
-
-const BLEND_MODE_GROUPS: { value: BlendMode; label: string }[][] = [
- [{ value: "normal", label: "Normal" }],
- [
- { value: "darken", label: "Darken" },
- { value: "multiply", label: "Multiply" },
- { value: "color-burn", label: "Color Burn" },
- ],
- [
- { value: "lighten", label: "Lighten" },
- { value: "screen", label: "Screen" },
- { value: "plus-lighter", label: "Plus Lighter" },
- { value: "color-dodge", label: "Color Dodge" },
- ],
- [
- { value: "overlay", label: "Overlay" },
- { value: "soft-light", label: "Soft Light" },
- { value: "hard-light", label: "Hard Light" },
- ],
- [
- { value: "difference", label: "Difference" },
- { value: "exclusion", label: "Exclusion" },
- ],
- [
- { value: "hue", label: "Hue" },
- { value: "saturation", label: "Saturation" },
- { value: "color", label: "Color" },
- { value: "luminosity", label: "Luminosity" },
- ],
-];
-
-export function BlendingTab({
- element,
- trackId,
-}: {
- element: BlendingElement;
- trackId: string;
-}) {
- const editor = useEditor();
- const isPreviewActive = useEditor((e) => e.timeline.isPreviewActive());
- const blendMode = element.blendMode ?? DEFAULTS.element.blendMode;
- const committedBlendModeRef = useRef(blendMode);
- if (!isPreviewActive) {
- committedBlendModeRef.current = blendMode;
- }
-
- const {
- onPointerLeave,
- onOpenChange: handleBlendModeOpenChange,
- markCommitted,
- } = useMenuPreview();
-
- const previewBlendMode = ({ value }: { value: BlendMode }) =>
- editor.timeline.previewElements({
- updates: [
- { trackId, elementId: element.id, updates: { blendMode: value } },
- ],
- });
-
- const commitBlendMode = (value: BlendMode) => {
- if (editor.timeline.isPreviewActive()) {
- editor.timeline.commitPreview();
- } else {
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- patch: { blendMode: value },
- },
- ],
- });
- }
- markCommitted();
- };
-
- const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
- startTime: element.startTime,
- duration: element.duration,
- });
- const resolvedOpacity = resolveOpacityAtTime({
- baseOpacity: element.opacity,
- animations: element.animations,
- localTime,
- });
-
- const opacity = useKeyframedNumberProperty({
- trackId,
- elementId: element.id,
- animations: element.animations,
- propertyPath: "opacity",
- localTime,
- isPlayheadWithinElementRange,
- displayValue: Math.round(resolvedOpacity * 100).toString(),
- parse: (input) => {
- const parsed = parseFloat(input);
- if (Number.isNaN(parsed)) return null;
- return clamp({ value: parsed, min: 0, max: 100 }) / 100;
- },
- valueAtPlayhead: resolvedOpacity,
- step: 0.01,
- buildBaseUpdates: ({ value }) => ({ opacity: value }),
- });
-
- return (
- <Section collapsible sectionKey={`${element.id}:blending`}>
- <SectionHeader>
- <SectionTitle>Blending</SectionTitle>
- </SectionHeader>
- <SectionContent>
- <div className="flex items-start gap-2">
- <SectionField
- label="Opacity"
- className="w-1/2"
- beforeLabel={
- <KeyframeToggle
- isActive={opacity.isKeyframedAtTime}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle opacity keyframe"
- onToggle={opacity.toggleKeyframe}
- />
- }
- >
- <NumberField
- className="w-full"
- icon={
- <OcCheckerboardIcon className="size-3.5 text-muted-foreground" />
- }
- value={opacity.displayValue}
- min={0}
- max={100}
- onFocus={opacity.onFocus}
- onChange={opacity.onChange}
- onBlur={opacity.onBlur}
- onScrub={opacity.scrubTo}
- onScrubEnd={opacity.commitScrub}
- onReset={() =>
- opacity.commitValue({ value: DEFAULTS.element.opacity })
- }
- isDefault={isPropertyAtDefault({
- hasAnimatedKeyframes: opacity.hasAnimatedKeyframes,
- isPlayheadWithinElementRange,
- resolvedValue: resolvedOpacity,
- staticValue: element.opacity,
- defaultValue: DEFAULTS.element.opacity,
- })}
- dragSensitivity="slow"
- />
- </SectionField>
- <SectionField label="Blend mode" className="w-1/2">
- <Select
- value={committedBlendModeRef.current}
- onOpenChange={handleBlendModeOpenChange}
- onValueChange={commitBlendMode}
- >
- <SelectTrigger
- icon={<HugeiconsIcon icon={RainDropIcon} />}
- className="w-full"
- >
- <SelectValue placeholder="Select blend mode" />
- </SelectTrigger>
- <SelectContent className="w-36" onPointerLeave={onPointerLeave}>
- {BLEND_MODE_GROUPS.map((group, groupIndex) => (
- <Fragment key={group[0]?.value ?? `group-${groupIndex}`}>
- {group.map((option) => (
- <SelectItem
- key={option.value}
- value={option.value}
- onPointerEnter={() =>
- previewBlendMode({ value: option.value })
- }
- >
- {option.label}
- </SelectItem>
- ))}
- {groupIndex < BLEND_MODE_GROUPS.length - 1 ? (
- <SelectSeparator />
- ) : null}
- </Fragment>
- ))}
- </SelectContent>
- </Select>
- </SectionField>
- </div>
- </SectionContent>
- </Section>
- );
-}
diff --git a/apps/web/src/components/editor/panels/properties/tabs/effects-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/effects-tab.tsx
deleted file mode 100644
index 1b94ee2d..00000000
--- a/apps/web/src/components/editor/panels/properties/tabs/effects-tab.tsx
+++ /dev/null
@@ -1,309 +0,0 @@
-"use client";
-
-import { useState } from "react";
-import type { ParamValues } from "@/lib/params";
-import type { Effect } from "@/lib/effects/types";
-import type { EffectElement, VisualElement } from "@/lib/timeline";
-import { effectsRegistry } from "@/lib/effects";
-import { useEditor } from "@/hooks/use-editor";
-import { useElementPreview } from "@/hooks/use-element-preview";
-import {
- Section,
- SectionContent,
- SectionHeader,
- SectionTitle,
- SectionFields,
-} from "@/components/section";
-import { PropertyParamField } from "../components/property-param-field";
-import { Button } from "@/components/ui/button";
-import { HugeiconsIcon } from "@hugeicons/react";
-import {
- Delete02Icon,
- ViewIcon,
- ViewOffSlashIcon,
- MagicWand05Icon,
-} from "@hugeicons/core-free-icons";
-import { cn } from "@/utils/ui";
-import { Separator } from "@/components/ui/separator";
-import { useAssetsPanelStore } from "@/stores/assets-panel-store";
-
-export function StandaloneEffectTab({
- element,
- trackId,
-}: {
- element: EffectElement;
- trackId: string;
-}) {
- const { renderElement, previewUpdates, commit } = useElementPreview({
- trackId,
- elementId: element.id,
- fallback: element,
- });
-
- const effect: Effect = {
- id: element.id,
- type: element.effectType,
- params: element.params,
- enabled: true,
- };
-
- const previewParam = (key: string) => (value: number | string | boolean) => {
- previewUpdates({
- params: { ...(renderElement as EffectElement).params, [key]: value },
- });
- };
-
- return (
- <div className="flex flex-col h-full">
- <div className="border-b px-3.5 h-11 shrink-0 flex items-center">
- <SectionTitle>Effect</SectionTitle>
- </div>
- <EffectSection
- effect={effect}
- renderParams={(renderElement as EffectElement).params}
- previewParam={previewParam}
- onCommit={commit}
- />
- </div>
- );
-}
-
-export function ClipEffectsTab({
- element,
- trackId,
-}: {
- element: VisualElement;
- trackId: string;
-}) {
- const [dragIndex, setDragIndex] = useState<number | null>(null);
- const [dropIndex, setDropIndex] = useState<number | null>(null);
- const editor = useEditor();
- const { renderElement, previewUpdates, commit } = useElementPreview({
- trackId,
- elementId: element.id,
- fallback: element,
- });
-
- const effects: Effect[] = element.effects ?? [];
-
- const getRenderParams = ({ effectId }: { effectId: string }): ParamValues => {
- return (
- (renderElement as VisualElement).effects?.find((ef) => ef.id === effectId)
- ?.params ??
- effects.find((ef) => ef.id === effectId)?.params ??
- {}
- );
- };
-
- const buildPreviewParam =
- (effectId: string) =>
- (key: string) =>
- (value: number | string | boolean) => {
- const updatedEffects = (
- (renderElement as VisualElement).effects ?? []
- ).map((existing) =>
- existing.id !== effectId
- ? existing
- : { ...existing, params: { ...existing.params, [key]: value } },
- );
- previewUpdates({ effects: updatedEffects });
- };
-
- const handleDragStart = ({ index }: { index: number }) => setDragIndex(index);
-
- const handleDragOver = ({
- event,
- index,
- }: {
- event: React.DragEvent;
- index: number;
- }) => {
- event.preventDefault();
- if (index !== dropIndex) setDropIndex(index);
- };
-
- const handleDrop = ({ toIndex }: { toIndex: number }) => {
- if (dragIndex !== null && dragIndex !== toIndex) {
- editor.timeline.reorderClipEffects({
- trackId,
- elementId: element.id,
- fromIndex: dragIndex,
- toIndex,
- });
- }
- setDragIndex(null);
- setDropIndex(null);
- };
-
- const handleDragEnd = () => {
- setDragIndex(null);
- setDropIndex(null);
- };
-
- return (
- <div className="flex flex-col h-full">
- <div className="border-b px-3.5 h-11 shrink-0 flex items-center">
- <SectionTitle>Effects</SectionTitle>
- </div>
- {effects.length === 0 ? (
- <EmptyView />
- ) : (
- <ul className="flex flex-col">
- {effects.map((effect, index) => {
- const resolvedDragIndex = dragIndex ?? -1;
- const isDragging = dragIndex === index;
- const isDropTarget =
- dropIndex === index && dragIndex !== null && dragIndex !== index;
- const showTopDropIndicator =
- isDropTarget && index < resolvedDragIndex;
- const showBottomDropIndicator =
- isDropTarget && index > resolvedDragIndex;
-
- return (
- <li
- key={effect.id}
- draggable
- onDragStart={() => handleDragStart({ index })}
- onDragOver={(event) => handleDragOver({ event, index })}
- onDrop={() => handleDrop({ toIndex: index })}
- onDragEnd={handleDragEnd}
- className={cn(
- "group list-none",
- isDragging && "opacity-40",
- showTopDropIndicator && "border-t-2 border-primary",
- showBottomDropIndicator && "border-b-2 border-primary",
- )}
- >
- <EffectSection
- effect={effect}
- renderParams={getRenderParams({ effectId: effect.id })}
- previewParam={buildPreviewParam(effect.id)}
- onCommit={commit}
- onToggle={() =>
- editor.timeline.toggleClipEffect({
- trackId,
- elementId: element.id,
- effectId: effect.id,
- })
- }
- onRemove={() =>
- editor.timeline.removeClipEffect({
- trackId,
- elementId: element.id,
- effectId: effect.id,
- })
- }
- />
- </li>
- );
- })}
- </ul>
- )}
- </div>
- );
-}
-
-function EmptyView() {
- const setActiveTab = useAssetsPanelStore((s) => s.setActiveTab);
-
- return (
- <div className="flex flex-col h-full items-center justify-center gap-4 text-center">
- <HugeiconsIcon
- icon={MagicWand05Icon}
- className="size-10 text-muted-foreground"
- strokeWidth={1}
- />
- <div className="flex flex-col gap-2">
- <h3 className="font-medium text-foreground">No effects</h3>
- <p className="text-muted-foreground text-sm text-balance max-w-44">
- Add effects to this layer from the Assets panel.
- </p>
- </div>
- <Button
- variant="default"
- size="sm"
- onClick={() => setActiveTab("effects")}
- >
- Open effects
- </Button>
- </div>
- );
-}
-
-function EffectSection({
- effect,
- renderParams,
- previewParam,
- onCommit,
- onToggle,
- onRemove,
-}: {
- effect: Effect;
- renderParams: ParamValues;
- previewParam: (key: string) => (value: number | string | boolean) => void;
- onCommit: () => void;
- onToggle?: () => void;
- onRemove?: () => void;
-}) {
- const definition = effectsRegistry.get(effect.type);
-
- return (
- <Section
- sectionKey={onToggle ? `clip-effect:${effect.id}` : undefined}
- showTopBorder={false}
- >
- <SectionHeader
- className={cn(onToggle && "cursor-move")}
- trailing={
- onToggle && (
- <div className="flex items-center gap-1">
- <Button
- variant={effect.enabled ? "secondary" : "ghost"}
- size="icon"
- aria-label={`Toggle ${definition.name}`}
- onClick={onToggle}
- >
- <HugeiconsIcon
- icon={effect.enabled ? ViewIcon : ViewOffSlashIcon}
- />
- </Button>
- <Button
- variant="ghost"
- size="icon"
- aria-label={`Remove ${definition.name}`}
- onClick={onRemove}
- >
- <HugeiconsIcon icon={Delete02Icon} />
- </Button>
- </div>
- )
- }
- >
- <SectionTitle
- className={cn(onToggle && !effect.enabled && "text-muted-foreground")}
- >
- {definition.name}
- </SectionTitle>
- </SectionHeader>
- <SectionContent
- className={cn("p-0", onToggle && !effect.enabled && "opacity-50")}
- >
- <SectionFields>
- {definition.params.map((param) => (
- <div key={param.key} className="flex flex-col gap-3.5">
- <div className="px-4">
- <PropertyParamField
- param={param}
- value={renderParams[param.key] ?? param.default}
- onPreview={previewParam(param.key)}
- onCommit={onCommit}
- />
- </div>
- <Separator />
- </div>
- ))}
- </SectionFields>
- </SectionContent>
- </Section>
- );
-}
diff --git a/apps/web/src/components/editor/panels/properties/tabs/graphic-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/graphic-tab.tsx
deleted file mode 100644
index c61c9cd7..00000000
--- a/apps/web/src/components/editor/panels/properties/tabs/graphic-tab.tsx
+++ /dev/null
@@ -1,238 +0,0 @@
-"use client";
-
-import { useRef } from "react";
-import { useElementPlayhead } from "../hooks/use-element-playhead";
-import {
- useKeyframedParamProperty,
- type KeyframedParamPropertyResult,
-} from "../hooks/use-keyframed-param-property";
-import { resolveGraphicParamsAtTime } from "@/lib/animation";
-import type { ParamDefinition, ParamValues } from "@/lib/params";
-import type { GraphicElement } from "@/lib/timeline";
-import { graphicsRegistry, registerDefaultGraphics } from "@/lib/graphics";
-import { useElementPreview } from "@/hooks/use-element-preview";
-import { useEditor } from "@/hooks/use-editor";
-import {
- Section,
- SectionContent,
- SectionFields,
- SectionHeader,
- SectionTitle,
-} from "@/components/section";
-import { PropertyParamField } from "../components/property-param-field";
-import { Button } from "@/components/ui/button";
-import { HugeiconsIcon } from "@hugeicons/react";
-import { MinusSignIcon, PlusSignIcon } from "@hugeicons/core-free-icons";
-import { cn } from "@/utils/ui";
-
-registerDefaultGraphics();
-
-const DEFAULT_STROKE_WIDTH = 2;
-
-export function GraphicTab({
- element,
- trackId,
-}: {
- element: GraphicElement;
- trackId: string;
-}) {
- const definition = graphicsRegistry.get(element.definitionId);
- const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
- startTime: element.startTime,
- duration: element.duration,
- });
- const { renderElement } = useElementPreview({
- trackId,
- elementId: element.id,
- fallback: element,
- });
-
- const liveElement = renderElement as GraphicElement;
- const resolvedParams = resolveGraphicParamsAtTime({
- element: liveElement,
- localTime,
- });
-
- const shapeParams = definition.params.filter((p) => p.group !== "stroke");
- const hasStrokeParams = definition.params.some((p) => p.group === "stroke");
-
- return (
- <div className="flex flex-col">
- <Section collapsible sectionKey={`${element.id}:graphic`}>
- <SectionHeader>
- <SectionTitle>{definition.name}</SectionTitle>
- </SectionHeader>
- <SectionContent>
- <SectionFields>
- {shapeParams.map((param) => (
- <AnimatedGraphicParamField
- key={param.key}
- param={param}
- trackId={trackId}
- element={liveElement}
- localTime={localTime}
- isPlayheadWithinElementRange={isPlayheadWithinElementRange}
- resolvedParams={resolvedParams}
- />
- ))}
- </SectionFields>
- </SectionContent>
- </Section>
- {hasStrokeParams && (
- <StrokeSection element={element} trackId={trackId} />
- )}
- </div>
- );
-}
-
-function StrokeSection({
- element,
- trackId,
-}: {
- element: GraphicElement;
- trackId: string;
-}) {
- const editor = useEditor();
- const definition = graphicsRegistry.get(element.definitionId);
- const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
- startTime: element.startTime,
- duration: element.duration,
- });
- const { renderElement } = useElementPreview({
- trackId,
- elementId: element.id,
- fallback: element,
- });
-
- const liveElement = renderElement as GraphicElement;
- const resolvedParams = resolveGraphicParamsAtTime({
- element: liveElement,
- localTime,
- });
- const strokeParams = definition.params.filter((p) => p.group === "stroke");
- const lastStrokeWidth = useRef(DEFAULT_STROKE_WIDTH);
- const isStrokeEnabled = Number(element.params.strokeWidth ?? 0) > 0;
-
- const toggleStroke = () => {
- if (isStrokeEnabled) {
- lastStrokeWidth.current = Number(
- element.params.strokeWidth ?? DEFAULT_STROKE_WIDTH,
- );
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- patch: { params: { ...element.params, strokeWidth: 0 } },
- },
- ],
- });
- } else {
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- patch: {
- params: {
- ...element.params,
- strokeWidth: lastStrokeWidth.current,
- },
- },
- },
- ],
- });
- }
- };
-
- return (
- <Section
- collapsible
- defaultOpen={isStrokeEnabled}
- sectionKey={`${element.id}:stroke`}
- >
- <SectionHeader
- trailing={
- <Button
- variant="ghost"
- size="icon"
- onClick={(event) => {
- event.stopPropagation();
- toggleStroke();
- }}
- >
- <HugeiconsIcon
- icon={isStrokeEnabled ? MinusSignIcon : PlusSignIcon}
- strokeWidth={1}
- />
- </Button>
- }
- >
- <SectionTitle>Stroke</SectionTitle>
- </SectionHeader>
- <SectionContent
- className={cn(!isStrokeEnabled && "pointer-events-none opacity-50")}
- >
- <SectionFields>
- {strokeParams.map((param) => (
- <AnimatedGraphicParamField
- key={param.key}
- param={param}
- trackId={trackId}
- element={liveElement}
- localTime={localTime}
- isPlayheadWithinElementRange={isPlayheadWithinElementRange}
- resolvedParams={resolvedParams}
- />
- ))}
- </SectionFields>
- </SectionContent>
- </Section>
- );
-}
-
-function AnimatedGraphicParamField({
- param,
- trackId,
- element,
- localTime,
- isPlayheadWithinElementRange,
- resolvedParams,
-}: {
- param: ParamDefinition;
- trackId: string;
- element: GraphicElement;
- localTime: number;
- isPlayheadWithinElementRange: boolean;
- resolvedParams: ParamValues;
-}) {
- const animatedParam: KeyframedParamPropertyResult = useKeyframedParamProperty({
- param,
- trackId,
- elementId: element.id,
- animations: element.animations,
- localTime,
- isPlayheadWithinElementRange,
- resolvedValue: resolvedParams[param.key] ?? param.default,
- buildBaseUpdates: ({ value }) => ({
- params: {
- ...element.params,
- [param.key]: value,
- },
- }),
- });
-
- return (
- <PropertyParamField
- param={param}
- value={resolvedParams[param.key] ?? param.default}
- onPreview={animatedParam.onPreview}
- onCommit={animatedParam.onCommit}
- keyframe={{
- isActive: animatedParam.isKeyframedAtTime,
- isDisabled: !isPlayheadWithinElementRange,
- onToggle: animatedParam.toggleKeyframe,
- }}
- />
- );
-}
diff --git a/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx
deleted file mode 100644
index 090fce64..00000000
--- a/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx
+++ /dev/null
@@ -1,826 +0,0 @@
-"use client";
-
-import type { MaskableElement } from "@/lib/timeline";
-import type { Mask, MaskType, TextMask } from "@/lib/masks/types";
-import type {
- NumberParamDefinition,
- SelectParamDefinition,
-} from "@/lib/params";
-import { masksRegistry, buildDefaultMaskInstance } from "@/lib/masks";
-import { useEditor } from "@/hooks/use-editor";
-import { useElementPreview } from "@/hooks/use-element-preview";
-import { useMenuPreview } from "@/hooks/use-menu-preview";
-import { getVisibleElementsWithBounds } from "@/lib/preview/element-bounds";
-import { HugeiconsIcon } from "@hugeicons/react";
-import {
- ArrowExpandIcon,
- Delete02Icon,
- FeatherIcon,
- PlusSignIcon,
- RotateClockwiseIcon,
- TextFontIcon,
-} from "@hugeicons/core-free-icons";
-import { useMemo, useState } from "react";
-import { Button } from "@/components/ui/button";
-import { ColorPicker } from "@/components/ui/color-picker";
-import { FontPicker } from "@/components/ui/font-picker";
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu";
-import { NumberField } from "@/components/ui/number-field";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
-import { Textarea } from "@/components/ui/textarea";
-import {
- Tooltip,
- TooltipContent,
- TooltipTrigger,
-} from "@/components/ui/tooltip";
-import {
- clamp,
- formatNumberForDisplay,
- getFractionDigitsForStep,
- snapToStep,
-} from "@/utils/math";
-import {
- Section,
- SectionContent,
- SectionField,
- SectionFields,
- SectionHeader,
- SectionTitle,
-} from "@/components/section";
-import { usePropertyDraft } from "../hooks/use-property-draft";
-import {
- OcMirrorIcon,
- OcShapesIcon,
- OcTextHeightIcon,
- OcTextWidthIcon,
-} from "@/components/icons";
-import { cn } from "@/utils/ui";
-
-type MasksTabProps = {
- element: MaskableElement;
- trackId: string;
-};
-
-type MaskItemProps = {
- trackId: string;
- elementId: string;
- mask: Mask;
- previewParam: (key: string) => (value: number | string | boolean) => void;
- onCommit: () => void;
-};
-
-type EmptyViewProps = {
- onAddMask: () => void;
-};
-
-type PreviewParamHandler = (
- key: string,
-) => (value: number | string | boolean) => void;
-
-type RegisteredMaskDefinition = ReturnType<(typeof masksRegistry)["get"]>;
-
-function isTextMask(mask: Mask): mask is TextMask {
- return mask.type === "text";
-}
-
-export function MasksTab({ element, trackId }: MasksTabProps) {
- const editor = useEditor();
- const { renderElement, previewUpdates, commit } =
- useElementPreview<MaskableElement>({
- trackId,
- elementId: element.id,
- fallback: element,
- });
- const maskDefs = masksRegistry.getAll();
- const tracks = useEditor(
- (e) => e.timeline.getPreviewTracks() ?? e.scenes.getActiveScene().tracks,
- );
- const currentTime = useEditor((e) => e.playback.getCurrentTime());
- const mediaAssets = useEditor((e) => e.media.getAssets());
- const canvasSize = useEditor(
- (e) => e.project.getActive().settings.canvasSize,
- );
- const masks = element.masks ?? [];
- const renderMasks = renderElement.masks ?? masks;
- const hasMask = masks.length > 0;
- const { onPointerLeave, onOpenChange, markCommitted } = useMenuPreview();
- const [isDropdownOpen, setIsDropdownOpen] = useState(false);
- const elementBounds = useMemo(() => {
- const clampedTime = Math.min(
- Math.max(currentTime, element.startTime),
- element.startTime + element.duration - 1,
- );
-
- return (
- getVisibleElementsWithBounds({
- tracks,
- currentTime: clampedTime,
- canvasSize,
- mediaAssets,
- }).find(
- (item) => item.trackId === trackId && item.elementId === element.id,
- )?.bounds ?? null
- );
- }, [
- canvasSize,
- currentTime,
- element.duration,
- element.id,
- element.startTime,
- mediaAssets,
- trackId,
- tracks,
- ]);
-
- const handleDropdownOpenChange = (open: boolean) => {
- if (hasMask && open) return;
- setIsDropdownOpen(open);
- onOpenChange(open);
- };
-
- const previewMask = ({ maskType }: { maskType: MaskType }) => {
- editor.timeline.previewElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: {
- masks: [
- buildDefaultMaskInstance({
- maskType,
- elementSize: elementBounds
- ? {
- width: elementBounds.width,
- height: elementBounds.height,
- }
- : undefined,
- }),
- ],
- } as Partial<MaskableElement>,
- },
- ],
- });
- };
-
- const commitMask = ({ maskType }: { maskType: MaskType }) => {
- if (editor.timeline.isPreviewActive()) {
- editor.timeline.commitPreview();
- } else {
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- patch: {
- masks: [
- buildDefaultMaskInstance({
- maskType,
- elementSize: elementBounds
- ? {
- width: elementBounds.width,
- height: elementBounds.height,
- }
- : undefined,
- }),
- ],
- } as Partial<MaskableElement>,
- },
- ],
- });
- }
- markCommitted();
- setIsDropdownOpen(false);
- };
-
- const previewMaskParam =
- ({ index, key }: { index: number; key: string }) =>
- (value: number | string | boolean) => {
- if (!renderMasks[index]) {
- return;
- }
-
- const updatedMasks = renderMasks.map((existingMask, maskIndex) =>
- maskIndex !== index
- ? existingMask
- : {
- ...existingMask,
- params: {
- ...existingMask.params,
- [key]: value,
- },
- },
- );
-
- previewUpdates({ masks: updatedMasks } as Partial<MaskableElement>);
- };
-
- return (
- <div className="flex flex-col h-full">
- <div className="border-b px-3.5 h-11 shrink-0 flex items-center justify-between gap-2">
- <SectionTitle>Masks</SectionTitle>
- <DropdownMenu
- open={hasMask ? false : isDropdownOpen}
- onOpenChange={handleDropdownOpenChange}
- >
- {hasMask ? (
- <Tooltip>
- <TooltipTrigger asChild>
- <span className="inline-flex">
- <Button
- variant="ghost"
- size="icon"
- disabled
- aria-label="Add mask"
- >
- <HugeiconsIcon icon={PlusSignIcon} className="size-3.5!" />
- </Button>
- </span>
- </TooltipTrigger>
- <TooltipContent className="max-w-56 text-balance">
- Only one mask is supported right now. If you need more,
- duplicate the clip and apply a different mask to each copy.
- </TooltipContent>
- </Tooltip>
- ) : (
- <DropdownMenuTrigger asChild>
- <Button variant="ghost" size="icon" aria-label="Add mask">
- <HugeiconsIcon icon={PlusSignIcon} className="size-3.5!" />
- </Button>
- </DropdownMenuTrigger>
- )}
- <DropdownMenuContent className="w-40" onPointerLeave={onPointerLeave}>
- {maskDefs.map((definition) => (
- <DropdownMenuItem
- key={definition.type}
- onPointerEnter={() =>
- previewMask({ maskType: definition.type })
- }
- onClick={() => commitMask({ maskType: definition.type })}
- >
- <HugeiconsIcon {...definition.icon} />
- {definition.name}
- </DropdownMenuItem>
- ))}
- </DropdownMenuContent>
- </DropdownMenu>
- </div>
-
- {masks.length === 0 ? (
- <EmptyView onAddMask={() => setIsDropdownOpen(true)} />
- ) : (
- masks.map((mask, index) => (
- <MaskItem
- key={mask.id}
- trackId={trackId}
- elementId={element.id}
- mask={renderMasks[index] ?? mask}
- previewParam={(paramKey) =>
- previewMaskParam({ index, key: paramKey })
- }
- onCommit={commit}
- />
- ))
- )}
- </div>
- );
-}
-
-function MaskItem({
- trackId,
- elementId,
- mask,
- previewParam,
- onCommit,
-}: MaskItemProps) {
- const editor = useEditor();
- const definition = masksRegistry.get(mask.type);
-
- return (
- <Section sectionKey={`mask-item:${mask.id}`} showTopBorder={false}>
- <SectionHeader
- trailing={
- <div className="flex items-center gap-1">
- <Button
- variant="ghost"
- size="icon"
- aria-label={`Toggle ${definition.name} mask inversion`}
- onClick={() =>
- editor.timeline.toggleMaskInverted({
- trackId,
- elementId,
- maskId: mask.id,
- })
- }
- >
- <OcMirrorIcon
- className={cn(mask.params.inverted && "-scale-x-100")}
- />
- </Button>
- <Button
- variant="ghost"
- size="icon"
- aria-label={`Remove ${definition.name} mask`}
- onClick={() =>
- editor.timeline.removeMask({
- trackId,
- elementId,
- maskId: mask.id,
- })
- }
- >
- <HugeiconsIcon icon={Delete02Icon} />
- </Button>
- </div>
- }
- >
- <div className="flex items-center gap-2">
- <HugeiconsIcon {...definition.icon} size={14} />
- <SectionTitle className="capitalize font-normal">
- {definition.name}
- </SectionTitle>
- </div>
- </SectionHeader>
- <SectionContent>
- <MaskParamsFields
- mask={mask}
- definition={definition}
- previewParam={previewParam}
- onCommit={onCommit}
- />
- </SectionContent>
- </Section>
- );
-}
-
-function MaskParamsFields({
- mask,
- definition,
- previewParam,
- onCommit,
-}: {
- mask: Mask;
- definition: RegisteredMaskDefinition;
- previewParam: PreviewParamHandler;
- onCommit: () => void;
-}) {
- const featherParam = getNumberParamDefinition({
- definition,
- key: "feather",
- });
- const strokeWidthParam = getNumberParamDefinition({
- definition,
- key: "strokeWidth",
- });
- const previewNumberParam = (key: string) => (value: number) =>
- previewParam(key)(value);
- const previewStrokeColor = previewParam("strokeColor");
- const strokeAlignParam = definition.params.find(
- (param): param is SelectParamDefinition<string> =>
- param.key === "strokeAlign" && param.type === "select",
- );
-
- return (
- <SectionFields>
- {isTextMask(mask) ? (
- <TextMaskFields
- mask={mask}
- previewParam={previewParam}
- onCommit={onCommit}
- fontSizeParam={getNumberParamDefinition({
- definition,
- key: "fontSize",
- })}
- />
- ) : null}
- {definition.features.hasPosition &&
- "centerX" in mask.params &&
- "centerY" in mask.params && (
- <SectionField label="Position">
- <div className="flex items-center gap-2">
- <MaskNumberField
- className="flex-1"
- icon="X"
- param={getNumberParamDefinition({
- definition,
- key: "centerX",
- })}
- value={getMaskNumber({
- params: mask.params,
- key: "centerX",
- })}
- onPreview={previewNumberParam("centerX")}
- onCommit={onCommit}
- />
- <MaskNumberField
- className="flex-1"
- icon="Y"
- param={getNumberParamDefinition({
- definition,
- key: "centerY",
- })}
- value={getMaskNumber({
- params: mask.params,
- key: "centerY",
- })}
- onPreview={previewNumberParam("centerY")}
- onCommit={onCommit}
- />
- </div>
- </SectionField>
- )}
-
- {definition.features.sizeMode === "width-height" &&
- "width" in mask.params &&
- "height" in mask.params && (
- <SectionField label="Size">
- <div className="flex items-center gap-2">
- <MaskNumberField
- className="flex-1"
- icon="W"
- param={getNumberParamDefinition({
- definition,
- key: "width",
- })}
- value={getMaskNumber({
- params: mask.params,
- key: "width",
- })}
- onPreview={previewNumberParam("width")}
- onCommit={onCommit}
- />
- <MaskNumberField
- className="flex-1"
- icon="H"
- param={getNumberParamDefinition({
- definition,
- key: "height",
- })}
- value={getMaskNumber({
- params: mask.params,
- key: "height",
- })}
- onPreview={previewNumberParam("height")}
- onCommit={onCommit}
- />
- </div>
- </SectionField>
- )}
-
- {definition.features.sizeMode === "height-only" &&
- "height" in mask.params && (
- <SectionField label="Height">
- <MaskNumberField
- icon="H"
- param={getNumberParamDefinition({
- definition,
- key: "height",
- })}
- value={getMaskNumber({
- params: mask.params,
- key: "height",
- })}
- onPreview={previewNumberParam("height")}
- onCommit={onCommit}
- />
- </SectionField>
- )}
-
- {definition.features.sizeMode === "width-only" &&
- "width" in mask.params && (
- <SectionField label="Width">
- <MaskNumberField
- icon="W"
- param={getNumberParamDefinition({
- definition,
- key: "width",
- })}
- value={getMaskNumber({
- params: mask.params,
- key: "width",
- })}
- onPreview={previewNumberParam("width")}
- onCommit={onCommit}
- />
- </SectionField>
- )}
-
- {definition.features.sizeMode === "uniform" && "scale" in mask.params && (
- <SectionField label="Scale">
- <MaskNumberField
- icon={
- isTextMask(mask) ? <HugeiconsIcon icon={ArrowExpandIcon} /> : "S"
- }
- param={getNumberParamDefinition({
- definition,
- key: "scale",
- })}
- value={getMaskNumber({
- params: mask.params,
- key: "scale",
- })}
- onPreview={previewNumberParam("scale")}
- onCommit={onCommit}
- />
- </SectionField>
- )}
-
- {definition.features.hasRotation && "rotation" in mask.params && (
- <SectionField label="Rotation">
- <MaskNumberField
- icon={<HugeiconsIcon icon={RotateClockwiseIcon} />}
- param={getNumberParamDefinition({
- definition,
- key: "rotation",
- })}
- value={getMaskNumber({
- params: mask.params,
- key: "rotation",
- })}
- onPreview={previewNumberParam("rotation")}
- onCommit={onCommit}
- />
- </SectionField>
- )}
-
- <SectionField label="Feather">
- <MaskNumberField
- icon={<HugeiconsIcon icon={FeatherIcon} />}
- param={featherParam}
- value={getMaskNumber({
- params: mask.params,
- key: "feather",
- })}
- onPreview={previewNumberParam("feather")}
- onCommit={onCommit}
- />
- </SectionField>
-
- <SectionField label="Stroke">
- <div className="flex flex-col gap-2">
- <div className="flex items-center gap-2">
- <MaskNumberField
- className="flex-1"
- icon="W"
- param={strokeWidthParam}
- value={getMaskNumber({
- params: mask.params,
- key: "strokeWidth",
- })}
- onPreview={previewNumberParam("strokeWidth")}
- onCommit={onCommit}
- />
- <ColorPicker
- className=""
- value={mask.params.strokeColor.replace(/^#/, "").toUpperCase()}
- onChange={(color) => previewStrokeColor(`#${color}`)}
- onChangeEnd={(color) => {
- previewStrokeColor(`#${color}`);
- onCommit();
- }}
- />
- </div>
- {strokeAlignParam ? (
- <Select
- value={mask.params.strokeAlign}
- onValueChange={(value) => {
- previewParam("strokeAlign")(value);
- onCommit();
- }}
- >
- <SelectTrigger className="w-full">
- <SelectValue />
- </SelectTrigger>
- <SelectContent>
- {strokeAlignParam.options.map((option) => (
- <SelectItem key={option.value} value={option.value}>
- {option.label}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- ) : null}
- </div>
- </SectionField>
- </SectionFields>
- );
-}
-
-const LETTER_SPACING_PARAM: NumberParamDefinition = {
- key: "letterSpacing",
- label: "Letter spacing",
- type: "number",
- default: 0,
- min: -100,
- max: 500,
- step: 1,
-};
-
-const LINE_HEIGHT_PARAM: NumberParamDefinition = {
- key: "lineHeight",
- label: "Line height",
- type: "number",
- default: 1.2,
- min: 0.1,
- max: 10,
- step: 0.1,
-};
-
-function TextMaskFields({
- mask,
- previewParam,
- onCommit,
- fontSizeParam,
-}: {
- mask: TextMask;
- previewParam: PreviewParamHandler;
- onCommit: () => void;
- fontSizeParam: NumberParamDefinition;
-}) {
- const content = usePropertyDraft({
- displayValue: mask.params.content,
- parse: (input) => input,
- onPreview: (value) => previewParam("content")(value),
- onCommit,
- });
-
- const previewNumberParam = (key: string) => (value: number) =>
- previewParam(key)(value);
-
- return (
- <>
- <SectionField label="Content">
- <Textarea
- value={content.displayValue}
- className="min-h-20"
- onFocus={content.onFocus}
- onChange={content.onChange}
- onBlur={content.onBlur}
- />
- </SectionField>
- <SectionField label="Font">
- <FontPicker
- defaultValue={mask.params.fontFamily}
- onValueChange={(value) => {
- previewParam("fontFamily")(value);
- onCommit();
- }}
- />
- </SectionField>
- <SectionField label="Size">
- <MaskNumberField
- icon={<HugeiconsIcon icon={TextFontIcon} />}
- param={fontSizeParam}
- value={mask.params.fontSize}
- onPreview={previewNumberParam("fontSize")}
- onCommit={onCommit}
- />
- </SectionField>
- <SectionField label="Spacing">
- <div className="flex items-start gap-2">
- <MaskNumberField
- className="w-1/2"
- icon={<OcTextWidthIcon size={14} />}
- param={LETTER_SPACING_PARAM}
- value={mask.params.letterSpacing ?? 0}
- onPreview={previewNumberParam("letterSpacing")}
- onCommit={onCommit}
- />
- <MaskNumberField
- className="w-1/2"
- icon={<OcTextHeightIcon size={14} />}
- param={LINE_HEIGHT_PARAM}
- value={mask.params.lineHeight ?? 1.2}
- onPreview={previewNumberParam("lineHeight")}
- onCommit={onCommit}
- />
- </div>
- </SectionField>
- </>
- );
-}
-
-function getNumberParamDefinition({
- definition,
- key,
-}: {
- definition: RegisteredMaskDefinition;
- key: string;
-}): NumberParamDefinition {
- const param = definition.params.find((candidate) => candidate.key === key);
-
- if (!param || param.type !== "number") {
- throw new Error(`Missing number param definition for mask key "${key}"`);
- }
-
- return param;
-}
-
-function getMaskNumber<
- TParams extends Mask["params"],
- TKey extends keyof TParams & string,
->({ params, key }: { params: TParams; key: TKey }): number {
- const value = params[key];
-
- if (typeof value !== "number") {
- throw new Error(`Expected numeric mask param for "${key}"`);
- }
-
- return value;
-}
-
-function MaskNumberField({
- param,
- value,
- onPreview,
- onCommit,
- icon,
- className,
-}: {
- param: NumberParamDefinition;
- value: number;
- onPreview: (value: number) => void;
- onCommit: () => void;
- icon?: React.ReactNode;
- className?: string;
-}) {
- const isPercent = param.unit === "percent";
- const percentMax = param.max ?? 100;
- const displayMultiplier = isPercent
- ? 100 / percentMax
- : (param.displayMultiplier ?? 1);
- const min = isPercent ? 0 : param.min;
- const max = isPercent ? 100 : param.max;
- const step = isPercent ? 1 : param.step;
- const displayValue = value * displayMultiplier;
- const maxFractionDigits = getFractionDigitsForStep({ step });
-
- const clampDisplay = (nextDisplayValue: number) =>
- max !== undefined
- ? clamp({ value: nextDisplayValue, min, max })
- : Math.max(min, nextDisplayValue);
-
- const previewFromDisplay = (nextDisplayValue: number) => {
- onPreview(
- clampDisplay(snapToStep({ value: nextDisplayValue, step })) /
- displayMultiplier,
- );
- };
-
- const draft = usePropertyDraft({
- displayValue: formatNumberForDisplay({
- value: displayValue,
- maxFractionDigits,
- }),
- parse: (input) => {
- const parsed = parseFloat(input);
- if (Number.isNaN(parsed)) return null;
- return (
- clampDisplay(snapToStep({ value: parsed, step })) / displayMultiplier
- );
- },
- onPreview,
- onCommit,
- });
-
- return (
- <NumberField
- className={className}
- icon={icon}
- value={draft.displayValue}
- dragSensitivity="slow"
- onFocus={draft.onFocus}
- onChange={draft.onChange}
- onBlur={draft.onBlur}
- onScrub={previewFromDisplay}
- onScrubEnd={onCommit}
- />
- );
-}
-
-function EmptyView({ onAddMask }: EmptyViewProps) {
- return (
- <div className="flex flex-col h-full items-center justify-center gap-4 text-center">
- <OcShapesIcon className="size-10 text-muted-foreground" strokeWidth={1} />
- <div className="flex flex-col gap-2">
- <h3 className="font-medium text-foreground">No masks</h3>
- <p className="text-muted-foreground text-sm text-balance max-w-40">
- Add a mask to hide or reveal parts of this layer.
- </p>
- </div>
- <Button variant="default" size="sm" onClick={onAddMask}>
- Add mask
- </Button>
- </div>
- );
-}
diff --git a/apps/web/src/components/editor/panels/properties/tabs/speed-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/speed-tab.tsx
deleted file mode 100644
index cb10aac3..00000000
--- a/apps/web/src/components/editor/panels/properties/tabs/speed-tab.tsx
+++ /dev/null
@@ -1,156 +0,0 @@
-import { useRef } from "react";
-import { useEditor } from "@/hooks/use-editor";
-import { NumberField } from "@/components/ui/number-field";
-import { Switch } from "@/components/ui/switch";
-import { HugeiconsIcon } from "@hugeicons/react";
-import { DashboardSpeed02Icon } from "@hugeicons/core-free-icons";
-import { buildConstantRetime } from "@/lib/retime";
-import {
- DEFAULT_RETIME_RATE,
- MIN_RETIME_RATE,
- MAX_RETIME_RATE,
- clampRetimeRate,
- canMaintainPitch,
-} from "@/lib/retime/rate";
-import type { AudioElement, VideoElement } from "@/lib/timeline";
-import {
- Section,
- SectionContent,
- SectionField,
- SectionFields,
- SectionHeader,
- SectionTitle,
-} from "@/components/section";
-import { usePropertyDraft } from "../hooks/use-property-draft";
-import {
- formatNumberForDisplay,
- getFractionDigitsForStep,
- snapToStep,
-} from "@/utils/math";
-
-const SPEED_STEP = 0.01;
-const SPEED_FRACTION_DIGITS = getFractionDigitsForStep({ step: SPEED_STEP });
-
-function rateToDisplay({ rate }: { rate: number }): string {
- return formatNumberForDisplay({
- value: rate,
- fractionDigits: SPEED_FRACTION_DIGITS,
- });
-}
-
-function parseSpeedInput({ input }: { input: string }): number | null {
- const parsed = parseFloat(input);
- if (Number.isNaN(parsed)) return null;
- return clampRetimeRate({
- rate: snapToStep({ value: parsed, step: SPEED_STEP }),
- });
-}
-
-function buildRetime({
- rate,
- maintainPitch,
-}: {
- rate: number;
- maintainPitch: boolean;
-}) {
- if (rate === DEFAULT_RETIME_RATE && !maintainPitch) return undefined;
- return buildConstantRetime({ rate, maintainPitch });
-}
-
-export function SpeedTab({
- element,
- trackId,
-}: {
- element: AudioElement | VideoElement;
- trackId: string;
-}) {
- const editor = useEditor();
- const rate = clampRetimeRate({
- rate: element.retime?.rate ?? DEFAULT_RETIME_RATE,
- });
- const isPitchPreserveAvailable = canMaintainPitch({ rate });
- const maintainPitch = element.retime?.maintainPitch ?? false;
- const pendingRateRef = useRef(rate);
-
- const commitRetime = ({
- rate: nextRate,
- maintainPitch: nextMaintainPitch,
- }: {
- rate: number;
- maintainPitch: boolean;
- }) => {
- editor.timeline.updateElementRetime({
- trackId,
- elementId: element.id,
- retime: buildRetime({ rate: nextRate, maintainPitch: nextMaintainPitch }),
- });
- };
-
- const speedDraft = usePropertyDraft({
- displayValue: rateToDisplay({ rate }),
- parse: (input) => parseSpeedInput({ input }),
- onPreview: (nextRate) => {
- pendingRateRef.current = nextRate;
- editor.timeline.previewElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: {
- retime: buildRetime({ rate: nextRate, maintainPitch }),
- },
- },
- ],
- });
- },
- onCommit: () => {
- commitRetime({ rate: pendingRateRef.current, maintainPitch });
- },
- });
-
- return (
- <Section collapsible sectionKey={`${element.id}:speed`}>
- <SectionHeader>
- <SectionTitle>Speed</SectionTitle>
- </SectionHeader>
- <SectionContent>
- <SectionFields>
- <SectionField label="Speed">
- <NumberField
- icon={<HugeiconsIcon icon={DashboardSpeed02Icon} />}
- value={speedDraft.displayValue}
- suffix="x"
- scrubRanges={[
- { from: 0.01, to: 1, pixelsPerUnit: 160 },
- { from: 1, to: 5, pixelsPerUnit: 48 },
- ]}
- scrubClamp={{ min: MIN_RETIME_RATE, max: MAX_RETIME_RATE }}
- onFocus={() => {
- pendingRateRef.current = rate;
- speedDraft.onFocus();
- }}
- onChange={speedDraft.onChange}
- onBlur={speedDraft.onBlur}
- onScrub={speedDraft.scrubTo}
- onScrubEnd={speedDraft.commitScrub}
- onReset={() =>
- commitRetime({ rate: DEFAULT_RETIME_RATE, maintainPitch })
- }
- isDefault={rate === DEFAULT_RETIME_RATE}
- />
- </SectionField>
- <div className="flex items-center justify-between">
- <span className="text-sm">Change pitch</span>
- <Switch
- checked={!maintainPitch}
- disabled={!isPitchPreserveAvailable}
- onCheckedChange={(checked) =>
- commitRetime({ rate, maintainPitch: !checked })
- }
- />
- </div>
- </SectionFields>
- </SectionContent>
- </Section>
- );
-}
diff --git a/apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx
deleted file mode 100644
index d37379ea..00000000
--- a/apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx
+++ /dev/null
@@ -1,767 +0,0 @@
-"use client";
-
-import { Textarea } from "@/components/ui/textarea";
-import { FontPicker } from "@/components/ui/font-picker";
-import type { TextElement } from "@/lib/timeline";
-import { NumberField } from "@/components/ui/number-field";
-import { useRef } from "react";
-import {
- Section,
- SectionContent,
- SectionField,
- SectionFields,
- SectionHeader,
- SectionTitle,
-} from "@/components/section";
-import { ColorPicker } from "@/components/ui/color-picker";
-import { Button } from "@/components/ui/button";
-import { uppercase } from "@/utils/string";
-import { clamp, formatNumberForDisplay } from "@/utils/math";
-import { useEditor } from "@/hooks/use-editor";
-import {
- CORNER_RADIUS_MAX,
- CORNER_RADIUS_MIN,
-} from "@/lib/text/background";
-import {
- DEFAULT_TEXT_COLOR,
- MAX_FONT_SIZE,
- MIN_FONT_SIZE,
-} from "@/lib/text/typography";
-import { usePropertyDraft } from "../hooks/use-property-draft";
-import { useKeyframedColorProperty } from "../hooks/use-keyframed-color-property";
-import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
-import { useElementPlayhead } from "../hooks/use-element-playhead";
-import { KeyframeToggle } from "../components/keyframe-toggle";
-import { isPropertyAtDefault } from "./transform-tab";
-import { resolveColorAtTime, resolveNumberAtTime } from "@/lib/animation";
-import { HugeiconsIcon } from "@hugeicons/react";
-import {
- MinusSignIcon,
- PlusSignIcon,
- TextFontIcon,
-} from "@hugeicons/core-free-icons";
-import { OcTextHeightIcon, OcTextWidthIcon } from "@/components/icons";
-import { DEFAULTS } from "@/lib/timeline/defaults";
-import { cn } from "@/utils/ui";
-
-export function TextTab({
- element,
- trackId,
-}: {
- element: TextElement;
- trackId: string;
-}) {
- return (
- <div className="flex flex-col">
- <ContentSection element={element} trackId={trackId} />
- <TypographySection element={element} trackId={trackId} />
- <SpacingSection element={element} trackId={trackId} />
- <BackgroundSection element={element} trackId={trackId} />
- </div>
- );
-}
-
-function ContentSection({
- element,
- trackId,
-}: {
- element: TextElement;
- trackId: string;
-}) {
- const editor = useEditor();
-
- const content = usePropertyDraft({
- displayValue: element.content,
- parse: (input) => input,
- onPreview: (value) =>
- editor.timeline.previewElements({
- updates: [
- { trackId, elementId: element.id, updates: { content: value } },
- ],
- }),
- onCommit: () => editor.timeline.commitPreview(),
- });
-
- return (
- <Section collapsible sectionKey={`${element.id}:content`}>
- <SectionHeader>
- <SectionTitle>Content</SectionTitle>
- </SectionHeader>
- <SectionContent>
- <Textarea
- placeholder="Name"
- value={content.displayValue}
- className="min-h-20"
- onFocus={content.onFocus}
- onChange={content.onChange}
- onBlur={content.onBlur}
- />
- </SectionContent>
- </Section>
- );
-}
-
-function TypographySection({
- element,
- trackId,
-}: {
- element: TextElement;
- trackId: string;
-}) {
- const editor = useEditor();
- const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
- startTime: element.startTime,
- duration: element.duration,
- });
- const resolvedTextColor = resolveColorAtTime({
- baseColor: element.color,
- animations: element.animations,
- propertyPath: "color",
- localTime,
- });
-
- const textColor = useKeyframedColorProperty({
- trackId,
- elementId: element.id,
- animations: element.animations,
- propertyPath: "color",
- localTime,
- isPlayheadWithinElementRange,
- resolvedColor: resolvedTextColor,
- buildBaseUpdates: ({ value }) => ({ color: value }),
- });
-
- const fontSize = usePropertyDraft({
- displayValue: element.fontSize.toString(),
- parse: (input) => {
- const parsed = parseFloat(input);
- if (Number.isNaN(parsed)) return null;
- return clamp({
- value: Math.round(parsed),
- min: MIN_FONT_SIZE,
- max: MAX_FONT_SIZE,
- });
- },
- onPreview: (value) =>
- editor.timeline.previewElements({
- updates: [
- { trackId, elementId: element.id, updates: { fontSize: value } },
- ],
- }),
- onCommit: () => editor.timeline.commitPreview(),
- });
-
- return (
- <Section collapsible sectionKey={`${element.id}:typography`}>
- <SectionHeader>
- <SectionTitle>Typography</SectionTitle>
- </SectionHeader>
- <SectionContent>
- <SectionFields>
- <SectionField label="Font">
- <FontPicker
- defaultValue={element.fontFamily}
- onValueChange={(value) =>
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- patch: { fontFamily: value },
- },
- ],
- })
- }
- />
- </SectionField>
- <SectionField label="Size">
- <NumberField
- value={fontSize.displayValue}
- min={MIN_FONT_SIZE}
- max={MAX_FONT_SIZE}
- onFocus={fontSize.onFocus}
- onChange={fontSize.onChange}
- onBlur={fontSize.onBlur}
- onScrub={fontSize.scrubTo}
- onScrubEnd={fontSize.commitScrub}
- onReset={() =>
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- patch: {
- fontSize: DEFAULTS.text.element.fontSize,
- },
- },
- ],
- })
- }
- isDefault={element.fontSize === DEFAULTS.text.element.fontSize}
- icon={<HugeiconsIcon icon={TextFontIcon} />}
- />
- </SectionField>
- <SectionField
- label="Color"
- beforeLabel={
- <KeyframeToggle
- isActive={textColor.isKeyframedAtTime}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle text color keyframe"
- onToggle={textColor.toggleKeyframe}
- />
- }
- >
- <ColorPicker
- value={uppercase({
- string: resolvedTextColor.replace("#", ""),
- })}
- onChange={(color) => textColor.onChange({ color: `#${color}` })}
- onChangeEnd={textColor.onChangeEnd}
- />
- </SectionField>
- </SectionFields>
- </SectionContent>
- </Section>
- );
-}
-
-function SpacingSection({
- element,
- trackId,
-}: {
- element: TextElement;
- trackId: string;
-}) {
- const editor = useEditor();
-
- const letterSpacing = usePropertyDraft({
- displayValue: Math.round(
- element.letterSpacing ?? DEFAULTS.text.letterSpacing,
- ).toString(),
- parse: (input) => {
- const parsed = parseFloat(input);
- return Number.isNaN(parsed) ? null : Math.round(parsed);
- },
- onPreview: (value) =>
- editor.timeline.previewElements({
- updates: [
- { trackId, elementId: element.id, updates: { letterSpacing: value } },
- ],
- }),
- onCommit: () => editor.timeline.commitPreview(),
- });
-
- const lineHeight = usePropertyDraft({
- displayValue: formatNumberForDisplay({
- value: element.lineHeight ?? DEFAULTS.text.lineHeight,
- fractionDigits: 1,
- }),
- parse: (input) => {
- const parsed = parseFloat(input);
- return Number.isNaN(parsed)
- ? null
- : Math.max(0.1, Math.round(parsed * 10) / 10);
- },
- onPreview: (value) =>
- editor.timeline.previewElements({
- updates: [
- { trackId, elementId: element.id, updates: { lineHeight: value } },
- ],
- }),
- onCommit: () => editor.timeline.commitPreview(),
- });
-
- return (
- <Section collapsible sectionKey={`${element.id}:spacing`}>
- <SectionHeader>
- <SectionTitle>Spacing</SectionTitle>
- </SectionHeader>
- <SectionContent>
- <div className="flex items-start gap-2">
- <SectionField label="Letter spacing" className="w-1/2">
- <NumberField
- value={letterSpacing.displayValue}
- onFocus={letterSpacing.onFocus}
- onChange={letterSpacing.onChange}
- onBlur={letterSpacing.onBlur}
- onScrub={letterSpacing.scrubTo}
- onScrubEnd={letterSpacing.commitScrub}
- onReset={() =>
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- patch: { letterSpacing: DEFAULTS.text.letterSpacing },
- },
- ],
- })
- }
- isDefault={
- (element.letterSpacing ?? DEFAULTS.text.letterSpacing) ===
- DEFAULTS.text.letterSpacing
- }
- icon={<OcTextWidthIcon size={14} />}
- />
- </SectionField>
- <SectionField label="Line height" className="w-1/2">
- <NumberField
- value={lineHeight.displayValue}
- onFocus={lineHeight.onFocus}
- onChange={lineHeight.onChange}
- onBlur={lineHeight.onBlur}
- onScrub={lineHeight.scrubTo}
- onScrubEnd={lineHeight.commitScrub}
- onReset={() =>
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- patch: { lineHeight: DEFAULTS.text.lineHeight },
- },
- ],
- })
- }
- isDefault={
- (element.lineHeight ?? DEFAULTS.text.lineHeight) ===
- DEFAULTS.text.lineHeight
- }
- icon={<OcTextHeightIcon size={14} />}
- />
- </SectionField>
- </div>
- </SectionContent>
- </Section>
- );
-}
-
-function BackgroundSection({
- element,
- trackId,
-}: {
- element: TextElement;
- trackId: string;
-}) {
- const editor = useEditor();
- const lastSelectedColor = useRef(DEFAULT_TEXT_COLOR);
- const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
- startTime: element.startTime,
- duration: element.duration,
- });
- const resolvedBgColor = resolveColorAtTime({
- baseColor: element.background.color,
- animations: element.animations,
- propertyPath: "background.color",
- localTime,
- });
-
- const bgColor = useKeyframedColorProperty({
- trackId,
- elementId: element.id,
- animations: element.animations,
- propertyPath: "background.color",
- localTime,
- isPlayheadWithinElementRange,
- resolvedColor: resolvedBgColor,
- buildBaseUpdates: ({ value }) => ({
- background: { ...element.background, color: value },
- }),
- });
-
- const bg = element.background;
-
- const resolvedPaddingX = resolveNumberAtTime({
- baseValue: bg.paddingX ?? DEFAULTS.text.background.paddingX,
- animations: element.animations,
- propertyPath: "background.paddingX",
- localTime,
- });
- const resolvedPaddingY = resolveNumberAtTime({
- baseValue: bg.paddingY ?? DEFAULTS.text.background.paddingY,
- animations: element.animations,
- propertyPath: "background.paddingY",
- localTime,
- });
- const resolvedOffsetX = resolveNumberAtTime({
- baseValue: bg.offsetX ?? DEFAULTS.text.background.offsetX,
- animations: element.animations,
- propertyPath: "background.offsetX",
- localTime,
- });
- const resolvedOffsetY = resolveNumberAtTime({
- baseValue: bg.offsetY ?? DEFAULTS.text.background.offsetY,
- animations: element.animations,
- propertyPath: "background.offsetY",
- localTime,
- });
- const resolvedCornerRadius = resolveNumberAtTime({
- baseValue: bg.cornerRadius ?? CORNER_RADIUS_MIN,
- animations: element.animations,
- propertyPath: "background.cornerRadius",
- localTime,
- });
-
- const paddingX = useKeyframedNumberProperty({
- trackId,
- elementId: element.id,
- animations: element.animations,
- propertyPath: "background.paddingX",
- localTime,
- isPlayheadWithinElementRange,
- displayValue: Math.round(resolvedPaddingX).toString(),
- parse: (input) => {
- const parsed = parseFloat(input);
- return Number.isNaN(parsed) ? null : Math.max(0, Math.round(parsed));
- },
- valueAtPlayhead: resolvedPaddingX,
- step: 1,
- buildBaseUpdates: ({ value }) => ({
- background: { ...bg, paddingX: value },
- }),
- });
-
- const paddingY = useKeyframedNumberProperty({
- trackId,
- elementId: element.id,
- animations: element.animations,
- propertyPath: "background.paddingY",
- localTime,
- isPlayheadWithinElementRange,
- displayValue: Math.round(resolvedPaddingY).toString(),
- parse: (input) => {
- const parsed = parseFloat(input);
- return Number.isNaN(parsed) ? null : Math.max(0, Math.round(parsed));
- },
- valueAtPlayhead: resolvedPaddingY,
- step: 1,
- buildBaseUpdates: ({ value }) => ({
- background: { ...bg, paddingY: value },
- }),
- });
-
- const offsetX = useKeyframedNumberProperty({
- trackId,
- elementId: element.id,
- animations: element.animations,
- propertyPath: "background.offsetX",
- localTime,
- isPlayheadWithinElementRange,
- displayValue: Math.round(resolvedOffsetX).toString(),
- parse: (input) => {
- const parsed = parseFloat(input);
- return Number.isNaN(parsed) ? null : Math.round(parsed);
- },
- valueAtPlayhead: resolvedOffsetX,
- step: 1,
- buildBaseUpdates: ({ value }) => ({
- background: { ...bg, offsetX: value },
- }),
- });
-
- const offsetY = useKeyframedNumberProperty({
- trackId,
- elementId: element.id,
- animations: element.animations,
- propertyPath: "background.offsetY",
- localTime,
- isPlayheadWithinElementRange,
- displayValue: Math.round(resolvedOffsetY).toString(),
- parse: (input) => {
- const parsed = parseFloat(input);
- return Number.isNaN(parsed) ? null : Math.round(parsed);
- },
- valueAtPlayhead: resolvedOffsetY,
- step: 1,
- buildBaseUpdates: ({ value }) => ({
- background: { ...bg, offsetY: value },
- }),
- });
-
- const cornerRadius = useKeyframedNumberProperty({
- trackId,
- elementId: element.id,
- animations: element.animations,
- propertyPath: "background.cornerRadius",
- localTime,
- isPlayheadWithinElementRange,
- displayValue: Math.round(resolvedCornerRadius).toString(),
- parse: (input) => {
- const parsed = parseFloat(input);
- if (Number.isNaN(parsed)) return null;
- return clamp({
- value: Math.round(parsed),
- min: CORNER_RADIUS_MIN,
- max: CORNER_RADIUS_MAX,
- });
- },
- valueAtPlayhead: resolvedCornerRadius,
- step: 1,
- buildBaseUpdates: ({ value }) => ({
- background: { ...bg, cornerRadius: value },
- }),
- });
-
- const toggleBackgroundEnabled = () => {
- const enabled = !element.background.enabled;
- const color =
- enabled && element.background.color === "transparent"
- ? lastSelectedColor.current
- : element.background.color;
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- patch: {
- background: {
- ...element.background,
- enabled,
- color,
- },
- },
- },
- ],
- });
- };
-
- return (
- <Section
- collapsible
- defaultOpen={element.background.enabled}
- sectionKey={`${element.id}:background`}
- >
- <SectionHeader
- trailing={
- <Button
- variant="ghost"
- size="icon"
- onClick={(event) => {
- event.stopPropagation();
- toggleBackgroundEnabled();
- }}
- >
- <HugeiconsIcon
- icon={element.background.enabled ? MinusSignIcon : PlusSignIcon}
- strokeWidth={1}
- />
- </Button>
- }
- >
- <SectionTitle>Background</SectionTitle>
- </SectionHeader>
- <SectionContent
- className={cn(
- !element.background.enabled && "pointer-events-none opacity-50",
- )}
- >
- <SectionFields>
- <SectionField
- label="Color"
- beforeLabel={
- <KeyframeToggle
- isActive={bgColor.isKeyframedAtTime}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle background color keyframe"
- onToggle={bgColor.toggleKeyframe}
- />
- }
- >
- <ColorPicker
- value={
- !element.background.enabled ||
- element.background.color === "transparent"
- ? lastSelectedColor.current.replace("#", "")
- : resolvedBgColor.replace("#", "")
- }
- onChange={(color) => {
- const hexColor = `#${color}`;
- if (color !== "transparent") {
- lastSelectedColor.current = hexColor;
- }
- bgColor.onChange({ color: hexColor });
- }}
- onChangeEnd={bgColor.onChangeEnd}
- />
- </SectionField>
- <div className="flex items-start gap-2">
- <SectionField
- label="Width"
- className="w-1/2"
- beforeLabel={
- <KeyframeToggle
- isActive={paddingX.isKeyframedAtTime}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle background width keyframe"
- onToggle={paddingX.toggleKeyframe}
- />
- }
- >
- <NumberField
- icon="W"
- value={paddingX.displayValue}
- min={0}
- onFocus={paddingX.onFocus}
- onChange={paddingX.onChange}
- onBlur={paddingX.onBlur}
- onScrub={paddingX.scrubTo}
- onScrubEnd={paddingX.commitScrub}
- onReset={() =>
- paddingX.commitValue({
- value: DEFAULTS.text.background.paddingX,
- })
- }
- isDefault={isPropertyAtDefault({
- hasAnimatedKeyframes: paddingX.hasAnimatedKeyframes,
- isPlayheadWithinElementRange,
- resolvedValue: resolvedPaddingX,
- staticValue: bg.paddingX ?? DEFAULTS.text.background.paddingX,
- defaultValue: DEFAULTS.text.background.paddingX,
- })}
- />
- </SectionField>
- <SectionField
- label="Height"
- className="w-1/2"
- beforeLabel={
- <KeyframeToggle
- isActive={paddingY.isKeyframedAtTime}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle background height keyframe"
- onToggle={paddingY.toggleKeyframe}
- />
- }
- >
- <NumberField
- icon="H"
- value={paddingY.displayValue}
- min={0}
- onFocus={paddingY.onFocus}
- onChange={paddingY.onChange}
- onBlur={paddingY.onBlur}
- onScrub={paddingY.scrubTo}
- onScrubEnd={paddingY.commitScrub}
- onReset={() =>
- paddingY.commitValue({
- value: DEFAULTS.text.background.paddingY,
- })
- }
- isDefault={isPropertyAtDefault({
- hasAnimatedKeyframes: paddingY.hasAnimatedKeyframes,
- isPlayheadWithinElementRange,
- resolvedValue: resolvedPaddingY,
- staticValue: bg.paddingY ?? DEFAULTS.text.background.paddingY,
- defaultValue: DEFAULTS.text.background.paddingY,
- })}
- />
- </SectionField>
- </div>
- <div className="flex items-start gap-2">
- <SectionField
- label="X-offset"
- className="w-1/2"
- beforeLabel={
- <KeyframeToggle
- isActive={offsetX.isKeyframedAtTime}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle x-offset keyframe"
- onToggle={offsetX.toggleKeyframe}
- />
- }
- >
- <NumberField
- icon="X"
- value={offsetX.displayValue}
- onFocus={offsetX.onFocus}
- onChange={offsetX.onChange}
- onBlur={offsetX.onBlur}
- onScrub={offsetX.scrubTo}
- onScrubEnd={offsetX.commitScrub}
- onReset={() =>
- offsetX.commitValue({
- value: DEFAULTS.text.background.offsetX,
- })
- }
- isDefault={isPropertyAtDefault({
- hasAnimatedKeyframes: offsetX.hasAnimatedKeyframes,
- isPlayheadWithinElementRange,
- resolvedValue: resolvedOffsetX,
- staticValue: bg.offsetX ?? DEFAULTS.text.background.offsetX,
- defaultValue: DEFAULTS.text.background.offsetX,
- })}
- />
- </SectionField>
- <SectionField
- label="Y-offset"
- className="w-1/2"
- beforeLabel={
- <KeyframeToggle
- isActive={offsetY.isKeyframedAtTime}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle y-offset keyframe"
- onToggle={offsetY.toggleKeyframe}
- />
- }
- >
- <NumberField
- icon="Y"
- value={offsetY.displayValue}
- onFocus={offsetY.onFocus}
- onChange={offsetY.onChange}
- onBlur={offsetY.onBlur}
- onScrub={offsetY.scrubTo}
- onScrubEnd={offsetY.commitScrub}
- onReset={() =>
- offsetY.commitValue({
- value: DEFAULTS.text.background.offsetY,
- })
- }
- isDefault={isPropertyAtDefault({
- hasAnimatedKeyframes: offsetY.hasAnimatedKeyframes,
- isPlayheadWithinElementRange,
- resolvedValue: resolvedOffsetY,
- staticValue: bg.offsetY ?? DEFAULTS.text.background.offsetY,
- defaultValue: DEFAULTS.text.background.offsetY,
- })}
- />
- </SectionField>
- </div>
- <SectionField
- label="Corner radius"
- beforeLabel={
- <KeyframeToggle
- isActive={cornerRadius.isKeyframedAtTime}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle corner radius keyframe"
- onToggle={cornerRadius.toggleKeyframe}
- />
- }
- >
- <NumberField
- icon="R"
- value={cornerRadius.displayValue}
- min={CORNER_RADIUS_MIN}
- max={CORNER_RADIUS_MAX}
- onFocus={cornerRadius.onFocus}
- onChange={cornerRadius.onChange}
- onBlur={cornerRadius.onBlur}
- onScrub={cornerRadius.scrubTo}
- onScrubEnd={cornerRadius.commitScrub}
- onReset={() =>
- cornerRadius.commitValue({ value: CORNER_RADIUS_MIN })
- }
- isDefault={isPropertyAtDefault({
- hasAnimatedKeyframes: cornerRadius.hasAnimatedKeyframes,
- isPlayheadWithinElementRange,
- resolvedValue: resolvedCornerRadius,
- staticValue: bg.cornerRadius ?? CORNER_RADIUS_MIN,
- defaultValue: CORNER_RADIUS_MIN,
- })}
- />
- </SectionField>
- </SectionFields>
- </SectionContent>
- </Section>
- );
-}
diff --git a/apps/web/src/components/editor/panels/properties/tabs/transform-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/transform-tab.tsx
deleted file mode 100644
index c3cef2d6..00000000
--- a/apps/web/src/components/editor/panels/properties/tabs/transform-tab.tsx
+++ /dev/null
@@ -1,462 +0,0 @@
-import { NumberField } from "@/components/ui/number-field";
-import { useEditor } from "@/hooks/use-editor";
-import { clamp, isNearlyEqual } from "@/utils/math";
-import type { VisualElement } from "@/lib/timeline";
-import {
- Section,
- SectionContent,
- SectionField,
- SectionFields,
- SectionHeader,
- SectionTitle,
-} from "@/components/section";
-import { Button } from "@/components/ui/button";
-import { HugeiconsIcon } from "@hugeicons/react";
-import {
- ArrowExpandIcon,
- Link05Icon,
- RotateClockwiseIcon,
-} from "@hugeicons/core-free-icons";
-import {
- getGroupKeyframesAtTime,
- hasGroupKeyframeAtTime,
- resolveTransformAtTime,
-} from "@/lib/animation";
-import { DEFAULTS } from "@/lib/timeline/defaults";
-import { useElementPlayhead } from "../hooks/use-element-playhead";
-import { KeyframeToggle } from "../components/keyframe-toggle";
-import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
-import { usePropertiesStore } from "../stores/properties-store";
-
-export function parseNumericInput({ input }: { input: string }): number | null {
- const parsed = parseFloat(input);
- return Number.isNaN(parsed) ? null : parsed;
-}
-
-export function isPropertyAtDefault({
- hasAnimatedKeyframes,
- isPlayheadWithinElementRange,
- resolvedValue,
- staticValue,
- defaultValue,
-}: {
- hasAnimatedKeyframes: boolean;
- isPlayheadWithinElementRange: boolean;
- resolvedValue: number;
- staticValue: number;
- defaultValue: number;
-}): boolean {
- if (hasAnimatedKeyframes && isPlayheadWithinElementRange) {
- return isNearlyEqual({
- leftValue: resolvedValue,
- rightValue: defaultValue,
- });
- }
-
- return staticValue === defaultValue;
-}
-
-export function TransformTab({
- element,
- trackId,
-}: {
- element: VisualElement;
- trackId: string;
-}) {
- const editor = useEditor();
- const isScaleLocked = usePropertiesStore((s) => s.isTransformScaleLocked);
- const setTransformScaleLocked = usePropertiesStore(
- (s) => s.setTransformScaleLocked,
- );
- const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
- startTime: element.startTime,
- duration: element.duration,
- });
- const resolvedTransform = resolveTransformAtTime({
- baseTransform: element.transform,
- animations: element.animations,
- localTime,
- });
-
- const positionX = useKeyframedNumberProperty({
- trackId,
- elementId: element.id,
- animations: element.animations,
- propertyPath: "transform.positionX",
- localTime,
- isPlayheadWithinElementRange,
- displayValue: Math.round(resolvedTransform.position.x).toString(),
- parse: (input) => parseNumericInput({ input }),
- valueAtPlayhead: resolvedTransform.position.x,
- step: 1,
- buildBaseUpdates: ({ value }) => ({
- transform: {
- ...element.transform,
- position: { ...element.transform.position, x: value },
- },
- }),
- });
-
- const positionY = useKeyframedNumberProperty({
- trackId,
- elementId: element.id,
- animations: element.animations,
- propertyPath: "transform.positionY",
- localTime,
- isPlayheadWithinElementRange,
- displayValue: Math.round(resolvedTransform.position.y).toString(),
- parse: (input) => parseNumericInput({ input }),
- valueAtPlayhead: resolvedTransform.position.y,
- step: 1,
- buildBaseUpdates: ({ value }) => ({
- transform: {
- ...element.transform,
- position: { ...element.transform.position, y: value },
- },
- }),
- });
-
- const parseScale = (input: string) => {
- const parsed = parseNumericInput({ input });
- if (parsed === null) return null;
- return parsed / 100;
- };
-
- const scaleX = useKeyframedNumberProperty({
- trackId,
- elementId: element.id,
- animations: element.animations,
- propertyPath: "transform.scaleX",
- localTime,
- isPlayheadWithinElementRange,
- displayValue: Math.round(resolvedTransform.scaleX * 100).toString(),
- parse: parseScale,
- valueAtPlayhead: resolvedTransform.scaleX,
- step: 0.01,
- buildBaseUpdates: ({ value }) => ({
- transform: {
- ...element.transform,
- scaleX: value,
- ...(isScaleLocked ? { scaleY: value } : {}),
- },
- }),
- buildAdditionalKeyframes: isScaleLocked
- ? ({ value }) => [{ propertyPath: "transform.scaleY", value }]
- : undefined,
- });
-
- const scaleY = useKeyframedNumberProperty({
- trackId,
- elementId: element.id,
- animations: element.animations,
- propertyPath: "transform.scaleY",
- localTime,
- isPlayheadWithinElementRange,
- displayValue: Math.round(resolvedTransform.scaleY * 100).toString(),
- parse: parseScale,
- valueAtPlayhead: resolvedTransform.scaleY,
- step: 0.01,
- buildBaseUpdates: ({ value }) => ({
- transform: {
- ...element.transform,
- scaleY: value,
- ...(isScaleLocked ? { scaleX: value } : {}),
- },
- }),
- buildAdditionalKeyframes: isScaleLocked
- ? ({ value }) => [{ propertyPath: "transform.scaleX", value }]
- : undefined,
- });
-
- const scaleFieldPropsX = {
- value: scaleX.displayValue,
- onFocus: scaleX.onFocus,
- onChange: scaleX.onChange,
- onBlur: scaleX.onBlur,
- dragSensitivity: "slow" as const,
- onScrub: scaleX.scrubTo,
- onScrubEnd: scaleX.commitScrub,
- onReset: () =>
- scaleX.commitValue({ value: DEFAULTS.element.transform.scaleX }),
- isDefault: isPropertyAtDefault({
- hasAnimatedKeyframes: scaleX.hasAnimatedKeyframes,
- isPlayheadWithinElementRange,
- resolvedValue: resolvedTransform.scaleX,
- staticValue: element.transform.scaleX,
- defaultValue: DEFAULTS.element.transform.scaleX,
- }),
- };
-
- const scaleFieldPropsY = {
- value: scaleY.displayValue,
- onFocus: scaleY.onFocus,
- onChange: scaleY.onChange,
- onBlur: scaleY.onBlur,
- dragSensitivity: "slow" as const,
- onScrub: scaleY.scrubTo,
- onScrubEnd: scaleY.commitScrub,
- onReset: () =>
- scaleY.commitValue({ value: DEFAULTS.element.transform.scaleY }),
- isDefault: isPropertyAtDefault({
- hasAnimatedKeyframes: scaleY.hasAnimatedKeyframes,
- isPlayheadWithinElementRange,
- resolvedValue: resolvedTransform.scaleY,
- staticValue: element.transform.scaleY,
- defaultValue: DEFAULTS.element.transform.scaleY,
- }),
- };
-
- const rotation = useKeyframedNumberProperty({
- trackId,
- elementId: element.id,
- animations: element.animations,
- propertyPath: "transform.rotate",
- localTime,
- isPlayheadWithinElementRange,
- displayValue: Math.round(resolvedTransform.rotate).toString(),
- parse: (input) => {
- const parsed = parseNumericInput({ input });
- if (parsed === null) return null;
- return clamp({ value: parsed, min: -360, max: 360 });
- },
- valueAtPlayhead: resolvedTransform.rotate,
- step: 1,
- buildBaseUpdates: ({ value }) => ({
- transform: {
- ...element.transform,
- rotate: value,
- },
- }),
- });
-
- const hasScaleKeyframe = hasGroupKeyframeAtTime({
- animations: element.animations,
- group: "transform.scale",
- time: localTime,
- });
-
- const toggleScaleKeyframe = () => {
- if (!isPlayheadWithinElementRange) return;
- const existing = getGroupKeyframesAtTime({
- animations: element.animations,
- group: "transform.scale",
- time: localTime,
- });
- if (existing.length > 0) {
- editor.timeline.removeKeyframes({
- keyframes: existing.map((ref) => ({
- trackId,
- elementId: element.id,
- ...ref,
- })),
- });
- return;
- }
- editor.timeline.upsertKeyframes({
- keyframes: [
- {
- trackId,
- elementId: element.id,
- propertyPath: "transform.scaleX",
- time: localTime,
- value: resolvedTransform.scaleX,
- },
- {
- trackId,
- elementId: element.id,
- propertyPath: "transform.scaleY",
- time: localTime,
- value: resolvedTransform.scaleY,
- },
- ],
- });
- };
-
- const scaleLockButton = (
- <Button
- type="button"
- variant={isScaleLocked ? "secondary" : "ghost"}
- size="icon"
- aria-pressed={isScaleLocked}
- onClick={() => setTransformScaleLocked(!isScaleLocked)}
- >
- <HugeiconsIcon icon={Link05Icon} />
- </Button>
- );
-
- return (
- <Section collapsible sectionKey={`${element.id}:transform`}>
- <SectionHeader>
- <SectionTitle>Transform</SectionTitle>
- </SectionHeader>
- <SectionContent>
- <SectionFields>
- <div className="flex items-end gap-2">
- {isScaleLocked ? (
- <>
- <SectionField
- label="Scale"
- className="min-w-0 flex-1"
- beforeLabel={
- <KeyframeToggle
- isActive={hasScaleKeyframe}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle scale keyframe"
- onToggle={toggleScaleKeyframe}
- />
- }
- >
- <NumberField
- icon={<HugeiconsIcon icon={ArrowExpandIcon} />}
- {...scaleFieldPropsX}
- />
- </SectionField>
- {scaleLockButton}
- </>
- ) : (
- <>
- <SectionField
- label="Width"
- className="min-w-0 flex-1"
- beforeLabel={
- <KeyframeToggle
- isActive={scaleX.isKeyframedAtTime}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle width scale keyframe"
- onToggle={scaleX.toggleKeyframe}
- />
- }
- >
- <NumberField icon="W" {...scaleFieldPropsX} />
- </SectionField>
- {scaleLockButton}
- <SectionField
- label="Height"
- className="min-w-0 flex-1"
- beforeLabel={
- <KeyframeToggle
- isActive={scaleY.isKeyframedAtTime}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle height scale keyframe"
- onToggle={scaleY.toggleKeyframe}
- />
- }
- >
- <NumberField icon="H" {...scaleFieldPropsY} />
- </SectionField>
- </>
- )}
- </div>
- <div className="flex items-end gap-2">
- <SectionField
- label="X"
- className="min-w-0 flex-1"
- beforeLabel={
- <KeyframeToggle
- isActive={positionX.isKeyframedAtTime}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle X position keyframe"
- onToggle={positionX.toggleKeyframe}
- />
- }
- >
- <NumberField
- icon="X"
- value={positionX.displayValue}
- onFocus={positionX.onFocus}
- onChange={positionX.onChange}
- onBlur={positionX.onBlur}
- onScrub={positionX.scrubTo}
- onScrubEnd={positionX.commitScrub}
- onReset={() =>
- positionX.commitValue({
- value: DEFAULTS.element.transform.position.x,
- })
- }
- isDefault={isPropertyAtDefault({
- hasAnimatedKeyframes: positionX.hasAnimatedKeyframes,
- isPlayheadWithinElementRange,
- resolvedValue: resolvedTransform.position.x,
- staticValue: element.transform.position.x,
- defaultValue: DEFAULTS.element.transform.position.x,
- })}
- />
- </SectionField>
- <SectionField
- label="Y"
- className="min-w-0 flex-1"
- beforeLabel={
- <KeyframeToggle
- isActive={positionY.isKeyframedAtTime}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle Y position keyframe"
- onToggle={positionY.toggleKeyframe}
- />
- }
- >
- <NumberField
- icon="Y"
- value={positionY.displayValue}
- onFocus={positionY.onFocus}
- onChange={positionY.onChange}
- onBlur={positionY.onBlur}
- onScrub={positionY.scrubTo}
- onScrubEnd={positionY.commitScrub}
- onReset={() =>
- positionY.commitValue({
- value: DEFAULTS.element.transform.position.y,
- })
- }
- isDefault={isPropertyAtDefault({
- hasAnimatedKeyframes: positionY.hasAnimatedKeyframes,
- isPlayheadWithinElementRange,
- resolvedValue: resolvedTransform.position.y,
- staticValue: element.transform.position.y,
- defaultValue: DEFAULTS.element.transform.position.y,
- })}
- />
- </SectionField>
- </div>
-
- <SectionField
- label="Rotation"
- beforeLabel={
- <KeyframeToggle
- isActive={rotation.isKeyframedAtTime}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle rotation keyframe"
- onToggle={rotation.toggleKeyframe}
- />
- }
- >
- <div className="flex items-center gap-2">
- <NumberField
- icon={<HugeiconsIcon icon={RotateClockwiseIcon} />}
- className="flex-none"
- value={rotation.displayValue}
- onFocus={rotation.onFocus}
- onChange={rotation.onChange}
- onBlur={rotation.onBlur}
- dragSensitivity="slow"
- onScrub={rotation.scrubTo}
- onScrubEnd={rotation.commitScrub}
- onReset={() =>
- rotation.commitValue({
- value: DEFAULTS.element.transform.rotate,
- })
- }
- isDefault={isPropertyAtDefault({
- hasAnimatedKeyframes: rotation.hasAnimatedKeyframes,
- isPlayheadWithinElementRange,
- resolvedValue: resolvedTransform.rotate,
- staticValue: element.transform.rotate,
- defaultValue: DEFAULTS.element.transform.rotate,
- })}
- />
- </div>
- </SectionField>
- </SectionFields>
- </SectionContent>
- </Section>
- );
-}