fix(expr): ExpressionField — direct-DOM scroll sync + align overlay border-box with textarea

This commit is contained in:
Claude
2026-08-30 11:21:03 +00:00
parent c502bdef03
commit 6b0a25b0b0

View File

@@ -1,4 +1,4 @@
import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react";
import { forwardRef, useImperativeHandle, useMemo, useRef } from "react";
import { render as renderTemplate } from "./templateEngine";
export interface ExpressionFieldHandle {
@@ -26,8 +26,10 @@ export const ExpressionField = forwardRef<ExpressionFieldHandle, Props>(function
) {
const taRef = useRef<HTMLTextAreaElement | null>(null);
const overlayRef = useRef<HTMLDivElement | null>(null);
const [overlayScroll, setOverlayScroll] = useState({ top: 0, left: 0 });
// Dep list is [onChange] so the handle rebinds if the parent passes a new
// handler between renders. `insertAtCaret` reads the freshest DOM value
// via taRef, so we don't need `value` in the deps.
useImperativeHandle(ref, () => ({
focus() {
taRef.current?.focus();
@@ -39,6 +41,7 @@ export const ExpressionField = forwardRef<ExpressionFieldHandle, Props>(function
const end = ta.selectionEnd ?? ta.value.length;
const next = ta.value.slice(0, start) + text + ta.value.slice(end);
onChange(next);
// Assumes the parent accepts `next` and re-renders before the next frame.
// Restore caret position after React re-renders the value.
requestAnimationFrame(() => {
ta.focus();
@@ -63,28 +66,29 @@ export const ExpressionField = forwardRef<ExpressionFieldHandle, Props>(function
return { kind: "ok" as const, text: truncated };
}, [value, previewInput, schemaType]);
// Direct DOM sync in the scroll handler — going through React state would
// schedule a wasted re-render per frame AND let the overlay lag by a frame,
// producing a visible desync during fast scrolls.
function syncScroll() {
const ta = taRef.current;
if (!ta) return;
setOverlayScroll({ top: ta.scrollTop, left: ta.scrollLeft });
const ov = overlayRef.current;
if (!ta || !ov) return;
ov.scrollTop = ta.scrollTop;
ov.scrollLeft = ta.scrollLeft;
}
useEffect(() => {
if (overlayRef.current) {
overlayRef.current.scrollTop = overlayScroll.top;
overlayRef.current.scrollLeft = overlayScroll.left;
}
}, [overlayScroll]);
return (
<div className="w-full">
<div className="relative">
<div
ref={overlayRef}
aria-hidden
// border-transparent matches the textarea's 1px border so the
// padding starts at the same offset — otherwise highlights render
// 1px off from the caret column.
className={
"absolute inset-0 pointer-events-none whitespace-pre-wrap break-words font-mono text-sm " +
"px-3 py-2 leading-5 text-slate-100 overflow-hidden"
"px-3 py-2 leading-5 text-slate-100 overflow-hidden border border-transparent rounded"
}
>
{tokens.map((t, i) =>