From 2235b9302064e218d97821243ffedf686b20fcb3 Mon Sep 17 00:00:00 2001 From: spencrmartin Date: Thu, 30 Oct 2025 17:40:35 -0400 Subject: [PATCH] Fix cumulative highlight drift with comprehensive style sync Fixed the gradual highlight misalignment that gets worse with each line by ensuring perfect style synchronization between layers: 1. Comprehensive style synchronization: - Replaced simple line height sync with full style consistency - Copies all computed styles that affect text layout from textarea - Includes fontSize, fontFamily, letterSpacing, wordSpacing - Ensures identical padding values to prevent cumulative drift 2. Box model consistency: - Added explicit boxSizing: 'border-box' to display layer - Matches textarea box model to prevent layout differences - Eliminates padding/border calculation discrepancies 3. Rounded line height calculations: - Uses Math.round() to prevent fractional pixel accumulation - Eliminates subpixel differences that compound over multiple lines - Ensures consistent line spacing throughout the text 4. Text rendering consistency: - Copies textRendering, webkitFontSmoothing, mozOsxFontSmoothing - Prevents browser-specific text rendering differences - Eliminates subpixel positioning variations This should eliminate the gradual 'walking away' of highlights and fix the bottom text cutoff issue by ensuring perfect layer alignment. --- ui/desktop/src/components/RichChatInput.tsx | 38 +++++++++++++++------ 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/ui/desktop/src/components/RichChatInput.tsx b/ui/desktop/src/components/RichChatInput.tsx index a22ff2dc86..415855dc43 100644 --- a/ui/desktop/src/components/RichChatInput.tsx +++ b/ui/desktop/src/components/RichChatInput.tsx @@ -204,7 +204,7 @@ export const RichChatInput = forwardRef(({ const computedStyle = window.getComputedStyle(textarea); const fontSize = parseFloat(computedStyle.fontSize); const lineHeightValue = computedStyle.lineHeight; - const lineHeight = lineHeightValue === "normal" ? fontSize * 1.2 : parseFloat(lineHeightValue); + const lineHeight = Math.round(lineHeightValue === "normal" ? fontSize * 1.2 : parseFloat(lineHeightValue)); const minHeight = rows * lineHeight; const maxHeight = 300; @@ -291,17 +291,31 @@ export const RichChatInput = forwardRef(({ }, [rows]); // Ensure line height consistency between layers - const ensureLineHeightConsistency = useCallback(() => { + // Comprehensive style synchronization between layers + const ensureStyleConsistency = useCallback(() => { if (hiddenTextareaRef.current && displayRef.current) { const textarea = hiddenTextareaRef.current; const display = displayRef.current; - // Get the computed line height from textarea + // Get all computed styles that affect text layout const computedStyle = window.getComputedStyle(textarea); - const lineHeightValue = computedStyle.lineHeight; - // Apply the same line height to display layer - display.style.lineHeight = lineHeightValue; + // Apply critical layout styles to ensure perfect alignment + display.style.lineHeight = computedStyle.lineHeight; + display.style.fontSize = computedStyle.fontSize; + display.style.fontFamily = computedStyle.fontFamily; + display.style.letterSpacing = computedStyle.letterSpacing; + display.style.wordSpacing = computedStyle.wordSpacing; + // Ensure identical padding (critical for alignment) + display.style.paddingTop = computedStyle.paddingTop; + display.style.paddingRight = computedStyle.paddingRight; + display.style.paddingBottom = computedStyle.paddingBottom; + display.style.paddingLeft = computedStyle.paddingLeft; + + // Ensure identical text rendering to prevent subpixel drift + display.style.textRendering = computedStyle.textRendering; + display.style.webkitFontSmoothing = computedStyle.webkitFontSmoothing; + display.style.mozOsxFontSmoothing = computedStyle.mozOsxFontSmoothing; } }, []); @@ -313,7 +327,7 @@ export const RichChatInput = forwardRef(({ // Use ResizeObserver to detect when textarea dimensions change const resizeObserver = new ResizeObserver(() => { syncDisplayHeight(); - ensureLineHeightConsistency(); + ensureStyleConsistency(); }); resizeObserver.observe(textarea); @@ -324,7 +338,7 @@ export const RichChatInput = forwardRef(({ if (textarea.scrollHeight !== lastScrollHeight) { lastScrollHeight = textarea.scrollHeight; syncDisplayHeight(); - ensureLineHeightConsistency(); + ensureStyleConsistency(); } requestAnimationFrame(checkScrollHeight); }; @@ -376,7 +390,7 @@ export const RichChatInput = forwardRef(({ const computedStyle = window.getComputedStyle(textarea); const fontSize = parseFloat(computedStyle.fontSize); const lineHeightValue = computedStyle.lineHeight; - const lineHeight = lineHeightValue === "normal" ? fontSize * 1.2 : parseFloat(lineHeightValue); + const lineHeight = Math.round(lineHeightValue === "normal" ? fontSize * 1.2 : parseFloat(lineHeightValue)); const minHeight = rows * lineHeight; textarea.style.height = "auto"; @@ -384,7 +398,7 @@ export const RichChatInput = forwardRef(({ display.style.height = `${minHeight}px`; syncDisplayHeight(); - ensureLineHeightConsistency(); + ensureStyleConsistency(); } }, }), []); @@ -844,7 +858,7 @@ export const RichChatInput = forwardRef(({ // Sync display height immediately for better responsiveness // Use both immediate sync and deferred sync for reliability syncDisplayHeight(); - ensureLineHeightConsistency(); + ensureStyleConsistency(); requestAnimationFrame(() => syncDisplayHeight()); }, [onChange, syncDisplayHeight]); @@ -1089,6 +1103,7 @@ export const RichChatInput = forwardRef(({ lineHeight: '1.5', // Match leading-relaxed padding: '12px 80px 12px 12px', // Match top and bottom padding: 12px each margin: '0', + boxSizing: "border-box", // Match textarea box model boxSizing: 'border-box', whiteSpace: 'pre-wrap', // Match visual display wordWrap: 'break-word', @@ -1114,6 +1129,7 @@ export const RichChatInput = forwardRef(({ lineHeight: '1.5', // Match textarea line height padding: '12px 80px 12px 12px', // Match textarea padding: 12px top and bottom margin: '0', + boxSizing: "border-box", // Match textarea box model whiteSpace: 'pre-wrap', // Match textarea wordWrap: 'break-word', // Hide scrollbars but keep scrolling functionality