fix(expr): ExpressionField — direct-DOM scroll sync + align overlay border-box with textarea
This commit is contained in:
@@ -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) =>
|
||||
|
||||
Reference in New Issue
Block a user