diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-03-02 13:13:07 +0100 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-03-02 13:13:07 +0100 |
| commit | e7dcb586c0408255a0bf788e355fdb6d50d9b923 (patch) | |
| tree | 74ff0fc0f1ba501a075342db9b4e454450926154 /apps/web/src/components/editor/panels/properties/text-properties.tsx | |
| parent | 93bea01c9ed3fcbebc64c9cef382a51d24c925ed (diff) | |
| download | opencut-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.tsx | 467 |
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> |
