summaryrefslogtreecommitdiff
path: root/apps/web/src/components/editor/panels/properties/text-properties.tsx
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-03-02 13:13:07 +0100
committerMaze Winther <mazewinther@gmail.com>2026-03-02 13:13:07 +0100
commite7dcb586c0408255a0bf788e355fdb6d50d9b923 (patch)
tree74ff0fc0f1ba501a075342db9b4e454450926154 /apps/web/src/components/editor/panels/properties/text-properties.tsx
parent93bea01c9ed3fcbebc64c9cef382a51d24c925ed (diff)
downloadopencut-e7dcb586c0408255a0bf788e355fdb6d50d9b923.tar.gz
feat: Clip effects, asset sorting, and timeline improvements
Major features and improvements: * **Clip Effects**: * Added UI in Properties Panel to manage effects on video/image clips (add, remove, toggle, reorder). * Implemented dynamic parameter fields for effects. * Added support for keyframing effect parameters. * **Assets Panel**: * Added sorting options: Name, Type, Duration, and File Size. * Persisted view preferences (grid/list mode, sort order) to local storage. * Refactored media item rendering and drag interactions. * **Timeline & Interaction**: * **Keyframe Dragging**: Added ability to drag keyframes directly on the timeline element. * **Resizing**: Improved resize logic to respect neighboring clips (prevents overlaps). * **Visuals**: Implemented tiled background rendering for video/image clips on the timeline. * **Shortcuts**: Added "Deselect All" action bound to the `Escape` key. * **Fixes**: Corrected drag-and-drop coordinate calculations when the timeline track area is scrolled. * **Text Elements**: * Refactored text background storage to use an explicit `enabled` flag. * Added `V8toV9` storage migration to update existing projects. * **Architecture**: * Moved export state management to `ProjectManager` for better lifecycle handling. * Refactored `PropertiesPanel` sections to be more composable (custom headers, borders).
Diffstat (limited to 'apps/web/src/components/editor/panels/properties/text-properties.tsx')
-rw-r--r--apps/web/src/components/editor/panels/properties/text-properties.tsx467
1 files changed, 275 insertions, 192 deletions
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 1a17c26a..73f3f1a3 100644
--- a/apps/web/src/components/editor/panels/properties/text-properties.tsx
+++ b/apps/web/src/components/editor/panels/properties/text-properties.tsx
@@ -3,13 +3,23 @@ import { FontPicker } from "@/components/ui/font-picker";
import type { TextElement } from "@/types/timeline";
import { NumberField } from "@/components/ui/number-field";
import { useRef } from "react";
-import { Section, SectionContent, SectionField, SectionFields, SectionHeader } from "./section";
+import {
+ Section,
+ SectionContent,
+ SectionField,
+ SectionFields,
+ SectionHeader,
+ SectionTitle,
+} from "./section";
import { ColorPicker } from "@/components/ui/color-picker";
+import { Button } from "@/components/ui/button";
import { uppercase } from "@/utils/string";
import { clamp } from "@/utils/math";
import { useEditor } from "@/hooks/use-editor";
import { DEFAULT_COLOR } from "@/constants/project-constants";
import {
+ CORNER_RADIUS_MAX,
+ CORNER_RADIUS_MIN,
DEFAULT_LETTER_SPACING,
DEFAULT_LINE_HEIGHT,
DEFAULT_TEXT_BACKGROUND,
@@ -20,30 +30,13 @@ import {
import { usePropertyDraft } from "./hooks/use-property-draft";
import { TransformSection, BlendingSection } from "./sections";
import { HugeiconsIcon } from "@hugeicons/react";
-import { TextFontIcon } from "@hugeicons/core-free-icons";
+import {
+ TextFontIcon,
+ ViewIcon,
+ ViewOffSlashIcon,
+} 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 });
+import { cn } from "@/utils/ui";
export function TextProperties({
element,
@@ -86,8 +79,8 @@ function ContentSection({
});
return (
- <Section collapsible sectionKey="text:content" hasBorderTop={false}>
- <SectionHeader title="Content" />
+ <Section collapsible sectionKey="text:content" showTopBorder={false}>
+ <SectionHeader><SectionTitle>Content</SectionTitle></SectionHeader>
<SectionContent>
<Textarea
placeholder="Name"
@@ -129,71 +122,71 @@ function TypographySection({
return (
<Section collapsible sectionKey="text:typography">
- <SectionHeader title="Typography" />
- <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: 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>
+ <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: 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>
);
}
@@ -208,72 +201,98 @@ function SpacingSection({
const editor = useEditor();
const letterSpacing = usePropertyDraft({
- displayValue: Math.round(element.letterSpacing ?? DEFAULT_LETTER_SPACING).toString(),
+ 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 } }],
+ updates: [
+ { trackId, elementId: element.id, updates: { letterSpacing: value } },
+ ],
}),
onCommit: () => editor.timeline.commitPreview(),
});
const lineHeight = usePropertyDraft({
- displayValue: lineHeightConverter.toDisplay(element.lineHeight ?? DEFAULT_LINE_HEIGHT).toString(),
+ displayValue: (element.lineHeight ?? DEFAULT_LINE_HEIGHT).toFixed(1),
parse: (input) => {
const parsed = parseFloat(input);
- return Number.isNaN(parsed) ? null : lineHeightConverter.fromDisplay(Math.round(parsed));
+ 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 } }],
+ 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 collapsible sectionKey="text:spacing" showBottomBorder={false}>
+ <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: 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>
);
}
@@ -289,10 +308,21 @@ function BackgroundSection({
const lastSelectedColor = useRef(DEFAULT_COLOR);
const cornerRadius = usePropertyDraft({
- displayValue: Math.round(element.background.cornerRadius ?? 0).toString(),
+ displayValue: Math.round(
+ clamp({
+ value: element.background.cornerRadius ?? 0,
+ min: CORNER_RADIUS_MIN,
+ max: CORNER_RADIUS_MAX,
+ }),
+ ).toString(),
parse: (input) => {
const parsed = parseFloat(input);
- return Number.isNaN(parsed) ? null : Math.max(0, Math.round(parsed));
+ if (Number.isNaN(parsed)) return null;
+ return clamp({
+ value: Math.round(parsed),
+ min: CORNER_RADIUS_MIN,
+ max: CORNER_RADIUS_MAX,
+ });
},
onPreview: (value) =>
editor.timeline.previewElements({
@@ -310,10 +340,12 @@ function BackgroundSection({
});
const paddingX = usePropertyDraft({
- displayValue: paddingXConverter.toDisplay(element.background.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX).toString(),
+ displayValue: Math.round(
+ element.background.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX,
+ ).toString(),
parse: (input) => {
const parsed = parseFloat(input);
- return Number.isNaN(parsed) ? null : paddingXConverter.fromDisplay(Math.round(parsed));
+ return Number.isNaN(parsed) ? null : Math.max(0, Math.round(parsed));
},
onPreview: (value) =>
editor.timeline.previewElements({
@@ -329,10 +361,12 @@ function BackgroundSection({
});
const paddingY = usePropertyDraft({
- displayValue: paddingYConverter.toDisplay(element.background.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY).toString(),
+ displayValue: Math.round(
+ element.background.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY,
+ ).toString(),
parse: (input) => {
const parsed = parseFloat(input);
- return Number.isNaN(parsed) ? null : paddingYConverter.fromDisplay(Math.round(parsed));
+ return Number.isNaN(parsed) ? null : Math.max(0, Math.round(parsed));
},
onPreview: (value) =>
editor.timeline.previewElements({
@@ -385,14 +419,63 @@ function BackgroundSection({
onCommit: () => editor.timeline.commitPreview(),
});
+ 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 sectionKey="text:background">
- <SectionHeader title="Background" />
- <SectionContent>
+ <Section
+ collapsible
+ defaultOpen={element.background.enabled}
+ sectionKey="text:background"
+ >
+ <SectionHeader
+ trailing={
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={(event) => {
+ event.stopPropagation();
+ toggleBackgroundEnabled();
+ }}
+ >
+ <HugeiconsIcon
+ icon={element.background.enabled ? ViewIcon : ViewOffSlashIcon}
+ />
+ </Button>
+ }
+ >
+ <SectionTitle>Background</SectionTitle>
+ </SectionHeader>
+ <SectionContent
+ className={cn(
+ !element.background.enabled && "pointer-events-none opacity-50",
+ )}
+ >
<SectionFields>
<SectionField label="Color">
<ColorPicker
value={
+ !element.background.enabled ||
element.background.color === "transparent"
? lastSelectedColor.current.replace("#", "")
: element.background.color.replace("#", "")
@@ -415,19 +498,14 @@ function BackgroundSection({
});
}}
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}
+ <SectionField label="Width" className="w-1/2">
+ <NumberField
+ icon="W"
+ value={paddingX.displayValue}
+ min={0}
onFocus={paddingX.onFocus}
onChange={paddingX.onChange}
onBlur={paddingX.onBlur}
@@ -450,16 +528,17 @@ function BackgroundSection({
})
}
isDefault={
- (element.background.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX) ===
+ (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}
+ <SectionField label="Height" className="w-1/2">
+ <NumberField
+ icon="H"
+ value={paddingY.displayValue}
+ min={0}
onFocus={paddingY.onFocus}
onChange={paddingY.onChange}
onBlur={paddingY.onBlur}
@@ -482,17 +561,18 @@ function BackgroundSection({
})
}
isDefault={
- (element.background.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY) ===
+ (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}
+ <SectionField label="X-offset" className="w-1/2">
+ <NumberField
+ icon="X"
+ value={offsetX.displayValue}
onFocus={offsetX.onFocus}
onChange={offsetX.onChange}
onBlur={offsetX.onBlur}
@@ -505,19 +585,19 @@ function BackgroundSection({
trackId,
elementId: element.id,
updates: {
- background: { ...element.background, offsetX: 0 },
- },
+ background: { ...element.background, offsetX: DEFAULT_TEXT_BACKGROUND.offsetX },
},
- ],
- })
- }
- isDefault={(element.background.offsetX ?? 0) === 0}
+ },
+ ],
+ })
+ }
+ isDefault={(element.background.offsetX ?? DEFAULT_TEXT_BACKGROUND.offsetX) === DEFAULT_TEXT_BACKGROUND.offsetX}
/>
</SectionField>
- <SectionField label="Y-offset" className="w-1/2">
- <NumberField
- icon="Y"
- value={offsetY.displayValue}
+ <SectionField label="Y-offset" className="w-1/2">
+ <NumberField
+ icon="Y"
+ value={offsetY.displayValue}
onFocus={offsetY.onFocus}
onChange={offsetY.onChange}
onBlur={offsetY.onBlur}
@@ -530,21 +610,22 @@ function BackgroundSection({
trackId,
elementId: element.id,
updates: {
- background: { ...element.background, offsetY: 0 },
- },
+ background: { ...element.background, offsetY: DEFAULT_TEXT_BACKGROUND.offsetY },
},
- ],
- })
- }
- isDefault={(element.background.offsetY ?? 0) === 0}
+ },
+ ],
+ })
+ }
+ isDefault={(element.background.offsetY ?? DEFAULT_TEXT_BACKGROUND.offsetY) === DEFAULT_TEXT_BACKGROUND.offsetY}
/>
</SectionField>
</div>
- <SectionField label="Corner Radius">
- <NumberField
- icon="R"
- value={cornerRadius.displayValue}
- min={0}
+ <SectionField label="Corner radius">
+ <NumberField
+ icon="R"
+ value={cornerRadius.displayValue}
+ min={CORNER_RADIUS_MIN}
+ max={CORNER_RADIUS_MAX}
onFocus={cornerRadius.onFocus}
onChange={cornerRadius.onChange}
onBlur={cornerRadius.onBlur}
@@ -557,16 +638,18 @@ function BackgroundSection({
trackId,
elementId: element.id,
updates: {
- background: {
- ...element.background,
- cornerRadius: 0,
- },
+ background: {
+ ...element.background,
+ cornerRadius: CORNER_RADIUS_MIN,
+ },
},
},
],
})
}
- isDefault={(element.background.cornerRadius ?? 0) === 0}
+ isDefault={
+ (element.background.cornerRadius ?? 0) === CORNER_RADIUS_MIN
+ }
/>
</SectionField>
</SectionFields>