diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-04-13 04:41:33 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-04-13 05:08:29 +0200 |
| commit | 7d4e166678c35d48b22b8d1d05639f981a956a8b (patch) | |
| tree | eb596d4f4609053524224169c247db24052db353 /apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx | |
| parent | cad88ee4d1c9e06e7529b88ef5e5d3fdf3f89149 (diff) | |
| download | opencut-7d4e166678c35d48b22b8d1d05639f981a956a8b.tar.gz | |
fix: text measurement, rotation handle, video cache races, playback sync
Made-with: Cursor
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 | 363 |
1 files changed, 151 insertions, 212 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 aff77f0f..78114b27 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,212 +1,151 @@ -"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 "@/lib/timeline";
-import {
- 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;
-
- 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({
- trackId,
- elementId,
- element,
- onCommit,
-}: {
- trackId: string;
- elementId: string;
- element: TextElement;
- onCommit: () => void;
-}) {
- const editor = useEditor();
- const viewport = usePreviewViewport();
- const divRef = useRef<HTMLDivElement>(null);
-
- useEffect(() => {
- const div = divRef.current;
- if (!div) return;
- div.focus();
- const range = document.createRange();
- range.selectNodeContents(div);
- const selection = window.getSelection();
- selection?.removeAllRanges();
- selection?.addRange(range);
- }, []);
-
- const handleInput = useCallback(() => {
- const div = divRef.current;
- if (!div) return;
- const text = div.innerText;
- editor.timeline.previewElements({
- updates: [{ trackId, elementId, updates: { content: text } }],
- });
- }, [editor.timeline, trackId, elementId]);
-
- const handleKeyDown = useCallback(
- ({ event }: { event: React.KeyboardEvent }) => {
- const { key } = event;
- if (key === "Escape") {
- event.preventDefault();
- onCommit();
- return;
- }
- },
- [onCommit],
- );
-
- const canvasSize = editor.project.getActive().settings.canvasSize;
-
- if (!canvasSize) return null;
-
- const { x: posX, y: posY } = viewport.positionToOverlay({
- positionX: element.transform.position.x,
- positionY: element.transform.position.y,
- });
-
- const { x: displayScaleX } = viewport.getDisplayScale();
-
- const displayFontSize =
- element.fontSize *
- (canvasSize.height / FONT_SIZE_SCALE_REFERENCE) *
- displayScaleX;
-
- const lineHeight = element.lineHeight ?? DEFAULTS.text.lineHeight;
- const fontWeight = element.fontWeight === "bold" ? "bold" : "normal";
- const fontStyle = element.fontStyle === "italic" ? "italic" : "normal";
- 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 =
- 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 - cssVisualCenterOffset,
- transform: `translate(-50%, -50%) scale(${element.transform.scaleX}, ${element.transform.scaleY}) rotate(${element.transform.rotate}deg)`,
- transformOrigin: "center center",
- }}
- >
- {/* biome-ignore lint/a11y/useSemanticElements: contenteditable required for multiline, IME, paste */}
- <div
- ref={divRef}
- contentEditable
- suppressContentEditableWarning
- tabIndex={0}
- role="textbox"
- aria-label="Edit text"
- className="cursor-text select-text outline-none whitespace-pre"
- style={{
- fontSize: displayFontSize,
- fontFamily: element.fontFamily,
- fontWeight,
- fontStyle,
- textAlign: element.textAlign,
- letterSpacing: `${displayLetterSpacing}px`,
- lineHeight,
- color: element.color,
- backgroundColor: shouldShowBackground ? bg.color : "transparent",
- minHeight: lineHeightPx,
- textDecoration: element.textDecoration ?? "none",
- padding: shouldShowBackground
- ? `${displayPaddingY}px ${displayPaddingX}px`
- : 0,
- minWidth: 1,
- }}
- onInput={handleInput}
- onBlur={onCommit}
- onKeyDown={(event) => handleKeyDown({ event })}
- >
- {element.content || ""}
- </div>
- </div>
- );
-}
+"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 "@/lib/timeline"; +import { + FONT_SIZE_SCALE_REFERENCE, +} from "@/constants/text-constants"; +import { DEFAULTS } from "@/lib/timeline/defaults"; +import { + getElementLocalTime, + resolveTransformAtTime, +} from "@/lib/animation"; + +export function TextEditOverlay({ + trackId, + elementId, + element, + onCommit, +}: { + trackId: string; + elementId: string; + element: TextElement; + onCommit: () => void; +}) { + const editor = useEditor(); + const viewport = usePreviewViewport(); + const divRef = useRef<HTMLDivElement>(null); + + useEffect(() => { + const div = divRef.current; + if (!div) return; + div.focus(); + const range = document.createRange(); + range.selectNodeContents(div); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + }, []); + + const handleInput = useCallback(() => { + const div = divRef.current; + if (!div) return; + const text = div.innerText; + editor.timeline.previewElements({ + updates: [{ trackId, elementId, updates: { content: text } }], + }); + }, [editor.timeline, trackId, elementId]); + + const handleKeyDown = useCallback( + ({ event }: { event: React.KeyboardEvent }) => { + const { key } = event; + if (key === "Escape") { + event.preventDefault(); + onCommit(); + return; + } + }, + [onCommit], + ); + + const canvasSize = editor.project.getActive().settings.canvasSize; + + if (!canvasSize) return null; + + const currentTime = editor.playback.getCurrentTime(); + const localTime = getElementLocalTime({ + timelineTime: currentTime, + elementStartTime: element.startTime, + elementDuration: element.duration, + }); + const transform = resolveTransformAtTime({ + baseTransform: element.transform, + animations: element.animations, + localTime, + }); + + const { x: posX, y: posY } = viewport.positionToOverlay({ + positionX: transform.position.x, + positionY: transform.position.y, + }); + + const { x: displayScaleX } = viewport.getDisplayScale(); + + const scaledFontSize = + element.fontSize * (canvasSize.height / FONT_SIZE_SCALE_REFERENCE); + + const lineHeight = element.lineHeight ?? DEFAULTS.text.lineHeight; + const fontWeight = element.fontWeight === "bold" ? "bold" : "normal"; + const fontStyle = element.fontStyle === "italic" ? "italic" : "normal"; + const canvasLetterSpacing = element.letterSpacing ?? 0; + const lineHeightPx = scaledFontSize * lineHeight; + + const bg = element.background; + const shouldShowBackground = + bg.enabled && bg.color && bg.color !== "transparent"; + const fontSizeRatio = element.fontSize / DEFAULTS.text.element.fontSize; + const canvasPaddingX = shouldShowBackground + ? (bg.paddingX ?? DEFAULTS.text.background.paddingX) * fontSizeRatio + : 0; + const canvasPaddingY = shouldShowBackground + ? (bg.paddingY ?? DEFAULTS.text.background.paddingY) * fontSizeRatio + : 0; + + return ( + <div + className="absolute" + style={{ + left: posX, + top: posY, + transform: `translate(-50%, -50%) scale(${transform.scaleX * displayScaleX}, ${transform.scaleY * displayScaleX}) rotate(${transform.rotate}deg)`, + transformOrigin: "center center", + }} + > + {/* biome-ignore lint/a11y/useSemanticElements: contenteditable required for multiline, IME, paste */} + <div + ref={divRef} + contentEditable + suppressContentEditableWarning + tabIndex={0} + role="textbox" + aria-label="Edit text" + className="cursor-text select-text outline-none whitespace-pre" + style={{ + fontSize: scaledFontSize, + fontFamily: element.fontFamily, + fontWeight, + fontStyle, + textAlign: element.textAlign, + letterSpacing: `${canvasLetterSpacing}px`, + lineHeight, + color: "transparent", + caretColor: element.color, + backgroundColor: shouldShowBackground ? bg.color : "transparent", + minHeight: lineHeightPx, + textDecoration: element.textDecoration ?? "none", + padding: shouldShowBackground + ? `${canvasPaddingY}px ${canvasPaddingX}px` + : 0, + minWidth: 1, + }} + onInput={handleInput} + onBlur={onCommit} + onKeyDown={(event) => handleKeyDown({ event })} + > + {element.content || ""} + </div> + </div> + ); +} |
