From 8782af683997fe3ad0a3d6555b5444518b543f0c Mon Sep 17 00:00:00 2001 From: Alex T Date: Wed, 17 Dec 2025 11:33:02 -0700 Subject: [PATCH] Limit horizontal scroll to visible content overflow Previously, horizontal scrolling was enabled for the entire buffer, allowing users to scroll even when visible lines did not overflow. This was distracting when viewing short lines that happened to be in a file with long lines elsewhere. Changes: - Add DOM-based text measurement utilities for accurate line width calculation - Modify wheel handler to only allow horizontal scroll when visible lines overflow - Add smooth animation (150ms ease-out) when horizontal scroll clamps or resets - Automatically adjust scroll position when vertical scrolling brings shorter lines into view --- src/features/editor/components/editor.tsx | 94 ++++++++++++++++- src/features/editor/utils/lines.ts | 117 ++++++++++++++++++++++ 2 files changed, 208 insertions(+), 3 deletions(-) diff --git a/src/features/editor/components/editor.tsx b/src/features/editor/components/editor.tsx index c0b1f504..49a94cc9 100644 --- a/src/features/editor/components/editor.tsx +++ b/src/features/editor/components/editor.tsx @@ -2,6 +2,7 @@ import "../styles/overlay-editor.css"; import { useCallback, useEffect, useMemo, useRef } from "react"; import { createPortal } from "react-dom"; import { useGitGutter } from "@/features/version-control/git/controllers/use-git-gutter"; +import { EDITOR_CONSTANTS } from "../config/constants"; import EditorContextMenu from "../context-menu/context-menu"; import { editorAPI } from "../extensions/api"; import { useContextMenu } from "../hooks/use-context-menu"; @@ -19,7 +20,13 @@ import { useEditorStateStore } from "../stores/state-store"; import { useEditorUIStore } from "../stores/ui-store"; import type { Decoration } from "../types/editor"; import { applyVirtualEdit, calculateActualOffset } from "../utils/fold-transformer"; -import { calculateLineHeight, calculateLineOffset, splitLines } from "../utils/lines"; +import { + animateScrollLeft, + calculateLineHeight, + calculateLineOffset, + getMaxVisibleLineWidth, + splitLines, +} from "../utils/lines"; import { applyMultiCursorBackspace, applyMultiCursorEdit } from "../utils/multi-cursor"; import { calculateCursorPosition } from "../utils/position"; import { scrollLogger } from "../utils/scroll-logger"; @@ -41,6 +48,7 @@ export function Editor({ className, onMouseMove, onMouseLeave, onMouseEnter }: E const inputRef = useRef(null); const highlightRef = useRef(null); const multiCursorRef = useRef(null); + const scrollAnimationCleanupRef = useRef<(() => void) | null>(null); const bufferId = useBufferStore.use.activeBufferId(); const buffers = useBufferStore.use.buffers(); @@ -554,6 +562,54 @@ export function Editor({ className, onMouseMove, onMouseLeave, onMouseEnter }: E } }, [initializeViewport, lines.length]); + // Clamp horizontal scroll when viewport changes and visible content is shorter + // This handles cases where vertical scrolling brings shorter lines into view + useEffect(() => { + const textarea = inputRef.current; + if (!textarea || textarea.scrollLeft === 0) return; + + // Calculate actual visible lines (without buffer) + const scrollTop = textarea.scrollTop; + const viewportHeight = textarea.clientHeight; + const actualStartLine = Math.max(0, Math.floor(scrollTop / lineHeight)); + const visibleLineCount = Math.ceil(viewportHeight / lineHeight); + const actualEndLine = Math.min(lines.length, actualStartLine + visibleLineCount); + + const { maxWidth } = getMaxVisibleLineWidth( + lines, + actualStartLine, + actualEndLine, + fontSize, + fontFamily, + tabSize, + ); + + const viewportWidth = + textarea.clientWidth - + EDITOR_CONSTANTS.EDITOR_PADDING_LEFT - + EDITOR_CONSTANTS.EDITOR_PADDING_RIGHT; + const overflow = maxWidth - viewportWidth; + + let targetScrollLeft: number | null = null; + + if (overflow <= 0) { + // No overflow in visible content, animate to 0 + targetScrollLeft = 0; + } else if (textarea.scrollLeft > overflow) { + // Clamp to max overflow + targetScrollLeft = overflow; + } + + if (targetScrollLeft !== null && targetScrollLeft !== textarea.scrollLeft) { + // Cancel any ongoing animation + if (scrollAnimationCleanupRef.current) { + scrollAnimationCleanupRef.current(); + } + // Start smooth animation + scrollAnimationCleanupRef.current = animateScrollLeft(textarea, targetScrollLeft, 150); + } + }, [viewportRange, lines, fontSize, fontFamily, tabSize, lineHeight]); + // Set textarea ref in editorAPI for operations like selectAll useEffect(() => { editorAPI.setTextareaRef(inputRef.current); @@ -576,6 +632,7 @@ export function Editor({ className, onMouseMove, onMouseLeave, onMouseEnter }: E // Native wheel handler for textarea - required for Tauri/WebView // React's onWheel doesn't support passive: false which is needed for proper scroll control + // Horizontal scroll is limited to visible content overflow only useEffect(() => { const textarea = inputRef.current; if (!textarea) return; @@ -583,12 +640,43 @@ export function Editor({ className, onMouseMove, onMouseLeave, onMouseEnter }: E const handleWheel = (e: WheelEvent) => { e.preventDefault(); textarea.scrollTop += e.deltaY; - textarea.scrollLeft += e.deltaX; + + // Only allow horizontal scroll if visible lines overflow the viewport + if (e.deltaX !== 0) { + // Calculate actual visible lines (without buffer) based on scroll position + const scrollTop = textarea.scrollTop; + const viewportHeight = textarea.clientHeight; + const actualStartLine = Math.max(0, Math.floor(scrollTop / lineHeight)); + const visibleLineCount = Math.ceil(viewportHeight / lineHeight); + const actualEndLine = Math.min(lines.length, actualStartLine + visibleLineCount); + + const { maxWidth } = getMaxVisibleLineWidth( + lines, + actualStartLine, + actualEndLine, + fontSize, + fontFamily, + tabSize, + ); + + // Account for editor padding + const viewportWidth = + textarea.clientWidth - + EDITOR_CONSTANTS.EDITOR_PADDING_LEFT - + EDITOR_CONSTANTS.EDITOR_PADDING_RIGHT; + const overflow = maxWidth - viewportWidth; + + if (overflow > 0) { + // Allow horizontal scroll but clamp to visible content overflow + const newScrollLeft = textarea.scrollLeft + e.deltaX; + textarea.scrollLeft = Math.max(0, Math.min(newScrollLeft, overflow)); + } + } }; textarea.addEventListener("wheel", handleWheel, { passive: false }); return () => textarea.removeEventListener("wheel", handleWheel); - }, []); + }, [lines, fontSize, fontFamily, tabSize, lineHeight]); // Track viewport height for cursor visibility calculations useEffect(() => { diff --git a/src/features/editor/utils/lines.ts b/src/features/editor/utils/lines.ts index 33d8e7b7..c3eb2c33 100644 --- a/src/features/editor/utils/lines.ts +++ b/src/features/editor/utils/lines.ts @@ -18,3 +18,120 @@ export function isMarkdownFile(filePath: string): boolean { const extension = filePath.split(".").pop()?.toLowerCase(); return extension === "md" || extension === "markdown"; } + +// Reusable DOM element for text measurement (more accurate than canvas) +let measureElement: HTMLSpanElement | null = null; + +function getMeasureElement(): HTMLSpanElement { + if (!measureElement) { + measureElement = document.createElement("span"); + measureElement.style.position = "absolute"; + measureElement.style.visibility = "hidden"; + measureElement.style.whiteSpace = "pre"; + measureElement.style.top = "-9999px"; + measureElement.style.left = "-9999px"; + document.body.appendChild(measureElement); + } + return measureElement; +} + +/** + * Measure the width of a text string in pixels using DOM measurement + * @param text The text to measure + * @param fontSize Font size in pixels + * @param fontFamily Font family string + * @param tabSize Tab size for tab character expansion + */ +export function measureTextWidth( + text: string, + fontSize: number, + fontFamily: string, + tabSize: number, +): number { + const element = getMeasureElement(); + element.style.fontSize = `${fontSize}px`; + element.style.fontFamily = fontFamily; + element.style.tabSize = String(tabSize); + + element.textContent = text; + return element.getBoundingClientRect().width; +} + +/** + * Calculate the maximum width of lines within a range + * @param lines Array of line strings + * @param startLine Start of range (inclusive) + * @param endLine End of range (exclusive) + * @param fontSize Font size in pixels + * @param fontFamily Font family string + * @param tabSize Tab size for tab character expansion + */ +/** + * Smoothly animate an element's scrollLeft to a target value + * @param element The element to animate + * @param targetScrollLeft Target scrollLeft value + * @param duration Animation duration in ms + * @returns Cleanup function to cancel the animation + */ +export function animateScrollLeft( + element: HTMLElement, + targetScrollLeft: number, + duration: number = 150, +): () => void { + const startScrollLeft = element.scrollLeft; + const distance = targetScrollLeft - startScrollLeft; + + if (distance === 0) return () => {}; + + const startTime = performance.now(); + let animationId: number | null = null; + + const easeOutCubic = (t: number): number => 1 - (1 - t) ** 3; + + const animate = (currentTime: number) => { + const elapsed = currentTime - startTime; + const progress = Math.min(elapsed / duration, 1); + const easedProgress = easeOutCubic(progress); + + element.scrollLeft = startScrollLeft + distance * easedProgress; + + if (progress < 1) { + animationId = requestAnimationFrame(animate); + } + }; + + animationId = requestAnimationFrame(animate); + + return () => { + if (animationId !== null) { + cancelAnimationFrame(animationId); + } + }; +} + +export function getMaxVisibleLineWidth( + lines: string[], + startLine: number, + endLine: number, + fontSize: number, + fontFamily: string, + tabSize: number, +): { maxWidth: number; longestLineIndex: number; longestLineLength: number } { + let maxWidth = 0; + let longestLineIndex = startLine; + const actualEnd = Math.min(endLine, lines.length); + + for (let i = startLine; i < actualEnd; i++) { + const lineWidth = measureTextWidth(lines[i], fontSize, fontFamily, tabSize); + if (lineWidth > maxWidth) { + maxWidth = lineWidth; + longestLineIndex = i; + } + } + + return { + maxWidth, + longestLineIndex, + longestLineLength: lines[longestLineIndex]?.length ?? 0, + }; +}