import getCaretCoordinates from "textarea-caret"; export interface Anchor { line: number; top: number; } const project = (anchors: Anchor[], value: number, from: keyof Anchor, to: keyof Anchor) => { for (let i = 1; i < anchors.length; i++) { const prev = anchors[i - 1]; const next = anchors[i]; if (value > next[from]) continue; const span = next[from] - prev[from]; if (span <= 0) return prev[to]; return prev[to] + ((value - prev[from]) / span) * (next[to] - prev[to]); } return anchors.at(-1)?.[to] ?? 0; }; export const topForLine = (anchors: Anchor[], line: number) => project(anchors, line, "line", "top"); export const lineForTop = (anchors: Anchor[], top: number) => project(anchors, top, "top", "line"); export const monotonic = (anchors: Anchor[]): Anchor[] => anchors.reduce((kept, anchor) => { const last = kept.at(-1); if (!last || (anchor.line > last.line && anchor.top >= last.top)) kept.push(anchor); return kept; }, []); const lineCount = (value: string) => value.split("\n").length; const previewAnchors = (pane: HTMLElement, lines: number): Anchor[] => { const origin = pane.getBoundingClientRect().top - pane.scrollTop; const found = Array.from(pane.querySelectorAll("[data-line]"), (element) => ({ line: Number(element.dataset.line), top: element.getBoundingClientRect().top - origin })); return monotonic([{ line: 0, top: 0 }, ...found, { line: lines, top: pane.scrollHeight }]); }; export const previewLine = (pane: HTMLElement, value: string) => lineForTop(previewAnchors(pane, lineCount(value)), pane.scrollTop); export const scrollPreviewToLine = (pane: HTMLElement, value: string, line: number) => { pane.scrollTop = topForLine(previewAnchors(pane, lineCount(value)), line); }; const editorGeometry = (textarea: HTMLTextAreaElement) => { const starts = [0]; for (let i = 0; i < textarea.value.length; i++) { if (textarea.value[i] === "\n") starts.push(i + 1); } const origin = getCaretCoordinates(textarea, 0).top; const lineTop = (line: number) => line >= starts.length ? textarea.scrollHeight : getCaretCoordinates(textarea, starts[line]).top - origin; const around = (line: number): Anchor[] => [ { line, top: lineTop(line) }, { line: line + 1, top: lineTop(line + 1) } ]; return { count: starts.length, lineTop, around }; }; export const editorLine = (textarea: HTMLTextAreaElement) => { const { count, lineTop, around } = editorGeometry(textarea); const target = textarea.scrollTop; let low = 0; let high = count - 1; while (low < high) { const mid = Math.ceil((low + high) / 2); if (lineTop(mid) <= target) low = mid; else high = mid - 1; } return lineForTop(around(low), target); }; export const scrollEditorToLine = (textarea: HTMLTextAreaElement, line: number) => { const { count, around } = editorGeometry(textarea); const base = Math.min(Math.max(0, Math.floor(line)), count - 1); textarea.scrollTop = topForLine(around(base), line); };