Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 91 additions & 3 deletions src/features/editor/components/editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand All @@ -41,6 +48,7 @@ export function Editor({ className, onMouseMove, onMouseLeave, onMouseEnter }: E
const inputRef = useRef<HTMLTextAreaElement>(null);
const highlightRef = useRef<HTMLDivElement>(null);
const multiCursorRef = useRef<HTMLDivElement>(null);
const scrollAnimationCleanupRef = useRef<(() => void) | null>(null);

const bufferId = useBufferStore.use.activeBufferId();
const buffers = useBufferStore.use.buffers();
Expand Down Expand Up @@ -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);
Expand All @@ -576,19 +632,51 @@ 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;

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(() => {
Expand Down
117 changes: 117 additions & 0 deletions src/features/editor/utils/lines.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}