summaryrefslogtreecommitdiff
path: root/apps/web/src/components/editor/panels/properties/tabs
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-03-29 14:15:21 +0200
committerMaze Winther <mazewinther@gmail.com>2026-03-29 15:58:46 +0200
commit220ec757d42b54f498d031436e5b4b03931b165c (patch)
tree931a9b147c692b1fa5fcdc17ec568dea6bd437b2 /apps/web/src/components/editor/panels/properties/tabs
parent585e28d49b853155c34a3eba03388722e2049cb7 (diff)
downloadopencut-220ec757d42b54f498d031436e5b4b03931b165c.tar.gz
refactor: move packages sub-folders into web app
Diffstat (limited to 'apps/web/src/components/editor/panels/properties/tabs')
-rw-r--r--apps/web/src/components/editor/panels/properties/tabs/blending-tab.tsx460
-rw-r--r--apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx2
-rw-r--r--apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx1530
3 files changed, 996 insertions, 996 deletions
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
index fdcde148..a69acebf 100644
--- a/apps/web/src/components/editor/panels/properties/tabs/blending-tab.tsx
+++ b/apps/web/src/components/editor/panels/properties/tabs/blending-tab.tsx
@@ -1,230 +1,230 @@
-import { useEditor } from "@/hooks/use-editor";
-import { clamp } from "@/utils/math";
-import { NumberField } from "@/components/ui/number-field";
-import { OcCheckerboardIcon } from "@opencut/ui/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: "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: string) => {
- if (editor.timeline.isPreviewActive()) {
- editor.timeline.commitPreview();
- } else {
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: { blendMode: value as BlendMode },
- },
- ],
- });
- }
- 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 as BlendMode })
- }
- >
- {option.label}
- </SelectItem>
- ))}
- {groupIndex < BLEND_MODE_GROUPS.length - 1 ? (
- <SelectSeparator />
- ) : null}
- </Fragment>
- ))}
- </SelectContent>
- </Select>
- </SectionField>
- </div>
- </SectionContent>
- </Section>
- );
-}
+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: "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: string) => {
+ if (editor.timeline.isPreviewActive()) {
+ editor.timeline.commitPreview();
+ } else {
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { blendMode: value as BlendMode },
+ },
+ ],
+ });
+ }
+ 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 as BlendMode })
+ }
+ >
+ {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/masks-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx
index db8bd728..d416dc28 100644
--- a/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx
+++ b/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx
@@ -53,7 +53,7 @@ import {
SectionTitle,
} from "@/components/section";
import { usePropertyDraft } from "../hooks/use-property-draft";
-import { OcMirrorIcon, OcShapesIcon } from "@opencut/ui/icons";
+import { OcMirrorIcon, OcShapesIcon } from "@/components/icons";
import { cn } from "@/utils/ui";
type MasksTabProps = {
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
index be85bede..4c58023b 100644
--- a/apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx
+++ b/apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx
@@ -1,765 +1,765 @@
-"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 { DEFAULT_COLOR } from "@/constants/project-constants";
-import {
- CORNER_RADIUS_MAX,
- CORNER_RADIUS_MIN,
- MAX_FONT_SIZE,
- MIN_FONT_SIZE,
-} from "@/constants/text-constants";
-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 "@opencut/ui/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,
- updates: { 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,
- updates: {
- 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,
- updates: { 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,
- updates: { 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_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,
- updates: {
- 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>
- );
-}
+"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 { DEFAULT_COLOR } from "@/constants/project-constants";
+import {
+ CORNER_RADIUS_MAX,
+ CORNER_RADIUS_MIN,
+ MAX_FONT_SIZE,
+ MIN_FONT_SIZE,
+} from "@/constants/text-constants";
+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,
+ updates: { 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,
+ updates: {
+ 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,
+ updates: { 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,
+ updates: { 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_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,
+ updates: {
+ 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>
+ );
+}