summaryrefslogtreecommitdiff
path: root/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx')
-rw-r--r--apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx149
1 files changed, 105 insertions, 44 deletions
diff --git a/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx b/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx
index 26649b26..d3c61a24 100644
--- a/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx
+++ b/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx
@@ -1,38 +1,89 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
+import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
import { useEditor } from "@/hooks/use-editor";
-import type { TextElement } from "@/types/timeline";
+import type { TextElement } from "@/lib/timeline";
import {
- positionToOverlay,
- getDisplayScale,
-} from "@/lib/preview/preview-coords";
-import {
- DEFAULT_LINE_HEIGHT,
FONT_SIZE_SCALE_REFERENCE,
} from "@/constants/text-constants";
+import { DEFAULTS } from "@/lib/timeline/defaults";
+import {
+ getMetricAscent,
+ getMetricDescent,
+ setCanvasLetterSpacing,
+} from "@/lib/text/layout";
+
+let cachedCanvas: HTMLCanvasElement | null = null;
+
+function getMeasurementContext(): CanvasRenderingContext2D | null {
+ if (!cachedCanvas) cachedCanvas = document.createElement("canvas");
+ return cachedCanvas.getContext("2d");
+}
+
+/**
+ * Uses fontBoundingBox metrics (which CSS uses for line box layout) rather than
+ * actualBoundingBox to model where the browser places the baseline within the
+ * line box, then measures actual glyph bounds to find the visual center.
+ */
+function measureCSSVisualCenterOffset({
+ lines,
+ fontString,
+ letterSpacingPx,
+ lineHeightPx,
+ displayFontSize,
+}: {
+ lines: string[];
+ fontString: string;
+ letterSpacingPx: number;
+ lineHeightPx: number;
+ displayFontSize: number;
+}): number {
+ const ctx = getMeasurementContext();
+ if (!ctx) return 0;
+
+ ctx.font = fontString;
+ ctx.textBaseline = "alphabetic";
+ setCanvasLetterSpacing({ ctx, letterSpacingPx });
+
+ const probe = ctx.measureText("M");
+ const fontAscent = probe.fontBoundingBoxAscent ?? displayFontSize * 0.8;
+ const fontDescent = probe.fontBoundingBoxDescent ?? displayFontSize * 0.2;
+ const halfLeading = (lineHeightPx - fontAscent - fontDescent) / 2;
-const TEXT_BACKGROUND_PADDING = "4px 8px";
-const TEXT_EDIT_VERTICAL_OFFSET_EM = 0.06;
+ let visualTop = Number.POSITIVE_INFINITY;
+ let visualBottom = Number.NEGATIVE_INFINITY;
+
+ for (let i = 0; i < lines.length; i++) {
+ const metrics = ctx.measureText(lines[i] || " ");
+ const baseline = i * lineHeightPx + halfLeading + fontAscent;
+ visualTop = Math.min(
+ visualTop,
+ baseline - getMetricAscent({ metrics, fallbackFontSize: displayFontSize }),
+ );
+ visualBottom = Math.max(
+ visualBottom,
+ baseline + getMetricDescent({ metrics, fallbackFontSize: displayFontSize }),
+ );
+ }
+
+ const cssBlockHeight = lines.length * lineHeightPx;
+ return (visualTop + visualBottom) / 2 - cssBlockHeight / 2;
+}
export function TextEditOverlay({
- canvasRef,
- containerRef,
trackId,
elementId,
element,
onCommit,
- onCancel,
}: {
- canvasRef: React.RefObject<HTMLCanvasElement | null>;
- containerRef: React.RefObject<HTMLDivElement | null>;
trackId: string;
elementId: string;
element: TextElement;
onCommit: () => void;
- onCancel: () => void;
}) {
const editor = useEditor();
+ const viewport = usePreviewViewport();
const divRef = useRef<HTMLDivElement>(null);
useEffect(() => {
@@ -60,59 +111,67 @@ export function TextEditOverlay({
const { key } = event;
if (key === "Escape") {
event.preventDefault();
- onCancel();
+ onCommit();
return;
}
},
- [onCancel],
+ [onCommit],
);
- const canvasRect = canvasRef.current?.getBoundingClientRect();
- const containerRect = containerRef.current?.getBoundingClientRect();
const canvasSize = editor.project.getActive().settings.canvasSize;
- if (!canvasRect || !containerRect || !canvasSize) return null;
+ if (!canvasSize) return null;
- const { x: posX, y: posY } = positionToOverlay({
+ const { x: posX, y: posY } = viewport.positionToOverlay({
positionX: element.transform.position.x,
positionY: element.transform.position.y,
- canvasRect,
- containerRect,
- canvasSize,
});
- const { x: displayScaleX } = getDisplayScale({
- canvasRect,
- canvasSize,
- });
+ const { x: displayScaleX } = viewport.getDisplayScale();
const displayFontSize =
element.fontSize *
(canvasSize.height / FONT_SIZE_SCALE_REFERENCE) *
displayScaleX;
- const verticalAlignmentOffset =
- displayFontSize * TEXT_EDIT_VERTICAL_OFFSET_EM;
-
- const lineHeight = element.lineHeight ?? DEFAULT_LINE_HEIGHT;
+ const lineHeight = element.lineHeight ?? DEFAULTS.text.lineHeight;
const fontWeight = element.fontWeight === "bold" ? "bold" : "normal";
const fontStyle = element.fontStyle === "italic" ? "italic" : "normal";
- const letterSpacing = element.letterSpacing ?? 0;
+ const displayLetterSpacing = (element.letterSpacing ?? 0) * displayScaleX;
+ const lineHeightPx = displayFontSize * lineHeight;
+ const lines = (element.content || "").split("\n");
+ const fontString = `${fontStyle} ${fontWeight} ${displayFontSize}px "${element.fontFamily}", sans-serif`;
+
+ const cssVisualCenterOffset = measureCSSVisualCenterOffset({
+ lines,
+ fontString,
+ letterSpacingPx: displayLetterSpacing,
+ lineHeightPx,
+ displayFontSize,
+ });
+
+ const bg = element.background;
const shouldShowBackground =
- element.background.enabled &&
- element.background.color &&
- element.background.color !== "transparent";
- const backgroundColor = shouldShowBackground
- ? element.background.color
- : "transparent";
+ bg.enabled && bg.color && bg.color !== "transparent";
+ const fontSizeRatio = element.fontSize / DEFAULTS.text.element.fontSize;
+ const displayPaddingX = shouldShowBackground
+ ? (bg.paddingX ?? DEFAULTS.text.background.paddingX) *
+ fontSizeRatio *
+ displayScaleX
+ : 0;
+ const displayPaddingY = shouldShowBackground
+ ? (bg.paddingY ?? DEFAULTS.text.background.paddingY) *
+ fontSizeRatio *
+ displayScaleX
+ : 0;
return (
<div
className="absolute"
style={{
left: posX,
- top: posY - verticalAlignmentOffset,
- transform: `translate(-50%, -50%) scale(${element.transform.scale}) rotate(${element.transform.rotate}deg)`,
+ top: posY - cssVisualCenterOffset,
+ transform: `translate(-50%, -50%) scale(${element.transform.scaleX}, ${element.transform.scaleY}) rotate(${element.transform.rotate}deg)`,
transformOrigin: "center center",
}}
>
@@ -131,13 +190,15 @@ export function TextEditOverlay({
fontWeight,
fontStyle,
textAlign: element.textAlign,
- letterSpacing: `${letterSpacing}px`,
+ letterSpacing: `${displayLetterSpacing}px`,
lineHeight,
color: element.color,
- backgroundColor,
- minHeight: displayFontSize * lineHeight,
+ backgroundColor: shouldShowBackground ? bg.color : "transparent",
+ minHeight: lineHeightPx,
textDecoration: element.textDecoration ?? "none",
- padding: shouldShowBackground ? TEXT_BACKGROUND_PADDING : 0,
+ padding: shouldShowBackground
+ ? `${displayPaddingY}px ${displayPaddingX}px`
+ : 0,
minWidth: 1,
}}
onInput={handleInput}