mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
fix a ton of issues + improvement
This commit is contained in:
@@ -99,9 +99,9 @@ export function TextEditOverlay({
|
||||
const fontStyle = element.fontStyle === "italic" ? "italic" : "normal";
|
||||
const letterSpacing = element.letterSpacing ?? 0;
|
||||
const backgroundColor =
|
||||
element.backgroundColor === "transparent"
|
||||
element.background.color === "transparent"
|
||||
? "transparent"
|
||||
: element.backgroundColor;
|
||||
: element.background.color;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,89 +147,33 @@ 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>
|
||||
|
||||
<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]) =>
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { fontSize: value },
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
onValueCommit={() => editor.timeline.commitPreview()}
|
||||
className="w-full"
|
||||
/>
|
||||
<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}
|
||||
onReset={() =>
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: {
|
||||
fontSize: DEFAULT_TEXT_ELEMENT.fontSize,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
isDefault={element.fontSize === DEFAULT_TEXT_ELEMENT.fontSize}
|
||||
/>
|
||||
</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">
|
||||
</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("#", ""),
|
||||
@@ -227,35 +191,385 @@ function ColorSection({
|
||||
}
|
||||
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"
|
||||
: ""
|
||||
</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} />}
|
||||
/>
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
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: {
|
||||
background: { ...element.background, color: hexColor },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}}
|
||||
onChangeEnd={() => editor.timeline.commitPreview()}
|
||||
className={
|
||||
element.background.color === "transparent"
|
||||
? "pointer-events-none opacity-50"
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
</SectionField>
|
||||
<div className="flex items-start gap-2">
|
||||
<SectionField label="Width" className="w-1/2">
|
||||
<NumberField
|
||||
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: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: {
|
||||
background: {
|
||||
...element.background,
|
||||
cornerRadius: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
isDefault={(element.background.cornerRadius ?? 0) === 0}
|
||||
/>
|
||||
</SectionField>
|
||||
</SectionFields>
|
||||
</SectionContent>
|
||||
</Section>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user