diff options
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.tsx | 149 |
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} |
