summaryrefslogtreecommitdiff
path: root/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
commit3e89d2998588f8e35c524e523ba2ee853f0f6475 (patch)
tree57221f96c1259b3b07e3ddf8c1adefc502b9d8eb /apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx
parent729d10592f4e802f91c555c309ff3ba4b119f843 (diff)
downloadopencut-3e89d2998588f8e35c524e523ba2ee853f0f6475.tar.gz
refactor: restructure files to their domains, new preview overlay system, and dep graph
Diffstat (limited to 'apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx')
-rw-r--r--apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx826
1 files changed, 0 insertions, 826 deletions
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
deleted file mode 100644
index 090fce64..00000000
--- a/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx
+++ /dev/null
@@ -1,826 +0,0 @@
-"use client";
-
-import type { MaskableElement } from "@/lib/timeline";
-import type { Mask, MaskType, TextMask } from "@/lib/masks/types";
-import type {
- NumberParamDefinition,
- SelectParamDefinition,
-} from "@/lib/params";
-import { masksRegistry, buildDefaultMaskInstance } from "@/lib/masks";
-import { useEditor } from "@/hooks/use-editor";
-import { useElementPreview } from "@/hooks/use-element-preview";
-import { useMenuPreview } from "@/hooks/use-menu-preview";
-import { getVisibleElementsWithBounds } from "@/lib/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 "../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<MaskableElement>({
- 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<MaskableElement>,
- },
- ],
- });
- };
-
- 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<MaskableElement>,
- },
- ],
- });
- }
- 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<MaskableElement>);
- };
-
- return (
- <div className="flex flex-col h-full">
- <div className="border-b px-3.5 h-11 shrink-0 flex items-center justify-between gap-2">
- <SectionTitle>Masks</SectionTitle>
- <DropdownMenu
- open={hasMask ? false : isDropdownOpen}
- onOpenChange={handleDropdownOpenChange}
- >
- {hasMask ? (
- <Tooltip>
- <TooltipTrigger asChild>
- <span className="inline-flex">
- <Button
- variant="ghost"
- size="icon"
- disabled
- aria-label="Add mask"
- >
- <HugeiconsIcon icon={PlusSignIcon} className="size-3.5!" />
- </Button>
- </span>
- </TooltipTrigger>
- <TooltipContent className="max-w-56 text-balance">
- Only one mask is supported right now. If you need more,
- duplicate the clip and apply a different mask to each copy.
- </TooltipContent>
- </Tooltip>
- ) : (
- <DropdownMenuTrigger asChild>
- <Button variant="ghost" size="icon" aria-label="Add mask">
- <HugeiconsIcon icon={PlusSignIcon} className="size-3.5!" />
- </Button>
- </DropdownMenuTrigger>
- )}
- <DropdownMenuContent className="w-40" onPointerLeave={onPointerLeave}>
- {maskDefs.map((definition) => (
- <DropdownMenuItem
- key={definition.type}
- onPointerEnter={() =>
- previewMask({ maskType: definition.type })
- }
- onClick={() => commitMask({ maskType: definition.type })}
- >
- <HugeiconsIcon {...definition.icon} />
- {definition.name}
- </DropdownMenuItem>
- ))}
- </DropdownMenuContent>
- </DropdownMenu>
- </div>
-
- {masks.length === 0 ? (
- <EmptyView onAddMask={() => setIsDropdownOpen(true)} />
- ) : (
- masks.map((mask, index) => (
- <MaskItem
- key={mask.id}
- trackId={trackId}
- elementId={element.id}
- mask={renderMasks[index] ?? mask}
- previewParam={(paramKey) =>
- previewMaskParam({ index, key: paramKey })
- }
- onCommit={commit}
- />
- ))
- )}
- </div>
- );
-}
-
-function MaskItem({
- trackId,
- elementId,
- mask,
- previewParam,
- onCommit,
-}: MaskItemProps) {
- const editor = useEditor();
- const definition = masksRegistry.get(mask.type);
-
- return (
- <Section sectionKey={`mask-item:${mask.id}`} showTopBorder={false}>
- <SectionHeader
- trailing={
- <div className="flex items-center gap-1">
- <Button
- variant="ghost"
- size="icon"
- aria-label={`Toggle ${definition.name} mask inversion`}
- onClick={() =>
- editor.timeline.toggleMaskInverted({
- trackId,
- elementId,
- maskId: mask.id,
- })
- }
- >
- <OcMirrorIcon
- className={cn(mask.params.inverted && "-scale-x-100")}
- />
- </Button>
- <Button
- variant="ghost"
- size="icon"
- aria-label={`Remove ${definition.name} mask`}
- onClick={() =>
- editor.timeline.removeMask({
- trackId,
- elementId,
- maskId: mask.id,
- })
- }
- >
- <HugeiconsIcon icon={Delete02Icon} />
- </Button>
- </div>
- }
- >
- <div className="flex items-center gap-2">
- <HugeiconsIcon {...definition.icon} size={14} />
- <SectionTitle className="capitalize font-normal">
- {definition.name}
- </SectionTitle>
- </div>
- </SectionHeader>
- <SectionContent>
- <MaskParamsFields
- mask={mask}
- definition={definition}
- previewParam={previewParam}
- onCommit={onCommit}
- />
- </SectionContent>
- </Section>
- );
-}
-
-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<string> =>
- param.key === "strokeAlign" && param.type === "select",
- );
-
- return (
- <SectionFields>
- {isTextMask(mask) ? (
- <TextMaskFields
- mask={mask}
- previewParam={previewParam}
- onCommit={onCommit}
- fontSizeParam={getNumberParamDefinition({
- definition,
- key: "fontSize",
- })}
- />
- ) : null}
- {definition.features.hasPosition &&
- "centerX" in mask.params &&
- "centerY" in mask.params && (
- <SectionField label="Position">
- <div className="flex items-center gap-2">
- <MaskNumberField
- className="flex-1"
- icon="X"
- param={getNumberParamDefinition({
- definition,
- key: "centerX",
- })}
- value={getMaskNumber({
- params: mask.params,
- key: "centerX",
- })}
- onPreview={previewNumberParam("centerX")}
- onCommit={onCommit}
- />
- <MaskNumberField
- className="flex-1"
- icon="Y"
- param={getNumberParamDefinition({
- definition,
- key: "centerY",
- })}
- value={getMaskNumber({
- params: mask.params,
- key: "centerY",
- })}
- onPreview={previewNumberParam("centerY")}
- onCommit={onCommit}
- />
- </div>
- </SectionField>
- )}
-
- {definition.features.sizeMode === "width-height" &&
- "width" in mask.params &&
- "height" in mask.params && (
- <SectionField label="Size">
- <div className="flex items-center gap-2">
- <MaskNumberField
- className="flex-1"
- icon="W"
- param={getNumberParamDefinition({
- definition,
- key: "width",
- })}
- value={getMaskNumber({
- params: mask.params,
- key: "width",
- })}
- onPreview={previewNumberParam("width")}
- onCommit={onCommit}
- />
- <MaskNumberField
- className="flex-1"
- icon="H"
- param={getNumberParamDefinition({
- definition,
- key: "height",
- })}
- value={getMaskNumber({
- params: mask.params,
- key: "height",
- })}
- onPreview={previewNumberParam("height")}
- onCommit={onCommit}
- />
- </div>
- </SectionField>
- )}
-
- {definition.features.sizeMode === "height-only" &&
- "height" in mask.params && (
- <SectionField label="Height">
- <MaskNumberField
- icon="H"
- param={getNumberParamDefinition({
- definition,
- key: "height",
- })}
- value={getMaskNumber({
- params: mask.params,
- key: "height",
- })}
- onPreview={previewNumberParam("height")}
- onCommit={onCommit}
- />
- </SectionField>
- )}
-
- {definition.features.sizeMode === "width-only" &&
- "width" in mask.params && (
- <SectionField label="Width">
- <MaskNumberField
- icon="W"
- param={getNumberParamDefinition({
- definition,
- key: "width",
- })}
- value={getMaskNumber({
- params: mask.params,
- key: "width",
- })}
- onPreview={previewNumberParam("width")}
- onCommit={onCommit}
- />
- </SectionField>
- )}
-
- {definition.features.sizeMode === "uniform" && "scale" in mask.params && (
- <SectionField label="Scale">
- <MaskNumberField
- icon={
- isTextMask(mask) ? <HugeiconsIcon icon={ArrowExpandIcon} /> : "S"
- }
- param={getNumberParamDefinition({
- definition,
- key: "scale",
- })}
- value={getMaskNumber({
- params: mask.params,
- key: "scale",
- })}
- onPreview={previewNumberParam("scale")}
- onCommit={onCommit}
- />
- </SectionField>
- )}
-
- {definition.features.hasRotation && "rotation" in mask.params && (
- <SectionField label="Rotation">
- <MaskNumberField
- icon={<HugeiconsIcon icon={RotateClockwiseIcon} />}
- param={getNumberParamDefinition({
- definition,
- key: "rotation",
- })}
- value={getMaskNumber({
- params: mask.params,
- key: "rotation",
- })}
- onPreview={previewNumberParam("rotation")}
- onCommit={onCommit}
- />
- </SectionField>
- )}
-
- <SectionField label="Feather">
- <MaskNumberField
- icon={<HugeiconsIcon icon={FeatherIcon} />}
- param={featherParam}
- value={getMaskNumber({
- params: mask.params,
- key: "feather",
- })}
- onPreview={previewNumberParam("feather")}
- onCommit={onCommit}
- />
- </SectionField>
-
- <SectionField label="Stroke">
- <div className="flex flex-col gap-2">
- <div className="flex items-center gap-2">
- <MaskNumberField
- className="flex-1"
- icon="W"
- param={strokeWidthParam}
- value={getMaskNumber({
- params: mask.params,
- key: "strokeWidth",
- })}
- onPreview={previewNumberParam("strokeWidth")}
- onCommit={onCommit}
- />
- <ColorPicker
- className=""
- value={mask.params.strokeColor.replace(/^#/, "").toUpperCase()}
- onChange={(color) => previewStrokeColor(`#${color}`)}
- onChangeEnd={(color) => {
- previewStrokeColor(`#${color}`);
- onCommit();
- }}
- />
- </div>
- {strokeAlignParam ? (
- <Select
- value={mask.params.strokeAlign}
- onValueChange={(value) => {
- previewParam("strokeAlign")(value);
- onCommit();
- }}
- >
- <SelectTrigger className="w-full">
- <SelectValue />
- </SelectTrigger>
- <SelectContent>
- {strokeAlignParam.options.map((option) => (
- <SelectItem key={option.value} value={option.value}>
- {option.label}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- ) : null}
- </div>
- </SectionField>
- </SectionFields>
- );
-}
-
-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 (
- <>
- <SectionField label="Content">
- <Textarea
- value={content.displayValue}
- className="min-h-20"
- onFocus={content.onFocus}
- onChange={content.onChange}
- onBlur={content.onBlur}
- />
- </SectionField>
- <SectionField label="Font">
- <FontPicker
- defaultValue={mask.params.fontFamily}
- onValueChange={(value) => {
- previewParam("fontFamily")(value);
- onCommit();
- }}
- />
- </SectionField>
- <SectionField label="Size">
- <MaskNumberField
- icon={<HugeiconsIcon icon={TextFontIcon} />}
- param={fontSizeParam}
- value={mask.params.fontSize}
- onPreview={previewNumberParam("fontSize")}
- onCommit={onCommit}
- />
- </SectionField>
- <SectionField label="Spacing">
- <div className="flex items-start gap-2">
- <MaskNumberField
- className="w-1/2"
- icon={<OcTextWidthIcon size={14} />}
- param={LETTER_SPACING_PARAM}
- value={mask.params.letterSpacing ?? 0}
- onPreview={previewNumberParam("letterSpacing")}
- onCommit={onCommit}
- />
- <MaskNumberField
- className="w-1/2"
- icon={<OcTextHeightIcon size={14} />}
- param={LINE_HEIGHT_PARAM}
- value={mask.params.lineHeight ?? 1.2}
- onPreview={previewNumberParam("lineHeight")}
- onCommit={onCommit}
- />
- </div>
- </SectionField>
- </>
- );
-}
-
-function getNumberParamDefinition({
- definition,
- key,
-}: {
- definition: RegisteredMaskDefinition;
- key: string;
-}): NumberParamDefinition {
- const param = definition.params.find((candidate) => candidate.key === key);
-
- if (!param || param.type !== "number") {
- throw new Error(`Missing number param definition for mask key "${key}"`);
- }
-
- return param;
-}
-
-function getMaskNumber<
- TParams extends Mask["params"],
- TKey extends keyof TParams & string,
->({ params, key }: { params: TParams; key: TKey }): number {
- const value = params[key];
-
- if (typeof value !== "number") {
- throw new Error(`Expected numeric mask param for "${key}"`);
- }
-
- return value;
-}
-
-function MaskNumberField({
- param,
- value,
- onPreview,
- onCommit,
- icon,
- className,
-}: {
- param: NumberParamDefinition;
- value: number;
- onPreview: (value: number) => void;
- onCommit: () => void;
- icon?: React.ReactNode;
- className?: string;
-}) {
- const isPercent = param.unit === "percent";
- const percentMax = param.max ?? 100;
- const displayMultiplier = isPercent
- ? 100 / percentMax
- : (param.displayMultiplier ?? 1);
- const min = isPercent ? 0 : param.min;
- const max = isPercent ? 100 : param.max;
- const step = isPercent ? 1 : param.step;
- const displayValue = value * displayMultiplier;
- const maxFractionDigits = getFractionDigitsForStep({ step });
-
- const clampDisplay = (nextDisplayValue: number) =>
- max !== undefined
- ? clamp({ value: nextDisplayValue, min, max })
- : Math.max(min, nextDisplayValue);
-
- const previewFromDisplay = (nextDisplayValue: number) => {
- onPreview(
- clampDisplay(snapToStep({ value: nextDisplayValue, step })) /
- displayMultiplier,
- );
- };
-
- const draft = usePropertyDraft({
- displayValue: formatNumberForDisplay({
- value: displayValue,
- maxFractionDigits,
- }),
- parse: (input) => {
- const parsed = parseFloat(input);
- if (Number.isNaN(parsed)) return null;
- return (
- clampDisplay(snapToStep({ value: parsed, step })) / displayMultiplier
- );
- },
- onPreview,
- onCommit,
- });
-
- return (
- <NumberField
- className={className}
- icon={icon}
- value={draft.displayValue}
- dragSensitivity="slow"
- onFocus={draft.onFocus}
- onChange={draft.onChange}
- onBlur={draft.onBlur}
- onScrub={previewFromDisplay}
- onScrubEnd={onCommit}
- />
- );
-}
-
-function EmptyView({ onAddMask }: EmptyViewProps) {
- return (
- <div className="flex flex-col h-full items-center justify-center gap-4 text-center">
- <OcShapesIcon className="size-10 text-muted-foreground" strokeWidth={1} />
- <div className="flex flex-col gap-2">
- <h3 className="font-medium text-foreground">No masks</h3>
- <p className="text-muted-foreground text-sm text-balance max-w-40">
- Add a mask to hide or reveal parts of this layer.
- </p>
- </div>
- <Button variant="default" size="sm" onClick={onAddMask}>
- Add mask
- </Button>
- </div>
- );
-}