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

View File

@ -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

View File

@ -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 (

View File

@ -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,

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);

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(

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"}

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);
};

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",

View File

@ -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);
}

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;

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,
};
}

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,

View File

@ -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++) {

View File

@ -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(),
];

View File

@ -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;
}

View File

@ -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 });
}
}

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";

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({
className = "",
size = 32,
@ -203,3 +158,84 @@ export function OcSocialIcon({
</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>
);
}