import type { ParamDefinition } from "@/lib/params"; import type { MaskDefinition, MaskParamUpdateArgs, TextMask, TextMaskParams, } from "@/lib/masks/types"; import { DEFAULTS } from "@/lib/timeline/defaults"; import { MIN_FONT_SIZE, MAX_FONT_SIZE } from "@/lib/text/typography"; import { drawMeasuredTextLayout, measureTextLayout, strokeMeasuredTextLayout, } from "@/lib/text/primitives"; import { getTextMeasurementContext } from "@/lib/text/measure-element"; import { getTextVisualRect } from "@/lib/text/layout"; import { getBoxMaskHandlePositions, getBoxMaskRectOverlay, } from "@/lib/masks/handle-positions"; import { computeFeatherUpdate } from "@/lib/masks/param-update"; import { setMaskLocalCenter, toGlobalMaskSnapLines, } from "@/lib/masks/geometry"; import { snapPosition, snapRotation, snapScale, type ScaleEdgePreference, } from "@/lib/preview/preview-snap"; const PERCENTAGE_DISPLAY = { displayMultiplier: 100, step: 1, } as const; const TEXT_MASK_ALIGNMENT = DEFAULTS.text.element.textAlign; const TEXT_MASK_PARAMS: ParamDefinition[] = [ { key: "centerX", label: "X", type: "number", default: 0, min: -100, max: 100, ...PERCENTAGE_DISPLAY, }, { key: "centerY", label: "Y", type: "number", default: 0, min: -100, max: 100, ...PERCENTAGE_DISPLAY, }, { key: "fontSize", label: "Size", type: "number", default: DEFAULTS.text.element.fontSize, min: MIN_FONT_SIZE, max: MAX_FONT_SIZE, step: 1, }, { key: "rotation", label: "Rotation", type: "number", default: 0, min: 0, max: 360, step: 1, }, { key: "scale", label: "Scale", type: "number", default: 1, min: 1, max: 500, ...PERCENTAGE_DISPLAY, }, ]; function measureTextMask({ params, height, }: { params: TextMaskParams; height: number; }) { const layout = measureTextLayout({ text: { content: params.content, fontSize: params.fontSize, fontFamily: params.fontFamily, fontWeight: params.fontWeight, fontStyle: params.fontStyle, textAlign: TEXT_MASK_ALIGNMENT, textDecoration: params.textDecoration, letterSpacing: params.letterSpacing, lineHeight: params.lineHeight, }, canvasHeight: height, ctx: getTextMeasurementContext(), }); const visualRect = getTextVisualRect({ textAlign: layout.textAlign, block: layout.block, background: { enabled: false, color: "transparent" }, fontSizeRatio: layout.fontSizeRatio, }); return { layout, intrinsicWidth: Math.max(1, visualRect.width), intrinsicHeight: Math.max(1, visualRect.height), }; } function getScalePreferredEdges({ handleId, }: { handleId: string; }): ScaleEdgePreference | undefined { if (handleId !== "scale") { return undefined; } return { right: true, bottom: true, }; } function computeTextMaskParamUpdate({ handleId, startParams, deltaX, deltaY, startCanvasX, startCanvasY, bounds, }: MaskParamUpdateArgs): Partial { if (handleId === "position") { return { centerX: startParams.centerX + deltaX / bounds.width, centerY: startParams.centerY + deltaY / bounds.height, }; } const pivotX = bounds.cx + startParams.centerX * bounds.width; const pivotY = bounds.cy + startParams.centerY * bounds.height; if (handleId === "rotation") { const startAngle = (Math.atan2(startCanvasY - pivotY, startCanvasX - pivotX) * 180) / Math.PI; const currentAngle = (Math.atan2( startCanvasY + deltaY - pivotY, startCanvasX + deltaX - pivotX, ) * 180) / Math.PI; let deltaAngle = currentAngle - startAngle; if (deltaAngle > 180) deltaAngle -= 360; if (deltaAngle < -180) deltaAngle += 360; return { rotation: (((startParams.rotation + deltaAngle) % 360) + 360) % 360, }; } if (handleId === "feather") { const angleRad = (startParams.rotation * Math.PI) / 180; return computeFeatherUpdate({ startFeather: startParams.feather, deltaX, deltaY, directionX: -Math.sin(angleRad), directionY: Math.cos(angleRad), }); } if (handleId === "scale") { const startDistance = Math.hypot( startCanvasX - pivotX, startCanvasY - pivotY, ); const currentDistance = Math.hypot( startCanvasX + deltaX - pivotX, startCanvasY + deltaY - pivotY, ); const scaleFactor = startDistance > 0 ? currentDistance / startDistance : 1; return { scale: Math.max(0.01, startParams.scale * scaleFactor), }; } return {}; } export const textMaskDefinition: MaskDefinition = { type: "text", name: "Text", features: { hasPosition: true, hasRotation: true, sizeMode: "uniform", }, params: TEXT_MASK_PARAMS, interaction: { getInteraction({ params, bounds, displayScale, scaleX: _scaleX, scaleY: _scaleY, }) { const { intrinsicWidth, intrinsicHeight } = measureTextMask({ params, height: bounds.height, }); const width = (intrinsicWidth * params.scale) / bounds.width; const height = (intrinsicHeight * params.scale) / bounds.height; return { handles: getBoxMaskHandlePositions({ centerX: params.centerX, centerY: params.centerY, width, height, rotation: params.rotation, feather: params.feather, sizeMode: "uniform", bounds, displayScale, }), overlays: [ getBoxMaskRectOverlay({ centerX: params.centerX, centerY: params.centerY, width, height, rotation: params.rotation, bounds, }), ], }; }, snap({ handleId, startParams, proposedParams, bounds, canvasSize, snapThreshold, }) { const { intrinsicWidth, intrinsicHeight } = measureTextMask({ params: startParams, height: bounds.height, }); const position = { x: proposedParams.centerX * bounds.width, y: proposedParams.centerY * bounds.height, }; if (handleId === "position") { const { snappedPosition, activeLines } = snapPosition({ proposedPosition: position, canvasSize: bounds, elementSize: { width: intrinsicWidth * proposedParams.scale, height: intrinsicHeight * proposedParams.scale, }, rotation: proposedParams.rotation, snapThreshold, }); return { params: { ...proposedParams, ...setMaskLocalCenter({ center: snappedPosition, bounds, }), }, activeLines: toGlobalMaskSnapLines({ lines: activeLines, bounds, canvasSize, }), }; } if (handleId === "rotation") { const { snappedRotation } = snapRotation({ proposedRotation: proposedParams.rotation, }); return { params: { ...proposedParams, rotation: snappedRotation, }, activeLines: [], }; } if (handleId === "scale") { const { snappedScale, activeLines } = snapScale({ proposedScale: proposedParams.scale, position, baseWidth: intrinsicWidth, baseHeight: intrinsicHeight, rotation: proposedParams.rotation, canvasSize: bounds, snapThreshold, preferredEdges: getScalePreferredEdges({ handleId }), }); return { params: { ...proposedParams, scale: Math.max(0.01, snappedScale), }, activeLines: toGlobalMaskSnapLines({ lines: activeLines, bounds, canvasSize, }), }; } return { params: proposedParams, activeLines: [], }; }, }, buildDefault(): Omit { return { type: "text", params: { feather: 0, inverted: false, strokeColor: "#ffffff", strokeWidth: 0, strokeAlign: "center", content: "Mask", fontSize: DEFAULTS.text.element.fontSize, fontFamily: DEFAULTS.text.element.fontFamily, fontWeight: DEFAULTS.text.element.fontWeight, fontStyle: DEFAULTS.text.element.fontStyle, textDecoration: DEFAULTS.text.element.textDecoration, letterSpacing: DEFAULTS.text.letterSpacing, lineHeight: DEFAULTS.text.lineHeight, centerX: 0, centerY: 0, rotation: 0, scale: 1, }, }; }, computeParamUpdate: computeTextMaskParamUpdate, isActive(params) { return params.content.trim().length > 0; }, renderer: { renderMask({ resolvedParams, ctx, width, height }) { const params = resolvedParams as TextMaskParams; const { layout } = measureTextMask({ params, height }); ctx.save(); ctx.translate( width / 2 + params.centerX * width, height / 2 + params.centerY * height, ); ctx.scale(params.scale, params.scale); if (params.rotation) { ctx.rotate((params.rotation * Math.PI) / 180); } drawMeasuredTextLayout({ ctx, layout, textColor: "#ffffff", background: null, }); ctx.restore(); }, renderStroke({ resolvedParams, ctx, width, height }) { const params = resolvedParams as TextMaskParams; const { layout } = measureTextMask({ params, height }); ctx.save(); ctx.translate( width / 2 + params.centerX * width, height / 2 + params.centerY * height, ); ctx.scale(params.scale, params.scale); if (params.rotation) { ctx.rotate((params.rotation * Math.PI) / 180); } strokeMeasuredTextLayout({ ctx, layout, strokeColor: params.strokeColor, strokeWidth: params.strokeWidth, }); if (params.strokeAlign === "inside") { ctx.globalCompositeOperation = "destination-in"; drawMeasuredTextLayout({ ctx, layout, textColor: "#ffffff", background: null, }); } if (params.strokeAlign === "outside") { ctx.globalCompositeOperation = "destination-out"; drawMeasuredTextLayout({ ctx, layout, textColor: "#ffffff", background: null, }); } ctx.restore(); }, }, };