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/__tests__/snap.test.ts | 487 +++++++++++++ apps/web/src/masks/components/masks-tab.tsx | 823 ++++++++++++++++++++++ apps/web/src/masks/custom-path.ts | 842 +++++++++++++++++++++++ apps/web/src/masks/definitions/box-like.ts | 313 +++++++++ apps/web/src/masks/definitions/cinematic-bars.ts | 135 ++++ apps/web/src/masks/definitions/custom.ts | 652 ++++++++++++++++++ apps/web/src/masks/definitions/diamond.ts | 100 +++ apps/web/src/masks/definitions/ellipse.ts | 75 ++ apps/web/src/masks/definitions/heart.ts | 142 ++++ apps/web/src/masks/definitions/index.ts | 74 ++ apps/web/src/masks/definitions/rectangle.ts | 97 +++ apps/web/src/masks/definitions/split.ts | 382 ++++++++++ apps/web/src/masks/definitions/star.ts | 140 ++++ apps/web/src/masks/definitions/text.ts | 438 ++++++++++++ apps/web/src/masks/dimensions.ts | 2 + apps/web/src/masks/feather.ts | 2 + apps/web/src/masks/geometry.ts | 121 ++++ apps/web/src/masks/handle-positions.ts | 497 +++++++++++++ apps/web/src/masks/index.ts | 18 + apps/web/src/masks/param-update.ts | 26 + apps/web/src/masks/registry.ts | 122 ++++ apps/web/src/masks/snap.ts | 336 +++++++++ apps/web/src/masks/types.ts | 298 ++++++++ apps/web/src/masks/use-mask-handles.ts | 633 +++++++++++++++++ apps/web/src/masks/utils.ts | 64 ++ 25 files changed, 6819 insertions(+) create mode 100644 apps/web/src/masks/__tests__/snap.test.ts create mode 100644 apps/web/src/masks/components/masks-tab.tsx create mode 100644 apps/web/src/masks/custom-path.ts create mode 100644 apps/web/src/masks/definitions/box-like.ts create mode 100644 apps/web/src/masks/definitions/cinematic-bars.ts create mode 100644 apps/web/src/masks/definitions/custom.ts create mode 100644 apps/web/src/masks/definitions/diamond.ts create mode 100644 apps/web/src/masks/definitions/ellipse.ts create mode 100644 apps/web/src/masks/definitions/heart.ts create mode 100644 apps/web/src/masks/definitions/index.ts create mode 100644 apps/web/src/masks/definitions/rectangle.ts create mode 100644 apps/web/src/masks/definitions/split.ts create mode 100644 apps/web/src/masks/definitions/star.ts create mode 100644 apps/web/src/masks/definitions/text.ts create mode 100644 apps/web/src/masks/dimensions.ts create mode 100644 apps/web/src/masks/feather.ts create mode 100644 apps/web/src/masks/geometry.ts create mode 100644 apps/web/src/masks/handle-positions.ts create mode 100644 apps/web/src/masks/index.ts create mode 100644 apps/web/src/masks/param-update.ts create mode 100644 apps/web/src/masks/registry.ts create mode 100644 apps/web/src/masks/snap.ts create mode 100644 apps/web/src/masks/types.ts create mode 100644 apps/web/src/masks/use-mask-handles.ts create mode 100644 apps/web/src/masks/utils.ts (limited to 'apps/web/src/masks') diff --git a/apps/web/src/masks/__tests__/snap.test.ts b/apps/web/src/masks/__tests__/snap.test.ts new file mode 100644 index 00000000..2f33615f --- /dev/null +++ b/apps/web/src/masks/__tests__/snap.test.ts @@ -0,0 +1,487 @@ +import { describe, expect, test } from "bun:test"; +import { + findClosestPointOnCustomMaskSegment, + getCustomMaskClosedStateAfterPointRemoval, + insertPointIntoCustomMaskSegment, + removeCustomMaskPoints, +} from "@/masks/custom-path"; +import { + appendPointToCustomMask, + customMaskDefinition, + insertPointOnCustomMaskSegment, +} from "@/masks/definitions/custom"; +import { getSplitMaskStrokeSegment } from "@/masks/definitions/split"; +import { textMaskDefinition } from "@/masks/definitions/text"; +import { getMaskSnapGeometry } from "@/masks/geometry"; +import { snapMaskInteraction } from "@/masks/snap"; +import type { ElementBounds } from "@/preview/element-bounds"; +import type { + CustomMaskParams, + RectangleMaskParams, + SplitMaskParams, + TextMaskParams, +} from "@/masks/types"; + +const bounds: ElementBounds = { + cx: 200, + cy: 150, + width: 200, + height: 100, + rotation: 0, +}; + +const canvasSize = { + width: 400, + height: 300, +}; + +const snapThreshold = { + x: 8, + y: 8, +}; + +function buildSplitParams( + overrides: Partial = {}, +): SplitMaskParams { + return { + feather: 0, + inverted: false, + strokeColor: "#ffffff", + strokeWidth: 0, + strokeAlign: "center", + centerX: 0, + centerY: 0, + rotation: 0, + ...overrides, + }; +} + +function buildRectangleParams( + overrides: Partial = {}, +): RectangleMaskParams { + return { + feather: 0, + inverted: false, + strokeColor: "#ffffff", + strokeWidth: 0, + strokeAlign: "center", + centerX: 0, + centerY: 0, + width: 0.4, + height: 0.2, + rotation: 0, + scale: 1, + ...overrides, + }; +} + +function buildTextMaskParams( + overrides: Partial = {}, +): TextMaskParams { + return { + feather: 0, + inverted: false, + strokeColor: "#ffffff", + strokeWidth: 0, + strokeAlign: "center", + content: "Mask", + fontSize: 15, + fontFamily: "Arial", + fontWeight: "normal", + fontStyle: "normal", + textDecoration: "none", + letterSpacing: 0, + lineHeight: 1.2, + centerX: 0, + centerY: 0, + rotation: 0, + scale: 1, + ...overrides, + }; +} + +function buildCustomMaskParams( + overrides: Partial = {}, +): CustomMaskParams { + return { + feather: 0, + inverted: false, + strokeColor: "#ffffff", + strokeWidth: 0, + strokeAlign: "center", + path: [ + { + id: "a", + x: -0.2, + y: -0.1, + inX: 0, + inY: 0, + outX: 0, + outY: 0, + }, + { + id: "b", + x: 0.2, + y: -0.1, + inX: 0, + inY: 0, + outX: 0, + outY: 0, + }, + { + id: "c", + x: 0, + y: 0.2, + inX: 0, + inY: 0, + outX: 0, + outY: 0, + }, + ], + closed: true, + centerX: 0, + centerY: 0, + rotation: 0, + scale: 1, + ...overrides, + }; +} + +function sortSegment( + segment: [{ x: number; y: number }, { x: number; y: number }], +): [{ x: number; y: number }, { x: number; y: number }] { + return [...segment].sort((a, b) => (a.x === b.x ? a.y - b.y : a.x - b.x)) as [ + { x: number; y: number }, + { x: number; y: number }, + ]; +} + +describe("mask geometry", () => { + test("resolves split mask center from centerX and centerY", () => { + expect( + getMaskSnapGeometry({ + params: buildSplitParams({ + centerX: 0.25, + centerY: -0.5, + rotation: 45, + }), + bounds, + }), + ).toEqual({ + position: { x: 50, y: -50 }, + size: { width: 0, height: 0 }, + rotation: 45, + }); + }); + + test("resolves box mask center and size from centerX and centerY", () => { + expect( + getMaskSnapGeometry({ + params: buildRectangleParams({ + centerX: -0.25, + centerY: 0.5, + width: 0.5, + height: 0.6, + rotation: 30, + }), + bounds, + }), + ).toEqual({ + position: { x: -50, y: 50 }, + size: { width: 100, height: 60 }, + rotation: 30, + }); + }); + + test("returns a vertical split stroke segment for rotation 0", () => { + const segment = getSplitMaskStrokeSegment({ + resolvedParams: buildSplitParams(), + width: bounds.width, + height: bounds.height, + }); + + expect(segment).not.toBeNull(); + if (!segment) { + throw new Error("Expected split stroke segment for rotation 0"); + } + expect(sortSegment(segment)).toEqual([ + { x: bounds.width / 2, y: 0 }, + { x: bounds.width / 2, y: bounds.height }, + ]); + }); + + test("returns a horizontal split stroke segment for rotation 90", () => { + const segment = getSplitMaskStrokeSegment({ + resolvedParams: buildSplitParams({ rotation: 90 }), + width: bounds.width, + height: bounds.height, + }); + + expect(segment).not.toBeNull(); + if (!segment) { + throw new Error("Expected split stroke segment for rotation 90"); + } + expect(sortSegment(segment)).toEqual([ + { x: 0, y: bounds.height / 2 }, + { x: bounds.width, y: bounds.height / 2 }, + ]); + }); +}); + +describe("mask snapping", () => { + test("snaps split mask movement using the shared position pipeline", () => { + const result = snapMaskInteraction({ + handleId: "position", + startParams: buildSplitParams({ + centerX: 0.03, + centerY: -0.04, + }), + proposedParams: buildSplitParams({ + centerX: 0.03, + centerY: -0.04, + }), + bounds, + canvasSize, + snapThreshold, + }); + + expect(result.params.centerX).toBe(0); + expect(result.params.centerY).toBe(0); + expect(result.activeLines).toEqual([ + { type: "vertical", position: 0 }, + { type: "horizontal", position: 0 }, + ]); + }); + + test("snaps box mask movement against element center and edges", () => { + const result = snapMaskInteraction({ + handleId: "position", + startParams: buildRectangleParams(), + proposedParams: buildRectangleParams({ + centerX: 0.29, + centerY: 0.03, + }), + bounds, + canvasSize, + snapThreshold, + }); + + expect(result.params.centerX).toBeCloseTo(0.3); + expect(result.params.centerY).toBe(0); + expect(result.activeLines).toEqual([ + { type: "vertical", position: 100 }, + { type: "horizontal", position: 0 }, + ]); + }); + + test("snaps mask rotation through the shared rotation path", () => { + const result = snapMaskInteraction({ + handleId: "rotation", + startParams: buildRectangleParams(), + proposedParams: buildRectangleParams({ + rotation: 88, + }), + bounds, + canvasSize, + snapThreshold, + }); + + expect(result.params.rotation).toBe(90); + expect(result.activeLines).toEqual([]); + }); + + test("snaps edge resize for box masks", () => { + const result = snapMaskInteraction({ + handleId: "right", + startParams: buildRectangleParams(), + proposedParams: buildRectangleParams({ + width: 0.98, + }), + bounds, + canvasSize, + snapThreshold, + }); + + expect(result.params.width).toBe(1); + expect(result.activeLines).toEqual([{ type: "vertical", position: 100 }]); + }); + + test("snaps corner resize for box masks", () => { + const result = snapMaskInteraction({ + handleId: "bottom-right", + startParams: buildRectangleParams(), + proposedParams: buildRectangleParams({ + width: 0.99, + height: 0.495, + }), + bounds, + canvasSize, + snapThreshold, + }); + + expect(result.params.width).toBe(1); + expect(result.params.height).toBe(0.5); + expect(result.activeLines).toEqual([{ type: "vertical", position: 100 }]); + }); + + test("snaps text mask movement using intrinsic text bounds", () => { + const params = buildTextMaskParams({ + centerX: 0.03, + centerY: -0.04, + }); + const result = textMaskDefinition.interaction.snap?.({ + handleId: "position", + startParams: params, + proposedParams: params, + bounds, + canvasSize, + snapThreshold, + }); + + expect(result?.params.centerX).toBe(0); + expect(result?.params.centerY).toBe(0); + expect(result?.activeLines).toEqual([ + { type: "vertical", position: 0 }, + { type: "horizontal", position: 0 }, + ]); + }); + + test("snaps custom mask movement using path geometry bounds", () => { + const params = buildCustomMaskParams({ + centerX: 0.03, + centerY: -0.04, + }); + const result = customMaskDefinition.interaction.snap?.({ + handleId: "position", + startParams: params, + proposedParams: params, + bounds, + canvasSize, + snapThreshold, + }); + + expect(result?.params.centerX).toBe(0); + expect(result?.params.centerY).toBe(0); + expect(result?.activeLines).toEqual([ + { type: "vertical", position: 0 }, + { type: "horizontal", position: 0 }, + ]); + }); + + test("marks blank text masks inactive", () => { + expect( + textMaskDefinition.isActive?.( + buildTextMaskParams({ + content: " ", + }), + ), + ).toBe(false); + }); +}); + +describe("custom mask creation", () => { + test("anchors the first point at the click position", () => { + const params = buildCustomMaskParams({ + path: [], + closed: false, + }); + const next = appendPointToCustomMask({ + params, + canvasPoint: { x: bounds.cx + 20, y: bounds.cy - 10 }, + bounds, + }); + + expect(next.centerX).toBeCloseTo(0.1); + expect(next.centerY).toBeCloseTo(-0.1); + expect(next.rotation).toBe(0); + expect(next.scale).toBe(1); + expect(next.path).toHaveLength(1); + }); +}); + +describe("custom mask point deletion", () => { + test("removes the selected points by id", () => { + const points = buildCustomMaskParams().path; + const nextPoints = removeCustomMaskPoints({ + points, + pointIds: ["b"], + }); + + expect(nextPoints.map((point) => point.id)).toEqual(["a", "c"]); + }); + + test("reopens a closed path once fewer than three points remain", () => { + const points = buildCustomMaskParams().path; + const nextPoints = removeCustomMaskPoints({ + points, + pointIds: ["c"], + }); + + expect( + getCustomMaskClosedStateAfterPointRemoval({ + wasClosed: true, + remainingPointCount: nextPoints.length, + }), + ).toBe(false); + }); +}); + +describe("custom mask point insertion", () => { + test("finds the closest point on the clicked segment", () => { + const params = buildCustomMaskParams(); + const points = params.path; + const closestPoint = findClosestPointOnCustomMaskSegment({ + points, + segmentIndex: 0, + canvasPoint: { x: bounds.cx, y: bounds.cy - 10 }, + centerX: params.centerX, + centerY: params.centerY, + rotation: params.rotation, + scale: params.scale, + bounds, + closed: params.closed, + }); + + expect(closestPoint).not.toBeNull(); + expect(closestPoint?.t).toBeCloseTo(0.5, 1); + expect(closestPoint?.point.x).toBeCloseTo(bounds.cx, 4); + expect(closestPoint?.point.y).toBeCloseTo(bounds.cy - 10, 4); + }); + + test("splits a segment into two segments at the insertion point", () => { + const points = buildCustomMaskParams().path; + const nextPoints = insertPointIntoCustomMaskSegment({ + points, + segmentIndex: 0, + pointId: "new", + t: 0.5, + closed: true, + }); + + expect(nextPoints.map((point) => point.id)).toEqual(["a", "new", "b", "c"]); + expect(nextPoints[1]).toMatchObject({ + id: "new", + x: 0, + y: -0.1, + inX: 0, + inY: 0, + outX: 0, + outY: 0, + }); + }); + + test("builds updated custom mask params for a clicked segment", () => { + const result = insertPointOnCustomMaskSegment({ + params: buildCustomMaskParams(), + segmentIndex: 0, + canvasPoint: { x: bounds.cx, y: bounds.cy - 10 }, + bounds, + pointId: "new", + }); + + expect(result).not.toBeNull(); + const nextPoints = result?.params.path ?? []; + expect(nextPoints).toHaveLength(4); + expect(nextPoints.some((point) => point.id === "new")).toBe(true); + }); +}); 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 ( + <> + +