From 3e89d2998588f8e35c524e523ba2ee853f0f6475 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 20 Apr 2026 11:31:17 +0200 Subject: refactor: restructure files to their domains, new preview overlay system, and dep graph --- apps/web/src/masks/components/masks-tab.tsx | 823 ++++++++++++++++++++++++++++ 1 file changed, 823 insertions(+) create mode 100644 apps/web/src/masks/components/masks-tab.tsx (limited to 'apps/web/src/masks/components') diff --git a/apps/web/src/masks/components/masks-tab.tsx b/apps/web/src/masks/components/masks-tab.tsx new file mode 100644 index 00000000..97fba3ab --- /dev/null +++ b/apps/web/src/masks/components/masks-tab.tsx @@ -0,0 +1,823 @@ +"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 ( + <> + +