diff options
Diffstat (limited to 'apps/web/src/text')
| -rw-r--r-- | apps/web/src/text/background.ts | 2 | ||||
| -rw-r--r-- | apps/web/src/text/components/assets-view.tsx | 46 | ||||
| -rw-r--r-- | apps/web/src/text/components/text-tab.tsx | 764 | ||||
| -rw-r--r-- | apps/web/src/text/layout.ts | 226 | ||||
| -rw-r--r-- | apps/web/src/text/measure-element.ts | 133 | ||||
| -rw-r--r-- | apps/web/src/text/primitives.ts | 241 | ||||
| -rw-r--r-- | apps/web/src/text/typography.ts | 9 |
7 files changed, 1421 insertions, 0 deletions
diff --git a/apps/web/src/text/background.ts b/apps/web/src/text/background.ts new file mode 100644 index 00000000..be5ab6f5 --- /dev/null +++ b/apps/web/src/text/background.ts @@ -0,0 +1,2 @@ +export const CORNER_RADIUS_MIN = 0; +export const CORNER_RADIUS_MAX = 100; diff --git a/apps/web/src/text/components/assets-view.tsx b/apps/web/src/text/components/assets-view.tsx new file mode 100644 index 00000000..25a49893 --- /dev/null +++ b/apps/web/src/text/components/assets-view.tsx @@ -0,0 +1,46 @@ +import { DraggableItem } from "@/components/editor/panels/assets/draggable-item";
+import { PanelView } from "@/components/editor/panels/assets/views/base-panel";
+import { useEditor } from "@/editor/use-editor";
+import { DEFAULTS } from "@/timeline/defaults";
+import { buildTextElement } from "@/timeline/element-utils";
+
+export function TextView() {
+ const editor = useEditor();
+
+ const handleAddToTimeline = ({ currentTime }: { currentTime: number }) => {
+ const activeScene = editor.scenes.getActiveScene();
+ if (!activeScene) return;
+
+ const element = buildTextElement({
+ raw: DEFAULTS.text.element,
+ startTime: currentTime,
+ });
+
+ editor.timeline.insertElement({
+ element,
+ placement: { mode: "auto" },
+ });
+ };
+
+ return (
+ <PanelView title="Text">
+ <DraggableItem
+ name="Default text"
+ preview={
+ <div className="bg-accent flex size-full items-center justify-center rounded">
+ <span className="text-xs select-none">Default text</span>
+ </div>
+ }
+ dragData={{
+ id: "temp-text-id",
+ type: DEFAULTS.text.element.type,
+ name: DEFAULTS.text.element.name,
+ content: DEFAULTS.text.element.content,
+ }}
+ aspectRatio={1}
+ onAddToTimeline={handleAddToTimeline}
+ shouldShowLabel={false}
+ />
+ </PanelView>
+ );
+}
diff --git a/apps/web/src/text/components/text-tab.tsx b/apps/web/src/text/components/text-tab.tsx new file mode 100644 index 00000000..bf14efba --- /dev/null +++ b/apps/web/src/text/components/text-tab.tsx @@ -0,0 +1,764 @@ +"use client"; + +import { Textarea } from "@/components/ui/textarea"; +import { FontPicker } from "@/components/ui/font-picker"; +import type { TextElement } from "@/timeline"; +import { NumberField } from "@/components/ui/number-field"; +import { useRef } from "react"; +import { + Section, + SectionContent, + SectionField, + SectionFields, + SectionHeader, + SectionTitle, +} from "@/components/section"; +import { ColorPicker } from "@/components/ui/color-picker"; +import { Button } from "@/components/ui/button"; +import { uppercase } from "@/utils/string"; +import { clamp, formatNumberForDisplay } from "@/utils/math"; +import { useEditor } from "@/editor/use-editor"; +import { CORNER_RADIUS_MAX, CORNER_RADIUS_MIN } from "@/text/background"; +import { + DEFAULT_TEXT_COLOR, + MAX_FONT_SIZE, + MIN_FONT_SIZE, +} from "@/text/typography"; +import { usePropertyDraft } from "@/components/editor/panels/properties/hooks/use-property-draft"; +import { useKeyframedColorProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-color-property"; +import { useKeyframedNumberProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-number-property"; +import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead"; +import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle"; +import { isPropertyAtDefault } from "@/rendering/components/transform-tab"; +import { resolveColorAtTime, resolveNumberAtTime } from "@/animation"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { + MinusSignIcon, + PlusSignIcon, + TextFontIcon, +} from "@hugeicons/core-free-icons"; +import { OcTextHeightIcon, OcTextWidthIcon } from "@/components/icons"; +import { DEFAULTS } from "@/timeline/defaults"; +import { cn } from "@/utils/ui"; + +export function TextTab({ + element, + trackId, +}: { + element: TextElement; + trackId: string; +}) { + return ( + <div className="flex flex-col"> + <ContentSection element={element} trackId={trackId} /> + <TypographySection element={element} trackId={trackId} /> + <SpacingSection element={element} trackId={trackId} /> + <BackgroundSection element={element} trackId={trackId} /> + </div> + ); +} + +function ContentSection({ + element, + trackId, +}: { + element: TextElement; + trackId: string; +}) { + const editor = useEditor(); + + const content = usePropertyDraft({ + displayValue: element.content, + parse: (input) => input, + onPreview: (value) => + editor.timeline.previewElements({ + updates: [ + { trackId, elementId: element.id, updates: { content: value } }, + ], + }), + onCommit: () => editor.timeline.commitPreview(), + }); + + return ( + <Section collapsible sectionKey={`${element.id}:content`}> + <SectionHeader> + <SectionTitle>Content</SectionTitle> + </SectionHeader> + <SectionContent> + <Textarea + placeholder="Name" + value={content.displayValue} + className="min-h-20" + onFocus={content.onFocus} + onChange={content.onChange} + onBlur={content.onBlur} + /> + </SectionContent> + </Section> + ); +} + +function TypographySection({ + element, + trackId, +}: { + element: TextElement; + trackId: string; +}) { + const editor = useEditor(); + const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ + startTime: element.startTime, + duration: element.duration, + }); + const resolvedTextColor = resolveColorAtTime({ + baseColor: element.color, + animations: element.animations, + propertyPath: "color", + localTime, + }); + + const textColor = useKeyframedColorProperty({ + trackId, + elementId: element.id, + animations: element.animations, + propertyPath: "color", + localTime, + isPlayheadWithinElementRange, + resolvedColor: resolvedTextColor, + buildBaseUpdates: ({ value }) => ({ color: value }), + }); + + const fontSize = usePropertyDraft({ + displayValue: element.fontSize.toString(), + parse: (input) => { + const parsed = parseFloat(input); + if (Number.isNaN(parsed)) return null; + return clamp({ + value: Math.round(parsed), + min: MIN_FONT_SIZE, + max: MAX_FONT_SIZE, + }); + }, + onPreview: (value) => + editor.timeline.previewElements({ + updates: [ + { trackId, elementId: element.id, updates: { fontSize: value } }, + ], + }), + onCommit: () => editor.timeline.commitPreview(), + }); + + return ( + <Section collapsible sectionKey={`${element.id}:typography`}> + <SectionHeader> + <SectionTitle>Typography</SectionTitle> + </SectionHeader> + <SectionContent> + <SectionFields> + <SectionField label="Font"> + <FontPicker + defaultValue={element.fontFamily} + onValueChange={(value) => + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + patch: { fontFamily: value }, + }, + ], + }) + } + /> + </SectionField> + <SectionField label="Size"> + <NumberField + value={fontSize.displayValue} + min={MIN_FONT_SIZE} + max={MAX_FONT_SIZE} + onFocus={fontSize.onFocus} + onChange={fontSize.onChange} + onBlur={fontSize.onBlur} + onScrub={fontSize.scrubTo} + onScrubEnd={fontSize.commitScrub} + onReset={() => + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + patch: { + fontSize: DEFAULTS.text.element.fontSize, + }, + }, + ], + }) + } + isDefault={element.fontSize === DEFAULTS.text.element.fontSize} + icon={<HugeiconsIcon icon={TextFontIcon} />} + /> + </SectionField> + <SectionField + label="Color" + beforeLabel={ + <KeyframeToggle + isActive={textColor.isKeyframedAtTime} + isDisabled={!isPlayheadWithinElementRange} + title="Toggle text color keyframe" + onToggle={textColor.toggleKeyframe} + /> + } + > + <ColorPicker + value={uppercase({ + string: resolvedTextColor.replace("#", ""), + })} + onChange={(color) => textColor.onChange({ color: `#${color}` })} + onChangeEnd={textColor.onChangeEnd} + /> + </SectionField> + </SectionFields> + </SectionContent> + </Section> + ); +} + +function SpacingSection({ + element, + trackId, +}: { + element: TextElement; + trackId: string; +}) { + const editor = useEditor(); + + const letterSpacing = usePropertyDraft({ + displayValue: Math.round( + element.letterSpacing ?? DEFAULTS.text.letterSpacing, + ).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: formatNumberForDisplay({ + value: element.lineHeight ?? DEFAULTS.text.lineHeight, + fractionDigits: 1, + }), + parse: (input) => { + const parsed = parseFloat(input); + return Number.isNaN(parsed) + ? null + : Math.max(0.1, Math.round(parsed * 10) / 10); + }, + onPreview: (value) => + editor.timeline.previewElements({ + updates: [ + { trackId, elementId: element.id, updates: { lineHeight: value } }, + ], + }), + onCommit: () => editor.timeline.commitPreview(), + }); + + return ( + <Section collapsible sectionKey={`${element.id}:spacing`}> + <SectionHeader> + <SectionTitle>Spacing</SectionTitle> + </SectionHeader> + <SectionContent> + <div className="flex items-start gap-2"> + <SectionField label="Letter spacing" className="w-1/2"> + <NumberField + value={letterSpacing.displayValue} + onFocus={letterSpacing.onFocus} + onChange={letterSpacing.onChange} + onBlur={letterSpacing.onBlur} + onScrub={letterSpacing.scrubTo} + onScrubEnd={letterSpacing.commitScrub} + onReset={() => + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + patch: { letterSpacing: DEFAULTS.text.letterSpacing }, + }, + ], + }) + } + isDefault={ + (element.letterSpacing ?? DEFAULTS.text.letterSpacing) === + DEFAULTS.text.letterSpacing + } + icon={<OcTextWidthIcon size={14} />} + /> + </SectionField> + <SectionField label="Line height" className="w-1/2"> + <NumberField + value={lineHeight.displayValue} + onFocus={lineHeight.onFocus} + onChange={lineHeight.onChange} + onBlur={lineHeight.onBlur} + onScrub={lineHeight.scrubTo} + onScrubEnd={lineHeight.commitScrub} + onReset={() => + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + patch: { lineHeight: DEFAULTS.text.lineHeight }, + }, + ], + }) + } + isDefault={ + (element.lineHeight ?? DEFAULTS.text.lineHeight) === + DEFAULTS.text.lineHeight + } + icon={<OcTextHeightIcon size={14} />} + /> + </SectionField> + </div> + </SectionContent> + </Section> + ); +} + +function BackgroundSection({ + element, + trackId, +}: { + element: TextElement; + trackId: string; +}) { + const editor = useEditor(); + const lastSelectedColor = useRef(DEFAULT_TEXT_COLOR); + const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ + startTime: element.startTime, + duration: element.duration, + }); + const resolvedBgColor = resolveColorAtTime({ + baseColor: element.background.color, + animations: element.animations, + propertyPath: "background.color", + localTime, + }); + + const bgColor = useKeyframedColorProperty({ + trackId, + elementId: element.id, + animations: element.animations, + propertyPath: "background.color", + localTime, + isPlayheadWithinElementRange, + resolvedColor: resolvedBgColor, + buildBaseUpdates: ({ value }) => ({ + background: { ...element.background, color: value }, + }), + }); + + const bg = element.background; + + const resolvedPaddingX = resolveNumberAtTime({ + baseValue: bg.paddingX ?? DEFAULTS.text.background.paddingX, + animations: element.animations, + propertyPath: "background.paddingX", + localTime, + }); + const resolvedPaddingY = resolveNumberAtTime({ + baseValue: bg.paddingY ?? DEFAULTS.text.background.paddingY, + animations: element.animations, + propertyPath: "background.paddingY", + localTime, + }); + const resolvedOffsetX = resolveNumberAtTime({ + baseValue: bg.offsetX ?? DEFAULTS.text.background.offsetX, + animations: element.animations, + propertyPath: "background.offsetX", + localTime, + }); + const resolvedOffsetY = resolveNumberAtTime({ + baseValue: bg.offsetY ?? DEFAULTS.text.background.offsetY, + animations: element.animations, + propertyPath: "background.offsetY", + localTime, + }); + const resolvedCornerRadius = resolveNumberAtTime({ + baseValue: bg.cornerRadius ?? CORNER_RADIUS_MIN, + animations: element.animations, + propertyPath: "background.cornerRadius", + localTime, + }); + + const paddingX = useKeyframedNumberProperty({ + trackId, + elementId: element.id, + animations: element.animations, + propertyPath: "background.paddingX", + localTime, + isPlayheadWithinElementRange, + displayValue: Math.round(resolvedPaddingX).toString(), + parse: (input) => { + const parsed = parseFloat(input); + return Number.isNaN(parsed) ? null : Math.max(0, Math.round(parsed)); + }, + valueAtPlayhead: resolvedPaddingX, + step: 1, + buildBaseUpdates: ({ value }) => ({ + background: { ...bg, paddingX: value }, + }), + }); + + const paddingY = useKeyframedNumberProperty({ + trackId, + elementId: element.id, + animations: element.animations, + propertyPath: "background.paddingY", + localTime, + isPlayheadWithinElementRange, + displayValue: Math.round(resolvedPaddingY).toString(), + parse: (input) => { + const parsed = parseFloat(input); + return Number.isNaN(parsed) ? null : Math.max(0, Math.round(parsed)); + }, + valueAtPlayhead: resolvedPaddingY, + step: 1, + buildBaseUpdates: ({ value }) => ({ + background: { ...bg, paddingY: value }, + }), + }); + + const offsetX = useKeyframedNumberProperty({ + trackId, + elementId: element.id, + animations: element.animations, + propertyPath: "background.offsetX", + localTime, + isPlayheadWithinElementRange, + displayValue: Math.round(resolvedOffsetX).toString(), + parse: (input) => { + const parsed = parseFloat(input); + return Number.isNaN(parsed) ? null : Math.round(parsed); + }, + valueAtPlayhead: resolvedOffsetX, + step: 1, + buildBaseUpdates: ({ value }) => ({ + background: { ...bg, offsetX: value }, + }), + }); + + const offsetY = useKeyframedNumberProperty({ + trackId, + elementId: element.id, + animations: element.animations, + propertyPath: "background.offsetY", + localTime, + isPlayheadWithinElementRange, + displayValue: Math.round(resolvedOffsetY).toString(), + parse: (input) => { + const parsed = parseFloat(input); + return Number.isNaN(parsed) ? null : Math.round(parsed); + }, + valueAtPlayhead: resolvedOffsetY, + step: 1, + buildBaseUpdates: ({ value }) => ({ + background: { ...bg, offsetY: value }, + }), + }); + + const cornerRadius = useKeyframedNumberProperty({ + trackId, + elementId: element.id, + animations: element.animations, + propertyPath: "background.cornerRadius", + localTime, + isPlayheadWithinElementRange, + displayValue: Math.round(resolvedCornerRadius).toString(), + parse: (input) => { + const parsed = parseFloat(input); + if (Number.isNaN(parsed)) return null; + return clamp({ + value: Math.round(parsed), + min: CORNER_RADIUS_MIN, + max: CORNER_RADIUS_MAX, + }); + }, + valueAtPlayhead: resolvedCornerRadius, + step: 1, + buildBaseUpdates: ({ value }) => ({ + background: { ...bg, cornerRadius: value }, + }), + }); + + const toggleBackgroundEnabled = () => { + const enabled = !element.background.enabled; + const color = + enabled && element.background.color === "transparent" + ? lastSelectedColor.current + : element.background.color; + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + patch: { + background: { + ...element.background, + enabled, + color, + }, + }, + }, + ], + }); + }; + + return ( + <Section + collapsible + defaultOpen={element.background.enabled} + sectionKey={`${element.id}:background`} + > + <SectionHeader + trailing={ + <Button + variant="ghost" + size="icon" + onClick={(event) => { + event.stopPropagation(); + toggleBackgroundEnabled(); + }} + > + <HugeiconsIcon + icon={element.background.enabled ? MinusSignIcon : PlusSignIcon} + strokeWidth={1} + /> + </Button> + } + > + <SectionTitle>Background</SectionTitle> + </SectionHeader> + <SectionContent + className={cn( + !element.background.enabled && "pointer-events-none opacity-50", + )} + > + <SectionFields> + <SectionField + label="Color" + beforeLabel={ + <KeyframeToggle + isActive={bgColor.isKeyframedAtTime} + isDisabled={!isPlayheadWithinElementRange} + title="Toggle background color keyframe" + onToggle={bgColor.toggleKeyframe} + /> + } + > + <ColorPicker + value={ + !element.background.enabled || + element.background.color === "transparent" + ? lastSelectedColor.current.replace("#", "") + : resolvedBgColor.replace("#", "") + } + onChange={(color) => { + const hexColor = `#${color}`; + if (color !== "transparent") { + lastSelectedColor.current = hexColor; + } + bgColor.onChange({ color: hexColor }); + }} + onChangeEnd={bgColor.onChangeEnd} + /> + </SectionField> + <div className="flex items-start gap-2"> + <SectionField + label="Width" + className="w-1/2" + beforeLabel={ + <KeyframeToggle + isActive={paddingX.isKeyframedAtTime} + isDisabled={!isPlayheadWithinElementRange} + title="Toggle background width keyframe" + onToggle={paddingX.toggleKeyframe} + /> + } + > + <NumberField + icon="W" + value={paddingX.displayValue} + min={0} + onFocus={paddingX.onFocus} + onChange={paddingX.onChange} + onBlur={paddingX.onBlur} + onScrub={paddingX.scrubTo} + onScrubEnd={paddingX.commitScrub} + onReset={() => + paddingX.commitValue({ + value: DEFAULTS.text.background.paddingX, + }) + } + isDefault={isPropertyAtDefault({ + hasAnimatedKeyframes: paddingX.hasAnimatedKeyframes, + isPlayheadWithinElementRange, + resolvedValue: resolvedPaddingX, + staticValue: bg.paddingX ?? DEFAULTS.text.background.paddingX, + defaultValue: DEFAULTS.text.background.paddingX, + })} + /> + </SectionField> + <SectionField + label="Height" + className="w-1/2" + beforeLabel={ + <KeyframeToggle + isActive={paddingY.isKeyframedAtTime} + isDisabled={!isPlayheadWithinElementRange} + title="Toggle background height keyframe" + onToggle={paddingY.toggleKeyframe} + /> + } + > + <NumberField + icon="H" + value={paddingY.displayValue} + min={0} + onFocus={paddingY.onFocus} + onChange={paddingY.onChange} + onBlur={paddingY.onBlur} + onScrub={paddingY.scrubTo} + onScrubEnd={paddingY.commitScrub} + onReset={() => + paddingY.commitValue({ + value: DEFAULTS.text.background.paddingY, + }) + } + isDefault={isPropertyAtDefault({ + hasAnimatedKeyframes: paddingY.hasAnimatedKeyframes, + isPlayheadWithinElementRange, + resolvedValue: resolvedPaddingY, + staticValue: bg.paddingY ?? DEFAULTS.text.background.paddingY, + defaultValue: DEFAULTS.text.background.paddingY, + })} + /> + </SectionField> + </div> + <div className="flex items-start gap-2"> + <SectionField + label="X-offset" + className="w-1/2" + beforeLabel={ + <KeyframeToggle + isActive={offsetX.isKeyframedAtTime} + isDisabled={!isPlayheadWithinElementRange} + title="Toggle x-offset keyframe" + onToggle={offsetX.toggleKeyframe} + /> + } + > + <NumberField + icon="X" + value={offsetX.displayValue} + onFocus={offsetX.onFocus} + onChange={offsetX.onChange} + onBlur={offsetX.onBlur} + onScrub={offsetX.scrubTo} + onScrubEnd={offsetX.commitScrub} + onReset={() => + offsetX.commitValue({ + value: DEFAULTS.text.background.offsetX, + }) + } + isDefault={isPropertyAtDefault({ + hasAnimatedKeyframes: offsetX.hasAnimatedKeyframes, + isPlayheadWithinElementRange, + resolvedValue: resolvedOffsetX, + staticValue: bg.offsetX ?? DEFAULTS.text.background.offsetX, + defaultValue: DEFAULTS.text.background.offsetX, + })} + /> + </SectionField> + <SectionField + label="Y-offset" + className="w-1/2" + beforeLabel={ + <KeyframeToggle + isActive={offsetY.isKeyframedAtTime} + isDisabled={!isPlayheadWithinElementRange} + title="Toggle y-offset keyframe" + onToggle={offsetY.toggleKeyframe} + /> + } + > + <NumberField + icon="Y" + value={offsetY.displayValue} + onFocus={offsetY.onFocus} + onChange={offsetY.onChange} + onBlur={offsetY.onBlur} + onScrub={offsetY.scrubTo} + onScrubEnd={offsetY.commitScrub} + onReset={() => + offsetY.commitValue({ + value: DEFAULTS.text.background.offsetY, + }) + } + isDefault={isPropertyAtDefault({ + hasAnimatedKeyframes: offsetY.hasAnimatedKeyframes, + isPlayheadWithinElementRange, + resolvedValue: resolvedOffsetY, + staticValue: bg.offsetY ?? DEFAULTS.text.background.offsetY, + defaultValue: DEFAULTS.text.background.offsetY, + })} + /> + </SectionField> + </div> + <SectionField + label="Corner radius" + beforeLabel={ + <KeyframeToggle + isActive={cornerRadius.isKeyframedAtTime} + isDisabled={!isPlayheadWithinElementRange} + title="Toggle corner radius keyframe" + onToggle={cornerRadius.toggleKeyframe} + /> + } + > + <NumberField + icon="R" + value={cornerRadius.displayValue} + min={CORNER_RADIUS_MIN} + max={CORNER_RADIUS_MAX} + onFocus={cornerRadius.onFocus} + onChange={cornerRadius.onChange} + onBlur={cornerRadius.onBlur} + onScrub={cornerRadius.scrubTo} + onScrubEnd={cornerRadius.commitScrub} + onReset={() => + cornerRadius.commitValue({ value: CORNER_RADIUS_MIN }) + } + isDefault={isPropertyAtDefault({ + hasAnimatedKeyframes: cornerRadius.hasAnimatedKeyframes, + isPlayheadWithinElementRange, + resolvedValue: resolvedCornerRadius, + staticValue: bg.cornerRadius ?? CORNER_RADIUS_MIN, + defaultValue: CORNER_RADIUS_MIN, + })} + /> + </SectionField> + </SectionFields> + </SectionContent> + </Section> + ); +} diff --git a/apps/web/src/text/layout.ts b/apps/web/src/text/layout.ts new file mode 100644 index 00000000..aabf5ca9 --- /dev/null +++ b/apps/web/src/text/layout.ts @@ -0,0 +1,226 @@ +import type { TextBackground, TextElement } from "@/timeline"; +import { DEFAULTS } from "@/timeline/defaults"; + +type TextRect = { + left: number; + top: number; + width: number; + height: number; +}; + +export interface TextBlockMeasurement { + visualCenterOffset: number; + height: number; + maxWidth: number; +} + +export type TextCanvasContext = + | CanvasRenderingContext2D + | OffscreenCanvasRenderingContext2D; + +const TEXT_DECORATION_THICKNESS_RATIO = 0.07; +const STRIKETHROUGH_VERTICAL_RATIO = 0.35; + +export function setCanvasLetterSpacing({ + ctx, + letterSpacingPx, +}: { + ctx: TextCanvasContext; + letterSpacingPx: number; +}): void { + if ("letterSpacing" in ctx) { + (ctx as CanvasRenderingContext2D & { letterSpacing: string }).letterSpacing = + `${letterSpacingPx}px`; + } +} + +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, +}: { + lineMetrics: TextMetrics[]; + lineHeightPx: number; +}): TextBlockMeasurement { + let maxWidth = 0; + + for (const metrics of lineMetrics) { + maxWidth = Math.max(maxWidth, metrics.width); + } + + const lineCount = lineMetrics.length; + const height = lineCount * lineHeightPx; + const visualCenterOffset = ((lineCount - 1) * lineHeightPx) / 2; + + return { visualCenterOffset, height, maxWidth }; +} + +function getTextRect({ + textAlign, + block, +}: { + textAlign: TextElement["textAlign"]; + block: TextBlockMeasurement; +}): TextRect { + const textAlignToLeft: Record<typeof textAlign, number> = { + left: 0, + right: -block.maxWidth, + center: -block.maxWidth / 2, + }; + const left = textAlignToLeft[textAlign]; + + return { + left, + top: -block.height / 2, + width: block.maxWidth, + height: block.height, + }; +} + +function isTextBackgroundVisible({ + background, +}: { + background: TextBackground; +}): boolean { + return ( + background.enabled && + Boolean(background.color) && + background.color !== "transparent" + ); +} + +export function getTextBackgroundRect({ + textAlign, + block, + background, + fontSizeRatio = 1, +}: { + textAlign: TextElement["textAlign"]; + block: TextBlockMeasurement; + background: TextBackground; + fontSizeRatio?: number; +}): TextRect | null { + if (!isTextBackgroundVisible({ background })) { + return null; + } + + const textRect = getTextRect({ textAlign, block }); + const paddingX = + (background.paddingX ?? DEFAULTS.text.background.paddingX) * fontSizeRatio; + const paddingY = + (background.paddingY ?? DEFAULTS.text.background.paddingY) * fontSizeRatio; + const offsetX = background.offsetX ?? DEFAULTS.text.background.offsetX; + const offsetY = background.offsetY ?? DEFAULTS.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: TextBackground; + 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, + }; +} + +export function drawTextDecoration({ + ctx, + textDecoration, + lineWidth, + lineY, + metrics, + scaledFontSize, + textAlign, +}: { + ctx: TextCanvasContext; + textDecoration: string; + lineWidth: number; + lineY: number; + metrics: TextMetrics; + scaledFontSize: number; + textAlign: CanvasTextAlign; +}): void { + if (textDecoration === "none" || !textDecoration) return; + + const thickness = Math.max( + 1, + scaledFontSize * TEXT_DECORATION_THICKNESS_RATIO, + ); + const ascent = getMetricAscent({ metrics, fallbackFontSize: scaledFontSize }); + const descent = getMetricDescent({ + metrics, + fallbackFontSize: scaledFontSize, + }); + + let xStart = -lineWidth / 2; + if (textAlign === "left") xStart = 0; + if (textAlign === "right") xStart = -lineWidth; + + if (textDecoration === "underline") { + const underlineY = lineY + descent + thickness; + ctx.fillRect(xStart, underlineY, lineWidth, thickness); + } + + if (textDecoration === "line-through") { + const strikeY = lineY - (ascent - descent) * STRIKETHROUGH_VERTICAL_RATIO; + ctx.fillRect(xStart, strikeY, lineWidth, thickness); + } +} diff --git a/apps/web/src/text/measure-element.ts b/apps/web/src/text/measure-element.ts new file mode 100644 index 00000000..3a49696f --- /dev/null +++ b/apps/web/src/text/measure-element.ts @@ -0,0 +1,133 @@ +import { CORNER_RADIUS_MIN } from "@/text/background"; +import { resolveNumberAtTime } from "@/animation"; +import { DEFAULTS } from "@/timeline/defaults"; +import type { TextBackground, TextElement } from "@/timeline"; +import { + getTextVisualRect, +} from "./layout"; +import { + measureTextLayout, + type MeasuredTextLayout, +} from "./primitives"; + +export interface ResolvedTextBackground extends TextBackground { + paddingX: number; + paddingY: number; + offsetX: number; + offsetY: number; + cornerRadius: number; +} + +export interface MeasuredTextElement extends MeasuredTextLayout { + resolvedBackground: ResolvedTextBackground; + visualRect: { left: number; top: number; width: number; height: number }; +} + +let textMeasurementContext: + | CanvasRenderingContext2D + | OffscreenCanvasRenderingContext2D + | null = null; + +export function getTextMeasurementContext(): + | CanvasRenderingContext2D + | OffscreenCanvasRenderingContext2D { + if (textMeasurementContext) { + return textMeasurementContext; + } + + if (typeof OffscreenCanvas !== "undefined") { + const canvas = new OffscreenCanvas(1, 1); + const context = canvas.getContext("2d"); + if (context) { + textMeasurementContext = context; + return context; + } + } + + if (typeof document !== "undefined") { + const canvas = document.createElement("canvas"); + const context = canvas.getContext("2d"); + if (context) { + textMeasurementContext = context; + return context; + } + } + + throw new Error("Failed to create text measurement context"); +} + +export function measureTextElement({ + element, + canvasHeight, + localTime, + ctx, +}: { + element: TextElement; + canvasHeight: number; + localTime: number; + ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D; +}): MeasuredTextElement { + const measuredLayout = measureTextLayout({ + text: { + content: element.content, + fontSize: element.fontSize, + fontFamily: element.fontFamily, + fontWeight: element.fontWeight, + fontStyle: element.fontStyle, + textAlign: element.textAlign, + textDecoration: element.textDecoration, + letterSpacing: element.letterSpacing, + lineHeight: element.lineHeight, + }, + canvasHeight, + ctx, + }); + + const bg = element.background; + const resolvedBackground: ResolvedTextBackground = { + ...bg, + paddingX: resolveNumberAtTime({ + baseValue: bg.paddingX ?? DEFAULTS.text.background.paddingX, + animations: element.animations, + propertyPath: "background.paddingX", + localTime, + }), + paddingY: resolveNumberAtTime({ + baseValue: bg.paddingY ?? DEFAULTS.text.background.paddingY, + animations: element.animations, + propertyPath: "background.paddingY", + localTime, + }), + offsetX: resolveNumberAtTime({ + baseValue: bg.offsetX ?? DEFAULTS.text.background.offsetX, + animations: element.animations, + propertyPath: "background.offsetX", + localTime, + }), + offsetY: resolveNumberAtTime({ + baseValue: bg.offsetY ?? DEFAULTS.text.background.offsetY, + animations: element.animations, + propertyPath: "background.offsetY", + localTime, + }), + cornerRadius: resolveNumberAtTime({ + baseValue: bg.cornerRadius ?? CORNER_RADIUS_MIN, + animations: element.animations, + propertyPath: "background.cornerRadius", + localTime, + }), + }; + + const visualRect = getTextVisualRect({ + textAlign: element.textAlign, + block: measuredLayout.block, + background: resolvedBackground, + fontSizeRatio: measuredLayout.fontSizeRatio, + }); + + return { + ...measuredLayout, + resolvedBackground, + visualRect, + }; +} diff --git a/apps/web/src/text/primitives.ts b/apps/web/src/text/primitives.ts new file mode 100644 index 00000000..9a9262a4 --- /dev/null +++ b/apps/web/src/text/primitives.ts @@ -0,0 +1,241 @@ +import type { TextCanvasContext, TextBlockMeasurement } from "@/text/layout"; +import { DEFAULTS } from "@/timeline/defaults"; +import { clamp } from "@/utils/math"; +import { CORNER_RADIUS_MAX, CORNER_RADIUS_MIN } from "./background"; +import { + drawTextDecoration, + getTextBackgroundRect, + measureTextBlock, + setCanvasLetterSpacing, +} from "./layout"; +import { FONT_SIZE_SCALE_REFERENCE } from "./typography"; + +export type TextAlign = "left" | "center" | "right"; +export type TextFontWeight = "normal" | "bold"; +export type TextFontStyle = "normal" | "italic"; +export type TextDecoration = "none" | "underline" | "line-through"; + +export interface TextLayoutParams { + content: string; + fontSize: number; + fontFamily: string; + fontWeight: TextFontWeight; + fontStyle: TextFontStyle; + textAlign: TextAlign; + textDecoration?: TextDecoration; + letterSpacing?: number; + lineHeight?: number; +} + +export interface ResolvedTextLayout { + scaledFontSize: number; + fontString: string; + letterSpacing: number; + lineHeightPx: number; + fontSizeRatio: number; + textAlign: TextAlign; + textDecoration: TextDecoration; +} + +export interface MeasuredTextLayout extends ResolvedTextLayout { + lines: string[]; + lineMetrics: TextMetrics[]; + block: TextBlockMeasurement; +} + +export interface ResolvedTextBackgroundLike { + enabled: boolean; + color: string; + paddingX: number; + paddingY: number; + offsetX: number; + offsetY: number; + cornerRadius: number; +} + +export function quoteFontFamily({ fontFamily }: { fontFamily: string }): string { + return `"${fontFamily.replace(/"/g, '\\"')}"`; +} + +export function buildTextFontString({ + fontFamily, + fontWeight, + fontStyle, + scaledFontSize, +}: { + fontFamily: string; + fontWeight: TextFontWeight; + fontStyle: TextFontStyle; + scaledFontSize: number; +}): string { + return `${fontStyle} ${fontWeight} ${scaledFontSize}px ${quoteFontFamily({ fontFamily })}, sans-serif`; +} + +export function resolveTextLayout({ + text, + canvasHeight, +}: { + text: TextLayoutParams; + canvasHeight: number; +}): ResolvedTextLayout { + const scaledFontSize = + text.fontSize * (canvasHeight / FONT_SIZE_SCALE_REFERENCE); + const fontWeight = text.fontWeight === "bold" ? "bold" : "normal"; + const fontStyle = text.fontStyle === "italic" ? "italic" : "normal"; + const letterSpacing = text.letterSpacing ?? DEFAULTS.text.letterSpacing; + const lineHeightPx = + scaledFontSize * (text.lineHeight ?? DEFAULTS.text.lineHeight); + const fontSizeRatio = text.fontSize / DEFAULTS.text.element.fontSize; + + return { + scaledFontSize, + fontString: buildTextFontString({ + fontFamily: text.fontFamily, + fontWeight, + fontStyle, + scaledFontSize, + }), + letterSpacing, + lineHeightPx, + fontSizeRatio, + textAlign: text.textAlign, + textDecoration: text.textDecoration ?? "none", + }; +} + +export function measureTextLayout({ + text, + canvasHeight, + ctx, +}: { + text: TextLayoutParams; + canvasHeight: number; + ctx: TextCanvasContext; +}): MeasuredTextLayout { + const resolvedLayout = resolveTextLayout({ text, canvasHeight }); + const lines = text.content.split("\n"); + + ctx.save(); + ctx.font = resolvedLayout.fontString; + ctx.textBaseline = "middle"; + setCanvasLetterSpacing({ + ctx, + letterSpacingPx: resolvedLayout.letterSpacing, + }); + const lineMetrics = lines.map((line) => ctx.measureText(line)); + ctx.restore(); + + const block = measureTextBlock({ + lineMetrics, + lineHeightPx: resolvedLayout.lineHeightPx, + }); + + return { + ...resolvedLayout, + lines, + lineMetrics, + block, + }; +} + +export function drawMeasuredTextLayout({ + ctx, + layout, + textColor, + background, + backgroundColor, + textBaseline = "middle", +}: { + ctx: TextCanvasContext; + layout: MeasuredTextLayout; + textColor: string; + background?: ResolvedTextBackgroundLike | null; + backgroundColor?: string; + textBaseline?: CanvasTextBaseline; +}): void { + ctx.font = layout.fontString; + ctx.textAlign = layout.textAlign; + ctx.textBaseline = textBaseline; + ctx.fillStyle = textColor; + setCanvasLetterSpacing({ ctx, letterSpacingPx: layout.letterSpacing }); + + if ( + background?.enabled && + backgroundColor && + backgroundColor !== "transparent" && + layout.lines.length > 0 + ) { + const backgroundRect = getTextBackgroundRect({ + textAlign: layout.textAlign, + block: layout.block, + background: { + ...background, + color: backgroundColor, + }, + fontSizeRatio: layout.fontSizeRatio, + }); + if (backgroundRect) { + const p = + clamp({ + value: background.cornerRadius, + min: CORNER_RADIUS_MIN, + max: CORNER_RADIUS_MAX, + }) / 100; + const radius = + (Math.min(backgroundRect.width, backgroundRect.height) / 2) * p; + ctx.fillStyle = backgroundColor; + ctx.beginPath(); + ctx.roundRect( + backgroundRect.left, + backgroundRect.top, + backgroundRect.width, + backgroundRect.height, + radius, + ); + ctx.fill(); + ctx.fillStyle = textColor; + } + } + + for (let index = 0; index < layout.lines.length; index++) { + const lineY = index * layout.lineHeightPx - layout.block.visualCenterOffset; + ctx.fillText(layout.lines[index], 0, lineY); + drawTextDecoration({ + ctx, + textDecoration: layout.textDecoration, + lineWidth: layout.lineMetrics[index].width, + lineY, + metrics: layout.lineMetrics[index], + scaledFontSize: layout.scaledFontSize, + textAlign: layout.textAlign, + }); + } +} + +export function strokeMeasuredTextLayout({ + ctx, + layout, + strokeColor, + strokeWidth, + textBaseline = "middle", +}: { + ctx: TextCanvasContext; + layout: MeasuredTextLayout; + strokeColor: string; + strokeWidth: number; + textBaseline?: CanvasTextBaseline; +}): void { + ctx.font = layout.fontString; + ctx.textAlign = layout.textAlign; + ctx.textBaseline = textBaseline; + ctx.strokeStyle = strokeColor; + ctx.lineWidth = strokeWidth; + ctx.lineJoin = "round"; + ctx.lineCap = "round"; + setCanvasLetterSpacing({ ctx, letterSpacingPx: layout.letterSpacing }); + + for (let index = 0; index < layout.lines.length; index++) { + const lineY = index * layout.lineHeightPx - layout.block.visualCenterOffset; + ctx.strokeText(layout.lines[index], 0, lineY); + } +} diff --git a/apps/web/src/text/typography.ts b/apps/web/src/text/typography.ts new file mode 100644 index 00000000..837236ab --- /dev/null +++ b/apps/web/src/text/typography.ts @@ -0,0 +1,9 @@ +export const MIN_FONT_SIZE = 5; +export const MAX_FONT_SIZE = 300; +export const DEFAULT_TEXT_COLOR = "#000000"; + +/** + * higher value: smaller font size + * lower value: larger font size + */ +export const FONT_SIZE_SCALE_REFERENCE = 90; |
