From 93d1e3383ce47e45157d70f9ba6251239dae6bc2 Mon Sep 17 00:00:00 2001 From: Maze <167211895+mazeincoding@users.noreply.github.com> Date: Mon, 23 Feb 2026 03:24:02 +0100 Subject: feat: major editor overhaul (assets, properties, timeline, fonts) (#709) * feat: major editor overhaul (assets, properties, timeline, fonts) Refactor editor core systems to standardize UI architecture and improve performance. Assets & Properties: - Replace monolithic property items with composable `Section` architecture. - Add specialized sections for Transform, Blending, and Text. - Implement `NumberField` with scrubbing and math evaluation. - Add new ColorPicker with EyeDropper and multiple format support. - Standardize asset panels using new `PanelView` layout. Fonts & Stickers: - Implement custom font atlas/sprite system for high-performance previews. - Add virtualized FontPicker with search and favorites. - Refactor stickers to use a provider-based architecture (icons, emoji, flags, shapes). - Standardize sticker IDs to `provider:value` format. Timeline & Interaction: - Convert bookmarks to rich objects with notes, colors, and duration. - Refactor drag-and-drop to use Command pattern (enabling proper undo/redo). - Add Shift modifier to disable snapping during moves/resizes. - Add new overlays for layout guides and text editing. Renderer: - Add support for multi-line text, custom line-height, and letter-spacing. - Implement global composite operation (blend modes). - Update sticker node to resolve dynamic provider IDs. Infrastructure: - Add storage migrations (v3->v6) for text weights, sticker IDs, and bookmarks. - Update global styles and core UI components (Button, Input, Popover). * add ts-nocheck directive to settings-legacy.tsx to suppress TypeScript errors * fix: correct global composite operation assignment in TextNode to ensure proper blend mode handling * deleted shadcn components with errors * formatting * fix linter issues * migrate from next middleware to proxy * add missing component back * add breadcrumb back * chore: add @radix-ui/react-primitive deps * chore: more deps * chore: add missing env vars to bun-ci * next env --- .../editor/panels/properties/empty-view.tsx | 2 +- .../panels/properties/hooks/use-property-draft.ts | 72 ++ .../editor/panels/properties/property-item.tsx | 132 ---- .../editor/panels/properties/section.tsx | 161 ++++ .../editor/panels/properties/sections/blending.tsx | 196 +++++ .../editor/panels/properties/sections/index.tsx | 2 + .../panels/properties/sections/transform.tsx | 248 +++++++ .../editor/panels/properties/text-properties.tsx | 806 ++++++--------------- 8 files changed, 889 insertions(+), 730 deletions(-) create mode 100644 apps/web/src/components/editor/panels/properties/hooks/use-property-draft.ts delete mode 100644 apps/web/src/components/editor/panels/properties/property-item.tsx create mode 100644 apps/web/src/components/editor/panels/properties/section.tsx create mode 100644 apps/web/src/components/editor/panels/properties/sections/blending.tsx create mode 100644 apps/web/src/components/editor/panels/properties/sections/index.tsx create mode 100644 apps/web/src/components/editor/panels/properties/sections/transform.tsx (limited to 'apps/web/src/components/editor/panels/properties') diff --git a/apps/web/src/components/editor/panels/properties/empty-view.tsx b/apps/web/src/components/editor/panels/properties/empty-view.tsx index 8ccf07ee..c5576dda 100644 --- a/apps/web/src/components/editor/panels/properties/empty-view.tsx +++ b/apps/web/src/components/editor/panels/properties/empty-view.tsx @@ -17,4 +17,4 @@ export function EmptyView() { ); -} \ No newline at end of file +} diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-property-draft.ts b/apps/web/src/components/editor/panels/properties/hooks/use-property-draft.ts new file mode 100644 index 00000000..395666cb --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/hooks/use-property-draft.ts @@ -0,0 +1,72 @@ +import { useReducer, useRef } from "react"; +import { evaluateMathExpression } from "@/utils/math"; + +function looksLikeExpression({ input }: { input: string }): boolean { + const trimmed = input.trim(); + if (!trimmed) return false; + if (/[+*/]/.test(input)) return true; + const minusIndex = trimmed.indexOf("-"); + return minusIndex > 0; +} + +export function usePropertyDraft({ + displayValue: sourceDisplay, + parse, + onPreview, + onCommit, + supportsExpressions = true, +}: { + displayValue: string; + parse: (input: string) => T | null; + onPreview: (value: T) => void; + onCommit: () => void; + supportsExpressions?: boolean; +}) { + const [, forceRender] = useReducer( + (renderVersion: number) => renderVersion + 1, + 0, + ); + const isEditing = useRef(false); + const draft = useRef(""); + + return { + displayValue: isEditing.current ? draft.current : sourceDisplay, + scrubTo: (value: number) => { + const parsed = parse(String(value)); + if (parsed !== null) onPreview(parsed); + }, + commitScrub: onCommit, + onFocus: () => { + isEditing.current = true; + draft.current = sourceDisplay; + forceRender(); + }, + onChange: ( + event: React.ChangeEvent, + ) => { + draft.current = event.target.value; + forceRender(); + + const parsed = parse(event.target.value); + if (parsed !== null) { + onPreview(parsed); + } + }, + onBlur: () => { + if ( + supportsExpressions && + looksLikeExpression({ input: draft.current }) + ) { + const evaluated = evaluateMathExpression({ input: draft.current }); + if (evaluated !== null) { + const parsed = parse(String(evaluated)); + if (parsed !== null) onPreview(parsed); + } + } + onCommit(); + isEditing.current = false; + draft.current = ""; + forceRender(); + }, + }; +} diff --git a/apps/web/src/components/editor/panels/properties/property-item.tsx b/apps/web/src/components/editor/panels/properties/property-item.tsx deleted file mode 100644 index a682097a..00000000 --- a/apps/web/src/components/editor/panels/properties/property-item.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import { useState } from "react"; -import { cn } from "@/utils/ui"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { MinusSignIcon, PlusSignIcon } from "@hugeicons/core-free-icons"; - -interface PropertyItemProps { - direction?: "row" | "column"; - children: React.ReactNode; - className?: string; -} - -export function PropertyItem({ - direction = "row", - children, - className, -}: PropertyItemProps) { - return ( -
- {children} -
- ); -} - -export function PropertyItemLabel({ - children, - className, -}: { - children: React.ReactNode; - className?: string; -}) { - return ( - - {children} - - ); -} - -export function PropertyItemValue({ - children, - className, -}: { - children: React.ReactNode; - className?: string; -}) { - return
{children}
; -} - -interface PropertyGroupProps { - title: string; - children: React.ReactNode; - defaultExpanded?: boolean; - collapsible?: boolean; - className?: string; - hasBorderTop?: boolean; - hasBorderBottom?: boolean; -} - -export function PropertyGroup({ - title, - children, - defaultExpanded = true, - collapsible = true, - className, - hasBorderTop = true, - hasBorderBottom = true, -}: PropertyGroupProps) { - const [isExpanded, setIsExpanded] = useState(defaultExpanded); - - return ( -
- {collapsible ? ( - - ) : ( -
- {title} -
- )} - {(collapsible ? isExpanded : true) && ( -
{children}
- )} -
- ); -} - -function PropertyGroupTitle({ - children, - isExpanded = false, -}: { - children: React.ReactNode; - isExpanded?: boolean; -}) { - return ( - - {children} - - ); -} diff --git a/apps/web/src/components/editor/panels/properties/section.tsx b/apps/web/src/components/editor/panels/properties/section.tsx new file mode 100644 index 00000000..0b45065f --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/section.tsx @@ -0,0 +1,161 @@ +import { createContext, useContext, useState } from "react"; +import { cn } from "@/utils/ui"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { ArrowDownIcon } from "@hugeicons/core-free-icons"; + +const sectionExpandedCache = new Map(); + +interface SectionContext { + isOpen: boolean; + toggle: () => void; + collapsible: boolean; +} + +const SectionCtx = createContext(null); + +function useSectionContext() { + return useContext(SectionCtx); +} + +interface SectionProps { + children: React.ReactNode; + collapsible?: boolean; + defaultOpen?: boolean; + sectionKey?: string; + className?: string; + hasBorderTop?: boolean; + hasBorderBottom?: boolean; +} + +export function Section({ + children, + collapsible = false, + defaultOpen = true, + sectionKey, + className, + hasBorderTop = true, + hasBorderBottom = true, +}: SectionProps) { + const cached = sectionKey ? sectionExpandedCache.get(sectionKey) : undefined; + const [isOpen, setIsOpen] = useState(cached ?? defaultOpen); + + const toggle = () => { + const next = !isOpen; + setIsOpen(next); + if (sectionKey) sectionExpandedCache.set(sectionKey, next); + }; + + return ( + +
+ {children} +
+
+ ); +} + +interface SectionHeaderProps { + title: string; + children?: React.ReactNode; + onClick?: () => void; + className?: string; +} + +export function SectionHeader({ + title, + children, + onClick, + className, +}: SectionHeaderProps) { + const ctx = useSectionContext(); + const isCollapsible = ctx?.collapsible ?? false; + const isOpen = ctx?.isOpen ?? true; + const isInteractive = isCollapsible || !!onClick; + + const handleClick = isCollapsible ? ctx?.toggle : onClick; + + const content = ( + <> + + {title} + +
+ {children} + {isCollapsible && ( + + )} +
+ + ); + + const baseClassName = cn( + "flex w-full items-center justify-between h-11 px-3.5", + className, + ); + + if (isInteractive) { + return ( + + ); + } + + return
{content}
; +} + +export function SectionContent({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + const ctx = useSectionContext(); + const isCollapsible = ctx?.collapsible ?? false; + const isOpen = ctx?.isOpen ?? true; + + if (isCollapsible) { + return ( +
+
+
{children}
+
+
+ ); + } + + return
{children}
; +} diff --git a/apps/web/src/components/editor/panels/properties/sections/blending.tsx b/apps/web/src/components/editor/panels/properties/sections/blending.tsx new file mode 100644 index 00000000..430efa25 --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/sections/blending.tsx @@ -0,0 +1,196 @@ +import { useEditor } from "@/hooks/use-editor"; +import { usePropertyDraft } from "../hooks/use-property-draft"; +import { clamp } from "@/utils/math"; +import { NumberField } from "@/components/ui/number-field"; +import { + DEFAULT_BLEND_MODE, + DEFAULT_OPACITY, +} from "@/constants/timeline-constants"; +import { OcCheckerboardIcon } from "@opencut/ui/icons"; +import { Fragment, useRef } from "react"; +import { Section, SectionContent, SectionHeader } from "../section"; +import { + Select, + SelectContent, + SelectItem, + SelectSeparator, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import type { BlendMode } from "@/types/rendering"; +import type { ElementType } from "@/types/timeline"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { RainDropIcon } from "@hugeicons/core-free-icons"; + +type BlendingElement = { + id: string; + opacity: number; + type: ElementType; + blendMode?: BlendMode; +}; + +const BLEND_MODE_GROUPS = [ + [{ value: "normal", label: "Normal" }], + [ + { value: "darken", label: "Darken" }, + { value: "multiply", label: "Multiply" }, + { value: "color-burn", label: "Color Burn" }, + ], + [ + { value: "lighten", label: "Lighten" }, + { value: "screen", label: "Screen" }, + { value: "plus-lighter", label: "Plus Lighter" }, + { value: "color-dodge", label: "Color Dodge" }, + ], + [ + { value: "overlay", label: "Overlay" }, + { value: "soft-light", label: "Soft Light" }, + { value: "hard-light", label: "Hard Light" }, + ], + [ + { value: "difference", label: "Difference" }, + { value: "exclusion", label: "Exclusion" }, + ], + [ + { value: "hue", label: "Hue" }, + { value: "saturation", label: "Saturation" }, + { value: "color", label: "Color" }, + { value: "luminosity", label: "Luminosity" }, + ], +]; + +export function BlendingSection({ + element, + trackId, +}: { + element: BlendingElement; + trackId: string; +}) { + const editor = useEditor(); + const blendMode = element.blendMode ?? DEFAULT_BLEND_MODE; + const didSelectRef = useRef(false); + const committedBlendModeRef = useRef(blendMode); + if (!editor.timeline.isPreviewActive()) { + committedBlendModeRef.current = blendMode; + } + + const previewBlendMode = (value: BlendMode) => + editor.timeline.previewElements({ + updates: [ + { trackId, elementId: element.id, updates: { blendMode: value } }, + ], + }); + + const commitBlendMode = (value: string) => { + if (editor.timeline.isPreviewActive()) { + editor.timeline.commitPreview(); + } else { + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { blendMode: value as BlendMode }, + }, + ], + }); + } + didSelectRef.current = true; + }; + + const handleBlendModeOpenChange = (isOpen: boolean) => { + if (!isOpen) { + if (!didSelectRef.current) editor.timeline.discardPreview(); + didSelectRef.current = false; + } + }; + + const opacity = usePropertyDraft({ + displayValue: Math.round(element.opacity * 100).toString(), + parse: (input) => { + const parsed = parseFloat(input); + if (Number.isNaN(parsed)) return null; + return clamp({ value: parsed, min: 0, max: 100 }) / 100; + }, + onPreview: (value) => + editor.timeline.previewElements({ + updates: [ + { trackId, elementId: element.id, updates: { opacity: value } }, + ], + }), + onCommit: () => editor.timeline.commitPreview(), + }); + + return ( +
+ + +
+
+ + } + 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" + /> +
+
+ +
+
+
+
+ ); +} diff --git a/apps/web/src/components/editor/panels/properties/sections/index.tsx b/apps/web/src/components/editor/panels/properties/sections/index.tsx new file mode 100644 index 00000000..8be58f39 --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/sections/index.tsx @@ -0,0 +1,2 @@ +export * from "./transform"; +export * from "./blending"; diff --git a/apps/web/src/components/editor/panels/properties/sections/transform.tsx b/apps/web/src/components/editor/panels/properties/sections/transform.tsx new file mode 100644 index 00000000..17a54b4c --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/sections/transform.tsx @@ -0,0 +1,248 @@ +import { NumberField } from "@/components/ui/number-field"; +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 { Button } from "@/components/ui/button"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { + ArrowExpandIcon, + Link05Icon, + RotateClockwiseIcon, +} from "@hugeicons/core-free-icons"; +import { useState } from "react"; +import { DEFAULT_TRANSFORM } from "@/constants/timeline-constants"; + +type TransformElement = { + id: string; + transform: Transform; + type: ElementType; +}; + +function parseFloat_({ input }: { input: string }): number | null { + const parsed = parseFloat(input); + return Number.isNaN(parsed) ? null : parsed; +} + +export function TransformSection({ + element, + trackId, +}: { + element: TransformElement; + trackId: string; +}) { + const editor = useEditor(); + const [isScaleLocked, setIsScaleLocked] = useState(false); + + const previewTransform = (transform: Partial) => { + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { transform: { ...element.transform, ...transform } }, + }, + ], + }); + }; + + const commit = () => editor.timeline.commitPreview(); + + const positionX = usePropertyDraft({ + displayValue: Math.round(element.transform.position.x).toString(), + parse: (input) => parseFloat_({ input }), + onPreview: (value) => + previewTransform({ + position: { ...element.transform.position, x: value }, + }), + onCommit: commit, + }); + + const positionY = usePropertyDraft({ + displayValue: Math.round(element.transform.position.y).toString(), + parse: (input) => parseFloat_({ input }), + onPreview: (value) => + previewTransform({ + position: { ...element.transform.position, y: value }, + }), + onCommit: commit, + }); + + const scale = usePropertyDraft({ + displayValue: Math.round(element.transform.scale * 100).toString(), + parse: (input) => { + const parsed = parseFloat_({ input }); + if (parsed === null) return null; + return Math.max(parsed, 1) / 100; + }, + onPreview: (value) => previewTransform({ scale: value }), + onCommit: commit, + }); + const scaleFieldProps = { + className: "flex-1", + value: scale.displayValue, + onFocus: scale.onFocus, + onChange: scale.onChange, + onBlur: scale.onBlur, + dragSensitivity: "slow" as const, + onScrub: scale.scrubTo, + onScrubEnd: scale.commitScrub, + onReset: () => + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { + transform: { + ...element.transform, + scale: DEFAULT_TRANSFORM.scale, + }, + }, + }, + ], + }), + isDefault: element.transform.scale === DEFAULT_TRANSFORM.scale, + }; + + const rotation = usePropertyDraft({ + displayValue: Math.round(element.transform.rotate).toString(), + parse: (input) => { + const parsed = parseFloat_({ input }); + if (parsed === null) return null; + return clamp({ value: parsed, min: -360, max: 360 }); + }, + onPreview: (value) => previewTransform({ rotate: value }), + onCommit: commit, + }); + + return ( +
+ + +
+
+ {isScaleLocked ? ( + <> + + + + ) : ( + } + {...scaleFieldProps} + className="flex-1" + /> + )} + +
+
+ + 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 + } + /> + + 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 + } + /> +
+ +
+ } + 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, + }, + }, + }, + ], + }) + } + isDefault={element.transform.rotate === DEFAULT_TRANSFORM.rotate} + /> +
+
+
+
+ ); +} diff --git a/apps/web/src/components/editor/panels/properties/text-properties.tsx b/apps/web/src/components/editor/panels/properties/text-properties.tsx index 22e50e4e..cfca52dc 100644 --- a/apps/web/src/components/editor/panels/properties/text-properties.tsx +++ b/apps/web/src/components/editor/panels/properties/text-properties.tsx @@ -1,24 +1,31 @@ import { Textarea } from "@/components/ui/textarea"; import { FontPicker } from "@/components/ui/font-picker"; -import type { FontFamily } from "@/constants/font-constants"; import type { TextElement } from "@/types/timeline"; import { Slider } from "@/components/ui/slider"; -import { Input } from "@/components/ui/input"; -import { Button } from "@/components/ui/button"; -import { useReducer, useRef } from "react"; -import { PanelBaseView } from "@/components/editor/panels/panel-base-view"; -import { - PropertyGroup, - PropertyItem, - PropertyItemLabel, - PropertyItemValue, -} from "./property-item"; +import { NumberField } from "@/components/ui/number-field"; +import { useRef } from "react"; +import { Section, SectionContent, 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 { MIN_FONT_SIZE, MAX_FONT_SIZE } from "@/constants/text-constants"; +import { + 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"; export function TextProperties({ element, @@ -27,624 +34,229 @@ export function TextProperties({ element: TextElement; trackId: string; }) { - const editor = useEditor(); - const containerRef = useRef(null); - const [, forceRender] = useReducer((x: number) => x + 1, 0); - const isEditingFontSize = useRef(false); - const isEditingOpacity = useRef(false); - const isEditingContent = useRef(false); - const fontSizeDraft = useRef(""); - const opacityDraft = useRef(""); - const contentDraft = useRef(""); - - const fontSizeDisplay = isEditingFontSize.current - ? fontSizeDraft.current - : element.fontSize.toString(); - const opacityDisplay = isEditingOpacity.current - ? opacityDraft.current - : Math.round(element.opacity * 100).toString(); - const contentDisplay = isEditingContent.current - ? contentDraft.current - : element.content; - - const lastSelectedColor = useRef(DEFAULT_COLOR); - const initialFontSizeRef = useRef(null); - const initialOpacityRef = useRef(null); - const initialContentRef = useRef(null); - const initialColorRef = useRef(null); - const initialBgColorRef = useRef(null); - - const handleFontSizeChange = ({ value }: { value: string }) => { - fontSizeDraft.current = value; - forceRender(); + return ( +
+ + + + + +
+ ); +} - if (value.trim() !== "") { - if (initialFontSizeRef.current === null) { - initialFontSizeRef.current = element.fontSize; - } - const parsed = parseInt(value, 10); - const fontSize = Number.isNaN(parsed) - ? element.fontSize - : clamp({ value: parsed, min: MIN_FONT_SIZE, max: MAX_FONT_SIZE }); - editor.timeline.updateElements({ - updates: [{ trackId, elementId: element.id, updates: { fontSize } }], - pushHistory: false, - }); - } - }; +function ContentSection({ + element, + trackId, +}: { + element: TextElement; + trackId: string; +}) { + const editor = useEditor(); - const handleFontSizeBlur = () => { - if (initialFontSizeRef.current !== null) { - const parsed = parseInt(fontSizeDraft.current, 10); - const fontSize = Number.isNaN(parsed) - ? element.fontSize - : clamp({ value: parsed, min: MIN_FONT_SIZE, max: MAX_FONT_SIZE }); - editor.timeline.updateElements({ + const content = usePropertyDraft({ + displayValue: element.content, + parse: (input) => input, + onPreview: (value) => + editor.timeline.previewElements({ updates: [ - { - trackId, - elementId: element.id, - updates: { fontSize: initialFontSizeRef.current }, - }, + { trackId, elementId: element.id, updates: { content: value } }, ], - pushHistory: false, - }); - editor.timeline.updateElements({ - updates: [{ trackId, elementId: element.id, updates: { fontSize } }], - pushHistory: true, - }); - initialFontSizeRef.current = null; - } - isEditingFontSize.current = false; - fontSizeDraft.current = ""; - forceRender(); - }; + }), + onCommit: () => editor.timeline.commitPreview(), + }); - const handleOpacityChange = ({ value }: { value: string }) => { - opacityDraft.current = value; - forceRender(); - - if (value.trim() !== "") { - if (initialOpacityRef.current === null) { - initialOpacityRef.current = element.opacity; - } - const parsed = parseInt(value, 10); - const opacityPercent = Number.isNaN(parsed) - ? Math.round(element.opacity * 100) - : clamp({ value: parsed, min: 0, max: 100 }); - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { opacity: opacityPercent / 100 }, - }, - ], - pushHistory: false, - }); - } - }; + return ( +
+ + +