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 letterSpacing = element.letterSpacing ?? 0;
const backgroundColor =
element.backgroundColor === "transparent"
element.background.color === "transparent"
? "transparent"
: element.backgroundColor;
: element.background.color;
return (
<div
@@ -19,7 +19,7 @@ export function PropertiesPanel() {
return (
<div className="panel bg-background h-full rounded-sm border overflow-hidden">
{selectedElements.length > 0 ? (
<ScrollArea className="h-full">
<ScrollArea className="h-full scrollbar-hidden">
{elementsWithTracks.map(({ track, element }) => {
if (element.type === "text") {
return (
@@ -2,6 +2,7 @@ import { createContext, useContext, useState } from "react";
import { cn } from "@/utils/ui";
import { HugeiconsIcon } from "@hugeicons/react";
import { ArrowDownIcon } from "@hugeicons/core-free-icons";
import { Label } from "@/components/ui/label";
const sectionExpandedCache = new Map<string, boolean>();
@@ -131,6 +132,33 @@ export function SectionHeader({
return <div className={baseClassName}>{content}</div>;
}
export function SectionFields({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return <div className={cn("flex flex-col gap-3.5", className)}>{children}</div>;
}
export function SectionField({
label,
children,
className,
}: {
label: string;
children: React.ReactNode;
className?: string;
}) {
return (
<div className={cn("flex flex-col gap-2", className)}>
<Label>{label}</Label>
{children}
</div>
);
}
export function SectionContent({
children,
className,
@@ -8,7 +8,7 @@ import {
} from "@/constants/timeline-constants";
import { OcCheckerboardIcon } from "@opencut/ui/icons";
import { Fragment, useRef } from "react";
import { Section, SectionContent, SectionHeader } from "../section";
import { Section, SectionContent, SectionField, SectionHeader } from "../section";
import {
Select,
SelectContent,
@@ -124,73 +124,73 @@ export function BlendingSection({
return (
<Section collapsible sectionKey={`${element.type}:blending`}>
<SectionHeader title="Blending" />
<SectionContent>
<div className="flex items-start gap-2">
<div className="w-1/2 space-y-1.5">
<NumberField
<SectionContent>
<div className="flex items-start gap-2">
<SectionField label="Opacity" className="w-1/2">
<NumberField
className="w-full"
icon={
<OcCheckerboardIcon className="size-3.5 text-muted-foreground" />
}
value={opacity.displayValue}
min={0}
max={100}
onFocus={opacity.onFocus}
onChange={opacity.onChange}
onBlur={opacity.onBlur}
onScrub={opacity.scrubTo}
onScrubEnd={opacity.commitScrub}
onReset={() =>
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
updates: { opacity: DEFAULT_OPACITY },
},
],
})
}
isDefault={element.opacity === DEFAULT_OPACITY}
dragSensitivity="slow"
/>
</SectionField>
<SectionField label="Blend mode" className="w-1/2">
<Select
value={committedBlendModeRef.current}
onOpenChange={handleBlendModeOpenChange}
onValueChange={commitBlendMode}
>
<SelectTrigger
icon={<HugeiconsIcon icon={RainDropIcon} />}
className="w-full"
icon={
<OcCheckerboardIcon className="size-3.5 text-muted-foreground" />
}
value={opacity.displayValue}
min={0}
max={100}
onFocus={opacity.onFocus}
onChange={opacity.onChange}
onBlur={opacity.onBlur}
onScrub={opacity.scrubTo}
onScrubEnd={opacity.commitScrub}
onReset={() =>
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
updates: { opacity: DEFAULT_OPACITY },
},
],
})
}
isDefault={element.opacity === DEFAULT_OPACITY}
dragSensitivity="slow"
/>
</div>
<div className="w-1/2 space-y-1.5">
<Select
value={committedBlendModeRef.current}
onOpenChange={handleBlendModeOpenChange}
onValueChange={commitBlendMode}
>
<SelectTrigger
icon={<HugeiconsIcon icon={RainDropIcon} />}
className="w-full"
>
<SelectValue placeholder="Select blend mode" />
</SelectTrigger>
<SelectContent className="w-36">
{BLEND_MODE_GROUPS.map((group, groupIndex) => (
<Fragment key={group[0]?.value ?? `group-${groupIndex}`}>
{group.map((option) => (
<SelectItem
key={option.value}
value={option.value}
onPointerEnter={() =>
previewBlendMode(option.value as BlendMode)
}
>
{option.label}
</SelectItem>
))}
{groupIndex < BLEND_MODE_GROUPS.length - 1 ? (
<SelectSeparator />
) : null}
</Fragment>
))}
</SelectContent>
</Select>
</div>
</div>
</SectionContent>
<SelectValue placeholder="Select blend mode" />
</SelectTrigger>
<SelectContent className="w-36">
{BLEND_MODE_GROUPS.map((group, groupIndex) => (
<Fragment key={group[0]?.value ?? `group-${groupIndex}`}>
{group.map((option) => (
<SelectItem
key={option.value}
value={option.value}
onPointerEnter={() =>
previewBlendMode(option.value as BlendMode)
}
>
{option.label}
</SelectItem>
))}
{groupIndex < BLEND_MODE_GROUPS.length - 1 ? (
<SelectSeparator />
) : null}
</Fragment>
))}
</SelectContent>
</Select>
</SectionField>
</div>
</SectionContent>
</Section>
);
}
@@ -3,7 +3,7 @@ import { useEditor } from "@/hooks/use-editor";
import { usePropertyDraft } from "../hooks/use-property-draft";
import { clamp } from "@/utils/math";
import type { ElementType, Transform } from "@/types/timeline";
import { Section, SectionContent, SectionHeader } from "../section";
import { Section, SectionContent, SectionField, SectionFields, SectionHeader } from "../section";
import { Button } from "@/components/ui/button";
import { HugeiconsIcon } from "@hugeicons/react";
import {
@@ -120,8 +120,9 @@ export function TransformSection({
return (
<Section collapsible sectionKey={`${element.type}:transform`}>
<SectionHeader title="Transform" />
<SectionContent>
<div className="flex flex-col gap-2">
<SectionContent>
<SectionFields>
<SectionField label="Scale">
<div className="flex items-center gap-2">
{isScaleLocked ? (
<>
@@ -144,105 +145,108 @@ export function TransformSection({
<HugeiconsIcon icon={Link05Icon} />
</Button>
</div>
</SectionField>
<SectionField label="Position">
<div className="flex items-center gap-2">
<NumberField
icon="X"
className="flex-1"
value={positionX.displayValue}
onFocus={positionX.onFocus}
onChange={positionX.onChange}
onBlur={positionX.onBlur}
onScrub={positionX.scrubTo}
onScrubEnd={positionX.commitScrub}
onReset={() =>
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
updates: {
transform: {
...element.transform,
position: {
...element.transform.position,
x: DEFAULT_TRANSFORM.position.x,
<NumberField
icon="X"
className="flex-1"
value={positionX.displayValue}
onFocus={positionX.onFocus}
onChange={positionX.onChange}
onBlur={positionX.onBlur}
onScrub={positionX.scrubTo}
onScrubEnd={positionX.commitScrub}
onReset={() =>
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
updates: {
transform: {
...element.transform,
position: {
...element.transform.position,
x: DEFAULT_TRANSFORM.position.x,
},
},
},
},
},
],
})
}
isDefault={
element.transform.position.x === DEFAULT_TRANSFORM.position.x
}
/>
<NumberField
icon="Y"
className="flex-1"
value={positionY.displayValue}
onFocus={positionY.onFocus}
onChange={positionY.onChange}
onBlur={positionY.onBlur}
onScrub={positionY.scrubTo}
onScrubEnd={positionY.commitScrub}
onReset={() =>
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
updates: {
transform: {
...element.transform,
position: {
...element.transform.position,
y: DEFAULT_TRANSFORM.position.y,
],
})
}
isDefault={
element.transform.position.x === DEFAULT_TRANSFORM.position.x
}
/>
<NumberField
icon="Y"
className="flex-1"
value={positionY.displayValue}
onFocus={positionY.onFocus}
onChange={positionY.onChange}
onBlur={positionY.onBlur}
onScrub={positionY.scrubTo}
onScrubEnd={positionY.commitScrub}
onReset={() =>
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
updates: {
transform: {
...element.transform,
position: {
...element.transform.position,
y: DEFAULT_TRANSFORM.position.y,
},
},
},
},
},
],
})
}
isDefault={
element.transform.position.y === DEFAULT_TRANSFORM.position.y
}
/>
],
})
}
isDefault={
element.transform.position.y === DEFAULT_TRANSFORM.position.y
}
/>
</div>
</SectionField>
<div className="flex items-center gap-2">
<NumberField
icon={<HugeiconsIcon icon={RotateClockwiseIcon} />}
className="flex-1"
value={rotation.displayValue}
onFocus={rotation.onFocus}
onChange={rotation.onChange}
onBlur={rotation.onBlur}
dragSensitivity="slow"
onScrub={rotation.scrubTo}
onScrubEnd={rotation.commitScrub}
onReset={() =>
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
updates: {
transform: {
...element.transform,
rotate: DEFAULT_TRANSFORM.rotate,
},
<SectionField label="Rotation">
<NumberField
icon={<HugeiconsIcon icon={RotateClockwiseIcon} />}
className="flex-none"
value={rotation.displayValue}
onFocus={rotation.onFocus}
onChange={rotation.onChange}
onBlur={rotation.onBlur}
dragSensitivity="slow"
onScrub={rotation.scrubTo}
onScrubEnd={rotation.commitScrub}
onReset={() =>
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
updates: {
transform: {
...element.transform,
rotate: DEFAULT_TRANSFORM.rotate,
},
},
],
})
}
isDefault={element.transform.rotate === DEFAULT_TRANSFORM.rotate}
/>
</div>
</div>
</SectionContent>
},
],
})
}
isDefault={element.transform.rotate === DEFAULT_TRANSFORM.rotate}
/>
</SectionField>
</SectionFields>
</SectionContent>
</Section>
);
}
@@ -1,31 +1,49 @@
import { Textarea } from "@/components/ui/textarea";
import { FontPicker } from "@/components/ui/font-picker";
import type { TextElement } from "@/types/timeline";
import { Slider } from "@/components/ui/slider";
import { NumberField } from "@/components/ui/number-field";
import { useRef } from "react";
import { Section, SectionContent, SectionHeader } from "./section";
import { Section, SectionContent, SectionField, SectionFields, SectionHeader } from "./section";
import { ColorPicker } from "@/components/ui/color-picker";
import { uppercase } from "@/utils/string";
import { clamp } from "@/utils/math";
import { useEditor } from "@/hooks/use-editor";
import { DEFAULT_COLOR } from "@/constants/project-constants";
import {
DEFAULT_LETTER_SPACING,
DEFAULT_LINE_HEIGHT,
DEFAULT_TEXT_BACKGROUND,
DEFAULT_TEXT_ELEMENT,
MAX_FONT_SIZE,
MIN_FONT_SIZE,
} from "@/constants/text-constants";
import { usePropertyDraft } from "./hooks/use-property-draft";
import { TransformSection, BlendingSection } from "./sections";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@/components/ui/select";
import { OcFontWeightIcon } from "@opencut/ui/icons";
import { Label } from "@/components/ui/label";
import { HugeiconsIcon } from "@hugeicons/react";
import { TextFontIcon } from "@hugeicons/core-free-icons";
import { OcTextHeightIcon, OcTextWidthIcon } from "@opencut/ui/icons";
function createOffsetConverter({
defaultValue,
scale = 1,
min,
}: {
defaultValue: number;
scale?: number;
min?: number;
}) {
return {
toDisplay: (value: number) => Math.round((value - defaultValue) * scale),
fromDisplay: (display: number) => {
const stored = defaultValue + display / scale;
return min !== undefined ? Math.max(min, stored) : stored;
},
};
}
const lineHeightConverter = createOffsetConverter({ defaultValue: DEFAULT_LINE_HEIGHT, scale: 10 });
const paddingXConverter = createOffsetConverter({ defaultValue: DEFAULT_TEXT_BACKGROUND.paddingX, min: 0 });
const paddingYConverter = createOffsetConverter({ defaultValue: DEFAULT_TEXT_BACKGROUND.paddingY, min: 0 });
export function TextProperties({
element,
@@ -39,8 +57,9 @@ export function TextProperties({
<ContentSection element={element} trackId={trackId} />
<TransformSection element={element} trackId={trackId} />
<BlendingSection element={element} trackId={trackId} />
<FontSection element={element} trackId={trackId} />
<ColorSection element={element} trackId={trackId} />
<TypographySection element={element} trackId={trackId} />
<SpacingSection element={element} trackId={trackId} />
<BackgroundSection element={element} trackId={trackId} />
</div>
);
}
@@ -83,7 +102,7 @@ function ContentSection({
);
}
function FontSection({
function TypographySection({
element,
trackId,
}: {
@@ -109,10 +128,11 @@ function FontSection({
});
return (
<Section collapsible sectionKey="text:font">
<SectionHeader title="Font" />
<SectionContent>
<div className="flex flex-col gap-2">
<Section collapsible sectionKey="text:typography">
<SectionHeader title="Typography" />
<SectionContent>
<SectionFields>
<SectionField label="Font">
<FontPicker
defaultValue={element.fontFamily}
onValueChange={(value) =>
@@ -127,89 +147,33 @@ function FontSection({
})
}
/>
<Select value={element.fontWeight}>
<SelectTrigger className="w-full" icon={<OcFontWeightIcon />}>
<SelectValue placeholder="Select weight" />
</SelectTrigger>
<SelectContent>
<SelectItem value="100">Thin</SelectItem>
<SelectItem value="200">Extra Light</SelectItem>
<SelectItem value="300">Light</SelectItem>
<SelectItem value="400">Normal</SelectItem>
<SelectItem value="500">Medium</SelectItem>
<SelectItem value="600">Semi Bold</SelectItem>
<SelectItem value="700">Bold</SelectItem>
<SelectItem value="800">Extra Bold</SelectItem>
<SelectItem value="900">Black</SelectItem>
</SelectContent>
</Select>
<Label>Font size</Label>
<div className="flex items-center gap-2">
<Slider
value={[element.fontSize]}
min={MIN_FONT_SIZE}
max={MAX_FONT_SIZE}
step={1}
onValueChange={([value]) =>
editor.timeline.previewElements({
updates: [
{
trackId,
elementId: element.id,
updates: { fontSize: value },
},
],
})
}
onValueCommit={() => editor.timeline.commitPreview()}
className="w-full"
/>
<NumberField
className="w-18 shrink-0"
value={fontSize.displayValue}
min={MIN_FONT_SIZE}
max={MAX_FONT_SIZE}
onFocus={fontSize.onFocus}
onChange={fontSize.onChange}
onBlur={fontSize.onBlur}
onReset={() =>
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
updates: {
fontSize: DEFAULT_TEXT_ELEMENT.fontSize,
},
},
],
})
}
isDefault={element.fontSize === DEFAULT_TEXT_ELEMENT.fontSize}
/>
</div>
</div>
</SectionContent>
</Section>
);
}
function ColorSection({
element,
trackId,
}: {
element: TextElement;
trackId: string;
}) {
const editor = useEditor();
const lastSelectedColor = useRef(DEFAULT_COLOR);
return (
<Section collapsible sectionKey="text:color" hasBorderBottom={false}>
<SectionHeader title="Color" />
<SectionContent>
<div className="flex flex-col gap-6">
</SectionField>
<SectionField label="Size">
<NumberField
value={fontSize.displayValue}
min={MIN_FONT_SIZE}
max={MAX_FONT_SIZE}
onFocus={fontSize.onFocus}
onChange={fontSize.onChange}
onBlur={fontSize.onBlur}
onScrub={fontSize.scrubTo}
onScrubEnd={fontSize.commitScrub}
onReset={() =>
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
updates: { fontSize: DEFAULT_TEXT_ELEMENT.fontSize },
},
],
})
}
isDefault={element.fontSize === DEFAULT_TEXT_ELEMENT.fontSize}
icon={<HugeiconsIcon icon={TextFontIcon} />}
/>
</SectionField>
<SectionField label="Color">
<ColorPicker
value={uppercase({
string: (element.color || "FFFFFF").replace("#", ""),
@@ -227,35 +191,385 @@ function ColorSection({
}
onChangeEnd={() => editor.timeline.commitPreview()}
/>
<ColorPicker
value={
element.backgroundColor === "transparent"
? lastSelectedColor.current.replace("#", "")
: element.backgroundColor.replace("#", "")
}
onChange={(color) => {
const hexColor = `#${color}`;
if (color !== "transparent") {
lastSelectedColor.current = hexColor;
}
editor.timeline.previewElements({
updates: [
{
trackId,
elementId: element.id,
updates: { backgroundColor: hexColor },
},
],
});
}}
onChangeEnd={() => editor.timeline.commitPreview()}
className={
element.backgroundColor === "transparent"
? "pointer-events-none opacity-50"
: ""
</SectionField>
</SectionFields>
</SectionContent>
</Section>
);
}
function SpacingSection({
element,
trackId,
}: {
element: TextElement;
trackId: string;
}) {
const editor = useEditor();
const letterSpacing = usePropertyDraft({
displayValue: Math.round(element.letterSpacing ?? DEFAULT_LETTER_SPACING).toString(),
parse: (input) => {
const parsed = parseFloat(input);
return Number.isNaN(parsed) ? null : Math.round(parsed);
},
onPreview: (value) =>
editor.timeline.previewElements({
updates: [{ trackId, elementId: element.id, updates: { letterSpacing: value } }],
}),
onCommit: () => editor.timeline.commitPreview(),
});
const lineHeight = usePropertyDraft({
displayValue: lineHeightConverter.toDisplay(element.lineHeight ?? DEFAULT_LINE_HEIGHT).toString(),
parse: (input) => {
const parsed = parseFloat(input);
return Number.isNaN(parsed) ? null : lineHeightConverter.fromDisplay(Math.round(parsed));
},
onPreview: (value) =>
editor.timeline.previewElements({
updates: [{ trackId, elementId: element.id, updates: { lineHeight: value } }],
}),
onCommit: () => editor.timeline.commitPreview(),
});
return (
<Section collapsible sectionKey="text:spacing" hasBorderBottom={false}>
<SectionHeader title="Spacing" />
<SectionContent>
<div className="flex items-start gap-2">
<SectionField label="Letter spacing" className="w-1/2">
<NumberField
value={letterSpacing.displayValue}
onFocus={letterSpacing.onFocus}
onChange={letterSpacing.onChange}
onBlur={letterSpacing.onBlur}
onScrub={letterSpacing.scrubTo}
onScrubEnd={letterSpacing.commitScrub}
onReset={() =>
editor.timeline.updateElements({
updates: [{ trackId, elementId: element.id, updates: { letterSpacing: DEFAULT_LETTER_SPACING } }],
})
}
isDefault={(element.letterSpacing ?? DEFAULT_LETTER_SPACING) === DEFAULT_LETTER_SPACING}
icon={<OcTextWidthIcon size={14} />}
/>
</div>
</SectionField>
<SectionField label="Line height" className="w-1/2">
<NumberField
value={lineHeight.displayValue}
onFocus={lineHeight.onFocus}
onChange={lineHeight.onChange}
onBlur={lineHeight.onBlur}
onScrub={lineHeight.scrubTo}
onScrubEnd={lineHeight.commitScrub}
onReset={() =>
editor.timeline.updateElements({
updates: [{ trackId, elementId: element.id, updates: { lineHeight: DEFAULT_LINE_HEIGHT } }],
})
}
isDefault={(element.lineHeight ?? DEFAULT_LINE_HEIGHT) === DEFAULT_LINE_HEIGHT}
icon={<OcTextHeightIcon size={14} />}
/>
</SectionField>
</div>
</SectionContent>
</Section>
);
}
function BackgroundSection({
element,
trackId,
}: {
element: TextElement;
trackId: string;
}) {
const editor = useEditor();
const lastSelectedColor = useRef(DEFAULT_COLOR);
const cornerRadius = usePropertyDraft({
displayValue: Math.round(element.background.cornerRadius ?? 0).toString(),
parse: (input) => {
const parsed = parseFloat(input);
return Number.isNaN(parsed) ? null : Math.max(0, Math.round(parsed));
},
onPreview: (value) =>
editor.timeline.previewElements({
updates: [
{
trackId,
elementId: element.id,
updates: {
background: { ...element.background, cornerRadius: value },
},
},
],
}),
onCommit: () => editor.timeline.commitPreview(),
});
const paddingX = usePropertyDraft({
displayValue: paddingXConverter.toDisplay(element.background.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX).toString(),
parse: (input) => {
const parsed = parseFloat(input);
return Number.isNaN(parsed) ? null : paddingXConverter.fromDisplay(Math.round(parsed));
},
onPreview: (value) =>
editor.timeline.previewElements({
updates: [
{
trackId,
elementId: element.id,
updates: { background: { ...element.background, paddingX: value } },
},
],
}),
onCommit: () => editor.timeline.commitPreview(),
});
const paddingY = usePropertyDraft({
displayValue: paddingYConverter.toDisplay(element.background.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY).toString(),
parse: (input) => {
const parsed = parseFloat(input);
return Number.isNaN(parsed) ? null : paddingYConverter.fromDisplay(Math.round(parsed));
},
onPreview: (value) =>
editor.timeline.previewElements({
updates: [
{
trackId,
elementId: element.id,
updates: { background: { ...element.background, paddingY: value } },
},
],
}),
onCommit: () => editor.timeline.commitPreview(),
});
const offsetX = usePropertyDraft({
displayValue: Math.round(element.background.offsetX ?? 0).toString(),
parse: (input) => {
const parsed = parseFloat(input);
return Number.isNaN(parsed) ? null : Math.round(parsed);
},
onPreview: (value) =>
editor.timeline.previewElements({
updates: [
{
trackId,
elementId: element.id,
updates: { background: { ...element.background, offsetX: value } },
},
],
}),
onCommit: () => editor.timeline.commitPreview(),
});
const offsetY = usePropertyDraft({
displayValue: Math.round(element.background.offsetY ?? 0).toString(),
parse: (input) => {
const parsed = parseFloat(input);
return Number.isNaN(parsed) ? null : Math.round(parsed);
},
onPreview: (value) =>
editor.timeline.previewElements({
updates: [
{
trackId,
elementId: element.id,
updates: { background: { ...element.background, offsetY: value } },
},
],
}),
onCommit: () => editor.timeline.commitPreview(),
});
return (
<Section collapsible sectionKey="text:background">
<SectionHeader title="Background" />
<SectionContent>
<SectionFields>
<SectionField label="Color">
<ColorPicker
value={
element.background.color === "transparent"
? lastSelectedColor.current.replace("#", "")
: element.background.color.replace("#", "")
}
onChange={(color) => {
const hexColor = `#${color}`;
if (color !== "transparent") {
lastSelectedColor.current = hexColor;
}
editor.timeline.previewElements({
updates: [
{
trackId,
elementId: element.id,
updates: {
background: { ...element.background, color: hexColor },
},
},
],
});
}}
onChangeEnd={() => editor.timeline.commitPreview()}
className={
element.background.color === "transparent"
? "pointer-events-none opacity-50"
: ""
}
/>
</SectionField>
<div className="flex items-start gap-2">
<SectionField label="Width" className="w-1/2">
<NumberField
icon="W"
value={paddingX.displayValue}
min={0}
onFocus={paddingX.onFocus}
onChange={paddingX.onChange}
onBlur={paddingX.onBlur}
onScrub={paddingX.scrubTo}
onScrubEnd={paddingX.commitScrub}
onReset={() =>
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
updates: {
background: {
...element.background,
paddingX: DEFAULT_TEXT_BACKGROUND.paddingX,
},
},
},
],
})
}
isDefault={
(element.background.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX) ===
DEFAULT_TEXT_BACKGROUND.paddingX
}
/>
</SectionField>
<SectionField label="Height" className="w-1/2">
<NumberField
icon="H"
value={paddingY.displayValue}
min={0}
onFocus={paddingY.onFocus}
onChange={paddingY.onChange}
onBlur={paddingY.onBlur}
onScrub={paddingY.scrubTo}
onScrubEnd={paddingY.commitScrub}
onReset={() =>
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
updates: {
background: {
...element.background,
paddingY: DEFAULT_TEXT_BACKGROUND.paddingY,
},
},
},
],
})
}
isDefault={
(element.background.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY) ===
DEFAULT_TEXT_BACKGROUND.paddingY
}
/>
</SectionField>
</div>
<div className="flex items-start gap-2">
<SectionField label="X-offset" className="w-1/2">
<NumberField
icon="X"
value={offsetX.displayValue}
onFocus={offsetX.onFocus}
onChange={offsetX.onChange}
onBlur={offsetX.onBlur}
onScrub={offsetX.scrubTo}
onScrubEnd={offsetX.commitScrub}
onReset={() =>
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
updates: {
background: { ...element.background, offsetX: 0 },
},
},
],
})
}
isDefault={(element.background.offsetX ?? 0) === 0}
/>
</SectionField>
<SectionField label="Y-offset" className="w-1/2">
<NumberField
icon="Y"
value={offsetY.displayValue}
onFocus={offsetY.onFocus}
onChange={offsetY.onChange}
onBlur={offsetY.onBlur}
onScrub={offsetY.scrubTo}
onScrubEnd={offsetY.commitScrub}
onReset={() =>
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
updates: {
background: { ...element.background, offsetY: 0 },
},
},
],
})
}
isDefault={(element.background.offsetY ?? 0) === 0}
/>
</SectionField>
</div>
<SectionField label="Corner Radius">
<NumberField
icon="R"
value={cornerRadius.displayValue}
min={0}
onFocus={cornerRadius.onFocus}
onChange={cornerRadius.onChange}
onBlur={cornerRadius.onBlur}
onScrub={cornerRadius.scrubTo}
onScrubEnd={cornerRadius.commitScrub}
onReset={() =>
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
updates: {
background: {
...element.background,
cornerRadius: 0,
},
},
},
],
})
}
isDefault={(element.background.cornerRadius ?? 0) === 0}
/>
</SectionField>
</SectionFields>
</SectionContent>
</Section>
);
+14 -6
View File
@@ -280,13 +280,21 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
)}
{...props}
>
<PopoverTrigger asChild>
<button
className="size-4.5 cursor-pointer border rounded-sm hover:ring-1 hover:ring-foreground/20"
style={{ backgroundColor: `#${value}` }}
type="button"
<PopoverTrigger asChild>
<button
className="size-4.5 cursor-pointer border rounded-sm hover:ring-1 hover:ring-foreground/20 overflow-hidden relative"
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">
<Input
className={cn(
+2 -2
View File
@@ -26,7 +26,7 @@ import type { FontAtlas, FontAtlasEntry } from "@/types/fonts";
import { cn } from "@/utils/ui";
import { ChevronDown, Search, Upload } from "lucide-react";
import { HugeiconsIcon } from "@hugeicons/react";
import { TextFontIcon } from "@hugeicons/core-free-icons";
import { TextIcon } from "@hugeicons/core-free-icons";
const FONT_TABS = [
{ key: "all", label: "All fonts" },
@@ -139,7 +139,7 @@ export function FontPicker({
>
<div className="flex min-w-0 items-center gap-1.5">
<span className="text-muted-foreground [&_svg]:size-3.5 shrink-0">
<HugeiconsIcon icon={TextFontIcon} />
<HugeiconsIcon icon={TextIcon} />
</span>
<span className="truncate" style={{ fontFamily: defaultValue }}>
{defaultValue ?? "Select a font"}
+5 -2
View File
@@ -71,9 +71,12 @@ function NumberField({
return;
}
cumulativeDeltaRef.current += moveEvent.movementX;
const sensitivity =
typeof dragSensitivity === "number"
? dragSensitivity
: DRAG_SENSITIVITIES[dragSensitivity];
const newValue =
startValueRef.current +
cumulativeDeltaRef.current * DRAG_SENSITIVITIES[dragSensitivity];
startValueRef.current + cumulativeDeltaRef.current * sensitivity;
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_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"> = {
type: "text",
name: "Text",
@@ -24,7 +33,7 @@ export const DEFAULT_TEXT_ELEMENT: Omit<TextElement, "id"> = {
fontSize: 15,
fontFamily: "Arial",
color: "#ffffff",
backgroundColor: "#000000",
background: DEFAULT_TEXT_BACKGROUND,
textAlign: "center",
fontWeight: "normal",
fontStyle: "normal",
@@ -47,7 +47,13 @@ export function useTimelinePlayhead({
isScrubbing && scrubTime !== null ? scrubTime : currentTime;
const handleScrub = useCallback(
({ event }: { event: MouseEvent | React.MouseEvent }) => {
({
event,
snappingEnabled = true,
}: {
event: MouseEvent | React.MouseEvent;
snappingEnabled?: boolean;
}) => {
const ruler = rulerRef.current;
if (!ruler) return;
const rulerRect = ruler.getBoundingClientRect();
@@ -76,7 +82,7 @@ export function useTimelinePlayhead({
fps: framesPerSecond,
});
const shouldSnap = !isShiftHeldRef.current;
const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
const time = (() => {
if (!shouldSnap) return frameTime;
const tracks = editor.timeline.getTracks();
@@ -133,10 +139,10 @@ export function useTimelinePlayhead({
setIsDraggingRuler(true);
setHasDraggedRuler(false);
editor.playback.setScrubbing({ isScrubbing: true });
handleScrub({ event });
},
[handleScrub, playheadRef, editor.playback],
editor.playback.setScrubbing({ isScrubbing: true });
handleScrub({ event, snappingEnabled: false });
},
[handleScrub, playheadRef, editor.playback],
);
const handlePlayheadMouseDownEvent = useCallback(
@@ -184,7 +190,7 @@ export function useTimelinePlayhead({
if (isDraggingRuler) {
setIsDraggingRuler(false);
if (!hasDraggedRuler) {
handleScrub({ event });
handleScrub({ event, snappingEnabled: 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 { isMainTrack } from "@/lib/timeline";
import {
DEFAULT_TEXT_ELEMENT,
DEFAULT_LINE_HEIGHT,
FONT_SIZE_SCALE_REFERENCE,
} from "@/constants/text-constants";
import { getTextVisualRect, measureTextBlock } from "@/lib/text/layout";
export interface ElementBounds {
cx: number;
@@ -121,27 +123,36 @@ export function getElementBounds({
const lines = element.content.split("\n");
const lineMetrics = lines.map((line) => ctx.measureText(line));
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 - (metrics.actualBoundingBoxAscent ?? scaledFontSize * 0.8),
);
bottom = Math.max(
bottom,
y + (metrics.actualBoundingBoxDescent ?? scaledFontSize * 0.2),
);
maxWidth = Math.max(maxWidth, metrics.width);
}
measuredWidth = maxWidth;
measuredHeight = bottom - top;
const block = measureTextBlock({
lineMetrics,
lineHeightPx,
fallbackFontSize: scaledFontSize,
});
const fontSizeRatio = element.fontSize / DEFAULT_TEXT_ELEMENT.fontSize;
const visualRect = getTextVisualRect({
textAlign: element.textAlign,
block,
background: element.background,
fontSizeRatio,
});
measuredWidth = visualRect.width;
measuredHeight = visualRect.height;
const localCenterX = visualRect.left + visualRect.width / 2;
const localCenterY = visualRect.top + visualRect.height / 2;
const scaledCenterX = localCenterX * element.transform.scale;
const scaledCenterY = localCenterY * element.transform.scale;
const rotationRad = (element.transform.rotate * Math.PI) / 180;
const cos = Math.cos(rotationRad);
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;
+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,
fontFamily: t.fontFamily ?? DEFAULT_TEXT_ELEMENT.fontFamily,
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,
fontWeight: t.fontWeight ?? DEFAULT_TEXT_ELEMENT.fontWeight,
fontStyle: t.fontStyle ?? DEFAULT_TEXT_ELEMENT.fontStyle,
@@ -2,9 +2,16 @@ import type { CanvasRenderer } from "../canvas-renderer";
import { BaseNode } from "./base-node";
import type { TextElement } from "@/types/timeline";
import {
DEFAULT_TEXT_ELEMENT,
DEFAULT_LINE_HEIGHT,
FONT_SIZE_SCALE_REFERENCE,
} from "@/constants/text-constants";
import {
getMetricAscent,
getMetricDescent,
getTextBackgroundRect,
measureTextBlock,
} from "@/lib/text/layout";
function scaleFontSize({
fontSize,
@@ -20,65 +27,6 @@ function quoteFontFamily({ fontFamily }: { fontFamily: string }): string {
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({
ctx,
textDecoration,
@@ -99,8 +47,8 @@ function drawTextDecoration({
if (textDecoration === "none" || !textDecoration) return;
const thickness = Math.max(1, scaledFontSize * 0.07);
const ascent = getMetricAscent({ metrics, fallback: scaledFontSize });
const descent = getMetricDescent({ metrics, fallback: scaledFontSize });
const ascent = getMetricAscent({ metrics, fallbackFontSize: scaledFontSize });
const descent = getMetricDescent({ metrics, fallbackFontSize: scaledFontSize });
let xStart = -lineWidth / 2;
if (textAlign === "left") xStart = 0;
@@ -171,6 +119,7 @@ export class TextNode extends BaseNode<TextNodeParams> {
const lines = this.params.content.split("\n");
const lineHeightPx = scaledFontSize * lineHeight;
const fontSizeRatio = this.params.fontSize / DEFAULT_TEXT_ELEMENT.fontSize;
const baseline = this.params.textBaseline ?? "middle";
renderer.context.textBaseline = baseline;
@@ -191,22 +140,31 @@ export class TextNode extends BaseNode<TextNodeParams> {
) as GlobalCompositeOperation;
renderer.context.globalAlpha = this.params.opacity;
if (this.params.backgroundColor && lineCount > 0) {
const padX = 8;
const padY = 4;
renderer.context.fillStyle = this.params.backgroundColor;
let bgLeft = -block.maxWidth / 2;
if (renderer.context.textAlign === "left") bgLeft = 0;
if (renderer.context.textAlign === "right") bgLeft = -block.maxWidth;
renderer.context.fillRect(
bgLeft - padX,
-block.height / 2 - padY,
block.maxWidth + padX * 2,
block.height + padY * 2,
);
renderer.context.fillStyle = this.params.color;
if (
this.params.background.color &&
this.params.background.color !== "transparent" &&
lineCount > 0
) {
const { color, cornerRadius = 0 } = this.params.background;
const backgroundRect = getTextBackgroundRect({
textAlign: this.params.textAlign,
block,
background: this.params.background,
fontSizeRatio,
});
if (backgroundRect) {
renderer.context.fillStyle = color;
renderer.context.beginPath();
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++) {
@@ -5,10 +5,11 @@ import { V2toV3Migration } from "./v2-to-v3";
import { V3toV4Migration } from "./v3-to-v4";
import { V4toV5Migration } from "./v4-to-v5";
import { V5toV6Migration } from "./v5-to-v6";
import { V6toV7Migration } from "./v6-to-v7";
export { runStorageMigrations } from "./runner";
export type { MigrationProgress } from "./runner";
export const CURRENT_PROJECT_VERSION = 6;
export const CURRENT_PROJECT_VERSION = 7;
export const migrations = [
new V0toV1Migration(),
@@ -17,4 +18,5 @@ export const migrations = [
new V3toV4Migration(),
new V4toV5Migration(),
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;
fontFamily: string;
color: string;
backgroundColor: string;
background: {
color: string;
cornerRadius?: number;
paddingX?: number;
paddingY?: number;
offsetX?: number;
offsetY?: number;
};
textAlign: "left" | "center" | "right";
fontWeight: "normal" | "bold";
fontStyle: "normal" | "italic";