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, + }; +}