"use client"; import type { MaskableElement } from "@/timeline"; import type { Mask, MaskType, TextMask } from "@/masks/types"; import type { NumberParamDefinition, SelectParamDefinition } from "@/params"; import { masksRegistry, buildDefaultMaskInstance } from "@/masks"; import { useEditor } from "@/editor/use-editor"; import { useElementPreview } from "@/timeline/hooks/use-element-preview"; import { useMenuPreview } from "@/editor/use-menu-preview"; import { getVisibleElementsWithBounds } from "@/preview/element-bounds"; import { HugeiconsIcon } from "@hugeicons/react"; import { ArrowExpandIcon, Delete02Icon, FeatherIcon, PlusSignIcon, RotateClockwiseIcon, TextFontIcon, } from "@hugeicons/core-free-icons"; import { useMemo, useState } from "react"; import { Button } from "@/components/ui/button"; import { ColorPicker } from "@/components/ui/color-picker"; import { FontPicker } from "@/components/ui/font-picker"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { NumberField } from "@/components/ui/number-field"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Textarea } from "@/components/ui/textarea"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import { clamp, formatNumberForDisplay, getFractionDigitsForStep, snapToStep, } from "@/utils/math"; import { Section, SectionContent, SectionField, SectionFields, SectionHeader, SectionTitle, } from "@/components/section"; import { usePropertyDraft } from "@/components/editor/panels/properties/hooks/use-property-draft"; import { OcMirrorIcon, OcShapesIcon, OcTextHeightIcon, OcTextWidthIcon, } from "@/components/icons"; import { cn } from "@/utils/ui"; type MasksTabProps = { element: MaskableElement; trackId: string; }; type MaskItemProps = { trackId: string; elementId: string; mask: Mask; previewParam: (key: string) => (value: number | string | boolean) => void; onCommit: () => void; }; type EmptyViewProps = { onAddMask: () => void; }; type PreviewParamHandler = ( key: string, ) => (value: number | string | boolean) => void; type RegisteredMaskDefinition = ReturnType<(typeof masksRegistry)["get"]>; function isTextMask(mask: Mask): mask is TextMask { return mask.type === "text"; } export function MasksTab({ element, trackId }: MasksTabProps) { const editor = useEditor(); const { renderElement, previewUpdates, commit } = useElementPreview({ trackId, elementId: element.id, fallback: element, }); const maskDefs = masksRegistry.getAll(); const tracks = useEditor( (e) => e.timeline.getPreviewTracks() ?? e.scenes.getActiveScene().tracks, ); const currentTime = useEditor((e) => e.playback.getCurrentTime()); const mediaAssets = useEditor((e) => e.media.getAssets()); const canvasSize = useEditor( (e) => e.project.getActive().settings.canvasSize, ); const masks = element.masks ?? []; const renderMasks = renderElement.masks ?? masks; const hasMask = masks.length > 0; const { onPointerLeave, onOpenChange, markCommitted } = useMenuPreview(); const [isDropdownOpen, setIsDropdownOpen] = useState(false); const elementBounds = useMemo(() => { const clampedTime = Math.min( Math.max(currentTime, element.startTime), element.startTime + element.duration - 1, ); return ( getVisibleElementsWithBounds({ tracks, currentTime: clampedTime, canvasSize, mediaAssets, }).find( (item) => item.trackId === trackId && item.elementId === element.id, )?.bounds ?? null ); }, [ canvasSize, currentTime, element.duration, element.id, element.startTime, mediaAssets, trackId, tracks, ]); const handleDropdownOpenChange = (open: boolean) => { if (hasMask && open) return; setIsDropdownOpen(open); onOpenChange(open); }; const previewMask = ({ maskType }: { maskType: MaskType }) => { editor.timeline.previewElements({ updates: [ { trackId, elementId: element.id, updates: { masks: [ buildDefaultMaskInstance({ maskType, elementSize: elementBounds ? { width: elementBounds.width, height: elementBounds.height, } : undefined, }), ], } as Partial, }, ], }); }; const commitMask = ({ maskType }: { maskType: MaskType }) => { if (editor.timeline.isPreviewActive()) { editor.timeline.commitPreview(); } else { editor.timeline.updateElements({ updates: [ { trackId, elementId: element.id, patch: { masks: [ buildDefaultMaskInstance({ maskType, elementSize: elementBounds ? { width: elementBounds.width, height: elementBounds.height, } : undefined, }), ], } as Partial, }, ], }); } markCommitted(); setIsDropdownOpen(false); }; const previewMaskParam = ({ index, key }: { index: number; key: string }) => (value: number | string | boolean) => { if (!renderMasks[index]) { return; } const updatedMasks = renderMasks.map((existingMask, maskIndex) => maskIndex !== index ? existingMask : { ...existingMask, params: { ...existingMask.params, [key]: value, }, }, ); previewUpdates({ masks: updatedMasks } as Partial); }; return (
Masks {hasMask ? ( Only one mask is supported right now. If you need more, duplicate the clip and apply a different mask to each copy. ) : ( )} {maskDefs.map((definition) => ( previewMask({ maskType: definition.type }) } onClick={() => commitMask({ maskType: definition.type })} > {definition.name} ))}
{masks.length === 0 ? ( setIsDropdownOpen(true)} /> ) : ( masks.map((mask, index) => ( previewMaskParam({ index, key: paramKey }) } onCommit={commit} /> )) )}
); } function MaskItem({ trackId, elementId, mask, previewParam, onCommit, }: MaskItemProps) { const editor = useEditor(); const definition = masksRegistry.get(mask.type); return (
} >
{definition.name}
); } function MaskParamsFields({ mask, definition, previewParam, onCommit, }: { mask: Mask; definition: RegisteredMaskDefinition; previewParam: PreviewParamHandler; onCommit: () => void; }) { const featherParam = getNumberParamDefinition({ definition, key: "feather", }); const strokeWidthParam = getNumberParamDefinition({ definition, key: "strokeWidth", }); const previewNumberParam = (key: string) => (value: number) => previewParam(key)(value); const previewStrokeColor = previewParam("strokeColor"); const strokeAlignParam = definition.params.find( (param): param is SelectParamDefinition => param.key === "strokeAlign" && param.type === "select", ); return ( {isTextMask(mask) ? ( ) : null} {definition.features.hasPosition && "centerX" in mask.params && "centerY" in mask.params && (
)} {definition.features.sizeMode === "width-height" && "width" in mask.params && "height" in mask.params && (
)} {definition.features.sizeMode === "height-only" && "height" in mask.params && ( )} {definition.features.sizeMode === "width-only" && "width" in mask.params && ( )} {definition.features.sizeMode === "uniform" && "scale" in mask.params && ( : "S" } param={getNumberParamDefinition({ definition, key: "scale", })} value={getMaskNumber({ params: mask.params, key: "scale", })} onPreview={previewNumberParam("scale")} onCommit={onCommit} /> )} {definition.features.hasRotation && "rotation" in mask.params && ( } param={getNumberParamDefinition({ definition, key: "rotation", })} value={getMaskNumber({ params: mask.params, key: "rotation", })} onPreview={previewNumberParam("rotation")} onCommit={onCommit} /> )} } param={featherParam} value={getMaskNumber({ params: mask.params, key: "feather", })} onPreview={previewNumberParam("feather")} onCommit={onCommit} />
previewStrokeColor(`#${color}`)} onChangeEnd={(color) => { previewStrokeColor(`#${color}`); onCommit(); }} />
{strokeAlignParam ? ( ) : null}
); } const LETTER_SPACING_PARAM: NumberParamDefinition = { key: "letterSpacing", label: "Letter spacing", type: "number", default: 0, min: -100, max: 500, step: 1, }; const LINE_HEIGHT_PARAM: NumberParamDefinition = { key: "lineHeight", label: "Line height", type: "number", default: 1.2, min: 0.1, max: 10, step: 0.1, }; function TextMaskFields({ mask, previewParam, onCommit, fontSizeParam, }: { mask: TextMask; previewParam: PreviewParamHandler; onCommit: () => void; fontSizeParam: NumberParamDefinition; }) { const content = usePropertyDraft({ displayValue: mask.params.content, parse: (input) => input, onPreview: (value) => previewParam("content")(value), onCommit, }); const previewNumberParam = (key: string) => (value: number) => previewParam(key)(value); return ( <>