fix a ton of issues + improvement

This commit is contained in:
Maze Winther
2026-02-25 01:37:56 +01:00
parent 4d77e3f2bb
commit f75b5d3fd2
20 changed files with 1134 additions and 452 deletions
@@ -99,9 +99,9 @@ export function TextEditOverlay({
const fontStyle = element.fontStyle === "italic" ? "italic" : "normal"; const fontStyle = element.fontStyle === "italic" ? "italic" : "normal";
const letterSpacing = element.letterSpacing ?? 0; const letterSpacing = element.letterSpacing ?? 0;
const backgroundColor = const backgroundColor =
element.backgroundColor === "transparent" element.background.color === "transparent"
? "transparent" ? "transparent"
: element.backgroundColor; : element.background.color;
return ( return (
<div <div
@@ -19,7 +19,7 @@ export function PropertiesPanel() {
return ( return (
<div className="panel bg-background h-full rounded-sm border overflow-hidden"> <div className="panel bg-background h-full rounded-sm border overflow-hidden">
{selectedElements.length > 0 ? ( {selectedElements.length > 0 ? (
<ScrollArea className="h-full"> <ScrollArea className="h-full scrollbar-hidden">
{elementsWithTracks.map(({ track, element }) => { {elementsWithTracks.map(({ track, element }) => {
if (element.type === "text") { if (element.type === "text") {
return ( return (
@@ -2,6 +2,7 @@ import { createContext, useContext, useState } from "react";
import { cn } from "@/utils/ui"; import { cn } from "@/utils/ui";
import { HugeiconsIcon } from "@hugeicons/react"; import { HugeiconsIcon } from "@hugeicons/react";
import { ArrowDownIcon } from "@hugeicons/core-free-icons"; import { ArrowDownIcon } from "@hugeicons/core-free-icons";
import { Label } from "@/components/ui/label";
const sectionExpandedCache = new Map<string, boolean>(); const sectionExpandedCache = new Map<string, boolean>();
@@ -131,6 +132,33 @@ export function SectionHeader({
return <div className={baseClassName}>{content}</div>; 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({ export function SectionContent({
children, children,
className, className,
@@ -8,7 +8,7 @@ import {
} from "@/constants/timeline-constants"; } from "@/constants/timeline-constants";
import { OcCheckerboardIcon } from "@opencut/ui/icons"; import { OcCheckerboardIcon } from "@opencut/ui/icons";
import { Fragment, useRef } from "react"; import { Fragment, useRef } from "react";
import { Section, SectionContent, SectionHeader } from "../section"; import { Section, SectionContent, SectionField, SectionHeader } from "../section";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -124,73 +124,73 @@ export function BlendingSection({
return ( return (
<Section collapsible sectionKey={`${element.type}:blending`}> <Section collapsible sectionKey={`${element.type}:blending`}>
<SectionHeader title="Blending" /> <SectionHeader title="Blending" />
<SectionContent> <SectionContent>
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<div className="w-1/2 space-y-1.5"> <SectionField label="Opacity" className="w-1/2">
<NumberField <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" 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 <SelectValue placeholder="Select blend mode" />
icon={<HugeiconsIcon icon={RainDropIcon} />} </SelectTrigger>
className="w-full" <SelectContent className="w-36">
> {BLEND_MODE_GROUPS.map((group, groupIndex) => (
<SelectValue placeholder="Select blend mode" /> <Fragment key={group[0]?.value ?? `group-${groupIndex}`}>
</SelectTrigger> {group.map((option) => (
<SelectContent className="w-36"> <SelectItem
{BLEND_MODE_GROUPS.map((group, groupIndex) => ( key={option.value}
<Fragment key={group[0]?.value ?? `group-${groupIndex}`}> value={option.value}
{group.map((option) => ( onPointerEnter={() =>
<SelectItem previewBlendMode(option.value as BlendMode)
key={option.value} }
value={option.value} >
onPointerEnter={() => {option.label}
previewBlendMode(option.value as BlendMode) </SelectItem>
} ))}
> {groupIndex < BLEND_MODE_GROUPS.length - 1 ? (
{option.label} <SelectSeparator />
</SelectItem> ) : null}
))} </Fragment>
{groupIndex < BLEND_MODE_GROUPS.length - 1 ? ( ))}
<SelectSeparator /> </SelectContent>
) : null} </Select>
</Fragment> </SectionField>
))} </div>
</SelectContent> </SectionContent>
</Select>
</div>
</div>
</SectionContent>
</Section> </Section>
); );
} }
@@ -3,7 +3,7 @@ import { useEditor } from "@/hooks/use-editor";
import { usePropertyDraft } from "../hooks/use-property-draft"; import { usePropertyDraft } from "../hooks/use-property-draft";
import { clamp } from "@/utils/math"; import { clamp } from "@/utils/math";
import type { ElementType, Transform } from "@/types/timeline"; 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 { Button } from "@/components/ui/button";
import { HugeiconsIcon } from "@hugeicons/react"; import { HugeiconsIcon } from "@hugeicons/react";
import { import {
@@ -120,8 +120,9 @@ export function TransformSection({
return ( return (
<Section collapsible sectionKey={`${element.type}:transform`}> <Section collapsible sectionKey={`${element.type}:transform`}>
<SectionHeader title="Transform" /> <SectionHeader title="Transform" />
<SectionContent> <SectionContent>
<div className="flex flex-col gap-2"> <SectionFields>
<SectionField label="Scale">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{isScaleLocked ? ( {isScaleLocked ? (
<> <>
@@ -144,105 +145,108 @@ export function TransformSection({
<HugeiconsIcon icon={Link05Icon} /> <HugeiconsIcon icon={Link05Icon} />
</Button> </Button>
</div> </div>
</SectionField>
<SectionField label="Position">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<NumberField <NumberField
icon="X" icon="X"
className="flex-1" className="flex-1"
value={positionX.displayValue} value={positionX.displayValue}
onFocus={positionX.onFocus} onFocus={positionX.onFocus}
onChange={positionX.onChange} onChange={positionX.onChange}
onBlur={positionX.onBlur} onBlur={positionX.onBlur}
onScrub={positionX.scrubTo} onScrub={positionX.scrubTo}
onScrubEnd={positionX.commitScrub} onScrubEnd={positionX.commitScrub}
onReset={() => onReset={() =>
editor.timeline.updateElements({ editor.timeline.updateElements({
updates: [ updates: [
{ {
trackId, trackId,
elementId: element.id, elementId: element.id,
updates: { updates: {
transform: { transform: {
...element.transform, ...element.transform,
position: { position: {
...element.transform.position, ...element.transform.position,
x: DEFAULT_TRANSFORM.position.x, x: DEFAULT_TRANSFORM.position.x,
},
}, },
}, },
}, },
}, ],
], })
}) }
} isDefault={
isDefault={ element.transform.position.x === DEFAULT_TRANSFORM.position.x
element.transform.position.x === DEFAULT_TRANSFORM.position.x }
} />
/> <NumberField
<NumberField icon="Y"
icon="Y" className="flex-1"
className="flex-1" value={positionY.displayValue}
value={positionY.displayValue} onFocus={positionY.onFocus}
onFocus={positionY.onFocus} onChange={positionY.onChange}
onChange={positionY.onChange} onBlur={positionY.onBlur}
onBlur={positionY.onBlur} onScrub={positionY.scrubTo}
onScrub={positionY.scrubTo} onScrubEnd={positionY.commitScrub}
onScrubEnd={positionY.commitScrub} onReset={() =>
onReset={() => editor.timeline.updateElements({
editor.timeline.updateElements({ updates: [
updates: [ {
{ trackId,
trackId, elementId: element.id,
elementId: element.id, updates: {
updates: { transform: {
transform: { ...element.transform,
...element.transform, position: {
position: { ...element.transform.position,
...element.transform.position, y: DEFAULT_TRANSFORM.position.y,
y: DEFAULT_TRANSFORM.position.y, },
}, },
}, },
}, },
}, ],
], })
}) }
} isDefault={
isDefault={ element.transform.position.y === DEFAULT_TRANSFORM.position.y
element.transform.position.y === DEFAULT_TRANSFORM.position.y }
} />
/>
</div> </div>
</SectionField>
<div className="flex items-center gap-2"> <SectionField label="Rotation">
<NumberField <NumberField
icon={<HugeiconsIcon icon={RotateClockwiseIcon} />} icon={<HugeiconsIcon icon={RotateClockwiseIcon} />}
className="flex-1" className="flex-none"
value={rotation.displayValue} value={rotation.displayValue}
onFocus={rotation.onFocus} onFocus={rotation.onFocus}
onChange={rotation.onChange} onChange={rotation.onChange}
onBlur={rotation.onBlur} onBlur={rotation.onBlur}
dragSensitivity="slow" dragSensitivity="slow"
onScrub={rotation.scrubTo} onScrub={rotation.scrubTo}
onScrubEnd={rotation.commitScrub} onScrubEnd={rotation.commitScrub}
onReset={() => onReset={() =>
editor.timeline.updateElements({ editor.timeline.updateElements({
updates: [ updates: [
{ {
trackId, trackId,
elementId: element.id, elementId: element.id,
updates: { updates: {
transform: { transform: {
...element.transform, ...element.transform,
rotate: DEFAULT_TRANSFORM.rotate, rotate: DEFAULT_TRANSFORM.rotate,
},
}, },
}, },
], },
}) ],
} })
isDefault={element.transform.rotate === DEFAULT_TRANSFORM.rotate} }
/> isDefault={element.transform.rotate === DEFAULT_TRANSFORM.rotate}
</div> />
</div> </SectionField>
</SectionContent> </SectionFields>
</SectionContent>
</Section> </Section>
); );
} }
@@ -1,31 +1,49 @@
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { FontPicker } from "@/components/ui/font-picker"; import { FontPicker } from "@/components/ui/font-picker";
import type { TextElement } from "@/types/timeline"; import type { TextElement } from "@/types/timeline";
import { Slider } from "@/components/ui/slider";
import { NumberField } from "@/components/ui/number-field"; import { NumberField } from "@/components/ui/number-field";
import { useRef } from "react"; 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 { ColorPicker } from "@/components/ui/color-picker";
import { uppercase } from "@/utils/string"; import { uppercase } from "@/utils/string";
import { clamp } from "@/utils/math"; import { clamp } from "@/utils/math";
import { useEditor } from "@/hooks/use-editor"; import { useEditor } from "@/hooks/use-editor";
import { DEFAULT_COLOR } from "@/constants/project-constants"; import { DEFAULT_COLOR } from "@/constants/project-constants";
import { import {
DEFAULT_LETTER_SPACING,
DEFAULT_LINE_HEIGHT,
DEFAULT_TEXT_BACKGROUND,
DEFAULT_TEXT_ELEMENT, DEFAULT_TEXT_ELEMENT,
MAX_FONT_SIZE, MAX_FONT_SIZE,
MIN_FONT_SIZE, MIN_FONT_SIZE,
} from "@/constants/text-constants"; } from "@/constants/text-constants";
import { usePropertyDraft } from "./hooks/use-property-draft"; import { usePropertyDraft } from "./hooks/use-property-draft";
import { TransformSection, BlendingSection } from "./sections"; import { TransformSection, BlendingSection } from "./sections";
import { import { HugeiconsIcon } from "@hugeicons/react";
Select, import { TextFontIcon } from "@hugeicons/core-free-icons";
SelectTrigger, import { OcTextHeightIcon, OcTextWidthIcon } from "@opencut/ui/icons";
SelectValue,
SelectContent, function createOffsetConverter({
SelectItem, defaultValue,
} from "@/components/ui/select"; scale = 1,
import { OcFontWeightIcon } from "@opencut/ui/icons"; min,
import { Label } from "@/components/ui/label"; }: {
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({ export function TextProperties({
element, element,
@@ -39,8 +57,9 @@ export function TextProperties({
<ContentSection element={element} trackId={trackId} /> <ContentSection element={element} trackId={trackId} />
<TransformSection element={element} trackId={trackId} /> <TransformSection element={element} trackId={trackId} />
<BlendingSection element={element} trackId={trackId} /> <BlendingSection element={element} trackId={trackId} />
<FontSection element={element} trackId={trackId} /> <TypographySection element={element} trackId={trackId} />
<ColorSection element={element} trackId={trackId} /> <SpacingSection element={element} trackId={trackId} />
<BackgroundSection element={element} trackId={trackId} />
</div> </div>
); );
} }
@@ -83,7 +102,7 @@ function ContentSection({
); );
} }
function FontSection({ function TypographySection({
element, element,
trackId, trackId,
}: { }: {
@@ -109,10 +128,11 @@ function FontSection({
}); });
return ( return (
<Section collapsible sectionKey="text:font"> <Section collapsible sectionKey="text:typography">
<SectionHeader title="Font" /> <SectionHeader title="Typography" />
<SectionContent> <SectionContent>
<div className="flex flex-col gap-2"> <SectionFields>
<SectionField label="Font">
<FontPicker <FontPicker
defaultValue={element.fontFamily} defaultValue={element.fontFamily}
onValueChange={(value) => onValueChange={(value) =>
@@ -127,89 +147,33 @@ function FontSection({
}) })
} }
/> />
<Select value={element.fontWeight}> </SectionField>
<SelectTrigger className="w-full" icon={<OcFontWeightIcon />}> <SectionField label="Size">
<SelectValue placeholder="Select weight" /> <NumberField
</SelectTrigger> value={fontSize.displayValue}
<SelectContent> min={MIN_FONT_SIZE}
<SelectItem value="100">Thin</SelectItem> max={MAX_FONT_SIZE}
<SelectItem value="200">Extra Light</SelectItem> onFocus={fontSize.onFocus}
<SelectItem value="300">Light</SelectItem> onChange={fontSize.onChange}
<SelectItem value="400">Normal</SelectItem> onBlur={fontSize.onBlur}
<SelectItem value="500">Medium</SelectItem> onScrub={fontSize.scrubTo}
<SelectItem value="600">Semi Bold</SelectItem> onScrubEnd={fontSize.commitScrub}
<SelectItem value="700">Bold</SelectItem> onReset={() =>
<SelectItem value="800">Extra Bold</SelectItem> editor.timeline.updateElements({
<SelectItem value="900">Black</SelectItem> updates: [
</SelectContent> {
</Select> trackId,
elementId: element.id,
<Label>Font size</Label> updates: { fontSize: DEFAULT_TEXT_ELEMENT.fontSize },
<div className="flex items-center gap-2"> },
<Slider ],
value={[element.fontSize]} })
min={MIN_FONT_SIZE} }
max={MAX_FONT_SIZE} isDefault={element.fontSize === DEFAULT_TEXT_ELEMENT.fontSize}
step={1} icon={<HugeiconsIcon icon={TextFontIcon} />}
onValueChange={([value]) => />
editor.timeline.previewElements({ </SectionField>
updates: [ <SectionField label="Color">
{
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">
<ColorPicker <ColorPicker
value={uppercase({ value={uppercase({
string: (element.color || "FFFFFF").replace("#", ""), string: (element.color || "FFFFFF").replace("#", ""),
@@ -227,35 +191,385 @@ function ColorSection({
} }
onChangeEnd={() => editor.timeline.commitPreview()} onChangeEnd={() => editor.timeline.commitPreview()}
/> />
<ColorPicker </SectionField>
value={ </SectionFields>
element.backgroundColor === "transparent" </SectionContent>
? lastSelectedColor.current.replace("#", "") </Section>
: element.backgroundColor.replace("#", "") );
} }
onChange={(color) => {
const hexColor = `#${color}`; function SpacingSection({
if (color !== "transparent") { element,
lastSelectedColor.current = hexColor; trackId,
} }: {
editor.timeline.previewElements({ element: TextElement;
updates: [ trackId: string;
{ }) {
trackId, const editor = useEditor();
elementId: element.id,
updates: { backgroundColor: hexColor }, 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);
onChangeEnd={() => editor.timeline.commitPreview()} },
className={ onPreview: (value) =>
element.backgroundColor === "transparent" editor.timeline.previewElements({
? "pointer-events-none opacity-50" 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> </SectionContent>
</Section> </Section>
); );
+14 -6
View File
@@ -280,13 +280,21 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
)} )}
{...props} {...props}
> >
<PopoverTrigger asChild> <PopoverTrigger asChild>
<button <button
className="size-4.5 cursor-pointer border rounded-sm hover:ring-1 hover:ring-foreground/20" className="size-4.5 cursor-pointer border rounded-sm hover:ring-1 hover:ring-foreground/20 overflow-hidden relative"
style={{ backgroundColor: `#${value}` }} type="button"
type="button" >
<span
className="absolute inset-0"
style={checkerboardStyle}
/> />
</PopoverTrigger> <span
className="absolute inset-0"
style={{ backgroundColor: `#${value}` }}
/>
</button>
</PopoverTrigger>
<div className="flex flex-1 items-center"> <div className="flex flex-1 items-center">
<Input <Input
className={cn( className={cn(
+2 -2
View File
@@ -26,7 +26,7 @@ import type { FontAtlas, FontAtlasEntry } from "@/types/fonts";
import { cn } from "@/utils/ui"; import { cn } from "@/utils/ui";
import { ChevronDown, Search, Upload } from "lucide-react"; import { ChevronDown, Search, Upload } from "lucide-react";
import { HugeiconsIcon } from "@hugeicons/react"; import { HugeiconsIcon } from "@hugeicons/react";
import { TextFontIcon } from "@hugeicons/core-free-icons"; import { TextIcon } from "@hugeicons/core-free-icons";
const FONT_TABS = [ const FONT_TABS = [
{ key: "all", label: "All fonts" }, { key: "all", label: "All fonts" },
@@ -139,7 +139,7 @@ export function FontPicker({
> >
<div className="flex min-w-0 items-center gap-1.5"> <div className="flex min-w-0 items-center gap-1.5">
<span className="text-muted-foreground [&_svg]:size-3.5 shrink-0"> <span className="text-muted-foreground [&_svg]:size-3.5 shrink-0">
<HugeiconsIcon icon={TextFontIcon} /> <HugeiconsIcon icon={TextIcon} />
</span> </span>
<span className="truncate" style={{ fontFamily: defaultValue }}> <span className="truncate" style={{ fontFamily: defaultValue }}>
{defaultValue ?? "Select a font"} {defaultValue ?? "Select a font"}
+5 -2
View File
@@ -71,9 +71,12 @@ function NumberField({
return; return;
} }
cumulativeDeltaRef.current += moveEvent.movementX; cumulativeDeltaRef.current += moveEvent.movementX;
const sensitivity =
typeof dragSensitivity === "number"
? dragSensitivity
: DRAG_SENSITIVITIES[dragSensitivity];
const newValue = const newValue =
startValueRef.current + startValueRef.current + cumulativeDeltaRef.current * sensitivity;
cumulativeDeltaRef.current * DRAG_SENSITIVITIES[dragSensitivity];
onScrub(newValue); onScrub(newValue);
}; };
+10 -1
View File
@@ -17,6 +17,15 @@ export const FONT_SIZE_SCALE_REFERENCE = 90;
export const DEFAULT_LETTER_SPACING = 0; export const DEFAULT_LETTER_SPACING = 0;
export const DEFAULT_LINE_HEIGHT = 1.2; export const DEFAULT_LINE_HEIGHT = 1.2;
export const DEFAULT_TEXT_BACKGROUND = {
color: "#000000",
cornerRadius: 0,
paddingX: 30,
paddingY: 42,
offsetX: 0,
offsetY: 0,
};
export const DEFAULT_TEXT_ELEMENT: Omit<TextElement, "id"> = { export const DEFAULT_TEXT_ELEMENT: Omit<TextElement, "id"> = {
type: "text", type: "text",
name: "Text", name: "Text",
@@ -24,7 +33,7 @@ export const DEFAULT_TEXT_ELEMENT: Omit<TextElement, "id"> = {
fontSize: 15, fontSize: 15,
fontFamily: "Arial", fontFamily: "Arial",
color: "#ffffff", color: "#ffffff",
backgroundColor: "#000000", background: DEFAULT_TEXT_BACKGROUND,
textAlign: "center", textAlign: "center",
fontWeight: "normal", fontWeight: "normal",
fontStyle: "normal", fontStyle: "normal",
@@ -47,7 +47,13 @@ export function useTimelinePlayhead({
isScrubbing && scrubTime !== null ? scrubTime : currentTime; isScrubbing && scrubTime !== null ? scrubTime : currentTime;
const handleScrub = useCallback( const handleScrub = useCallback(
({ event }: { event: MouseEvent | React.MouseEvent }) => { ({
event,
snappingEnabled = true,
}: {
event: MouseEvent | React.MouseEvent;
snappingEnabled?: boolean;
}) => {
const ruler = rulerRef.current; const ruler = rulerRef.current;
if (!ruler) return; if (!ruler) return;
const rulerRect = ruler.getBoundingClientRect(); const rulerRect = ruler.getBoundingClientRect();
@@ -76,7 +82,7 @@ export function useTimelinePlayhead({
fps: framesPerSecond, fps: framesPerSecond,
}); });
const shouldSnap = !isShiftHeldRef.current; const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
const time = (() => { const time = (() => {
if (!shouldSnap) return frameTime; if (!shouldSnap) return frameTime;
const tracks = editor.timeline.getTracks(); const tracks = editor.timeline.getTracks();
@@ -133,10 +139,10 @@ export function useTimelinePlayhead({
setIsDraggingRuler(true); setIsDraggingRuler(true);
setHasDraggedRuler(false); setHasDraggedRuler(false);
editor.playback.setScrubbing({ isScrubbing: true }); editor.playback.setScrubbing({ isScrubbing: true });
handleScrub({ event }); handleScrub({ event, snappingEnabled: false });
}, },
[handleScrub, playheadRef, editor.playback], [handleScrub, playheadRef, editor.playback],
); );
const handlePlayheadMouseDownEvent = useCallback( const handlePlayheadMouseDownEvent = useCallback(
@@ -184,7 +190,7 @@ export function useTimelinePlayhead({
if (isDraggingRuler) { if (isDraggingRuler) {
setIsDraggingRuler(false); setIsDraggingRuler(false);
if (!hasDraggedRuler) { if (!hasDraggedRuler) {
handleScrub({ event }); handleScrub({ event, snappingEnabled: false });
} }
setHasDraggedRuler(false); setHasDraggedRuler(false);
} }
+32 -21
View File
@@ -2,9 +2,11 @@ import type { TimelineTrack, TimelineElement } from "@/types/timeline";
import type { MediaAsset } from "@/types/assets"; import type { MediaAsset } from "@/types/assets";
import { isMainTrack } from "@/lib/timeline"; import { isMainTrack } from "@/lib/timeline";
import { import {
DEFAULT_TEXT_ELEMENT,
DEFAULT_LINE_HEIGHT, DEFAULT_LINE_HEIGHT,
FONT_SIZE_SCALE_REFERENCE, FONT_SIZE_SCALE_REFERENCE,
} from "@/constants/text-constants"; } from "@/constants/text-constants";
import { getTextVisualRect, measureTextBlock } from "@/lib/text/layout";
export interface ElementBounds { export interface ElementBounds {
cx: number; cx: number;
@@ -121,27 +123,36 @@ export function getElementBounds({
const lines = element.content.split("\n"); const lines = element.content.split("\n");
const lineMetrics = lines.map((line) => ctx.measureText(line)); const lineMetrics = lines.map((line) => ctx.measureText(line));
const block = measureTextBlock({
let top = Number.POSITIVE_INFINITY; lineMetrics,
let bottom = Number.NEGATIVE_INFINITY; lineHeightPx,
let maxWidth = 0; fallbackFontSize: scaledFontSize,
});
for (let i = 0; i < lineMetrics.length; i++) { const fontSizeRatio = element.fontSize / DEFAULT_TEXT_ELEMENT.fontSize;
const metrics = lineMetrics[i]; const visualRect = getTextVisualRect({
const y = i * lineHeightPx; textAlign: element.textAlign,
top = Math.min( block,
top, background: element.background,
y - (metrics.actualBoundingBoxAscent ?? scaledFontSize * 0.8), fontSizeRatio,
); });
bottom = Math.max( measuredWidth = visualRect.width;
bottom, measuredHeight = visualRect.height;
y + (metrics.actualBoundingBoxDescent ?? scaledFontSize * 0.2), const localCenterX = visualRect.left + visualRect.width / 2;
); const localCenterY = visualRect.top + visualRect.height / 2;
maxWidth = Math.max(maxWidth, metrics.width); const scaledCenterX = localCenterX * element.transform.scale;
} const scaledCenterY = localCenterY * element.transform.scale;
const rotationRad = (element.transform.rotate * Math.PI) / 180;
measuredWidth = maxWidth; const cos = Math.cos(rotationRad);
measuredHeight = bottom - top; const sin = Math.sin(rotationRad);
const rotatedCenterX = scaledCenterX * cos - scaledCenterY * sin;
const rotatedCenterY = scaledCenterX * sin + scaledCenterY * cos;
return {
cx: canvasWidth / 2 + element.transform.position.x + rotatedCenterX,
cy: canvasHeight / 2 + element.transform.position.y + rotatedCenterY,
width: measuredWidth * element.transform.scale,
height: measuredHeight * element.transform.scale,
rotation: element.transform.rotate,
};
} }
const width = measuredWidth * element.transform.scale; const width = measuredWidth * element.transform.scale;
+167
View File
@@ -0,0 +1,167 @@
import { DEFAULT_TEXT_BACKGROUND } from "@/constants/text-constants";
import type { TextElement } from "@/types/timeline";
type TextRect = {
left: number;
top: number;
width: number;
height: number;
};
export interface TextBlockMeasurement {
visualCenterOffset: number;
height: number;
maxWidth: number;
}
export function getMetricAscent({
metrics,
fallbackFontSize,
}: {
metrics: TextMetrics;
fallbackFontSize: number;
}): number {
return metrics.actualBoundingBoxAscent ?? fallbackFontSize * 0.8;
}
export function getMetricDescent({
metrics,
fallbackFontSize,
}: {
metrics: TextMetrics;
fallbackFontSize: number;
}): number {
return metrics.actualBoundingBoxDescent ?? fallbackFontSize * 0.2;
}
export function measureTextBlock({
lineMetrics,
lineHeightPx,
fallbackFontSize,
}: {
lineMetrics: TextMetrics[];
lineHeightPx: number;
fallbackFontSize: number;
}): TextBlockMeasurement {
let top = Number.POSITIVE_INFINITY;
let bottom = Number.NEGATIVE_INFINITY;
let maxWidth = 0;
for (let index = 0; index < lineMetrics.length; index++) {
const metrics = lineMetrics[index];
const lineY = index * lineHeightPx;
top = Math.min(
top,
lineY - getMetricAscent({ metrics, fallbackFontSize }),
);
bottom = Math.max(
bottom,
lineY + getMetricDescent({ metrics, fallbackFontSize }),
);
maxWidth = Math.max(maxWidth, metrics.width);
}
const height = bottom - top;
const visualCenterOffset = (top + bottom) / 2;
return { visualCenterOffset, height, maxWidth };
}
function getTextRect({
textAlign,
block,
}: {
textAlign: TextElement["textAlign"];
block: TextBlockMeasurement;
}): TextRect {
const left =
textAlign === "left" ? 0 : textAlign === "right" ? -block.maxWidth : -block.maxWidth / 2;
return {
left,
top: -block.height / 2,
width: block.maxWidth,
height: block.height,
};
}
function isTextBackgroundVisible({
background,
}: {
background: TextElement["background"];
}): boolean {
return Boolean(background.color) && background.color !== "transparent";
}
export function getTextBackgroundRect({
textAlign,
block,
background,
fontSizeRatio = 1,
}: {
textAlign: TextElement["textAlign"];
block: TextBlockMeasurement;
background: TextElement["background"];
fontSizeRatio?: number;
}): TextRect | null {
if (!isTextBackgroundVisible({ background })) {
return null;
}
const textRect = getTextRect({ textAlign, block });
const paddingX =
(background.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX) * fontSizeRatio;
const paddingY =
(background.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY) * fontSizeRatio;
const offsetX = background.offsetX ?? DEFAULT_TEXT_BACKGROUND.offsetX;
const offsetY = background.offsetY ?? DEFAULT_TEXT_BACKGROUND.offsetY;
return {
left: textRect.left - paddingX + offsetX,
top: textRect.top - paddingY + offsetY,
width: textRect.width + paddingX * 2,
height: textRect.height + paddingY * 2,
};
}
export function getTextVisualRect({
textAlign,
block,
background,
fontSizeRatio = 1,
}: {
textAlign: TextElement["textAlign"];
block: TextBlockMeasurement;
background: TextElement["background"];
fontSizeRatio?: number;
}): TextRect {
const textRect = getTextRect({ textAlign, block });
const backgroundRect = getTextBackgroundRect({
textAlign,
block,
background,
fontSizeRatio,
});
if (!backgroundRect) {
return textRect;
}
const left = Math.min(textRect.left, backgroundRect.left);
const top = Math.min(textRect.top, backgroundRect.top);
const right = Math.max(
textRect.left + textRect.width,
backgroundRect.left + backgroundRect.width,
);
const bottom = Math.max(
textRect.top + textRect.height,
backgroundRect.top + backgroundRect.height,
);
return {
left,
top,
width: right - left,
height: bottom - top,
};
}
+8 -1
View File
@@ -154,7 +154,14 @@ export function buildTextElement({
: DEFAULT_TEXT_ELEMENT.fontSize, : DEFAULT_TEXT_ELEMENT.fontSize,
fontFamily: t.fontFamily ?? DEFAULT_TEXT_ELEMENT.fontFamily, fontFamily: t.fontFamily ?? DEFAULT_TEXT_ELEMENT.fontFamily,
color: t.color ?? DEFAULT_TEXT_ELEMENT.color, color: t.color ?? DEFAULT_TEXT_ELEMENT.color,
backgroundColor: t.backgroundColor ?? DEFAULT_TEXT_ELEMENT.backgroundColor, background: {
color: t.background?.color ?? DEFAULT_TEXT_ELEMENT.background.color,
cornerRadius: t.background?.cornerRadius,
paddingX: t.background?.paddingX,
paddingY: t.background?.paddingY,
offsetX: t.background?.offsetX,
offsetY: t.background?.offsetY,
},
textAlign: t.textAlign ?? DEFAULT_TEXT_ELEMENT.textAlign, textAlign: t.textAlign ?? DEFAULT_TEXT_ELEMENT.textAlign,
fontWeight: t.fontWeight ?? DEFAULT_TEXT_ELEMENT.fontWeight, fontWeight: t.fontWeight ?? DEFAULT_TEXT_ELEMENT.fontWeight,
fontStyle: t.fontStyle ?? DEFAULT_TEXT_ELEMENT.fontStyle, fontStyle: t.fontStyle ?? DEFAULT_TEXT_ELEMENT.fontStyle,
@@ -2,9 +2,16 @@ import type { CanvasRenderer } from "../canvas-renderer";
import { BaseNode } from "./base-node"; import { BaseNode } from "./base-node";
import type { TextElement } from "@/types/timeline"; import type { TextElement } from "@/types/timeline";
import { import {
DEFAULT_TEXT_ELEMENT,
DEFAULT_LINE_HEIGHT, DEFAULT_LINE_HEIGHT,
FONT_SIZE_SCALE_REFERENCE, FONT_SIZE_SCALE_REFERENCE,
} from "@/constants/text-constants"; } from "@/constants/text-constants";
import {
getMetricAscent,
getMetricDescent,
getTextBackgroundRect,
measureTextBlock,
} from "@/lib/text/layout";
function scaleFontSize({ function scaleFontSize({
fontSize, fontSize,
@@ -20,65 +27,6 @@ function quoteFontFamily({ fontFamily }: { fontFamily: string }): string {
return `"${fontFamily.replace(/"/g, '\\"')}"`; return `"${fontFamily.replace(/"/g, '\\"')}"`;
} }
function getMetricAscent({
metrics,
fallback,
}: {
metrics: TextMetrics;
fallback: number;
}): number {
return metrics.actualBoundingBoxAscent ?? fallback * 0.8;
}
function getMetricDescent({
metrics,
fallback,
}: {
metrics: TextMetrics;
fallback: number;
}): number {
return metrics.actualBoundingBoxDescent ?? fallback * 0.2;
}
interface TextBlockMeasurement {
visualCenterOffset: number;
height: number;
maxWidth: number;
}
function measureTextBlock({
lineMetrics,
lineHeightPx,
fallbackFontSize,
}: {
lineMetrics: TextMetrics[];
lineHeightPx: number;
fallbackFontSize: number;
}): TextBlockMeasurement {
let top = Number.POSITIVE_INFINITY;
let bottom = Number.NEGATIVE_INFINITY;
let maxWidth = 0;
for (let i = 0; i < lineMetrics.length; i++) {
const metrics = lineMetrics[i];
const y = i * lineHeightPx;
top = Math.min(
top,
y - getMetricAscent({ metrics, fallback: fallbackFontSize }),
);
bottom = Math.max(
bottom,
y + getMetricDescent({ metrics, fallback: fallbackFontSize }),
);
maxWidth = Math.max(maxWidth, metrics.width);
}
const height = bottom - top;
const visualCenterOffset = (top + bottom) / 2;
return { visualCenterOffset, height, maxWidth };
}
function drawTextDecoration({ function drawTextDecoration({
ctx, ctx,
textDecoration, textDecoration,
@@ -99,8 +47,8 @@ function drawTextDecoration({
if (textDecoration === "none" || !textDecoration) return; if (textDecoration === "none" || !textDecoration) return;
const thickness = Math.max(1, scaledFontSize * 0.07); const thickness = Math.max(1, scaledFontSize * 0.07);
const ascent = getMetricAscent({ metrics, fallback: scaledFontSize }); const ascent = getMetricAscent({ metrics, fallbackFontSize: scaledFontSize });
const descent = getMetricDescent({ metrics, fallback: scaledFontSize }); const descent = getMetricDescent({ metrics, fallbackFontSize: scaledFontSize });
let xStart = -lineWidth / 2; let xStart = -lineWidth / 2;
if (textAlign === "left") xStart = 0; if (textAlign === "left") xStart = 0;
@@ -171,6 +119,7 @@ export class TextNode extends BaseNode<TextNodeParams> {
const lines = this.params.content.split("\n"); const lines = this.params.content.split("\n");
const lineHeightPx = scaledFontSize * lineHeight; const lineHeightPx = scaledFontSize * lineHeight;
const fontSizeRatio = this.params.fontSize / DEFAULT_TEXT_ELEMENT.fontSize;
const baseline = this.params.textBaseline ?? "middle"; const baseline = this.params.textBaseline ?? "middle";
renderer.context.textBaseline = baseline; renderer.context.textBaseline = baseline;
@@ -191,22 +140,31 @@ export class TextNode extends BaseNode<TextNodeParams> {
) as GlobalCompositeOperation; ) as GlobalCompositeOperation;
renderer.context.globalAlpha = this.params.opacity; renderer.context.globalAlpha = this.params.opacity;
if (this.params.backgroundColor && lineCount > 0) { if (
const padX = 8; this.params.background.color &&
const padY = 4; this.params.background.color !== "transparent" &&
renderer.context.fillStyle = this.params.backgroundColor; lineCount > 0
let bgLeft = -block.maxWidth / 2; ) {
if (renderer.context.textAlign === "left") bgLeft = 0; const { color, cornerRadius = 0 } = this.params.background;
if (renderer.context.textAlign === "right") bgLeft = -block.maxWidth; const backgroundRect = getTextBackgroundRect({
textAlign: this.params.textAlign,
renderer.context.fillRect( block,
bgLeft - padX, background: this.params.background,
-block.height / 2 - padY, fontSizeRatio,
block.maxWidth + padX * 2, });
block.height + padY * 2, if (backgroundRect) {
); renderer.context.fillStyle = color;
renderer.context.beginPath();
renderer.context.fillStyle = this.params.color; renderer.context.roundRect(
backgroundRect.left,
backgroundRect.top,
backgroundRect.width,
backgroundRect.height,
cornerRadius,
);
renderer.context.fill();
renderer.context.fillStyle = this.params.color;
}
} }
for (let i = 0; i < lineCount; i++) { for (let i = 0; i < lineCount; i++) {
@@ -5,10 +5,11 @@ import { V2toV3Migration } from "./v2-to-v3";
import { V3toV4Migration } from "./v3-to-v4"; import { V3toV4Migration } from "./v3-to-v4";
import { V4toV5Migration } from "./v4-to-v5"; import { V4toV5Migration } from "./v4-to-v5";
import { V5toV6Migration } from "./v5-to-v6"; import { V5toV6Migration } from "./v5-to-v6";
import { V6toV7Migration } from "./v6-to-v7";
export { runStorageMigrations } from "./runner"; export { runStorageMigrations } from "./runner";
export type { MigrationProgress } from "./runner"; export type { MigrationProgress } from "./runner";
export const CURRENT_PROJECT_VERSION = 6; export const CURRENT_PROJECT_VERSION = 7;
export const migrations = [ export const migrations = [
new V0toV1Migration(), new V0toV1Migration(),
@@ -17,4 +18,5 @@ export const migrations = [
new V3toV4Migration(), new V3toV4Migration(),
new V4toV5Migration(), new V4toV5Migration(),
new V5toV6Migration(), new V5toV6Migration(),
new V6toV7Migration(),
]; ];
@@ -0,0 +1,106 @@
import type { MigrationResult, ProjectRecord } from "./types";
import { getProjectId, isRecord } from "./utils";
export function transformProjectV6ToV7({
project,
}: {
project: ProjectRecord;
}): MigrationResult<ProjectRecord> {
const projectId = getProjectId({ project });
if (!projectId) {
return { project, skipped: true, reason: "no project id" };
}
if (isV7Project({ project })) {
return { project, skipped: true, reason: "already v7" };
}
const migratedProject = migrateProjectTextElements({ project });
return {
project: { ...migratedProject, version: 7 },
skipped: false,
};
}
function migrateProjectTextElements({
project,
}: {
project: ProjectRecord;
}): ProjectRecord {
const scenesValue = project.scenes;
if (!Array.isArray(scenesValue)) return project;
let hasChanges = false;
const migratedScenes = scenesValue.map((scene) => {
const migrated = migrateSceneTextElements({ scene });
if (migrated !== scene) hasChanges = true;
return migrated;
});
if (!hasChanges) return project;
return { ...project, scenes: migratedScenes };
}
function migrateSceneTextElements({ scene }: { scene: unknown }): unknown {
if (!isRecord(scene)) return scene;
const tracksValue = scene.tracks;
if (!Array.isArray(tracksValue)) return scene;
let hasChanges = false;
const migratedTracks = tracksValue.map((track) => {
const migrated = migrateTrackTextElements({ track });
if (migrated !== track) hasChanges = true;
return migrated;
});
if (!hasChanges) return scene;
return { ...scene, tracks: migratedTracks };
}
function migrateTrackTextElements({ track }: { track: unknown }): unknown {
if (!isRecord(track)) return track;
const elementsValue = track.elements;
if (!Array.isArray(elementsValue)) return track;
let hasChanges = false;
const migratedElements = elementsValue.map((element) => {
const migrated = migrateTextElement({ element });
if (migrated !== element) hasChanges = true;
return migrated;
});
if (!hasChanges) return track;
return { ...track, elements: migratedElements };
}
function migrateTextElement({ element }: { element: unknown }): unknown {
if (!isRecord(element)) return element;
if (element.type !== "text") return element;
if (isRecord(element.background)) return element;
const backgroundColor =
typeof element.backgroundColor === "string"
? element.backgroundColor
: "transparent";
const { backgroundColor: _removed, ...rest } = element;
return {
...rest,
background: {
color: backgroundColor,
cornerRadius: 0,
paddingX: 8,
paddingY: 4,
offsetX: 0,
offsetY: 0,
},
};
}
function isV7Project({ project }: { project: ProjectRecord }): boolean {
return typeof project.version === "number" && project.version >= 7;
}
@@ -0,0 +1,16 @@
import { StorageMigration } from "./base";
import type { ProjectRecord } from "./transformers/types";
import { transformProjectV6ToV7 } from "./transformers/v6-to-v7";
export class V6toV7Migration extends StorageMigration {
from = 6;
to = 7;
async transform(project: ProjectRecord): Promise<{
project: ProjectRecord;
skipped: boolean;
reason?: string;
}> {
return transformProjectV6ToV7({ project });
}
}
+8 -1
View File
@@ -114,7 +114,14 @@ export interface TextElement extends BaseTimelineElement {
fontSize: number; fontSize: number;
fontFamily: string; fontFamily: string;
color: string; color: string;
backgroundColor: string; background: {
color: string;
cornerRadius?: number;
paddingX?: number;
paddingY?: number;
offsetX?: number;
offsetY?: number;
};
textAlign: "left" | "center" | "right"; textAlign: "left" | "center" | "right";
fontWeight: "normal" | "bold"; fontWeight: "normal" | "bold";
fontStyle: "normal" | "italic"; fontStyle: "normal" | "italic";
+81 -45
View File
@@ -65,51 +65,6 @@ export function OcCheckerboardIcon({
); );
} }
export function OcFontWeightIcon({
className = "",
size = 32,
}: {
className?: string;
size?: number;
}) {
return (
<svg
width={size}
height={size}
className={className}
viewBox="0 0 10 10"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<title>Font Weight</title>
<rect
x="0.8"
y="1"
width="1"
height="8"
fill="currentColor"
fillOpacity="0.5"
/>
<rect
x="3.6"
y="1"
width="1.6"
height="8"
fill="currentColor"
fillOpacity="0.5"
/>
<rect
x="6.8"
y="1"
width="2.2"
height="8"
fill="currentColor"
fillOpacity="0.5"
/>
</svg>
);
}
export function OcSlidersVerticalIcon({ export function OcSlidersVerticalIcon({
className = "", className = "",
size = 32, size = 32,
@@ -203,3 +158,84 @@ export function OcSocialIcon({
</svg> </svg>
); );
} }
export function OcTextWidthIcon({
className = "",
size = 32,
}: {
className?: string;
size?: number;
}) {
return (
<svg
width={Math.round((size * 17) / 24)}
height={size}
className={className}
viewBox="0 0 17 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<title>Text width</title>
<path
d="M3.75 17.17C3.14316 17.7599 0.75 19.3297 0.75 20.17C0.75 21.0103 3.14316 22.5802 3.75 23.17M12.75 17.25C13.3568 17.8398 15.75 19.4097 15.75 20.25C15.75 21.0903 13.3568 22.6602 12.75 23.25M1.17285 20.1495L7.85685 20.1895L15.618 20.2295M8.25 0.75V15.75M8.25 0.75C9.6374 0.75 11.4195 0.78054 12.8384 0.92648C13.4385 0.98819 13.7386 1.01905 14.0041 1.1279C14.5566 1.35428 15.0018 1.85062 15.1694 2.4268C15.25 2.70381 15.25 3.01991 15.25 3.65214M8.25 0.75C6.8626 0.75 5.08047 0.78054 3.66161 0.92648C3.0615 0.98819 2.76144 1.01905 2.49586 1.1279C1.94344 1.35428 1.49816 1.85062 1.33057 2.4268C1.25 2.70381 1.25 3.01991 1.25 3.65214"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export function OcTextHeightIcon({
className = "",
size = 32,
}: {
className?: string;
size?: number;
}) {
return (
<svg
width={Math.round((size * 20) / 23)}
height={size}
className={className}
viewBox="0 0 20 23"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<title>Text height</title>
<path
d="M12.67 18.9194C13.2599 19.5262 14.8297 21.9194 15.67 21.9194C16.5103 21.9194 18.0802 19.5262 18.67 18.9194M12.75 10.9965C13.3398 10.3897 14.9097 7.99653 15.75 7.99653C16.5903 7.99653 18.1602 10.3897 18.75 10.9965M15.6495 21.4965L15.7295 8.12853M7.75 0.75V21.75M7.75 0.75C9.1374 0.75 11.9195 0.78054 13.3384 0.92648C13.9385 0.98819 14.2386 1.01905 14.5041 1.1279C15.0566 1.35428 15.5018 1.85062 15.6694 2.4268C15.75 2.70381 15.75 3.01991 15.75 3.65214M7.75 0.75C6.3626 0.75 4.58047 0.78054 3.16161 0.92648C2.5615 0.98819 2.26144 1.01905 1.99586 1.1279C1.44344 1.35428 0.99816 1.85062 0.83057 2.4268C0.75 2.70381 0.75 3.01991 0.75 3.65214"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export function OcFontIcon({
className = "",
size = 32,
}: {
className?: string;
size?: number;
}) {
return (
<svg
width={size}
height={Math.round((size * 24) / 18)}
className={className}
viewBox="0 0 18 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<title>Font</title>
<path
d="M16.4094 3.80541C16.0122 3.80541 15.8136 3.51231 15.6889 3.09844C15.267 1.53634 14.7188 1.07415 14.397 1.07415C14.0737 1.07415 13.5763 1.66034 13.0297 2.87942C12.3075 4.41576 11.8233 6.08898 11.3752 7.69939L14.2181 7.69617C14.3921 8.03758 14.251 8.74455 13.8275 8.93941H11.0173C10.0981 11.9638 9.32336 14.8786 8.40416 17.7323C7.88218 19.3427 7.31096 20.879 6.58873 21.8533C5.79263 22.95 4.30221 24 2.5114 24C1.24421 24 0 23.4154 0 22.1223C0 21.3171 0.845339 20.5618 1.51669 20.5618C1.80558 20.5505 2.07642 20.7019 2.21266 20.9515C2.83476 22.0482 3.4306 22.6827 3.70472 22.6827C3.97884 22.6827 4.20208 22.3171 4.65019 20.7325L7.91337 8.94102L5.56611 8.9378C5.39212 8.42568 5.63998 7.81212 5.93708 7.68973H8.27448C8.7226 6.27578 9.2495 4.82963 9.99471 3.53647C11.1125 1.5621 12.9033 0 15.3639 0C17.2285 0 18 0.879286 18 2.00013C17.9754 3.19667 16.9068 3.80541 16.4094 3.80541Z"
fill="currentColor"
/>
</svg>
);
}