diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-03-25 16:47:22 +0100 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-03-29 15:48:22 +0200 |
| commit | 8db3bead13441a5882d8689dfc6a9f09c82375d9 (patch) | |
| tree | e9d0cafa93904ffcbec3188c37961bcc16c6422d /legacy/components/properties/text-properties.tsx | |
| parent | 39ea298a9ccba3104c549c0ca079c231981811f5 (diff) | |
| download | opencut-8db3bead13441a5882d8689dfc6a9f09c82375d9.tar.gz | |
feat: masks, properties refactor, shaders, storage migrations, and more
Diffstat (limited to 'legacy/components/properties/text-properties.tsx')
| -rw-r--r-- | legacy/components/properties/text-properties.tsx | 732 |
1 files changed, 732 insertions, 0 deletions
diff --git a/legacy/components/properties/text-properties.tsx b/legacy/components/properties/text-properties.tsx new file mode 100644 index 00000000..ae6de420 --- /dev/null +++ b/legacy/components/properties/text-properties.tsx @@ -0,0 +1,732 @@ +import { Textarea } from "@/components/ui/textarea"; +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, + 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, + DEFAULT_TEXT_ELEMENT, + MAX_FONT_SIZE, + MIN_FONT_SIZE, +} from "@/constants/text-constants"; +import { usePropertyDraft } from "./hooks/use-property-draft"; +import { useKeyframedColorProperty } from "./hooks/use-keyframed-color-property"; +import { useKeyframedNumberProperty } from "./hooks/use-keyframed-number-property"; +import { useElementPlayhead } from "./hooks/use-element-playhead"; +import { TransformSection, BlendingSection } from "./sections"; +import { KeyframeToggle } from "./keyframe-toggle"; +import { isPropertyAtDefault } from "./sections/transform"; +import { resolveColorAtTime, resolveNumberAtTime } from "@/lib/animation"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { + TextFontIcon, + ViewIcon, + ViewOffSlashIcon, +} from "@hugeicons/core-free-icons"; +import { OcTextHeightIcon, OcTextWidthIcon } from "@opencut/ui/icons"; +import { cn } from "@/utils/ui"; + +export function TextProperties({ + element, + trackId, +}: { + element: TextElement; + trackId: string; +}) { + return ( + <div className="flex h-full flex-col"> + <ContentSection element={element} trackId={trackId} /> + <TransformSection element={element} trackId={trackId} /> + <BlendingSection element={element} trackId={trackId} /> + <TypographySection element={element} trackId={trackId} /> + <SpacingSection element={element} trackId={trackId} /> + <BackgroundSection element={element} trackId={trackId} /> + </div> + ); +} + +function ContentSection({ + element, + trackId, +}: { + element: TextElement; + trackId: string; +}) { + const editor = useEditor(); + + const content = usePropertyDraft({ + displayValue: element.content, + parse: (input) => input, + onPreview: (value) => + editor.timeline.previewElements({ + updates: [ + { trackId, elementId: element.id, updates: { content: value } }, + ], + }), + onCommit: () => editor.timeline.commitPreview(), + }); + + return ( + <Section collapsible sectionKey="text:content" showTopBorder={false}> + <SectionHeader> + <SectionTitle>Content</SectionTitle> + </SectionHeader> + <SectionContent> + <Textarea + placeholder="Name" + value={content.displayValue} + className="min-h-20" + onFocus={content.onFocus} + onChange={content.onChange} + onBlur={content.onBlur} + /> + </SectionContent> + </Section> + ); +} + +function TypographySection({ + element, + trackId, +}: { + element: TextElement; + trackId: string; +}) { + const editor = useEditor(); + const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ + startTime: element.startTime, + duration: element.duration, + }); + const resolvedTextColor = resolveColorAtTime({ + baseColor: element.color, + animations: element.animations, + propertyPath: "color", + localTime, + }); + + const textColor = useKeyframedColorProperty({ + trackId, + elementId: element.id, + animations: element.animations, + propertyPath: "color", + localTime, + isPlayheadWithinElementRange, + resolvedColor: resolvedTextColor, + buildBaseUpdates: ({ value }) => ({ color: value }), + }); + + const fontSize = usePropertyDraft({ + displayValue: element.fontSize.toString(), + parse: (input) => { + const parsed = parseFloat(input); + if (Number.isNaN(parsed)) return null; + return clamp({ value: parsed, min: MIN_FONT_SIZE, max: MAX_FONT_SIZE }); + }, + onPreview: (value) => + editor.timeline.previewElements({ + updates: [ + { trackId, elementId: element.id, updates: { fontSize: value } }, + ], + }), + onCommit: () => editor.timeline.commitPreview(), + }); + + return ( + <Section collapsible sectionKey="text:typography"> + <SectionHeader> + <SectionTitle>Typography</SectionTitle> + </SectionHeader> + <SectionContent> + <SectionFields> + <SectionField label="Font"> + <FontPicker + defaultValue={element.fontFamily} + onValueChange={(value) => + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { fontFamily: value }, + }, + ], + }) + } + /> + </SectionField> + <SectionField label="Size"> + <NumberField + value={fontSize.displayValue} + min={MIN_FONT_SIZE} + max={MAX_FONT_SIZE} + onFocus={fontSize.onFocus} + onChange={fontSize.onChange} + onBlur={fontSize.onBlur} + onScrub={fontSize.scrubTo} + onScrubEnd={fontSize.commitScrub} + onReset={() => + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { fontSize: DEFAULT_TEXT_ELEMENT.fontSize }, + }, + ], + }) + } + isDefault={element.fontSize === DEFAULT_TEXT_ELEMENT.fontSize} + icon={<HugeiconsIcon icon={TextFontIcon} />} + /> + </SectionField> + <SectionField + label="Color" + beforeLabel={ + <KeyframeToggle + isActive={textColor.isKeyframedAtTime} + isDisabled={!isPlayheadWithinElementRange} + title="Toggle text color keyframe" + onToggle={textColor.toggleKeyframe} + /> + } + > + <ColorPicker + value={uppercase({ + string: resolvedTextColor.replace("#", ""), + })} + onChange={(color) => textColor.onChange({ color: `#${color}` })} + onChangeEnd={textColor.onChangeEnd} + /> + </SectionField> + </SectionFields> + </SectionContent> + </Section> + ); +} + +function SpacingSection({ + element, + trackId, +}: { + element: TextElement; + trackId: string; +}) { + const editor = useEditor(); + + const letterSpacing = usePropertyDraft({ + displayValue: Math.round( + element.letterSpacing ?? 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: (element.lineHeight ?? DEFAULT_LINE_HEIGHT).toFixed(1), + parse: (input) => { + const parsed = parseFloat(input); + return Number.isNaN(parsed) + ? null + : Math.max(0.1, Math.round(parsed * 10) / 10); + }, + onPreview: (value) => + editor.timeline.previewElements({ + updates: [ + { trackId, elementId: element.id, updates: { lineHeight: value } }, + ], + }), + onCommit: () => editor.timeline.commitPreview(), + }); + + return ( + <Section collapsible sectionKey="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> + ); +} + +function BackgroundSection({ + element, + trackId, +}: { + element: TextElement; + trackId: string; +}) { + const editor = useEditor(); + const lastSelectedColor = useRef(DEFAULT_COLOR); + const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ + startTime: element.startTime, + duration: element.duration, + }); + const resolvedBgColor = resolveColorAtTime({ + baseColor: element.background.color, + animations: element.animations, + propertyPath: "background.color", + localTime, + }); + + const bgColor = useKeyframedColorProperty({ + trackId, + elementId: element.id, + animations: element.animations, + propertyPath: "background.color", + localTime, + isPlayheadWithinElementRange, + resolvedColor: resolvedBgColor, + buildBaseUpdates: ({ value }) => ({ + background: { ...element.background, color: value }, + }), + }); + + const bg = element.background; + + const resolvedPaddingX = resolveNumberAtTime({ + baseValue: bg.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX, + animations: element.animations, + propertyPath: "background.paddingX", + localTime, + }); + const resolvedPaddingY = resolveNumberAtTime({ + baseValue: bg.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY, + animations: element.animations, + propertyPath: "background.paddingY", + localTime, + }); + const resolvedOffsetX = resolveNumberAtTime({ + baseValue: bg.offsetX ?? DEFAULT_TEXT_BACKGROUND.offsetX, + animations: element.animations, + propertyPath: "background.offsetX", + localTime, + }); + const resolvedOffsetY = resolveNumberAtTime({ + baseValue: bg.offsetY ?? DEFAULT_TEXT_BACKGROUND.offsetY, + animations: element.animations, + propertyPath: "background.offsetY", + localTime, + }); + const resolvedCornerRadius = resolveNumberAtTime({ + baseValue: bg.cornerRadius ?? CORNER_RADIUS_MIN, + animations: element.animations, + propertyPath: "background.cornerRadius", + localTime, + }); + + const paddingX = useKeyframedNumberProperty({ + trackId, + elementId: element.id, + animations: element.animations, + propertyPath: "background.paddingX", + localTime, + isPlayheadWithinElementRange, + displayValue: Math.round(resolvedPaddingX).toString(), + parse: (input) => { + const parsed = parseFloat(input); + return Number.isNaN(parsed) ? null : Math.max(0, Math.round(parsed)); + }, + valueAtPlayhead: resolvedPaddingX, + buildBaseUpdates: ({ value }) => ({ + background: { ...bg, paddingX: value }, + }), + }); + + const paddingY = useKeyframedNumberProperty({ + trackId, + elementId: element.id, + animations: element.animations, + propertyPath: "background.paddingY", + localTime, + isPlayheadWithinElementRange, + displayValue: Math.round(resolvedPaddingY).toString(), + parse: (input) => { + const parsed = parseFloat(input); + return Number.isNaN(parsed) ? null : Math.max(0, Math.round(parsed)); + }, + valueAtPlayhead: resolvedPaddingY, + buildBaseUpdates: ({ value }) => ({ + background: { ...bg, paddingY: value }, + }), + }); + + const offsetX = useKeyframedNumberProperty({ + trackId, + elementId: element.id, + animations: element.animations, + propertyPath: "background.offsetX", + localTime, + isPlayheadWithinElementRange, + displayValue: Math.round(resolvedOffsetX).toString(), + parse: (input) => { + const parsed = parseFloat(input); + return Number.isNaN(parsed) ? null : Math.round(parsed); + }, + valueAtPlayhead: resolvedOffsetX, + buildBaseUpdates: ({ value }) => ({ + background: { ...bg, offsetX: value }, + }), + }); + + const offsetY = useKeyframedNumberProperty({ + trackId, + elementId: element.id, + animations: element.animations, + propertyPath: "background.offsetY", + localTime, + isPlayheadWithinElementRange, + displayValue: Math.round(resolvedOffsetY).toString(), + parse: (input) => { + const parsed = parseFloat(input); + return Number.isNaN(parsed) ? null : Math.round(parsed); + }, + valueAtPlayhead: resolvedOffsetY, + buildBaseUpdates: ({ value }) => ({ + background: { ...bg, offsetY: value }, + }), + }); + + const cornerRadius = useKeyframedNumberProperty({ + trackId, + elementId: element.id, + animations: element.animations, + propertyPath: "background.cornerRadius", + localTime, + isPlayheadWithinElementRange, + displayValue: Math.round(resolvedCornerRadius).toString(), + parse: (input) => { + const parsed = parseFloat(input); + if (Number.isNaN(parsed)) return null; + return clamp({ value: Math.round(parsed), min: CORNER_RADIUS_MIN, max: CORNER_RADIUS_MAX }); + }, + valueAtPlayhead: resolvedCornerRadius, + buildBaseUpdates: ({ value }) => ({ + background: { ...bg, cornerRadius: value }, + }), + }); + + const toggleBackgroundEnabled = () => { + const enabled = !element.background.enabled; + const color = + enabled && element.background.color === "transparent" + ? lastSelectedColor.current + : element.background.color; + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { + background: { + ...element.background, + enabled, + color, + }, + }, + }, + ], + }); + }; + + return ( + <Section + collapsible + defaultOpen={element.background.enabled} + sectionKey="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" + beforeLabel={ + <KeyframeToggle + isActive={bgColor.isKeyframedAtTime} + isDisabled={!isPlayheadWithinElementRange} + title="Toggle background color keyframe" + onToggle={bgColor.toggleKeyframe} + /> + } + > + <ColorPicker + value={ + !element.background.enabled || + element.background.color === "transparent" + ? lastSelectedColor.current.replace("#", "") + : resolvedBgColor.replace("#", "") + } + onChange={(color) => { + const hexColor = `#${color}`; + if (color !== "transparent") { + lastSelectedColor.current = hexColor; + } + bgColor.onChange({ color: hexColor }); + }} + onChangeEnd={bgColor.onChangeEnd} + /> + </SectionField> + <div className="flex items-start gap-2"> + <SectionField + label="Width" + className="w-1/2" + beforeLabel={ + <KeyframeToggle + isActive={paddingX.isKeyframedAtTime} + isDisabled={!isPlayheadWithinElementRange} + title="Toggle background width keyframe" + onToggle={paddingX.toggleKeyframe} + /> + } + > + <NumberField + icon="W" + value={paddingX.displayValue} + min={0} + onFocus={paddingX.onFocus} + onChange={paddingX.onChange} + onBlur={paddingX.onBlur} + onScrub={paddingX.scrubTo} + onScrubEnd={paddingX.commitScrub} + onReset={() => paddingX.commitValue({ value: DEFAULT_TEXT_BACKGROUND.paddingX })} + isDefault={isPropertyAtDefault({ + hasAnimatedKeyframes: paddingX.hasAnimatedKeyframes, + isPlayheadWithinElementRange, + resolvedValue: resolvedPaddingX, + staticValue: bg.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX, + defaultValue: DEFAULT_TEXT_BACKGROUND.paddingX, + })} + /> + </SectionField> + <SectionField + label="Height" + className="w-1/2" + beforeLabel={ + <KeyframeToggle + isActive={paddingY.isKeyframedAtTime} + isDisabled={!isPlayheadWithinElementRange} + title="Toggle background height keyframe" + onToggle={paddingY.toggleKeyframe} + /> + } + > + <NumberField + icon="H" + value={paddingY.displayValue} + min={0} + onFocus={paddingY.onFocus} + onChange={paddingY.onChange} + onBlur={paddingY.onBlur} + onScrub={paddingY.scrubTo} + onScrubEnd={paddingY.commitScrub} + onReset={() => paddingY.commitValue({ value: DEFAULT_TEXT_BACKGROUND.paddingY })} + isDefault={isPropertyAtDefault({ + hasAnimatedKeyframes: paddingY.hasAnimatedKeyframes, + isPlayheadWithinElementRange, + resolvedValue: resolvedPaddingY, + staticValue: bg.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY, + defaultValue: DEFAULT_TEXT_BACKGROUND.paddingY, + })} + /> + </SectionField> + </div> + <div className="flex items-start gap-2"> + <SectionField + label="X-offset" + className="w-1/2" + beforeLabel={ + <KeyframeToggle + isActive={offsetX.isKeyframedAtTime} + isDisabled={!isPlayheadWithinElementRange} + title="Toggle x-offset keyframe" + onToggle={offsetX.toggleKeyframe} + /> + } + > + <NumberField + icon="X" + value={offsetX.displayValue} + onFocus={offsetX.onFocus} + onChange={offsetX.onChange} + onBlur={offsetX.onBlur} + onScrub={offsetX.scrubTo} + onScrubEnd={offsetX.commitScrub} + onReset={() => offsetX.commitValue({ value: DEFAULT_TEXT_BACKGROUND.offsetX })} + isDefault={isPropertyAtDefault({ + hasAnimatedKeyframes: offsetX.hasAnimatedKeyframes, + isPlayheadWithinElementRange, + resolvedValue: resolvedOffsetX, + staticValue: bg.offsetX ?? DEFAULT_TEXT_BACKGROUND.offsetX, + defaultValue: DEFAULT_TEXT_BACKGROUND.offsetX, + })} + /> + </SectionField> + <SectionField + label="Y-offset" + className="w-1/2" + beforeLabel={ + <KeyframeToggle + isActive={offsetY.isKeyframedAtTime} + isDisabled={!isPlayheadWithinElementRange} + title="Toggle y-offset keyframe" + onToggle={offsetY.toggleKeyframe} + /> + } + > + <NumberField + icon="Y" + value={offsetY.displayValue} + onFocus={offsetY.onFocus} + onChange={offsetY.onChange} + onBlur={offsetY.onBlur} + onScrub={offsetY.scrubTo} + onScrubEnd={offsetY.commitScrub} + onReset={() => offsetY.commitValue({ value: DEFAULT_TEXT_BACKGROUND.offsetY })} + isDefault={isPropertyAtDefault({ + hasAnimatedKeyframes: offsetY.hasAnimatedKeyframes, + isPlayheadWithinElementRange, + resolvedValue: resolvedOffsetY, + staticValue: bg.offsetY ?? DEFAULT_TEXT_BACKGROUND.offsetY, + defaultValue: DEFAULT_TEXT_BACKGROUND.offsetY, + })} + /> + </SectionField> + </div> + <SectionField + label="Corner radius" + beforeLabel={ + <KeyframeToggle + isActive={cornerRadius.isKeyframedAtTime} + isDisabled={!isPlayheadWithinElementRange} + title="Toggle corner radius keyframe" + onToggle={cornerRadius.toggleKeyframe} + /> + } + > + <NumberField + icon="R" + value={cornerRadius.displayValue} + min={CORNER_RADIUS_MIN} + max={CORNER_RADIUS_MAX} + onFocus={cornerRadius.onFocus} + onChange={cornerRadius.onChange} + onBlur={cornerRadius.onBlur} + onScrub={cornerRadius.scrubTo} + onScrubEnd={cornerRadius.commitScrub} + onReset={() => cornerRadius.commitValue({ value: CORNER_RADIUS_MIN })} + isDefault={isPropertyAtDefault({ + hasAnimatedKeyframes: cornerRadius.hasAnimatedKeyframes, + isPlayheadWithinElementRange, + resolvedValue: resolvedCornerRadius, + staticValue: bg.cornerRadius ?? CORNER_RADIUS_MIN, + defaultValue: CORNER_RADIUS_MIN, + })} + /> + </SectionField> + </SectionFields> + </SectionContent> + </Section> + ); +} |
