diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-03-29 14:15:21 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-03-29 15:58:46 +0200 |
| commit | 220ec757d42b54f498d031436e5b4b03931b165c (patch) | |
| tree | 931a9b147c692b1fa5fcdc17ec568dea6bd437b2 /apps/web/src/components/editor/panels | |
| parent | 585e28d49b853155c34a3eba03388722e2049cb7 (diff) | |
| download | opencut-220ec757d42b54f498d031436e5b4b03931b165c.tar.gz | |
refactor: move packages sub-folders into web app
Diffstat (limited to 'apps/web/src/components/editor/panels')
6 files changed, 1669 insertions, 1669 deletions
diff --git a/apps/web/src/components/editor/panels/assets/views/settings/index.tsx b/apps/web/src/components/editor/panels/assets/views/settings/index.tsx index 0578eeb7..1526a842 100644 --- a/apps/web/src/components/editor/panels/assets/views/settings/index.tsx +++ b/apps/web/src/components/editor/panels/assets/views/settings/index.tsx @@ -1,369 +1,369 @@ -"use client";
-
-import { useRef, useState } from "react";
-import { PanelView } from "@/components/editor/panels/assets/views/base-panel";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
-import { FPS_PRESETS } from "@/constants/project-constants";
-import { useEditor } from "@/hooks/use-editor";
-import {
- Section,
- SectionContent,
- SectionHeader,
- SectionTitle,
-} from "@/components/section";
-import { BackgroundContent } from "./background";
-import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
-import { Button } from "@/components/ui/button";
-import { NumberField } from "@/components/ui/number-field";
-import { useEditorStore } from "@/stores/editor-store";
-import { usePropertyDraft } from "@/components/editor/panels/properties/hooks/use-property-draft";
-import { HugeiconsIcon } from "@hugeicons/react";
-import { Tick02Icon } from "@hugeicons/core-free-icons";
-import { cn } from "@/utils/ui";
-import { dimensionToAspectRatio } from "@/utils/geometry";
-import { formatNumberForDisplay } from "@/utils/math";
-import { OcSquarePlusIcon } from "@opencut/ui/icons";
-import type { TCanvasSize } from "@/lib/project/types";
-
-type SettingsView = "project-info" | "background";
-
-const PRESET_LABELS: Record<string, string> = {
- "1:1": "1:1",
- "16:9": "16:9",
- "9:16": "9:16",
- "4:3": "4:3",
-};
-
-function areCanvasSizesEqual({
- left,
- right,
-}: {
- left: TCanvasSize;
- right: TCanvasSize;
-}) {
- return left.width === right.width && left.height === right.height;
-}
-
-function formatCanvasDimension({ value }: { value: number }) {
- return formatNumberForDisplay({ value, maxFractionDigits: 0 });
-}
-
-function parseCanvasDimension({ input }: { input: string }): number | null {
- const trimmed = input.trim();
- if (!trimmed) return null;
-
- const parsed = Number(trimmed);
- if (!Number.isFinite(parsed)) return null;
-
- const rounded = Math.round(parsed);
- return rounded > 0 ? rounded : null;
-}
-
-function useCanvasDimensionDraft({
- value,
- onCommit,
-}: {
- value: number;
- onCommit: (value: number) => void;
-}) {
- const pendingValueRef = useRef(value);
- const syncedValueRef = useRef(value);
-
- if (syncedValueRef.current !== value) {
- syncedValueRef.current = value;
- pendingValueRef.current = value;
- }
-
- return usePropertyDraft({
- displayValue: formatCanvasDimension({ value }),
- parse: (input) => parseCanvasDimension({ input }),
- onPreview: (nextValue) => {
- pendingValueRef.current = nextValue;
- },
- onCommit: () => {
- if (pendingValueRef.current !== value) {
- onCommit(pendingValueRef.current);
- }
- },
- });
-}
-
-export function SettingsView() {
- const [view, setView] = useState<SettingsView>("project-info");
- const editor = useEditor();
- const activeProject = useEditor((e) => e.project.getActive());
- const { canvasPresets } = useEditorStore();
- const currentCanvasSize = activeProject.settings.canvasSize;
- const canvasSizeMode = activeProject.settings.canvasSizeMode ?? "preset";
- const lastCustomCanvasSize =
- activeProject.settings.lastCustomCanvasSize ?? null;
-
- const presetItems = canvasPresets.map((preset, index) => {
- const ratio = dimensionToAspectRatio(preset);
- return {
- id: index.toString(),
- label: PRESET_LABELS[ratio] ?? ratio,
- ratio,
- canvasSize: preset,
- };
- });
-
- const selectedPresetId = canvasSizeMode === "preset"
- ? (presetItems.find((preset) =>
- areCanvasSizesEqual({
- left: preset.canvasSize,
- right: currentCanvasSize,
- }),
- )?.id ?? null)
- : null;
-
- const updateCustomCanvasSize = ({
- canvasSize,
- }: {
- canvasSize: TCanvasSize;
- }) => {
- const shouldUpdateCanvasSize = !areCanvasSizesEqual({
- left: canvasSize,
- right: currentCanvasSize,
- });
- const shouldUpdateLastCustomCanvasSize =
- lastCustomCanvasSize === null ||
- !areCanvasSizesEqual({
- left: canvasSize,
- right: lastCustomCanvasSize,
- });
- const shouldUpdateCanvasSizeMode = canvasSizeMode !== "custom";
-
- if (
- !shouldUpdateCanvasSize &&
- !shouldUpdateLastCustomCanvasSize &&
- !shouldUpdateCanvasSizeMode
- ) {
- return;
- }
-
- editor.project.updateSettings({
- settings: {
- ...(shouldUpdateCanvasSize ? { canvasSize } : {}),
- ...(shouldUpdateCanvasSizeMode
- ? { canvasSizeMode: "custom" as const }
- : {}),
- lastCustomCanvasSize: canvasSize,
- },
- });
- };
-
- const selectPresetCanvasSize = ({
- canvasSize,
- }: {
- canvasSize: TCanvasSize;
- }) => {
- const shouldUpdateCanvasSize = !areCanvasSizesEqual({
- left: canvasSize,
- right: currentCanvasSize,
- });
- const shouldUpdateCanvasSizeMode = canvasSizeMode !== "preset";
-
- if (!shouldUpdateCanvasSize && !shouldUpdateCanvasSizeMode) return;
-
- editor.project.updateSettings({
- settings: {
- ...(shouldUpdateCanvasSize ? { canvasSize } : {}),
- ...(shouldUpdateCanvasSizeMode
- ? { canvasSizeMode: "preset" as const }
- : {}),
- },
- });
- };
-
- const selectCustomCanvasSize = () => {
- updateCustomCanvasSize({
- canvasSize: lastCustomCanvasSize ?? currentCanvasSize,
- });
- };
-
- const widthDraft = useCanvasDimensionDraft({
- value: currentCanvasSize.width,
- onCommit: (width) =>
- updateCustomCanvasSize({
- canvasSize: { width, height: currentCanvasSize.height },
- }),
- });
-
- const heightDraft = useCanvasDimensionDraft({
- value: currentCanvasSize.height,
- onCommit: (height) =>
- updateCustomCanvasSize({
- canvasSize: { width: currentCanvasSize.width, height },
- }),
- });
-
- const isCustomSelected = canvasSizeMode === "custom";
-
- return (
- <PanelView
- contentClassName="px-0"
- scrollClassName="pt-0"
- actions={
- <Tabs value={view} onValueChange={(v) => setView(v as SettingsView)}>
- <TabsList>
- <TabsTrigger value="project-info">Project info</TabsTrigger>
- <TabsTrigger value="background">Background</TabsTrigger>
- </TabsList>
- </Tabs>
- }
- >
- {view === "project-info" && (
- <div className="flex flex-col">
- <Section showTopBorder={false}>
- <SectionHeader>
- <SectionTitle className="flex-1">Name</SectionTitle>
- <span className="text-sm truncate">
- {activeProject.metadata.name}
- </span>
- </SectionHeader>
- </Section>
- <Section showTopBorder={false}>
- <SectionHeader className="justify-between">
- <SectionTitle className="flex-1">Frame rate</SectionTitle>
- <Select
- value={activeProject.settings.fps.toString()}
- onValueChange={(value) => {
- const fps = parseFloat(value);
- editor.project.updateSettings({ settings: { fps } });
- }}
- >
- <SelectTrigger className="bg-transparent border-none p-1 h-auto">
- <SelectValue placeholder="Select a frame rate" />
- </SelectTrigger>
- <SelectContent>
- {FPS_PRESETS.map((preset) => (
- <SelectItem key={preset.value} value={preset.value}>
- {preset.label}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- </SectionHeader>
- </Section>
- <Section
- showTopBorder={false}
- collapsible
- sectionKey="settings:aspect-ratio"
- >
- <SectionHeader>
- <SectionTitle className="flex-1">Aspect ratio</SectionTitle>
- </SectionHeader>
- <SectionContent className="px-2 flex flex-col gap-1 pb-2">
- {presetItems.map((preset) => (
- <AspectRatioItem
- key={preset.id}
- label={preset.label}
- previewIcon={<AspectRatioPreview ratio={preset.ratio} />}
- isSelected={selectedPresetId === preset.id}
- onClick={() => {
- selectPresetCanvasSize({
- canvasSize: preset.canvasSize,
- });
- }}
- />
- ))}
- <div className="pb-2">
- <AspectRatioItem
- key="custom"
- label="Custom"
- previewIcon={<OcSquarePlusIcon />}
- isSelected={isCustomSelected}
- onClick={selectCustomCanvasSize}
- uiOptions={
- <div className=" flex items-center gap-2 text-foreground">
- <NumberField
- value={widthDraft.displayValue}
- className="w-full"
- aria-label="Canvas width"
- onFocus={widthDraft.onFocus}
- onChange={widthDraft.onChange}
- onBlur={widthDraft.onBlur}
- />
- <NumberField
- value={heightDraft.displayValue}
- className="w-full"
- aria-label="Canvas height"
- onFocus={heightDraft.onFocus}
- onChange={heightDraft.onChange}
- onBlur={heightDraft.onBlur}
- />
- </div>
- }
- />
- </div>
- </SectionContent>
- </Section>
- </div>
- )}
- {view === "background" && <BackgroundContent />}
- </PanelView>
- );
-}
-
-function AspectRatioItem({
- label,
- previewIcon,
- isSelected,
- onClick,
- uiOptions,
-}: {
- label: string;
- previewIcon: React.ReactNode;
- isSelected: boolean;
- onClick: () => void;
- uiOptions?: React.ReactNode;
-}) {
- return (
- <Button
- variant={isSelected ? "secondary" : "ghost"}
- className={cn(
- "px-2 py-0 flex flex-col h-fit w-full",
- !isSelected && "border border-transparent opacity-75!",
- )}
- onClick={onClick}
- >
- <div className="w-full flex justify-between items-center h-8">
- <div className="flex-1 flex items-center gap-2">
- <div className="flex items-center justify-center size-5">
- {previewIcon}
- </div>
- <span className="text-sm truncate">{label}</span>
- </div>
- <div>
- {isSelected && <HugeiconsIcon icon={Tick02Icon} className="size-4" />}
- </div>
- </div>
- {uiOptions && isSelected && (
- <div className="w-full pb-2">{uiOptions}</div>
- )}
- </Button>
- );
-}
-
-function AspectRatioPreview({ ratio }: { ratio?: string }) {
- if (!ratio) return null;
-
- const [w, h] = ratio.split(":").map(Number);
- const maxSize = 16;
- const width = w >= h ? maxSize : (w / h) * maxSize;
- const height = h >= w ? maxSize : (h / w) * maxSize;
-
- return (
- <div
- style={{ width, height, borderWidth: 1.5 }}
- className="rounded-xs border-current opacity-60"
- />
- );
-}
+"use client"; + +import { useRef, useState } from "react"; +import { PanelView } from "@/components/editor/panels/assets/views/base-panel"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { FPS_PRESETS } from "@/constants/project-constants"; +import { useEditor } from "@/hooks/use-editor"; +import { + Section, + SectionContent, + SectionHeader, + SectionTitle, +} from "@/components/section"; +import { BackgroundContent } from "./background"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Button } from "@/components/ui/button"; +import { NumberField } from "@/components/ui/number-field"; +import { useEditorStore } from "@/stores/editor-store"; +import { usePropertyDraft } from "@/components/editor/panels/properties/hooks/use-property-draft"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { Tick02Icon } from "@hugeicons/core-free-icons"; +import { cn } from "@/utils/ui"; +import { dimensionToAspectRatio } from "@/utils/geometry"; +import { formatNumberForDisplay } from "@/utils/math"; +import { OcSquarePlusIcon } from "@/components/icons"; +import type { TCanvasSize } from "@/lib/project/types"; + +type SettingsView = "project-info" | "background"; + +const PRESET_LABELS: Record<string, string> = { + "1:1": "1:1", + "16:9": "16:9", + "9:16": "9:16", + "4:3": "4:3", +}; + +function areCanvasSizesEqual({ + left, + right, +}: { + left: TCanvasSize; + right: TCanvasSize; +}) { + return left.width === right.width && left.height === right.height; +} + +function formatCanvasDimension({ value }: { value: number }) { + return formatNumberForDisplay({ value, maxFractionDigits: 0 }); +} + +function parseCanvasDimension({ input }: { input: string }): number | null { + const trimmed = input.trim(); + if (!trimmed) return null; + + const parsed = Number(trimmed); + if (!Number.isFinite(parsed)) return null; + + const rounded = Math.round(parsed); + return rounded > 0 ? rounded : null; +} + +function useCanvasDimensionDraft({ + value, + onCommit, +}: { + value: number; + onCommit: (value: number) => void; +}) { + const pendingValueRef = useRef(value); + const syncedValueRef = useRef(value); + + if (syncedValueRef.current !== value) { + syncedValueRef.current = value; + pendingValueRef.current = value; + } + + return usePropertyDraft({ + displayValue: formatCanvasDimension({ value }), + parse: (input) => parseCanvasDimension({ input }), + onPreview: (nextValue) => { + pendingValueRef.current = nextValue; + }, + onCommit: () => { + if (pendingValueRef.current !== value) { + onCommit(pendingValueRef.current); + } + }, + }); +} + +export function SettingsView() { + const [view, setView] = useState<SettingsView>("project-info"); + const editor = useEditor(); + const activeProject = useEditor((e) => e.project.getActive()); + const { canvasPresets } = useEditorStore(); + const currentCanvasSize = activeProject.settings.canvasSize; + const canvasSizeMode = activeProject.settings.canvasSizeMode ?? "preset"; + const lastCustomCanvasSize = + activeProject.settings.lastCustomCanvasSize ?? null; + + const presetItems = canvasPresets.map((preset, index) => { + const ratio = dimensionToAspectRatio(preset); + return { + id: index.toString(), + label: PRESET_LABELS[ratio] ?? ratio, + ratio, + canvasSize: preset, + }; + }); + + const selectedPresetId = canvasSizeMode === "preset" + ? (presetItems.find((preset) => + areCanvasSizesEqual({ + left: preset.canvasSize, + right: currentCanvasSize, + }), + )?.id ?? null) + : null; + + const updateCustomCanvasSize = ({ + canvasSize, + }: { + canvasSize: TCanvasSize; + }) => { + const shouldUpdateCanvasSize = !areCanvasSizesEqual({ + left: canvasSize, + right: currentCanvasSize, + }); + const shouldUpdateLastCustomCanvasSize = + lastCustomCanvasSize === null || + !areCanvasSizesEqual({ + left: canvasSize, + right: lastCustomCanvasSize, + }); + const shouldUpdateCanvasSizeMode = canvasSizeMode !== "custom"; + + if ( + !shouldUpdateCanvasSize && + !shouldUpdateLastCustomCanvasSize && + !shouldUpdateCanvasSizeMode + ) { + return; + } + + editor.project.updateSettings({ + settings: { + ...(shouldUpdateCanvasSize ? { canvasSize } : {}), + ...(shouldUpdateCanvasSizeMode + ? { canvasSizeMode: "custom" as const } + : {}), + lastCustomCanvasSize: canvasSize, + }, + }); + }; + + const selectPresetCanvasSize = ({ + canvasSize, + }: { + canvasSize: TCanvasSize; + }) => { + const shouldUpdateCanvasSize = !areCanvasSizesEqual({ + left: canvasSize, + right: currentCanvasSize, + }); + const shouldUpdateCanvasSizeMode = canvasSizeMode !== "preset"; + + if (!shouldUpdateCanvasSize && !shouldUpdateCanvasSizeMode) return; + + editor.project.updateSettings({ + settings: { + ...(shouldUpdateCanvasSize ? { canvasSize } : {}), + ...(shouldUpdateCanvasSizeMode + ? { canvasSizeMode: "preset" as const } + : {}), + }, + }); + }; + + const selectCustomCanvasSize = () => { + updateCustomCanvasSize({ + canvasSize: lastCustomCanvasSize ?? currentCanvasSize, + }); + }; + + const widthDraft = useCanvasDimensionDraft({ + value: currentCanvasSize.width, + onCommit: (width) => + updateCustomCanvasSize({ + canvasSize: { width, height: currentCanvasSize.height }, + }), + }); + + const heightDraft = useCanvasDimensionDraft({ + value: currentCanvasSize.height, + onCommit: (height) => + updateCustomCanvasSize({ + canvasSize: { width: currentCanvasSize.width, height }, + }), + }); + + const isCustomSelected = canvasSizeMode === "custom"; + + return ( + <PanelView + contentClassName="px-0" + scrollClassName="pt-0" + actions={ + <Tabs value={view} onValueChange={(v) => setView(v as SettingsView)}> + <TabsList> + <TabsTrigger value="project-info">Project info</TabsTrigger> + <TabsTrigger value="background">Background</TabsTrigger> + </TabsList> + </Tabs> + } + > + {view === "project-info" && ( + <div className="flex flex-col"> + <Section showTopBorder={false}> + <SectionHeader> + <SectionTitle className="flex-1">Name</SectionTitle> + <span className="text-sm truncate"> + {activeProject.metadata.name} + </span> + </SectionHeader> + </Section> + <Section showTopBorder={false}> + <SectionHeader className="justify-between"> + <SectionTitle className="flex-1">Frame rate</SectionTitle> + <Select + value={activeProject.settings.fps.toString()} + onValueChange={(value) => { + const fps = parseFloat(value); + editor.project.updateSettings({ settings: { fps } }); + }} + > + <SelectTrigger className="bg-transparent border-none p-1 h-auto"> + <SelectValue placeholder="Select a frame rate" /> + </SelectTrigger> + <SelectContent> + {FPS_PRESETS.map((preset) => ( + <SelectItem key={preset.value} value={preset.value}> + {preset.label} + </SelectItem> + ))} + </SelectContent> + </Select> + </SectionHeader> + </Section> + <Section + showTopBorder={false} + collapsible + sectionKey="settings:aspect-ratio" + > + <SectionHeader> + <SectionTitle className="flex-1">Aspect ratio</SectionTitle> + </SectionHeader> + <SectionContent className="px-2 flex flex-col gap-1 pb-2"> + {presetItems.map((preset) => ( + <AspectRatioItem + key={preset.id} + label={preset.label} + previewIcon={<AspectRatioPreview ratio={preset.ratio} />} + isSelected={selectedPresetId === preset.id} + onClick={() => { + selectPresetCanvasSize({ + canvasSize: preset.canvasSize, + }); + }} + /> + ))} + <div className="pb-2"> + <AspectRatioItem + key="custom" + label="Custom" + previewIcon={<OcSquarePlusIcon />} + isSelected={isCustomSelected} + onClick={selectCustomCanvasSize} + uiOptions={ + <div className=" flex items-center gap-2 text-foreground"> + <NumberField + value={widthDraft.displayValue} + className="w-full" + aria-label="Canvas width" + onFocus={widthDraft.onFocus} + onChange={widthDraft.onChange} + onBlur={widthDraft.onBlur} + /> + <NumberField + value={heightDraft.displayValue} + className="w-full" + aria-label="Canvas height" + onFocus={heightDraft.onFocus} + onChange={heightDraft.onChange} + onBlur={heightDraft.onBlur} + /> + </div> + } + /> + </div> + </SectionContent> + </Section> + </div> + )} + {view === "background" && <BackgroundContent />} + </PanelView> + ); +} + +function AspectRatioItem({ + label, + previewIcon, + isSelected, + onClick, + uiOptions, +}: { + label: string; + previewIcon: React.ReactNode; + isSelected: boolean; + onClick: () => void; + uiOptions?: React.ReactNode; +}) { + return ( + <Button + variant={isSelected ? "secondary" : "ghost"} + className={cn( + "px-2 py-0 flex flex-col h-fit w-full", + !isSelected && "border border-transparent opacity-75!", + )} + onClick={onClick} + > + <div className="w-full flex justify-between items-center h-8"> + <div className="flex-1 flex items-center gap-2"> + <div className="flex items-center justify-center size-5"> + {previewIcon} + </div> + <span className="text-sm truncate">{label}</span> + </div> + <div> + {isSelected && <HugeiconsIcon icon={Tick02Icon} className="size-4" />} + </div> + </div> + {uiOptions && isSelected && ( + <div className="w-full pb-2">{uiOptions}</div> + )} + </Button> + ); +} + +function AspectRatioPreview({ ratio }: { ratio?: string }) { + if (!ratio) return null; + + const [w, h] = ratio.split(":").map(Number); + const maxSize = 16; + const width = w >= h ? maxSize : (w / h) * maxSize; + const height = h >= w ? maxSize : (h / w) * maxSize; + + return ( + <div + style={{ width, height, borderWidth: 1.5 }} + className="rounded-xs border-current opacity-60" + /> + ); +} diff --git a/apps/web/src/components/editor/panels/properties/registry.tsx b/apps/web/src/components/editor/panels/properties/registry.tsx index 38d0a189..df6c0e52 100644 --- a/apps/web/src/components/editor/panels/properties/registry.tsx +++ b/apps/web/src/components/editor/panels/properties/registry.tsx @@ -1,303 +1,303 @@ -import type { ReactNode } from "react";
-import type {
- EffectElement,
- GraphicElement,
- ImageElement,
- MaskableElement,
- RetimableElement,
- StickerElement,
- TextElement,
- VisualElement,
- VideoElement,
- AudioElement,
- TimelineElement,
-} from "@/lib/timeline";
-import type { MediaAsset } from "@/lib/media/types";
-import { HugeiconsIcon } from "@hugeicons/react";
-import {
- TextFontIcon,
- ArrowExpandIcon,
- RainDropIcon,
- MusicNote03Icon,
- MagicWand05Icon,
- DashboardSpeed02Icon,
-} from "@hugeicons/core-free-icons";
-import { TransformTab } from "./tabs/transform-tab";
-import { BlendingTab } from "./tabs/blending-tab";
-import { AudioTab } from "./tabs/audio-tab";
-import { TextTab } from "./tabs/text-tab";
-import { ClipEffectsTab, StandaloneEffectTab } from "./tabs/effects-tab";
-import { MasksTab } from "./tabs/masks-tab";
-import { SpeedTab } from "./tabs/speed-tab";
-import { GraphicTab } from "./tabs/graphic-tab";
-import { OcShapesIcon } from "@opencut/ui/icons";
-
-export type TabContentProps = {
- trackId: string;
-};
-
-export type PropertiesTabDef = {
- id: string;
- label: string;
- icon: ReactNode;
- content: (props: TabContentProps) => ReactNode;
-};
-
-export type ElementPropertiesConfig = {
- defaultTab: string;
- tabs: PropertiesTabDef[];
-};
-
-function buildTransformTab({
- element,
-}: {
- element: VisualElement;
-}): PropertiesTabDef {
- return {
- id: "transform",
- label: "Transform",
- icon: <HugeiconsIcon icon={ArrowExpandIcon} size={16} />,
- content: ({ trackId }) => (
- <TransformTab element={element} trackId={trackId} />
- ),
- };
-}
-
-function buildBlendingTab({
- element,
-}: {
- element: VisualElement;
-}): PropertiesTabDef {
- return {
- id: "blending",
- label: "Blending",
- icon: <HugeiconsIcon icon={RainDropIcon} size={16} />,
- content: ({ trackId }) => (
- <BlendingTab element={element} trackId={trackId} />
- ),
- };
-}
-
-function buildAudioTab({
- element,
-}: {
- element: AudioElement | VideoElement;
-}): PropertiesTabDef {
- return {
- id: "audio",
- label: "Audio",
- icon: <HugeiconsIcon icon={MusicNote03Icon} size={16} />,
- content: ({ trackId }) => <AudioTab element={element} trackId={trackId} />,
- };
-}
-
-function buildSpeedTab({
- element,
-}: {
- element: RetimableElement;
-}): PropertiesTabDef {
- return {
- id: "speed",
- label: "Speed",
- icon: <HugeiconsIcon icon={DashboardSpeed02Icon} size={16} />,
- content: ({ trackId }) => <SpeedTab element={element} trackId={trackId} />,
- };
-}
-
-function buildMasksTab({
- element,
-}: {
- element: MaskableElement;
-}): PropertiesTabDef {
- return {
- id: "masks",
- label: "Masks",
- icon: <OcShapesIcon size={16} />,
- content: ({ trackId }) => <MasksTab element={element} trackId={trackId} />,
- };
-}
-
-function buildClipEffectsTab({
- element,
-}: {
- element: VisualElement;
-}): PropertiesTabDef {
- return {
- id: "effects",
- label: "Effects",
- icon: <HugeiconsIcon icon={MagicWand05Icon} size={16} />,
- content: ({ trackId }) => (
- <ClipEffectsTab element={element} trackId={trackId} />
- ),
- };
-}
-
-function buildTextTab({ element }: { element: TextElement }): PropertiesTabDef {
- return {
- id: "text",
- label: "Text",
- icon: <HugeiconsIcon icon={TextFontIcon} size={16} />,
- content: ({ trackId }) => <TextTab element={element} trackId={trackId} />,
- };
-}
-
-function buildGraphicTab({
- element,
-}: {
- element: GraphicElement;
-}): PropertiesTabDef {
- return {
- id: "graphic",
- label: "Graphic",
- icon: <OcShapesIcon size={16} />,
- content: ({ trackId }) => <GraphicTab element={element} trackId={trackId} />,
- };
-}
-
-function buildStandaloneEffectTab({
- element,
-}: {
- element: EffectElement;
-}): PropertiesTabDef {
- return {
- id: "effects",
- label: "Effects",
- icon: <HugeiconsIcon icon={MagicWand05Icon} size={16} />,
- content: ({ trackId }) => (
- <StandaloneEffectTab element={element} trackId={trackId} />
- ),
- };
-}
-
-function getTextConfig({
- element,
-}: {
- element: TextElement;
-}): ElementPropertiesConfig {
- return {
- defaultTab: "text",
- tabs: [
- buildTextTab({ element }),
- buildTransformTab({ element }),
- buildBlendingTab({ element }),
- ],
- };
-}
-
-function getVideoConfig({
- element,
- mediaAsset,
-}: {
- element: VideoElement;
- mediaAsset: MediaAsset | undefined;
-}): ElementPropertiesConfig {
- const showAudioTab = mediaAsset?.hasAudio !== false;
- return {
- defaultTab: "transform",
- tabs: [
- buildTransformTab({ element }),
- ...(showAudioTab ? [buildAudioTab({ element })] : []),
- buildSpeedTab({ element }),
- buildBlendingTab({ element }),
- buildMasksTab({ element }),
- buildClipEffectsTab({ element }),
- ],
- };
-}
-
-function getImageConfig({
- element,
-}: {
- element: ImageElement;
-}): ElementPropertiesConfig {
- return {
- defaultTab: "transform",
- tabs: [
- buildTransformTab({ element }),
- buildBlendingTab({ element }),
- buildMasksTab({ element }),
- buildClipEffectsTab({ element }),
- ],
- };
-}
-
-function getStickerConfig({
- element,
-}: {
- element: StickerElement;
-}): ElementPropertiesConfig {
- return {
- defaultTab: "transform",
- tabs: [
- buildTransformTab({ element }),
- buildBlendingTab({ element }),
- buildClipEffectsTab({ element }),
- ],
- };
-}
-
-function getGraphicConfig({
- element,
-}: {
- element: GraphicElement;
-}): ElementPropertiesConfig {
- return {
- defaultTab: "graphic",
- tabs: [
- buildGraphicTab({ element }),
- buildTransformTab({ element }),
- buildBlendingTab({ element }),
- buildMasksTab({ element }),
- buildClipEffectsTab({ element }),
- ],
- };
-}
-
-function getAudioConfig({
- element,
-}: {
- element: AudioElement;
-}): ElementPropertiesConfig {
- return {
- defaultTab: "audio",
- tabs: [buildAudioTab({ element }), buildSpeedTab({ element })],
- };
-}
-
-function getEffectConfig({
- element,
-}: {
- element: EffectElement;
-}): ElementPropertiesConfig {
- return {
- defaultTab: "effects",
- tabs: [buildStandaloneEffectTab({ element })],
- };
-}
-
-export function getPropertiesConfig({
- element,
- mediaAssets,
-}: {
- element: TimelineElement;
- mediaAssets: MediaAsset[];
-}): ElementPropertiesConfig {
- switch (element.type) {
- case "text":
- return getTextConfig({ element });
- case "video": {
- const mediaAsset = mediaAssets.find((a) => a.id === element.mediaId);
- return getVideoConfig({ element, mediaAsset });
- }
- case "image":
- return getImageConfig({ element });
- case "sticker":
- return getStickerConfig({ element });
- case "graphic":
- return getGraphicConfig({ element });
- case "audio":
- return getAudioConfig({ element });
- case "effect":
- return getEffectConfig({ element });
- }
-}
+import type { ReactNode } from "react"; +import type { + EffectElement, + GraphicElement, + ImageElement, + MaskableElement, + RetimableElement, + StickerElement, + TextElement, + VisualElement, + VideoElement, + AudioElement, + TimelineElement, +} from "@/lib/timeline"; +import type { MediaAsset } from "@/lib/media/types"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { + TextFontIcon, + ArrowExpandIcon, + RainDropIcon, + MusicNote03Icon, + MagicWand05Icon, + DashboardSpeed02Icon, +} from "@hugeicons/core-free-icons"; +import { TransformTab } from "./tabs/transform-tab"; +import { BlendingTab } from "./tabs/blending-tab"; +import { AudioTab } from "./tabs/audio-tab"; +import { TextTab } from "./tabs/text-tab"; +import { ClipEffectsTab, StandaloneEffectTab } from "./tabs/effects-tab"; +import { MasksTab } from "./tabs/masks-tab"; +import { SpeedTab } from "./tabs/speed-tab"; +import { GraphicTab } from "./tabs/graphic-tab"; +import { OcShapesIcon } from "@/components/icons"; + +export type TabContentProps = { + trackId: string; +}; + +export type PropertiesTabDef = { + id: string; + label: string; + icon: ReactNode; + content: (props: TabContentProps) => ReactNode; +}; + +export type ElementPropertiesConfig = { + defaultTab: string; + tabs: PropertiesTabDef[]; +}; + +function buildTransformTab({ + element, +}: { + element: VisualElement; +}): PropertiesTabDef { + return { + id: "transform", + label: "Transform", + icon: <HugeiconsIcon icon={ArrowExpandIcon} size={16} />, + content: ({ trackId }) => ( + <TransformTab element={element} trackId={trackId} /> + ), + }; +} + +function buildBlendingTab({ + element, +}: { + element: VisualElement; +}): PropertiesTabDef { + return { + id: "blending", + label: "Blending", + icon: <HugeiconsIcon icon={RainDropIcon} size={16} />, + content: ({ trackId }) => ( + <BlendingTab element={element} trackId={trackId} /> + ), + }; +} + +function buildAudioTab({ + element, +}: { + element: AudioElement | VideoElement; +}): PropertiesTabDef { + return { + id: "audio", + label: "Audio", + icon: <HugeiconsIcon icon={MusicNote03Icon} size={16} />, + content: ({ trackId }) => <AudioTab element={element} trackId={trackId} />, + }; +} + +function buildSpeedTab({ + element, +}: { + element: RetimableElement; +}): PropertiesTabDef { + return { + id: "speed", + label: "Speed", + icon: <HugeiconsIcon icon={DashboardSpeed02Icon} size={16} />, + content: ({ trackId }) => <SpeedTab element={element} trackId={trackId} />, + }; +} + +function buildMasksTab({ + element, +}: { + element: MaskableElement; +}): PropertiesTabDef { + return { + id: "masks", + label: "Masks", + icon: <OcShapesIcon size={16} />, + content: ({ trackId }) => <MasksTab element={element} trackId={trackId} />, + }; +} + +function buildClipEffectsTab({ + element, +}: { + element: VisualElement; +}): PropertiesTabDef { + return { + id: "effects", + label: "Effects", + icon: <HugeiconsIcon icon={MagicWand05Icon} size={16} />, + content: ({ trackId }) => ( + <ClipEffectsTab element={element} trackId={trackId} /> + ), + }; +} + +function buildTextTab({ element }: { element: TextElement }): PropertiesTabDef { + return { + id: "text", + label: "Text", + icon: <HugeiconsIcon icon={TextFontIcon} size={16} />, + content: ({ trackId }) => <TextTab element={element} trackId={trackId} />, + }; +} + +function buildGraphicTab({ + element, +}: { + element: GraphicElement; +}): PropertiesTabDef { + return { + id: "graphic", + label: "Graphic", + icon: <OcShapesIcon size={16} />, + content: ({ trackId }) => <GraphicTab element={element} trackId={trackId} />, + }; +} + +function buildStandaloneEffectTab({ + element, +}: { + element: EffectElement; +}): PropertiesTabDef { + return { + id: "effects", + label: "Effects", + icon: <HugeiconsIcon icon={MagicWand05Icon} size={16} />, + content: ({ trackId }) => ( + <StandaloneEffectTab element={element} trackId={trackId} /> + ), + }; +} + +function getTextConfig({ + element, +}: { + element: TextElement; +}): ElementPropertiesConfig { + return { + defaultTab: "text", + tabs: [ + buildTextTab({ element }), + buildTransformTab({ element }), + buildBlendingTab({ element }), + ], + }; +} + +function getVideoConfig({ + element, + mediaAsset, +}: { + element: VideoElement; + mediaAsset: MediaAsset | undefined; +}): ElementPropertiesConfig { + const showAudioTab = mediaAsset?.hasAudio !== false; + return { + defaultTab: "transform", + tabs: [ + buildTransformTab({ element }), + ...(showAudioTab ? [buildAudioTab({ element })] : []), + buildSpeedTab({ element }), + buildBlendingTab({ element }), + buildMasksTab({ element }), + buildClipEffectsTab({ element }), + ], + }; +} + +function getImageConfig({ + element, +}: { + element: ImageElement; +}): ElementPropertiesConfig { + return { + defaultTab: "transform", + tabs: [ + buildTransformTab({ element }), + buildBlendingTab({ element }), + buildMasksTab({ element }), + buildClipEffectsTab({ element }), + ], + }; +} + +function getStickerConfig({ + element, +}: { + element: StickerElement; +}): ElementPropertiesConfig { + return { + defaultTab: "transform", + tabs: [ + buildTransformTab({ element }), + buildBlendingTab({ element }), + buildClipEffectsTab({ element }), + ], + }; +} + +function getGraphicConfig({ + element, +}: { + element: GraphicElement; +}): ElementPropertiesConfig { + return { + defaultTab: "graphic", + tabs: [ + buildGraphicTab({ element }), + buildTransformTab({ element }), + buildBlendingTab({ element }), + buildMasksTab({ element }), + buildClipEffectsTab({ element }), + ], + }; +} + +function getAudioConfig({ + element, +}: { + element: AudioElement; +}): ElementPropertiesConfig { + return { + defaultTab: "audio", + tabs: [buildAudioTab({ element }), buildSpeedTab({ element })], + }; +} + +function getEffectConfig({ + element, +}: { + element: EffectElement; +}): ElementPropertiesConfig { + return { + defaultTab: "effects", + tabs: [buildStandaloneEffectTab({ element })], + }; +} + +export function getPropertiesConfig({ + element, + mediaAssets, +}: { + element: TimelineElement; + mediaAssets: MediaAsset[]; +}): ElementPropertiesConfig { + switch (element.type) { + case "text": + return getTextConfig({ element }); + case "video": { + const mediaAsset = mediaAssets.find((a) => a.id === element.mediaId); + return getVideoConfig({ element, mediaAsset }); + } + case "image": + return getImageConfig({ element }); + case "sticker": + return getStickerConfig({ element }); + case "graphic": + return getGraphicConfig({ element }); + case "audio": + return getAudioConfig({ element }); + case "effect": + return getEffectConfig({ element }); + } +} diff --git a/apps/web/src/components/editor/panels/properties/tabs/blending-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/blending-tab.tsx index fdcde148..a69acebf 100644 --- a/apps/web/src/components/editor/panels/properties/tabs/blending-tab.tsx +++ b/apps/web/src/components/editor/panels/properties/tabs/blending-tab.tsx @@ -1,230 +1,230 @@ -import { useEditor } from "@/hooks/use-editor";
-import { clamp } from "@/utils/math";
-import { NumberField } from "@/components/ui/number-field";
-import { OcCheckerboardIcon } from "@opencut/ui/icons";
-import { Fragment, useRef } from "react";
-import { useMenuPreview } from "@/hooks/use-menu-preview";
-import {
- Section,
- SectionContent,
- SectionField,
- SectionHeader,
- SectionTitle,
-} from "@/components/section";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectSeparator,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
-import type { BlendMode } from "@/lib/rendering";
-import type { ElementType } from "@/lib/timeline";
-import type { ElementAnimations } from "@/lib/animation/types";
-import { HugeiconsIcon } from "@hugeicons/react";
-import { RainDropIcon } from "@hugeicons/core-free-icons";
-import { KeyframeToggle } from "../components/keyframe-toggle";
-import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
-import { useElementPlayhead } from "../hooks/use-element-playhead";
-import { resolveOpacityAtTime } from "@/lib/animation";
-import { DEFAULTS } from "@/lib/timeline/defaults";
-import { isPropertyAtDefault } from "./transform-tab";
-
-type BlendingElement = {
- id: string;
- opacity: number;
- type: ElementType;
- blendMode?: BlendMode;
- startTime: number;
- duration: number;
- animations?: ElementAnimations;
-};
-
-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 BlendingTab({
- element,
- trackId,
-}: {
- element: BlendingElement;
- trackId: string;
-}) {
- const editor = useEditor();
- const isPreviewActive = useEditor((e) => e.timeline.isPreviewActive());
- const blendMode = element.blendMode ?? DEFAULTS.element.blendMode;
- const committedBlendModeRef = useRef(blendMode);
- if (!isPreviewActive) {
- committedBlendModeRef.current = blendMode;
- }
-
- const {
- onPointerLeave,
- onOpenChange: handleBlendModeOpenChange,
- markCommitted,
- } = useMenuPreview();
-
- const previewBlendMode = ({ value }: { 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 },
- },
- ],
- });
- }
- markCommitted();
- };
-
- const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
- startTime: element.startTime,
- duration: element.duration,
- });
- const resolvedOpacity = resolveOpacityAtTime({
- baseOpacity: element.opacity,
- animations: element.animations,
- localTime,
- });
-
- const opacity = useKeyframedNumberProperty({
- trackId,
- elementId: element.id,
- animations: element.animations,
- propertyPath: "opacity",
- localTime,
- isPlayheadWithinElementRange,
- displayValue: Math.round(resolvedOpacity * 100).toString(),
- parse: (input) => {
- const parsed = parseFloat(input);
- if (Number.isNaN(parsed)) return null;
- return clamp({ value: parsed, min: 0, max: 100 }) / 100;
- },
- valueAtPlayhead: resolvedOpacity,
- step: 0.01,
- buildBaseUpdates: ({ value }) => ({ opacity: value }),
- });
-
- return (
- <Section collapsible sectionKey={`${element.id}:blending`}>
- <SectionHeader>
- <SectionTitle>Blending</SectionTitle>
- </SectionHeader>
- <SectionContent>
- <div className="flex items-start gap-2">
- <SectionField
- label="Opacity"
- className="w-1/2"
- beforeLabel={
- <KeyframeToggle
- isActive={opacity.isKeyframedAtTime}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle opacity keyframe"
- onToggle={opacity.toggleKeyframe}
- />
- }
- >
- <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={() =>
- opacity.commitValue({ value: DEFAULTS.element.opacity })
- }
- isDefault={isPropertyAtDefault({
- hasAnimatedKeyframes: opacity.hasAnimatedKeyframes,
- isPlayheadWithinElementRange,
- resolvedValue: resolvedOpacity,
- staticValue: element.opacity,
- defaultValue: DEFAULTS.element.opacity,
- })}
- dragSensitivity="slow"
- />
- </SectionField>
- <SectionField label="Blend mode" className="w-1/2">
- <Select
- value={committedBlendModeRef.current}
- onOpenChange={handleBlendModeOpenChange}
- onValueChange={commitBlendMode}
- >
- <SelectTrigger
- icon={<HugeiconsIcon icon={RainDropIcon} />}
- className="w-full"
- >
- <SelectValue placeholder="Select blend mode" />
- </SelectTrigger>
- <SelectContent className="w-36" onPointerLeave={onPointerLeave}>
- {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({ value: option.value as BlendMode })
- }
- >
- {option.label}
- </SelectItem>
- ))}
- {groupIndex < BLEND_MODE_GROUPS.length - 1 ? (
- <SelectSeparator />
- ) : null}
- </Fragment>
- ))}
- </SelectContent>
- </Select>
- </SectionField>
- </div>
- </SectionContent>
- </Section>
- );
-}
+import { useEditor } from "@/hooks/use-editor"; +import { clamp } from "@/utils/math"; +import { NumberField } from "@/components/ui/number-field"; +import { OcCheckerboardIcon } from "@/components/icons"; +import { Fragment, useRef } from "react"; +import { useMenuPreview } from "@/hooks/use-menu-preview"; +import { + Section, + SectionContent, + SectionField, + SectionHeader, + SectionTitle, +} from "@/components/section"; +import { + Select, + SelectContent, + SelectItem, + SelectSeparator, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import type { BlendMode } from "@/lib/rendering"; +import type { ElementType } from "@/lib/timeline"; +import type { ElementAnimations } from "@/lib/animation/types"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { RainDropIcon } from "@hugeicons/core-free-icons"; +import { KeyframeToggle } from "../components/keyframe-toggle"; +import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property"; +import { useElementPlayhead } from "../hooks/use-element-playhead"; +import { resolveOpacityAtTime } from "@/lib/animation"; +import { DEFAULTS } from "@/lib/timeline/defaults"; +import { isPropertyAtDefault } from "./transform-tab"; + +type BlendingElement = { + id: string; + opacity: number; + type: ElementType; + blendMode?: BlendMode; + startTime: number; + duration: number; + animations?: ElementAnimations; +}; + +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 BlendingTab({ + element, + trackId, +}: { + element: BlendingElement; + trackId: string; +}) { + const editor = useEditor(); + const isPreviewActive = useEditor((e) => e.timeline.isPreviewActive()); + const blendMode = element.blendMode ?? DEFAULTS.element.blendMode; + const committedBlendModeRef = useRef(blendMode); + if (!isPreviewActive) { + committedBlendModeRef.current = blendMode; + } + + const { + onPointerLeave, + onOpenChange: handleBlendModeOpenChange, + markCommitted, + } = useMenuPreview(); + + const previewBlendMode = ({ value }: { 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 }, + }, + ], + }); + } + markCommitted(); + }; + + const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ + startTime: element.startTime, + duration: element.duration, + }); + const resolvedOpacity = resolveOpacityAtTime({ + baseOpacity: element.opacity, + animations: element.animations, + localTime, + }); + + const opacity = useKeyframedNumberProperty({ + trackId, + elementId: element.id, + animations: element.animations, + propertyPath: "opacity", + localTime, + isPlayheadWithinElementRange, + displayValue: Math.round(resolvedOpacity * 100).toString(), + parse: (input) => { + const parsed = parseFloat(input); + if (Number.isNaN(parsed)) return null; + return clamp({ value: parsed, min: 0, max: 100 }) / 100; + }, + valueAtPlayhead: resolvedOpacity, + step: 0.01, + buildBaseUpdates: ({ value }) => ({ opacity: value }), + }); + + return ( + <Section collapsible sectionKey={`${element.id}:blending`}> + <SectionHeader> + <SectionTitle>Blending</SectionTitle> + </SectionHeader> + <SectionContent> + <div className="flex items-start gap-2"> + <SectionField + label="Opacity" + className="w-1/2" + beforeLabel={ + <KeyframeToggle + isActive={opacity.isKeyframedAtTime} + isDisabled={!isPlayheadWithinElementRange} + title="Toggle opacity keyframe" + onToggle={opacity.toggleKeyframe} + /> + } + > + <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={() => + opacity.commitValue({ value: DEFAULTS.element.opacity }) + } + isDefault={isPropertyAtDefault({ + hasAnimatedKeyframes: opacity.hasAnimatedKeyframes, + isPlayheadWithinElementRange, + resolvedValue: resolvedOpacity, + staticValue: element.opacity, + defaultValue: DEFAULTS.element.opacity, + })} + dragSensitivity="slow" + /> + </SectionField> + <SectionField label="Blend mode" className="w-1/2"> + <Select + value={committedBlendModeRef.current} + onOpenChange={handleBlendModeOpenChange} + onValueChange={commitBlendMode} + > + <SelectTrigger + icon={<HugeiconsIcon icon={RainDropIcon} />} + className="w-full" + > + <SelectValue placeholder="Select blend mode" /> + </SelectTrigger> + <SelectContent className="w-36" onPointerLeave={onPointerLeave}> + {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({ value: option.value as BlendMode }) + } + > + {option.label} + </SelectItem> + ))} + {groupIndex < BLEND_MODE_GROUPS.length - 1 ? ( + <SelectSeparator /> + ) : null} + </Fragment> + ))} + </SelectContent> + </Select> + </SectionField> + </div> + </SectionContent> + </Section> + ); +} diff --git a/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx index db8bd728..d416dc28 100644 --- a/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx +++ b/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx @@ -53,7 +53,7 @@ import { SectionTitle, } from "@/components/section"; import { usePropertyDraft } from "../hooks/use-property-draft"; -import { OcMirrorIcon, OcShapesIcon } from "@opencut/ui/icons"; +import { OcMirrorIcon, OcShapesIcon } from "@/components/icons"; import { cn } from "@/utils/ui"; type MasksTabProps = { diff --git a/apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx index be85bede..4c58023b 100644 --- a/apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx +++ b/apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx @@ -1,765 +1,765 @@ -"use client";
-
-import { Textarea } from "@/components/ui/textarea";
-import { FontPicker } from "@/components/ui/font-picker";
-import type { TextElement } from "@/lib/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 "@/hooks/use-editor";
-import { DEFAULT_COLOR } from "@/constants/project-constants";
-import {
- CORNER_RADIUS_MAX,
- CORNER_RADIUS_MIN,
- MAX_FONT_SIZE,
- MIN_FONT_SIZE,
-} from "@/constants/text-constants";
-import { usePropertyDraft } from "../hooks/use-property-draft";
-import { useKeyframedColorProperty } from "../hooks/use-keyframed-color-property";
-import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
-import { useElementPlayhead } from "../hooks/use-element-playhead";
-import { KeyframeToggle } from "../components/keyframe-toggle";
-import { isPropertyAtDefault } from "./transform-tab";
-import { resolveColorAtTime, resolveNumberAtTime } from "@/lib/animation";
-import { HugeiconsIcon } from "@hugeicons/react";
-import {
- MinusSignIcon,
- PlusSignIcon,
- TextFontIcon,
-} from "@hugeicons/core-free-icons";
-import { OcTextHeightIcon, OcTextWidthIcon } from "@opencut/ui/icons";
-import { DEFAULTS } from "@/lib/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,
- updates: { 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,
- updates: {
- 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,
- updates: { 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,
- updates: { 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_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,
- updates: {
- 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>
- );
-}
+"use client"; + +import { Textarea } from "@/components/ui/textarea"; +import { FontPicker } from "@/components/ui/font-picker"; +import type { TextElement } from "@/lib/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 "@/hooks/use-editor"; +import { DEFAULT_COLOR } from "@/constants/project-constants"; +import { + CORNER_RADIUS_MAX, + CORNER_RADIUS_MIN, + MAX_FONT_SIZE, + MIN_FONT_SIZE, +} from "@/constants/text-constants"; +import { usePropertyDraft } from "../hooks/use-property-draft"; +import { useKeyframedColorProperty } from "../hooks/use-keyframed-color-property"; +import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property"; +import { useElementPlayhead } from "../hooks/use-element-playhead"; +import { KeyframeToggle } from "../components/keyframe-toggle"; +import { isPropertyAtDefault } from "./transform-tab"; +import { resolveColorAtTime, resolveNumberAtTime } from "@/lib/animation"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { + MinusSignIcon, + PlusSignIcon, + TextFontIcon, +} from "@hugeicons/core-free-icons"; +import { OcTextHeightIcon, OcTextWidthIcon } from "@/components/icons"; +import { DEFAULTS } from "@/lib/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, + updates: { 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, + updates: { + 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, + updates: { 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, + updates: { 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_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, + updates: { + 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/components/editor/panels/timeline/index.tsx b/apps/web/src/components/editor/panels/timeline/index.tsx index f02f8461..5b1ebb96 100644 --- a/apps/web/src/components/editor/panels/timeline/index.tsx +++ b/apps/web/src/components/editor/panels/timeline/index.tsx @@ -13,7 +13,7 @@ import { VolumeOffIcon, } from "@hugeicons/core-free-icons"; import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react"; -import { OcShapesIcon, OcVideoIcon } from "@opencut/ui/icons"; +import { OcShapesIcon, OcVideoIcon } from "@/components/icons"; import { ContextMenu, ContextMenuContent, |
