summaryrefslogtreecommitdiff
path: root/apps/web/src/components/editor/panels/properties
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/components/editor/panels/properties')
-rw-r--r--apps/web/src/components/editor/panels/properties/index.tsx2
-rw-r--r--apps/web/src/components/editor/panels/properties/section.tsx28
-rw-r--r--apps/web/src/components/editor/panels/properties/sections/blending.tsx132
-rw-r--r--apps/web/src/components/editor/panels/properties/sections/transform.tsx186
-rw-r--r--apps/web/src/components/editor/panels/properties/text-properties.tsx560
5 files changed, 627 insertions, 281 deletions
diff --git a/apps/web/src/components/editor/panels/properties/index.tsx b/apps/web/src/components/editor/panels/properties/index.tsx
index 05ad5396..790acdda 100644
--- a/apps/web/src/components/editor/panels/properties/index.tsx
+++ b/apps/web/src/components/editor/panels/properties/index.tsx
@@ -19,7 +19,7 @@ export function PropertiesPanel() {
return (
<div className="panel bg-background h-full rounded-sm border overflow-hidden">
{selectedElements.length > 0 ? (
- <ScrollArea className="h-full">
+ <ScrollArea className="h-full scrollbar-hidden">
{elementsWithTracks.map(({ track, element }) => {
if (element.type === "text") {
return (
diff --git a/apps/web/src/components/editor/panels/properties/section.tsx b/apps/web/src/components/editor/panels/properties/section.tsx
index 0b45065f..72d78d80 100644
--- a/apps/web/src/components/editor/panels/properties/section.tsx
+++ b/apps/web/src/components/editor/panels/properties/section.tsx
@@ -2,6 +2,7 @@ import { createContext, useContext, useState } from "react";
import { cn } from "@/utils/ui";
import { HugeiconsIcon } from "@hugeicons/react";
import { ArrowDownIcon } from "@hugeicons/core-free-icons";
+import { Label } from "@/components/ui/label";
const sectionExpandedCache = new Map<string, boolean>();
@@ -131,6 +132,33 @@ export function SectionHeader({
return <div className={baseClassName}>{content}</div>;
}
+export function SectionFields({
+ children,
+ className,
+}: {
+ children: React.ReactNode;
+ className?: string;
+}) {
+ return <div className={cn("flex flex-col gap-3.5", className)}>{children}</div>;
+}
+
+export function SectionField({
+ label,
+ children,
+ className,
+}: {
+ label: string;
+ children: React.ReactNode;
+ className?: string;
+}) {
+ return (
+ <div className={cn("flex flex-col gap-2", className)}>
+ <Label>{label}</Label>
+ {children}
+ </div>
+ );
+}
+
export function SectionContent({
children,
className,
diff --git a/apps/web/src/components/editor/panels/properties/sections/blending.tsx b/apps/web/src/components/editor/panels/properties/sections/blending.tsx
index 430efa25..4343c692 100644
--- a/apps/web/src/components/editor/panels/properties/sections/blending.tsx
+++ b/apps/web/src/components/editor/panels/properties/sections/blending.tsx
@@ -8,7 +8,7 @@ import {
} from "@/constants/timeline-constants";
import { OcCheckerboardIcon } from "@opencut/ui/icons";
import { Fragment, useRef } from "react";
-import { Section, SectionContent, SectionHeader } from "../section";
+import { Section, SectionContent, SectionField, SectionHeader } from "../section";
import {
Select,
SelectContent,
@@ -124,73 +124,73 @@ export function BlendingSection({
return (
<Section collapsible sectionKey={`${element.type}:blending`}>
<SectionHeader title="Blending" />
- <SectionContent>
- <div className="flex items-start gap-2">
- <div className="w-1/2 space-y-1.5">
- <NumberField
+ <SectionContent>
+ <div className="flex items-start gap-2">
+ <SectionField label="Opacity" className="w-1/2">
+ <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={() =>
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { opacity: DEFAULT_OPACITY },
+ },
+ ],
+ })
+ }
+ isDefault={element.opacity === DEFAULT_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"
- 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={() =>
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: { opacity: DEFAULT_OPACITY },
- },
- ],
- })
- }
- isDefault={element.opacity === DEFAULT_OPACITY}
- dragSensitivity="slow"
- />
- </div>
- <div className="w-1/2 space-y-1.5">
- <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">
- {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(option.value as BlendMode)
- }
- >
- {option.label}
- </SelectItem>
- ))}
- {groupIndex < BLEND_MODE_GROUPS.length - 1 ? (
- <SelectSeparator />
- ) : null}
- </Fragment>
- ))}
- </SelectContent>
- </Select>
- </div>
- </div>
- </SectionContent>
+ <SelectValue placeholder="Select blend mode" />
+ </SelectTrigger>
+ <SelectContent className="w-36">
+ {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(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/sections/transform.tsx b/apps/web/src/components/editor/panels/properties/sections/transform.tsx
index 17a54b4c..21da36e9 100644
--- a/apps/web/src/components/editor/panels/properties/sections/transform.tsx
+++ b/apps/web/src/components/editor/panels/properties/sections/transform.tsx
@@ -3,7 +3,7 @@ import { useEditor } from "@/hooks/use-editor";
import { usePropertyDraft } from "../hooks/use-property-draft";
import { clamp } from "@/utils/math";
import type { ElementType, Transform } from "@/types/timeline";
-import { Section, SectionContent, SectionHeader } from "../section";
+import { Section, SectionContent, SectionField, SectionFields, SectionHeader } from "../section";
import { Button } from "@/components/ui/button";
import { HugeiconsIcon } from "@hugeicons/react";
import {
@@ -120,8 +120,9 @@ export function TransformSection({
return (
<Section collapsible sectionKey={`${element.type}:transform`}>
<SectionHeader title="Transform" />
- <SectionContent>
- <div className="flex flex-col gap-2">
+ <SectionContent>
+ <SectionFields>
+ <SectionField label="Scale">
<div className="flex items-center gap-2">
{isScaleLocked ? (
<>
@@ -144,105 +145,108 @@ export function TransformSection({
<HugeiconsIcon icon={Link05Icon} />
</Button>
</div>
+ </SectionField>
+ <SectionField label="Position">
<div className="flex items-center gap-2">
- <NumberField
- icon="X"
- className="flex-1"
- value={positionX.displayValue}
- onFocus={positionX.onFocus}
- onChange={positionX.onChange}
- onBlur={positionX.onBlur}
- onScrub={positionX.scrubTo}
- onScrubEnd={positionX.commitScrub}
- onReset={() =>
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: {
- transform: {
- ...element.transform,
- position: {
- ...element.transform.position,
- x: DEFAULT_TRANSFORM.position.x,
+ <NumberField
+ icon="X"
+ className="flex-1"
+ value={positionX.displayValue}
+ onFocus={positionX.onFocus}
+ onChange={positionX.onChange}
+ onBlur={positionX.onBlur}
+ onScrub={positionX.scrubTo}
+ onScrubEnd={positionX.commitScrub}
+ onReset={() =>
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: {
+ transform: {
+ ...element.transform,
+ position: {
+ ...element.transform.position,
+ x: DEFAULT_TRANSFORM.position.x,
+ },
},
},
},
- },
- ],
- })
- }
- isDefault={
- element.transform.position.x === DEFAULT_TRANSFORM.position.x
- }
- />
- <NumberField
- icon="Y"
- className="flex-1"
- value={positionY.displayValue}
- onFocus={positionY.onFocus}
- onChange={positionY.onChange}
- onBlur={positionY.onBlur}
- onScrub={positionY.scrubTo}
- onScrubEnd={positionY.commitScrub}
- onReset={() =>
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: {
- transform: {
- ...element.transform,
- position: {
- ...element.transform.position,
- y: DEFAULT_TRANSFORM.position.y,
+ ],
+ })
+ }
+ isDefault={
+ element.transform.position.x === DEFAULT_TRANSFORM.position.x
+ }
+ />
+ <NumberField
+ icon="Y"
+ className="flex-1"
+ value={positionY.displayValue}
+ onFocus={positionY.onFocus}
+ onChange={positionY.onChange}
+ onBlur={positionY.onBlur}
+ onScrub={positionY.scrubTo}
+ onScrubEnd={positionY.commitScrub}
+ onReset={() =>
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: {
+ transform: {
+ ...element.transform,
+ position: {
+ ...element.transform.position,
+ y: DEFAULT_TRANSFORM.position.y,
+ },
},
},
},
- },
- ],
- })
- }
- isDefault={
- element.transform.position.y === DEFAULT_TRANSFORM.position.y
- }
- />
+ ],
+ })
+ }
+ isDefault={
+ element.transform.position.y === DEFAULT_TRANSFORM.position.y
+ }
+ />
</div>
+ </SectionField>
- <div className="flex items-center gap-2">
- <NumberField
- icon={<HugeiconsIcon icon={RotateClockwiseIcon} />}
- className="flex-1"
- value={rotation.displayValue}
- onFocus={rotation.onFocus}
- onChange={rotation.onChange}
- onBlur={rotation.onBlur}
- dragSensitivity="slow"
- onScrub={rotation.scrubTo}
- onScrubEnd={rotation.commitScrub}
- onReset={() =>
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: {
- transform: {
- ...element.transform,
- rotate: DEFAULT_TRANSFORM.rotate,
- },
+ <SectionField label="Rotation">
+ <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={() =>
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: {
+ transform: {
+ ...element.transform,
+ rotate: DEFAULT_TRANSFORM.rotate,
},
},
- ],
- })
- }
- isDefault={element.transform.rotate === DEFAULT_TRANSFORM.rotate}
- />
- </div>
- </div>
- </SectionContent>
+ },
+ ],
+ })
+ }
+ isDefault={element.transform.rotate === DEFAULT_TRANSFORM.rotate}
+ />
+ </SectionField>
+ </SectionFields>
+ </SectionContent>
</Section>
);
}
diff --git a/apps/web/src/components/editor/panels/properties/text-properties.tsx b/apps/web/src/components/editor/panels/properties/text-properties.tsx
index cfca52dc..1a17c26a 100644
--- a/apps/web/src/components/editor/panels/properties/text-properties.tsx
+++ b/apps/web/src/components/editor/panels/properties/text-properties.tsx
@@ -1,31 +1,49 @@
import { Textarea } from "@/components/ui/textarea";
import { FontPicker } from "@/components/ui/font-picker";
import type { TextElement } from "@/types/timeline";
-import { Slider } from "@/components/ui/slider";
import { NumberField } from "@/components/ui/number-field";
import { useRef } from "react";
-import { Section, SectionContent, SectionHeader } from "./section";
+import { Section, SectionContent, SectionField, SectionFields, SectionHeader } from "./section";
import { ColorPicker } from "@/components/ui/color-picker";
import { uppercase } from "@/utils/string";
import { clamp } from "@/utils/math";
import { useEditor } from "@/hooks/use-editor";
import { DEFAULT_COLOR } from "@/constants/project-constants";
import {
+ DEFAULT_LETTER_SPACING,
+ DEFAULT_LINE_HEIGHT,
+ DEFAULT_TEXT_BACKGROUND,
DEFAULT_TEXT_ELEMENT,
MAX_FONT_SIZE,
MIN_FONT_SIZE,
} from "@/constants/text-constants";
import { usePropertyDraft } from "./hooks/use-property-draft";
import { TransformSection, BlendingSection } from "./sections";
-import {
- Select,
- SelectTrigger,
- SelectValue,
- SelectContent,
- SelectItem,
-} from "@/components/ui/select";
-import { OcFontWeightIcon } from "@opencut/ui/icons";
-import { Label } from "@/components/ui/label";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { TextFontIcon } from "@hugeicons/core-free-icons";
+import { OcTextHeightIcon, OcTextWidthIcon } from "@opencut/ui/icons";
+
+function createOffsetConverter({
+ defaultValue,
+ scale = 1,
+ min,
+}: {
+ defaultValue: number;
+ scale?: number;
+ min?: number;
+}) {
+ return {
+ toDisplay: (value: number) => Math.round((value - defaultValue) * scale),
+ fromDisplay: (display: number) => {
+ const stored = defaultValue + display / scale;
+ return min !== undefined ? Math.max(min, stored) : stored;
+ },
+ };
+}
+
+const lineHeightConverter = createOffsetConverter({ defaultValue: DEFAULT_LINE_HEIGHT, scale: 10 });
+const paddingXConverter = createOffsetConverter({ defaultValue: DEFAULT_TEXT_BACKGROUND.paddingX, min: 0 });
+const paddingYConverter = createOffsetConverter({ defaultValue: DEFAULT_TEXT_BACKGROUND.paddingY, min: 0 });
export function TextProperties({
element,
@@ -39,8 +57,9 @@ export function TextProperties({
<ContentSection element={element} trackId={trackId} />
<TransformSection element={element} trackId={trackId} />
<BlendingSection element={element} trackId={trackId} />
- <FontSection element={element} trackId={trackId} />
- <ColorSection element={element} trackId={trackId} />
+ <TypographySection element={element} trackId={trackId} />
+ <SpacingSection element={element} trackId={trackId} />
+ <BackgroundSection element={element} trackId={trackId} />
</div>
);
}
@@ -83,7 +102,7 @@ function ContentSection({
);
}
-function FontSection({
+function TypographySection({
element,
trackId,
}: {
@@ -109,10 +128,11 @@ function FontSection({
});
return (
- <Section collapsible sectionKey="text:font">
- <SectionHeader title="Font" />
- <SectionContent>
- <div className="flex flex-col gap-2">
+ <Section collapsible sectionKey="text:typography">
+ <SectionHeader title="Typography" />
+ <SectionContent>
+ <SectionFields>
+ <SectionField label="Font">
<FontPicker
defaultValue={element.fontFamily}
onValueChange={(value) =>
@@ -127,52 +147,409 @@ function FontSection({
})
}
/>
- <Select value={element.fontWeight}>
- <SelectTrigger className="w-full" icon={<OcFontWeightIcon />}>
- <SelectValue placeholder="Select weight" />
- </SelectTrigger>
- <SelectContent>
- <SelectItem value="100">Thin</SelectItem>
- <SelectItem value="200">Extra Light</SelectItem>
- <SelectItem value="300">Light</SelectItem>
- <SelectItem value="400">Normal</SelectItem>
- <SelectItem value="500">Medium</SelectItem>
- <SelectItem value="600">Semi Bold</SelectItem>
- <SelectItem value="700">Bold</SelectItem>
- <SelectItem value="800">Extra Bold</SelectItem>
- <SelectItem value="900">Black</SelectItem>
- </SelectContent>
- </Select>
+ </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: DEFAULT_TEXT_ELEMENT.fontSize },
+ },
+ ],
+ })
+ }
+ isDefault={element.fontSize === DEFAULT_TEXT_ELEMENT.fontSize}
+ icon={<HugeiconsIcon icon={TextFontIcon} />}
+ />
+ </SectionField>
+ <SectionField label="Color">
+ <ColorPicker
+ value={uppercase({
+ string: (element.color || "FFFFFF").replace("#", ""),
+ })}
+ onChange={(color) =>
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { color: `#${color}` },
+ },
+ ],
+ })
+ }
+ onChangeEnd={() => editor.timeline.commitPreview()}
+ />
+ </SectionField>
+ </SectionFields>
+ </SectionContent>
+ </Section>
+ );
+}
+
+function SpacingSection({
+ element,
+ trackId,
+}: {
+ element: TextElement;
+ trackId: string;
+}) {
+ const editor = useEditor();
+
+ const letterSpacing = usePropertyDraft({
+ displayValue: Math.round(element.letterSpacing ?? DEFAULT_LETTER_SPACING).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: lineHeightConverter.toDisplay(element.lineHeight ?? DEFAULT_LINE_HEIGHT).toString(),
+ parse: (input) => {
+ const parsed = parseFloat(input);
+ return Number.isNaN(parsed) ? null : lineHeightConverter.fromDisplay(Math.round(parsed));
+ },
+ onPreview: (value) =>
+ editor.timeline.previewElements({
+ updates: [{ trackId, elementId: element.id, updates: { lineHeight: value } }],
+ }),
+ onCommit: () => editor.timeline.commitPreview(),
+ });
+
+ return (
+ <Section collapsible sectionKey="text:spacing" hasBorderBottom={false}>
+ <SectionHeader title="Spacing" />
+ <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: DEFAULT_LETTER_SPACING } }],
+ })
+ }
+ isDefault={(element.letterSpacing ?? DEFAULT_LETTER_SPACING) === DEFAULT_LETTER_SPACING}
+ 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: DEFAULT_LINE_HEIGHT } }],
+ })
+ }
+ isDefault={(element.lineHeight ?? DEFAULT_LINE_HEIGHT) === DEFAULT_LINE_HEIGHT}
+ icon={<OcTextHeightIcon size={14} />}
+ />
+ </SectionField>
+ </div>
+ </SectionContent>
+ </Section>
+ );
+}
- <Label>Font size</Label>
- <div className="flex items-center gap-2">
- <Slider
- value={[element.fontSize]}
- min={MIN_FONT_SIZE}
- max={MAX_FONT_SIZE}
- step={1}
- onValueChange={([value]) =>
+function BackgroundSection({
+ element,
+ trackId,
+}: {
+ element: TextElement;
+ trackId: string;
+}) {
+ const editor = useEditor();
+ const lastSelectedColor = useRef(DEFAULT_COLOR);
+
+ const cornerRadius = usePropertyDraft({
+ displayValue: Math.round(element.background.cornerRadius ?? 0).toString(),
+ parse: (input) => {
+ const parsed = parseFloat(input);
+ return Number.isNaN(parsed) ? null : Math.max(0, Math.round(parsed));
+ },
+ onPreview: (value) =>
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: {
+ background: { ...element.background, cornerRadius: value },
+ },
+ },
+ ],
+ }),
+ onCommit: () => editor.timeline.commitPreview(),
+ });
+
+ const paddingX = usePropertyDraft({
+ displayValue: paddingXConverter.toDisplay(element.background.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX).toString(),
+ parse: (input) => {
+ const parsed = parseFloat(input);
+ return Number.isNaN(parsed) ? null : paddingXConverter.fromDisplay(Math.round(parsed));
+ },
+ onPreview: (value) =>
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { background: { ...element.background, paddingX: value } },
+ },
+ ],
+ }),
+ onCommit: () => editor.timeline.commitPreview(),
+ });
+
+ const paddingY = usePropertyDraft({
+ displayValue: paddingYConverter.toDisplay(element.background.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY).toString(),
+ parse: (input) => {
+ const parsed = parseFloat(input);
+ return Number.isNaN(parsed) ? null : paddingYConverter.fromDisplay(Math.round(parsed));
+ },
+ onPreview: (value) =>
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { background: { ...element.background, paddingY: value } },
+ },
+ ],
+ }),
+ onCommit: () => editor.timeline.commitPreview(),
+ });
+
+ const offsetX = usePropertyDraft({
+ displayValue: Math.round(element.background.offsetX ?? 0).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: { background: { ...element.background, offsetX: value } },
+ },
+ ],
+ }),
+ onCommit: () => editor.timeline.commitPreview(),
+ });
+
+ const offsetY = usePropertyDraft({
+ displayValue: Math.round(element.background.offsetY ?? 0).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: { background: { ...element.background, offsetY: value } },
+ },
+ ],
+ }),
+ onCommit: () => editor.timeline.commitPreview(),
+ });
+
+ return (
+ <Section collapsible sectionKey="text:background">
+ <SectionHeader title="Background" />
+ <SectionContent>
+ <SectionFields>
+ <SectionField label="Color">
+ <ColorPicker
+ value={
+ element.background.color === "transparent"
+ ? lastSelectedColor.current.replace("#", "")
+ : element.background.color.replace("#", "")
+ }
+ onChange={(color) => {
+ const hexColor = `#${color}`;
+ if (color !== "transparent") {
+ lastSelectedColor.current = hexColor;
+ }
editor.timeline.previewElements({
updates: [
{
trackId,
elementId: element.id,
- updates: { fontSize: value },
+ updates: {
+ background: { ...element.background, color: hexColor },
+ },
},
],
- })
+ });
+ }}
+ onChangeEnd={() => editor.timeline.commitPreview()}
+ className={
+ element.background.color === "transparent"
+ ? "pointer-events-none opacity-50"
+ : ""
}
- onValueCommit={() => editor.timeline.commitPreview()}
- className="w-full"
/>
+ </SectionField>
+ <div className="flex items-start gap-2">
+ <SectionField label="Width" className="w-1/2">
<NumberField
- className="w-18 shrink-0"
- value={fontSize.displayValue}
- min={MIN_FONT_SIZE}
- max={MAX_FONT_SIZE}
- onFocus={fontSize.onFocus}
- onChange={fontSize.onChange}
- onBlur={fontSize.onBlur}
+ icon="W"
+ value={paddingX.displayValue}
+ min={0}
+ onFocus={paddingX.onFocus}
+ onChange={paddingX.onChange}
+ onBlur={paddingX.onBlur}
+ onScrub={paddingX.scrubTo}
+ onScrubEnd={paddingX.commitScrub}
+ onReset={() =>
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: {
+ background: {
+ ...element.background,
+ paddingX: DEFAULT_TEXT_BACKGROUND.paddingX,
+ },
+ },
+ },
+ ],
+ })
+ }
+ isDefault={
+ (element.background.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX) ===
+ DEFAULT_TEXT_BACKGROUND.paddingX
+ }
+ />
+ </SectionField>
+ <SectionField label="Height" className="w-1/2">
+ <NumberField
+ icon="H"
+ value={paddingY.displayValue}
+ min={0}
+ onFocus={paddingY.onFocus}
+ onChange={paddingY.onChange}
+ onBlur={paddingY.onBlur}
+ onScrub={paddingY.scrubTo}
+ onScrubEnd={paddingY.commitScrub}
+ onReset={() =>
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: {
+ background: {
+ ...element.background,
+ paddingY: DEFAULT_TEXT_BACKGROUND.paddingY,
+ },
+ },
+ },
+ ],
+ })
+ }
+ isDefault={
+ (element.background.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY) ===
+ DEFAULT_TEXT_BACKGROUND.paddingY
+ }
+ />
+ </SectionField>
+ </div>
+ <div className="flex items-start gap-2">
+ <SectionField label="X-offset" className="w-1/2">
+ <NumberField
+ icon="X"
+ value={offsetX.displayValue}
+ onFocus={offsetX.onFocus}
+ onChange={offsetX.onChange}
+ onBlur={offsetX.onBlur}
+ onScrub={offsetX.scrubTo}
+ onScrubEnd={offsetX.commitScrub}
+ onReset={() =>
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: {
+ background: { ...element.background, offsetX: 0 },
+ },
+ },
+ ],
+ })
+ }
+ isDefault={(element.background.offsetX ?? 0) === 0}
+ />
+ </SectionField>
+ <SectionField label="Y-offset" className="w-1/2">
+ <NumberField
+ icon="Y"
+ value={offsetY.displayValue}
+ onFocus={offsetY.onFocus}
+ onChange={offsetY.onChange}
+ onBlur={offsetY.onBlur}
+ onScrub={offsetY.scrubTo}
+ onScrubEnd={offsetY.commitScrub}
+ onReset={() =>
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: {
+ background: { ...element.background, offsetY: 0 },
+ },
+ },
+ ],
+ })
+ }
+ isDefault={(element.background.offsetY ?? 0) === 0}
+ />
+ </SectionField>
+ </div>
+ <SectionField label="Corner Radius">
+ <NumberField
+ icon="R"
+ value={cornerRadius.displayValue}
+ min={0}
+ onFocus={cornerRadius.onFocus}
+ onChange={cornerRadius.onChange}
+ onBlur={cornerRadius.onBlur}
+ onScrub={cornerRadius.scrubTo}
+ onScrubEnd={cornerRadius.commitScrub}
onReset={() =>
editor.timeline.updateElements({
updates: [
@@ -180,82 +557,19 @@ function FontSection({
trackId,
elementId: element.id,
updates: {
- fontSize: DEFAULT_TEXT_ELEMENT.fontSize,
+ background: {
+ ...element.background,
+ cornerRadius: 0,
+ },
},
},
],
})
}
- isDefault={element.fontSize === DEFAULT_TEXT_ELEMENT.fontSize}
+ isDefault={(element.background.cornerRadius ?? 0) === 0}
/>
- </div>
- </div>
- </SectionContent>
- </Section>
- );
-}
-
-function ColorSection({
- element,
- trackId,
-}: {
- element: TextElement;
- trackId: string;
-}) {
- const editor = useEditor();
- const lastSelectedColor = useRef(DEFAULT_COLOR);
-
- return (
- <Section collapsible sectionKey="text:color" hasBorderBottom={false}>
- <SectionHeader title="Color" />
- <SectionContent>
- <div className="flex flex-col gap-6">
- <ColorPicker
- value={uppercase({
- string: (element.color || "FFFFFF").replace("#", ""),
- })}
- onChange={(color) =>
- editor.timeline.previewElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: { color: `#${color}` },
- },
- ],
- })
- }
- onChangeEnd={() => editor.timeline.commitPreview()}
- />
- <ColorPicker
- value={
- element.backgroundColor === "transparent"
- ? lastSelectedColor.current.replace("#", "")
- : element.backgroundColor.replace("#", "")
- }
- onChange={(color) => {
- const hexColor = `#${color}`;
- if (color !== "transparent") {
- lastSelectedColor.current = hexColor;
- }
- editor.timeline.previewElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: { backgroundColor: hexColor },
- },
- ],
- });
- }}
- onChangeEnd={() => editor.timeline.commitPreview()}
- className={
- element.backgroundColor === "transparent"
- ? "pointer-events-none opacity-50"
- : ""
- }
- />
- </div>
+ </SectionField>
+ </SectionFields>
</SectionContent>
</Section>
);