diff options
| author | Maze <167211895+mazeincoding@users.noreply.github.com> | 2026-02-23 03:24:02 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-02-23 03:24:02 +0100 |
| commit | 93d1e3383ce47e45157d70f9ba6251239dae6bc2 (patch) | |
| tree | 1e33a2d7533b65f0e7c8927f03905a1a99937bd1 /apps/web/src/components/editor/panels/properties | |
| parent | fca99d6126c31fbb18ed9f1034cee6f940b040e8 (diff) | |
| download | opencut-93d1e3383ce47e45157d70f9ba6251239dae6bc2.tar.gz | |
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
Diffstat (limited to 'apps/web/src/components/editor/panels/properties')
8 files changed, 889 insertions, 730 deletions
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() { </div> </div> ); -}
\ 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<T>({ + 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<HTMLInputElement | HTMLTextAreaElement>, + ) => { + 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 ( - <div - className={cn( - "flex gap-2", - direction === "row" - ? "items-center justify-between gap-6" - : "flex-col gap-1.5", - className, - )} - > - {children} - </div> - ); -} - -export function PropertyItemLabel({ - children, - className, -}: { - children: React.ReactNode; - className?: string; -}) { - return ( - <span className={cn("text-muted-foreground text-xs", className)}> - {children} - </span> - ); -} - -export function PropertyItemValue({ - children, - className, -}: { - children: React.ReactNode; - className?: string; -}) { - return <div className={cn("flex-1 text-sm", className)}>{children}</div>; -} - -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 ( - <div - className={cn( - "flex flex-col", - hasBorderTop && "border-t", - hasBorderBottom && "last:border-b", - className, - )} - > - {collapsible ? ( - <button - type="button" - className="flex items-center justify-between p-3.5 cursor-pointer" - onClick={() => setIsExpanded(!isExpanded)} - > - <PropertyGroupTitle isExpanded={isExpanded}> - {title} - </PropertyGroupTitle> - <HugeiconsIcon - icon={isExpanded ? MinusSignIcon : PlusSignIcon} - className={cn( - "size-3", - isExpanded ? "text-foreground" : "text-muted-foreground", - )} - /> - </button> - ) : ( - <div className="flex items-center justify-between p-4"> - <PropertyGroupTitle isExpanded>{title}</PropertyGroupTitle> - </div> - )} - {(collapsible ? isExpanded : true) && ( - <div className="p-3 pt-0">{children}</div> - )} - </div> - ); -} - -function PropertyGroupTitle({ - children, - isExpanded = false, -}: { - children: React.ReactNode; - isExpanded?: boolean; -}) { - return ( - <span - className={cn( - "text-xs font-medium", - isExpanded ? "text-foreground" : "text-muted-foreground", - )} - > - {children} - </span> - ); -} 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<string, boolean>(); + +interface SectionContext { + isOpen: boolean; + toggle: () => void; + collapsible: boolean; +} + +const SectionCtx = createContext<SectionContext | null>(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 ( + <SectionCtx.Provider value={{ isOpen, toggle, collapsible }}> + <div + className={cn( + "flex flex-col", + hasBorderTop && "border-t", + hasBorderBottom && "last:border-b", + className, + )} + > + {children} + </div> + </SectionCtx.Provider> + ); +} + +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 = ( + <> + <span + className={cn( + "text-sm font-medium", + isOpen ? "text-foreground" : "text-muted-foreground", + )} + > + {title} + </span> + <div className="flex items-center gap-1"> + {children} + {isCollapsible && ( + <HugeiconsIcon + icon={ArrowDownIcon} + className={cn( + "size-3 shrink-0 transition-transform duration-200 ease-out", + isOpen + ? "rotate-0 text-foreground" + : "-rotate-90 text-muted-foreground", + )} + /> + )} + </div> + </> + ); + + const baseClassName = cn( + "flex w-full items-center justify-between h-11 px-3.5", + className, + ); + + if (isInteractive) { + return ( + <button + type="button" + className={cn(baseClassName, "cursor-pointer text-left")} + onClick={(event) => { + handleClick?.(); + event.currentTarget.blur(); + }} + > + {content} + </button> + ); + } + + return <div className={baseClassName}>{content}</div>; +} + +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 ( + <div + className={cn( + "grid transition-[grid-template-rows] duration-100 ease-out", + isOpen ? "grid-rows-[1fr]" : "grid-rows-[0fr]", + )} + > + <div className="overflow-hidden"> + <div className={cn("p-4 pt-0", className)}>{children}</div> + </div> + </div> + ); + } + + return <div className={cn("p-4 pt-0", className)}>{children}</div>; +} 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 ( + <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 + 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> + </Section> + ); +} 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<Transform>) => { + 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 ( + <Section collapsible sectionKey={`${element.type}:transform`}> + <SectionHeader title="Transform" /> + <SectionContent> + <div className="flex flex-col gap-2"> + <div className="flex items-center gap-2"> + {isScaleLocked ? ( + <> + <NumberField icon="W" {...scaleFieldProps} /> + <NumberField icon="H" {...scaleFieldProps} /> + </> + ) : ( + <NumberField + icon={<HugeiconsIcon icon={ArrowExpandIcon} />} + {...scaleFieldProps} + className="flex-1" + /> + )} + <Button + variant={isScaleLocked ? "secondary" : "ghost"} + size="icon" + aria-pressed={isScaleLocked} + onClick={() => setIsScaleLocked((isLocked) => !isLocked)} + > + <HugeiconsIcon icon={Link05Icon} /> + </Button> + </div> + <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, + }, + }, + }, + }, + ], + }) + } + 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 + } + /> + </div> + + <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, + }, + }, + }, + ], + }) + } + isDefault={element.transform.rotate === DEFAULT_TRANSFORM.rotate} + /> + </div> + </div> + </SectionContent> + </Section> + ); +} 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<HTMLDivElement>(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<number | null>(null); - const initialOpacityRef = useRef<number | null>(null); - const initialContentRef = useRef<string | null>(null); - const initialColorRef = useRef<string | null>(null); - const initialBgColorRef = useRef<string | null>(null); - - const handleFontSizeChange = ({ value }: { value: string }) => { - fontSizeDraft.current = value; - forceRender(); + return ( + <div className="flex h-full flex-col"> + <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} /> + </div> + ); +} - 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 ( + <Section collapsible sectionKey="text:content" hasBorderTop={false}> + <SectionHeader title="Content" /> + <SectionContent> + <Textarea + placeholder="Name" + value={content.displayValue} + className="min-h-20" + onFocus={content.onFocus} + onChange={content.onChange} + onBlur={content.onBlur} + /> + </SectionContent> + </Section> + ); +} - const handleOpacityBlur = () => { - if (initialOpacityRef.current !== null) { - const parsed = parseInt(opacityDraft.current, 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: initialOpacityRef.current }, - }, - ], - pushHistory: false, - }); - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { opacity: opacityPercent / 100 }, - }, - ], - pushHistory: true, - }); - initialOpacityRef.current = null; - } - isEditingOpacity.current = false; - opacityDraft.current = ""; - forceRender(); - }; +function FontSection({ + element, + trackId, +}: { + element: TextElement; + trackId: string; +}) { + const editor = useEditor(); - const handleColorChange = ({ color }: { color: string }) => { - if (color !== "transparent") { - lastSelectedColor.current = color; - } - if (initialBgColorRef.current === null) { - initialBgColorRef.current = element.backgroundColor; - } - if (initialBgColorRef.current !== null) { - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { backgroundColor: color }, - }, - ], - pushHistory: false, - }); - } else { - editor.timeline.updateElements({ + const fontSize = usePropertyDraft({ + displayValue: element.fontSize.toString(), + parse: (input) => { + const parsed = parseFloat(input); + if (Number.isNaN(parsed)) return null; + return clamp({ value: parsed, min: MIN_FONT_SIZE, max: MAX_FONT_SIZE }); + }, + onPreview: (value) => + editor.timeline.previewElements({ updates: [ - { - trackId, - elementId: element.id, - updates: { backgroundColor: color }, - }, + { trackId, elementId: element.id, updates: { fontSize: value } }, ], - }); - } - }; - - const handleColorChangeEnd = ({ color }: { color: string }) => { - if (initialBgColorRef.current !== null) { - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { backgroundColor: initialBgColorRef.current }, - }, - ], - pushHistory: false, - }); - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { backgroundColor: `#${color}` }, - }, - ], - pushHistory: true, - }); - initialBgColorRef.current = null; - } - }; + }), + onCommit: () => editor.timeline.commitPreview(), + }); return ( - <div className="flex h-full flex-col" ref={containerRef}> - <PanelBaseView className="p-0"> - <PropertyGroup title="Content" hasBorderTop={false} collapsible={false}> - <Textarea - placeholder="Name" - value={contentDisplay} - className="bg-accent min-h-20" - onFocus={() => { - isEditingContent.current = true; - contentDraft.current = element.content; - initialContentRef.current = element.content; - forceRender(); - }} - onChange={(event) => { - contentDraft.current = event.target.value; - forceRender(); - if (initialContentRef.current === null) { - initialContentRef.current = element.content; - } + <Section collapsible sectionKey="text:font"> + <SectionHeader title="Font" /> + <SectionContent> + <div className="flex flex-col gap-2"> + <FontPicker + defaultValue={element.fontFamily} + onValueChange={(value) => editor.timeline.updateElements({ updates: [ { trackId, elementId: element.id, - updates: { content: event.target.value }, + updates: { fontFamily: value }, }, ], - pushHistory: false, - }); - }} - onBlur={() => { - if (initialContentRef.current !== null) { - const finalContent = contentDraft.current; - editor.timeline.updateElements({ + }) + } + /> + <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: { content: initialContentRef.current }, + updates: { fontSize: value }, }, ], - pushHistory: false, - }); + }) + } + 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: { content: finalContent }, + updates: { + fontSize: DEFAULT_TEXT_ELEMENT.fontSize, + }, }, ], - pushHistory: true, - }); - initialContentRef.current = null; + }) } - isEditingContent.current = false; - contentDraft.current = ""; - forceRender(); + isDefault={element.fontSize === DEFAULT_TEXT_ELEMENT.fontSize} + /> + </div> + </div> + </SectionContent> + </Section> + ); +} + +function ColorSection({ + element, + trackId, +}: { + element: TextElement; + trackId: string; +}) { + const editor = useEditor(); + const lastSelectedColor = useRef(DEFAULT_COLOR); + + return ( + <Section collapsible sectionKey="text:color" hasBorderBottom={false}> + <SectionHeader title="Color" /> + <SectionContent> + <div className="flex flex-col gap-6"> + <ColorPicker + value={uppercase({ + string: (element.color || "FFFFFF").replace("#", ""), + })} + onChange={(color) => + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { color: `#${color}` }, + }, + ], + }) + } + 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" + : "" + } /> - </PropertyGroup> - <PropertyGroup title="Typography" collapsible={false}> - <div className="space-y-6"> - <PropertyItem direction="column"> - <PropertyItemLabel>Font</PropertyItemLabel> - <PropertyItemValue> - <FontPicker - defaultValue={element.fontFamily} - onValueChange={(value: FontFamily) => - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { fontFamily: value }, - }, - ], - }) - } - /> - </PropertyItemValue> - </PropertyItem> - <PropertyItem direction="column"> - <PropertyItemLabel>Style</PropertyItemLabel> - <PropertyItemValue> - <div className="flex items-center gap-2"> - <Button - variant={ - element.fontWeight === "bold" ? "default" : "outline" - } - size="sm" - onClick={() => - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { - fontWeight: - element.fontWeight === "bold" - ? "normal" - : "bold", - }, - }, - ], - }) - } - className="h-8 px-3 font-bold" - > - B - </Button> - <Button - variant={ - element.fontStyle === "italic" ? "default" : "outline" - } - size="sm" - onClick={() => - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { - fontStyle: - element.fontStyle === "italic" - ? "normal" - : "italic", - }, - }, - ], - }) - } - className="h-8 px-3 italic" - > - I - </Button> - <Button - variant={ - element.textDecoration === "underline" - ? "default" - : "outline" - } - size="sm" - onClick={() => - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { - textDecoration: - element.textDecoration === "underline" - ? "none" - : "underline", - }, - }, - ], - }) - } - className="h-8 px-3 underline" - > - U - </Button> - <Button - variant={ - element.textDecoration === "line-through" - ? "default" - : "outline" - } - size="sm" - onClick={() => - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { - textDecoration: - element.textDecoration === "line-through" - ? "none" - : "line-through", - }, - }, - ], - }) - } - className="h-8 px-3 line-through" - > - S - </Button> - </div> - </PropertyItemValue> - </PropertyItem> - <PropertyItem direction="column"> - <PropertyItemLabel>Font size</PropertyItemLabel> - <PropertyItemValue> - <div className="flex items-center gap-2"> - <Slider - value={[element.fontSize]} - min={MIN_FONT_SIZE} - max={MAX_FONT_SIZE} - step={1} - onValueChange={([value]) => { - if (initialFontSizeRef.current === null) { - initialFontSizeRef.current = element.fontSize; - } - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { fontSize: value }, - }, - ], - pushHistory: false, - }); - }} - onValueCommit={([value]) => { - if (initialFontSizeRef.current !== null) { - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { - fontSize: initialFontSizeRef.current, - }, - }, - ], - pushHistory: false, - }); - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { fontSize: value }, - }, - ], - pushHistory: true, - }); - initialFontSizeRef.current = null; - } - }} - className="w-full" - /> - <Input - type="number" - value={fontSizeDisplay} - min={MIN_FONT_SIZE} - max={MAX_FONT_SIZE} - onFocus={() => { - isEditingFontSize.current = true; - fontSizeDraft.current = element.fontSize.toString(); - forceRender(); - }} - onChange={(e) => - handleFontSizeChange({ value: e.target.value }) - } - onBlur={handleFontSizeBlur} - className="bg-accent h-7 w-12 [appearance:textfield] rounded-sm px-2 text-center !text-xs [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" - /> - </div> - </PropertyItemValue> - </PropertyItem> - </div> - </PropertyGroup> - <PropertyGroup title="Appearance" collapsible={false}> - <div className="space-y-6"> - <PropertyItem direction="column"> - <PropertyItemLabel>Color</PropertyItemLabel> - <PropertyItemValue> - <ColorPicker - value={uppercase({ - string: (element.color || "FFFFFF").replace("#", ""), - })} - onChange={(color) => { - if (initialColorRef.current === null) { - initialColorRef.current = element.color || "#FFFFFF"; - } - if (initialColorRef.current !== null) { - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { color: `#${color}` }, - }, - ], - pushHistory: false, - }); - } else { - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { color: `#${color}` }, - }, - ], - }); - } - }} - onChangeEnd={(color) => { - if (initialColorRef.current !== null) { - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { color: initialColorRef.current }, - }, - ], - pushHistory: false, - }); - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { color: `#${color}` }, - }, - ], - pushHistory: true, - }); - initialColorRef.current = null; - } - }} - containerRef={containerRef} - /> - </PropertyItemValue> - </PropertyItem> - <PropertyItem direction="column"> - <PropertyItemLabel>Opacity</PropertyItemLabel> - <PropertyItemValue> - <div className="flex items-center gap-2"> - <Slider - value={[element.opacity * 100]} - min={0} - max={100} - step={1} - onValueChange={([value]) => { - if (initialOpacityRef.current === null) { - initialOpacityRef.current = element.opacity; - } - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { opacity: value / 100 }, - }, - ], - pushHistory: false, - }); - }} - onValueCommit={([value]) => { - if (initialOpacityRef.current !== null) { - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { opacity: initialOpacityRef.current }, - }, - ], - pushHistory: false, - }); - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { opacity: value / 100 }, - }, - ], - pushHistory: true, - }); - initialOpacityRef.current = null; - } - }} - className="w-full" - /> - <Input - type="number" - value={opacityDisplay} - min={0} - max={100} - onFocus={() => { - isEditingOpacity.current = true; - opacityDraft.current = Math.round( - element.opacity * 100, - ).toString(); - forceRender(); - }} - onChange={(e) => - handleOpacityChange({ value: e.target.value }) - } - onBlur={handleOpacityBlur} - className="bg-accent h-7 w-12 [appearance:textfield] rounded-sm text-center !text-xs [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" - /> - </div> - </PropertyItemValue> - </PropertyItem> - <PropertyItem direction="column"> - <PropertyItemLabel>Background</PropertyItemLabel> - <PropertyItemValue> - <ColorPicker - value={ - element.backgroundColor === "transparent" - ? lastSelectedColor.current.replace("#", "") - : element.backgroundColor.replace("#", "") - } - onChange={(color) => - handleColorChange({ color: `#${color}` }) - } - onChangeEnd={(color) => handleColorChangeEnd({ color })} - containerRef={containerRef} - className={ - element.backgroundColor === "transparent" - ? "pointer-events-none opacity-50" - : "" - } - /> - </PropertyItemValue> - </PropertyItem> - </div> - </PropertyGroup> - </PanelBaseView> - </div> + </div> + </SectionContent> + </Section> ); } |
