From 434ea2a2d7d4efc215479ec213d1f7841ef3bac3 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Thu, 23 Apr 2026 22:08:40 +0800 Subject: [PATCH 001/138] First implement --- apps/demo/index.html | 1 + apps/demo/src/main.ts | 19 + packages/diffs/src/components/Editor.ts | 848 ++++++++++++++++++ packages/diffs/src/editor/editHistory.ts | 365 ++++++++ packages/diffs/src/editor/editorShortcuts.ts | 67 ++ packages/diffs/src/editor/editorUtils.ts | 98 ++ packages/diffs/src/editor/multiSelection.ts | 327 +++++++ .../diffs/src/editor/normlizeEditorOptions.ts | 75 ++ packages/diffs/src/editor/selection.ts | 298 ++++++ packages/diffs/src/editor/textDocument.ts | 305 +++++++ packages/diffs/src/editor/textareaState.ts | 257 ++++++ packages/diffs/src/editor/visualColumns.ts | 19 + packages/diffs/src/index.ts | 2 + packages/diffs/test/editHistory.test.ts | 218 +++++ packages/diffs/test/editorShortcuts.test.ts | 92 ++ packages/diffs/test/editorUtils.test.ts | 34 + packages/diffs/test/multiSelection.test.ts | 193 ++++ packages/diffs/test/selection.test.ts | 237 +++++ packages/diffs/test/textDocument.test.ts | 527 +++++++++++ packages/diffs/test/textareaState.test.ts | 265 ++++++ packages/diffs/test/visualColumns.test.ts | 24 + 21 files changed, 4271 insertions(+) create mode 100644 packages/diffs/src/components/Editor.ts create mode 100644 packages/diffs/src/editor/editHistory.ts create mode 100644 packages/diffs/src/editor/editorShortcuts.ts create mode 100644 packages/diffs/src/editor/editorUtils.ts create mode 100644 packages/diffs/src/editor/multiSelection.ts create mode 100644 packages/diffs/src/editor/normlizeEditorOptions.ts create mode 100644 packages/diffs/src/editor/selection.ts create mode 100644 packages/diffs/src/editor/textDocument.ts create mode 100644 packages/diffs/src/editor/textareaState.ts create mode 100644 packages/diffs/src/editor/visualColumns.ts create mode 100644 packages/diffs/test/editHistory.test.ts create mode 100644 packages/diffs/test/editorShortcuts.test.ts create mode 100644 packages/diffs/test/editorUtils.test.ts create mode 100644 packages/diffs/test/multiSelection.test.ts create mode 100644 packages/diffs/test/selection.test.ts create mode 100644 packages/diffs/test/textDocument.test.ts create mode 100644 packages/diffs/test/textareaState.test.ts create mode 100644 packages/diffs/test/visualColumns.test.ts diff --git a/apps/demo/index.html b/apps/demo/index.html index 29eb2454a..dfab4fc2d 100644 --- a/apps/demo/index.html +++ b/apps/demo/index.html @@ -16,6 +16,7 @@ + diff --git a/apps/demo/src/main.ts b/apps/demo/src/main.ts index 853a8417c..d035f3dc9 100644 --- a/apps/demo/src/main.ts +++ b/apps/demo/src/main.ts @@ -2,6 +2,7 @@ import { DEFAULT_THEMES, DIFFS_TAG_NAME, type DiffsThemeNames, + Editor, File, type FileContents, FileDiff, @@ -76,6 +77,7 @@ const diffInstances: ( | VirtualizedFileDiff )[] = []; const fileInstances: File[] = []; +const editorInstances: Editor[] = []; const streamingInstances: FileStream[] = []; const conflictInstances: UnresolvedFile[] = []; @@ -89,6 +91,7 @@ function cleanupInstances(container: HTMLElement) { for (const instances of [ diffInstances, fileInstances, + editorInstances, streamingInstances, conflictInstances, ]) { @@ -626,6 +629,7 @@ function toggleTheme() { for (const instances of [ diffInstances, + editorInstances, fileInstances, streamingInstances, conflictInstances, @@ -797,6 +801,21 @@ if (renderFileButton != null) { }); } +const renderEditorButton = document.getElementById('render-editor'); +if (renderEditorButton != null) { + // oxlint-disable-next-line @typescript-oxlint/no-misused-promises + renderEditorButton.addEventListener('click', () => { + const wrapper = document.getElementById('wrapper'); + if (wrapper == null) return; + cleanupInstances(wrapper); + + const editor = new Editor({ theme: DEMO_THEME }); + void editor.render({ editorContainer: wrapper }); + editor.setText(tsContent, 'tsx'); + editorInstances.push(editor); + }); +} + const renderFileConflictButton = document.getElementById('render-conflict'); if (renderFileConflictButton != null) { // oxlint-disable-next-line @typescript-oxlint/no-misused-promises diff --git a/packages/diffs/src/components/Editor.ts b/packages/diffs/src/components/Editor.ts new file mode 100644 index 000000000..1bb223a56 --- /dev/null +++ b/packages/diffs/src/components/Editor.ts @@ -0,0 +1,848 @@ +import { EncodedTokenMetadata, type IGrammar, INITIAL } from 'shiki/textmate'; + +import { + type EditorShortcutCommand, + getPrimaryModifier, + resolveEditorShortcutCommand, +} from '../editor/editorShortcuts'; +import { + addEventListener, + coalesceMicrotask, + createElement, + extend, + measureMonoFontWidth, +} from '../editor/editorUtils'; +import { + getOrderedSelectionText, + mapSelectionRangeChange, + mapSelectionTextChange, + mapSelectionTextReplace, +} from '../editor/multiSelection'; +import { normlizeEditorOptions } from '../editor/normlizeEditorOptions'; +import type { IEditorSelection, ISelection } from '../editor/selection'; +import { + cloneSelection, + convertSelection, + createSelection, + fromWebSelectionDirection, + getPrimarySelection, + isCollapsedSelection, + normalizeSelections, + SelectionDirection, + toSelectionArray, + toWebSelectionDirection, +} from '../editor/selection'; +import { + createTextareaSnippet, + matchesTextareaState, + resolveTextareaTextChange, + type TextareaState, +} from '../editor/textareaState'; +import { TextDocument } from '../editor/textDocument'; +import { getVisualColumn } from '../editor/visualColumns'; +import { getSharedHighlighter } from '../highlighter/shared_highlighter'; +import type { BaseCodeOptions, DiffsHighlighter } from '../types'; +import { getHighlighterOptions } from '../utils/getHighlighterOptions'; + +export interface EditorOptions extends BaseCodeOptions { + tabIndex?: number; + fontFamily?: string; + fontSize?: number; + lineHeight?: number; + paddingY?: number; +} + +export class Editor { + #highlighter?: DiffsHighlighter; + #colorMap?: string[]; + #textDocument?: TextDocument; + + // options + #options: EditorOptions; + #fontFamily: string; + #fontSize: number; + #lineHeightPx: number; + #paddingY: number; + #tabSize: number; + #monoFontWidth: number; + #lineNumberWidth: number; + #gutterWidth: number; + + // dom elements + #editorEl?: HTMLElement; + #styleEl?: HTMLStyleElement; + #textareaEl?: HTMLTextAreaElement; + #activeLineEl?: HTMLElement; + #textLineEls?: Map; + #selectionEls?: Map; + + // state + #isEditorElFocused?: boolean; + #isTextareaElFocused?: boolean; + #textareaState?: TextareaState; + #selections?: ISelection[]; + #reservedSelections?: ISelection[]; + #languageLoadRequestId = 0; + + #disposes?: (() => void)[]; + + constructor(options: EditorOptions = {}) { + const { fontFamily, fontSize, lineHeight, paddingY, tabSize } = + normlizeEditorOptions(options); + this.#options = options; + this.#fontFamily = fontFamily; + this.#fontSize = fontSize; + this.#lineHeightPx = Math.round(lineHeight); + this.#paddingY = paddingY; + this.#tabSize = tabSize; + this.#monoFontWidth = measureMonoFontWidth( + 'normal ' + this.#fontSize + 'px ' + this.#fontFamily + ); + this.#lineNumberWidth = Math.round(2 * this.#monoFontWidth); + this.#gutterWidth = this.#lineNumberWidth; // currently the gutter width is equal to line number width + } + + get options(): EditorOptions { + return this.#options; + } + + get text(): string | undefined { + return this.#textDocument?.getText(); + } + + get textDocument(): TextDocument | undefined { + return this.#textDocument; + } + + setText(text: string, lang = 'plaintext'): void { + this.setTextDocument(new TextDocument('inmemory://1', text, lang)); + } + + setTextDocument(textDocument: TextDocument): void { + this.#textDocument = textDocument; + this.#textareaState = undefined; + this.#reservedSelections = undefined; + const selection = createSelection(0, 0, 0, 0); + this.#selections = [selection]; + void this.#renderText(textDocument, selection); + } + + setThemeType(themeType: 'dark' | 'light' | 'system'): void { + this.#options.themeType = themeType; + this.#colorMap = undefined; // clear color map + this.#updateStyle(); + } + + async render({ + editorContainer, + }: { + editorContainer: HTMLElement; + }): Promise { + if (this.#editorEl !== undefined) { + this.cleanUp(); + } + const { tabIndex = -1 } = this.#options; + const fontFamily = this.#fontFamily; + const queueTextareaSync = coalesceMicrotask(() => + this.#syncTextareaState() + ); + this.#editorEl = extend( + createElement('div', { + style: { + position: 'relative', + boxSizing: 'border-box', + paddingTop: `${this.#paddingY}px`, + paddingBottom: `${this.#paddingY}px`, + fontFamily, + isolation: 'isolate', + }, + }), + { + tabIndex, + } + ); + this.#editorEl.style.setProperty( + '--line-number-width', + this.#lineNumberWidth + 'px' + ); + this.#styleEl = createElement('style', undefined, this.#editorEl); + this.#textareaEl = extend( + createElement('textarea', { class: 'ť' }, this.#editorEl), + { + autocapitalize: 'off', + autocomplete: 'off', + autocorrect: false, + spellcheck: false, + wrap: 'off', + } + ); + this.#disposes = [ + addEventListener(document, 'selectionchange', () => { + const selectionRaw = document.getSelection(); + if ( + selectionRaw !== null && + this.#selectionBelongsToEditor(selectionRaw) + ) { + const selection = convertSelection(selectionRaw); + if (selection !== null) { + this.#restoreSelection( + this.#reservedSelections !== undefined + ? [...this.#reservedSelections, selection] + : selection + ); + } + } + }), + addEventListener(this.#editorEl, 'mousedown', (e) => { + if (e.button === 0 && getPrimaryModifier(e)) { + this.#reservedSelections = this.#selections?.map(cloneSelection); + } + }), + addEventListener(document, 'mouseup', () => { + this.#reservedSelections = undefined; + }), + addEventListener(this.#editorEl, 'focus', () => { + this.#isEditorElFocused = true; + }), + addEventListener(this.#editorEl, 'blur', () => { + this.#isEditorElFocused = false; + }), + addEventListener(this.#textareaEl, 'focus', () => { + this.#isTextareaElFocused = true; + }), + addEventListener(this.#textareaEl, 'blur', () => { + this.#isTextareaElFocused = false; + }), + addEventListener(document, 'keydown', (e) => { + if (!this.#hasFocusWithinEditor()) { + return; + } + if (this.#isTextareaElFocused !== true) { + const command = resolveEditorShortcutCommand(e); + if (command !== undefined) { + void this.#runShortcutCommand(command); + } + } + if ( + this.#isTextareaElFocused !== true && + this.#isEditorElFocused === true && + e.key !== 'Shift' && + e.key !== 'Control' && + e.key !== 'Alt' && + e.key !== 'Meta' + ) { + this.#textareaEl?.focus(); + } + }), + addEventListener(this.#textareaEl, 'keydown', (e) => { + const command = resolveEditorShortcutCommand(e); + if (command !== undefined) { + e.preventDefault(); + void this.#runShortcutCommand(command); + } + }), + addEventListener(this.#textareaEl, 'input', queueTextareaSync), + addEventListener(this.#textareaEl, 'selectionchange', () => { + if ( + this.#textareaState !== undefined && + this.#textareaEl !== undefined && + matchesTextareaState(this.#textareaState, this.#textareaEl) + ) { + return; + } + queueTextareaSync(); + }), + ]; + this.#highlighter = await getSharedHighlighter( + getHighlighterOptions(undefined, this.#options) + ); + this.#updateStyle(); + if (this.#textDocument !== undefined) { + void this.#renderText(this.#textDocument, this.#selections); + } + editorContainer.appendChild(this.#editorEl); + } + + public cleanUp(): void { + this.#textLineEls?.clear(); + this.#selectionEls?.clear(); + this.#disposes?.forEach((dispose) => dispose()); + this.#editorEl?.remove(); + this.#editorEl = undefined; + this.#styleEl = undefined; + this.#textareaEl = undefined; + this.#activeLineEl = undefined; + this.#textLineEls = undefined; + this.#selectionEls = undefined; + this.#disposes = undefined; + this.#isEditorElFocused = false; + this.#isTextareaElFocused = false; + this.#textareaState = undefined; + this.#reservedSelections = undefined; + } + + #updateStyle() { + const editorEl = this.#editorEl; + const styleEl = this.#styleEl; + const highlighter = this.#highlighter; + if ( + editorEl === undefined || + styleEl === undefined || + highlighter === undefined + ) { + return; + } + + let themeType = this.#options.themeType ?? 'system'; + let themeName = this.#options.theme; + if (typeof themeName === 'string') { + themeName = themeName as string; + } else if (themeName !== undefined) { + if (themeType === 'system') { + themeType = window.matchMedia('(prefers-color-scheme: dark)').matches + ? 'dark' + : 'light'; + } + themeName = themeName[themeType]; + } else { + themeName = highlighter.getLoadedThemes()[0]; + } + + let theme; + if (this.#colorMap === undefined) { + const ret = highlighter.setTheme(themeName); + theme = ret.theme; + this.#colorMap = ret.colorMap; + } else { + theme = highlighter.getTheme(themeName); + } + + const fontSize = this.#fontSize; + const lineHeightPx = this.#lineHeightPx; + const colors = theme.colors ?? {}; + const foreground = theme.fg; + const background = theme.bg; + const selectionBackground = colors['editor.selectionBackground']; + const lineHighlightBackground = colors['editor.lineHighlightBackground']; + const lineNumberForeground = + colors['editorLineNumber.foreground'] ?? colors.foreground; + + editorEl.style.color = foreground; + editorEl.style.backgroundColor = background; + styleEl.textContent = + '@scope{' + + '::selection{background-color:transparent}' + + '@keyframes blinking{0%{opacity:0.9}50%{opacity:0}100%{opacity:0.9}}' + + `pre{position:relative;margin:0;font:inherit;font-size:${fontSize}px;line-height:${lineHeightPx}px;cursor:text;white-space:pre;tab-size:${this.#tabSize}}` + + `.ī{position:absolute;width:2px;height:${lineHeightPx}px;background-color:${foreground};pointer-events:none;animation:blinking 1.2s infinite;animation-delay:0.6s}` + + `.š{position:absolute;z-index:-10;height:${lineHeightPx}px;background-color:${selectionBackground};pointer-events:none}` + + (`.ħ{box-sizing:border-box;position:absolute;z-index:-10;width:100%;height:${lineHeightPx}px;` + + (lineHighlightBackground !== undefined + ? `background-color:${lineHighlightBackground}` + : `border:2px solid ${selectionBackground}`) + + ';pointer-events:none}') + + ('.ť{position:absolute;z-index:-10;width:100%;padding:0;' + + `line-height:${lineHeightPx}px;` + + 'font:inherit;background-color:transparent;color:transparent;opacity:0;border:none;outline:none;resize:none}') + + `.ń{display:inline-block;text-align:right;width:var(--line-number-width);padding:0 ${this.#monoFontWidth}px;box-sizing:border-box;color:${lineNumberForeground};user-select:none;pointer-events:none;cursor:default}` + + `.ǎ>.ń,.ǎ>.ď,.ǎ>.đ{color:${foreground}}` + + this.#colorMap + .map((color, i) => `.ċ${i.toString(36)}{color:${color}}`) + .join('') + + '}'; + } + + #setLineNumberDigits(lineNumberDigits: number) { + this.#lineNumberWidth = Math.round( + (lineNumberDigits + 2) * this.#monoFontWidth + ); + this.#gutterWidth = this.#lineNumberWidth; + this.#editorEl?.style.setProperty( + '--line-number-width', + this.#lineNumberWidth + 'px' + ); + } + + #renderText(textDocument: TextDocument, selection?: IEditorSelection): void { + const totalLines = textDocument.lineCount; + const languageId = textDocument.languageId; + + const lineNumberDigits = Math.max(2, totalLines.toString().length); + this.#setLineNumberDigits(lineNumberDigits); + + let grammar: IGrammar | undefined; + if (this.#highlighter !== undefined) { + if (this.#highlighter.getLoadedLanguages().includes(languageId)) { + grammar = this.#highlighter.getLanguage(languageId); + } else { + const requestId = ++this.#languageLoadRequestId; + void this.#highlighter.loadLanguage(languageId).then(() => { + if ( + requestId !== this.#languageLoadRequestId || + this.#textDocument !== textDocument + ) { + return; + } + this.#renderText(textDocument, selection ?? this.#selections); + }); + } + } + + const lineEls = new Map(); + for (let line = 0, ruleStack = INITIAL; line < totalLines; line++) { + const lineText = textDocument.getLineText(line) ?? ''; + const preEl = createElement('pre', undefined, this.#editorEl); + // oxlint-disable-next-line typescript/no-explicit-any + (preEl as any).LINE = line; + lineEls.set(line, preEl); + + const lineNumberEl = createElement('span', { class: 'ń' }, preEl); + lineNumberEl.textContent = (line + 1).toString(); + + if (grammar === undefined) { + if (lineText.length === 0) { + createElement('br', undefined, preEl); + continue; + } + const span = createElement('span', undefined, preEl); + span.textContent = lineText; + // oxlint-disable-next-line typescript/no-explicit-any + (span as any).CHAR = 0; + continue; + } + + const result = grammar.tokenizeLine2(lineText, ruleStack); + const tokens = result.tokens; + const lineLength = lineText.length; + const tokensLength = tokens.length / 2; + for (let j = 0; j < tokensLength; j++) { + const offset = tokens[2 * j]; + const nextOffset = + j + 1 < tokensLength ? tokens[2 * j + 2] : lineLength; + if (offset === nextOffset) { + createElement('br', undefined, preEl); + continue; + } + const metadata = tokens[2 * j + 1]; + const span = createElement( + 'span', + { + class: + 'ċ' + EncodedTokenMetadata.getForeground(metadata).toString(36), + }, + preEl + ); + // oxlint-disable-next-line typescript/no-explicit-any + (span as any).CHAR = offset; + span.textContent = lineText.slice(offset, nextOffset); + } + + ruleStack = result.ruleStack; + } + + // clear previous line elements + this.#textLineEls?.forEach((el) => { + el.remove(); + el.onmouseover = null; + el.onmouseleave = null; + }); + this.#textLineEls?.clear(); + this.#textLineEls = lineEls; + this.#activeLineEl = undefined; + + this.#restoreSelection( + selection ?? this.#selections ?? createSelection(0, 0, 0, 0) + ); + } + + #createSelectionFromOffsets( + startOffset: number, + endOffset = startOffset, + direction = SelectionDirection.None + ) { + const textDocument = this.#textDocument!; + const start = textDocument.positionAt(startOffset); + const end = textDocument.positionAt(endOffset); + return createSelection( + start.line, + start.character, + end.line, + end.character, + direction + ); + } + + #syncTextareaState() { + const textDocument = this.#textDocument; + const textareaEl = this.#textareaEl; + const textareaState = this.#textareaState; + if ( + textDocument === undefined || + textareaEl === undefined || + textareaState === undefined + ) { + return; + } + const { + selections: selectionsBefore, + selection: selectionBefore, + snippet: textareaSnippet, + value: originalValue, + } = textareaState; + const { selectionStart, selectionEnd, selectionDirection, value } = + textareaEl; + const snippetStartOffset = textDocument.offsetAt({ + line: textareaSnippet.firstLine, + character: 0, + }); + if (value !== originalValue) { + const { + start: oldChangedStart, + end: oldChangedEnd, + text: newChangedText, + selectionStart: nextSelectionStart, + selectionEnd: nextSelectionEnd, + } = resolveTextareaTextChange({ + documentValue: textDocument.getText(), + originalValue, + value, + originalSelectionStart: textareaSnippet.selectionStart, + originalSelectionEnd: textareaSnippet.selectionEnd, + selectionStart, + selectionEnd, + }); + const { edits, nextSelections } = mapSelectionTextChange( + textDocument, + selectionsBefore, + { + start: snippetStartOffset + oldChangedStart, + end: snippetStartOffset + oldChangedEnd, + text: newChangedText, + selectionStart: snippetStartOffset + nextSelectionStart, + selectionEnd: snippetStartOffset + nextSelectionEnd, + direction: fromWebSelectionDirection(selectionDirection), + } + ); + const nextSelection = + nextSelections.length === 1 ? nextSelections[0] : nextSelections; + textDocument.applyEdits(edits, { + selectionBefore: + selectionsBefore.length === 1 ? selectionBefore : selectionsBefore, + }); + textDocument.setLastUndoSelectionAfter(nextSelection); + void this.#renderText(textDocument, nextSelection); + // if (newChangedText.trim() && nextSelections.length === 1 && isCollapsedSelection(nextSelections[0]!)) { + // this.#langs.get(textDocument.languageId)?.lspDriver?.doComplete(textDocument, nextSelections[0]!.end); + // } + } else { + const nextPrimarySelection = this.#createSelectionFromOffsets( + snippetStartOffset + selectionStart, + snippetStartOffset + selectionEnd, + fromWebSelectionDirection(selectionDirection) + ); + this.#restoreSelection( + selectionsBefore.length > 1 + ? mapSelectionRangeChange( + textDocument, + selectionsBefore, + nextPrimarySelection + ) + : nextPrimarySelection + ); + } + } + + #restoreSelection(selection: IEditorSelection) { + const selections = normalizeSelections(toSelectionArray(selection)); + const primarySelection = getPrimarySelection(selections); + if (primarySelection === undefined) { + return; + } + this.#selections = selections; + const selectionEls = new Map(); + this.#setActiveLine(primarySelection); + if (isCollapsedSelection(primarySelection)) { + this.#renderHighlightLine(primarySelection, selectionEls); + } + selections.forEach((selection) => { + if (!isCollapsedSelection(selection)) { + this.#renderSelections(selection, selectionEls); + } + this.#renderCursor(selection, selectionEls); + }); + this.#selectionEls?.forEach((el) => el.remove()); + this.#selectionEls?.clear(); + this.#selectionEls = selectionEls; + this.#resetTextarea(primarySelection, selections); + } + + #renderHighlightLine( + selection: ISelection, + selectionEls: Map + ) { + const hlEl = createElement( + 'div', + { + class: 'ħ', + style: { + top: this.#getLineY(selection.start.line) + 'px', + }, + }, + this.#editorEl + ); + hlEl.scrollIntoView({ block: 'nearest' }); + selectionEls.set(`highlightLine-${selection.start.line}`, hlEl); + } + + #renderSelections( + selection: ISelection, + selectionEls: Map + ) { + const { start, end } = selection; + for (let ln = start.line; ln <= end.line; ln++) { + const lineText = this.#textDocument!.getLineText(ln) ?? ''; + const lineLength = lineText.length; + const startCharacter = ln === start.line ? start.character : 0; + const endCharacter = ln === end.line ? end.character : lineLength; + const startColumn = getVisualColumn( + lineText, + startCharacter, + this.#tabSize + ); + const endColumn = getVisualColumn(lineText, endCharacter, this.#tabSize); + const spacing = + endCharacter === startCharacter || ln === end.line ? 0 : 4; + const style = { + top: this.#getLineY(ln) + 'px', + left: this.#gutterWidth + startColumn * this.#monoFontWidth + 'px', + width: + Math.max(endColumn - startColumn, 1) * this.#monoFontWidth + + spacing + + 'px', + }; + const selectionEl = createElement( + 'div', + { class: 'š', style }, + this.#editorEl + ); + selectionEls.set( + `selection-${ln}-${startCharacter}-${endCharacter}`, + selectionEl + ); + } + } + + #renderCursor(selection: ISelection, selectionEls: Map) { + const { start, end, direction } = selection; + const isBackward = direction === SelectionDirection.Backward; + const lineText = + this.#textDocument?.getLineText(isBackward ? start.line : end.line) ?? ''; + const line = isBackward ? start.line : end.line; + const character = isBackward ? start.character : end.character; + const column = getVisualColumn(lineText, character, this.#tabSize); + const cursorEl = createElement( + 'div', + { + class: 'ī', + style: { + top: this.#getLineY(line) + 'px', + left: this.#gutterWidth + column * this.#monoFontWidth + 'px', + }, + }, + this.#editorEl + ); + selectionEls.set( + 'cursor-' + line + '-' + character + '-' + direction, + cursorEl + ); + } + + #setActiveLine(selection: ISelection) { + this.#activeLineEl?.classList.remove('ǎ'); + const activeLine = + selection.direction === SelectionDirection.Backward + ? selection.start.line + : selection.end.line; + const activeLineEl = this.#textLineEls?.get(activeLine); + activeLineEl?.classList.add('ǎ'); + this.#activeLineEl = activeLineEl; + } + + #resetTextarea(selection: ISelection, selections: ISelection[]) { + const textDocument = this.#textDocument; + const textareaEl = this.#textareaEl; + if (textDocument === undefined || textareaEl === undefined) { + return; + } + const textareaSnippet = createTextareaSnippet(textDocument, selection); + this.#textareaState = { + selections, + selection, + snippet: textareaSnippet, + value: textareaSnippet.text, + }; + textareaEl.value = textareaSnippet.text; + textareaEl.setSelectionRange( + textareaSnippet.selectionStart, + textareaSnippet.selectionEnd, + toWebSelectionDirection(selection.direction) + ); + textareaEl.style.left = this.#gutterWidth + 'px'; + textareaEl.style.width = `calc(100% - ${this.#gutterWidth}px)`; + textareaEl.style.top = this.#getLineY(textareaSnippet.firstLine) + 'px'; + textareaEl.style.height = + textareaSnippet.text.split('\n').length * this.#lineHeightPx + 'px'; + } + + async #runShortcutCommand(command: EditorShortcutCommand) { + switch (command) { + case 'paste': { + let text: string; + try { + text = await navigator.clipboard.readText(); + } catch { + return; + } + this.#replaceSelectionText( + this.#resolvePastedSelectionText(text) ?? text + ); + break; + } + case 'copy': + case 'cut': + if ( + this.#selections !== undefined && + this.#textDocument !== undefined + ) { + try { + await navigator.clipboard.writeText( + getOrderedSelectionText( + this.#textDocument, + this.#selections + ).join(this.#textDocument.EOF) + ); + } catch { + return; + } + if (command === 'cut') { + this.#replaceSelectionText(''); + } + } + break; + case 'documentStart': + case 'documentEnd': + this.#restoreSelection( + this.#getDocumentBoundarySelection(command === 'documentEnd') + ); + break; + case 'undo': + if (this.#textDocument?.canUndo === true) { + void this.#renderText(this.#textDocument, this.#textDocument.undo()); + } + break; + case 'redo': + if (this.#textDocument?.canRedo === true) { + void this.#renderText(this.#textDocument, this.#textDocument.redo()); + } + break; + case 'selectAll': + this.#restoreSelection(this.#getSelectAllSelection()); + break; + } + } + + #getSelectAllSelection() { + const textDocument = this.#textDocument; + if (textDocument === undefined) { + throw new Error('Editor has no text document'); + } + const lastLine = textDocument.lineCount; + const lastLineIndex = lastLine - 1; + const lastCharacter = textDocument.getLineText(lastLineIndex)?.length ?? 0; + return createSelection( + 0, + 0, + lastLineIndex, + lastCharacter, + SelectionDirection.Forward + ); + } + + #replaceSelectionText(text: string | string[]) { + const selections = this.#selections; + if (selections === undefined) { + return; + } + const textDocument = this.#textDocument; + const selection = getPrimarySelection(selections); + if (textDocument == null || selection == null) { + return; + } + const normalizedText = Array.isArray(text) + ? text.map((value) => value.replace(/\r\n?|\n/g, textDocument.EOF)) + : text.replace(/\r\n?|\n/g, textDocument.EOF); + const { edits, nextSelections } = Array.isArray(normalizedText) + ? mapSelectionTextReplace(textDocument, selections, normalizedText) + : mapSelectionTextChange(textDocument, selections, { + start: textDocument.offsetAt(selection.start), + end: textDocument.offsetAt(selection.end), + text: normalizedText, + selectionStart: + textDocument.offsetAt(selection.start) + normalizedText.length, + selectionEnd: + textDocument.offsetAt(selection.start) + normalizedText.length, + direction: SelectionDirection.None, + }); + const nextSelection = + nextSelections.length === 1 ? nextSelections[0] : nextSelections; + textDocument.applyEdits(edits, { + selectionBefore: selections.length === 1 ? selection : selections, + }); + textDocument.setLastUndoSelectionAfter(nextSelection); + void this.#renderText(textDocument, nextSelection); + } + + #getDocumentBoundarySelection(atEnd: boolean) { + const textDocument = this.#textDocument; + if (textDocument === undefined) { + throw new Error('Editor has no text document'); + } + const line = atEnd ? textDocument.lineCount - 1 : 0; + const character = atEnd ? (textDocument.getLineText(line)?.length ?? 0) : 0; + return createSelection(line, character, line, character); + } + + #resolvePastedSelectionText(text: string) { + const selectionCount = this.#selections?.length ?? 0; + if (selectionCount === 0) { + return undefined; + } + const parts = text.split(/\r\n?|\n/g); + return parts.length === selectionCount ? parts : undefined; + } + + #getLineY(line: number) { + return line * this.#lineHeightPx + this.#paddingY; + } + + #hasFocusWithinEditor() { + const activeElement = document.activeElement; + return ( + activeElement === this.#editorEl || + activeElement === this.#textareaEl || + (activeElement !== null && + this.#editorEl?.contains(activeElement) === true) + ); + } + + #selectionBelongsToEditor(selection: Selection) { + return ( + this.#nodeBelongsToEditor(selection.anchorNode) && + this.#nodeBelongsToEditor(selection.focusNode) + ); + } + + #nodeBelongsToEditor(node: Node | null) { + return node !== null && this.#editorEl?.contains(node) === true; + } +} diff --git a/packages/diffs/src/editor/editHistory.ts b/packages/diffs/src/editor/editHistory.ts new file mode 100644 index 000000000..05b0b7fb6 --- /dev/null +++ b/packages/diffs/src/editor/editHistory.ts @@ -0,0 +1,365 @@ +import { cloneEditorSelection, type IEditorSelection } from './selection'; + +export type ResolvedEdit = { start: number; end: number; text: string }; + +export type HistoryEntry = { + /** Forward offset edits from the entry's base text to its final text. */ + forwardEdits: ResolvedEdit[]; + /** Inverse offset edits from the entry's final text back to its base text. */ + inverseEdits: ResolvedEdit[]; + /** Base text length before the entry is applied. */ + textLengthBefore: number; + /** Final text length after the entry is applied. */ + textLengthAfter: number; + /** Selection before the transaction (restored on undo). */ + selectionBefore: IEditorSelection; + /** Selection after the transaction (restored on redo). */ + selectionAfter?: IEditorSelection; + /** Timestamp in ms used to coalesce adjacent edits. */ + timestampMs: number; +}; + +export function assertNonOverlappingDescending( + sortedDesc: ResolvedEdit[] +): void { + for (let i = 0; i < sortedDesc.length - 1; i++) { + if (sortedDesc[i + 1].end > sortedDesc[i].start) { + throw new Error('Overlapping text edits are not supported'); + } + } +} + +export function computeTextAfterApplying( + base: string, + sortedDesc: ResolvedEdit[] +): string { + let text = base; + for (const { start, end, text: insert } of sortedDesc) { + text = text.slice(0, start) + insert + text.slice(end); + } + return text; +} + +/** `resolved` in any order; sorted descending internally. */ +export function applyOffsetEdits( + base: string, + resolved: ResolvedEdit[] +): string { + const sorted = [...resolved].sort((a, b) => b.start - a.start); + assertNonOverlappingDescending(sorted); + return computeTextAfterApplying(base, sorted); +} + +export function buildInverseOffsetEdits( + textBefore: string, + ascending: ResolvedEdit[] +): ResolvedEdit[] { + const inverse: ResolvedEdit[] = []; + for (let i = 0; i < ascending.length; i++) { + const edit = ascending[i]; + const replacedText = textBefore.slice(edit.start, edit.end); + let startAfterEdit = edit.start; + for (let j = 0; j < i; j++) { + const previousEdit = ascending[j]; + startAfterEdit += + previousEdit.text.length - (previousEdit.end - previousEdit.start); + } + inverse.push({ + start: startAfterEdit, + end: startAfterEdit + edit.text.length, + text: replacedText, + }); + } + return inverse; +} + +type IntermediateSegment = + | { + kind: 'orig'; + sourceStart: number; + sourceEnd: number; + outputStart: number; + outputEnd: number; + } + | { kind: 'insert'; text: string; outputStart: number; outputEnd: number }; + +type ComposedPiece = + | { kind: 'orig'; start: number; end: number } + | { kind: 'insert'; text: string }; + +function cloneResolvedEdits(edits: ResolvedEdit[]) { + return edits.map((edit) => ({ ...edit })); +} + +function buildIntermediateSegments( + edits: ResolvedEdit[], + sourceLength: number +): IntermediateSegment[] { + const segments: IntermediateSegment[] = []; + let sourceCursor = 0; + let outputCursor = 0; + for (const edit of edits) { + if (sourceCursor < edit.start) { + const length = edit.start - sourceCursor; + segments.push({ + kind: 'orig', + sourceStart: sourceCursor, + sourceEnd: edit.start, + outputStart: outputCursor, + outputEnd: outputCursor + length, + }); + outputCursor += length; + } + if (edit.text.length > 0) { + segments.push({ + kind: 'insert', + text: edit.text, + outputStart: outputCursor, + outputEnd: outputCursor + edit.text.length, + }); + outputCursor += edit.text.length; + } + sourceCursor = edit.end; + } + if (sourceCursor < sourceLength) { + segments.push({ + kind: 'orig', + sourceStart: sourceCursor, + sourceEnd: sourceLength, + outputStart: outputCursor, + outputEnd: outputCursor + (sourceLength - sourceCursor), + }); + } + return segments; +} + +function appendPiece(pieces: ComposedPiece[], piece: ComposedPiece) { + if (piece.kind === 'insert' && piece.text.length === 0) { + return; + } + if (piece.kind === 'orig' && piece.start === piece.end) { + return; + } + const last = pieces[pieces.length - 1]; + if (last === undefined) { + pieces.push(piece); + return; + } + if (last.kind === 'insert' && piece.kind === 'insert') { + last.text += piece.text; + return; + } + if ( + last.kind === 'orig' && + piece.kind === 'orig' && + last.end === piece.start + ) { + last.end = piece.end; + return; + } + pieces.push(piece); +} + +function appendIntermediateSlice( + pieces: ComposedPiece[], + segments: IntermediateSegment[], + start: number, + end: number +) { + if (start >= end) { + return; + } + for (const segment of segments) { + if (segment.outputEnd <= start) { + continue; + } + if (segment.outputStart >= end) { + break; + } + const sliceStart = Math.max(start, segment.outputStart); + const sliceEnd = Math.min(end, segment.outputEnd); + if (segment.kind === 'orig') { + const offset = sliceStart - segment.outputStart; + appendPiece(pieces, { + kind: 'orig', + start: segment.sourceStart + offset, + end: segment.sourceStart + offset + (sliceEnd - sliceStart), + }); + continue; + } + appendPiece(pieces, { + kind: 'insert', + text: segment.text.slice( + sliceStart - segment.outputStart, + sliceEnd - segment.outputStart + ), + }); + } +} + +function piecesToEdits( + pieces: ComposedPiece[], + sourceLength: number +): ResolvedEdit[] { + const edits: ResolvedEdit[] = []; + let sourceCursor = 0; + let pendingStart: number | undefined; + let pendingText = ''; + for (const piece of pieces) { + if (piece.kind === 'insert') { + pendingStart ??= sourceCursor; + pendingText += piece.text; + continue; + } + if (piece.start < sourceCursor) { + throw new Error('Composed edit pieces must preserve source order'); + } + if (pendingStart !== undefined || piece.start !== sourceCursor) { + edits.push({ + start: pendingStart ?? sourceCursor, + end: piece.start, + text: pendingText, + }); + pendingStart = undefined; + pendingText = ''; + } + sourceCursor = piece.end; + } + if (pendingStart !== undefined || sourceCursor !== sourceLength) { + edits.push({ + start: pendingStart ?? sourceCursor, + end: sourceLength, + text: pendingText, + }); + } + return edits.filter( + (edit) => edit.start !== edit.end || edit.text.length > 0 + ); +} + +export function composeOffsetEdits( + first: ResolvedEdit[], + second: ResolvedEdit[], + sourceLength: number +): ResolvedEdit[] { + const firstAscending = cloneResolvedEdits(first).sort( + (a, b) => a.start - b.start + ); + const secondAscending = cloneResolvedEdits(second).sort( + (a, b) => a.start - b.start + ); + const segments = buildIntermediateSegments(firstAscending, sourceLength); + const pieces: ComposedPiece[] = []; + const intermediateLength = + segments.length === 0 + ? sourceLength + : segments[segments.length - 1].outputEnd; + let cursor = 0; + for (const edit of secondAscending) { + appendIntermediateSlice(pieces, segments, cursor, edit.start); + appendPiece(pieces, { kind: 'insert', text: edit.text }); + cursor = edit.end; + } + appendIntermediateSlice(pieces, segments, cursor, intermediateLength); + return piecesToEdits(pieces, sourceLength); +} + +export class EditHistory { + #undo: HistoryEntry[] = []; + #redo: HistoryEntry[] = []; + + get canUndo(): boolean { + return this.#undo.length > 0; + } + + get canRedo(): boolean { + return this.#redo.length > 0; + } + + clear(): void { + this.#undo.length = 0; + this.#redo.length = 0; + } + + push( + textBefore: string, + resolvedEdits: ResolvedEdit[], + selectionBefore: IEditorSelection, + selectionAfter?: IEditorSelection, + coalesceWithinMs?: number + ): void { + const timestampMs = Date.now(); + const ascendingEdits = [...resolvedEdits].sort((a, b) => a.start - b.start); + const inverseEdits = buildInverseOffsetEdits(textBefore, ascendingEdits); + const textLengthBefore = textBefore.length; + const textLengthAfter = + textLengthBefore + + ascendingEdits.reduce( + (sum, edit) => sum + edit.text.length - (edit.end - edit.start), + 0 + ); + const lastEntry = this.#undo[this.#undo.length - 1]; + if ( + lastEntry !== undefined && + this.#redo.length === 0 && + coalesceWithinMs !== undefined && + coalesceWithinMs >= 0 && + timestampMs - lastEntry.timestampMs <= coalesceWithinMs + ) { + lastEntry.forwardEdits = composeOffsetEdits( + lastEntry.forwardEdits, + ascendingEdits, + lastEntry.textLengthBefore + ); + lastEntry.inverseEdits = composeOffsetEdits( + inverseEdits, + lastEntry.inverseEdits, + textLengthAfter + ); + lastEntry.textLengthAfter = textLengthAfter; + lastEntry.timestampMs = timestampMs; + if (selectionAfter !== undefined) { + lastEntry.selectionAfter = cloneEditorSelection(selectionAfter); + } + return; + } + this.#undo.push({ + forwardEdits: cloneResolvedEdits(ascendingEdits), + inverseEdits: inverseEdits, + textLengthBefore, + textLengthAfter, + selectionBefore: cloneEditorSelection(selectionBefore), + selectionAfter: + selectionAfter !== undefined + ? cloneEditorSelection(selectionAfter) + : undefined, + timestampMs, + }); + this.#redo.length = 0; + } + + setLastUndoSelectionAfter(selection: IEditorSelection): void { + const lastEntry = this.#undo[this.#undo.length - 1]; + if (lastEntry !== undefined) { + lastEntry.selectionAfter = cloneEditorSelection(selection); + } + } + + /** Moves the latest undo entry to the redo stack and returns it, or `undefined` if empty. */ + popUndoToRedo(): HistoryEntry | void { + const entry = this.#undo.pop(); + if (entry !== undefined) { + this.#redo.push(entry); + return entry; + } + } + + /** Moves the latest redo entry back to the undo stack and returns it, or `undefined` if empty. */ + popRedoToUndo(): HistoryEntry | void { + const entry = this.#redo.pop(); + if (entry !== undefined) { + this.#undo.push(entry); + return entry; + } + } +} diff --git a/packages/diffs/src/editor/editorShortcuts.ts b/packages/diffs/src/editor/editorShortcuts.ts new file mode 100644 index 000000000..5548879c5 --- /dev/null +++ b/packages/diffs/src/editor/editorShortcuts.ts @@ -0,0 +1,67 @@ +export type EditorShortcutCommand = + | 'copy' + | 'cut' + | 'paste' + | 'documentStart' + | 'documentEnd' + | 'undo' + | 'redo' + | 'selectAll'; + +const SHORTCUTS: Partial> = { + a: 'selectAll', + c: 'copy', + v: 'paste', + x: 'cut', +}; + +function isMacLike(): boolean { + return /macOS|MacIntel|iPhone|iPad|iPod/i.test(getPlatform()); +} + +export function getPrimaryModifier(event: MouseEvent | KeyboardEvent): boolean { + return isMacLike() + ? event.metaKey && !event.ctrlKey + : event.ctrlKey && !event.metaKey; +} + +export function resolveEditorShortcutCommand( + event: KeyboardEvent +): EditorShortcutCommand | undefined { + if (event.altKey) { + return undefined; + } + + const key = event.key.length === 1 ? event.key.toLowerCase() : event.key; + const hasPrimaryModifier = getPrimaryModifier(event); + const isMac = isMacLike(); + + if (!hasPrimaryModifier) { + return undefined; + } + + if (key === 'z') { + return event.shiftKey ? 'redo' : 'undo'; + } + + if (!isMac && key === 'y') { + return 'redo'; + } + + if (key === 'Home' || (isMac && key === 'ArrowUp')) { + return 'documentStart'; + } + + if (key === 'End' || (isMac && key === 'ArrowDown')) { + return 'documentEnd'; + } + + return SHORTCUTS[key]; +} + +function getPlatform(): string { + const navigator = globalThis.navigator as Navigator & { + userAgentData?: { platform?: string }; + }; + return navigator?.platform ?? navigator?.userAgentData?.platform ?? 'unknown'; +} diff --git a/packages/diffs/src/editor/editorUtils.ts b/packages/diffs/src/editor/editorUtils.ts new file mode 100644 index 000000000..1f41c6472 --- /dev/null +++ b/packages/diffs/src/editor/editorUtils.ts @@ -0,0 +1,98 @@ +export function createElement( + tagName: K, + props?: { + id?: string; + class?: string; + style?: Record; + }, + parent?: Element +): HTMLElementTagNameMap[K] { + const el = document.createElement(tagName); + if (props?.class) { + el.className = props.class; + } + if (props?.style !== undefined) { + Object.assign(el.style, props.style); + } + if (props?.id) { + el.id = props.id; + } + if (parent !== undefined) { + parent.appendChild(el); + } + return el; +} + +export function addEventListener( + el: HTMLElement, + event: K, + listener: (this: HTMLElement, evt: HTMLElementEventMap[K]) => void +): () => void; +export function addEventListener( + el: Document, + event: K, + listener: (this: Document, evt: DocumentEventMap[K]) => void +): () => void; +export function addEventListener( + el: Window, + event: K, + listener: (this: Window, evt: WindowEventMap[K]) => void +): () => void; +export function addEventListener( + el: HTMLElement | Document | Window, + event: string, + listener: EventListener +) { + el.addEventListener(event, listener); + return () => { + el.removeEventListener(event, listener); + }; +} + +export function coalesceMicrotask(run: () => void): () => void { + let queued = false; + return () => { + if (queued) { + return; + } + queued = true; + queueMicrotask(() => { + queued = false; + run(); + }); + }; +} + +export function measureMonoFontWidth(font: string): number { + const canvas = createElement('canvas'); + const context = canvas.getContext('2d'); + if (context === null) { + throw new Error('measureMonoFontWidth: Failed to get canvas context'); + } + context.font = font; + const width = context.measureText('0').width; + for (let i = 1; i < 16; i++) { + const w = context.measureText(i.toString(16)).width; + if (w !== width) { + throw new Error(`The font "${font}" isn't a monospace font`); + } + } + return width; +} + +export function getLineIndentation(lineText: string): string { + let indentation = ''; + for (let i = 0; i < lineText.length; i++) { + const char = lineText[i]; + if (char === ' ' || char === '\t') { + indentation += char; + } else { + break; + } + } + return indentation; +} + +export function extend(obj: T, attrs: Partial): T { + return Object.assign(obj, attrs); +} diff --git a/packages/diffs/src/editor/multiSelection.ts b/packages/diffs/src/editor/multiSelection.ts new file mode 100644 index 000000000..3e24bc849 --- /dev/null +++ b/packages/diffs/src/editor/multiSelection.ts @@ -0,0 +1,327 @@ +import { applyOffsetEdits } from './editHistory'; +import { + comparePosition, + createSelection, + type ISelection, + type ISelections, + normalizeSelections, + SelectionDirection, +} from './selection'; +import { TextDocument, type TextEdit } from './textDocument'; + +type SelectionEditMapping = { + edits: TextEdit[]; + nextSelections: ISelections; +}; + +type SelectionTextChange = { + start: number; + end: number; + text: string; + selectionStart: number; + selectionEnd: number; + direction: SelectionDirection; +}; + +export function mapSelectionTextChange( + textDocument: TextDocument, + selections: readonly ISelection[], + change: SelectionTextChange +): SelectionEditMapping { + const primarySelection = selections[selections.length - 1]; + if (primarySelection === undefined) { + return { edits: [], nextSelections: [] }; + } + const primaryStartOffset = textDocument.offsetAt(primarySelection.start); + const primaryEndOffset = textDocument.offsetAt(primarySelection.end); + const relativeStart = change.start - primaryStartOffset; + const relativeEnd = change.end - primaryEndOffset; + const postSelectionStartOffset = change.selectionStart - change.start; + const postSelectionEndOffset = change.selectionEnd - change.start; + const ordered = selections + .map((selection, index) => ({ + selection, + index, + start: textDocument.offsetAt(selection.start), + end: textDocument.offsetAt(selection.end), + isPrimary: index === selections.length - 1, + })) + .sort((a, b) => { + const startOrder = a.start - b.start; + if (startOrder !== 0) { + return startOrder; + } + const endOrder = a.end - b.end; + if (endOrder !== 0) { + return endOrder; + } + return a.index - b.index; + }); + const edits: TextEdit[] = []; + const nextSelectionOffsets: Array<[number, number] | undefined> = Array.from({ + length: selections.length, + }); + let offsetDelta = 0; + let mergedGroup: + | { + start: number; + end: number; + indices: number[]; + } + | undefined; + const finalizeMergedGroup = () => { + if (mergedGroup === undefined) { + return; + } + edits.push({ + range: { + start: textDocument.positionAt(mergedGroup.start), + end: textDocument.positionAt(mergedGroup.end), + }, + newText: change.text, + }); + const nextOffsets: [number, number] = [ + mergedGroup.start + offsetDelta + postSelectionStartOffset, + mergedGroup.start + offsetDelta + postSelectionEndOffset, + ]; + for (const index of mergedGroup.indices) { + nextSelectionOffsets[index] = nextOffsets; + } + offsetDelta += change.text.length - (mergedGroup.end - mergedGroup.start); + mergedGroup = undefined; + }; + for (const entry of ordered) { + const startOffset = Math.max(0, entry.start + relativeStart); + const endOffset = Math.max(startOffset, entry.end + relativeEnd); + if (mergedGroup !== undefined && startOffset < mergedGroup.end) { + mergedGroup.end = Math.max(mergedGroup.end, endOffset); + mergedGroup.indices.push(entry.index); + continue; + } + finalizeMergedGroup(); + mergedGroup = { + start: startOffset, + end: endOffset, + indices: [entry.index], + }; + } + finalizeMergedGroup(); + const nextDocument = new TextDocument( + textDocument.uri, + applyOffsetEdits( + textDocument.getText(), + edits.map((edit) => ({ + start: textDocument.offsetAt(edit.range.start), + end: textDocument.offsetAt(edit.range.end), + text: edit.newText, + })) + ), + textDocument.languageId + ); + return { + edits, + nextSelections: normalizeSelections( + nextSelectionOffsets.map((offsets) => { + const [start, end] = offsets!; + return createSelection( + ...toLineCharacter(nextDocument, start), + ...toLineCharacter(nextDocument, end), + change.direction + ); + }) + ), + }; +} + +export function mapSelectionRangeChange( + textDocument: TextDocument, + selections: readonly ISelection[], + nextPrimarySelection: ISelection +): ISelections { + const primarySelection = selections[selections.length - 1]; + if (primarySelection === undefined) { + return []; + } + const primaryAnchorOffset = getSelectionAnchorOffset( + textDocument, + primarySelection + ); + const primaryFocusOffset = getSelectionFocusOffset( + textDocument, + primarySelection + ); + const nextPrimaryAnchorOffset = getSelectionAnchorOffset( + textDocument, + nextPrimarySelection + ); + const nextPrimaryFocusOffset = getSelectionFocusOffset( + textDocument, + nextPrimarySelection + ); + const anchorDelta = nextPrimaryAnchorOffset - primaryAnchorOffset; + const focusDelta = nextPrimaryFocusOffset - primaryFocusOffset; + const textLength = textDocument.getText().length; + return normalizeSelections( + selections.map((selection) => + createSelectionFromAnchorAndFocusOffsets( + textDocument, + clampOffset( + getSelectionAnchorOffset(textDocument, selection) + anchorDelta, + textLength + ), + clampOffset( + getSelectionFocusOffset(textDocument, selection) + focusDelta, + textLength + ) + ) + ) + ); +} + +export function mapSelectionTextReplace( + textDocument: TextDocument, + selections: readonly ISelection[], + texts: readonly string[] +): SelectionEditMapping { + if (selections.length !== texts.length) { + throw new Error( + 'Selection text replacements must match the selection count' + ); + } + const ordered = selections + .map((selection, index) => ({ + index, + start: textDocument.offsetAt(selection.start), + end: textDocument.offsetAt(selection.end), + text: texts[index], + })) + .sort((a, b) => { + const startOrder = a.start - b.start; + if (startOrder !== 0) { + return startOrder; + } + const endOrder = a.end - b.end; + if (endOrder !== 0) { + return endOrder; + } + return a.index - b.index; + }); + const edits: TextEdit[] = []; + const nextSelectionOffsets: number[] = Array.from({ + length: selections.length, + }); + let offsetDelta = 0; + let previousEditEnd = -1; + for (const entry of ordered) { + if (entry.start < previousEditEnd) { + throw new Error('Overlapping multi-selection edits are not supported'); + } + previousEditEnd = entry.end; + edits.push({ + range: { + start: textDocument.positionAt(entry.start), + end: textDocument.positionAt(entry.end), + }, + newText: entry.text, + }); + nextSelectionOffsets[entry.index] = + entry.start + offsetDelta + entry.text.length; + offsetDelta += entry.text.length - (entry.end - entry.start); + } + const nextDocument = createTextDocumentAfterEdits(textDocument, edits); + return { + edits, + nextSelections: normalizeSelections( + nextSelectionOffsets.map((offset) => + createSelection( + ...toLineCharacter(nextDocument, offset), + ...toLineCharacter(nextDocument, offset), + SelectionDirection.None + ) + ) + ), + }; +} + +export function getOrderedSelectionText( + textDocument: TextDocument, + selections: readonly ISelection[] +): string[] { + return [...selections] + .sort((a, b) => { + const startOrder = comparePosition(a.start, b.start); + if (startOrder !== 0) { + return startOrder; + } + return comparePosition(a.end, b.end); + }) + .map((selection) => textDocument.getText(selection)); +} + +function createTextDocumentAfterEdits( + textDocument: TextDocument, + edits: readonly TextEdit[] +) { + return new TextDocument( + textDocument.uri, + applyOffsetEdits( + textDocument.getText(), + edits.map((edit) => ({ + start: textDocument.offsetAt(edit.range.start), + end: textDocument.offsetAt(edit.range.end), + text: edit.newText, + })) + ), + textDocument.languageId + ); +} + +function getSelectionAnchorOffset( + textDocument: TextDocument, + selection: ISelection +) { + return selection.direction === SelectionDirection.Backward + ? textDocument.offsetAt(selection.end) + : textDocument.offsetAt(selection.start); +} + +function getSelectionFocusOffset( + textDocument: TextDocument, + selection: ISelection +) { + return selection.direction === SelectionDirection.Backward + ? textDocument.offsetAt(selection.start) + : textDocument.offsetAt(selection.end); +} + +function createSelectionFromAnchorAndFocusOffsets( + textDocument: TextDocument, + anchorOffset: number, + focusOffset: number +) { + const direction = + anchorOffset === focusOffset + ? SelectionDirection.None + : anchorOffset < focusOffset + ? SelectionDirection.Forward + : SelectionDirection.Backward; + const start = Math.min(anchorOffset, focusOffset); + const end = Math.max(anchorOffset, focusOffset); + return createSelection( + ...toLineCharacter(textDocument, start), + ...toLineCharacter(textDocument, end), + direction + ); +} + +function clampOffset(offset: number, textLength: number) { + return Math.max(0, Math.min(offset, textLength)); +} + +function toLineCharacter( + textDocument: TextDocument, + offset: number +): [number, number] { + const position = textDocument.positionAt(offset); + return [position.line, position.character]; +} diff --git a/packages/diffs/src/editor/normlizeEditorOptions.ts b/packages/diffs/src/editor/normlizeEditorOptions.ts new file mode 100644 index 000000000..c534b93b5 --- /dev/null +++ b/packages/diffs/src/editor/normlizeEditorOptions.ts @@ -0,0 +1,75 @@ +const DEFAULT_FONT_FAMILY = + "'SF Mono', Monaco, Consolas, 'Ubuntu Mono', 'Liberation Mono', 'Courier New', monospace"; +const DEFAULT_FONT_SIZE = 14; +const DEFAULT_LINE_HEIGHT = 20; +const DEFAULT_PADDING_Y = 10; + +function getRootCssVariableValue(variableName: string): string | undefined { + if (typeof window === 'undefined') { + return undefined; + } + const value = window + .getComputedStyle(document.documentElement) + .getPropertyValue(variableName) + .trim(); + return value.length > 0 ? value : undefined; +} + +function parseCssNumber(value: string): number | undefined { + const parsed = Number.parseFloat(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + +export interface EditorTypographyOptions { + fontFamily?: string; + fontSize?: number; + lineHeight?: number; + paddingY?: number; + tabSize?: number; +} + +export interface NormalizedEditorOptions { + fontFamily: string; + fontSize: number; + lineHeight: number; + paddingY: number; + tabSize: number; +} + +export function normlizeEditorOptions( + options: EditorTypographyOptions = {} +): NormalizedEditorOptions { + const fontFamily = + options.fontFamily ?? + getRootCssVariableValue('--diffs-font-family') ?? + getRootCssVariableValue('--diffs-font-fallback') ?? + DEFAULT_FONT_FAMILY; + + const fontSize = + options.fontSize ?? + parseCssNumber(getRootCssVariableValue('--diffs-font-size') ?? '') ?? + DEFAULT_FONT_SIZE; + + const lineHeight = + options.lineHeight ?? + parseCssNumber(getRootCssVariableValue('--diffs-line-height') ?? '') ?? + DEFAULT_LINE_HEIGHT; + + const paddingY = Math.max(0, options.paddingY ?? DEFAULT_PADDING_Y); + const tabSize = Math.max( + 1, + Math.floor( + options.tabSize ?? + parseCssNumber(getRootCssVariableValue('--diffs-tab-size') ?? '') ?? + 2 + ) + ); + + return { + fontFamily, + fontSize, + lineHeight, + paddingY, + tabSize, + }; +} diff --git a/packages/diffs/src/editor/selection.ts b/packages/diffs/src/editor/selection.ts new file mode 100644 index 000000000..7943357fb --- /dev/null +++ b/packages/diffs/src/editor/selection.ts @@ -0,0 +1,298 @@ +import type { Position, Range } from './textDocument'; + +export enum SelectionDirection { + Backward = -1, + None = 0, + Forward = 1, +} + +export type ISelection = Range & { + direction: SelectionDirection; +}; + +export type ISelections = ISelection[]; + +export type IEditorSelection = ISelection | ISelections; + +export function createSelection( + startLine: number, + startCharacter: number, + endLine: number, + endCharacter: number, + direction: SelectionDirection = SelectionDirection.None +): ISelection { + return { + start: { line: startLine, character: startCharacter }, + end: { line: endLine, character: endCharacter }, + direction, + }; +} + +export function convertSelection({ + rangeCount, + anchorNode, + focusNode, + anchorOffset, + focusOffset, +}: Selection): ISelection | null { + if (rangeCount === 0 || anchorNode === null || focusNode === null) { + return null; + } + const anchor = boundaryToPosition(anchorNode, anchorOffset); + const focus = boundaryToPosition(focusNode, focusOffset); + if (anchor === null || focus === null) { + return null; + } + const order = comparePosition(anchor, focus); + const direction = + order === 0 + ? SelectionDirection.None + : order < 0 + ? SelectionDirection.Forward + : SelectionDirection.Backward; + const start = direction === SelectionDirection.Forward ? anchor : focus; + const end = direction === SelectionDirection.Forward ? focus : anchor; + return { + start, + end, + direction, + }; +} + +export function isCollapsedSelection(selection: ISelection): boolean { + return ( + selection.start.line === selection.end.line && + selection.start.character === selection.end.character + ); +} + +export function cloneSelection(selection: ISelection): ISelection { + return { + start: { ...selection.start }, + end: { ...selection.end }, + direction: selection.direction, + }; +} + +export function cloneEditorSelection( + selection: IEditorSelection +): IEditorSelection { + return Array.isArray(selection) + ? selection.map(cloneSelection) + : cloneSelection(selection); +} + +export function toSelectionArray( + selection: IEditorSelection | undefined +): ISelections { + if (selection === undefined) { + return []; + } + return Array.isArray(selection) + ? selection.map(cloneSelection) + : [cloneSelection(selection)]; +} + +export function getPrimarySelection( + selections: readonly ISelection[] +): ISelection | undefined { + const selection = selections[selections.length - 1]; + return selection !== undefined ? cloneSelection(selection) : undefined; +} + +export function normalizeSelections( + selections: readonly ISelection[] +): ISelections { + if (selections.length === 0) { + return []; + } + const primarySelection = selections[selections.length - 1]; + const ordered = selections + .map((selection, index) => ({ + selection: cloneSelection(selection), + index, + isPrimary: selection === primarySelection, + })) + .sort((a, b) => { + const startOrder = comparePosition(a.selection.start, b.selection.start); + if (startOrder !== 0) { + return startOrder; + } + const endOrder = comparePosition(a.selection.end, b.selection.end); + if (endOrder !== 0) { + return endOrder; + } + return a.index - b.index; + }); + const merged: Array<{ selection: ISelection; isPrimary: boolean }> = []; + for (const entry of ordered) { + const current = merged[merged.length - 1]; + if ( + current === undefined || + comparePosition(entry.selection.start, current.selection.end) > 0 + ) { + merged.push({ + selection: entry.selection, + isPrimary: entry.isPrimary, + }); + continue; + } + if (comparePosition(entry.selection.end, current.selection.end) > 0) { + current.selection.end = { ...entry.selection.end }; + } + current.isPrimary ||= entry.isPrimary; + if (entry.isPrimary) { + current.selection.direction = entry.selection.direction; + } + } + const primaryIndex = Math.max( + 0, + merged.findIndex((entry) => entry.isPrimary) + ); + const normalized = merged.map((entry) => entry.selection); + const [primary] = normalized.splice(primaryIndex, 1); + normalized.push(primary); + return normalized; +} + +export function toWebSelectionDirection( + direction: SelectionDirection +): 'none' | 'forward' | 'backward' { + return direction === SelectionDirection.None + ? 'none' + : direction === SelectionDirection.Forward + ? 'forward' + : 'backward'; +} + +export function fromWebSelectionDirection( + direction: 'none' | 'forward' | 'backward' +): SelectionDirection { + return direction === 'none' + ? SelectionDirection.None + : direction === 'forward' + ? SelectionDirection.Forward + : SelectionDirection.Backward; +} + +export function comparePosition(a: Position, b: Position): number { + if (a.line !== b.line) { + return a.line - b.line; + } + return a.character - b.character; +} + +function getLineAttr(el: HTMLElement): number | undefined { + // oxlint-disable-next-line typescript/no-explicit-any + return (el as any).LINE as number | undefined; +} + +function getCharacterAttr(el: HTMLElement): number | undefined { + // oxlint-disable-next-line typescript/no-explicit-any + return (el as any).CHAR as number | undefined; +} + +function getPositionWithinPre( + pre: HTMLElement, + offset: number +): Position | null { + const line = getLineAttr(pre); + if (line === undefined) { + return null; + } + let character = 0; + for (let i = 0; i < offset; i++) { + const c = pre.children[i]; + if (c?.tagName === 'SPAN') { + const span = c as HTMLElement; + const o = getCharacterAttr(span); + if (o === undefined) { + continue; + } + const len = span.textContent?.length ?? 0; + character = o + len; + } + } + return { line, character }; +} + +function getDirectPreChild( + node: Node +): { pre: HTMLElement; childIndex: number } | null { + let current = + node.nodeType === 1 ? (node as HTMLElement) : node.parentElement; + while (current !== null && current.parentElement !== null) { + if (current.parentElement.tagName === 'PRE') { + return { + pre: current.parentElement, + childIndex: Array.prototype.indexOf.call( + current.parentElement.children, + current + ), + }; + } + current = current.parentElement; + } + return null; +} + +function boundaryToPosition(node: Node, offset: number): Position | null { + if (node.nodeType === 3) { + const parent = node.parentElement; + if (parent === null) { + return null; + } + if (parent.tagName === 'SPAN') { + const pre = parent.parentElement; + if (pre === null || pre.tagName !== 'PRE') { + return null; + } + const line = getLineAttr(pre); + const base = getCharacterAttr(parent); + if (line !== undefined && base !== undefined) { + return { line, character: base + offset }; + } + } + const preChild = getDirectPreChild(node); + if (preChild !== null) { + return getPositionWithinPre(preChild.pre, preChild.childIndex); + } + return null; + } + if (node.nodeType === 1) { + const el = node as HTMLElement; + if (el.tagName === 'PRE') { + return getPositionWithinPre(el, offset); + } + if (el.tagName === 'BR') { + const pre = el.parentElement; + if (pre === null || pre.tagName !== 'PRE') { + return null; + } + const line = getLineAttr(pre); + if (line !== undefined) { + return { line, character: 0 }; + } + } + if (el.tagName === 'SPAN') { + const pre = el.parentElement; + if (pre === null || pre.tagName !== 'PRE') { + return null; + } + const line = getLineAttr(pre); + const base = getCharacterAttr(el); + if (line !== undefined && base !== undefined) { + let character = base; + for (let i = 0; i < offset; i++) { + character += el.childNodes[i]?.textContent?.length ?? 0; + } + return { line, character }; + } + } + const preChild = getDirectPreChild(el); + if (preChild !== null) { + return getPositionWithinPre(preChild.pre, preChild.childIndex); + } + } + return null; +} diff --git a/packages/diffs/src/editor/textDocument.ts b/packages/diffs/src/editor/textDocument.ts new file mode 100644 index 000000000..51b0d039f --- /dev/null +++ b/packages/diffs/src/editor/textDocument.ts @@ -0,0 +1,305 @@ +import { + applyOffsetEdits, + EditHistory, + type ResolvedEdit, +} from './editHistory'; +import { cloneEditorSelection, type IEditorSelection } from './selection'; + +/** + * Position in a text document expressed as zero-based line and character offset. + * The offsets are based on a UTF-16 string representation. So a string of the form + * `a𐐀b` the character offset of the character `a` is 0, the character offset of `𐐀` + * is 1 and the character offset of b is 3 since `𐐀` is represented using two code + * units in UTF-16. + * + * Positions are line end character agnostic. So you can not specify a position that + * denotes `\r|\n` or `\n|` where `|` represents the character offset. + */ +export interface Position { + /** + * Line position in a document (zero-based). + * + * If a line number is greater than the number of lines in a document, it + * defaults back to the number of lines in the document. + * If a line number is negative, it defaults to 0. + * + * The above two properties are implementation specific. + */ + line: number; + /** + * Character offset on a line in a document (zero-based). + * + * The meaning of this offset is determined by the negotiated + * `PositionEncodingKind`. + * + * If the character value is greater than the line length it defaults back + * to the line length. This property is implementation specific. + */ + character: number; +} + +/** + * A range in a text document expressed as (zero-based) start and end positions. + * + * If you want to specify a range that contains a line including the line ending + * character(s) then use an end position denoting the start of the next line. + * For example: + * ```ts + * { + * start: { line: 5, character: 23 } + * end : { line 6, character : 0 } + * } + * ``` + */ +export interface Range { + /** + * The range's start position. + */ + start: Position; + /** + * The range's end position. + */ + end: Position; +} + +/** + * A text edit applicable to a text document. + */ +export interface TextEdit { + /** + * The range of the text document to be manipulated. To insert + * text into a document create a range where start === end. + */ + range: Range; + /** + * The string to be inserted. For delete operations use an + * empty string. + */ + newText: string; +} + +type LineOffsets = number[] & { + hasCRLF?: boolean; +}; + +/** + * A vscode-languageserver-textdocument compatible text document. + */ +export class TextDocument { + #uri: string; + #text: string; + #languageId: string; + #version: number; + #lineOffsets: LineOffsets; + #history = new EditHistory(); + + constructor( + uri: string, + text: string, + languageId = 'plaintext', + version = 0 + ) { + this.#uri = new URL(uri, 'file://').toString(); + this.#text = text; + this.#lineOffsets = computeLineOffsets(text); + this.#languageId = languageId; + this.#version = version; + } + + get uri(): string { + return this.#uri; + } + + get languageId(): string { + return this.#languageId; + } + + get version(): number { + return this.#version; + } + + get lineCount(): number { + return this.#lineOffsets.length; + } + + get canUndo(): boolean { + return this.#history.canUndo; + } + + get canRedo(): boolean { + return this.#history.canRedo; + } + + get EOF(): string { + return this.#lineOffsets.hasCRLF === true ? '\r\n' : '\n'; + } + + getText(range?: Range): string { + if (range !== undefined) { + const start = this.offsetAt(range.start); + const end = this.offsetAt(range.end); + return this.#text.slice(start, end); + } + return this.#text; + } + + getLineText(line: number): string | undefined { + if (line < 0 || line >= this.#lineOffsets.length) { + return undefined; + } + const start = this.#lineOffsets[line]; + const end = this.#lineOffsets[line + 1] ?? this.#text.length; + return this.#text.slice(start, this.#ensureBeforeEOL(end, start)); + } + + setText(text: string): void { + this.#history.clear(); + this.#setDocumentText(text); + } + + applyEdits( + edits: TextEdit[], + updateHistory?: { + selectionBefore: IEditorSelection; + coalesceWithinMs?: number; + } + ): void { + if (edits.length === 0) { + return; + } + const resolvedEdits = this.#resolveEdits(edits); + const T0 = this.#text; + const newText = applyOffsetEdits(T0, resolvedEdits); + if (updateHistory !== undefined) { + const { selectionBefore, coalesceWithinMs = 500 } = updateHistory; + this.#history.push( + T0, + resolvedEdits, + selectionBefore, + undefined, + coalesceWithinMs + ); + } + this.#setDocumentText(newText); + } + + setLastUndoSelectionAfter(selection: IEditorSelection): void { + this.#history.setLastUndoSelectionAfter(selection); + } + + undo(): IEditorSelection | undefined { + const entry = this.#history.popUndoToRedo(); + if (entry === undefined) { + return undefined; + } + this.#setDocumentText(applyOffsetEdits(this.#text, entry.inverseEdits)); + return entry.selectionBefore !== undefined + ? cloneEditorSelection(entry.selectionBefore) + : undefined; + } + + redo(): IEditorSelection | undefined { + const entry = this.#history.popRedoToUndo(); + if (entry === undefined) { + return undefined; + } + this.#setDocumentText(applyOffsetEdits(this.#text, entry.forwardEdits)); + return entry.selectionAfter !== undefined + ? cloneEditorSelection(entry.selectionAfter) + : undefined; + } + + #resolveEdits(edits: TextEdit[]): ResolvedEdit[] { + return edits.map((edit) => this.#resolveEdit(edit)); + } + + #resolveEdit(edit: TextEdit): ResolvedEdit { + let start = this.offsetAt(edit.range.start); + let end = this.offsetAt(edit.range.end); + if (start > end) { + const t = start; + start = end; + end = t; + } + return { start, end, text: edit.newText }; + } + + #setDocumentText(text: string, incrementVersion = true) { + this.#text = text; + this.#lineOffsets = computeLineOffsets(text); + if (incrementVersion) { + this.#version++; + } + } + + positionAt(offset: number): Position { + const columnOffset = Math.max(Math.min(offset, this.#text.length), 0); + const lineOffsets = this.#lineOffsets; + let lo = 0; + let hi = lineOffsets.length - 1; + if (hi === 0) { + return { line: 0, character: columnOffset }; + } + while (lo < hi) { + const mid = lo + Math.floor((hi - lo + 1) / 2); + if (lineOffsets[mid] <= columnOffset) { + lo = mid; + } else { + hi = mid - 1; + } + } + const line = lo; + const character = + this.#ensureBeforeEOL(columnOffset, lineOffsets[line]) - lineOffsets[lo]; + return { line, character }; + } + + offsetAt(position: Position): number { + const { line, character } = position; + const textLength = this.#text.length; + const lineOffsets = this.#lineOffsets; + if (line >= lineOffsets.length) { + return textLength; + } else if (line < 0) { + return 0; + } + const lineOffset = lineOffsets[line]; + if (character <= 0) { + return lineOffset; + } + const nextLineOffset = + line + 1 < lineOffsets.length ? lineOffsets[line + 1] : textLength; + const offset = Math.min(lineOffset + character, nextLineOffset); + return this.#ensureBeforeEOL(offset, lineOffset); + } + + #ensureBeforeEOL(end: number, start: number) { + while (end > start && isEOL(this.#text.charCodeAt(end - 1))) { + end--; + } + return end; + } +} + +function isEOL(char: number) { + return char === /* \n */ 10 || char === 13 /* \r */; +} + +function computeLineOffsets(text: string): LineOffsets { + const offsets: LineOffsets = [0]; + for (let i = 0; i < text.length; i++) { + const char = text.charCodeAt(i); + if (isEOL(char)) { + if ( + char === 13 /* \r */ && + i + 1 < text.length && + text.charCodeAt(i + 1) === /* \n */ 10 + ) { + offsets.hasCRLF = true; + i++; + } + offsets.push(i + 1); + } + } + return offsets; +} diff --git a/packages/diffs/src/editor/textareaState.ts b/packages/diffs/src/editor/textareaState.ts new file mode 100644 index 000000000..fb193d8e3 --- /dev/null +++ b/packages/diffs/src/editor/textareaState.ts @@ -0,0 +1,257 @@ +import { getLineIndentation } from './editorUtils'; +import { + fromWebSelectionDirection, + type ISelection, + type ISelections, +} from './selection'; + +export type TextareaState = { + selections: ISelections; + selection: ISelection; + snippet: ITextareaSnippet; + value: string; +}; + +type TextLineSource = { + lineCount: number; + getLineText(line: number): string | undefined; +}; + +interface ITextareaSnippet { + firstLine: number; + lastLine: number; + text: string; + selectionStart: number; + selectionEnd: number; +} + +type TextareaTextChange = { + start: number; + end: number; + text: string; + selectionStart: number; + selectionEnd: number; +}; + +type ResolveTextareaTextChangeOptions = { + documentValue?: string; + originalValue: string; + value: string; + originalSelectionStart: number; + originalSelectionEnd: number; + selectionStart: number; + selectionEnd: number; +}; + +type TextareaSnapshot = Pick< + HTMLTextAreaElement, + 'value' | 'selectionStart' | 'selectionEnd' | 'selectionDirection' +>; + +function gcd(a: number, b: number): number { + let x = Math.abs(a); + let y = Math.abs(b); + while (y !== 0) { + const t = y; + y = x % y; + x = t; + } + return x; +} + +function detectIndentUnit(text: string): string { + const lines = text.split('\n'); + const spaceIndentLengths: number[] = []; + let tabIndentedLineCount = 0; + for (const line of lines) { + if (line.trim() === '') { + continue; + } + const indentation = getLineIndentation(line); + if (indentation === '') { + continue; + } + if (indentation.startsWith('\t')) { + tabIndentedLineCount++; + continue; + } + spaceIndentLengths.push(indentation.length); + } + if (spaceIndentLengths.length > 0) { + const unitLength = spaceIndentLengths.reduce((acc, length) => + gcd(acc, length) + ); + if (unitLength > 1) { + return ' '.repeat(unitLength); + } + return ' '.repeat(Math.min(...spaceIndentLengths)); + } + if (tabIndentedLineCount > 0) { + return '\t'; + } + return ' '; +} + +export function createTextareaSnippet( + textLineSource: TextLineSource, + selection: ISelection +): ITextareaSnippet { + const firstLine = Math.max(0, selection.start.line - 1); + const lastLine = Math.min( + textLineSource.lineCount - 1, + selection.end.line + 1 + ); + const lines: string[] = []; + let offset = 0; + let selectionStart = 0; + let selectionEnd = 0; + + for (let line = firstLine; line <= lastLine; line++) { + const lineText = textLineSource.getLineText(line); + if (lineText === undefined) { + throw new Error(`Line ${line} is out of bounds`); + } + if (line === selection.start.line) { + selectionStart = offset + selection.start.character; + } + if (line === selection.end.line) { + selectionEnd = offset + selection.end.character; + } + lines.push(lineText); + offset += lineText.length; + if (line < lastLine) { + offset++; + } + } + + return { + firstLine, + lastLine, + text: lines.join('\n'), + selectionStart, + selectionEnd, + }; +} + +export function matchesTextareaState( + textareaState: TextareaState, + { value, selectionStart, selectionEnd, selectionDirection }: TextareaSnapshot +): boolean { + return ( + value === textareaState.value && + selectionStart === textareaState.snippet.selectionStart && + selectionEnd === textareaState.snippet.selectionEnd && + fromWebSelectionDirection(selectionDirection) === + textareaState.selection.direction + ); +} + +export function resolveTextareaTextChange({ + documentValue, + originalValue, + value, + originalSelectionStart, + originalSelectionEnd, + selectionStart, + selectionEnd, +}: ResolveTextareaTextChangeOptions): TextareaTextChange { + let prefixLength = 0; + const prefixLimit = Math.min(originalSelectionStart, selectionStart); + while ( + prefixLength < prefixLimit && + originalValue[prefixLength] === value[prefixLength] + ) { + prefixLength++; + } + + let suffixLength = 0; + const suffixLimit = Math.min( + originalValue.length - originalSelectionEnd, + value.length - selectionEnd + ); + while ( + suffixLength < suffixLimit && + originalValue[originalValue.length - 1 - suffixLength] === + value[value.length - 1 - suffixLength] + ) { + suffixLength++; + } + + const start = prefixLength; + const end = originalValue.length - suffixLength; + let text = value.slice(prefixLength, value.length - suffixLength); + let nextSelectionStart = selectionStart; + let nextSelectionEnd = selectionEnd; + const getLineBounds = (offset: number) => { + const lineStart = + originalValue.lastIndexOf('\n', Math.max(0, offset - 1)) + 1; + const lineEnd = originalValue.indexOf('\n', offset); + return { + lineStart, + lineEnd: lineEnd === -1 ? originalValue.length : lineEnd, + }; + }; + + if ( + originalSelectionStart === originalSelectionEnd && + selectionStart === selectionEnd && + text === '\n' && + end === start + ) { + const { lineStart, lineEnd } = getLineBounds(start); + const lineText = originalValue.slice(lineStart, lineEnd); + const indentation = getLineIndentation(lineText); + if (indentation.length > 0) { + text += indentation; + const delta = indentation.length; + nextSelectionStart += delta; + nextSelectionEnd += delta; + } + } + + if ( + originalSelectionStart === originalSelectionEnd && + selectionStart === selectionEnd && + text === '' && + end - start === 1 && + selectionStart === originalSelectionStart - 1 + ) { + const { lineStart, lineEnd } = getLineBounds(originalSelectionStart); + const lineText = originalValue.slice(lineStart, lineEnd); + const indentation = getLineIndentation(lineText); + if ( + indentation.length > 0 && + indentation.length === lineText.length && + end === lineEnd + ) { + const indentUnit = detectIndentUnit(documentValue ?? originalValue); + const deletedIndentLength = indentation.startsWith('\t') + ? 1 + : Math.min( + indentUnit === '\t' ? 1 : indentUnit.length, + indentation.length + ); + const expandedStart = Math.max(lineStart, end - deletedIndentLength); + const delta = start - expandedStart; + if (delta > 0) { + nextSelectionStart -= delta; + nextSelectionEnd -= delta; + } + return { + start: expandedStart, + end, + text, + selectionStart: nextSelectionStart, + selectionEnd: nextSelectionEnd, + }; + } + } + + return { + start, + end, + text, + selectionStart: nextSelectionStart, + selectionEnd: nextSelectionEnd, + }; +} diff --git a/packages/diffs/src/editor/visualColumns.ts b/packages/diffs/src/editor/visualColumns.ts new file mode 100644 index 000000000..c258f1197 --- /dev/null +++ b/packages/diffs/src/editor/visualColumns.ts @@ -0,0 +1,19 @@ +export function getVisualColumn( + text: string, + character: number, + tabSize: number +): number { + const clampedCharacter = Math.max(0, Math.min(character, text.length)); + const normalizedTabSize = Math.max(1, Math.floor(tabSize)); + let column = 0; + for (let i = 0; i < clampedCharacter; i++) { + if (text.charCodeAt(i) === 9) { + const remainder = column % normalizedTabSize; + column += + remainder === 0 ? normalizedTabSize : normalizedTabSize - remainder; + continue; + } + column++; + } + return column; +} diff --git a/packages/diffs/src/index.ts b/packages/diffs/src/index.ts index 0fa831597..260de7740 100644 --- a/packages/diffs/src/index.ts +++ b/packages/diffs/src/index.ts @@ -3,6 +3,7 @@ import { createCssVariablesTheme as createCSSVariablesTheme, } from 'shiki'; +export * from './components/Editor'; export * from './components/File'; export * from './components/FileDiff'; export * from './components/FileStream'; @@ -11,6 +12,7 @@ export * from './components/VirtualizedFile'; export * from './components/VirtualizedFileDiff'; export * from './components/Virtualizer'; export * from './constants'; +export * from './editor/textDocument'; export * from './highlighter/languages/areLanguagesAttached'; export * from './highlighter/languages/attachResolvedLanguages'; export * from './highlighter/languages/cleanUpResolvedLanguages'; diff --git a/packages/diffs/test/editHistory.test.ts b/packages/diffs/test/editHistory.test.ts new file mode 100644 index 000000000..cef4226e8 --- /dev/null +++ b/packages/diffs/test/editHistory.test.ts @@ -0,0 +1,218 @@ +import { describe, expect, test } from 'bun:test'; + +import { + applyOffsetEdits, + assertNonOverlappingDescending, + buildInverseOffsetEdits, + composeOffsetEdits, + EditHistory, +} from '../src/editor/editHistory'; +import { createSelection, SelectionDirection } from '../src/editor/selection'; + +function caret(character: number) { + return createSelection(0, character, 0, character, SelectionDirection.None); +} + +describe('EditHistory helpers', () => { + test('applyOffsetEdits sorts edits and applies them in offset space', () => { + expect( + applyOffsetEdits('0123456789', [ + { start: 8, end: 10, text: 'YZ' }, + { start: 1, end: 4, text: 'AB' }, + ]) + ).toBe('0AB4567YZ'); + }); + + test('assertNonOverlappingDescending rejects overlapping edits', () => { + expect(() => + assertNonOverlappingDescending([ + { start: 6, end: 8, text: 'X' }, + { start: 4, end: 7, text: 'Y' }, + ]) + ).toThrow('Overlapping text edits are not supported'); + }); + + test('buildInverseOffsetEdits restores the original text for mixed edits', () => { + const textBefore = 'abcde'; + const forwardEdits = [ + { start: 1, end: 2, text: 'XY' }, + { start: 4, end: 5, text: '' }, + ]; + + const textAfter = applyOffsetEdits(textBefore, forwardEdits); + const inverseEdits = buildInverseOffsetEdits(textBefore, forwardEdits); + + expect(textAfter).toBe('aXYcd'); + expect(inverseEdits).toEqual([ + { start: 1, end: 3, text: 'b' }, + { start: 5, end: 5, text: 'e' }, + ]); + expect(applyOffsetEdits(textAfter, inverseEdits)).toBe(textBefore); + }); + + test('composeOffsetEdits collapses sequential edits into source coordinates', () => { + const first = [{ start: 1, end: 1, text: 'bc' }]; + const second = [{ start: 2, end: 4, text: 'Z' }]; + + const composed = composeOffsetEdits(first, second, 2); + + expect(composed).toEqual([{ start: 1, end: 2, text: 'bZ' }]); + expect(applyOffsetEdits('ad', composed)).toBe('abZ'); + }); +}); + +describe('EditHistory', () => { + test('push stores cloned selections and pop methods move entries between stacks', () => { + const history = new EditHistory(); + const selectionBefore = [caret(0), caret(1)]; + const selectionAfter = [caret(2), caret(3)]; + + history.push( + 'ab', + [{ start: 1, end: 1, text: 'X' }], + selectionBefore, + selectionAfter, + -1 + ); + + selectionBefore[0].start.character = 99; + selectionBefore[0].end.character = 99; + selectionAfter[0].start.character = 99; + selectionAfter[0].end.character = 99; + + expect(history.canUndo).toBe(true); + expect(history.canRedo).toBe(false); + + const entry = history.popUndoToRedo(); + + expect(entry).toEqual({ + forwardEdits: [{ start: 1, end: 1, text: 'X' }], + inverseEdits: [{ start: 1, end: 2, text: '' }], + textLengthBefore: 2, + textLengthAfter: 3, + selectionBefore: [caret(0), caret(1)], + selectionAfter: [caret(2), caret(3)], + timestampMs: expect.any(Number), + }); + expect(history.canUndo).toBe(false); + expect(history.canRedo).toBe(true); + + expect(history.popRedoToUndo()).toEqual(entry); + expect(history.canUndo).toBe(true); + expect(history.canRedo).toBe(false); + }); + + test('setLastUndoSelectionAfter stores a cloned redo selection', () => { + const history = new EditHistory(); + const selectionAfter = caret(2); + + history.push( + 'a', + [{ start: 1, end: 1, text: 'b' }], + caret(1), + undefined, + -1 + ); + history.setLastUndoSelectionAfter(selectionAfter); + selectionAfter.start.character = 99; + selectionAfter.end.character = 99; + + expect(history.popUndoToRedo()).toMatchObject({ + selectionAfter: caret(2), + }); + }); + + test('push coalesces adjacent edits into a single undo entry', () => { + const history = new EditHistory(); + const originalNow = Date.now; + let now = 1000; + + Object.defineProperty(Date, 'now', { + configurable: true, + value: () => now, + }); + + try { + history.push( + '', + [{ start: 0, end: 0, text: 'a' }], + caret(0), + caret(1), + 1000 + ); + now += 400; + history.push( + 'a', + [{ start: 1, end: 1, text: 'b' }], + caret(1), + caret(2), + 1000 + ); + + const entry = history.popUndoToRedo(); + + expect(entry).toEqual({ + forwardEdits: [{ start: 0, end: 0, text: 'ab' }], + inverseEdits: [{ start: 0, end: 2, text: '' }], + textLengthBefore: 0, + textLengthAfter: 2, + selectionBefore: caret(0), + selectionAfter: caret(2), + timestampMs: 1400, + }); + expect(history.popUndoToRedo()).toBeUndefined(); + } finally { + Object.defineProperty(Date, 'now', { + configurable: true, + value: originalNow, + }); + } + }); + + test('push clears redo history when recording a new undo entry', () => { + const history = new EditHistory(); + + history.push('', [{ start: 0, end: 0, text: 'a' }], caret(0), caret(1), -1); + history.push( + 'a', + [{ start: 1, end: 1, text: 'b' }], + caret(1), + caret(2), + -1 + ); + + expect(history.popUndoToRedo()).toMatchObject({ + forwardEdits: [{ start: 1, end: 1, text: 'b' }], + }); + expect(history.canRedo).toBe(true); + + history.push( + 'a', + [{ start: 1, end: 1, text: 'c' }], + caret(1), + caret(2), + -1 + ); + + expect(history.canRedo).toBe(false); + expect(history.popUndoToRedo()).toMatchObject({ + forwardEdits: [{ start: 1, end: 1, text: 'c' }], + }); + expect(history.popUndoToRedo()).toMatchObject({ + forwardEdits: [{ start: 0, end: 0, text: 'a' }], + }); + }); + + test('clear resets both undo and redo stacks', () => { + const history = new EditHistory(); + + history.push('', [{ start: 0, end: 0, text: 'a' }], caret(0), caret(1), -1); + history.popUndoToRedo(); + history.clear(); + + expect(history.canUndo).toBe(false); + expect(history.canRedo).toBe(false); + expect(history.popUndoToRedo()).toBeUndefined(); + expect(history.popRedoToUndo()).toBeUndefined(); + }); +}); diff --git a/packages/diffs/test/editorShortcuts.test.ts b/packages/diffs/test/editorShortcuts.test.ts new file mode 100644 index 000000000..f4a1a65b0 --- /dev/null +++ b/packages/diffs/test/editorShortcuts.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, test } from 'bun:test'; + +import { + type EditorShortcutCommand, + resolveEditorShortcutCommand, +} from '../src/editor/editorShortcuts'; + +type ShortcutKeyboardEvent = Pick< + KeyboardEvent, + 'altKey' | 'ctrlKey' | 'metaKey' | 'shiftKey' | 'key' +>; +type ShortcutCase = { + event: Partial & Pick; + expected: EditorShortcutCommand | undefined; +}; + +function event({ + key, + ...overrides +}: Partial & + Pick): KeyboardEvent { + return { + altKey: false, + ctrlKey: false, + metaKey: false, + shiftKey: false, + ...overrides, + key, + } as KeyboardEvent; +} + +function withPlatform(platform: string, run: () => void): void { + const navigator = globalThis.navigator; + const originalPlatform = navigator.platform; + Object.defineProperty(navigator, 'platform', { + configurable: true, + value: platform, + }); + + try { + run(); + } finally { + Object.defineProperty(navigator, 'platform', { + configurable: true, + value: originalPlatform, + }); + } +} + +function expectShortcuts(platform: string, cases: ShortcutCase[]): void { + withPlatform(platform, () => { + for (const { event: shortcutEvent, expected } of cases) { + expect(resolveEditorShortcutCommand(event(shortcutEvent))).toBe(expected); + } + }); +} + +describe('resolveEditorShortcutCommand', () => { + test('uses command shortcuts on macOS', () => { + expectShortcuts('MacIntel', [ + { event: { key: 'c', metaKey: true }, expected: 'copy' }, + { event: { key: 'x', metaKey: true }, expected: 'cut' }, + { event: { key: 'v', metaKey: true }, expected: 'paste' }, + { event: { key: 'z', metaKey: true }, expected: 'undo' }, + { event: { key: 'z', metaKey: true, shiftKey: true }, expected: 'redo' }, + { event: { key: 'a', metaKey: true }, expected: 'selectAll' }, + { event: { key: 'ArrowUp', metaKey: true }, expected: 'documentStart' }, + { event: { key: 'ArrowDown', metaKey: true }, expected: 'documentEnd' }, + ]); + }); + + test('uses control shortcuts on windows and linux', () => { + expectShortcuts('Linux x86_64', [ + { event: { key: 'c', ctrlKey: true }, expected: 'copy' }, + { event: { key: 'x', ctrlKey: true }, expected: 'cut' }, + { event: { key: 'v', ctrlKey: true }, expected: 'paste' }, + { event: { key: 'z', ctrlKey: true }, expected: 'undo' }, + { event: { key: 'z', ctrlKey: true, shiftKey: true }, expected: 'redo' }, + { event: { key: 'y', ctrlKey: true }, expected: 'redo' }, + { event: { key: 'a', ctrlKey: true }, expected: 'selectAll' }, + { event: { key: 'Home', ctrlKey: true }, expected: 'documentStart' }, + { event: { key: 'End', ctrlKey: true }, expected: 'documentEnd' }, + ]); + }); + + test('ignores modified alt shortcuts and unsupported navigation', () => { + expectShortcuts('Linux x86_64', [ + { event: { key: 'ArrowUp', ctrlKey: true }, expected: undefined }, + { event: { key: 'z', ctrlKey: true, altKey: true }, expected: undefined }, + ]); + }); +}); diff --git a/packages/diffs/test/editorUtils.test.ts b/packages/diffs/test/editorUtils.test.ts new file mode 100644 index 000000000..f7e26f405 --- /dev/null +++ b/packages/diffs/test/editorUtils.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from 'bun:test'; + +import { coalesceMicrotask } from '../src/editor/editorUtils'; + +describe('coalesceMicrotask', () => { + test('runs once for repeated calls in the same tick', async () => { + let callCount = 0; + const run = coalesceMicrotask(() => { + callCount++; + }); + + run(); + run(); + run(); + expect(callCount).toBe(0); + + await Promise.resolve(); + expect(callCount).toBe(1); + }); + + test('allows a later tick to run again', async () => { + let callCount = 0; + const run = coalesceMicrotask(() => { + callCount++; + }); + + run(); + await Promise.resolve(); + run(); + await Promise.resolve(); + + expect(callCount).toBe(2); + }); +}); diff --git a/packages/diffs/test/multiSelection.test.ts b/packages/diffs/test/multiSelection.test.ts new file mode 100644 index 000000000..6e117cc6f --- /dev/null +++ b/packages/diffs/test/multiSelection.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, test } from 'bun:test'; + +import { + mapSelectionRangeChange, + mapSelectionTextChange, + mapSelectionTextReplace, +} from '../src/editor/multiSelection'; +import { createSelection, SelectionDirection } from '../src/editor/selection'; +import { TextDocument } from '../src/editor/textDocument'; + +describe('mapSelectionTextChange', () => { + test('inserts the same text at multiple carets', () => { + const textDocument = new TextDocument('inmemory://1', 'a\nb\nc'); + const selections = [ + createSelection(0, 1, 0, 1), + createSelection(1, 1, 1, 1), + createSelection(2, 1, 2, 1), + ]; + const { edits, nextSelections } = mapSelectionTextChange( + textDocument, + selections, + { + start: 5, + end: 5, + text: '!', + selectionStart: 6, + selectionEnd: 6, + direction: SelectionDirection.None, + } + ); + + textDocument.applyEdits(edits); + + expect(textDocument.getText()).toBe('a!\nb!\nc!'); + expect(nextSelections).toEqual([ + createSelection(0, 2, 0, 2), + createSelection(1, 2, 1, 2), + createSelection(2, 2, 2, 2), + ]); + }); + + test('replaces each selected range with the typed text', () => { + const textDocument = new TextDocument('inmemory://1', 'foo bar baz'); + const selections = [ + createSelection(0, 0, 0, 3, SelectionDirection.Forward), + createSelection(0, 4, 0, 7, SelectionDirection.Forward), + createSelection(0, 8, 0, 11, SelectionDirection.Forward), + ]; + const { edits, nextSelections } = mapSelectionTextChange( + textDocument, + selections, + { + start: 8, + end: 11, + text: 'x', + selectionStart: 9, + selectionEnd: 9, + direction: SelectionDirection.None, + } + ); + + textDocument.applyEdits(edits); + + expect(textDocument.getText()).toBe('x x x'); + expect(nextSelections).toEqual([ + createSelection(0, 1, 0, 1), + createSelection(0, 3, 0, 3), + createSelection(0, 5, 0, 5), + ]); + }); + + test('mirrors backspace for multiple carets', () => { + const textDocument = new TextDocument('inmemory://1', 'ax\nbx\ncx'); + const selections = [ + createSelection(0, 1, 0, 1), + createSelection(1, 1, 1, 1), + createSelection(2, 1, 2, 1), + ]; + const { edits, nextSelections } = mapSelectionTextChange( + textDocument, + selections, + { + start: 6, + end: 7, + text: '', + selectionStart: 6, + selectionEnd: 6, + direction: SelectionDirection.None, + } + ); + + textDocument.applyEdits(edits); + + expect(textDocument.getText()).toBe('x\nx\nx'); + expect(nextSelections).toEqual([ + createSelection(0, 0, 0, 0), + createSelection(1, 0, 1, 0), + createSelection(2, 0, 2, 0), + ]); + }); + + test('coalesces transformed edits that would overlap', () => { + const textDocument = new TextDocument('inmemory://1', ' '); + const selections = [ + createSelection(0, 1, 0, 1), + createSelection(0, 2, 0, 2), + ]; + const { edits, nextSelections } = mapSelectionTextChange( + textDocument, + selections, + { + start: 0, + end: 2, + text: '', + selectionStart: 0, + selectionEnd: 0, + direction: SelectionDirection.None, + } + ); + + textDocument.applyEdits(edits); + + expect(textDocument.getText()).toBe(' '); + expect(nextSelections).toEqual([createSelection(0, 0, 0, 0)]); + }); +}); + +describe('mapSelectionRangeChange', () => { + test('moves all carets when the primary caret moves', () => { + const textDocument = new TextDocument('inmemory://1', 'ab\ncd\nef'); + const selections = [ + createSelection(0, 1, 0, 1), + createSelection(1, 1, 1, 1), + createSelection(2, 1, 2, 1), + ]; + + expect( + mapSelectionRangeChange( + textDocument, + selections, + createSelection(2, 0, 2, 0) + ) + ).toEqual([ + createSelection(0, 0, 0, 0), + createSelection(1, 0, 1, 0), + createSelection(2, 0, 2, 0), + ]); + }); + + test('extends all selections when the primary selection grows', () => { + const textDocument = new TextDocument('inmemory://1', 'abcd\nefgh'); + const selections = [ + createSelection(0, 1, 0, 2, SelectionDirection.Forward), + createSelection(1, 1, 1, 2, SelectionDirection.Forward), + ]; + + expect( + mapSelectionRangeChange( + textDocument, + selections, + createSelection(1, 1, 1, 3, SelectionDirection.Forward) + ) + ).toEqual([ + createSelection(0, 1, 0, 3, SelectionDirection.Forward), + createSelection(1, 1, 1, 3, SelectionDirection.Forward), + ]); + }); +}); + +describe('mapSelectionTextReplace', () => { + test('replaces each selection with its own pasted text', () => { + const textDocument = new TextDocument('inmemory://1', 'x\ny\nz'); + const selections = [ + createSelection(0, 1, 0, 1), + createSelection(1, 1, 1, 1), + createSelection(2, 1, 2, 1), + ]; + const { edits, nextSelections } = mapSelectionTextReplace( + textDocument, + selections, + ['a', 'b', 'c'] + ); + + textDocument.applyEdits(edits); + + expect(textDocument.getText()).toBe('xa\nyb\nzc'); + expect(nextSelections).toEqual([ + createSelection(0, 2, 0, 2), + createSelection(1, 2, 1, 2), + createSelection(2, 2, 2, 2), + ]); + }); +}); diff --git a/packages/diffs/test/selection.test.ts b/packages/diffs/test/selection.test.ts new file mode 100644 index 000000000..034ae53ff --- /dev/null +++ b/packages/diffs/test/selection.test.ts @@ -0,0 +1,237 @@ +import { describe, expect, test } from 'bun:test'; + +import { + convertSelection, + createSelection, + normalizeSelections, + SelectionDirection, +} from '../src/editor/selection'; + +type MockNode = { + nodeType: number; + tagName?: string; + parentElement?: MockElement | null; + children?: MockElement[]; + childNodes?: MockNode[]; + textContent?: string | null; +}; + +type MockElement = MockNode & { + tagName: string; + parentElement?: MockElement | null; + children: MockElement[]; + childNodes: MockNode[]; +}; + +function selection( + anchorNode: Node, + anchorOffset: number, + focusNode: Node, + focusOffset: number +): Selection { + return { + rangeCount: 1, + anchorNode, + anchorOffset, + focusNode, + focusOffset, + } as Selection; +} + +function pre(line: number, children: MockElement[] = []): MockElement { + const element: MockElement = { + nodeType: 1, + tagName: 'PRE', + parentElement: null, + children, + childNodes: children, + textContent: null, + }; + Reflect.set(element, 'LINE', line); + for (const child of children) { + child.parentElement = element; + } + return element; +} + +function br(): MockElement { + return { + nodeType: 1, + tagName: 'BR', + parentElement: null, + children: [], + childNodes: [], + textContent: '', + }; +} + +function span(text: string, char?: number): MockElement { + const textNode: MockNode = { + nodeType: 3, + textContent: text, + }; + const element: MockElement = { + nodeType: 1, + tagName: 'SPAN', + parentElement: null, + children: [], + childNodes: [textNode], + textContent: text, + }; + textNode.parentElement = element; + if (char !== undefined) { + Reflect.set(element, 'CHAR', char); + } + return element; +} + +function button(text: string): MockElement { + const textNode: MockNode = { + nodeType: 3, + textContent: text, + }; + const element: MockElement = { + nodeType: 1, + tagName: 'BUTTON', + parentElement: null, + children: [], + childNodes: [textNode], + textContent: text, + }; + textNode.parentElement = element; + return element; +} + +function element(tagName: string, children: MockNode[] = []): MockElement { + const el: MockElement = { + nodeType: 1, + tagName, + parentElement: null, + children: children.filter( + (child): child is MockElement => child.nodeType === 1 + ), + childNodes: children, + textContent: children.map((child) => child.textContent ?? '').join(''), + }; + for (const child of children) { + child.parentElement = el; + } + return el; +} + +describe('convertSelection', () => { + test('maps a caret on an empty rendered line to character zero', () => { + const line = pre(1, [br()]); + expect( + convertSelection( + selection(line as unknown as Node, 0, line as unknown as Node, 0) + ) + ).toEqual({ + start: { line: 1, character: 0 }, + end: { line: 1, character: 0 }, + direction: SelectionDirection.None, + }); + }); + + test('treats a placeholder br boundary as the start of the line', () => { + const line = pre(2, [br()]); + expect( + convertSelection( + selection(line as unknown as Node, 1, line as unknown as Node, 1) + ) + ).toEqual({ + start: { line: 2, character: 0 }, + end: { line: 2, character: 0 }, + direction: SelectionDirection.None, + }); + }); + + test('ignores the line number gutter span on an empty line', () => { + const line = pre(3, [span('4'), br()]); + expect( + convertSelection( + selection(line as unknown as Node, 1, line as unknown as Node, 1) + ) + ).toEqual({ + start: { line: 3, character: 0 }, + end: { line: 3, character: 0 }, + direction: SelectionDirection.None, + }); + expect( + convertSelection( + selection(line as unknown as Node, 2, line as unknown as Node, 2) + ) + ).toEqual({ + start: { line: 3, character: 0 }, + end: { line: 3, character: 0 }, + direction: SelectionDirection.None, + }); + }); + + test('ignores the fold toggle button in the gutter', () => { + const line = pre(4, [span('5'), button('>'), span('color', 0)]); + expect( + convertSelection( + selection(line as unknown as Node, 2, line as unknown as Node, 2) + ) + ).toEqual({ + start: { line: 4, character: 0 }, + end: { line: 4, character: 0 }, + direction: SelectionDirection.None, + }); + }); + + test('maps clicks inside a fold button on an empty line to character zero', () => { + const icon = element('SVG', [element('POLYLINE')]); + const toggle = element('BUTTON', [icon]); + pre(5, [span('6'), toggle, br()]); + expect( + convertSelection( + selection(toggle as unknown as Node, 0, toggle as unknown as Node, 0) + ) + ).toEqual({ + start: { line: 5, character: 0 }, + end: { line: 5, character: 0 }, + direction: SelectionDirection.None, + }); + expect( + convertSelection( + selection(icon as unknown as Node, 0, icon as unknown as Node, 0) + ) + ).toEqual({ + start: { line: 5, character: 0 }, + end: { line: 5, character: 0 }, + direction: SelectionDirection.None, + }); + }); +}); + +describe('normalizeSelections', () => { + test('keeps the primary selection last while merging overlaps', () => { + expect( + normalizeSelections([ + createSelection(0, 0, 0, 2, SelectionDirection.Forward), + createSelection(0, 4, 0, 5, SelectionDirection.None), + createSelection(0, 1, 0, 4, SelectionDirection.Backward), + ]) + ).toEqual([createSelection(0, 0, 0, 5, SelectionDirection.Backward)]); + }); + + test('collapses duplicate carets into a single selection', () => { + expect( + normalizeSelections([ + createSelection(0, 3, 0, 3), + createSelection(0, 3, 0, 3), + ]) + ).toEqual([createSelection(0, 3, 0, 3)]); + }); + + test('sorts disjoint selections into document order with the primary selection last', () => { + expect( + normalizeSelections([ + createSelection(1, 0, 1, 0), + createSelection(0, 2, 0, 2), + ]) + ).toEqual([createSelection(1, 0, 1, 0), createSelection(0, 2, 0, 2)]); + }); +}); diff --git a/packages/diffs/test/textDocument.test.ts b/packages/diffs/test/textDocument.test.ts new file mode 100644 index 000000000..a1b8aa205 --- /dev/null +++ b/packages/diffs/test/textDocument.test.ts @@ -0,0 +1,527 @@ +import { describe, expect, test } from 'bun:test'; + +import { createSelection, SelectionDirection } from '../src/editor/selection'; +import { TextDocument, type TextEdit } from '../src/editor/textDocument'; + +function doc(text: string) { + return new TextDocument('inmemory://1', text, 'plain'); +} + +function caret(line: number, character: number) { + return createSelection( + line, + character, + line, + character, + SelectionDirection.None + ); +} + +describe('TextDocument', () => { + test('lang and lineCount', () => { + const d = doc('a\nb\nc'); + expect(d.languageId).toBe('plain'); + expect(d.lineCount).toBe(3); + }); + + test('getText without range returns full buffer', () => { + expect(doc('hello').getText()).toBe('hello'); + }); + + test('getText with range', () => { + const d = doc('aa\nbb\ncc'); + expect( + d.getText({ + start: { line: 1, character: 0 }, + end: { line: 1, character: 1 }, + }) + ).toBe('b'); + }); + + test('getLineText', () => { + const d = doc('first\nsecond'); + expect(d.getLineText(0)).toBe('first'); + expect(d.getLineText(1)).toBe('second'); + expect(d.getLineText(-1)).toBeUndefined(); + expect(d.getLineText(99)).toBeUndefined(); + }); + + test('EOF is LF for Unix newlines', () => { + expect(doc('a\nb').EOF).toBe('\n'); + }); + + test('EOF is CRLF when text uses CRLF', () => { + expect(doc('a\r\nb').EOF).toBe('\r\n'); + }); + + test('offsetAt clamps to line and document bounds', () => { + const d = doc('ab\nc'); + expect(d.offsetAt({ line: 0, character: 0 })).toBe(0); + expect(d.offsetAt({ line: 0, character: 99 })).toBe(2); + expect(d.offsetAt({ line: 1, character: 0 })).toBe(3); + expect(d.offsetAt({ line: 99, character: 0 })).toBe(d.getText().length); + }); + + test('positionAt is inverse of offsetAt for in-range columns', () => { + const d = doc('ab\nc'); + expect(d.positionAt(0)).toEqual({ line: 0, character: 0 }); + expect(d.positionAt(3)).toEqual({ line: 1, character: 0 }); + expect(d.positionAt(d.getText().length)).toEqual({ line: 1, character: 1 }); + const { line, character } = d.positionAt(2); + expect(d.offsetAt({ line, character })).toBe(2); + }); + + test('applyEdits single replacement', () => { + const d = doc('hello world'); + d.applyEdits([ + { + range: { + start: { line: 0, character: 6 }, + end: { line: 0, character: 11 }, + }, + newText: 'you', + }, + ]); + expect(d.getText()).toBe('hello you'); + }); + + test('applyEdits swaps inverted start/end', () => { + const d = doc('abcd'); + d.applyEdits([ + { + range: { + start: { line: 0, character: 3 }, + end: { line: 0, character: 1 }, + }, + newText: 'X', + }, + ]); + expect(d.getText()).toBe('aXd'); + }); + + test('applyEdits multiple non-overlapping regions', () => { + const d = doc('aa bb cc'); + const edits: TextEdit[] = [ + { + range: { + start: { line: 0, character: 6 }, + end: { line: 0, character: 8 }, + }, + newText: 'CC', + }, + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 2 }, + }, + newText: 'AA', + }, + ]; + d.applyEdits(edits); + expect(d.getText()).toBe('AA bb CC'); + }); + + test('undo restores batch with two disjoint edits', () => { + const d = doc('aa bb cc'); + d.applyEdits( + [ + { + range: { + start: { line: 0, character: 6 }, + end: { line: 0, character: 8 }, + }, + newText: 'CC', + }, + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 2 }, + }, + newText: 'AA', + }, + ], + { selectionBefore: caret(0, 0) } + ); + d.undo(); + expect(d.getText()).toBe('aa bb cc'); + }); + + test('undo multi-line replacement', () => { + const d = doc('line1\nline2\nline3'); + d.applyEdits( + [ + { + range: { + start: { line: 1, character: 0 }, + end: { line: 1, character: 5 }, + }, + newText: 'two', + }, + ], + { selectionBefore: caret(1, 0) } + ); + expect(d.getText()).toBe('line1\ntwo\nline3'); + d.undo(); + expect(d.getText()).toBe('line1\nline2\nline3'); + }); + + test('undo stack depth for sequential edits', () => { + const d = doc(''); + d.applyEdits( + [ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 0 }, + }, + newText: 'a', + }, + ], + { selectionBefore: caret(0, 0), coalesceWithinMs: -1 } + ); + d.applyEdits( + [ + { + range: { + start: { line: 0, character: 1 }, + end: { line: 0, character: 1 }, + }, + newText: 'b', + }, + ], + { selectionBefore: caret(0, 1), coalesceWithinMs: -1 } + ); + d.undo(); + expect(d.getText()).toBe('a'); + d.undo(); + expect(d.getText()).toBe(''); + }); + + test('sequential edits within coalesce window undo as one entry', () => { + const d = doc(''); + const originalNow = Date.now; + let now = 1000; + Object.defineProperty(Date, 'now', { + configurable: true, + value: () => now, + }); + try { + d.applyEdits( + [ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 0 }, + }, + newText: 'a', + }, + ], + { selectionBefore: caret(0, 0), coalesceWithinMs: 1000 } + ); + now += 400; + d.applyEdits( + [ + { + range: { + start: { line: 0, character: 1 }, + end: { line: 0, character: 1 }, + }, + newText: 'b', + }, + ], + { selectionBefore: caret(0, 1), coalesceWithinMs: 1000 } + ); + expect(d.getText()).toBe('ab'); + d.undo(); + expect(d.getText()).toBe(''); + d.redo(); + expect(d.getText()).toBe('ab'); + expect(d.canUndo).toBe(true); + expect(d.canRedo).toBe(false); + } finally { + Object.defineProperty(Date, 'now', { + configurable: true, + value: originalNow, + }); + } + }); + + test('coalesced edits can update earlier inserted text', () => { + const d = doc('x'); + const originalNow = Date.now; + let now = 1000; + Object.defineProperty(Date, 'now', { + configurable: true, + value: () => now, + }); + try { + d.applyEdits( + [ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 1 }, + }, + newText: 'ab', + }, + ], + { selectionBefore: caret(0, 0), coalesceWithinMs: 1000 } + ); + now += 400; + d.applyEdits( + [ + { + range: { + start: { line: 0, character: 1 }, + end: { line: 0, character: 2 }, + }, + newText: 'c', + }, + ], + { selectionBefore: caret(0, 1), coalesceWithinMs: 1000 } + ); + expect(d.getText()).toBe('ac'); + d.undo(); + expect(d.getText()).toBe('x'); + d.redo(); + expect(d.getText()).toBe('ac'); + } finally { + Object.defineProperty(Date, 'now', { + configurable: true, + value: originalNow, + }); + } + }); + + test('sequential edits outside coalesce window keep separate entries', () => { + const d = doc(''); + const originalNow = Date.now; + let now = 1000; + Object.defineProperty(Date, 'now', { + configurable: true, + value: () => now, + }); + try { + d.applyEdits( + [ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 0 }, + }, + newText: 'a', + }, + ], + { selectionBefore: caret(0, 0), coalesceWithinMs: 1000 } + ); + now += 1200; + d.applyEdits( + [ + { + range: { + start: { line: 0, character: 1 }, + end: { line: 0, character: 1 }, + }, + newText: 'b', + }, + ], + { selectionBefore: caret(0, 1), coalesceWithinMs: 1000 } + ); + d.undo(); + expect(d.getText()).toBe('a'); + d.undo(); + expect(d.getText()).toBe(''); + } finally { + Object.defineProperty(Date, 'now', { + configurable: true, + value: originalNow, + }); + } + }); + + test('applyEdits rejects overlapping ranges', () => { + const d = doc('0123456789'); + expect(() => + d.applyEdits([ + { + range: { + start: { line: 0, character: 1 }, + end: { line: 0, character: 5 }, + }, + newText: 'X', + }, + { + range: { + start: { line: 0, character: 4 }, + end: { line: 0, character: 7 }, + }, + newText: 'Y', + }, + ]) + ).toThrow('Overlapping text edits are not supported'); + }); + + test('applyEdits empty array does not touch history', () => { + const d = doc('x'); + d.applyEdits([]); + expect(d.canUndo).toBe(false); + }); + + test('applyEdits default does not record undo', () => { + const d = doc('a'); + d.applyEdits([ + { + range: { + start: { line: 0, character: 1 }, + end: { line: 0, character: 1 }, + }, + newText: 'b', + }, + ]); + expect(d.getText()).toBe('ab'); + expect(d.canUndo).toBe(false); + expect(d.undo()).toBeUndefined(); + }); + + test('undo and redo', () => { + const d = doc('a'); + d.applyEdits( + [ + { + range: { + start: { line: 0, character: 1 }, + end: { line: 0, character: 1 }, + }, + newText: 'b', + }, + ], + { selectionBefore: caret(0, 1) } + ); + expect(d.getText()).toBe('ab'); + expect(d.canUndo).toBe(true); + expect(d.canRedo).toBe(false); + + d.undo(); + expect(d.getText()).toBe('a'); + expect(d.canUndo).toBe(false); + expect(d.canRedo).toBe(true); + + d.redo(); + expect(d.getText()).toBe('ab'); + expect(d.canUndo).toBe(true); + expect(d.canRedo).toBe(false); + }); + + test('new edit after undo clears redo stack', () => { + const d = doc('a'); + d.applyEdits( + [ + { + range: { + start: { line: 0, character: 1 }, + end: { line: 0, character: 1 }, + }, + newText: 'b', + }, + ], + { selectionBefore: caret(0, 1) } + ); + d.undo(); + d.applyEdits( + [ + { + range: { + start: { line: 0, character: 1 }, + end: { line: 0, character: 1 }, + }, + newText: 'c', + }, + ], + { selectionBefore: caret(0, 1) } + ); + expect(d.getText()).toBe('ac'); + expect(d.canRedo).toBe(false); + }); + + test('setText replaces content and clears history', () => { + const d = doc('a'); + d.applyEdits( + [ + { + range: { + start: { line: 0, character: 1 }, + end: { line: 0, character: 1 }, + }, + newText: 'b', + }, + ], + { selectionBefore: caret(0, 1) } + ); + expect(d.canUndo).toBe(true); + d.setText('fresh'); + expect(d.getText()).toBe('fresh'); + expect(d.canUndo).toBe(false); + expect(d.canRedo).toBe(false); + }); + + test('undo on empty stack returns false', () => { + const d = doc('z'); + expect(d.undo()).toBeUndefined(); + }); + + test('redo on empty stack returns false', () => { + const d = doc('z'); + expect(d.redo()).toBeUndefined(); + }); + + test('undo and redo return stored selections', () => { + const d = doc('abc'); + const selectionBefore = caret(0, 1); + const selectionAfter = caret(0, 2); + d.applyEdits( + [ + { + range: { + start: { line: 0, character: 1 }, + end: { line: 0, character: 1 }, + }, + newText: 'x', + }, + ], + { selectionBefore } + ); + d.setLastUndoSelectionAfter(selectionAfter); + + expect(d.undo()).toEqual(selectionBefore); + expect(d.redo()).toEqual(selectionAfter); + }); + + test('undo and redo preserve multiple selections', () => { + const d = doc('a\nb'); + const selectionBefore = [caret(0, 1), caret(1, 1)]; + const selectionAfter = [caret(0, 2), caret(1, 2)]; + d.applyEdits( + [ + { + range: { + start: { line: 1, character: 1 }, + end: { line: 1, character: 1 }, + }, + newText: '!', + }, + { + range: { + start: { line: 0, character: 1 }, + end: { line: 0, character: 1 }, + }, + newText: '!', + }, + ], + { selectionBefore } + ); + d.setLastUndoSelectionAfter(selectionAfter); + + expect(d.undo()).toEqual(selectionBefore); + expect(d.redo()).toEqual(selectionAfter); + }); +}); diff --git a/packages/diffs/test/textareaState.test.ts b/packages/diffs/test/textareaState.test.ts new file mode 100644 index 000000000..2dbcb95a7 --- /dev/null +++ b/packages/diffs/test/textareaState.test.ts @@ -0,0 +1,265 @@ +import { describe, expect, test } from 'bun:test'; + +import { + createSelection, + type ISelection, + SelectionDirection, + toWebSelectionDirection, +} from '../src/editor/selection'; +import { + createTextareaSnippet, + matchesTextareaState, + resolveTextareaTextChange, +} from '../src/editor/textareaState'; +import { TextDocument } from '../src/editor/textDocument'; + +type TextareaSnippetCase = { + name: string; + text: string; + selection: ISelection; + expected: { + firstLine: number; + lastLine: number; + text: string; + selectionStart: number; + selectionEnd: number; + }; +}; + +const textareaSnippetCases: TextareaSnippetCase[] = [ + { + name: 'includes only next context on the first line', + text: 'alpha\nbeta', + selection: createSelection(0, 0, 0, 0, SelectionDirection.None), + expected: { + firstLine: 0, + lastLine: 1, + text: 'alpha\nbeta', + selectionStart: 0, + selectionEnd: 0, + }, + }, + { + name: 'includes both surrounding context lines for a middle-line selection', + text: 'alpha\nbravo\ncharlie\ndelta', + selection: createSelection(1, 1, 1, 4, SelectionDirection.Forward), + expected: { + firstLine: 0, + lastLine: 2, + text: 'alpha\nbravo\ncharlie', + selectionStart: 7, + selectionEnd: 10, + }, + }, + { + name: 'clamps trailing context at the last line for multi-line selections', + text: 'alpha\nbravo\ncharlie', + selection: createSelection(1, 2, 2, 7, SelectionDirection.Forward), + expected: { + firstLine: 0, + lastLine: 2, + text: 'alpha\nbravo\ncharlie', + selectionStart: 8, + selectionEnd: 19, + }, + }, + { + name: 'preserves empty selected and context lines', + text: 'top\n\nbottom\n', + selection: createSelection(1, 0, 3, 0, SelectionDirection.Forward), + expected: { + firstLine: 0, + lastLine: 3, + text: 'top\n\nbottom\n', + selectionStart: 4, + selectionEnd: 12, + }, + }, + { + name: 'handles a single empty line selection', + text: 'a\n\nc', + selection: createSelection(1, 0, 1, 0, SelectionDirection.None), + expected: { + firstLine: 0, + lastLine: 2, + text: 'a\n\nc', + selectionStart: 2, + selectionEnd: 2, + }, + }, +]; + +function applyTextareaChange( + text: string, + selection: ReturnType, + value: string, + selectionStart: number, + selectionEnd = selectionStart, + documentValue = text +) { + const textDocument = new TextDocument('inmemory://1', text, 'plain'); + const snippet = createTextareaSnippet(textDocument, selection); + const change = resolveTextareaTextChange({ + documentValue, + originalValue: snippet.text, + value, + originalSelectionStart: snippet.selectionStart, + originalSelectionEnd: snippet.selectionEnd, + selectionStart, + selectionEnd, + }); + const snippetStartOffset = textDocument.offsetAt({ + line: snippet.firstLine, + character: 0, + }); + const start = textDocument.positionAt(snippetStartOffset + change.start); + const end = textDocument.positionAt(snippetStartOffset + change.end); + textDocument.applyEdits( + [ + { + range: { + start, + end, + }, + newText: change.text, + }, + ], + { selectionBefore: selection } + ); + return textDocument.getText(); +} + +describe('createTextareaSnippet', () => { + for (const { name, text, selection, expected } of textareaSnippetCases) { + test(name, () => { + const textDocument = new TextDocument('inmemory://1', text, 'plain'); + expect(createTextareaSnippet(textDocument, selection)).toEqual(expected); + }); + } +}); + +describe('resolveTextareaTextChange', () => { + test('inserts a newline before an existing empty line', () => { + const text = 'a\n\nb'; + const selection = createSelection(1, 0, 1, 0, SelectionDirection.None); + + expect(applyTextareaChange(text, selection, 'a\n\n\nb', 3)).toBe( + 'a\n\n\nb' + ); + }); + + test('deletes the nearest newline from consecutive empty lines', () => { + const text = 'a\n\n\nb'; + const selection = createSelection(2, 0, 2, 0, SelectionDirection.None); + + expect(applyTextareaChange(text, selection, '\nb', 0)).toBe('a\n\nb'); + }); + + test('keeps line indentation when inserting a newline', () => { + const text = ' foo'; + const selection = createSelection(0, 5, 0, 5, SelectionDirection.None); + + expect(applyTextareaChange(text, selection, ' foo\n', 6)).toBe( + ' foo\n ' + ); + }); + + test('backspace removes one document indent unit on a spaces-only line', () => { + const text = ' alpha\n \n beta'; + const selection = createSelection(1, 4, 1, 4, SelectionDirection.None); + + expect( + applyTextareaChange(text, selection, ' alpha\n \n beta', 11) + ).toBe(' alpha\n \n beta'); + }); + + test('backspace removes one document indent unit on a tabs-only line', () => { + const text = '\talpha\n\t\t\n\tbeta'; + const selection = createSelection(1, 2, 1, 2, SelectionDirection.None); + + expect(applyTextareaChange(text, selection, '\talpha\n\t\n\tbeta', 8)).toBe( + '\talpha\n\t\n\tbeta' + ); + }); + + test('backspace removes two spaces at a time from six-space line with wider nearby indent', () => { + const text = ' root\n alpha\n \n beta'; + const selection = createSelection(2, 6, 2, 6, SelectionDirection.None); + + expect( + applyTextareaChange( + text, + selection, + ' alpha\n \n beta', + 15, + 15, + ' root\n child\n leaf' + ) + ).toBe(' root\n alpha\n \n beta'); + }); + + test('backspace removes four spaces when document indent is four spaces', () => { + const text = ' alpha\n \n beta'; + const selection = createSelection(1, 8, 1, 8, SelectionDirection.None); + + expect( + applyTextareaChange(text, selection, ' alpha\n \n beta', 17) + ).toBe(' alpha\n \n beta'); + }); +}); + +describe('matchesTextareaState', () => { + test('matches the textarea state produced for a rendered selection', () => { + const textDocument = new TextDocument( + 'inmemory://1', + 'alpha\nbravo\ncharlie', + 'plain' + ); + const selection = createSelection(1, 1, 1, 4, SelectionDirection.Forward); + const snippet = createTextareaSnippet(textDocument, selection); + + expect( + matchesTextareaState( + { + selections: [selection], + selection, + snippet, + value: snippet.text, + }, + { + value: snippet.text, + selectionStart: snippet.selectionStart, + selectionEnd: snippet.selectionEnd, + selectionDirection: toWebSelectionDirection(selection.direction), + } + ) + ).toBe(true); + }); + + test('returns false once the user changes the textarea selection', () => { + const textDocument = new TextDocument( + 'inmemory://1', + 'alpha\nbravo\ncharlie', + 'plain' + ); + const selection = createSelection(1, 1, 1, 4, SelectionDirection.Forward); + const snippet = createTextareaSnippet(textDocument, selection); + + expect( + matchesTextareaState( + { + selections: [selection], + selection, + snippet, + value: snippet.text, + }, + { + value: snippet.text, + selectionStart: snippet.selectionStart + 1, + selectionEnd: snippet.selectionEnd + 1, + selectionDirection: toWebSelectionDirection(selection.direction), + } + ) + ).toBe(false); + }); +}); diff --git a/packages/diffs/test/visualColumns.test.ts b/packages/diffs/test/visualColumns.test.ts new file mode 100644 index 000000000..8396cc0ee --- /dev/null +++ b/packages/diffs/test/visualColumns.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from 'bun:test'; + +import { getVisualColumn } from '../src/editor/visualColumns'; + +describe('getVisualColumn', () => { + test('keeps plain text columns unchanged', () => { + expect(getVisualColumn('hello', 0, 2)).toBe(0); + expect(getVisualColumn('hello', 3, 2)).toBe(3); + expect(getVisualColumn('hello', 99, 2)).toBe(5); + }); + + test('expands tabs to the configured tab size', () => { + expect(getVisualColumn('\ta', 1, 2)).toBe(2); + expect(getVisualColumn('\ta', 1, 4)).toBe(4); + expect(getVisualColumn('\ta', 2, 2)).toBe(3); + }); + + test('aligns tab stops based on current visual column', () => { + expect(getVisualColumn('a\tb', 2, 2)).toBe(2); + expect(getVisualColumn('a\tb', 2, 4)).toBe(4); + expect(getVisualColumn('ab\tc', 3, 4)).toBe(4); + expect(getVisualColumn('abc\tz', 4, 4)).toBe(4); + }); +}); From 364e3b20827ab875a461771597cadb0e5dc2886e Mon Sep 17 00:00:00 2001 From: Je Xia Date: Thu, 23 Apr 2026 22:09:11 +0800 Subject: [PATCH 002/138] Fix paste --- packages/diffs/src/components/Editor.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/diffs/src/components/Editor.ts b/packages/diffs/src/components/Editor.ts index 1bb223a56..9aa54f41c 100644 --- a/packages/diffs/src/components/Editor.ts +++ b/packages/diffs/src/components/Editor.ts @@ -220,7 +220,9 @@ export class Editor { if (this.#isTextareaElFocused !== true) { const command = resolveEditorShortcutCommand(e); if (command !== undefined) { + e.preventDefault(); void this.#runShortcutCommand(command); + return; } } if ( From b915a8c5abe8c3c844aad19e70884263827cd567 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Thu, 23 Apr 2026 22:21:18 +0800 Subject: [PATCH 003/138] Fix character X --- packages/diffs/src/components/Editor.ts | 75 +++++++++++++++++++++++-- 1 file changed, 69 insertions(+), 6 deletions(-) diff --git a/packages/diffs/src/components/Editor.ts b/packages/diffs/src/components/Editor.ts index 9aa54f41c..43cd5e614 100644 --- a/packages/diffs/src/components/Editor.ts +++ b/packages/diffs/src/components/Editor.ts @@ -612,15 +612,14 @@ export class Editor { this.#tabSize ); const endColumn = getVisualColumn(lineText, endCharacter, this.#tabSize); + const startX = this.#getCharacterX(ln, startCharacter, startColumn); + const endX = this.#getCharacterX(ln, endCharacter, endColumn); const spacing = endCharacter === startCharacter || ln === end.line ? 0 : 4; const style = { top: this.#getLineY(ln) + 'px', - left: this.#gutterWidth + startColumn * this.#monoFontWidth + 'px', - width: - Math.max(endColumn - startColumn, 1) * this.#monoFontWidth + - spacing + - 'px', + left: startX + 'px', + width: Math.max(endX - startX, 1) + spacing + 'px', }; const selectionEl = createElement( 'div', @@ -642,13 +641,14 @@ export class Editor { const line = isBackward ? start.line : end.line; const character = isBackward ? start.character : end.character; const column = getVisualColumn(lineText, character, this.#tabSize); + const left = this.#getCharacterX(line, character, column); const cursorEl = createElement( 'div', { class: 'ī', style: { top: this.#getLineY(line) + 'px', - left: this.#gutterWidth + column * this.#monoFontWidth + 'px', + left: left + 'px', }, }, this.#editorEl @@ -827,6 +827,69 @@ export class Editor { return line * this.#lineHeightPx + this.#paddingY; } + #getCharacterX(line: number, character: number, visualColumn: number) { + const fallbackLeft = this.#gutterWidth + visualColumn * this.#monoFontWidth; + const lineEl = this.#textLineEls?.get(line); + const editorEl = this.#editorEl; + if (lineEl === undefined || editorEl === undefined) { + return fallbackLeft; + } + + let targetSpan: HTMLElement | undefined; + let targetOffset = 0; + let lastSpan: HTMLElement | undefined; + let lastEnd = 0; + const children = lineEl.children; + for (let i = 0; i < children.length; i++) { + const child = children[i]; + if (!(child instanceof HTMLElement) || child.tagName !== 'SPAN') { + continue; + } + // oxlint-disable-next-line typescript/no-explicit-any + const start = (child as any).CHAR as number | undefined; + if (start === undefined) { + continue; + } + const textLength = child.textContent?.length ?? 0; + const end = start + textLength; + if (character >= start && character <= end) { + targetSpan = child; + targetOffset = character - start; + break; + } + if (end >= lastEnd) { + lastSpan = child; + lastEnd = end; + } + } + + const range = document.createRange(); + if (targetSpan !== undefined) { + const textNode = targetSpan.firstChild; + if (textNode === null) { + return fallbackLeft; + } + const nodeLength = textNode.textContent?.length ?? 0; + const boundedOffset = Math.max(0, Math.min(targetOffset, nodeLength)); + range.setStart(textNode, boundedOffset); + range.setEnd(textNode, boundedOffset); + } else if (lastSpan !== undefined) { + const textNode = lastSpan.firstChild; + if (textNode === null) { + return fallbackLeft; + } + const nodeLength = textNode.textContent?.length ?? 0; + range.setStart(textNode, nodeLength); + range.setEnd(textNode, nodeLength); + } else { + return fallbackLeft; + } + + const editorRect = editorEl.getBoundingClientRect(); + const pointRect = range.getBoundingClientRect(); + return pointRect.left - editorRect.left; + } + #hasFocusWithinEditor() { const activeElement = document.activeElement; return ( From f05e492fc7052fbe2ec4d4e44ef452f9881e3d4e Mon Sep 17 00:00:00 2001 From: Je Xia Date: Thu, 23 Apr 2026 22:33:06 +0800 Subject: [PATCH 004/138] Add 'indent' and 'outdent' commands --- packages/diffs/src/components/Editor.ts | 138 ++++++++++++++++++- packages/diffs/src/editor/editorShortcuts.ts | 6 + packages/diffs/src/editor/selection.ts | 9 ++ packages/diffs/test/editorShortcuts.test.ts | 8 ++ 4 files changed, 158 insertions(+), 3 deletions(-) diff --git a/packages/diffs/src/components/Editor.ts b/packages/diffs/src/components/Editor.ts index 43cd5e614..1ad2573db 100644 --- a/packages/diffs/src/components/Editor.ts +++ b/packages/diffs/src/components/Editor.ts @@ -10,6 +10,7 @@ import { coalesceMicrotask, createElement, extend, + getLineIndentation, measureMonoFontWidth, } from '../editor/editorUtils'; import { @@ -19,7 +20,11 @@ import { mapSelectionTextReplace, } from '../editor/multiSelection'; import { normlizeEditorOptions } from '../editor/normlizeEditorOptions'; -import type { IEditorSelection, ISelection } from '../editor/selection'; +import type { + EditorSelectionTextChange, + IEditorSelection, + ISelection, +} from '../editor/selection'; import { cloneSelection, convertSelection, @@ -98,8 +103,8 @@ export class Editor { this.#monoFontWidth = measureMonoFontWidth( 'normal ' + this.#fontSize + 'px ' + this.#fontFamily ); - this.#lineNumberWidth = Math.round(2 * this.#monoFontWidth); - this.#gutterWidth = this.#lineNumberWidth; // currently the gutter width is equal to line number width + this.#lineNumberWidth = this.#monoFontWidth; + this.#gutterWidth = this.#lineNumberWidth; } get options(): EditorOptions { @@ -710,6 +715,10 @@ export class Editor { ); break; } + case 'indent': + case 'outdent': + this.#changePrimaryLineIndent(command === 'outdent'); + break; case 'copy': case 'cut': if ( @@ -804,6 +813,129 @@ export class Editor { void this.#renderText(textDocument, nextSelection); } + #changePrimaryLineIndent(outdent: boolean) { + const textDocument = this.#textDocument; + const selections = this.#selections; + const primarySelection = selections?.[selections.length - 1]; + if ( + textDocument === undefined || + selections === undefined || + primarySelection === undefined + ) { + return; + } + const line = primarySelection.start.line; + const lineText = textDocument.getLineText(line) ?? ''; + const lineStartOffset = textDocument.offsetAt({ line, character: 0 }); + const cursorOffset = textDocument.offsetAt(primarySelection.end); + + if (!outdent) { + const indentUnit = this.#resolveLineIndentUnit(line); + const insertOffset = isCollapsedSelection(primarySelection) + ? cursorOffset + : lineStartOffset; + this.#applySelectionTextChange(selections, { + start: insertOffset, + end: insertOffset, + text: indentUnit, + selectionStart: insertOffset + indentUnit.length, + selectionEnd: insertOffset + indentUnit.length, + direction: SelectionDirection.None, + }); + return; + } + + const indentation = getLineIndentation(lineText); + if (indentation.length === 0) { + return; + } + const cursorColumn = Math.max(0, cursorOffset - lineStartOffset); + if (cursorColumn === 0) { + return; + } + const indentUnit = this.#resolveLineIndentUnit(line); + const deleteLength = indentation.startsWith('\t') + ? 1 + : Math.max( + 1, + Math.min(indentUnit.length, indentation.length, cursorColumn) + ); + const deleteStart = cursorOffset - deleteLength; + const deleteSegment = lineText.slice( + Math.max(0, cursorColumn - deleteLength), + cursorColumn + ); + const expectedChar = indentation.startsWith('\t') ? '\t' : ' '; + if ([...deleteSegment].some((char) => char !== expectedChar)) { + return; + } + this.#applySelectionTextChange(selections, { + start: deleteStart, + end: cursorOffset, + text: '', + selectionStart: deleteStart, + selectionEnd: deleteStart, + direction: SelectionDirection.None, + }); + } + + #resolveLineIndentUnit(line: number): string { + const textDocument = this.#textDocument; + if (textDocument === undefined) { + return ' '.repeat(this.#tabSize); + } + const resolved = this.#resolveIndentUnitFromText( + getLineIndentation(textDocument.getLineText(line) ?? '') + ); + if (resolved !== undefined) { + return resolved; + } + for (let ln = line - 1; ln >= 0; ln--) { + const fromPrevious = this.#resolveIndentUnitFromText( + getLineIndentation(textDocument.getLineText(ln) ?? '') + ); + if (fromPrevious !== undefined) { + return fromPrevious; + } + } + return ' '.repeat(this.#tabSize); + } + + #resolveIndentUnitFromText(indentation: string): string | undefined { + if (indentation.startsWith('\t')) { + return '\t'; + } + if (indentation.startsWith(' ')) { + return ' '.repeat( + Math.max(1, Math.min(this.#tabSize, indentation.length)) + ); + } + return undefined; + } + + #applySelectionTextChange( + selections: ISelection[], + change: EditorSelectionTextChange + ) { + const textDocument = this.#textDocument; + const primarySelection = getPrimarySelection(selections); + if (textDocument === undefined || primarySelection === undefined) { + return; + } + const { edits, nextSelections } = mapSelectionTextChange( + textDocument, + selections, + change + ); + const nextSelection = + nextSelections.length === 1 ? nextSelections[0] : nextSelections; + textDocument.applyEdits(edits, { + selectionBefore: selections.length === 1 ? primarySelection : selections, + }); + textDocument.setLastUndoSelectionAfter(nextSelection); + void this.#renderText(textDocument, nextSelection); + } + #getDocumentBoundarySelection(atEnd: boolean) { const textDocument = this.#textDocument; if (textDocument === undefined) { diff --git a/packages/diffs/src/editor/editorShortcuts.ts b/packages/diffs/src/editor/editorShortcuts.ts index 5548879c5..4f57ddc73 100644 --- a/packages/diffs/src/editor/editorShortcuts.ts +++ b/packages/diffs/src/editor/editorShortcuts.ts @@ -2,6 +2,8 @@ export type EditorShortcutCommand = | 'copy' | 'cut' | 'paste' + | 'indent' + | 'outdent' | 'documentStart' | 'documentEnd' | 'undo' @@ -36,6 +38,10 @@ export function resolveEditorShortcutCommand( const hasPrimaryModifier = getPrimaryModifier(event); const isMac = isMacLike(); + if (!hasPrimaryModifier && key === 'Tab') { + return event.shiftKey ? 'outdent' : 'indent'; + } + if (!hasPrimaryModifier) { return undefined; } diff --git a/packages/diffs/src/editor/selection.ts b/packages/diffs/src/editor/selection.ts index 7943357fb..0f02b3857 100644 --- a/packages/diffs/src/editor/selection.ts +++ b/packages/diffs/src/editor/selection.ts @@ -14,6 +14,15 @@ export type ISelections = ISelection[]; export type IEditorSelection = ISelection | ISelections; +export type EditorSelectionTextChange = { + start: number; + end: number; + text: string; + selectionStart: number; + selectionEnd: number; + direction: SelectionDirection; +}; + export function createSelection( startLine: number, startCharacter: number, diff --git a/packages/diffs/test/editorShortcuts.test.ts b/packages/diffs/test/editorShortcuts.test.ts index f4a1a65b0..c285497a5 100644 --- a/packages/diffs/test/editorShortcuts.test.ts +++ b/packages/diffs/test/editorShortcuts.test.ts @@ -89,4 +89,12 @@ describe('resolveEditorShortcutCommand', () => { { event: { key: 'z', ctrlKey: true, altKey: true }, expected: undefined }, ]); }); + + test('maps tab and shift+tab without primary modifier', () => { + expectShortcuts('Linux x86_64', [ + { event: { key: 'Tab' }, expected: 'indent' }, + { event: { key: 'Tab', shiftKey: true }, expected: 'outdent' }, + { event: { key: 'Tab', ctrlKey: true }, expected: undefined }, + ]); + }); }); From b1edaeefa627b7df9c481b5c4ae2dacbbe6d4851 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Fri, 24 Apr 2026 00:20:40 +0800 Subject: [PATCH 005/138] Add typing buffer --- packages/diffs/src/components/Editor.ts | 156 +++++++++++++++++++--- packages/diffs/src/editor/editHistory.ts | 8 -- packages/diffs/src/editor/textDocument.ts | 23 +--- packages/diffs/test/editHistory.test.ts | 55 ++------ packages/diffs/test/textDocument.test.ts | 94 +++++++------ packages/diffs/test/textareaState.test.ts | 2 +- 6 files changed, 211 insertions(+), 127 deletions(-) diff --git a/packages/diffs/src/components/Editor.ts b/packages/diffs/src/components/Editor.ts index 1ad2573db..f6a65b85d 100644 --- a/packages/diffs/src/components/Editor.ts +++ b/packages/diffs/src/components/Editor.ts @@ -85,6 +85,13 @@ export class Editor { #isEditorElFocused?: boolean; #isTextareaElFocused?: boolean; #textareaState?: TextareaState; + #pendingTextareaSnapshot?: { + value: string; + selectionStart: number; + selectionEnd: number; + selectionDirection: HTMLTextAreaElement['selectionDirection']; + }; + #typingFlushTimeout?: number; #selections?: ISelection[]; #reservedSelections?: ISelection[]; #languageLoadRequestId = 0; @@ -217,6 +224,7 @@ export class Editor { }), addEventListener(this.#textareaEl, 'blur', () => { this.#isTextareaElFocused = false; + this.#flushPendingTextareaChanges(); }), addEventListener(document, 'keydown', (e) => { if (!this.#hasFocusWithinEditor()) { @@ -225,6 +233,7 @@ export class Editor { if (this.#isTextareaElFocused !== true) { const command = resolveEditorShortcutCommand(e); if (command !== undefined) { + this.#flushPendingTextareaChanges(); e.preventDefault(); void this.#runShortcutCommand(command); return; @@ -244,6 +253,7 @@ export class Editor { addEventListener(this.#textareaEl, 'keydown', (e) => { const command = resolveEditorShortcutCommand(e); if (command !== undefined) { + this.#flushPendingTextareaChanges(); e.preventDefault(); void this.#runShortcutCommand(command); } @@ -271,6 +281,7 @@ export class Editor { } public cleanUp(): void { + this.#clearTypingFlushTimeout(); this.#textLineEls?.clear(); this.#selectionEls?.clear(); this.#disposes?.forEach((dispose) => dispose()); @@ -285,6 +296,7 @@ export class Editor { this.#isEditorElFocused = false; this.#isTextareaElFocused = false; this.#textareaState = undefined; + this.#pendingTextareaSnapshot = undefined; this.#reservedSelections = undefined; } @@ -348,7 +360,7 @@ export class Editor { ? `background-color:${lineHighlightBackground}` : `border:2px solid ${selectionBackground}`) + ';pointer-events:none}') + - ('.ť{position:absolute;z-index:-10;width:100%;padding:0;' + + ('.ť{position:absolute;z-index:-20;width:100%;padding:0;' + `line-height:${lineHeightPx}px;` + 'font:inherit;background-color:transparent;color:transparent;opacity:0;border:none;outline:none;resize:none}') + `.ń{display:inline-block;text-align:right;width:var(--line-number-width);padding:0 ${this.#monoFontWidth}px;box-sizing:border-box;color:${lineNumberForeground};user-select:none;pointer-events:none;cursor:default}` + @@ -496,8 +508,9 @@ export class Editor { snippet: textareaSnippet, value: originalValue, } = textareaState; + const pendingSnapshot = this.#pendingTextareaSnapshot; const { selectionStart, selectionEnd, selectionDirection, value } = - textareaEl; + pendingSnapshot ?? textareaEl; const snippetStartOffset = textDocument.offsetAt({ line: textareaSnippet.firstLine, character: 0, @@ -532,12 +545,28 @@ export class Editor { ); const nextSelection = nextSelections.length === 1 ? nextSelections[0] : nextSelections; - textDocument.applyEdits(edits, { - selectionBefore: - selectionsBefore.length === 1 ? selectionBefore : selectionsBefore, - }); - textDocument.setLastUndoSelectionAfter(nextSelection); - void this.#renderText(textDocument, nextSelection); + const isBufferedTypingChange = + pendingSnapshot === undefined && + selectionsBefore.length === 1 && + isCollapsedSelection(selectionBefore) && + nextSelections.length === 1 && + isCollapsedSelection(nextSelections[0]) && + selectionStart === selectionEnd; + if (isBufferedTypingChange) { + this.#pendingTextareaSnapshot = { + value, + selectionStart, + selectionEnd, + selectionDirection, + }; + this.#scheduleTypingFlush(); + return; + } + this.#applyResolvedTextareaChange( + edits, + selectionsBefore.length === 1 ? selectionBefore : selectionsBefore, + nextSelection + ); // if (newChangedText.trim() && nextSelections.length === 1 && isCollapsedSelection(nextSelections[0]!)) { // this.#langs.get(textDocument.languageId)?.lspDriver?.doComplete(textDocument, nextSelections[0]!.end); // } @@ -559,6 +588,96 @@ export class Editor { } } + #scheduleTypingFlush() { + this.#clearTypingFlushTimeout(); + this.#typingFlushTimeout = window.setTimeout(() => { + this.#typingFlushTimeout = undefined; + this.#flushPendingTextareaChanges(); + }, 300); + } + + #clearTypingFlushTimeout() { + if (this.#typingFlushTimeout !== undefined) { + window.clearTimeout(this.#typingFlushTimeout); + this.#typingFlushTimeout = undefined; + } + } + + #flushPendingTextareaChanges() { + const textDocument = this.#textDocument; + const textareaState = this.#textareaState; + const pendingSnapshot = this.#pendingTextareaSnapshot; + if ( + textDocument === undefined || + textareaState === undefined || + pendingSnapshot === undefined + ) { + return; + } + this.#clearTypingFlushTimeout(); + const { + selections: selectionsBefore, + selection: selectionBefore, + snippet: textareaSnippet, + value: originalValue, + } = textareaState; + const { value, selectionStart, selectionEnd, selectionDirection } = + pendingSnapshot; + const snippetStartOffset = textDocument.offsetAt({ + line: textareaSnippet.firstLine, + character: 0, + }); + const { + start: oldChangedStart, + end: oldChangedEnd, + text: newChangedText, + selectionStart: nextSelectionStart, + selectionEnd: nextSelectionEnd, + } = resolveTextareaTextChange({ + documentValue: textDocument.getText(), + originalValue, + value, + originalSelectionStart: textareaSnippet.selectionStart, + originalSelectionEnd: textareaSnippet.selectionEnd, + selectionStart, + selectionEnd, + }); + const { edits, nextSelections } = mapSelectionTextChange( + textDocument, + selectionsBefore, + { + start: snippetStartOffset + oldChangedStart, + end: snippetStartOffset + oldChangedEnd, + text: newChangedText, + selectionStart: snippetStartOffset + nextSelectionStart, + selectionEnd: snippetStartOffset + nextSelectionEnd, + direction: fromWebSelectionDirection(selectionDirection), + } + ); + const nextSelection = + nextSelections.length === 1 ? nextSelections[0] : nextSelections; + this.#pendingTextareaSnapshot = undefined; + this.#applyResolvedTextareaChange( + edits, + selectionsBefore.length === 1 ? selectionBefore : selectionsBefore, + nextSelection + ); + } + + #applyResolvedTextareaChange( + edits: Parameters[0], + selectionBefore: IEditorSelection, + nextSelection: IEditorSelection + ) { + const textDocument = this.#textDocument; + if (textDocument === undefined) { + return; + } + textDocument.applyEdits(edits, selectionBefore); + textDocument.setLastUndoSelectionAfter(nextSelection); + void this.#renderText(textDocument, nextSelection); + } + #restoreSelection(selection: IEditorSelection) { const selections = normalizeSelections(toSelectionArray(selection)); const primarySelection = getPrimarySelection(selections); @@ -688,6 +807,7 @@ export class Editor { snippet: textareaSnippet, value: textareaSnippet.text, }; + this.#pendingTextareaSnapshot = undefined; textareaEl.value = textareaSnippet.text; textareaEl.setSelectionRange( textareaSnippet.selectionStart, @@ -806,9 +926,10 @@ export class Editor { }); const nextSelection = nextSelections.length === 1 ? nextSelections[0] : nextSelections; - textDocument.applyEdits(edits, { - selectionBefore: selections.length === 1 ? selection : selections, - }); + textDocument.applyEdits( + edits, + selections.length === 1 ? selection : selections + ); textDocument.setLastUndoSelectionAfter(nextSelection); void this.#renderText(textDocument, nextSelection); } @@ -866,8 +987,10 @@ export class Editor { cursorColumn ); const expectedChar = indentation.startsWith('\t') ? '\t' : ' '; - if ([...deleteSegment].some((char) => char !== expectedChar)) { - return; + for (const char of deleteSegment) { + if (char !== expectedChar) { + return; + } } this.#applySelectionTextChange(selections, { start: deleteStart, @@ -929,9 +1052,10 @@ export class Editor { ); const nextSelection = nextSelections.length === 1 ? nextSelections[0] : nextSelections; - textDocument.applyEdits(edits, { - selectionBefore: selections.length === 1 ? primarySelection : selections, - }); + textDocument.applyEdits( + edits, + selections.length === 1 ? primarySelection : selections + ); textDocument.setLastUndoSelectionAfter(nextSelection); void this.#renderText(textDocument, nextSelection); } diff --git a/packages/diffs/src/editor/editHistory.ts b/packages/diffs/src/editor/editHistory.ts index 05b0b7fb6..a1dc405a5 100644 --- a/packages/diffs/src/editor/editHistory.ts +++ b/packages/diffs/src/editor/editHistory.ts @@ -285,7 +285,6 @@ export class EditHistory { textBefore: string, resolvedEdits: ResolvedEdit[], selectionBefore: IEditorSelection, - selectionAfter?: IEditorSelection, coalesceWithinMs?: number ): void { const timestampMs = Date.now(); @@ -318,9 +317,6 @@ export class EditHistory { ); lastEntry.textLengthAfter = textLengthAfter; lastEntry.timestampMs = timestampMs; - if (selectionAfter !== undefined) { - lastEntry.selectionAfter = cloneEditorSelection(selectionAfter); - } return; } this.#undo.push({ @@ -329,10 +325,6 @@ export class EditHistory { textLengthBefore, textLengthAfter, selectionBefore: cloneEditorSelection(selectionBefore), - selectionAfter: - selectionAfter !== undefined - ? cloneEditorSelection(selectionAfter) - : undefined, timestampMs, }); this.#redo.length = 0; diff --git a/packages/diffs/src/editor/textDocument.ts b/packages/diffs/src/editor/textDocument.ts index 51b0d039f..4fe7d5ec9 100644 --- a/packages/diffs/src/editor/textDocument.ts +++ b/packages/diffs/src/editor/textDocument.ts @@ -157,28 +157,15 @@ export class TextDocument { this.#setDocumentText(text); } - applyEdits( - edits: TextEdit[], - updateHistory?: { - selectionBefore: IEditorSelection; - coalesceWithinMs?: number; - } - ): void { + applyEdits(edits: TextEdit[], selectionBefore?: IEditorSelection): void { if (edits.length === 0) { return; } const resolvedEdits = this.#resolveEdits(edits); - const T0 = this.#text; - const newText = applyOffsetEdits(T0, resolvedEdits); - if (updateHistory !== undefined) { - const { selectionBefore, coalesceWithinMs = 500 } = updateHistory; - this.#history.push( - T0, - resolvedEdits, - selectionBefore, - undefined, - coalesceWithinMs - ); + const textBefore = this.#text; + const newText = applyOffsetEdits(textBefore, resolvedEdits); + if (selectionBefore !== undefined) { + this.#history.push(textBefore, resolvedEdits, selectionBefore, 500); } this.#setDocumentText(newText); } diff --git a/packages/diffs/test/editHistory.test.ts b/packages/diffs/test/editHistory.test.ts index cef4226e8..9367d0ed9 100644 --- a/packages/diffs/test/editHistory.test.ts +++ b/packages/diffs/test/editHistory.test.ts @@ -67,13 +67,8 @@ describe('EditHistory', () => { const selectionBefore = [caret(0), caret(1)]; const selectionAfter = [caret(2), caret(3)]; - history.push( - 'ab', - [{ start: 1, end: 1, text: 'X' }], - selectionBefore, - selectionAfter, - -1 - ); + history.push('ab', [{ start: 1, end: 1, text: 'X' }], selectionBefore, -1); + history.setLastUndoSelectionAfter(selectionAfter); selectionBefore[0].start.character = 99; selectionBefore[0].end.character = 99; @@ -106,13 +101,7 @@ describe('EditHistory', () => { const history = new EditHistory(); const selectionAfter = caret(2); - history.push( - 'a', - [{ start: 1, end: 1, text: 'b' }], - caret(1), - undefined, - -1 - ); + history.push('a', [{ start: 1, end: 1, text: 'b' }], caret(1), -1); history.setLastUndoSelectionAfter(selectionAfter); selectionAfter.start.character = 99; selectionAfter.end.character = 99; @@ -133,21 +122,11 @@ describe('EditHistory', () => { }); try { - history.push( - '', - [{ start: 0, end: 0, text: 'a' }], - caret(0), - caret(1), - 1000 - ); + history.push('', [{ start: 0, end: 0, text: 'a' }], caret(0), 1000); + history.setLastUndoSelectionAfter(caret(1)); now += 400; - history.push( - 'a', - [{ start: 1, end: 1, text: 'b' }], - caret(1), - caret(2), - 1000 - ); + history.push('a', [{ start: 1, end: 1, text: 'b' }], caret(1), 1000); + history.setLastUndoSelectionAfter(caret(2)); const entry = history.popUndoToRedo(); @@ -172,27 +151,15 @@ describe('EditHistory', () => { test('push clears redo history when recording a new undo entry', () => { const history = new EditHistory(); - history.push('', [{ start: 0, end: 0, text: 'a' }], caret(0), caret(1), -1); - history.push( - 'a', - [{ start: 1, end: 1, text: 'b' }], - caret(1), - caret(2), - -1 - ); + history.push('', [{ start: 0, end: 0, text: 'a' }], caret(0), -1); + history.push('a', [{ start: 1, end: 1, text: 'b' }], caret(1), -1); expect(history.popUndoToRedo()).toMatchObject({ forwardEdits: [{ start: 1, end: 1, text: 'b' }], }); expect(history.canRedo).toBe(true); - history.push( - 'a', - [{ start: 1, end: 1, text: 'c' }], - caret(1), - caret(2), - -1 - ); + history.push('a', [{ start: 1, end: 1, text: 'c' }], caret(1), -1); expect(history.canRedo).toBe(false); expect(history.popUndoToRedo()).toMatchObject({ @@ -206,7 +173,7 @@ describe('EditHistory', () => { test('clear resets both undo and redo stacks', () => { const history = new EditHistory(); - history.push('', [{ start: 0, end: 0, text: 'a' }], caret(0), caret(1), -1); + history.push('', [{ start: 0, end: 0, text: 'a' }], caret(0), -1); history.popUndoToRedo(); history.clear(); diff --git a/packages/diffs/test/textDocument.test.ts b/packages/diffs/test/textDocument.test.ts index a1b8aa205..8e4b614a5 100644 --- a/packages/diffs/test/textDocument.test.ts +++ b/packages/diffs/test/textDocument.test.ts @@ -140,7 +140,7 @@ describe('TextDocument', () => { newText: 'AA', }, ], - { selectionBefore: caret(0, 0) } + caret(0, 0) ); d.undo(); expect(d.getText()).toBe('aa bb cc'); @@ -158,7 +158,7 @@ describe('TextDocument', () => { newText: 'two', }, ], - { selectionBefore: caret(1, 0) } + caret(1, 0) ); expect(d.getText()).toBe('line1\ntwo\nline3'); d.undo(); @@ -167,34 +167,48 @@ describe('TextDocument', () => { test('undo stack depth for sequential edits', () => { const d = doc(''); - d.applyEdits( - [ - { - range: { - start: { line: 0, character: 0 }, - end: { line: 0, character: 0 }, + const originalNow = Date.now; + let now = 1000; + Object.defineProperty(Date, 'now', { + configurable: true, + value: () => now, + }); + try { + d.applyEdits( + [ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 0 }, + }, + newText: 'a', }, - newText: 'a', - }, - ], - { selectionBefore: caret(0, 0), coalesceWithinMs: -1 } - ); - d.applyEdits( - [ - { - range: { - start: { line: 0, character: 1 }, - end: { line: 0, character: 1 }, + ], + caret(0, 0) + ); + now += 600; + d.applyEdits( + [ + { + range: { + start: { line: 0, character: 1 }, + end: { line: 0, character: 1 }, + }, + newText: 'b', }, - newText: 'b', - }, - ], - { selectionBefore: caret(0, 1), coalesceWithinMs: -1 } - ); - d.undo(); - expect(d.getText()).toBe('a'); - d.undo(); - expect(d.getText()).toBe(''); + ], + caret(0, 1) + ); + d.undo(); + expect(d.getText()).toBe('a'); + d.undo(); + expect(d.getText()).toBe(''); + } finally { + Object.defineProperty(Date, 'now', { + configurable: true, + value: originalNow, + }); + } }); test('sequential edits within coalesce window undo as one entry', () => { @@ -216,7 +230,7 @@ describe('TextDocument', () => { newText: 'a', }, ], - { selectionBefore: caret(0, 0), coalesceWithinMs: 1000 } + caret(0, 0) ); now += 400; d.applyEdits( @@ -229,7 +243,7 @@ describe('TextDocument', () => { newText: 'b', }, ], - { selectionBefore: caret(0, 1), coalesceWithinMs: 1000 } + caret(0, 1) ); expect(d.getText()).toBe('ab'); d.undo(); @@ -265,7 +279,7 @@ describe('TextDocument', () => { newText: 'ab', }, ], - { selectionBefore: caret(0, 0), coalesceWithinMs: 1000 } + caret(0, 0) ); now += 400; d.applyEdits( @@ -278,7 +292,7 @@ describe('TextDocument', () => { newText: 'c', }, ], - { selectionBefore: caret(0, 1), coalesceWithinMs: 1000 } + caret(0, 1) ); expect(d.getText()).toBe('ac'); d.undo(); @@ -312,7 +326,7 @@ describe('TextDocument', () => { newText: 'a', }, ], - { selectionBefore: caret(0, 0), coalesceWithinMs: 1000 } + caret(0, 0) ); now += 1200; d.applyEdits( @@ -325,7 +339,7 @@ describe('TextDocument', () => { newText: 'b', }, ], - { selectionBefore: caret(0, 1), coalesceWithinMs: 1000 } + caret(0, 1) ); d.undo(); expect(d.getText()).toBe('a'); @@ -395,7 +409,7 @@ describe('TextDocument', () => { newText: 'b', }, ], - { selectionBefore: caret(0, 1) } + caret(0, 1) ); expect(d.getText()).toBe('ab'); expect(d.canUndo).toBe(true); @@ -424,7 +438,7 @@ describe('TextDocument', () => { newText: 'b', }, ], - { selectionBefore: caret(0, 1) } + caret(0, 1) ); d.undo(); d.applyEdits( @@ -437,7 +451,7 @@ describe('TextDocument', () => { newText: 'c', }, ], - { selectionBefore: caret(0, 1) } + caret(0, 1) ); expect(d.getText()).toBe('ac'); expect(d.canRedo).toBe(false); @@ -455,7 +469,7 @@ describe('TextDocument', () => { newText: 'b', }, ], - { selectionBefore: caret(0, 1) } + caret(0, 1) ); expect(d.canUndo).toBe(true); d.setText('fresh'); @@ -488,7 +502,7 @@ describe('TextDocument', () => { newText: 'x', }, ], - { selectionBefore } + selectionBefore ); d.setLastUndoSelectionAfter(selectionAfter); @@ -517,7 +531,7 @@ describe('TextDocument', () => { newText: '!', }, ], - { selectionBefore } + selectionBefore ); d.setLastUndoSelectionAfter(selectionAfter); diff --git a/packages/diffs/test/textareaState.test.ts b/packages/diffs/test/textareaState.test.ts index 2dbcb95a7..9b5fca270 100644 --- a/packages/diffs/test/textareaState.test.ts +++ b/packages/diffs/test/textareaState.test.ts @@ -124,7 +124,7 @@ function applyTextareaChange( newText: change.text, }, ], - { selectionBefore: selection } + selection ); return textDocument.getText(); } From 3779a55ac31222e51256b05c33107743d55dcd80 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Fri, 24 Apr 2026 01:00:38 +0800 Subject: [PATCH 006/138] Type alias migration from `ISelection`/`IEditorSelection` to `EditorSelection` --- packages/diffs/src/components/Editor.ts | 37 +++++++++++------- packages/diffs/src/editor/editHistory.ts | 12 +++--- packages/diffs/src/editor/multiSelection.ts | 21 +++++----- packages/diffs/src/editor/selection.ts | 43 +++++++++++---------- packages/diffs/src/editor/textDocument.ts | 15 ++++--- packages/diffs/src/editor/textareaState.ts | 18 ++++----- packages/diffs/test/textareaState.test.ts | 4 +- 7 files changed, 80 insertions(+), 70 deletions(-) diff --git a/packages/diffs/src/components/Editor.ts b/packages/diffs/src/components/Editor.ts index f6a65b85d..ff76427f4 100644 --- a/packages/diffs/src/components/Editor.ts +++ b/packages/diffs/src/components/Editor.ts @@ -21,9 +21,8 @@ import { } from '../editor/multiSelection'; import { normlizeEditorOptions } from '../editor/normlizeEditorOptions'; import type { + EditorSelection, EditorSelectionTextChange, - IEditorSelection, - ISelection, } from '../editor/selection'; import { cloneSelection, @@ -92,8 +91,8 @@ export class Editor { selectionDirection: HTMLTextAreaElement['selectionDirection']; }; #typingFlushTimeout?: number; - #selections?: ISelection[]; - #reservedSelections?: ISelection[]; + #selections?: EditorSelection[]; + #reservedSelections?: EditorSelection[]; #languageLoadRequestId = 0; #disposes?: (() => void)[]; @@ -382,7 +381,10 @@ export class Editor { ); } - #renderText(textDocument: TextDocument, selection?: IEditorSelection): void { + #renderText( + textDocument: TextDocument, + selection?: EditorSelection | EditorSelection[] + ): void { const totalLines = textDocument.lineCount; const languageId = textDocument.languageId; @@ -666,8 +668,8 @@ export class Editor { #applyResolvedTextareaChange( edits: Parameters[0], - selectionBefore: IEditorSelection, - nextSelection: IEditorSelection + selectionBefore: EditorSelection | EditorSelection[], + nextSelection: EditorSelection | EditorSelection[] ) { const textDocument = this.#textDocument; if (textDocument === undefined) { @@ -678,8 +680,10 @@ export class Editor { void this.#renderText(textDocument, nextSelection); } - #restoreSelection(selection: IEditorSelection) { - const selections = normalizeSelections(toSelectionArray(selection)); + #restoreSelection(selection: EditorSelection | EditorSelection[]) { + const selections = normalizeSelections( + Array.isArray(selection) ? selection : toSelectionArray(selection) + ); const primarySelection = getPrimarySelection(selections); if (primarySelection === undefined) { return; @@ -703,7 +707,7 @@ export class Editor { } #renderHighlightLine( - selection: ISelection, + selection: EditorSelection, selectionEls: Map ) { const hlEl = createElement( @@ -721,7 +725,7 @@ export class Editor { } #renderSelections( - selection: ISelection, + selection: EditorSelection, selectionEls: Map ) { const { start, end } = selection; @@ -757,7 +761,10 @@ export class Editor { } } - #renderCursor(selection: ISelection, selectionEls: Map) { + #renderCursor( + selection: EditorSelection, + selectionEls: Map + ) { const { start, end, direction } = selection; const isBackward = direction === SelectionDirection.Backward; const lineText = @@ -783,7 +790,7 @@ export class Editor { ); } - #setActiveLine(selection: ISelection) { + #setActiveLine(selection: EditorSelection) { this.#activeLineEl?.classList.remove('ǎ'); const activeLine = selection.direction === SelectionDirection.Backward @@ -794,7 +801,7 @@ export class Editor { this.#activeLineEl = activeLineEl; } - #resetTextarea(selection: ISelection, selections: ISelection[]) { + #resetTextarea(selection: EditorSelection, selections: EditorSelection[]) { const textDocument = this.#textDocument; const textareaEl = this.#textareaEl; if (textDocument === undefined || textareaEl === undefined) { @@ -1037,7 +1044,7 @@ export class Editor { } #applySelectionTextChange( - selections: ISelection[], + selections: EditorSelection[], change: EditorSelectionTextChange ) { const textDocument = this.#textDocument; diff --git a/packages/diffs/src/editor/editHistory.ts b/packages/diffs/src/editor/editHistory.ts index a1dc405a5..00ac8c916 100644 --- a/packages/diffs/src/editor/editHistory.ts +++ b/packages/diffs/src/editor/editHistory.ts @@ -1,4 +1,4 @@ -import { cloneEditorSelection, type IEditorSelection } from './selection'; +import { cloneEditorSelection, type EditorSelection } from './selection'; export type ResolvedEdit = { start: number; end: number; text: string }; @@ -12,9 +12,9 @@ export type HistoryEntry = { /** Final text length after the entry is applied. */ textLengthAfter: number; /** Selection before the transaction (restored on undo). */ - selectionBefore: IEditorSelection; + selectionBefore: EditorSelection | EditorSelection[]; /** Selection after the transaction (restored on redo). */ - selectionAfter?: IEditorSelection; + selectionAfter?: EditorSelection | EditorSelection[]; /** Timestamp in ms used to coalesce adjacent edits. */ timestampMs: number; }; @@ -284,7 +284,7 @@ export class EditHistory { push( textBefore: string, resolvedEdits: ResolvedEdit[], - selectionBefore: IEditorSelection, + selectionBefore: EditorSelection | EditorSelection[], coalesceWithinMs?: number ): void { const timestampMs = Date.now(); @@ -330,7 +330,9 @@ export class EditHistory { this.#redo.length = 0; } - setLastUndoSelectionAfter(selection: IEditorSelection): void { + setLastUndoSelectionAfter( + selection: EditorSelection | EditorSelection[] + ): void { const lastEntry = this.#undo[this.#undo.length - 1]; if (lastEntry !== undefined) { lastEntry.selectionAfter = cloneEditorSelection(selection); diff --git a/packages/diffs/src/editor/multiSelection.ts b/packages/diffs/src/editor/multiSelection.ts index 3e24bc849..c079988fe 100644 --- a/packages/diffs/src/editor/multiSelection.ts +++ b/packages/diffs/src/editor/multiSelection.ts @@ -2,8 +2,7 @@ import { applyOffsetEdits } from './editHistory'; import { comparePosition, createSelection, - type ISelection, - type ISelections, + type EditorSelection, normalizeSelections, SelectionDirection, } from './selection'; @@ -11,7 +10,7 @@ import { TextDocument, type TextEdit } from './textDocument'; type SelectionEditMapping = { edits: TextEdit[]; - nextSelections: ISelections; + nextSelections: EditorSelection[]; }; type SelectionTextChange = { @@ -25,7 +24,7 @@ type SelectionTextChange = { export function mapSelectionTextChange( textDocument: TextDocument, - selections: readonly ISelection[], + selections: readonly EditorSelection[], change: SelectionTextChange ): SelectionEditMapping { const primarySelection = selections[selections.length - 1]; @@ -135,9 +134,9 @@ export function mapSelectionTextChange( export function mapSelectionRangeChange( textDocument: TextDocument, - selections: readonly ISelection[], - nextPrimarySelection: ISelection -): ISelections { + selections: readonly EditorSelection[], + nextPrimarySelection: EditorSelection +): EditorSelection[] { const primarySelection = selections[selections.length - 1]; if (primarySelection === undefined) { return []; @@ -180,7 +179,7 @@ export function mapSelectionRangeChange( export function mapSelectionTextReplace( textDocument: TextDocument, - selections: readonly ISelection[], + selections: readonly EditorSelection[], texts: readonly string[] ): SelectionEditMapping { if (selections.length !== texts.length) { @@ -245,7 +244,7 @@ export function mapSelectionTextReplace( export function getOrderedSelectionText( textDocument: TextDocument, - selections: readonly ISelection[] + selections: readonly EditorSelection[] ): string[] { return [...selections] .sort((a, b) => { @@ -278,7 +277,7 @@ function createTextDocumentAfterEdits( function getSelectionAnchorOffset( textDocument: TextDocument, - selection: ISelection + selection: EditorSelection ) { return selection.direction === SelectionDirection.Backward ? textDocument.offsetAt(selection.end) @@ -287,7 +286,7 @@ function getSelectionAnchorOffset( function getSelectionFocusOffset( textDocument: TextDocument, - selection: ISelection + selection: EditorSelection ) { return selection.direction === SelectionDirection.Backward ? textDocument.offsetAt(selection.start) diff --git a/packages/diffs/src/editor/selection.ts b/packages/diffs/src/editor/selection.ts index 0f02b3857..e2023bbe5 100644 --- a/packages/diffs/src/editor/selection.ts +++ b/packages/diffs/src/editor/selection.ts @@ -6,14 +6,10 @@ export enum SelectionDirection { Forward = 1, } -export type ISelection = Range & { +export type EditorSelection = Range & { direction: SelectionDirection; }; -export type ISelections = ISelection[]; - -export type IEditorSelection = ISelection | ISelections; - export type EditorSelectionTextChange = { start: number; end: number; @@ -29,7 +25,7 @@ export function createSelection( endLine: number, endCharacter: number, direction: SelectionDirection = SelectionDirection.None -): ISelection { +): EditorSelection { return { start: { line: startLine, character: startCharacter }, end: { line: endLine, character: endCharacter }, @@ -37,13 +33,18 @@ export function createSelection( }; } +/** + * Converts a selection from a web selection to an editor selection. + * @param selection - The web selection to convert. + * @returns The converted editor selection. + */ export function convertSelection({ rangeCount, anchorNode, focusNode, anchorOffset, focusOffset, -}: Selection): ISelection | null { +}: Selection): EditorSelection | null { if (rangeCount === 0 || anchorNode === null || focusNode === null) { return null; } @@ -68,14 +69,14 @@ export function convertSelection({ }; } -export function isCollapsedSelection(selection: ISelection): boolean { +export function isCollapsedSelection(selection: EditorSelection): boolean { return ( selection.start.line === selection.end.line && selection.start.character === selection.end.character ); } -export function cloneSelection(selection: ISelection): ISelection { +export function cloneSelection(selection: EditorSelection): EditorSelection { return { start: { ...selection.start }, end: { ...selection.end }, @@ -83,17 +84,17 @@ export function cloneSelection(selection: ISelection): ISelection { }; } -export function cloneEditorSelection( - selection: IEditorSelection -): IEditorSelection { +export function cloneEditorSelection< + T extends EditorSelection | EditorSelection[], +>(selection: T): T { return Array.isArray(selection) - ? selection.map(cloneSelection) - : cloneSelection(selection); + ? (selection.map(cloneSelection) as T) + : (cloneSelection(selection) as T); } export function toSelectionArray( - selection: IEditorSelection | undefined -): ISelections { + selection: EditorSelection | undefined +): EditorSelection[] { if (selection === undefined) { return []; } @@ -103,15 +104,15 @@ export function toSelectionArray( } export function getPrimarySelection( - selections: readonly ISelection[] -): ISelection | undefined { + selections: readonly EditorSelection[] +): EditorSelection | undefined { const selection = selections[selections.length - 1]; return selection !== undefined ? cloneSelection(selection) : undefined; } export function normalizeSelections( - selections: readonly ISelection[] -): ISelections { + selections: readonly EditorSelection[] +): EditorSelection[] { if (selections.length === 0) { return []; } @@ -133,7 +134,7 @@ export function normalizeSelections( } return a.index - b.index; }); - const merged: Array<{ selection: ISelection; isPrimary: boolean }> = []; + const merged: Array<{ selection: EditorSelection; isPrimary: boolean }> = []; for (const entry of ordered) { const current = merged[merged.length - 1]; if ( diff --git a/packages/diffs/src/editor/textDocument.ts b/packages/diffs/src/editor/textDocument.ts index 4fe7d5ec9..6d1dfc1c1 100644 --- a/packages/diffs/src/editor/textDocument.ts +++ b/packages/diffs/src/editor/textDocument.ts @@ -3,7 +3,7 @@ import { EditHistory, type ResolvedEdit, } from './editHistory'; -import { cloneEditorSelection, type IEditorSelection } from './selection'; +import { cloneEditorSelection, type EditorSelection } from './selection'; /** * Position in a text document expressed as zero-based line and character offset. @@ -157,7 +157,10 @@ export class TextDocument { this.#setDocumentText(text); } - applyEdits(edits: TextEdit[], selectionBefore?: IEditorSelection): void { + applyEdits( + edits: TextEdit[], + selectionBefore?: EditorSelection | EditorSelection[] + ): void { if (edits.length === 0) { return; } @@ -170,11 +173,13 @@ export class TextDocument { this.#setDocumentText(newText); } - setLastUndoSelectionAfter(selection: IEditorSelection): void { + setLastUndoSelectionAfter( + selection: EditorSelection | EditorSelection[] + ): void { this.#history.setLastUndoSelectionAfter(selection); } - undo(): IEditorSelection | undefined { + undo(): EditorSelection | EditorSelection[] | undefined { const entry = this.#history.popUndoToRedo(); if (entry === undefined) { return undefined; @@ -185,7 +190,7 @@ export class TextDocument { : undefined; } - redo(): IEditorSelection | undefined { + redo(): EditorSelection | EditorSelection[] | undefined { const entry = this.#history.popRedoToUndo(); if (entry === undefined) { return undefined; diff --git a/packages/diffs/src/editor/textareaState.ts b/packages/diffs/src/editor/textareaState.ts index fb193d8e3..a297d8e36 100644 --- a/packages/diffs/src/editor/textareaState.ts +++ b/packages/diffs/src/editor/textareaState.ts @@ -1,14 +1,10 @@ import { getLineIndentation } from './editorUtils'; -import { - fromWebSelectionDirection, - type ISelection, - type ISelections, -} from './selection'; +import { type EditorSelection, fromWebSelectionDirection } from './selection'; export type TextareaState = { - selections: ISelections; - selection: ISelection; - snippet: ITextareaSnippet; + selections: EditorSelection[]; + selection: EditorSelection; + snippet: TextareaSnippet; value: string; }; @@ -17,7 +13,7 @@ type TextLineSource = { getLineText(line: number): string | undefined; }; -interface ITextareaSnippet { +interface TextareaSnippet { firstLine: number; lastLine: number; text: string; @@ -94,8 +90,8 @@ function detectIndentUnit(text: string): string { export function createTextareaSnippet( textLineSource: TextLineSource, - selection: ISelection -): ITextareaSnippet { + selection: EditorSelection +): TextareaSnippet { const firstLine = Math.max(0, selection.start.line - 1); const lastLine = Math.min( textLineSource.lineCount - 1, diff --git a/packages/diffs/test/textareaState.test.ts b/packages/diffs/test/textareaState.test.ts index 9b5fca270..b3f9f3569 100644 --- a/packages/diffs/test/textareaState.test.ts +++ b/packages/diffs/test/textareaState.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from 'bun:test'; import { createSelection, - type ISelection, + type EditorSelection, SelectionDirection, toWebSelectionDirection, } from '../src/editor/selection'; @@ -16,7 +16,7 @@ import { TextDocument } from '../src/editor/textDocument'; type TextareaSnippetCase = { name: string; text: string; - selection: ISelection; + selection: EditorSelection; expected: { firstLine: number; lastLine: number; From fefbac3a1b9050e601ce46396929ec147455eac1 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Fri, 24 Apr 2026 01:42:12 +0800 Subject: [PATCH 007/138] Fix indent command for multiline selection --- packages/diffs/src/components/Editor.ts | 129 +++++++++++++++++++++++- 1 file changed, 126 insertions(+), 3 deletions(-) diff --git a/packages/diffs/src/components/Editor.ts b/packages/diffs/src/components/Editor.ts index ff76427f4..3bd2137fc 100644 --- a/packages/diffs/src/components/Editor.ts +++ b/packages/diffs/src/components/Editor.ts @@ -42,7 +42,7 @@ import { resolveTextareaTextChange, type TextareaState, } from '../editor/textareaState'; -import { TextDocument } from '../editor/textDocument'; +import { TextDocument, type TextEdit } from '../editor/textDocument'; import { getVisualColumn } from '../editor/visualColumns'; import { getSharedHighlighter } from '../highlighter/shared_highlighter'; import type { BaseCodeOptions, DiffsHighlighter } from '../types'; @@ -844,7 +844,7 @@ export class Editor { } case 'indent': case 'outdent': - this.#changePrimaryLineIndent(command === 'outdent'); + this.#changeSelectedLineIndent(command === 'outdent'); break; case 'copy': case 'cut': @@ -941,7 +941,90 @@ export class Editor { void this.#renderText(textDocument, nextSelection); } - #changePrimaryLineIndent(outdent: boolean) { + #changeSelectedLineIndent(outdent: boolean) { + const textDocument = this.#textDocument; + const selections = this.#selections; + const primarySelection = selections?.[selections.length - 1]; + if ( + textDocument === undefined || + selections === undefined || + primarySelection === undefined + ) { + return; + } + const hasExpandedSelection = selections.some( + (selection) => !isCollapsedSelection(selection) + ); + if (!hasExpandedSelection) { + this.#changePrimaryCaretIndent(outdent); + return; + } + const targetLines = this.#getSelectedLines(selections); + if (targetLines.length === 0) { + return; + } + const edits: TextEdit[] = []; + const lineDeltas = new Map(); + for (const line of targetLines) { + const lineText = textDocument.getLineText(line) ?? ''; + if (!outdent) { + const indentUnit = this.#resolveLineIndentUnit(line); + edits.push({ + range: { + start: { line, character: 0 }, + end: { line, character: 0 }, + }, + newText: indentUnit, + }); + lineDeltas.set(line, { insert: indentUnit.length, delete: 0 }); + continue; + } + const indentation = getLineIndentation(lineText); + if (indentation.length === 0) { + continue; + } + const indentUnit = this.#resolveLineIndentUnit(line); + const deleteLength = indentation.startsWith('\t') + ? 1 + : Math.max(1, Math.min(indentUnit.length, indentation.length)); + edits.push({ + range: { + start: { line, character: 0 }, + end: { line, character: deleteLength }, + }, + newText: '', + }); + lineDeltas.set(line, { insert: 0, delete: deleteLength }); + } + if (edits.length === 0) { + return; + } + const nextSelections = normalizeSelections( + selections.map((selection) => ({ + start: this.#adjustPositionForLineIndent( + selection.start.line, + selection.start.character, + lineDeltas + ), + end: this.#adjustPositionForLineIndent( + selection.end.line, + selection.end.character, + lineDeltas + ), + direction: selection.direction, + })) + ); + const nextSelection = + nextSelections.length === 1 ? nextSelections[0] : nextSelections; + textDocument.applyEdits( + edits, + selections.length === 1 ? primarySelection : selections + ); + textDocument.setLastUndoSelectionAfter(nextSelection); + void this.#renderText(textDocument, nextSelection); + } + + #changePrimaryCaretIndent(outdent: boolean) { const textDocument = this.#textDocument; const selections = this.#selections; const primarySelection = selections?.[selections.length - 1]; @@ -1009,6 +1092,46 @@ export class Editor { }); } + #getSelectedLines(selections: readonly EditorSelection[]): number[] { + const selectedLines = new Set(); + for (const selection of selections) { + let startLine = selection.start.line; + let endLine = selection.end.line; + if ( + !isCollapsedSelection(selection) && + selection.end.character === 0 && + endLine > startLine + ) { + endLine--; + } + if (endLine < startLine) { + [startLine, endLine] = [endLine, startLine]; + } + for (let line = startLine; line <= endLine; line++) { + selectedLines.add(line); + } + } + return [...selectedLines].sort((a, b) => a - b); + } + + #adjustPositionForLineIndent( + line: number, + character: number, + lineDeltas: ReadonlyMap + ): { line: number; character: number } { + const delta = lineDeltas.get(line); + if (delta === undefined) { + return { line, character }; + } + return { + line, + character: Math.max( + 0, + character + delta.insert - Math.min(character, delta.delete) + ), + }; + } + #resolveLineIndentUnit(line: number): string { const textDocument = this.#textDocument; if (textDocument === undefined) { From 1d081f567eb8797acad5c9ecffe0174a2d5eb159 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Sat, 25 Apr 2026 00:34:43 +0800 Subject: [PATCH 008/138] Refactor --- packages/diffs/src/components/Editor.ts | 567 +++++++------------- packages/diffs/src/editor/editHistory.ts | 14 +- packages/diffs/src/editor/editorUtils.ts | 13 + packages/diffs/src/editor/multiSelection.ts | 90 ++-- packages/diffs/src/editor/selection.ts | 27 +- packages/diffs/src/editor/textDocument.ts | 29 +- packages/diffs/src/editor/textareaState.ts | 4 +- packages/diffs/src/editor/visualColumns.ts | 2 +- packages/diffs/test/editHistory.test.ts | 45 +- packages/diffs/test/textDocument.test.ts | 54 +- packages/diffs/test/textareaState.test.ts | 23 +- 11 files changed, 346 insertions(+), 522 deletions(-) diff --git a/packages/diffs/src/components/Editor.ts b/packages/diffs/src/components/Editor.ts index 3bd2137fc..2c285ce20 100644 --- a/packages/diffs/src/components/Editor.ts +++ b/packages/diffs/src/components/Editor.ts @@ -10,7 +10,7 @@ import { coalesceMicrotask, createElement, extend, - getLineIndentation, + getLineIndentationUnit, measureMonoFontWidth, } from '../editor/editorUtils'; import { @@ -20,20 +20,15 @@ import { mapSelectionTextReplace, } from '../editor/multiSelection'; import { normlizeEditorOptions } from '../editor/normlizeEditorOptions'; -import type { - EditorSelection, - EditorSelectionTextChange, -} from '../editor/selection'; +import type { EditorSelection } from '../editor/selection'; import { - cloneSelection, + cloneEditorSelection, convertSelection, createSelection, fromWebSelectionDirection, getPrimarySelection, isCollapsedSelection, - normalizeSelections, SelectionDirection, - toSelectionArray, toWebSelectionDirection, } from '../editor/selection'; import { @@ -125,6 +120,10 @@ export class Editor { return this.#textDocument; } + get #hasSelection(): boolean { + return this.#selections !== undefined && this.#selections.length > 0; + } + setText(text: string, lang = 'plaintext'): void { this.setTextDocument(new TextDocument('inmemory://1', text, lang)); } @@ -133,9 +132,8 @@ export class Editor { this.#textDocument = textDocument; this.#textareaState = undefined; this.#reservedSelections = undefined; - const selection = createSelection(0, 0, 0, 0); - this.#selections = [selection]; - void this.#renderText(textDocument, selection); + this.#selections = undefined; + void this.#renderText(textDocument); } setThemeType(themeType: 'dark' | 'light' | 'system'): void { @@ -196,17 +194,17 @@ export class Editor { ) { const selection = convertSelection(selectionRaw); if (selection !== null) { - this.#restoreSelection( - this.#reservedSelections !== undefined - ? [...this.#reservedSelections, selection] - : selection - ); + this.#restoreSelections([ + ...(this.#reservedSelections ?? []), + selection, + ]); } } }), addEventListener(this.#editorEl, 'mousedown', (e) => { if (e.button === 0 && getPrimaryModifier(e)) { - this.#reservedSelections = this.#selections?.map(cloneSelection); + this.#reservedSelections = + this.#selections?.map(cloneEditorSelection); } }), addEventListener(document, 'mouseup', () => { @@ -383,7 +381,7 @@ export class Editor { #renderText( textDocument: TextDocument, - selection?: EditorSelection | EditorSelection[] + selections?: EditorSelection[] ): void { const totalLines = textDocument.lineCount; const languageId = textDocument.languageId; @@ -404,7 +402,7 @@ export class Editor { ) { return; } - this.#renderText(textDocument, selection ?? this.#selections); + this.#renderText(textDocument, selections); }); } } @@ -471,9 +469,9 @@ export class Editor { this.#textLineEls = lineEls; this.#activeLineEl = undefined; - this.#restoreSelection( - selection ?? this.#selections ?? createSelection(0, 0, 0, 0) - ); + if (selections !== undefined) { + this.#restoreSelections(selections); + } } #createSelectionFromOffsets( @@ -506,7 +504,7 @@ export class Editor { } const { selections: selectionsBefore, - selection: selectionBefore, + primarySelection: selectionBefore, snippet: textareaSnippet, value: originalValue, } = textareaState; @@ -545,8 +543,6 @@ export class Editor { direction: fromWebSelectionDirection(selectionDirection), } ); - const nextSelection = - nextSelections.length === 1 ? nextSelections[0] : nextSelections; const isBufferedTypingChange = pendingSnapshot === undefined && selectionsBefore.length === 1 && @@ -566,8 +562,8 @@ export class Editor { } this.#applyResolvedTextareaChange( edits, - selectionsBefore.length === 1 ? selectionBefore : selectionsBefore, - nextSelection + selectionsBefore, + nextSelections ); // if (newChangedText.trim() && nextSelections.length === 1 && isCollapsedSelection(nextSelections[0]!)) { // this.#langs.get(textDocument.languageId)?.lspDriver?.doComplete(textDocument, nextSelections[0]!.end); @@ -578,14 +574,12 @@ export class Editor { snippetStartOffset + selectionEnd, fromWebSelectionDirection(selectionDirection) ); - this.#restoreSelection( - selectionsBefore.length > 1 - ? mapSelectionRangeChange( - textDocument, - selectionsBefore, - nextPrimarySelection - ) - : nextPrimarySelection + this.#restoreSelections( + mapSelectionRangeChange( + textDocument, + selectionsBefore, + nextPrimarySelection + ) ); } } @@ -619,7 +613,6 @@ export class Editor { this.#clearTypingFlushTimeout(); const { selections: selectionsBefore, - selection: selectionBefore, snippet: textareaSnippet, value: originalValue, } = textareaState; @@ -656,34 +649,25 @@ export class Editor { direction: fromWebSelectionDirection(selectionDirection), } ); - const nextSelection = - nextSelections.length === 1 ? nextSelections[0] : nextSelections; this.#pendingTextareaSnapshot = undefined; - this.#applyResolvedTextareaChange( - edits, - selectionsBefore.length === 1 ? selectionBefore : selectionsBefore, - nextSelection - ); + this.#applyResolvedTextareaChange(edits, selectionsBefore, nextSelections); } #applyResolvedTextareaChange( edits: Parameters[0], - selectionBefore: EditorSelection | EditorSelection[], - nextSelection: EditorSelection | EditorSelection[] + selectionsBefore: EditorSelection[], + nextSelections: EditorSelection[] ) { const textDocument = this.#textDocument; if (textDocument === undefined) { return; } - textDocument.applyEdits(edits, selectionBefore); - textDocument.setLastUndoSelectionAfter(nextSelection); - void this.#renderText(textDocument, nextSelection); + textDocument.applyEdits(edits, true, selectionsBefore); + textDocument.setLastUndoSelectionsAfter(nextSelections); + void this.#renderText(textDocument, nextSelections); } - #restoreSelection(selection: EditorSelection | EditorSelection[]) { - const selections = normalizeSelections( - Array.isArray(selection) ? selection : toSelectionArray(selection) - ); + #restoreSelections(selections: EditorSelection[]) { const primarySelection = getPrimarySelection(selections); if (primarySelection === undefined) { return; @@ -696,14 +680,14 @@ export class Editor { } selections.forEach((selection) => { if (!isCollapsedSelection(selection)) { - this.#renderSelections(selection, selectionEls); + this.#renderSelectionRange(selection, selectionEls); } this.#renderCursor(selection, selectionEls); }); this.#selectionEls?.forEach((el) => el.remove()); this.#selectionEls?.clear(); this.#selectionEls = selectionEls; - this.#resetTextarea(primarySelection, selections); + this.#updateTextarea(primarySelection, selections); } #renderHighlightLine( @@ -724,7 +708,7 @@ export class Editor { selectionEls.set(`highlightLine-${selection.start.line}`, hlEl); } - #renderSelections( + #renderSelectionRange( selection: EditorSelection, selectionEls: Map ) { @@ -801,16 +785,22 @@ export class Editor { this.#activeLineEl = activeLineEl; } - #resetTextarea(selection: EditorSelection, selections: EditorSelection[]) { + #updateTextarea( + primarySelection: EditorSelection, + selections: EditorSelection[] + ) { const textDocument = this.#textDocument; const textareaEl = this.#textareaEl; if (textDocument === undefined || textareaEl === undefined) { return; } - const textareaSnippet = createTextareaSnippet(textDocument, selection); + const textareaSnippet = createTextareaSnippet( + textDocument, + primarySelection + ); this.#textareaState = { selections, - selection, + primarySelection, snippet: textareaSnippet, value: textareaSnippet.text, }; @@ -819,7 +809,7 @@ export class Editor { textareaEl.setSelectionRange( textareaSnippet.selectionStart, textareaSnippet.selectionEnd, - toWebSelectionDirection(selection.direction) + toWebSelectionDirection(primarySelection.direction) ); textareaEl.style.left = this.#gutterWidth + 'px'; textareaEl.style.width = `calc(100% - ${this.#gutterWidth}px)`; @@ -830,33 +820,19 @@ export class Editor { async #runShortcutCommand(command: EditorShortcutCommand) { switch (command) { - case 'paste': { - let text: string; - try { - text = await navigator.clipboard.readText(); - } catch { - return; - } - this.#replaceSelectionText( - this.#resolvePastedSelectionText(text) ?? text - ); - break; - } - case 'indent': - case 'outdent': - this.#changeSelectedLineIndent(command === 'outdent'); + case 'selectAll': + this.#restoreSelections([this.#getFullSelection()]); break; + case 'copy': case 'cut': - if ( - this.#selections !== undefined && - this.#textDocument !== undefined - ) { + if (this.#hasSelection && this.#textDocument !== undefined) { try { + // todo: use navigator.clipboard.write() for multiple selections copy await navigator.clipboard.writeText( getOrderedSelectionText( this.#textDocument, - this.#selections + this.#selections! ).join(this.#textDocument.EOF) ); } catch { @@ -867,352 +843,197 @@ export class Editor { } } break; + + case 'paste': { + let text: string | string[]; + try { + // todo: use navigator.clipboard.read() for multiple segments paste + text = await navigator.clipboard.readText(); + } catch { + return; + } + this.#replaceSelectionText(text); + break; + } + + case 'indent': + case 'outdent': + if (this.#hasSelection && this.#textDocument !== undefined) { + const edits: TextEdit[] = []; + const nextSelections: EditorSelection[] = []; + for (const selection of this.#selections!) { + const startLine = selection.start.line; + const lineText = this.#textDocument.getLineText(startLine); + if (lineText !== undefined) { + const outdent = command === 'outdent'; + if (startLine !== selection.end.line || outdent) { + const ret = this.#resolveIndentEdits(selection, outdent); + edits.push(...ret[0]); + nextSelections.push(ret[1]); + } else { + const indentUnit = getLineIndentationUnit( + lineText, + this.#tabSize + ); + this.#replaceSelectionText(indentUnit); + } + } + } + if (edits.length > 0) { + this.#textDocument.applyEdits(edits, true, this.#selections); + this.#textDocument.setLastUndoSelectionsAfter(nextSelections); + void this.#renderText(this.#textDocument, nextSelections); + } + } + break; + case 'documentStart': case 'documentEnd': - this.#restoreSelection( - this.#getDocumentBoundarySelection(command === 'documentEnd') - ); + this.#restoreSelections([ + this.#getDocumentBoundarySelection(command === 'documentEnd'), + ]); break; + case 'undo': if (this.#textDocument?.canUndo === true) { void this.#renderText(this.#textDocument, this.#textDocument.undo()); } break; + case 'redo': if (this.#textDocument?.canRedo === true) { void this.#renderText(this.#textDocument, this.#textDocument.redo()); } break; - case 'selectAll': - this.#restoreSelection(this.#getSelectAllSelection()); - break; } } - #getSelectAllSelection() { + // for select all command + #getFullSelection() { const textDocument = this.#textDocument; if (textDocument === undefined) { throw new Error('Editor has no text document'); } - const lastLine = textDocument.lineCount; - const lastLineIndex = lastLine - 1; - const lastCharacter = textDocument.getLineText(lastLineIndex)?.length ?? 0; + const lastLine = textDocument.lineCount - 1; + const lastCharacter = textDocument.getLineText(lastLine)?.length ?? 0; return createSelection( 0, 0, - lastLineIndex, + lastLine, lastCharacter, SelectionDirection.Forward ); } - #replaceSelectionText(text: string | string[]) { - const selections = this.#selections; - if (selections === undefined) { - return; - } + // for documentStart/documentEnd commands + #getDocumentBoundarySelection(atEnd: boolean) { const textDocument = this.#textDocument; - const selection = getPrimarySelection(selections); - if (textDocument == null || selection == null) { - return; + if (textDocument === undefined) { + throw new Error('Editor has no text document'); } - const normalizedText = Array.isArray(text) - ? text.map((value) => value.replace(/\r\n?|\n/g, textDocument.EOF)) - : text.replace(/\r\n?|\n/g, textDocument.EOF); - const { edits, nextSelections } = Array.isArray(normalizedText) - ? mapSelectionTextReplace(textDocument, selections, normalizedText) - : mapSelectionTextChange(textDocument, selections, { - start: textDocument.offsetAt(selection.start), - end: textDocument.offsetAt(selection.end), - text: normalizedText, - selectionStart: - textDocument.offsetAt(selection.start) + normalizedText.length, - selectionEnd: - textDocument.offsetAt(selection.start) + normalizedText.length, - direction: SelectionDirection.None, - }); - const nextSelection = - nextSelections.length === 1 ? nextSelections[0] : nextSelections; - textDocument.applyEdits( - edits, - selections.length === 1 ? selection : selections - ); - textDocument.setLastUndoSelectionAfter(nextSelection); - void this.#renderText(textDocument, nextSelection); + const line = atEnd ? textDocument.lineCount - 1 : 0; + const character = atEnd ? (textDocument.getLineText(line)?.length ?? 0) : 0; + return createSelection(line, character, line, character); } - #changeSelectedLineIndent(outdent: boolean) { + #resolveIndentEdits( + selection: EditorSelection, + outdent: boolean + ): [edits: TextEdit[], nextSelection: EditorSelection] { const textDocument = this.#textDocument; - const selections = this.#selections; - const primarySelection = selections?.[selections.length - 1]; - if ( - textDocument === undefined || - selections === undefined || - primarySelection === undefined - ) { - return; - } - const hasExpandedSelection = selections.some( - (selection) => !isCollapsedSelection(selection) - ); - if (!hasExpandedSelection) { - this.#changePrimaryCaretIndent(outdent); - return; + if (textDocument === undefined) { + return [[], selection]; } - const targetLines = this.#getSelectedLines(selections); - if (targetLines.length === 0) { - return; + const { start, end } = selection; + let endLine = end.line; + if (start.line < end.line && end.character === 0) { + endLine--; } const edits: TextEdit[] = []; - const lineDeltas = new Map(); - for (const line of targetLines) { - const lineText = textDocument.getLineText(line) ?? ''; - if (!outdent) { - const indentUnit = this.#resolveLineIndentUnit(line); - edits.push({ - range: { - start: { line, character: 0 }, - end: { line, character: 0 }, - }, - newText: indentUnit, - }); - lineDeltas.set(line, { insert: indentUnit.length, delete: 0 }); + const newSelection: EditorSelection = { ...selection }; + for (let line = start.line; line <= endLine; line++) { + const lineText = textDocument.getLineText(line); + if (lineText === undefined) { continue; } - const indentation = getLineIndentation(lineText); - if (indentation.length === 0) { - continue; + const indentUnit = getLineIndentationUnit(lineText, this.#tabSize); + let deleteLength = 0; + let newText = indentUnit; + if (outdent) { + if (lineText.startsWith('\t')) { + deleteLength = 1; + } else if (lineText.startsWith(' ')) { + const leadingSpacesLength = + lineText.length - lineText.trimStart().length; + deleteLength = Math.min(indentUnit.length, leadingSpacesLength); + } + if (deleteLength === 0) { + continue; + } + newText = ''; } - const indentUnit = this.#resolveLineIndentUnit(line); - const deleteLength = indentation.startsWith('\t') - ? 1 - : Math.max(1, Math.min(indentUnit.length, indentation.length)); edits.push({ range: { start: { line, character: 0 }, end: { line, character: deleteLength }, }, - newText: '', + newText, }); - lineDeltas.set(line, { insert: 0, delete: deleteLength }); - } - if (edits.length === 0) { - return; - } - const nextSelections = normalizeSelections( - selections.map((selection) => ({ - start: this.#adjustPositionForLineIndent( - selection.start.line, - selection.start.character, - lineDeltas - ), - end: this.#adjustPositionForLineIndent( - selection.end.line, - selection.end.character, - lineDeltas - ), - direction: selection.direction, - })) - ); - const nextSelection = - nextSelections.length === 1 ? nextSelections[0] : nextSelections; - textDocument.applyEdits( - edits, - selections.length === 1 ? primarySelection : selections - ); - textDocument.setLastUndoSelectionAfter(nextSelection); - void this.#renderText(textDocument, nextSelection); - } - - #changePrimaryCaretIndent(outdent: boolean) { - const textDocument = this.#textDocument; - const selections = this.#selections; - const primarySelection = selections?.[selections.length - 1]; - if ( - textDocument === undefined || - selections === undefined || - primarySelection === undefined - ) { - return; - } - const line = primarySelection.start.line; - const lineText = textDocument.getLineText(line) ?? ''; - const lineStartOffset = textDocument.offsetAt({ line, character: 0 }); - const cursorOffset = textDocument.offsetAt(primarySelection.end); - - if (!outdent) { - const indentUnit = this.#resolveLineIndentUnit(line); - const insertOffset = isCollapsedSelection(primarySelection) - ? cursorOffset - : lineStartOffset; - this.#applySelectionTextChange(selections, { - start: insertOffset, - end: insertOffset, - text: indentUnit, - selectionStart: insertOffset + indentUnit.length, - selectionEnd: insertOffset + indentUnit.length, - direction: SelectionDirection.None, - }); - return; - } - - const indentation = getLineIndentation(lineText); - if (indentation.length === 0) { - return; - } - const cursorColumn = Math.max(0, cursorOffset - lineStartOffset); - if (cursorColumn === 0) { - return; - } - const indentUnit = this.#resolveLineIndentUnit(line); - const deleteLength = indentation.startsWith('\t') - ? 1 - : Math.max( - 1, - Math.min(indentUnit.length, indentation.length, cursorColumn) - ); - const deleteStart = cursorOffset - deleteLength; - const deleteSegment = lineText.slice( - Math.max(0, cursorColumn - deleteLength), - cursorColumn - ); - const expectedChar = indentation.startsWith('\t') ? '\t' : ' '; - for (const char of deleteSegment) { - if (char !== expectedChar) { - return; - } - } - this.#applySelectionTextChange(selections, { - start: deleteStart, - end: cursorOffset, - text: '', - selectionStart: deleteStart, - selectionEnd: deleteStart, - direction: SelectionDirection.None, - }); - } - - #getSelectedLines(selections: readonly EditorSelection[]): number[] { - const selectedLines = new Set(); - for (const selection of selections) { - let startLine = selection.start.line; - let endLine = selection.end.line; - if ( - !isCollapsedSelection(selection) && - selection.end.character === 0 && - endLine > startLine - ) { - endLine--; - } - if (endLine < startLine) { - [startLine, endLine] = [endLine, startLine]; - } - for (let line = startLine; line <= endLine; line++) { - selectedLines.add(line); + const delte = newText.length - deleteLength; + if (line === start.line) { + newSelection.start = { + ...start, + character: Math.max(0, start.character + delte), + }; } - } - return [...selectedLines].sort((a, b) => a - b); - } - - #adjustPositionForLineIndent( - line: number, - character: number, - lineDeltas: ReadonlyMap - ): { line: number; character: number } { - const delta = lineDeltas.get(line); - if (delta === undefined) { - return { line, character }; - } - return { - line, - character: Math.max( - 0, - character + delta.insert - Math.min(character, delta.delete) - ), - }; - } - - #resolveLineIndentUnit(line: number): string { - const textDocument = this.#textDocument; - if (textDocument === undefined) { - return ' '.repeat(this.#tabSize); - } - const resolved = this.#resolveIndentUnitFromText( - getLineIndentation(textDocument.getLineText(line) ?? '') - ); - if (resolved !== undefined) { - return resolved; - } - for (let ln = line - 1; ln >= 0; ln--) { - const fromPrevious = this.#resolveIndentUnitFromText( - getLineIndentation(textDocument.getLineText(ln) ?? '') - ); - if (fromPrevious !== undefined) { - return fromPrevious; + if (line === end.line) { + newSelection.end = { + ...end, + character: Math.max(0, end.character + delte), + }; } } - return ' '.repeat(this.#tabSize); + return [edits, newSelection]; } - #resolveIndentUnitFromText(indentation: string): string | undefined { - if (indentation.startsWith('\t')) { - return '\t'; - } - if (indentation.startsWith(' ')) { - return ' '.repeat( - Math.max(1, Math.min(this.#tabSize, indentation.length)) - ); - } - return undefined; - } - - #applySelectionTextChange( - selections: EditorSelection[], - change: EditorSelectionTextChange - ) { - const textDocument = this.#textDocument; - const primarySelection = getPrimarySelection(selections); - if (textDocument === undefined || primarySelection === undefined) { + // replace the selection text + #replaceSelectionText(text: string | string[]) { + const selections = this.#selections; + if (selections === undefined) { return; } - const { edits, nextSelections } = mapSelectionTextChange( - textDocument, - selections, - change - ); - const nextSelection = - nextSelections.length === 1 ? nextSelections[0] : nextSelections; - textDocument.applyEdits( - edits, - selections.length === 1 ? primarySelection : selections - ); - textDocument.setLastUndoSelectionAfter(nextSelection); - void this.#renderText(textDocument, nextSelection); - } - - #getDocumentBoundarySelection(atEnd: boolean) { const textDocument = this.#textDocument; - if (textDocument === undefined) { - throw new Error('Editor has no text document'); - } - const line = atEnd ? textDocument.lineCount - 1 : 0; - const character = atEnd ? (textDocument.getLineText(line)?.length ?? 0) : 0; - return createSelection(line, character, line, character); - } - - #resolvePastedSelectionText(text: string) { - const selectionCount = this.#selections?.length ?? 0; - if (selectionCount === 0) { - return undefined; + const selection = getPrimarySelection(selections); + if (textDocument == null || selection == null) { + return; } - const parts = text.split(/\r\n?|\n/g); - return parts.length === selectionCount ? parts : undefined; + const normalizedText = Array.isArray(text) + ? text.map((value) => value.replace(/\r\n?|\n/g, textDocument.EOF)) + : text.replace(/\r\n?|\n/g, textDocument.EOF); + const { edits, nextSelections } = Array.isArray(normalizedText) + ? mapSelectionTextReplace(textDocument, selections, normalizedText) + : mapSelectionTextChange(textDocument, selections, { + start: textDocument.offsetAt(selection.start), + end: textDocument.offsetAt(selection.end), + text: normalizedText, + selectionStart: + textDocument.offsetAt(selection.start) + normalizedText.length, + selectionEnd: + textDocument.offsetAt(selection.start) + normalizedText.length, + direction: SelectionDirection.None, + }); + textDocument.applyEdits(edits, true, selections); + textDocument.setLastUndoSelectionsAfter(nextSelections); + void this.#renderText(textDocument, nextSelections); } + // get line Y position #getLineY(line: number) { return line * this.#lineHeightPx + this.#paddingY; } + // get character X position + // todo: does it support emoji/non-ascii input? #getCharacterX(line: number, character: number, visualColumn: number) { const fallbackLeft = this.#gutterWidth + visualColumn * this.#monoFontWidth; const lineEl = this.#textLineEls?.get(line); @@ -1276,24 +1097,24 @@ export class Editor { return pointRect.left - editorRect.left; } + // check if the active element has focus within editor #hasFocusWithinEditor() { const activeElement = document.activeElement; + if (activeElement === null) { + return false; + } return ( activeElement === this.#editorEl || activeElement === this.#textareaEl || - (activeElement !== null && - this.#editorEl?.contains(activeElement) === true) + this.#editorEl?.contains(activeElement) === true ); } + // check if the web selection belongs to editor #selectionBelongsToEditor(selection: Selection) { return ( - this.#nodeBelongsToEditor(selection.anchorNode) && - this.#nodeBelongsToEditor(selection.focusNode) + this.#editorEl?.contains(selection.anchorNode) === true && + this.#editorEl?.contains(selection.focusNode) === true ); } - - #nodeBelongsToEditor(node: Node | null) { - return node !== null && this.#editorEl?.contains(node) === true; - } } diff --git a/packages/diffs/src/editor/editHistory.ts b/packages/diffs/src/editor/editHistory.ts index 00ac8c916..a186193b2 100644 --- a/packages/diffs/src/editor/editHistory.ts +++ b/packages/diffs/src/editor/editHistory.ts @@ -12,9 +12,9 @@ export type HistoryEntry = { /** Final text length after the entry is applied. */ textLengthAfter: number; /** Selection before the transaction (restored on undo). */ - selectionBefore: EditorSelection | EditorSelection[]; + selectionsBefore: EditorSelection[]; /** Selection after the transaction (restored on redo). */ - selectionAfter?: EditorSelection | EditorSelection[]; + selectionsAfter?: EditorSelection[]; /** Timestamp in ms used to coalesce adjacent edits. */ timestampMs: number; }; @@ -284,7 +284,7 @@ export class EditHistory { push( textBefore: string, resolvedEdits: ResolvedEdit[], - selectionBefore: EditorSelection | EditorSelection[], + selectionsBefore: EditorSelection[], coalesceWithinMs?: number ): void { const timestampMs = Date.now(); @@ -324,18 +324,16 @@ export class EditHistory { inverseEdits: inverseEdits, textLengthBefore, textLengthAfter, - selectionBefore: cloneEditorSelection(selectionBefore), + selectionsBefore: selectionsBefore?.map(cloneEditorSelection), timestampMs, }); this.#redo.length = 0; } - setLastUndoSelectionAfter( - selection: EditorSelection | EditorSelection[] - ): void { + setLastUndoSelectionsAfter(selections: EditorSelection[]): void { const lastEntry = this.#undo[this.#undo.length - 1]; if (lastEntry !== undefined) { - lastEntry.selectionAfter = cloneEditorSelection(selection); + lastEntry.selectionsAfter = selections.map(cloneEditorSelection); } } diff --git a/packages/diffs/src/editor/editorUtils.ts b/packages/diffs/src/editor/editorUtils.ts index 1f41c6472..c8879c59b 100644 --- a/packages/diffs/src/editor/editorUtils.ts +++ b/packages/diffs/src/editor/editorUtils.ts @@ -93,6 +93,19 @@ export function getLineIndentation(lineText: string): string { return indentation; } +export function getLineIndentationUnit( + lineText: string, + tabSize: number +): string { + if (lineText.startsWith('\t')) { + return '\t'; + } + if (lineText.startsWith(' ')) { + return ' '.repeat(Math.max(1, Math.min(tabSize, lineText.length))); + } + return ' '.repeat(tabSize); +} + export function extend(obj: T, attrs: Partial): T { return Object.assign(obj, attrs); } diff --git a/packages/diffs/src/editor/multiSelection.ts b/packages/diffs/src/editor/multiSelection.ts index c079988fe..da95db804 100644 --- a/packages/diffs/src/editor/multiSelection.ts +++ b/packages/diffs/src/editor/multiSelection.ts @@ -22,6 +22,51 @@ type SelectionTextChange = { direction: SelectionDirection; }; +export function mapSelectionRangeChange( + textDocument: TextDocument, + selections: readonly EditorSelection[], + nextPrimarySelection: EditorSelection +): EditorSelection[] { + const primarySelection = selections[selections.length - 1]; + if (primarySelection === undefined) { + return []; + } + const primaryAnchorOffset = getSelectionAnchorOffset( + textDocument, + primarySelection + ); + const primaryFocusOffset = getSelectionFocusOffset( + textDocument, + primarySelection + ); + const nextPrimaryAnchorOffset = getSelectionAnchorOffset( + textDocument, + nextPrimarySelection + ); + const nextPrimaryFocusOffset = getSelectionFocusOffset( + textDocument, + nextPrimarySelection + ); + const anchorDelta = nextPrimaryAnchorOffset - primaryAnchorOffset; + const focusDelta = nextPrimaryFocusOffset - primaryFocusOffset; + const textLength = textDocument.getText().length; + return normalizeSelections( + selections.map((selection) => + createSelectionFromAnchorAndFocusOffsets( + textDocument, + clampOffset( + getSelectionAnchorOffset(textDocument, selection) + anchorDelta, + textLength + ), + clampOffset( + getSelectionFocusOffset(textDocument, selection) + focusDelta, + textLength + ) + ) + ) + ); +} + export function mapSelectionTextChange( textDocument: TextDocument, selections: readonly EditorSelection[], @@ -132,51 +177,6 @@ export function mapSelectionTextChange( }; } -export function mapSelectionRangeChange( - textDocument: TextDocument, - selections: readonly EditorSelection[], - nextPrimarySelection: EditorSelection -): EditorSelection[] { - const primarySelection = selections[selections.length - 1]; - if (primarySelection === undefined) { - return []; - } - const primaryAnchorOffset = getSelectionAnchorOffset( - textDocument, - primarySelection - ); - const primaryFocusOffset = getSelectionFocusOffset( - textDocument, - primarySelection - ); - const nextPrimaryAnchorOffset = getSelectionAnchorOffset( - textDocument, - nextPrimarySelection - ); - const nextPrimaryFocusOffset = getSelectionFocusOffset( - textDocument, - nextPrimarySelection - ); - const anchorDelta = nextPrimaryAnchorOffset - primaryAnchorOffset; - const focusDelta = nextPrimaryFocusOffset - primaryFocusOffset; - const textLength = textDocument.getText().length; - return normalizeSelections( - selections.map((selection) => - createSelectionFromAnchorAndFocusOffsets( - textDocument, - clampOffset( - getSelectionAnchorOffset(textDocument, selection) + anchorDelta, - textLength - ), - clampOffset( - getSelectionFocusOffset(textDocument, selection) + focusDelta, - textLength - ) - ) - ) - ); -} - export function mapSelectionTextReplace( textDocument: TextDocument, selections: readonly EditorSelection[], diff --git a/packages/diffs/src/editor/selection.ts b/packages/diffs/src/editor/selection.ts index e2023bbe5..f23e8a599 100644 --- a/packages/diffs/src/editor/selection.ts +++ b/packages/diffs/src/editor/selection.ts @@ -76,7 +76,9 @@ export function isCollapsedSelection(selection: EditorSelection): boolean { ); } -export function cloneSelection(selection: EditorSelection): EditorSelection { +export function cloneEditorSelection( + selection: EditorSelection +): EditorSelection { return { start: { ...selection.start }, end: { ...selection.end }, @@ -84,30 +86,11 @@ export function cloneSelection(selection: EditorSelection): EditorSelection { }; } -export function cloneEditorSelection< - T extends EditorSelection | EditorSelection[], ->(selection: T): T { - return Array.isArray(selection) - ? (selection.map(cloneSelection) as T) - : (cloneSelection(selection) as T); -} - -export function toSelectionArray( - selection: EditorSelection | undefined -): EditorSelection[] { - if (selection === undefined) { - return []; - } - return Array.isArray(selection) - ? selection.map(cloneSelection) - : [cloneSelection(selection)]; -} - export function getPrimarySelection( selections: readonly EditorSelection[] ): EditorSelection | undefined { const selection = selections[selections.length - 1]; - return selection !== undefined ? cloneSelection(selection) : undefined; + return selection !== undefined ? cloneEditorSelection(selection) : undefined; } export function normalizeSelections( @@ -119,7 +102,7 @@ export function normalizeSelections( const primarySelection = selections[selections.length - 1]; const ordered = selections .map((selection, index) => ({ - selection: cloneSelection(selection), + selection: cloneEditorSelection(selection), index, isPrimary: selection === primarySelection, })) diff --git a/packages/diffs/src/editor/textDocument.ts b/packages/diffs/src/editor/textDocument.ts index 6d1dfc1c1..87ebd2394 100644 --- a/packages/diffs/src/editor/textDocument.ts +++ b/packages/diffs/src/editor/textDocument.ts @@ -25,7 +25,7 @@ export interface Position { * * The above two properties are implementation specific. */ - line: number; + readonly line: number; /** * Character offset on a line in a document (zero-based). * @@ -35,7 +35,7 @@ export interface Position { * If the character value is greater than the line length it defaults back * to the line length. This property is implementation specific. */ - character: number; + readonly character: number; } /** @@ -159,7 +159,8 @@ export class TextDocument { applyEdits( edits: TextEdit[], - selectionBefore?: EditorSelection | EditorSelection[] + updateHistory = false, + selectionsBefore?: EditorSelection[] ): void { if (edits.length === 0) { return; @@ -167,37 +168,35 @@ export class TextDocument { const resolvedEdits = this.#resolveEdits(edits); const textBefore = this.#text; const newText = applyOffsetEdits(textBefore, resolvedEdits); - if (selectionBefore !== undefined) { - this.#history.push(textBefore, resolvedEdits, selectionBefore, 500); + if (updateHistory && selectionsBefore !== undefined) { + this.#history.push(textBefore, resolvedEdits, selectionsBefore, 500); } this.#setDocumentText(newText); } - setLastUndoSelectionAfter( - selection: EditorSelection | EditorSelection[] - ): void { - this.#history.setLastUndoSelectionAfter(selection); + setLastUndoSelectionsAfter(selections: EditorSelection[]): void { + this.#history.setLastUndoSelectionsAfter(selections); } - undo(): EditorSelection | EditorSelection[] | undefined { + undo(): EditorSelection[] | undefined { const entry = this.#history.popUndoToRedo(); if (entry === undefined) { return undefined; } this.#setDocumentText(applyOffsetEdits(this.#text, entry.inverseEdits)); - return entry.selectionBefore !== undefined - ? cloneEditorSelection(entry.selectionBefore) + return entry.selectionsBefore !== undefined + ? entry.selectionsBefore.map(cloneEditorSelection) : undefined; } - redo(): EditorSelection | EditorSelection[] | undefined { + redo(): EditorSelection[] | undefined { const entry = this.#history.popRedoToUndo(); if (entry === undefined) { return undefined; } this.#setDocumentText(applyOffsetEdits(this.#text, entry.forwardEdits)); - return entry.selectionAfter !== undefined - ? cloneEditorSelection(entry.selectionAfter) + return entry.selectionsAfter !== undefined + ? entry.selectionsAfter.map(cloneEditorSelection) : undefined; } diff --git a/packages/diffs/src/editor/textareaState.ts b/packages/diffs/src/editor/textareaState.ts index a297d8e36..df04abf52 100644 --- a/packages/diffs/src/editor/textareaState.ts +++ b/packages/diffs/src/editor/textareaState.ts @@ -3,7 +3,7 @@ import { type EditorSelection, fromWebSelectionDirection } from './selection'; export type TextareaState = { selections: EditorSelection[]; - selection: EditorSelection; + primarySelection: EditorSelection; snippet: TextareaSnippet; value: string; }; @@ -138,7 +138,7 @@ export function matchesTextareaState( selectionStart === textareaState.snippet.selectionStart && selectionEnd === textareaState.snippet.selectionEnd && fromWebSelectionDirection(selectionDirection) === - textareaState.selection.direction + textareaState.primarySelection.direction ); } diff --git a/packages/diffs/src/editor/visualColumns.ts b/packages/diffs/src/editor/visualColumns.ts index c258f1197..543e22abd 100644 --- a/packages/diffs/src/editor/visualColumns.ts +++ b/packages/diffs/src/editor/visualColumns.ts @@ -7,7 +7,7 @@ export function getVisualColumn( const normalizedTabSize = Math.max(1, Math.floor(tabSize)); let column = 0; for (let i = 0; i < clampedCharacter; i++) { - if (text.charCodeAt(i) === 9) { + if (text.charCodeAt(i) === /* \t */ 9) { const remainder = column % normalizedTabSize; column += remainder === 0 ? normalizedTabSize : normalizedTabSize - remainder; diff --git a/packages/diffs/test/editHistory.test.ts b/packages/diffs/test/editHistory.test.ts index 9367d0ed9..eefa2fd23 100644 --- a/packages/diffs/test/editHistory.test.ts +++ b/packages/diffs/test/editHistory.test.ts @@ -68,12 +68,10 @@ describe('EditHistory', () => { const selectionAfter = [caret(2), caret(3)]; history.push('ab', [{ start: 1, end: 1, text: 'X' }], selectionBefore, -1); - history.setLastUndoSelectionAfter(selectionAfter); + history.setLastUndoSelectionsAfter(selectionAfter); - selectionBefore[0].start.character = 99; - selectionBefore[0].end.character = 99; - selectionAfter[0].start.character = 99; - selectionAfter[0].end.character = 99; + selectionBefore[0] = caret(99); + selectionAfter[0] = caret(99); expect(history.canUndo).toBe(true); expect(history.canRedo).toBe(false); @@ -85,8 +83,8 @@ describe('EditHistory', () => { inverseEdits: [{ start: 1, end: 2, text: '' }], textLengthBefore: 2, textLengthAfter: 3, - selectionBefore: [caret(0), caret(1)], - selectionAfter: [caret(2), caret(3)], + selectionsBefore: [caret(0), caret(1)], + selectionsAfter: [caret(2), caret(3)], timestampMs: expect.any(Number), }); expect(history.canUndo).toBe(false); @@ -97,17 +95,16 @@ describe('EditHistory', () => { expect(history.canRedo).toBe(false); }); - test('setLastUndoSelectionAfter stores a cloned redo selection', () => { + test('setLastUndoSelectionsAfter stores cloned redo selections', () => { const history = new EditHistory(); - const selectionAfter = caret(2); + let selectionAfter = caret(2); - history.push('a', [{ start: 1, end: 1, text: 'b' }], caret(1), -1); - history.setLastUndoSelectionAfter(selectionAfter); - selectionAfter.start.character = 99; - selectionAfter.end.character = 99; + history.push('a', [{ start: 1, end: 1, text: 'b' }], [caret(1)], -1); + history.setLastUndoSelectionsAfter([selectionAfter]); + selectionAfter = caret(99); expect(history.popUndoToRedo()).toMatchObject({ - selectionAfter: caret(2), + selectionsAfter: [caret(2)], }); }); @@ -122,11 +119,11 @@ describe('EditHistory', () => { }); try { - history.push('', [{ start: 0, end: 0, text: 'a' }], caret(0), 1000); - history.setLastUndoSelectionAfter(caret(1)); + history.push('', [{ start: 0, end: 0, text: 'a' }], [caret(0)], 1000); + history.setLastUndoSelectionsAfter([caret(1)]); now += 400; - history.push('a', [{ start: 1, end: 1, text: 'b' }], caret(1), 1000); - history.setLastUndoSelectionAfter(caret(2)); + history.push('a', [{ start: 1, end: 1, text: 'b' }], [caret(1)], 1000); + history.setLastUndoSelectionsAfter([caret(2)]); const entry = history.popUndoToRedo(); @@ -135,8 +132,8 @@ describe('EditHistory', () => { inverseEdits: [{ start: 0, end: 2, text: '' }], textLengthBefore: 0, textLengthAfter: 2, - selectionBefore: caret(0), - selectionAfter: caret(2), + selectionsBefore: [caret(0)], + selectionsAfter: [caret(2)], timestampMs: 1400, }); expect(history.popUndoToRedo()).toBeUndefined(); @@ -151,15 +148,15 @@ describe('EditHistory', () => { test('push clears redo history when recording a new undo entry', () => { const history = new EditHistory(); - history.push('', [{ start: 0, end: 0, text: 'a' }], caret(0), -1); - history.push('a', [{ start: 1, end: 1, text: 'b' }], caret(1), -1); + history.push('', [{ start: 0, end: 0, text: 'a' }], [caret(0)], -1); + history.push('a', [{ start: 1, end: 1, text: 'b' }], [caret(1)], -1); expect(history.popUndoToRedo()).toMatchObject({ forwardEdits: [{ start: 1, end: 1, text: 'b' }], }); expect(history.canRedo).toBe(true); - history.push('a', [{ start: 1, end: 1, text: 'c' }], caret(1), -1); + history.push('a', [{ start: 1, end: 1, text: 'c' }], [caret(1)], -1); expect(history.canRedo).toBe(false); expect(history.popUndoToRedo()).toMatchObject({ @@ -173,7 +170,7 @@ describe('EditHistory', () => { test('clear resets both undo and redo stacks', () => { const history = new EditHistory(); - history.push('', [{ start: 0, end: 0, text: 'a' }], caret(0), -1); + history.push('', [{ start: 0, end: 0, text: 'a' }], [caret(0)], -1); history.popUndoToRedo(); history.clear(); diff --git a/packages/diffs/test/textDocument.test.ts b/packages/diffs/test/textDocument.test.ts index 8e4b614a5..dd79eba2a 100644 --- a/packages/diffs/test/textDocument.test.ts +++ b/packages/diffs/test/textDocument.test.ts @@ -140,7 +140,8 @@ describe('TextDocument', () => { newText: 'AA', }, ], - caret(0, 0) + true, + [caret(0, 0)] ); d.undo(); expect(d.getText()).toBe('aa bb cc'); @@ -158,7 +159,8 @@ describe('TextDocument', () => { newText: 'two', }, ], - caret(1, 0) + true, + [caret(1, 0)] ); expect(d.getText()).toBe('line1\ntwo\nline3'); d.undo(); @@ -184,7 +186,8 @@ describe('TextDocument', () => { newText: 'a', }, ], - caret(0, 0) + true, + [caret(0, 0)] ); now += 600; d.applyEdits( @@ -197,7 +200,8 @@ describe('TextDocument', () => { newText: 'b', }, ], - caret(0, 1) + true, + [caret(0, 1)] ); d.undo(); expect(d.getText()).toBe('a'); @@ -230,7 +234,8 @@ describe('TextDocument', () => { newText: 'a', }, ], - caret(0, 0) + true, + [caret(0, 0)] ); now += 400; d.applyEdits( @@ -243,7 +248,8 @@ describe('TextDocument', () => { newText: 'b', }, ], - caret(0, 1) + true, + [caret(0, 1)] ); expect(d.getText()).toBe('ab'); d.undo(); @@ -279,7 +285,8 @@ describe('TextDocument', () => { newText: 'ab', }, ], - caret(0, 0) + true, + [caret(0, 0)] ); now += 400; d.applyEdits( @@ -292,7 +299,8 @@ describe('TextDocument', () => { newText: 'c', }, ], - caret(0, 1) + true, + [caret(0, 1)] ); expect(d.getText()).toBe('ac'); d.undo(); @@ -326,7 +334,8 @@ describe('TextDocument', () => { newText: 'a', }, ], - caret(0, 0) + true, + [caret(0, 0)] ); now += 1200; d.applyEdits( @@ -339,7 +348,8 @@ describe('TextDocument', () => { newText: 'b', }, ], - caret(0, 1) + true, + [caret(0, 1)] ); d.undo(); expect(d.getText()).toBe('a'); @@ -409,7 +419,8 @@ describe('TextDocument', () => { newText: 'b', }, ], - caret(0, 1) + true, + [caret(0, 1)] ); expect(d.getText()).toBe('ab'); expect(d.canUndo).toBe(true); @@ -438,7 +449,8 @@ describe('TextDocument', () => { newText: 'b', }, ], - caret(0, 1) + true, + [caret(0, 1)] ); d.undo(); d.applyEdits( @@ -451,7 +463,8 @@ describe('TextDocument', () => { newText: 'c', }, ], - caret(0, 1) + true, + [caret(0, 1)] ); expect(d.getText()).toBe('ac'); expect(d.canRedo).toBe(false); @@ -469,7 +482,8 @@ describe('TextDocument', () => { newText: 'b', }, ], - caret(0, 1) + true, + [caret(0, 1)] ); expect(d.canUndo).toBe(true); d.setText('fresh'); @@ -502,12 +516,13 @@ describe('TextDocument', () => { newText: 'x', }, ], - selectionBefore + true, + [selectionBefore] ); - d.setLastUndoSelectionAfter(selectionAfter); + d.setLastUndoSelectionsAfter([selectionAfter]); - expect(d.undo()).toEqual(selectionBefore); - expect(d.redo()).toEqual(selectionAfter); + expect(d.undo()).toEqual([selectionBefore]); + expect(d.redo()).toEqual([selectionAfter]); }); test('undo and redo preserve multiple selections', () => { @@ -531,9 +546,10 @@ describe('TextDocument', () => { newText: '!', }, ], + true, selectionBefore ); - d.setLastUndoSelectionAfter(selectionAfter); + d.setLastUndoSelectionsAfter(selectionAfter); expect(d.undo()).toEqual(selectionBefore); expect(d.redo()).toEqual(selectionAfter); diff --git a/packages/diffs/test/textareaState.test.ts b/packages/diffs/test/textareaState.test.ts index b3f9f3569..9cb07d831 100644 --- a/packages/diffs/test/textareaState.test.ts +++ b/packages/diffs/test/textareaState.test.ts @@ -114,18 +114,15 @@ function applyTextareaChange( }); const start = textDocument.positionAt(snippetStartOffset + change.start); const end = textDocument.positionAt(snippetStartOffset + change.end); - textDocument.applyEdits( - [ - { - range: { - start, - end, - }, - newText: change.text, + textDocument.applyEdits([ + { + range: { + start, + end, }, - ], - selection - ); + newText: change.text, + }, + ]); return textDocument.getText(); } @@ -222,7 +219,7 @@ describe('matchesTextareaState', () => { matchesTextareaState( { selections: [selection], - selection, + primarySelection: selection, snippet, value: snippet.text, }, @@ -249,7 +246,7 @@ describe('matchesTextareaState', () => { matchesTextareaState( { selections: [selection], - selection, + primarySelection: selection, snippet, value: snippet.text, }, From 73623fbe9053e614f4f39bb0d381a989144fae9f Mon Sep 17 00:00:00 2001 From: Je Xia Date: Sat, 25 Apr 2026 11:27:43 +0800 Subject: [PATCH 009/138] Improve shiki theme/garmmar loading --- apps/demo/src/main.ts | 4 +- packages/diffs/src/components/Editor.ts | 226 +++++++----------- packages/diffs/src/editor/editHistory.ts | 15 +- ...mlizeEditorOptions.ts => editorOptions.ts} | 48 ++-- packages/diffs/src/editor/editorUtils.ts | 19 ++ packages/diffs/src/editor/selection.ts | 78 +++++- packages/diffs/src/editor/textDocument.ts | 21 +- packages/diffs/test/editHistory.test.ts | 68 +++++- packages/diffs/test/textDocument.test.ts | 16 +- 9 files changed, 274 insertions(+), 221 deletions(-) rename packages/diffs/src/editor/{normlizeEditorOptions.ts => editorOptions.ts} (53%) diff --git a/apps/demo/src/main.ts b/apps/demo/src/main.ts index d035f3dc9..21d0a66d7 100644 --- a/apps/demo/src/main.ts +++ b/apps/demo/src/main.ts @@ -809,8 +809,8 @@ if (renderEditorButton != null) { if (wrapper == null) return; cleanupInstances(wrapper); - const editor = new Editor({ theme: DEMO_THEME }); - void editor.render({ editorContainer: wrapper }); + const editor = new Editor({ theme: DEMO_THEME, themeType: getThemeType() }); + editor.render({ editorContainer: wrapper }); editor.setText(tsContent, 'tsx'); editorInstances.push(editor); }); diff --git a/packages/diffs/src/components/Editor.ts b/packages/diffs/src/components/Editor.ts index 2c285ce20..c820068ed 100644 --- a/packages/diffs/src/components/Editor.ts +++ b/packages/diffs/src/components/Editor.ts @@ -1,5 +1,6 @@ import { EncodedTokenMetadata, type IGrammar, INITIAL } from 'shiki/textmate'; +import { normlizeEditorOptions } from '../editor/editorOptions'; import { type EditorShortcutCommand, getPrimaryModifier, @@ -11,6 +12,7 @@ import { createElement, extend, getLineIndentationUnit, + getRootCssVariableValue, measureMonoFontWidth, } from '../editor/editorUtils'; import { @@ -19,15 +21,14 @@ import { mapSelectionTextChange, mapSelectionTextReplace, } from '../editor/multiSelection'; -import { normlizeEditorOptions } from '../editor/normlizeEditorOptions'; import type { EditorSelection } from '../editor/selection'; import { - cloneEditorSelection, convertSelection, createSelection, fromWebSelectionDirection, getPrimarySelection, isCollapsedSelection, + resolveIndentEdits, SelectionDirection, toWebSelectionDirection, } from '../editor/selection'; @@ -40,27 +41,31 @@ import { import { TextDocument, type TextEdit } from '../editor/textDocument'; import { getVisualColumn } from '../editor/visualColumns'; import { getSharedHighlighter } from '../highlighter/shared_highlighter'; -import type { BaseCodeOptions, DiffsHighlighter } from '../types'; +import type { + BaseCodeOptions, + DiffsHighlighter, + ThemeRegistrationResolved, +} from '../types'; import { getHighlighterOptions } from '../utils/getHighlighterOptions'; export interface EditorOptions extends BaseCodeOptions { - tabIndex?: number; fontFamily?: string; fontSize?: number; lineHeight?: number; paddingY?: number; + tabIndex?: number; + tabSize?: number; } export class Editor { - #highlighter?: DiffsHighlighter; - #colorMap?: string[]; + #highlighter?: DiffsHighlighter | Promise; #textDocument?: TextDocument; // options #options: EditorOptions; #fontFamily: string; #fontSize: number; - #lineHeightPx: number; + #lineHeight: number; #paddingY: number; #tabSize: number; #monoFontWidth: number; @@ -98,7 +103,7 @@ export class Editor { this.#options = options; this.#fontFamily = fontFamily; this.#fontSize = fontSize; - this.#lineHeightPx = Math.round(lineHeight); + this.#lineHeight = lineHeight; this.#paddingY = paddingY; this.#tabSize = tabSize; this.#monoFontWidth = measureMonoFontWidth( @@ -138,15 +143,10 @@ export class Editor { setThemeType(themeType: 'dark' | 'light' | 'system'): void { this.#options.themeType = themeType; - this.#colorMap = undefined; // clear color map this.#updateStyle(); } - async render({ - editorContainer, - }: { - editorContainer: HTMLElement; - }): Promise { + render({ editorContainer }: { editorContainer: HTMLElement }): void { if (this.#editorEl !== undefined) { this.cleanUp(); } @@ -203,8 +203,9 @@ export class Editor { }), addEventListener(this.#editorEl, 'mousedown', (e) => { if (e.button === 0 && getPrimaryModifier(e)) { - this.#reservedSelections = - this.#selections?.map(cloneEditorSelection); + this.#reservedSelections = this.#selections?.map((selection) => ({ + ...selection, + })); } }), addEventListener(document, 'mouseup', () => { @@ -267,12 +268,16 @@ export class Editor { queueTextareaSync(); }), ]; - this.#highlighter = await getSharedHighlighter( + this.#highlighter = getSharedHighlighter( getHighlighterOptions(undefined, this.#options) - ); + ).then((highlighter) => { + this.#highlighter = highlighter; + this.#updateStyle(); + return highlighter; + }); this.#updateStyle(); if (this.#textDocument !== undefined) { - void this.#renderText(this.#textDocument, this.#selections); + this.#renderText(this.#textDocument, this.#selections); } editorContainer.appendChild(this.#editorEl); } @@ -300,48 +305,50 @@ export class Editor { #updateStyle() { const editorEl = this.#editorEl; const styleEl = this.#styleEl; - const highlighter = this.#highlighter; - if ( - editorEl === undefined || - styleEl === undefined || - highlighter === undefined - ) { + const options = this.#options; + if (editorEl === undefined || styleEl === undefined) { return; } - let themeType = this.#options.themeType ?? 'system'; - let themeName = this.#options.theme; - if (typeof themeName === 'string') { - themeName = themeName as string; - } else if (themeName !== undefined) { + let themeName: string | undefined; + let theme: ThemeRegistrationResolved | undefined; + let colorMap: string[] | undefined; + if (typeof options.theme === 'string') { + themeName = options.theme; + } else if (typeof options.theme === 'object' && options.theme !== null) { + let themeType = options.themeType ?? 'system'; if (themeType === 'system') { themeType = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; } - themeName = themeName[themeType]; - } else { - themeName = highlighter.getLoadedThemes()[0]; + themeName = options.theme[themeType]; } - - let theme; - if (this.#colorMap === undefined) { - const ret = highlighter.setTheme(themeName); - theme = ret.theme; - this.#colorMap = ret.colorMap; - } else { - theme = highlighter.getTheme(themeName); + if ( + this.#highlighter !== undefined && + !(this.#highlighter instanceof Promise) + ) { + themeName ??= this.#highlighter.getLoadedThemes()[0]; + ({ theme, colorMap } = this.#highlighter.setTheme(themeName)); } - const fontSize = this.#fontSize; - const lineHeightPx = this.#lineHeightPx; - const colors = theme.colors ?? {}; - const foreground = theme.fg; - const background = theme.bg; - const selectionBackground = colors['editor.selectionBackground']; - const lineHighlightBackground = colors['editor.lineHighlightBackground']; + const colors = theme?.colors ?? {}; + const foreground = + theme?.fg ?? + colors['editor.foreground'] ?? + getRootCssVariableValue('--diffs-fg') ?? + ''; + const background = + theme?.bg ?? + colors['editor.background'] ?? + getRootCssVariableValue('--diffs-bg') ?? + ''; + const selectionBackground = + colors['editor.selectionBackground'] ?? 'rgba(128,128,128,0.05)'; const lineNumberForeground = colors['editorLineNumber.foreground'] ?? colors.foreground; + const lineHighlightBackground = colors['editor.lineHighlightBackground']; + const lineHeight = this.#lineHeight; editorEl.style.color = foreground; editorEl.style.backgroundColor = background; @@ -349,20 +356,20 @@ export class Editor { '@scope{' + '::selection{background-color:transparent}' + '@keyframes blinking{0%{opacity:0.9}50%{opacity:0}100%{opacity:0.9}}' + - `pre{position:relative;margin:0;font:inherit;font-size:${fontSize}px;line-height:${lineHeightPx}px;cursor:text;white-space:pre;tab-size:${this.#tabSize}}` + - `.ī{position:absolute;width:2px;height:${lineHeightPx}px;background-color:${foreground};pointer-events:none;animation:blinking 1.2s infinite;animation-delay:0.6s}` + - `.š{position:absolute;z-index:-10;height:${lineHeightPx}px;background-color:${selectionBackground};pointer-events:none}` + - (`.ħ{box-sizing:border-box;position:absolute;z-index:-10;width:100%;height:${lineHeightPx}px;` + + `pre{position:relative;margin:0;font:inherit;font-size:${this.#fontSize}px;line-height:${lineHeight}px;cursor:text;white-space:pre;tab-size:${this.#tabSize}}` + + `.ī{position:absolute;width:2px;height:${lineHeight}px;background-color:${foreground};pointer-events:none;animation:blinking 1.2s infinite;animation-delay:0.6s}` + + `.š{position:absolute;z-index:-10;height:${lineHeight}px;background-color:${selectionBackground};pointer-events:none}` + + (`.ħ{box-sizing:border-box;position:absolute;z-index:-10;width:100%;height:${lineHeight}px;` + (lineHighlightBackground !== undefined ? `background-color:${lineHighlightBackground}` : `border:2px solid ${selectionBackground}`) + ';pointer-events:none}') + ('.ť{position:absolute;z-index:-20;width:100%;padding:0;' + - `line-height:${lineHeightPx}px;` + + `line-height:${lineHeight}px;` + 'font:inherit;background-color:transparent;color:transparent;opacity:0;border:none;outline:none;resize:none}') + `.ń{display:inline-block;text-align:right;width:var(--line-number-width);padding:0 ${this.#monoFontWidth}px;box-sizing:border-box;color:${lineNumberForeground};user-select:none;pointer-events:none;cursor:default}` + `.ǎ>.ń,.ǎ>.ď,.ǎ>.đ{color:${foreground}}` + - this.#colorMap + (colorMap ?? []) .map((color, i) => `.ċ${i.toString(36)}{color:${color}}`) .join('') + '}'; @@ -390,20 +397,24 @@ export class Editor { this.#setLineNumberDigits(lineNumberDigits); let grammar: IGrammar | undefined; - if (this.#highlighter !== undefined) { - if (this.#highlighter.getLoadedLanguages().includes(languageId)) { - grammar = this.#highlighter.getLanguage(languageId); - } else { + const highlighter = this.#highlighter; + if (highlighter !== undefined) { + const loadLanguage = async (highlighter: DiffsHighlighter) => { const requestId = ++this.#languageLoadRequestId; - void this.#highlighter.loadLanguage(languageId).then(() => { - if ( - requestId !== this.#languageLoadRequestId || - this.#textDocument !== textDocument - ) { - return; - } + await highlighter.loadLanguage(languageId); + if ( + requestId === this.#languageLoadRequestId && + this.#textDocument === textDocument + ) { this.#renderText(textDocument, selections); - }); + } + }; + if (highlighter instanceof Promise) { + void highlighter.then(loadLanguage); + } else if (highlighter.getLoadedLanguages().includes(languageId)) { + grammar = highlighter.getLanguage(languageId); + } else { + void loadLanguage(highlighter); } } @@ -654,7 +665,7 @@ export class Editor { } #applyResolvedTextareaChange( - edits: Parameters[0], + edits: TextEdit[], selectionsBefore: EditorSelection[], nextSelections: EditorSelection[] ) { @@ -663,7 +674,6 @@ export class Editor { return; } textDocument.applyEdits(edits, true, selectionsBefore); - textDocument.setLastUndoSelectionsAfter(nextSelections); void this.#renderText(textDocument, nextSelections); } @@ -815,7 +825,7 @@ export class Editor { textareaEl.style.width = `calc(100% - ${this.#gutterWidth}px)`; textareaEl.style.top = this.#getLineY(textareaSnippet.firstLine) + 'px'; textareaEl.style.height = - textareaSnippet.text.split('\n').length * this.#lineHeightPx + 'px'; + textareaSnippet.text.split('\n').length * this.#lineHeight + 'px'; } async #runShortcutCommand(command: EditorShortcutCommand) { @@ -867,7 +877,12 @@ export class Editor { if (lineText !== undefined) { const outdent = command === 'outdent'; if (startLine !== selection.end.line || outdent) { - const ret = this.#resolveIndentEdits(selection, outdent); + const ret = resolveIndentEdits( + this.#textDocument, + selection, + this.#tabSize, + outdent + ); edits.push(...ret[0]); nextSelections.push(ret[1]); } else { @@ -880,8 +895,12 @@ export class Editor { } } if (edits.length > 0) { - this.#textDocument.applyEdits(edits, true, this.#selections); - this.#textDocument.setLastUndoSelectionsAfter(nextSelections); + this.#textDocument.applyEdits( + edits, + true, + this.#selections, + nextSelections + ); void this.#renderText(this.#textDocument, nextSelections); } } @@ -936,66 +955,6 @@ export class Editor { return createSelection(line, character, line, character); } - #resolveIndentEdits( - selection: EditorSelection, - outdent: boolean - ): [edits: TextEdit[], nextSelection: EditorSelection] { - const textDocument = this.#textDocument; - if (textDocument === undefined) { - return [[], selection]; - } - const { start, end } = selection; - let endLine = end.line; - if (start.line < end.line && end.character === 0) { - endLine--; - } - const edits: TextEdit[] = []; - const newSelection: EditorSelection = { ...selection }; - for (let line = start.line; line <= endLine; line++) { - const lineText = textDocument.getLineText(line); - if (lineText === undefined) { - continue; - } - const indentUnit = getLineIndentationUnit(lineText, this.#tabSize); - let deleteLength = 0; - let newText = indentUnit; - if (outdent) { - if (lineText.startsWith('\t')) { - deleteLength = 1; - } else if (lineText.startsWith(' ')) { - const leadingSpacesLength = - lineText.length - lineText.trimStart().length; - deleteLength = Math.min(indentUnit.length, leadingSpacesLength); - } - if (deleteLength === 0) { - continue; - } - newText = ''; - } - edits.push({ - range: { - start: { line, character: 0 }, - end: { line, character: deleteLength }, - }, - newText, - }); - const delte = newText.length - deleteLength; - if (line === start.line) { - newSelection.start = { - ...start, - character: Math.max(0, start.character + delte), - }; - } - if (line === end.line) { - newSelection.end = { - ...end, - character: Math.max(0, end.character + delte), - }; - } - } - return [edits, newSelection]; - } - // replace the selection text #replaceSelectionText(text: string | string[]) { const selections = this.#selections; @@ -1023,13 +982,12 @@ export class Editor { direction: SelectionDirection.None, }); textDocument.applyEdits(edits, true, selections); - textDocument.setLastUndoSelectionsAfter(nextSelections); void this.#renderText(textDocument, nextSelections); } // get line Y position #getLineY(line: number) { - return line * this.#lineHeightPx + this.#paddingY; + return line * this.#lineHeight + this.#paddingY; } // get character X position diff --git a/packages/diffs/src/editor/editHistory.ts b/packages/diffs/src/editor/editHistory.ts index a186193b2..0218a810b 100644 --- a/packages/diffs/src/editor/editHistory.ts +++ b/packages/diffs/src/editor/editHistory.ts @@ -1,4 +1,4 @@ -import { cloneEditorSelection, type EditorSelection } from './selection'; +import { type EditorSelection } from './selection'; export type ResolvedEdit = { start: number; end: number; text: string }; @@ -285,6 +285,7 @@ export class EditHistory { textBefore: string, resolvedEdits: ResolvedEdit[], selectionsBefore: EditorSelection[], + selectionsAfter?: EditorSelection[], coalesceWithinMs?: number ): void { const timestampMs = Date.now(); @@ -324,19 +325,15 @@ export class EditHistory { inverseEdits: inverseEdits, textLengthBefore, textLengthAfter, - selectionsBefore: selectionsBefore?.map(cloneEditorSelection), + selectionsBefore: selectionsBefore?.map((selection) => ({ + ...selection, + })), + selectionsAfter: selectionsAfter?.map((selection) => ({ ...selection })), timestampMs, }); this.#redo.length = 0; } - setLastUndoSelectionsAfter(selections: EditorSelection[]): void { - const lastEntry = this.#undo[this.#undo.length - 1]; - if (lastEntry !== undefined) { - lastEntry.selectionsAfter = selections.map(cloneEditorSelection); - } - } - /** Moves the latest undo entry to the redo stack and returns it, or `undefined` if empty. */ popUndoToRedo(): HistoryEntry | void { const entry = this.#undo.pop(); diff --git a/packages/diffs/src/editor/normlizeEditorOptions.ts b/packages/diffs/src/editor/editorOptions.ts similarity index 53% rename from packages/diffs/src/editor/normlizeEditorOptions.ts rename to packages/diffs/src/editor/editorOptions.ts index c534b93b5..581c5c0b7 100644 --- a/packages/diffs/src/editor/normlizeEditorOptions.ts +++ b/packages/diffs/src/editor/editorOptions.ts @@ -1,33 +1,12 @@ +import type { EditorOptions } from '../components/Editor'; +import { getRootCssVariableValue, parseCssNumber } from './editorUtils'; + const DEFAULT_FONT_FAMILY = "'SF Mono', Monaco, Consolas, 'Ubuntu Mono', 'Liberation Mono', 'Courier New', monospace"; const DEFAULT_FONT_SIZE = 14; const DEFAULT_LINE_HEIGHT = 20; const DEFAULT_PADDING_Y = 10; -function getRootCssVariableValue(variableName: string): string | undefined { - if (typeof window === 'undefined') { - return undefined; - } - const value = window - .getComputedStyle(document.documentElement) - .getPropertyValue(variableName) - .trim(); - return value.length > 0 ? value : undefined; -} - -function parseCssNumber(value: string): number | undefined { - const parsed = Number.parseFloat(value); - return Number.isFinite(parsed) ? parsed : undefined; -} - -export interface EditorTypographyOptions { - fontFamily?: string; - fontSize?: number; - lineHeight?: number; - paddingY?: number; - tabSize?: number; -} - export interface NormalizedEditorOptions { fontFamily: string; fontSize: number; @@ -37,24 +16,25 @@ export interface NormalizedEditorOptions { } export function normlizeEditorOptions( - options: EditorTypographyOptions = {} + options: EditorOptions = {} ): NormalizedEditorOptions { const fontFamily = options.fontFamily ?? getRootCssVariableValue('--diffs-font-family') ?? getRootCssVariableValue('--diffs-font-fallback') ?? DEFAULT_FONT_FAMILY; - - const fontSize = + const fontSize = Math.max( + 10, options.fontSize ?? - parseCssNumber(getRootCssVariableValue('--diffs-font-size') ?? '') ?? - DEFAULT_FONT_SIZE; - - const lineHeight = + parseCssNumber(getRootCssVariableValue('--diffs-font-size') ?? '') ?? + DEFAULT_FONT_SIZE + ); + const lineHeight = Math.max( + 12, options.lineHeight ?? - parseCssNumber(getRootCssVariableValue('--diffs-line-height') ?? '') ?? - DEFAULT_LINE_HEIGHT; - + parseCssNumber(getRootCssVariableValue('--diffs-line-height') ?? '') ?? + DEFAULT_LINE_HEIGHT + ); const paddingY = Math.max(0, options.paddingY ?? DEFAULT_PADDING_Y); const tabSize = Math.max( 1, diff --git a/packages/diffs/src/editor/editorUtils.ts b/packages/diffs/src/editor/editorUtils.ts index c8879c59b..c8ebc3265 100644 --- a/packages/diffs/src/editor/editorUtils.ts +++ b/packages/diffs/src/editor/editorUtils.ts @@ -49,6 +49,25 @@ export function addEventListener( }; } +export function getRootCssVariableValue( + variableName: string +): string | undefined { + const value = getComputedStyle(document.documentElement) + .getPropertyValue(variableName) + .trim(); + return value !== '' ? value : undefined; +} + +export function parseCssNumber(value?: string): number | undefined { + if (value === undefined || value === '') { + return undefined; + } + const f = Number.parseFloat( + value.endsWith('px') ? value.slice(0, -2) : value + ); + return Number.isFinite(f) ? f : undefined; +} + export function coalesceMicrotask(run: () => void): () => void { let queued = false; return () => { diff --git a/packages/diffs/src/editor/selection.ts b/packages/diffs/src/editor/selection.ts index f23e8a599..9e412a9c3 100644 --- a/packages/diffs/src/editor/selection.ts +++ b/packages/diffs/src/editor/selection.ts @@ -1,4 +1,5 @@ -import type { Position, Range } from './textDocument'; +import { getLineIndentationUnit } from './editorUtils'; +import type { Position, Range, TextDocument, TextEdit } from './textDocument'; export enum SelectionDirection { Backward = -1, @@ -69,6 +70,67 @@ export function convertSelection({ }; } +export function resolveIndentEdits( + textDocument: TextDocument, + selection: EditorSelection, + tabSize: number, + outdent: boolean +): [edits: TextEdit[], nextSelection: EditorSelection] { + if (textDocument === undefined) { + return [[], selection]; + } + const { start, end } = selection; + let endLine = end.line; + if (start.line < end.line && end.character === 0) { + endLine--; + } + const edits: TextEdit[] = []; + const newSelection: EditorSelection = { ...selection }; + for (let line = start.line; line <= endLine; line++) { + const lineText = textDocument.getLineText(line); + if (lineText === undefined) { + continue; + } + const indentUnit = getLineIndentationUnit(lineText, tabSize); + let deleteLength = 0; + let newText = indentUnit; + if (outdent) { + if (lineText.startsWith('\t')) { + deleteLength = 1; + } else if (lineText.startsWith(' ')) { + const leadingSpacesLength = + lineText.length - lineText.trimStart().length; + deleteLength = Math.min(indentUnit.length, leadingSpacesLength); + } + if (deleteLength === 0) { + continue; + } + newText = ''; + } + edits.push({ + range: { + start: { line, character: 0 }, + end: { line, character: deleteLength }, + }, + newText, + }); + const delte = newText.length - deleteLength; + if (line === start.line) { + newSelection.start = { + ...start, + character: Math.max(0, start.character + delte), + }; + } + if (line === end.line) { + newSelection.end = { + ...end, + character: Math.max(0, end.character + delte), + }; + } + } + return [edits, newSelection]; +} + export function isCollapsedSelection(selection: EditorSelection): boolean { return ( selection.start.line === selection.end.line && @@ -76,21 +138,11 @@ export function isCollapsedSelection(selection: EditorSelection): boolean { ); } -export function cloneEditorSelection( - selection: EditorSelection -): EditorSelection { - return { - start: { ...selection.start }, - end: { ...selection.end }, - direction: selection.direction, - }; -} - export function getPrimarySelection( selections: readonly EditorSelection[] ): EditorSelection | undefined { const selection = selections[selections.length - 1]; - return selection !== undefined ? cloneEditorSelection(selection) : undefined; + return selection !== undefined ? { ...selection } : undefined; } export function normalizeSelections( @@ -102,7 +154,7 @@ export function normalizeSelections( const primarySelection = selections[selections.length - 1]; const ordered = selections .map((selection, index) => ({ - selection: cloneEditorSelection(selection), + selection: { ...selection }, index, isPrimary: selection === primarySelection, })) diff --git a/packages/diffs/src/editor/textDocument.ts b/packages/diffs/src/editor/textDocument.ts index 87ebd2394..5b593b807 100644 --- a/packages/diffs/src/editor/textDocument.ts +++ b/packages/diffs/src/editor/textDocument.ts @@ -3,7 +3,7 @@ import { EditHistory, type ResolvedEdit, } from './editHistory'; -import { cloneEditorSelection, type EditorSelection } from './selection'; +import { type EditorSelection } from './selection'; /** * Position in a text document expressed as zero-based line and character offset. @@ -160,7 +160,8 @@ export class TextDocument { applyEdits( edits: TextEdit[], updateHistory = false, - selectionsBefore?: EditorSelection[] + selectionsBefore?: EditorSelection[], + selectionsAfter?: EditorSelection[] ): void { if (edits.length === 0) { return; @@ -169,15 +170,17 @@ export class TextDocument { const textBefore = this.#text; const newText = applyOffsetEdits(textBefore, resolvedEdits); if (updateHistory && selectionsBefore !== undefined) { - this.#history.push(textBefore, resolvedEdits, selectionsBefore, 500); + this.#history.push( + textBefore, + resolvedEdits, + selectionsBefore, + selectionsAfter, + 500 + ); } this.#setDocumentText(newText); } - setLastUndoSelectionsAfter(selections: EditorSelection[]): void { - this.#history.setLastUndoSelectionsAfter(selections); - } - undo(): EditorSelection[] | undefined { const entry = this.#history.popUndoToRedo(); if (entry === undefined) { @@ -185,7 +188,7 @@ export class TextDocument { } this.#setDocumentText(applyOffsetEdits(this.#text, entry.inverseEdits)); return entry.selectionsBefore !== undefined - ? entry.selectionsBefore.map(cloneEditorSelection) + ? entry.selectionsBefore.map((selection) => ({ ...selection })) : undefined; } @@ -196,7 +199,7 @@ export class TextDocument { } this.#setDocumentText(applyOffsetEdits(this.#text, entry.forwardEdits)); return entry.selectionsAfter !== undefined - ? entry.selectionsAfter.map(cloneEditorSelection) + ? entry.selectionsAfter.map((selection) => ({ ...selection })) : undefined; } diff --git a/packages/diffs/test/editHistory.test.ts b/packages/diffs/test/editHistory.test.ts index eefa2fd23..cfd3eb734 100644 --- a/packages/diffs/test/editHistory.test.ts +++ b/packages/diffs/test/editHistory.test.ts @@ -67,8 +67,13 @@ describe('EditHistory', () => { const selectionBefore = [caret(0), caret(1)]; const selectionAfter = [caret(2), caret(3)]; - history.push('ab', [{ start: 1, end: 1, text: 'X' }], selectionBefore, -1); - history.setLastUndoSelectionsAfter(selectionAfter); + history.push( + 'ab', + [{ start: 1, end: 1, text: 'X' }], + selectionBefore, + selectionAfter, + -1 + ); selectionBefore[0] = caret(99); selectionAfter[0] = caret(99); @@ -99,8 +104,13 @@ describe('EditHistory', () => { const history = new EditHistory(); let selectionAfter = caret(2); - history.push('a', [{ start: 1, end: 1, text: 'b' }], [caret(1)], -1); - history.setLastUndoSelectionsAfter([selectionAfter]); + history.push( + 'a', + [{ start: 1, end: 1, text: 'b' }], + [caret(1)], + [selectionAfter], + -1 + ); selectionAfter = caret(99); expect(history.popUndoToRedo()).toMatchObject({ @@ -119,11 +129,21 @@ describe('EditHistory', () => { }); try { - history.push('', [{ start: 0, end: 0, text: 'a' }], [caret(0)], 1000); - history.setLastUndoSelectionsAfter([caret(1)]); + history.push( + '', + [{ start: 0, end: 0, text: 'a' }], + [caret(0)], + [caret(1)], + 1000 + ); now += 400; - history.push('a', [{ start: 1, end: 1, text: 'b' }], [caret(1)], 1000); - history.setLastUndoSelectionsAfter([caret(2)]); + history.push( + 'a', + [{ start: 1, end: 1, text: 'b' }], + [caret(1)], + [caret(2)], + 1000 + ); const entry = history.popUndoToRedo(); @@ -148,15 +168,33 @@ describe('EditHistory', () => { test('push clears redo history when recording a new undo entry', () => { const history = new EditHistory(); - history.push('', [{ start: 0, end: 0, text: 'a' }], [caret(0)], -1); - history.push('a', [{ start: 1, end: 1, text: 'b' }], [caret(1)], -1); + history.push( + '', + [{ start: 0, end: 0, text: 'a' }], + [caret(0)], + undefined, + -1 + ); + history.push( + 'a', + [{ start: 1, end: 1, text: 'b' }], + [caret(1)], + undefined, + -1 + ); expect(history.popUndoToRedo()).toMatchObject({ forwardEdits: [{ start: 1, end: 1, text: 'b' }], }); expect(history.canRedo).toBe(true); - history.push('a', [{ start: 1, end: 1, text: 'c' }], [caret(1)], -1); + history.push( + 'a', + [{ start: 1, end: 1, text: 'c' }], + [caret(1)], + undefined, + -1 + ); expect(history.canRedo).toBe(false); expect(history.popUndoToRedo()).toMatchObject({ @@ -170,7 +208,13 @@ describe('EditHistory', () => { test('clear resets both undo and redo stacks', () => { const history = new EditHistory(); - history.push('', [{ start: 0, end: 0, text: 'a' }], [caret(0)], -1); + history.push( + '', + [{ start: 0, end: 0, text: 'a' }], + [caret(0)], + undefined, + -1 + ); history.popUndoToRedo(); history.clear(); diff --git a/packages/diffs/test/textDocument.test.ts b/packages/diffs/test/textDocument.test.ts index dd79eba2a..ad14cb42b 100644 --- a/packages/diffs/test/textDocument.test.ts +++ b/packages/diffs/test/textDocument.test.ts @@ -517,9 +517,9 @@ describe('TextDocument', () => { }, ], true, - [selectionBefore] + [selectionBefore], + [selectionAfter] ); - d.setLastUndoSelectionsAfter([selectionAfter]); expect(d.undo()).toEqual([selectionBefore]); expect(d.redo()).toEqual([selectionAfter]); @@ -527,8 +527,8 @@ describe('TextDocument', () => { test('undo and redo preserve multiple selections', () => { const d = doc('a\nb'); - const selectionBefore = [caret(0, 1), caret(1, 1)]; - const selectionAfter = [caret(0, 2), caret(1, 2)]; + const selectionsBefore = [caret(0, 1), caret(1, 1)]; + const selectionsAfter = [caret(0, 2), caret(1, 2)]; d.applyEdits( [ { @@ -547,11 +547,11 @@ describe('TextDocument', () => { }, ], true, - selectionBefore + selectionsBefore, + selectionsAfter ); - d.setLastUndoSelectionsAfter(selectionAfter); - expect(d.undo()).toEqual(selectionBefore); - expect(d.redo()).toEqual(selectionAfter); + expect(d.undo()).toEqual(selectionsBefore); + expect(d.redo()).toEqual(selectionsAfter); }); }); From fc88eb867d85b41cb4249becabd50862c25a5e0f Mon Sep 17 00:00:00 2001 From: Je Xia Date: Sat, 25 Apr 2026 12:36:09 +0800 Subject: [PATCH 010/138] Add `minNumberColumnWidth` option --- packages/diffs/src/components/Editor.ts | 120 ++++++++++----------- packages/diffs/src/editor/editHistory.ts | 3 + packages/diffs/src/editor/editorOptions.ts | 49 +++++++-- packages/diffs/src/editor/editorUtils.ts | 25 +++-- packages/diffs/src/editor/visualColumns.ts | 2 +- packages/diffs/test/editorUtils.test.ts | 17 ++- packages/diffs/test/visualColumns.test.ts | 22 ++-- 7 files changed, 146 insertions(+), 92 deletions(-) diff --git a/packages/diffs/src/components/Editor.ts b/packages/diffs/src/components/Editor.ts index c820068ed..7eea63be2 100644 --- a/packages/diffs/src/components/Editor.ts +++ b/packages/diffs/src/components/Editor.ts @@ -1,6 +1,9 @@ import { EncodedTokenMetadata, type IGrammar, INITIAL } from 'shiki/textmate'; -import { normlizeEditorOptions } from '../editor/editorOptions'; +import { + type NormalizedEditorOptions, + normlizeEditorOptions, +} from '../editor/editorOptions'; import { type EditorShortcutCommand, getPrimaryModifier, @@ -39,7 +42,7 @@ import { type TextareaState, } from '../editor/textareaState'; import { TextDocument, type TextEdit } from '../editor/textDocument'; -import { getVisualColumn } from '../editor/visualColumns'; +import { getVisualColumns } from '../editor/visualColumns'; import { getSharedHighlighter } from '../highlighter/shared_highlighter'; import type { BaseCodeOptions, @@ -55,21 +58,16 @@ export interface EditorOptions extends BaseCodeOptions { paddingY?: number; tabIndex?: number; tabSize?: number; + minNumberColumnWidth?: number; } export class Editor { + #options: NormalizedEditorOptions; #highlighter?: DiffsHighlighter | Promise; #textDocument?: TextDocument; - // options - #options: EditorOptions; - #fontFamily: string; - #fontSize: number; - #lineHeight: number; - #paddingY: number; - #tabSize: number; - #monoFontWidth: number; - #lineNumberWidth: number; + // computed width values + #monoCharWidth: number; #gutterWidth: number; // dom elements @@ -98,19 +96,11 @@ export class Editor { #disposes?: (() => void)[]; constructor(options: EditorOptions = {}) { - const { fontFamily, fontSize, lineHeight, paddingY, tabSize } = - normlizeEditorOptions(options); - this.#options = options; - this.#fontFamily = fontFamily; - this.#fontSize = fontSize; - this.#lineHeight = lineHeight; - this.#paddingY = paddingY; - this.#tabSize = tabSize; - this.#monoFontWidth = measureMonoFontWidth( - 'normal ' + this.#fontSize + 'px ' + this.#fontFamily + this.#options = normlizeEditorOptions(options); + this.#monoCharWidth = measureMonoFontWidth( + 'normal ' + this.#options.fontSize + 'px ' + this.#options.fontFamily ); - this.#lineNumberWidth = this.#monoFontWidth; - this.#gutterWidth = this.#lineNumberWidth; + this.#gutterWidth = 0; } get options(): EditorOptions { @@ -138,7 +128,7 @@ export class Editor { this.#textareaState = undefined; this.#reservedSelections = undefined; this.#selections = undefined; - void this.#renderText(textDocument); + this.#renderText(textDocument); } setThemeType(themeType: 'dark' | 'light' | 'system'): void { @@ -151,7 +141,6 @@ export class Editor { this.cleanUp(); } const { tabIndex = -1 } = this.#options; - const fontFamily = this.#fontFamily; const queueTextareaSync = coalesceMicrotask(() => this.#syncTextareaState() ); @@ -160,9 +149,10 @@ export class Editor { style: { position: 'relative', boxSizing: 'border-box', - paddingTop: `${this.#paddingY}px`, - paddingBottom: `${this.#paddingY}px`, - fontFamily, + paddingTop: `${this.#options.paddingY}px`, + paddingBottom: `${this.#options.paddingY}px`, + fontFamily: this.#options.fontFamily, + fontFeatureSettings: 'var(--diffs-font-features)', isolation: 'isolate', }, }), @@ -170,10 +160,6 @@ export class Editor { tabIndex, } ); - this.#editorEl.style.setProperty( - '--line-number-width', - this.#lineNumberWidth + 'px' - ); this.#styleEl = createElement('style', undefined, this.#editorEl); this.#textareaEl = extend( createElement('textarea', { class: 'ť' }, this.#editorEl), @@ -348,7 +334,7 @@ export class Editor { const lineNumberForeground = colors['editorLineNumber.foreground'] ?? colors.foreground; const lineHighlightBackground = colors['editor.lineHighlightBackground']; - const lineHeight = this.#lineHeight; + const { lineHeight, fontSize, tabSize } = this.#options; editorEl.style.color = foreground; editorEl.style.backgroundColor = background; @@ -356,7 +342,7 @@ export class Editor { '@scope{' + '::selection{background-color:transparent}' + '@keyframes blinking{0%{opacity:0.9}50%{opacity:0}100%{opacity:0.9}}' + - `pre{position:relative;margin:0;font:inherit;font-size:${this.#fontSize}px;line-height:${lineHeight}px;cursor:text;white-space:pre;tab-size:${this.#tabSize}}` + + `pre{position:relative;margin:0;font:inherit;font-size:${fontSize}px;line-height:${lineHeight}px;cursor:text;white-space:pre;tab-size:${tabSize}}` + `.ī{position:absolute;width:2px;height:${lineHeight}px;background-color:${foreground};pointer-events:none;animation:blinking 1.2s infinite;animation-delay:0.6s}` + `.š{position:absolute;z-index:-10;height:${lineHeight}px;background-color:${selectionBackground};pointer-events:none}` + (`.ħ{box-sizing:border-box;position:absolute;z-index:-10;width:100%;height:${lineHeight}px;` + @@ -367,7 +353,7 @@ export class Editor { ('.ť{position:absolute;z-index:-20;width:100%;padding:0;' + `line-height:${lineHeight}px;` + 'font:inherit;background-color:transparent;color:transparent;opacity:0;border:none;outline:none;resize:none}') + - `.ń{display:inline-block;text-align:right;width:var(--line-number-width);padding:0 ${this.#monoFontWidth}px;box-sizing:border-box;color:${lineNumberForeground};user-select:none;pointer-events:none;cursor:default}` + + `.ń{display:inline-block;text-align:right;width:var(--diffs-editor-line-number-width);padding:0 ${this.#monoCharWidth}px;color:${lineNumberForeground};user-select:none;pointer-events:none;cursor:default}` + `.ǎ>.ń,.ǎ>.ď,.ǎ>.đ{color:${foreground}}` + (colorMap ?? []) .map((color, i) => `.ċ${i.toString(36)}{color:${color}}`) @@ -375,17 +361,6 @@ export class Editor { '}'; } - #setLineNumberDigits(lineNumberDigits: number) { - this.#lineNumberWidth = Math.round( - (lineNumberDigits + 2) * this.#monoFontWidth - ); - this.#gutterWidth = this.#lineNumberWidth; - this.#editorEl?.style.setProperty( - '--line-number-width', - this.#lineNumberWidth + 'px' - ); - } - #renderText( textDocument: TextDocument, selections?: EditorSelection[] @@ -393,8 +368,18 @@ export class Editor { const totalLines = textDocument.lineCount; const languageId = textDocument.languageId; - const lineNumberDigits = Math.max(2, totalLines.toString().length); - this.#setLineNumberDigits(lineNumberDigits); + // update gutter width + const lineNumberDigits = totalLines.toString().length; + const lineNumberWidth = Math.round( + Math.max(this.#options.minNumberColumnWidth, lineNumberDigits) * + this.#monoCharWidth + ); + const lineNumberPadding = 2 * this.#monoCharWidth; + this.#editorEl?.style.setProperty( + '--diffs-editor-line-number-width', + lineNumberWidth + 'px' + ); + this.#gutterWidth = lineNumberWidth + lineNumberPadding; let grammar: IGrammar | undefined; const highlighter = this.#highlighter; @@ -674,7 +659,7 @@ export class Editor { return; } textDocument.applyEdits(edits, true, selectionsBefore); - void this.#renderText(textDocument, nextSelections); + this.#renderText(textDocument, nextSelections); } #restoreSelections(selections: EditorSelection[]) { @@ -728,14 +713,18 @@ export class Editor { const lineLength = lineText.length; const startCharacter = ln === start.line ? start.character : 0; const endCharacter = ln === end.line ? end.character : lineLength; - const startColumn = getVisualColumn( + const startColumns = getVisualColumns( lineText, startCharacter, - this.#tabSize + this.#options.tabSize + ); + const endColumns = getVisualColumns( + lineText, + endCharacter, + this.#options.tabSize ); - const endColumn = getVisualColumn(lineText, endCharacter, this.#tabSize); - const startX = this.#getCharacterX(ln, startCharacter, startColumn); - const endX = this.#getCharacterX(ln, endCharacter, endColumn); + const startX = this.#getCharacterX(ln, startCharacter, startColumns); + const endX = this.#getCharacterX(ln, endCharacter, endColumns); const spacing = endCharacter === startCharacter || ln === end.line ? 0 : 4; const style = { @@ -765,7 +754,7 @@ export class Editor { this.#textDocument?.getLineText(isBackward ? start.line : end.line) ?? ''; const line = isBackward ? start.line : end.line; const character = isBackward ? start.character : end.character; - const column = getVisualColumn(lineText, character, this.#tabSize); + const column = getVisualColumns(lineText, character, this.#options.tabSize); const left = this.#getCharacterX(line, character, column); const cursorEl = createElement( 'div', @@ -825,7 +814,7 @@ export class Editor { textareaEl.style.width = `calc(100% - ${this.#gutterWidth}px)`; textareaEl.style.top = this.#getLineY(textareaSnippet.firstLine) + 'px'; textareaEl.style.height = - textareaSnippet.text.split('\n').length * this.#lineHeight + 'px'; + textareaSnippet.text.split('\n').length * this.#options.lineHeight + 'px'; } async #runShortcutCommand(command: EditorShortcutCommand) { @@ -880,7 +869,7 @@ export class Editor { const ret = resolveIndentEdits( this.#textDocument, selection, - this.#tabSize, + this.#options.tabSize, outdent ); edits.push(...ret[0]); @@ -888,7 +877,7 @@ export class Editor { } else { const indentUnit = getLineIndentationUnit( lineText, - this.#tabSize + this.#options.tabSize ); this.#replaceSelectionText(indentUnit); } @@ -901,7 +890,7 @@ export class Editor { this.#selections, nextSelections ); - void this.#renderText(this.#textDocument, nextSelections); + this.#renderText(this.#textDocument, nextSelections); } } break; @@ -915,13 +904,13 @@ export class Editor { case 'undo': if (this.#textDocument?.canUndo === true) { - void this.#renderText(this.#textDocument, this.#textDocument.undo()); + this.#renderText(this.#textDocument, this.#textDocument.undo()); } break; case 'redo': if (this.#textDocument?.canRedo === true) { - void this.#renderText(this.#textDocument, this.#textDocument.redo()); + this.#renderText(this.#textDocument, this.#textDocument.redo()); } break; } @@ -982,18 +971,19 @@ export class Editor { direction: SelectionDirection.None, }); textDocument.applyEdits(edits, true, selections); - void this.#renderText(textDocument, nextSelections); + this.#renderText(textDocument, nextSelections); } // get line Y position #getLineY(line: number) { - return line * this.#lineHeight + this.#paddingY; + return line * this.#options.lineHeight + this.#options.paddingY; } // get character X position // todo: does it support emoji/non-ascii input? - #getCharacterX(line: number, character: number, visualColumn: number) { - const fallbackLeft = this.#gutterWidth + visualColumn * this.#monoFontWidth; + #getCharacterX(line: number, character: number, visualColumns: number) { + const fallbackLeft = + this.#gutterWidth + visualColumns * this.#monoCharWidth; const lineEl = this.#textLineEls?.get(line); const editorEl = this.#editorEl; if (lineEl === undefined || editorEl === undefined) { diff --git a/packages/diffs/src/editor/editHistory.ts b/packages/diffs/src/editor/editHistory.ts index 0218a810b..e10b769d0 100644 --- a/packages/diffs/src/editor/editHistory.ts +++ b/packages/diffs/src/editor/editHistory.ts @@ -317,6 +317,9 @@ export class EditHistory { textLengthAfter ); lastEntry.textLengthAfter = textLengthAfter; + lastEntry.selectionsAfter = selectionsAfter?.map((selection) => ({ + ...selection, + })); lastEntry.timestampMs = timestampMs; return; } diff --git a/packages/diffs/src/editor/editorOptions.ts b/packages/diffs/src/editor/editorOptions.ts index 581c5c0b7..a8654b245 100644 --- a/packages/diffs/src/editor/editorOptions.ts +++ b/packages/diffs/src/editor/editorOptions.ts @@ -1,18 +1,20 @@ import type { EditorOptions } from '../components/Editor'; -import { getRootCssVariableValue, parseCssNumber } from './editorUtils'; +import { getRootCssVariableValue, parseCssValue } from './editorUtils'; const DEFAULT_FONT_FAMILY = "'SF Mono', Monaco, Consolas, 'Ubuntu Mono', 'Liberation Mono', 'Courier New', monospace"; const DEFAULT_FONT_SIZE = 14; const DEFAULT_LINE_HEIGHT = 20; const DEFAULT_PADDING_Y = 10; +const DEFAULT_MIN_NUMBER_COLUMN_WIDTH = 3; -export interface NormalizedEditorOptions { +export interface NormalizedEditorOptions extends EditorOptions { fontFamily: string; fontSize: number; lineHeight: number; paddingY: number; tabSize: number; + minNumberColumnWidth: number; } export function normlizeEditorOptions( @@ -26,30 +28,63 @@ export function normlizeEditorOptions( const fontSize = Math.max( 10, options.fontSize ?? - parseCssNumber(getRootCssVariableValue('--diffs-font-size') ?? '') ?? + getCssVariableAsNumber('--diffs-font-size') ?? DEFAULT_FONT_SIZE ); const lineHeight = Math.max( 12, options.lineHeight ?? - parseCssNumber(getRootCssVariableValue('--diffs-line-height') ?? '') ?? + getLineHeightFromCssVariable('--diffs-line-height', fontSize) ?? DEFAULT_LINE_HEIGHT ); const paddingY = Math.max(0, options.paddingY ?? DEFAULT_PADDING_Y); const tabSize = Math.max( 1, Math.floor( - options.tabSize ?? - parseCssNumber(getRootCssVariableValue('--diffs-tab-size') ?? '') ?? - 2 + options.tabSize ?? getCssVariableAsNumber('--diffs-tab-size') ?? 2 ) ); + const minNumberColumnWidth = Math.max( + 1, + getCssVariableAsNumber('--diffs-min-number-column-width') ?? + options.minNumberColumnWidth ?? + DEFAULT_MIN_NUMBER_COLUMN_WIDTH + ); return { + ...options, fontFamily, fontSize, lineHeight, paddingY, tabSize, + minNumberColumnWidth, }; } + +function getCssVariableAsNumber(variableName: string): number | undefined { + const cssPropertyValue = getRootCssVariableValue(variableName); + if (cssPropertyValue === '' || cssPropertyValue === undefined) { + return undefined; + } + return parseCssValue(cssPropertyValue)[0]; +} + +function getLineHeightFromCssVariable( + variableName: string, + fontSize: number +): number | undefined { + const cssPropertyValue = getRootCssVariableValue(variableName); + if (cssPropertyValue === '' || cssPropertyValue === undefined) { + return undefined; + } + const [value, unit] = parseCssValue(cssPropertyValue); + if (unit === 'px') { + return value; + } + if (unit === '' || unit === 'em') { + return value * fontSize; + } + // unsupported units + return undefined; +} diff --git a/packages/diffs/src/editor/editorUtils.ts b/packages/diffs/src/editor/editorUtils.ts index c8ebc3265..e723801c6 100644 --- a/packages/diffs/src/editor/editorUtils.ts +++ b/packages/diffs/src/editor/editorUtils.ts @@ -58,14 +58,25 @@ export function getRootCssVariableValue( return value !== '' ? value : undefined; } -export function parseCssNumber(value?: string): number | undefined { - if (value === undefined || value === '') { - return undefined; +export function parseCssValue(value: string): [value: number, unit: string] { + const parsedValue = Number.parseFloat(value); + if (!Number.isFinite(parsedValue)) { + return [0, '']; } - const f = Number.parseFloat( - value.endsWith('px') ? value.slice(0, -2) : value - ); - return Number.isFinite(f) ? f : undefined; + let unitStartIndex = -1; + for (let i = 0; i < value.length; i++) { + const code = value.charCodeAt(i); + if ( + code !== /*.*/ 46 && + (code < /*0*/ 48 || code > /*9*/ 57) && + i !== 0 && + i !== value.length - 1 + ) { + unitStartIndex = i; + break; + } + } + return [parsedValue, unitStartIndex > 0 ? value.slice(unitStartIndex) : '']; } export function coalesceMicrotask(run: () => void): () => void { diff --git a/packages/diffs/src/editor/visualColumns.ts b/packages/diffs/src/editor/visualColumns.ts index 543e22abd..36778cd52 100644 --- a/packages/diffs/src/editor/visualColumns.ts +++ b/packages/diffs/src/editor/visualColumns.ts @@ -1,4 +1,4 @@ -export function getVisualColumn( +export function getVisualColumns( text: string, character: number, tabSize: number diff --git a/packages/diffs/test/editorUtils.test.ts b/packages/diffs/test/editorUtils.test.ts index f7e26f405..07bb3d3b2 100644 --- a/packages/diffs/test/editorUtils.test.ts +++ b/packages/diffs/test/editorUtils.test.ts @@ -1,6 +1,21 @@ import { describe, expect, test } from 'bun:test'; -import { coalesceMicrotask } from '../src/editor/editorUtils'; +import { coalesceMicrotask, parseCssValue } from '../src/editor/editorUtils'; + +describe('parseCssValue', () => { + const cases: Array<[string, [number, string]]> = [ + ['abc', [0, '']], + ['14', [14, '']], + ['1.5', [1.5, '']], + ['14px', [14, 'px']], + ['1.25rem', [1.25, 'rem']], + ['-2em', [-2, 'em']], + ]; + + test.each(cases)('parses %p as %p', (value, expected) => { + expect(parseCssValue(value)).toEqual(expected); + }); +}); describe('coalesceMicrotask', () => { test('runs once for repeated calls in the same tick', async () => { diff --git a/packages/diffs/test/visualColumns.test.ts b/packages/diffs/test/visualColumns.test.ts index 8396cc0ee..918c27861 100644 --- a/packages/diffs/test/visualColumns.test.ts +++ b/packages/diffs/test/visualColumns.test.ts @@ -1,24 +1,24 @@ import { describe, expect, test } from 'bun:test'; -import { getVisualColumn } from '../src/editor/visualColumns'; +import { getVisualColumns } from '../src/editor/visualColumns'; describe('getVisualColumn', () => { test('keeps plain text columns unchanged', () => { - expect(getVisualColumn('hello', 0, 2)).toBe(0); - expect(getVisualColumn('hello', 3, 2)).toBe(3); - expect(getVisualColumn('hello', 99, 2)).toBe(5); + expect(getVisualColumns('hello', 0, 2)).toBe(0); + expect(getVisualColumns('hello', 3, 2)).toBe(3); + expect(getVisualColumns('hello', 99, 2)).toBe(5); }); test('expands tabs to the configured tab size', () => { - expect(getVisualColumn('\ta', 1, 2)).toBe(2); - expect(getVisualColumn('\ta', 1, 4)).toBe(4); - expect(getVisualColumn('\ta', 2, 2)).toBe(3); + expect(getVisualColumns('\ta', 1, 2)).toBe(2); + expect(getVisualColumns('\ta', 1, 4)).toBe(4); + expect(getVisualColumns('\ta', 2, 2)).toBe(3); }); test('aligns tab stops based on current visual column', () => { - expect(getVisualColumn('a\tb', 2, 2)).toBe(2); - expect(getVisualColumn('a\tb', 2, 4)).toBe(4); - expect(getVisualColumn('ab\tc', 3, 4)).toBe(4); - expect(getVisualColumn('abc\tz', 4, 4)).toBe(4); + expect(getVisualColumns('a\tb', 2, 2)).toBe(2); + expect(getVisualColumns('a\tb', 2, 4)).toBe(4); + expect(getVisualColumns('ab\tc', 3, 4)).toBe(4); + expect(getVisualColumns('abc\tz', 4, 4)).toBe(4); }); }); From 01135435e5d7370abedf93afb21799e1f9954b61 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Sat, 25 Apr 2026 15:58:56 +0800 Subject: [PATCH 011/138] Refactor --- packages/diffs/src/components/Editor.ts | 120 +++++++-------- .../{editorShortcuts.ts => editorCommand.ts} | 8 +- packages/diffs/src/editor/multiSelection.ts | 137 ++++++------------ packages/diffs/src/editor/selection.ts | 69 --------- packages/diffs/test/editHistory.test.ts | 17 ++- ...hortcuts.test.ts => editorCommand.test.ts} | 12 +- packages/diffs/test/multiSelection.test.ts | 42 ++++-- packages/diffs/test/selection.test.ts | 37 +---- packages/diffs/test/textDocument.test.ts | 16 +- packages/diffs/test/textareaState.test.ts | 15 +- 10 files changed, 172 insertions(+), 301 deletions(-) rename packages/diffs/src/editor/{editorShortcuts.ts => editorCommand.ts} (88%) rename packages/diffs/test/{editorShortcuts.test.ts => editorCommand.test.ts} (92%) diff --git a/packages/diffs/src/components/Editor.ts b/packages/diffs/src/components/Editor.ts index 7eea63be2..7048c49aa 100644 --- a/packages/diffs/src/components/Editor.ts +++ b/packages/diffs/src/components/Editor.ts @@ -1,14 +1,14 @@ import { EncodedTokenMetadata, type IGrammar, INITIAL } from 'shiki/textmate'; +import { + type EditorCommand, + getPrimaryModifier, + resolveEditorCommandFromKeyboardEvent, +} from '../editor/editorCommand'; import { type NormalizedEditorOptions, normlizeEditorOptions, } from '../editor/editorOptions'; -import { - type EditorShortcutCommand, - getPrimaryModifier, - resolveEditorShortcutCommand, -} from '../editor/editorShortcuts'; import { addEventListener, coalesceMicrotask, @@ -20,14 +20,13 @@ import { } from '../editor/editorUtils'; import { getOrderedSelectionText, - mapSelectionRangeChange, + mapSelectionMove, mapSelectionTextChange, mapSelectionTextReplace, } from '../editor/multiSelection'; import type { EditorSelection } from '../editor/selection'; import { convertSelection, - createSelection, fromWebSelectionDirection, getPrimarySelection, isCollapsedSelection, @@ -43,7 +42,10 @@ import { } from '../editor/textareaState'; import { TextDocument, type TextEdit } from '../editor/textDocument'; import { getVisualColumns } from '../editor/visualColumns'; -import { getSharedHighlighter } from '../highlighter/shared_highlighter'; +import { + getHighlighterIfLoaded, + getSharedHighlighter, +} from '../highlighter/shared_highlighter'; import type { BaseCodeOptions, DiffsHighlighter, @@ -134,6 +136,9 @@ export class Editor { setThemeType(themeType: 'dark' | 'light' | 'system'): void { this.#options.themeType = themeType; this.#updateStyle(); + if (this.#textDocument !== undefined) { + this.#renderText(this.#textDocument, this.#selections); + } } render({ editorContainer }: { editorContainer: HTMLElement }): void { @@ -141,7 +146,7 @@ export class Editor { this.cleanUp(); } const { tabIndex = -1 } = this.#options; - const queueTextareaSync = coalesceMicrotask(() => + const queueTextareaStateSync = coalesceMicrotask(() => this.#syncTextareaState() ); this.#editorEl = extend( @@ -215,34 +220,28 @@ export class Editor { return; } if (this.#isTextareaElFocused !== true) { - const command = resolveEditorShortcutCommand(e); + const command = resolveEditorCommandFromKeyboardEvent(e); if (command !== undefined) { this.#flushPendingTextareaChanges(); e.preventDefault(); - void this.#runShortcutCommand(command); + console.log('keydown', command); + void this.#runCommand(command); return; } } - if ( - this.#isTextareaElFocused !== true && - this.#isEditorElFocused === true && - e.key !== 'Shift' && - e.key !== 'Control' && - e.key !== 'Alt' && - e.key !== 'Meta' - ) { + if (this.#isEditorElFocused === true) { this.#textareaEl?.focus(); } }), addEventListener(this.#textareaEl, 'keydown', (e) => { - const command = resolveEditorShortcutCommand(e); + const command = resolveEditorCommandFromKeyboardEvent(e); if (command !== undefined) { this.#flushPendingTextareaChanges(); e.preventDefault(); - void this.#runShortcutCommand(command); + void this.#runCommand(command); } }), - addEventListener(this.#textareaEl, 'input', queueTextareaSync), + addEventListener(this.#textareaEl, 'input', queueTextareaStateSync), addEventListener(this.#textareaEl, 'selectionchange', () => { if ( this.#textareaState !== undefined && @@ -251,16 +250,18 @@ export class Editor { ) { return; } - queueTextareaSync(); + queueTextareaStateSync(); }), ]; - this.#highlighter = getSharedHighlighter( - getHighlighterOptions(undefined, this.#options) - ).then((highlighter) => { - this.#highlighter = highlighter; - this.#updateStyle(); - return highlighter; - }); + this.#highlighter = + getHighlighterIfLoaded() ?? + getSharedHighlighter( + getHighlighterOptions(undefined, this.#options) + ).then((highlighter) => { + this.#highlighter = highlighter; + this.#updateStyle(); + return highlighter; + }); this.#updateStyle(); if (this.#textDocument !== undefined) { this.#renderText(this.#textDocument, this.#selections); @@ -336,8 +337,10 @@ export class Editor { const lineHighlightBackground = colors['editor.lineHighlightBackground']; const { lineHeight, fontSize, tabSize } = this.#options; - editorEl.style.color = foreground; - editorEl.style.backgroundColor = background; + extend(editorEl.style, { + color: foreground, + backgroundColor: background, + }); styleEl.textContent = '@scope{' + '::selection{background-color:transparent}' + @@ -470,23 +473,6 @@ export class Editor { } } - #createSelectionFromOffsets( - startOffset: number, - endOffset = startOffset, - direction = SelectionDirection.None - ) { - const textDocument = this.#textDocument!; - const start = textDocument.positionAt(startOffset); - const end = textDocument.positionAt(endOffset); - return createSelection( - start.line, - start.character, - end.line, - end.character, - direction - ); - } - #syncTextareaState() { const textDocument = this.#textDocument; const textareaEl = this.#textareaEl; @@ -564,17 +550,12 @@ export class Editor { // if (newChangedText.trim() && nextSelections.length === 1 && isCollapsedSelection(nextSelections[0]!)) { // this.#langs.get(textDocument.languageId)?.lspDriver?.doComplete(textDocument, nextSelections[0]!.end); // } - } else { - const nextPrimarySelection = this.#createSelectionFromOffsets( - snippetStartOffset + selectionStart, - snippetStartOffset + selectionEnd, - fromWebSelectionDirection(selectionDirection) - ); + } else if (selectionStart === selectionEnd) { this.#restoreSelections( - mapSelectionRangeChange( + mapSelectionMove( textDocument, selectionsBefore, - nextPrimarySelection + textDocument.positionAt(snippetStartOffset + selectionStart) ) ); } @@ -817,7 +798,7 @@ export class Editor { textareaSnippet.text.split('\n').length * this.#options.lineHeight + 'px'; } - async #runShortcutCommand(command: EditorShortcutCommand) { + async #runCommand(command: EditorCommand) { switch (command) { case 'selectAll': this.#restoreSelections([this.#getFullSelection()]); @@ -917,31 +898,34 @@ export class Editor { } // for select all command - #getFullSelection() { + #getFullSelection(): EditorSelection { const textDocument = this.#textDocument; if (textDocument === undefined) { throw new Error('Editor has no text document'); } const lastLine = textDocument.lineCount - 1; const lastCharacter = textDocument.getLineText(lastLine)?.length ?? 0; - return createSelection( - 0, - 0, - lastLine, - lastCharacter, - SelectionDirection.Forward - ); + return { + start: { line: 0, character: 0 }, + end: { line: lastLine, character: lastCharacter }, + direction: SelectionDirection.Forward, + }; } // for documentStart/documentEnd commands - #getDocumentBoundarySelection(atEnd: boolean) { + #getDocumentBoundarySelection(atEnd: boolean): EditorSelection { const textDocument = this.#textDocument; if (textDocument === undefined) { throw new Error('Editor has no text document'); } const line = atEnd ? textDocument.lineCount - 1 : 0; const character = atEnd ? (textDocument.getLineText(line)?.length ?? 0) : 0; - return createSelection(line, character, line, character); + const start = { line, character }; + return { + start: start, + end: start, + direction: SelectionDirection.Forward, + }; } // replace the selection text diff --git a/packages/diffs/src/editor/editorShortcuts.ts b/packages/diffs/src/editor/editorCommand.ts similarity index 88% rename from packages/diffs/src/editor/editorShortcuts.ts rename to packages/diffs/src/editor/editorCommand.ts index 4f57ddc73..11ae996fa 100644 --- a/packages/diffs/src/editor/editorShortcuts.ts +++ b/packages/diffs/src/editor/editorCommand.ts @@ -1,4 +1,4 @@ -export type EditorShortcutCommand = +export type EditorCommand = | 'copy' | 'cut' | 'paste' @@ -10,7 +10,7 @@ export type EditorShortcutCommand = | 'redo' | 'selectAll'; -const SHORTCUTS: Partial> = { +const SHORTCUTS: Partial> = { a: 'selectAll', c: 'copy', v: 'paste', @@ -27,9 +27,9 @@ export function getPrimaryModifier(event: MouseEvent | KeyboardEvent): boolean { : event.ctrlKey && !event.metaKey; } -export function resolveEditorShortcutCommand( +export function resolveEditorCommandFromKeyboardEvent( event: KeyboardEvent -): EditorShortcutCommand | undefined { +): EditorCommand | undefined { if (event.altKey) { return undefined; } diff --git a/packages/diffs/src/editor/multiSelection.ts b/packages/diffs/src/editor/multiSelection.ts index da95db804..81a8f0b5e 100644 --- a/packages/diffs/src/editor/multiSelection.ts +++ b/packages/diffs/src/editor/multiSelection.ts @@ -1,12 +1,10 @@ import { applyOffsetEdits } from './editHistory'; import { comparePosition, - createSelection, type EditorSelection, - normalizeSelections, SelectionDirection, } from './selection'; -import { TextDocument, type TextEdit } from './textDocument'; +import { type Position, TextDocument, type TextEdit } from './textDocument'; type SelectionEditMapping = { edits: TextEdit[]; @@ -22,49 +20,45 @@ type SelectionTextChange = { direction: SelectionDirection; }; -export function mapSelectionRangeChange( +export function mapSelectionMove( textDocument: TextDocument, selections: readonly EditorSelection[], - nextPrimarySelection: EditorSelection + nextPosition: Position ): EditorSelection[] { const primarySelection = selections[selections.length - 1]; if (primarySelection === undefined) { return []; } - const primaryAnchorOffset = getSelectionAnchorOffset( - textDocument, - primarySelection - ); - const primaryFocusOffset = getSelectionFocusOffset( - textDocument, - primarySelection - ); - const nextPrimaryAnchorOffset = getSelectionAnchorOffset( - textDocument, - nextPrimarySelection - ); - const nextPrimaryFocusOffset = getSelectionFocusOffset( - textDocument, - nextPrimarySelection - ); - const anchorDelta = nextPrimaryAnchorOffset - primaryAnchorOffset; - const focusDelta = nextPrimaryFocusOffset - primaryFocusOffset; - const textLength = textDocument.getText().length; - return normalizeSelections( - selections.map((selection) => - createSelectionFromAnchorAndFocusOffsets( - textDocument, - clampOffset( - getSelectionAnchorOffset(textDocument, selection) + anchorDelta, - textLength - ), - clampOffset( - getSelectionFocusOffset(textDocument, selection) + focusDelta, - textLength - ) - ) - ) - ); + const deltaLine = nextPosition.line - primarySelection.start.line; + const deltaCharacter = + nextPosition.character - primarySelection.start.character; + const isMoveToLineStart = + deltaLine === 0 && nextPosition.character === 0 && deltaCharacter < -1; + const isMoveToLineEnd = + deltaLine === 0 && + nextPosition.character === + textDocument.getLineText(nextPosition.line)?.length && + deltaCharacter > 1; + return selections.map((selection) => { + let newLine = selection.start.line + deltaLine; + let newCharacter = selection.start.character + deltaCharacter; + if (selection !== primarySelection) { + if (isMoveToLineStart) { + newCharacter = 0; + } else if (isMoveToLineEnd) { + newCharacter = textDocument.getLineText(newLine)?.length ?? 0; + } + } + const newPosition: Position = { + line: newLine, + character: newCharacter, + }; + return { + start: newPosition, + end: newPosition, + direction: SelectionDirection.None, + }; + }); } export function mapSelectionTextChange( @@ -102,7 +96,7 @@ export function mapSelectionTextChange( return a.index - b.index; }); const edits: TextEdit[] = []; - const nextSelectionOffsets: Array<[number, number] | undefined> = Array.from({ + const nextSelectionOffsets: Array<[number, number]> = Array.from({ length: selections.length, }); let offsetDelta = 0; @@ -164,15 +158,8 @@ export function mapSelectionTextChange( ); return { edits, - nextSelections: normalizeSelections( - nextSelectionOffsets.map((offsets) => { - const [start, end] = offsets!; - return createSelection( - ...toLineCharacter(nextDocument, start), - ...toLineCharacter(nextDocument, end), - change.direction - ); - }) + nextSelections: nextSelectionOffsets.map((offsets) => + createSelectionFromAnchorAndFocusOffsets(nextDocument, ...offsets) ), }; } @@ -230,14 +217,8 @@ export function mapSelectionTextReplace( const nextDocument = createTextDocumentAfterEdits(textDocument, edits); return { edits, - nextSelections: normalizeSelections( - nextSelectionOffsets.map((offset) => - createSelection( - ...toLineCharacter(nextDocument, offset), - ...toLineCharacter(nextDocument, offset), - SelectionDirection.None - ) - ) + nextSelections: nextSelectionOffsets.map((offset) => + createSelectionFromAnchorAndFocusOffsets(nextDocument, offset, offset) ), }; } @@ -275,29 +256,11 @@ function createTextDocumentAfterEdits( ); } -function getSelectionAnchorOffset( - textDocument: TextDocument, - selection: EditorSelection -) { - return selection.direction === SelectionDirection.Backward - ? textDocument.offsetAt(selection.end) - : textDocument.offsetAt(selection.start); -} - -function getSelectionFocusOffset( - textDocument: TextDocument, - selection: EditorSelection -) { - return selection.direction === SelectionDirection.Backward - ? textDocument.offsetAt(selection.start) - : textDocument.offsetAt(selection.end); -} - function createSelectionFromAnchorAndFocusOffsets( textDocument: TextDocument, anchorOffset: number, focusOffset: number -) { +): EditorSelection { const direction = anchorOffset === focusOffset ? SelectionDirection.None @@ -306,21 +269,9 @@ function createSelectionFromAnchorAndFocusOffsets( : SelectionDirection.Backward; const start = Math.min(anchorOffset, focusOffset); const end = Math.max(anchorOffset, focusOffset); - return createSelection( - ...toLineCharacter(textDocument, start), - ...toLineCharacter(textDocument, end), - direction - ); -} - -function clampOffset(offset: number, textLength: number) { - return Math.max(0, Math.min(offset, textLength)); -} - -function toLineCharacter( - textDocument: TextDocument, - offset: number -): [number, number] { - const position = textDocument.positionAt(offset); - return [position.line, position.character]; + return { + start: textDocument.positionAt(start), + end: textDocument.positionAt(end), + direction, + }; } diff --git a/packages/diffs/src/editor/selection.ts b/packages/diffs/src/editor/selection.ts index 9e412a9c3..9a6debf5d 100644 --- a/packages/diffs/src/editor/selection.ts +++ b/packages/diffs/src/editor/selection.ts @@ -20,20 +20,6 @@ export type EditorSelectionTextChange = { direction: SelectionDirection; }; -export function createSelection( - startLine: number, - startCharacter: number, - endLine: number, - endCharacter: number, - direction: SelectionDirection = SelectionDirection.None -): EditorSelection { - return { - start: { line: startLine, character: startCharacter }, - end: { line: endLine, character: endCharacter }, - direction, - }; -} - /** * Converts a selection from a web selection to an editor selection. * @param selection - The web selection to convert. @@ -145,61 +131,6 @@ export function getPrimarySelection( return selection !== undefined ? { ...selection } : undefined; } -export function normalizeSelections( - selections: readonly EditorSelection[] -): EditorSelection[] { - if (selections.length === 0) { - return []; - } - const primarySelection = selections[selections.length - 1]; - const ordered = selections - .map((selection, index) => ({ - selection: { ...selection }, - index, - isPrimary: selection === primarySelection, - })) - .sort((a, b) => { - const startOrder = comparePosition(a.selection.start, b.selection.start); - if (startOrder !== 0) { - return startOrder; - } - const endOrder = comparePosition(a.selection.end, b.selection.end); - if (endOrder !== 0) { - return endOrder; - } - return a.index - b.index; - }); - const merged: Array<{ selection: EditorSelection; isPrimary: boolean }> = []; - for (const entry of ordered) { - const current = merged[merged.length - 1]; - if ( - current === undefined || - comparePosition(entry.selection.start, current.selection.end) > 0 - ) { - merged.push({ - selection: entry.selection, - isPrimary: entry.isPrimary, - }); - continue; - } - if (comparePosition(entry.selection.end, current.selection.end) > 0) { - current.selection.end = { ...entry.selection.end }; - } - current.isPrimary ||= entry.isPrimary; - if (entry.isPrimary) { - current.selection.direction = entry.selection.direction; - } - } - const primaryIndex = Math.max( - 0, - merged.findIndex((entry) => entry.isPrimary) - ); - const normalized = merged.map((entry) => entry.selection); - const [primary] = normalized.splice(primaryIndex, 1); - normalized.push(primary); - return normalized; -} - export function toWebSelectionDirection( direction: SelectionDirection ): 'none' | 'forward' | 'backward' { diff --git a/packages/diffs/test/editHistory.test.ts b/packages/diffs/test/editHistory.test.ts index cfd3eb734..3a1f6e81a 100644 --- a/packages/diffs/test/editHistory.test.ts +++ b/packages/diffs/test/editHistory.test.ts @@ -7,7 +7,22 @@ import { composeOffsetEdits, EditHistory, } from '../src/editor/editHistory'; -import { createSelection, SelectionDirection } from '../src/editor/selection'; +import type { EditorSelection } from '../src/editor/selection'; +import { SelectionDirection } from '../src/editor/selection'; + +function createSelection( + startLine: number, + startCharacter: number, + endLine: number, + endCharacter: number, + direction: SelectionDirection = SelectionDirection.None +): EditorSelection { + return { + start: { line: startLine, character: startCharacter }, + end: { line: endLine, character: endCharacter }, + direction, + }; +} function caret(character: number) { return createSelection(0, character, 0, character, SelectionDirection.None); diff --git a/packages/diffs/test/editorShortcuts.test.ts b/packages/diffs/test/editorCommand.test.ts similarity index 92% rename from packages/diffs/test/editorShortcuts.test.ts rename to packages/diffs/test/editorCommand.test.ts index c285497a5..15df22c81 100644 --- a/packages/diffs/test/editorShortcuts.test.ts +++ b/packages/diffs/test/editorCommand.test.ts @@ -1,9 +1,9 @@ import { describe, expect, test } from 'bun:test'; import { - type EditorShortcutCommand, - resolveEditorShortcutCommand, -} from '../src/editor/editorShortcuts'; + type EditorCommand, + resolveEditorCommandFromKeyboardEvent, +} from '../src/editor/editorCommand'; type ShortcutKeyboardEvent = Pick< KeyboardEvent, @@ -11,7 +11,7 @@ type ShortcutKeyboardEvent = Pick< >; type ShortcutCase = { event: Partial & Pick; - expected: EditorShortcutCommand | undefined; + expected: EditorCommand | undefined; }; function event({ @@ -50,7 +50,9 @@ function withPlatform(platform: string, run: () => void): void { function expectShortcuts(platform: string, cases: ShortcutCase[]): void { withPlatform(platform, () => { for (const { event: shortcutEvent, expected } of cases) { - expect(resolveEditorShortcutCommand(event(shortcutEvent))).toBe(expected); + expect(resolveEditorCommandFromKeyboardEvent(event(shortcutEvent))).toBe( + expected + ); } }); } diff --git a/packages/diffs/test/multiSelection.test.ts b/packages/diffs/test/multiSelection.test.ts index 6e117cc6f..f58d0f2a3 100644 --- a/packages/diffs/test/multiSelection.test.ts +++ b/packages/diffs/test/multiSelection.test.ts @@ -1,13 +1,28 @@ import { describe, expect, test } from 'bun:test'; import { - mapSelectionRangeChange, + mapSelectionMove, mapSelectionTextChange, mapSelectionTextReplace, } from '../src/editor/multiSelection'; -import { createSelection, SelectionDirection } from '../src/editor/selection'; +import type { EditorSelection } from '../src/editor/selection'; +import { SelectionDirection } from '../src/editor/selection'; import { TextDocument } from '../src/editor/textDocument'; +function createSelection( + startLine: number, + startCharacter: number, + endLine: number, + endCharacter: number, + direction: SelectionDirection = SelectionDirection.None +): EditorSelection { + return { + start: { line: startLine, character: startCharacter }, + end: { line: endLine, character: endCharacter }, + direction, + }; +} + describe('mapSelectionTextChange', () => { test('inserts the same text at multiple carets', () => { const textDocument = new TextDocument('inmemory://1', 'a\nb\nc'); @@ -121,11 +136,14 @@ describe('mapSelectionTextChange', () => { textDocument.applyEdits(edits); expect(textDocument.getText()).toBe(' '); - expect(nextSelections).toEqual([createSelection(0, 0, 0, 0)]); + expect(nextSelections).toEqual([ + createSelection(0, 0, 0, 0), + createSelection(0, 0, 0, 0), + ]); }); }); -describe('mapSelectionRangeChange', () => { +describe('mapSelectionMove', () => { test('moves all carets when the primary caret moves', () => { const textDocument = new TextDocument('inmemory://1', 'ab\ncd\nef'); const selections = [ @@ -135,11 +153,7 @@ describe('mapSelectionRangeChange', () => { ]; expect( - mapSelectionRangeChange( - textDocument, - selections, - createSelection(2, 0, 2, 0) - ) + mapSelectionMove(textDocument, selections, { line: 2, character: 0 }) ).toEqual([ createSelection(0, 0, 0, 0), createSelection(1, 0, 1, 0), @@ -155,14 +169,10 @@ describe('mapSelectionRangeChange', () => { ]; expect( - mapSelectionRangeChange( - textDocument, - selections, - createSelection(1, 1, 1, 3, SelectionDirection.Forward) - ) + mapSelectionMove(textDocument, selections, { line: 1, character: 1 }) ).toEqual([ - createSelection(0, 1, 0, 3, SelectionDirection.Forward), - createSelection(1, 1, 1, 3, SelectionDirection.Forward), + createSelection(0, 1, 0, 1, SelectionDirection.None), + createSelection(1, 1, 1, 1, SelectionDirection.None), ]); }); }); diff --git a/packages/diffs/test/selection.test.ts b/packages/diffs/test/selection.test.ts index 034ae53ff..c7700f9f9 100644 --- a/packages/diffs/test/selection.test.ts +++ b/packages/diffs/test/selection.test.ts @@ -1,11 +1,6 @@ import { describe, expect, test } from 'bun:test'; -import { - convertSelection, - createSelection, - normalizeSelections, - SelectionDirection, -} from '../src/editor/selection'; +import { convertSelection, SelectionDirection } from '../src/editor/selection'; type MockNode = { nodeType: number; @@ -205,33 +200,3 @@ describe('convertSelection', () => { }); }); }); - -describe('normalizeSelections', () => { - test('keeps the primary selection last while merging overlaps', () => { - expect( - normalizeSelections([ - createSelection(0, 0, 0, 2, SelectionDirection.Forward), - createSelection(0, 4, 0, 5, SelectionDirection.None), - createSelection(0, 1, 0, 4, SelectionDirection.Backward), - ]) - ).toEqual([createSelection(0, 0, 0, 5, SelectionDirection.Backward)]); - }); - - test('collapses duplicate carets into a single selection', () => { - expect( - normalizeSelections([ - createSelection(0, 3, 0, 3), - createSelection(0, 3, 0, 3), - ]) - ).toEqual([createSelection(0, 3, 0, 3)]); - }); - - test('sorts disjoint selections into document order with the primary selection last', () => { - expect( - normalizeSelections([ - createSelection(1, 0, 1, 0), - createSelection(0, 2, 0, 2), - ]) - ).toEqual([createSelection(1, 0, 1, 0), createSelection(0, 2, 0, 2)]); - }); -}); diff --git a/packages/diffs/test/textDocument.test.ts b/packages/diffs/test/textDocument.test.ts index ad14cb42b..487de63d3 100644 --- a/packages/diffs/test/textDocument.test.ts +++ b/packages/diffs/test/textDocument.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'bun:test'; -import { createSelection, SelectionDirection } from '../src/editor/selection'; +import type { EditorSelection } from '../src/editor/selection'; +import { SelectionDirection } from '../src/editor/selection'; import { TextDocument, type TextEdit } from '../src/editor/textDocument'; function doc(text: string) { @@ -8,13 +9,12 @@ function doc(text: string) { } function caret(line: number, character: number) { - return createSelection( - line, - character, - line, - character, - SelectionDirection.None - ); + const position = { line, character }; + return { + start: position, + end: position, + direction: SelectionDirection.None, + } satisfies EditorSelection; } describe('TextDocument', () => { diff --git a/packages/diffs/test/textareaState.test.ts b/packages/diffs/test/textareaState.test.ts index 9cb07d831..a9fb2ad83 100644 --- a/packages/diffs/test/textareaState.test.ts +++ b/packages/diffs/test/textareaState.test.ts @@ -1,7 +1,6 @@ import { describe, expect, test } from 'bun:test'; import { - createSelection, type EditorSelection, SelectionDirection, toWebSelectionDirection, @@ -26,6 +25,20 @@ type TextareaSnippetCase = { }; }; +function createSelection( + startLine: number, + startCharacter: number, + endLine: number, + endCharacter: number, + direction: SelectionDirection = SelectionDirection.None +): EditorSelection { + return { + start: { line: startLine, character: startCharacter }, + end: { line: endLine, character: endCharacter }, + direction, + }; +} + const textareaSnippetCases: TextareaSnippetCase[] = [ { name: 'includes only next context on the first line', From 79177f5317ad989a483bb38071692614129cedb0 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Sat, 25 Apr 2026 16:31:24 +0800 Subject: [PATCH 012/138] Remove history coalesce --- packages/diffs/src/components/Editor.ts | 23 +- packages/diffs/src/editor/editHistory.ts | 328 +++----------------- packages/diffs/src/editor/multiSelection.ts | 21 +- packages/diffs/src/editor/textDocument.ts | 9 +- packages/diffs/test/editHistory.test.ts | 100 +----- packages/diffs/test/textDocument.test.ts | 148 --------- 6 files changed, 75 insertions(+), 554 deletions(-) diff --git a/packages/diffs/src/components/Editor.ts b/packages/diffs/src/components/Editor.ts index 7048c49aa..28e4b756c 100644 --- a/packages/diffs/src/components/Editor.ts +++ b/packages/diffs/src/components/Editor.ts @@ -19,13 +19,13 @@ import { measureMonoFontWidth, } from '../editor/editorUtils'; import { - getOrderedSelectionText, mapSelectionMove, mapSelectionTextChange, mapSelectionTextReplace, } from '../editor/multiSelection'; import type { EditorSelection } from '../editor/selection'; import { + comparePosition, convertSelection, fromWebSelectionDirection, getPrimarySelection, @@ -810,10 +810,7 @@ export class Editor { try { // todo: use navigator.clipboard.write() for multiple selections copy await navigator.clipboard.writeText( - getOrderedSelectionText( - this.#textDocument, - this.#selections! - ).join(this.#textDocument.EOF) + this.#getSelectionText(this.#selections!) ); } catch { return; @@ -928,6 +925,22 @@ export class Editor { }; } + #getSelectionText(selections: readonly EditorSelection[]): string { + if (this.#textDocument === undefined) { + return ''; + } + return [...selections] + .sort((a, b) => { + const startOrder = comparePosition(a.start, b.start); + if (startOrder !== 0) { + return startOrder; + } + return comparePosition(a.end, b.end); + }) + .map((selection) => this.#textDocument!.getText(selection)) + .join(this.#textDocument.EOF); + } + // replace the selection text #replaceSelectionText(text: string | string[]) { const selections = this.#selections; diff --git a/packages/diffs/src/editor/editHistory.ts b/packages/diffs/src/editor/editHistory.ts index e10b769d0..c184f5818 100644 --- a/packages/diffs/src/editor/editHistory.ts +++ b/packages/diffs/src/editor/editHistory.ts @@ -1,6 +1,10 @@ import { type EditorSelection } from './selection'; -export type ResolvedEdit = { start: number; end: number; text: string }; +export type ResolvedEdit = { + start: number; + end: number; + text: string; +}; export type HistoryEntry = { /** Forward offset edits from the entry's base text to its final text. */ @@ -15,255 +19,8 @@ export type HistoryEntry = { selectionsBefore: EditorSelection[]; /** Selection after the transaction (restored on redo). */ selectionsAfter?: EditorSelection[]; - /** Timestamp in ms used to coalesce adjacent edits. */ - timestampMs: number; }; -export function assertNonOverlappingDescending( - sortedDesc: ResolvedEdit[] -): void { - for (let i = 0; i < sortedDesc.length - 1; i++) { - if (sortedDesc[i + 1].end > sortedDesc[i].start) { - throw new Error('Overlapping text edits are not supported'); - } - } -} - -export function computeTextAfterApplying( - base: string, - sortedDesc: ResolvedEdit[] -): string { - let text = base; - for (const { start, end, text: insert } of sortedDesc) { - text = text.slice(0, start) + insert + text.slice(end); - } - return text; -} - -/** `resolved` in any order; sorted descending internally. */ -export function applyOffsetEdits( - base: string, - resolved: ResolvedEdit[] -): string { - const sorted = [...resolved].sort((a, b) => b.start - a.start); - assertNonOverlappingDescending(sorted); - return computeTextAfterApplying(base, sorted); -} - -export function buildInverseOffsetEdits( - textBefore: string, - ascending: ResolvedEdit[] -): ResolvedEdit[] { - const inverse: ResolvedEdit[] = []; - for (let i = 0; i < ascending.length; i++) { - const edit = ascending[i]; - const replacedText = textBefore.slice(edit.start, edit.end); - let startAfterEdit = edit.start; - for (let j = 0; j < i; j++) { - const previousEdit = ascending[j]; - startAfterEdit += - previousEdit.text.length - (previousEdit.end - previousEdit.start); - } - inverse.push({ - start: startAfterEdit, - end: startAfterEdit + edit.text.length, - text: replacedText, - }); - } - return inverse; -} - -type IntermediateSegment = - | { - kind: 'orig'; - sourceStart: number; - sourceEnd: number; - outputStart: number; - outputEnd: number; - } - | { kind: 'insert'; text: string; outputStart: number; outputEnd: number }; - -type ComposedPiece = - | { kind: 'orig'; start: number; end: number } - | { kind: 'insert'; text: string }; - -function cloneResolvedEdits(edits: ResolvedEdit[]) { - return edits.map((edit) => ({ ...edit })); -} - -function buildIntermediateSegments( - edits: ResolvedEdit[], - sourceLength: number -): IntermediateSegment[] { - const segments: IntermediateSegment[] = []; - let sourceCursor = 0; - let outputCursor = 0; - for (const edit of edits) { - if (sourceCursor < edit.start) { - const length = edit.start - sourceCursor; - segments.push({ - kind: 'orig', - sourceStart: sourceCursor, - sourceEnd: edit.start, - outputStart: outputCursor, - outputEnd: outputCursor + length, - }); - outputCursor += length; - } - if (edit.text.length > 0) { - segments.push({ - kind: 'insert', - text: edit.text, - outputStart: outputCursor, - outputEnd: outputCursor + edit.text.length, - }); - outputCursor += edit.text.length; - } - sourceCursor = edit.end; - } - if (sourceCursor < sourceLength) { - segments.push({ - kind: 'orig', - sourceStart: sourceCursor, - sourceEnd: sourceLength, - outputStart: outputCursor, - outputEnd: outputCursor + (sourceLength - sourceCursor), - }); - } - return segments; -} - -function appendPiece(pieces: ComposedPiece[], piece: ComposedPiece) { - if (piece.kind === 'insert' && piece.text.length === 0) { - return; - } - if (piece.kind === 'orig' && piece.start === piece.end) { - return; - } - const last = pieces[pieces.length - 1]; - if (last === undefined) { - pieces.push(piece); - return; - } - if (last.kind === 'insert' && piece.kind === 'insert') { - last.text += piece.text; - return; - } - if ( - last.kind === 'orig' && - piece.kind === 'orig' && - last.end === piece.start - ) { - last.end = piece.end; - return; - } - pieces.push(piece); -} - -function appendIntermediateSlice( - pieces: ComposedPiece[], - segments: IntermediateSegment[], - start: number, - end: number -) { - if (start >= end) { - return; - } - for (const segment of segments) { - if (segment.outputEnd <= start) { - continue; - } - if (segment.outputStart >= end) { - break; - } - const sliceStart = Math.max(start, segment.outputStart); - const sliceEnd = Math.min(end, segment.outputEnd); - if (segment.kind === 'orig') { - const offset = sliceStart - segment.outputStart; - appendPiece(pieces, { - kind: 'orig', - start: segment.sourceStart + offset, - end: segment.sourceStart + offset + (sliceEnd - sliceStart), - }); - continue; - } - appendPiece(pieces, { - kind: 'insert', - text: segment.text.slice( - sliceStart - segment.outputStart, - sliceEnd - segment.outputStart - ), - }); - } -} - -function piecesToEdits( - pieces: ComposedPiece[], - sourceLength: number -): ResolvedEdit[] { - const edits: ResolvedEdit[] = []; - let sourceCursor = 0; - let pendingStart: number | undefined; - let pendingText = ''; - for (const piece of pieces) { - if (piece.kind === 'insert') { - pendingStart ??= sourceCursor; - pendingText += piece.text; - continue; - } - if (piece.start < sourceCursor) { - throw new Error('Composed edit pieces must preserve source order'); - } - if (pendingStart !== undefined || piece.start !== sourceCursor) { - edits.push({ - start: pendingStart ?? sourceCursor, - end: piece.start, - text: pendingText, - }); - pendingStart = undefined; - pendingText = ''; - } - sourceCursor = piece.end; - } - if (pendingStart !== undefined || sourceCursor !== sourceLength) { - edits.push({ - start: pendingStart ?? sourceCursor, - end: sourceLength, - text: pendingText, - }); - } - return edits.filter( - (edit) => edit.start !== edit.end || edit.text.length > 0 - ); -} - -export function composeOffsetEdits( - first: ResolvedEdit[], - second: ResolvedEdit[], - sourceLength: number -): ResolvedEdit[] { - const firstAscending = cloneResolvedEdits(first).sort( - (a, b) => a.start - b.start - ); - const secondAscending = cloneResolvedEdits(second).sort( - (a, b) => a.start - b.start - ); - const segments = buildIntermediateSegments(firstAscending, sourceLength); - const pieces: ComposedPiece[] = []; - const intermediateLength = - segments.length === 0 - ? sourceLength - : segments[segments.length - 1].outputEnd; - let cursor = 0; - for (const edit of secondAscending) { - appendIntermediateSlice(pieces, segments, cursor, edit.start); - appendPiece(pieces, { kind: 'insert', text: edit.text }); - cursor = edit.end; - } - appendIntermediateSlice(pieces, segments, cursor, intermediateLength); - return piecesToEdits(pieces, sourceLength); -} - export class EditHistory { #undo: HistoryEntry[] = []; #redo: HistoryEntry[] = []; @@ -285,46 +42,19 @@ export class EditHistory { textBefore: string, resolvedEdits: ResolvedEdit[], selectionsBefore: EditorSelection[], - selectionsAfter?: EditorSelection[], - coalesceWithinMs?: number + selectionsAfter?: EditorSelection[] ): void { - const timestampMs = Date.now(); - const ascendingEdits = [...resolvedEdits].sort((a, b) => a.start - b.start); - const inverseEdits = buildInverseOffsetEdits(textBefore, ascendingEdits); + const forwardEdits = [...resolvedEdits].sort((a, b) => a.start - b.start); + const inverseEdits = buildInverseOffsetEdits(textBefore, forwardEdits); const textLengthBefore = textBefore.length; const textLengthAfter = textLengthBefore + - ascendingEdits.reduce( + forwardEdits.reduce( (sum, edit) => sum + edit.text.length - (edit.end - edit.start), 0 ); - const lastEntry = this.#undo[this.#undo.length - 1]; - if ( - lastEntry !== undefined && - this.#redo.length === 0 && - coalesceWithinMs !== undefined && - coalesceWithinMs >= 0 && - timestampMs - lastEntry.timestampMs <= coalesceWithinMs - ) { - lastEntry.forwardEdits = composeOffsetEdits( - lastEntry.forwardEdits, - ascendingEdits, - lastEntry.textLengthBefore - ); - lastEntry.inverseEdits = composeOffsetEdits( - inverseEdits, - lastEntry.inverseEdits, - textLengthAfter - ); - lastEntry.textLengthAfter = textLengthAfter; - lastEntry.selectionsAfter = selectionsAfter?.map((selection) => ({ - ...selection, - })); - lastEntry.timestampMs = timestampMs; - return; - } this.#undo.push({ - forwardEdits: cloneResolvedEdits(ascendingEdits), + forwardEdits: forwardEdits.map((edit) => ({ ...edit })), inverseEdits: inverseEdits, textLengthBefore, textLengthAfter, @@ -332,7 +62,6 @@ export class EditHistory { ...selection, })), selectionsAfter: selectionsAfter?.map((selection) => ({ ...selection })), - timestampMs, }); this.#redo.length = 0; } @@ -355,3 +84,40 @@ export class EditHistory { } } } + +export function applyOffsetEdits(base: string, edits: ResolvedEdit[]): string { + const sortedEdits = [...edits].sort((a, b) => b.start - a.start); + for (let i = 0; i < sortedEdits.length - 1; i++) { + if (sortedEdits[i + 1].end > sortedEdits[i].start) { + throw new Error('Overlapping text edits are not supported'); + } + } + let text = base; + for (const { start, end, text: insert } of sortedEdits) { + text = text.slice(0, start) + insert + text.slice(end); + } + return text; +} + +export function buildInverseOffsetEdits( + textBefore: string, + ascending: ResolvedEdit[] +): ResolvedEdit[] { + const inverse: ResolvedEdit[] = []; + for (let i = 0; i < ascending.length; i++) { + const edit = ascending[i]; + const replacedText = textBefore.slice(edit.start, edit.end); + let startAfterEdit = edit.start; + for (let j = 0; j < i; j++) { + const previousEdit = ascending[j]; + startAfterEdit += + previousEdit.text.length - (previousEdit.end - previousEdit.start); + } + inverse.push({ + start: startAfterEdit, + end: startAfterEdit + edit.text.length, + text: replacedText, + }); + } + return inverse; +} diff --git a/packages/diffs/src/editor/multiSelection.ts b/packages/diffs/src/editor/multiSelection.ts index 81a8f0b5e..237d60762 100644 --- a/packages/diffs/src/editor/multiSelection.ts +++ b/packages/diffs/src/editor/multiSelection.ts @@ -1,9 +1,5 @@ import { applyOffsetEdits } from './editHistory'; -import { - comparePosition, - type EditorSelection, - SelectionDirection, -} from './selection'; +import { type EditorSelection, SelectionDirection } from './selection'; import { type Position, TextDocument, type TextEdit } from './textDocument'; type SelectionEditMapping = { @@ -223,21 +219,6 @@ export function mapSelectionTextReplace( }; } -export function getOrderedSelectionText( - textDocument: TextDocument, - selections: readonly EditorSelection[] -): string[] { - return [...selections] - .sort((a, b) => { - const startOrder = comparePosition(a.start, b.start); - if (startOrder !== 0) { - return startOrder; - } - return comparePosition(a.end, b.end); - }) - .map((selection) => textDocument.getText(selection)); -} - function createTextDocumentAfterEdits( textDocument: TextDocument, edits: readonly TextEdit[] diff --git a/packages/diffs/src/editor/textDocument.ts b/packages/diffs/src/editor/textDocument.ts index 5b593b807..794b026dc 100644 --- a/packages/diffs/src/editor/textDocument.ts +++ b/packages/diffs/src/editor/textDocument.ts @@ -166,7 +166,7 @@ export class TextDocument { if (edits.length === 0) { return; } - const resolvedEdits = this.#resolveEdits(edits); + const resolvedEdits = edits.map((edit) => this.#resolveEdit(edit)); const textBefore = this.#text; const newText = applyOffsetEdits(textBefore, resolvedEdits); if (updateHistory && selectionsBefore !== undefined) { @@ -174,8 +174,7 @@ export class TextDocument { textBefore, resolvedEdits, selectionsBefore, - selectionsAfter, - 500 + selectionsAfter ); } this.#setDocumentText(newText); @@ -203,10 +202,6 @@ export class TextDocument { : undefined; } - #resolveEdits(edits: TextEdit[]): ResolvedEdit[] { - return edits.map((edit) => this.#resolveEdit(edit)); - } - #resolveEdit(edit: TextEdit): ResolvedEdit { let start = this.offsetAt(edit.range.start); let end = this.offsetAt(edit.range.end); diff --git a/packages/diffs/test/editHistory.test.ts b/packages/diffs/test/editHistory.test.ts index 3a1f6e81a..f9cd27360 100644 --- a/packages/diffs/test/editHistory.test.ts +++ b/packages/diffs/test/editHistory.test.ts @@ -2,9 +2,7 @@ import { describe, expect, test } from 'bun:test'; import { applyOffsetEdits, - assertNonOverlappingDescending, buildInverseOffsetEdits, - composeOffsetEdits, EditHistory, } from '../src/editor/editHistory'; import type { EditorSelection } from '../src/editor/selection'; @@ -40,7 +38,7 @@ describe('EditHistory helpers', () => { test('assertNonOverlappingDescending rejects overlapping edits', () => { expect(() => - assertNonOverlappingDescending([ + applyOffsetEdits('0123456789', [ { start: 6, end: 8, text: 'X' }, { start: 4, end: 7, text: 'Y' }, ]) @@ -64,16 +62,6 @@ describe('EditHistory helpers', () => { ]); expect(applyOffsetEdits(textAfter, inverseEdits)).toBe(textBefore); }); - - test('composeOffsetEdits collapses sequential edits into source coordinates', () => { - const first = [{ start: 1, end: 1, text: 'bc' }]; - const second = [{ start: 2, end: 4, text: 'Z' }]; - - const composed = composeOffsetEdits(first, second, 2); - - expect(composed).toEqual([{ start: 1, end: 2, text: 'bZ' }]); - expect(applyOffsetEdits('ad', composed)).toBe('abZ'); - }); }); describe('EditHistory', () => { @@ -86,8 +74,7 @@ describe('EditHistory', () => { 'ab', [{ start: 1, end: 1, text: 'X' }], selectionBefore, - selectionAfter, - -1 + selectionAfter ); selectionBefore[0] = caret(99); @@ -105,7 +92,6 @@ describe('EditHistory', () => { textLengthAfter: 3, selectionsBefore: [caret(0), caret(1)], selectionsAfter: [caret(2), caret(3)], - timestampMs: expect.any(Number), }); expect(history.canUndo).toBe(false); expect(history.canRedo).toBe(true); @@ -123,8 +109,7 @@ describe('EditHistory', () => { 'a', [{ start: 1, end: 1, text: 'b' }], [caret(1)], - [selectionAfter], - -1 + [selectionAfter] ); selectionAfter = caret(99); @@ -133,83 +118,18 @@ describe('EditHistory', () => { }); }); - test('push coalesces adjacent edits into a single undo entry', () => { - const history = new EditHistory(); - const originalNow = Date.now; - let now = 1000; - - Object.defineProperty(Date, 'now', { - configurable: true, - value: () => now, - }); - - try { - history.push( - '', - [{ start: 0, end: 0, text: 'a' }], - [caret(0)], - [caret(1)], - 1000 - ); - now += 400; - history.push( - 'a', - [{ start: 1, end: 1, text: 'b' }], - [caret(1)], - [caret(2)], - 1000 - ); - - const entry = history.popUndoToRedo(); - - expect(entry).toEqual({ - forwardEdits: [{ start: 0, end: 0, text: 'ab' }], - inverseEdits: [{ start: 0, end: 2, text: '' }], - textLengthBefore: 0, - textLengthAfter: 2, - selectionsBefore: [caret(0)], - selectionsAfter: [caret(2)], - timestampMs: 1400, - }); - expect(history.popUndoToRedo()).toBeUndefined(); - } finally { - Object.defineProperty(Date, 'now', { - configurable: true, - value: originalNow, - }); - } - }); - test('push clears redo history when recording a new undo entry', () => { const history = new EditHistory(); - history.push( - '', - [{ start: 0, end: 0, text: 'a' }], - [caret(0)], - undefined, - -1 - ); - history.push( - 'a', - [{ start: 1, end: 1, text: 'b' }], - [caret(1)], - undefined, - -1 - ); + history.push('', [{ start: 0, end: 0, text: 'a' }], [caret(0)], undefined); + history.push('a', [{ start: 1, end: 1, text: 'b' }], [caret(1)], undefined); expect(history.popUndoToRedo()).toMatchObject({ forwardEdits: [{ start: 1, end: 1, text: 'b' }], }); expect(history.canRedo).toBe(true); - history.push( - 'a', - [{ start: 1, end: 1, text: 'c' }], - [caret(1)], - undefined, - -1 - ); + history.push('a', [{ start: 1, end: 1, text: 'c' }], [caret(1)], undefined); expect(history.canRedo).toBe(false); expect(history.popUndoToRedo()).toMatchObject({ @@ -223,13 +143,7 @@ describe('EditHistory', () => { test('clear resets both undo and redo stacks', () => { const history = new EditHistory(); - history.push( - '', - [{ start: 0, end: 0, text: 'a' }], - [caret(0)], - undefined, - -1 - ); + history.push('', [{ start: 0, end: 0, text: 'a' }], [caret(0)], undefined); history.popUndoToRedo(); history.clear(); diff --git a/packages/diffs/test/textDocument.test.ts b/packages/diffs/test/textDocument.test.ts index 487de63d3..db389033b 100644 --- a/packages/diffs/test/textDocument.test.ts +++ b/packages/diffs/test/textDocument.test.ts @@ -215,154 +215,6 @@ describe('TextDocument', () => { } }); - test('sequential edits within coalesce window undo as one entry', () => { - const d = doc(''); - const originalNow = Date.now; - let now = 1000; - Object.defineProperty(Date, 'now', { - configurable: true, - value: () => now, - }); - try { - d.applyEdits( - [ - { - range: { - start: { line: 0, character: 0 }, - end: { line: 0, character: 0 }, - }, - newText: 'a', - }, - ], - true, - [caret(0, 0)] - ); - now += 400; - d.applyEdits( - [ - { - range: { - start: { line: 0, character: 1 }, - end: { line: 0, character: 1 }, - }, - newText: 'b', - }, - ], - true, - [caret(0, 1)] - ); - expect(d.getText()).toBe('ab'); - d.undo(); - expect(d.getText()).toBe(''); - d.redo(); - expect(d.getText()).toBe('ab'); - expect(d.canUndo).toBe(true); - expect(d.canRedo).toBe(false); - } finally { - Object.defineProperty(Date, 'now', { - configurable: true, - value: originalNow, - }); - } - }); - - test('coalesced edits can update earlier inserted text', () => { - const d = doc('x'); - const originalNow = Date.now; - let now = 1000; - Object.defineProperty(Date, 'now', { - configurable: true, - value: () => now, - }); - try { - d.applyEdits( - [ - { - range: { - start: { line: 0, character: 0 }, - end: { line: 0, character: 1 }, - }, - newText: 'ab', - }, - ], - true, - [caret(0, 0)] - ); - now += 400; - d.applyEdits( - [ - { - range: { - start: { line: 0, character: 1 }, - end: { line: 0, character: 2 }, - }, - newText: 'c', - }, - ], - true, - [caret(0, 1)] - ); - expect(d.getText()).toBe('ac'); - d.undo(); - expect(d.getText()).toBe('x'); - d.redo(); - expect(d.getText()).toBe('ac'); - } finally { - Object.defineProperty(Date, 'now', { - configurable: true, - value: originalNow, - }); - } - }); - - test('sequential edits outside coalesce window keep separate entries', () => { - const d = doc(''); - const originalNow = Date.now; - let now = 1000; - Object.defineProperty(Date, 'now', { - configurable: true, - value: () => now, - }); - try { - d.applyEdits( - [ - { - range: { - start: { line: 0, character: 0 }, - end: { line: 0, character: 0 }, - }, - newText: 'a', - }, - ], - true, - [caret(0, 0)] - ); - now += 1200; - d.applyEdits( - [ - { - range: { - start: { line: 0, character: 1 }, - end: { line: 0, character: 1 }, - }, - newText: 'b', - }, - ], - true, - [caret(0, 1)] - ); - d.undo(); - expect(d.getText()).toBe('a'); - d.undo(); - expect(d.getText()).toBe(''); - } finally { - Object.defineProperty(Date, 'now', { - configurable: true, - value: originalNow, - }); - } - }); - test('applyEdits rejects overlapping ranges', () => { const d = doc('0123456789'); expect(() => From 4cb7b412f1e7da938bdf81df88402809698b06ad Mon Sep 17 00:00:00 2001 From: Je Xia Date: Sat, 25 Apr 2026 16:35:53 +0800 Subject: [PATCH 013/138] Fix selction/crate not updated when do "redo" command --- packages/diffs/src/components/Editor.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/diffs/src/components/Editor.ts b/packages/diffs/src/components/Editor.ts index 28e4b756c..1fba76ce1 100644 --- a/packages/diffs/src/components/Editor.ts +++ b/packages/diffs/src/components/Editor.ts @@ -639,7 +639,7 @@ export class Editor { if (textDocument === undefined) { return; } - textDocument.applyEdits(edits, true, selectionsBefore); + textDocument.applyEdits(edits, true, selectionsBefore, nextSelections); this.#renderText(textDocument, nextSelections); } From 57cfbe68217ffd37a623f44d7549f021ebb956ca Mon Sep 17 00:00:00 2001 From: Je Xia Date: Sat, 25 Apr 2026 16:46:53 +0800 Subject: [PATCH 014/138] Remove visualColumns.ts --- packages/diffs/src/components/Editor.ts | 38 ++++--- packages/diffs/src/editor/selection.ts | 118 ++++++++++----------- packages/diffs/src/editor/visualColumns.ts | 19 ---- packages/diffs/test/visualColumns.test.ts | 24 ----- 4 files changed, 80 insertions(+), 119 deletions(-) delete mode 100644 packages/diffs/src/editor/visualColumns.ts delete mode 100644 packages/diffs/test/visualColumns.test.ts diff --git a/packages/diffs/src/components/Editor.ts b/packages/diffs/src/components/Editor.ts index 1fba76ce1..c5bbdff19 100644 --- a/packages/diffs/src/components/Editor.ts +++ b/packages/diffs/src/components/Editor.ts @@ -41,7 +41,6 @@ import { type TextareaState, } from '../editor/textareaState'; import { TextDocument, type TextEdit } from '../editor/textDocument'; -import { getVisualColumns } from '../editor/visualColumns'; import { getHighlighterIfLoaded, getSharedHighlighter, @@ -694,17 +693,9 @@ export class Editor { const lineLength = lineText.length; const startCharacter = ln === start.line ? start.character : 0; const endCharacter = ln === end.line ? end.character : lineLength; - const startColumns = getVisualColumns( - lineText, - startCharacter, - this.#options.tabSize - ); - const endColumns = getVisualColumns( - lineText, - endCharacter, - this.#options.tabSize - ); - const startX = this.#getCharacterX(ln, startCharacter, startColumns); + const startColumn = this.#getVisualColumn(lineText, startCharacter); + const endColumns = this.#getVisualColumn(lineText, endCharacter); + const startX = this.#getCharacterX(ln, startCharacter, startColumn); const endX = this.#getCharacterX(ln, endCharacter, endColumns); const spacing = endCharacter === startCharacter || ln === end.line ? 0 : 4; @@ -735,7 +726,7 @@ export class Editor { this.#textDocument?.getLineText(isBackward ? start.line : end.line) ?? ''; const line = isBackward ? start.line : end.line; const character = isBackward ? start.character : end.character; - const column = getVisualColumns(lineText, character, this.#options.tabSize); + const column = this.#getVisualColumn(lineText, character); const left = this.#getCharacterX(line, character, column); const cursorEl = createElement( 'div', @@ -977,10 +968,9 @@ export class Editor { } // get character X position - // todo: does it support emoji/non-ascii input? - #getCharacterX(line: number, character: number, visualColumns: number) { - const fallbackLeft = - this.#gutterWidth + visualColumns * this.#monoCharWidth; + // todo: support emoji/non-ascii chars + #getCharacterX(line: number, character: number, visualColumn: number) { + const fallbackLeft = this.#gutterWidth + visualColumn * this.#monoCharWidth; const lineEl = this.#textLineEls?.get(line); const editorEl = this.#editorEl; if (lineEl === undefined || editorEl === undefined) { @@ -1042,6 +1032,20 @@ export class Editor { return pointRect.left - editorRect.left; } + #getVisualColumn(text: string, character: number): number { + const tabSize = this.#options.tabSize; + let column = 0; + for (let i = 0; i < Math.min(character, text.length); i++) { + if (text.charCodeAt(i) === /* \t */ 9) { + const remainder = column % tabSize; + column += remainder === 0 ? tabSize : tabSize - remainder; + continue; + } + column++; + } + return column; + } + // check if the active element has focus within editor #hasFocusWithinEditor() { const activeElement = document.activeElement; diff --git a/packages/diffs/src/editor/selection.ts b/packages/diffs/src/editor/selection.ts index 9a6debf5d..78c5f3e48 100644 --- a/packages/diffs/src/editor/selection.ts +++ b/packages/diffs/src/editor/selection.ts @@ -158,60 +158,6 @@ export function comparePosition(a: Position, b: Position): number { return a.character - b.character; } -function getLineAttr(el: HTMLElement): number | undefined { - // oxlint-disable-next-line typescript/no-explicit-any - return (el as any).LINE as number | undefined; -} - -function getCharacterAttr(el: HTMLElement): number | undefined { - // oxlint-disable-next-line typescript/no-explicit-any - return (el as any).CHAR as number | undefined; -} - -function getPositionWithinPre( - pre: HTMLElement, - offset: number -): Position | null { - const line = getLineAttr(pre); - if (line === undefined) { - return null; - } - let character = 0; - for (let i = 0; i < offset; i++) { - const c = pre.children[i]; - if (c?.tagName === 'SPAN') { - const span = c as HTMLElement; - const o = getCharacterAttr(span); - if (o === undefined) { - continue; - } - const len = span.textContent?.length ?? 0; - character = o + len; - } - } - return { line, character }; -} - -function getDirectPreChild( - node: Node -): { pre: HTMLElement; childIndex: number } | null { - let current = - node.nodeType === 1 ? (node as HTMLElement) : node.parentElement; - while (current !== null && current.parentElement !== null) { - if (current.parentElement.tagName === 'PRE') { - return { - pre: current.parentElement, - childIndex: Array.prototype.indexOf.call( - current.parentElement.children, - current - ), - }; - } - current = current.parentElement; - } - return null; -} - function boundaryToPosition(node: Node, offset: number): Position | null { if (node.nodeType === 3) { const parent = node.parentElement; @@ -223,8 +169,8 @@ function boundaryToPosition(node: Node, offset: number): Position | null { if (pre === null || pre.tagName !== 'PRE') { return null; } - const line = getLineAttr(pre); - const base = getCharacterAttr(parent); + const line = getLineProp(pre); + const base = getCharacterProp(parent); if (line !== undefined && base !== undefined) { return { line, character: base + offset }; } @@ -245,7 +191,7 @@ function boundaryToPosition(node: Node, offset: number): Position | null { if (pre === null || pre.tagName !== 'PRE') { return null; } - const line = getLineAttr(pre); + const line = getLineProp(pre); if (line !== undefined) { return { line, character: 0 }; } @@ -255,8 +201,8 @@ function boundaryToPosition(node: Node, offset: number): Position | null { if (pre === null || pre.tagName !== 'PRE') { return null; } - const line = getLineAttr(pre); - const base = getCharacterAttr(el); + const line = getLineProp(pre); + const base = getCharacterProp(el); if (line !== undefined && base !== undefined) { let character = base; for (let i = 0; i < offset; i++) { @@ -272,3 +218,57 @@ function boundaryToPosition(node: Node, offset: number): Position | null { } return null; } + +function getPositionWithinPre( + pre: HTMLElement, + offset: number +): Position | null { + const line = getLineProp(pre); + if (line === undefined) { + return null; + } + let character = 0; + for (let i = 0; i < offset; i++) { + const c = pre.children[i]; + if (c?.tagName === 'SPAN') { + const span = c as HTMLElement; + const o = getCharacterProp(span); + if (o === undefined) { + continue; + } + const len = span.textContent?.length ?? 0; + character = o + len; + } + } + return { line, character }; +} + +function getDirectPreChild( + node: Node +): { pre: HTMLElement; childIndex: number } | null { + let current = + node.nodeType === 1 ? (node as HTMLElement) : node.parentElement; + while (current !== null && current.parentElement !== null) { + if (current.parentElement.tagName === 'PRE') { + return { + pre: current.parentElement, + childIndex: Array.prototype.indexOf.call( + current.parentElement.children, + current + ), + }; + } + current = current.parentElement; + } + return null; +} + +function getLineProp(el: HTMLElement): number | undefined { + // oxlint-disable-next-line typescript/no-explicit-any + return (el as any).LINE as number | undefined; +} + +function getCharacterProp(el: HTMLElement): number | undefined { + // oxlint-disable-next-line typescript/no-explicit-any + return (el as any).CHAR as number | undefined; +} diff --git a/packages/diffs/src/editor/visualColumns.ts b/packages/diffs/src/editor/visualColumns.ts deleted file mode 100644 index 36778cd52..000000000 --- a/packages/diffs/src/editor/visualColumns.ts +++ /dev/null @@ -1,19 +0,0 @@ -export function getVisualColumns( - text: string, - character: number, - tabSize: number -): number { - const clampedCharacter = Math.max(0, Math.min(character, text.length)); - const normalizedTabSize = Math.max(1, Math.floor(tabSize)); - let column = 0; - for (let i = 0; i < clampedCharacter; i++) { - if (text.charCodeAt(i) === /* \t */ 9) { - const remainder = column % normalizedTabSize; - column += - remainder === 0 ? normalizedTabSize : normalizedTabSize - remainder; - continue; - } - column++; - } - return column; -} diff --git a/packages/diffs/test/visualColumns.test.ts b/packages/diffs/test/visualColumns.test.ts deleted file mode 100644 index 918c27861..000000000 --- a/packages/diffs/test/visualColumns.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { describe, expect, test } from 'bun:test'; - -import { getVisualColumns } from '../src/editor/visualColumns'; - -describe('getVisualColumn', () => { - test('keeps plain text columns unchanged', () => { - expect(getVisualColumns('hello', 0, 2)).toBe(0); - expect(getVisualColumns('hello', 3, 2)).toBe(3); - expect(getVisualColumns('hello', 99, 2)).toBe(5); - }); - - test('expands tabs to the configured tab size', () => { - expect(getVisualColumns('\ta', 1, 2)).toBe(2); - expect(getVisualColumns('\ta', 1, 4)).toBe(4); - expect(getVisualColumns('\ta', 2, 2)).toBe(3); - }); - - test('aligns tab stops based on current visual column', () => { - expect(getVisualColumns('a\tb', 2, 2)).toBe(2); - expect(getVisualColumns('a\tb', 2, 4)).toBe(4); - expect(getVisualColumns('ab\tc', 3, 4)).toBe(4); - expect(getVisualColumns('abc\tz', 4, 4)).toBe(4); - }); -}); From 78ec87682be549b0a5c8147f7a4d5a539a2ef751 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Sat, 25 Apr 2026 16:50:54 +0800 Subject: [PATCH 015/138] Move editor ts files --- packages/diffs/src/components/Editor.ts | 34 +++++++++---------- packages/diffs/src/editor/editHistory.ts | 2 +- ...iSelection.ts => editorMultiSelections.ts} | 2 +- .../{selection.ts => editorSelection.ts} | 0 ...extareaState.ts => editorTextareaState.ts} | 5 ++- packages/diffs/src/editor/textDocument.ts | 2 +- packages/diffs/test/editHistory.test.ts | 4 +-- ....test.ts => editorMultiSelections.test.ts} | 6 ++-- ...ection.test.ts => editorSelection.test.ts} | 5 ++- ...te.test.ts => editorTextareaState.test.ts} | 4 +-- packages/diffs/test/textDocument.test.ts | 4 +-- 11 files changed, 37 insertions(+), 31 deletions(-) rename packages/diffs/src/editor/{multiSelection.ts => editorMultiSelections.ts} (98%) rename packages/diffs/src/editor/{selection.ts => editorSelection.ts} (100%) rename packages/diffs/src/editor/{textareaState.ts => editorTextareaState.ts} (98%) rename packages/diffs/test/{multiSelection.test.ts => editorMultiSelections.test.ts} (96%) rename packages/diffs/test/{selection.test.ts => editorSelection.test.ts} (98%) rename packages/diffs/test/{textareaState.test.ts => editorTextareaState.test.ts} (98%) diff --git a/packages/diffs/src/components/Editor.ts b/packages/diffs/src/components/Editor.ts index c5bbdff19..9d2c93dd8 100644 --- a/packages/diffs/src/components/Editor.ts +++ b/packages/diffs/src/components/Editor.ts @@ -5,25 +5,16 @@ import { getPrimaryModifier, resolveEditorCommandFromKeyboardEvent, } from '../editor/editorCommand'; -import { - type NormalizedEditorOptions, - normlizeEditorOptions, -} from '../editor/editorOptions'; -import { - addEventListener, - coalesceMicrotask, - createElement, - extend, - getLineIndentationUnit, - getRootCssVariableValue, - measureMonoFontWidth, -} from '../editor/editorUtils'; import { mapSelectionMove, mapSelectionTextChange, mapSelectionTextReplace, -} from '../editor/multiSelection'; -import type { EditorSelection } from '../editor/selection'; +} from '../editor/editorMultiSelections'; +import { + type NormalizedEditorOptions, + normlizeEditorOptions, +} from '../editor/editorOptions'; +import type { EditorSelection } from '../editor/editorSelection'; import { comparePosition, convertSelection, @@ -33,13 +24,22 @@ import { resolveIndentEdits, SelectionDirection, toWebSelectionDirection, -} from '../editor/selection'; +} from '../editor/editorSelection'; import { createTextareaSnippet, matchesTextareaState, resolveTextareaTextChange, type TextareaState, -} from '../editor/textareaState'; +} from '../editor/editorTextareaState'; +import { + addEventListener, + coalesceMicrotask, + createElement, + extend, + getLineIndentationUnit, + getRootCssVariableValue, + measureMonoFontWidth, +} from '../editor/editorUtils'; import { TextDocument, type TextEdit } from '../editor/textDocument'; import { getHighlighterIfLoaded, diff --git a/packages/diffs/src/editor/editHistory.ts b/packages/diffs/src/editor/editHistory.ts index c184f5818..6958b6533 100644 --- a/packages/diffs/src/editor/editHistory.ts +++ b/packages/diffs/src/editor/editHistory.ts @@ -1,4 +1,4 @@ -import { type EditorSelection } from './selection'; +import { type EditorSelection } from './editorSelection'; export type ResolvedEdit = { start: number; diff --git a/packages/diffs/src/editor/multiSelection.ts b/packages/diffs/src/editor/editorMultiSelections.ts similarity index 98% rename from packages/diffs/src/editor/multiSelection.ts rename to packages/diffs/src/editor/editorMultiSelections.ts index 237d60762..a5af46bcd 100644 --- a/packages/diffs/src/editor/multiSelection.ts +++ b/packages/diffs/src/editor/editorMultiSelections.ts @@ -1,5 +1,5 @@ import { applyOffsetEdits } from './editHistory'; -import { type EditorSelection, SelectionDirection } from './selection'; +import { type EditorSelection, SelectionDirection } from './editorSelection'; import { type Position, TextDocument, type TextEdit } from './textDocument'; type SelectionEditMapping = { diff --git a/packages/diffs/src/editor/selection.ts b/packages/diffs/src/editor/editorSelection.ts similarity index 100% rename from packages/diffs/src/editor/selection.ts rename to packages/diffs/src/editor/editorSelection.ts diff --git a/packages/diffs/src/editor/textareaState.ts b/packages/diffs/src/editor/editorTextareaState.ts similarity index 98% rename from packages/diffs/src/editor/textareaState.ts rename to packages/diffs/src/editor/editorTextareaState.ts index df04abf52..43603ec94 100644 --- a/packages/diffs/src/editor/textareaState.ts +++ b/packages/diffs/src/editor/editorTextareaState.ts @@ -1,5 +1,8 @@ +import { + type EditorSelection, + fromWebSelectionDirection, +} from './editorSelection'; import { getLineIndentation } from './editorUtils'; -import { type EditorSelection, fromWebSelectionDirection } from './selection'; export type TextareaState = { selections: EditorSelection[]; diff --git a/packages/diffs/src/editor/textDocument.ts b/packages/diffs/src/editor/textDocument.ts index 794b026dc..b4a263b98 100644 --- a/packages/diffs/src/editor/textDocument.ts +++ b/packages/diffs/src/editor/textDocument.ts @@ -3,7 +3,7 @@ import { EditHistory, type ResolvedEdit, } from './editHistory'; -import { type EditorSelection } from './selection'; +import { type EditorSelection } from './editorSelection'; /** * Position in a text document expressed as zero-based line and character offset. diff --git a/packages/diffs/test/editHistory.test.ts b/packages/diffs/test/editHistory.test.ts index f9cd27360..844e03505 100644 --- a/packages/diffs/test/editHistory.test.ts +++ b/packages/diffs/test/editHistory.test.ts @@ -5,8 +5,8 @@ import { buildInverseOffsetEdits, EditHistory, } from '../src/editor/editHistory'; -import type { EditorSelection } from '../src/editor/selection'; -import { SelectionDirection } from '../src/editor/selection'; +import type { EditorSelection } from '../src/editor/editorSelection'; +import { SelectionDirection } from '../src/editor/editorSelection'; function createSelection( startLine: number, diff --git a/packages/diffs/test/multiSelection.test.ts b/packages/diffs/test/editorMultiSelections.test.ts similarity index 96% rename from packages/diffs/test/multiSelection.test.ts rename to packages/diffs/test/editorMultiSelections.test.ts index f58d0f2a3..36a8472f6 100644 --- a/packages/diffs/test/multiSelection.test.ts +++ b/packages/diffs/test/editorMultiSelections.test.ts @@ -4,9 +4,9 @@ import { mapSelectionMove, mapSelectionTextChange, mapSelectionTextReplace, -} from '../src/editor/multiSelection'; -import type { EditorSelection } from '../src/editor/selection'; -import { SelectionDirection } from '../src/editor/selection'; +} from '../src/editor/editorMultiSelections'; +import type { EditorSelection } from '../src/editor/editorSelection'; +import { SelectionDirection } from '../src/editor/editorSelection'; import { TextDocument } from '../src/editor/textDocument'; function createSelection( diff --git a/packages/diffs/test/selection.test.ts b/packages/diffs/test/editorSelection.test.ts similarity index 98% rename from packages/diffs/test/selection.test.ts rename to packages/diffs/test/editorSelection.test.ts index c7700f9f9..eaed3bfb1 100644 --- a/packages/diffs/test/selection.test.ts +++ b/packages/diffs/test/editorSelection.test.ts @@ -1,6 +1,9 @@ import { describe, expect, test } from 'bun:test'; -import { convertSelection, SelectionDirection } from '../src/editor/selection'; +import { + convertSelection, + SelectionDirection, +} from '../src/editor/editorSelection'; type MockNode = { nodeType: number; diff --git a/packages/diffs/test/textareaState.test.ts b/packages/diffs/test/editorTextareaState.test.ts similarity index 98% rename from packages/diffs/test/textareaState.test.ts rename to packages/diffs/test/editorTextareaState.test.ts index a9fb2ad83..887980549 100644 --- a/packages/diffs/test/textareaState.test.ts +++ b/packages/diffs/test/editorTextareaState.test.ts @@ -4,12 +4,12 @@ import { type EditorSelection, SelectionDirection, toWebSelectionDirection, -} from '../src/editor/selection'; +} from '../src/editor/editorSelection'; import { createTextareaSnippet, matchesTextareaState, resolveTextareaTextChange, -} from '../src/editor/textareaState'; +} from '../src/editor/editorTextareaState'; import { TextDocument } from '../src/editor/textDocument'; type TextareaSnippetCase = { diff --git a/packages/diffs/test/textDocument.test.ts b/packages/diffs/test/textDocument.test.ts index db389033b..0a1575659 100644 --- a/packages/diffs/test/textDocument.test.ts +++ b/packages/diffs/test/textDocument.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from 'bun:test'; -import type { EditorSelection } from '../src/editor/selection'; -import { SelectionDirection } from '../src/editor/selection'; +import type { EditorSelection } from '../src/editor/editorSelection'; +import { SelectionDirection } from '../src/editor/editorSelection'; import { TextDocument, type TextEdit } from '../src/editor/textDocument'; function doc(text: string) { From a0934f8a7916d789020c931f80baceb249c9c883 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Sun, 26 Apr 2026 00:18:20 +0800 Subject: [PATCH 016/138] Refactor textarea buffer --- packages/diffs/src/components/Editor.ts | 541 ++++++++---------- packages/diffs/src/editor/editHistory.ts | 34 +- packages/diffs/src/editor/editSnippet.ts | 89 +++ packages/diffs/src/editor/editorCommand.ts | 4 +- .../diffs/src/editor/editorMultiSelections.ts | 23 +- packages/diffs/src/editor/editorSelection.ts | 31 +- .../diffs/src/editor/editorTextareaState.ts | 256 --------- packages/diffs/src/editor/textDocument.ts | 14 +- packages/diffs/test/editSnippet.test.ts | 55 ++ .../diffs/test/editorMultiSelections.test.ts | 12 - packages/diffs/test/editorSelection.test.ts | 86 +++ .../diffs/test/editorTextareaState.test.ts | 275 --------- 12 files changed, 524 insertions(+), 896 deletions(-) create mode 100644 packages/diffs/src/editor/editSnippet.ts delete mode 100644 packages/diffs/src/editor/editorTextareaState.ts create mode 100644 packages/diffs/test/editSnippet.test.ts delete mode 100644 packages/diffs/test/editorTextareaState.test.ts diff --git a/packages/diffs/src/components/Editor.ts b/packages/diffs/src/components/Editor.ts index 9d2c93dd8..3f2f1223b 100644 --- a/packages/diffs/src/components/Editor.ts +++ b/packages/diffs/src/components/Editor.ts @@ -1,8 +1,9 @@ import { EncodedTokenMetadata, type IGrammar, INITIAL } from 'shiki/textmate'; +import { DEFAULT_THEMES } from '../constants'; import { type EditorCommand, - getPrimaryModifier, + isPrimaryModifier, resolveEditorCommandFromKeyboardEvent, } from '../editor/editorCommand'; import { @@ -14,37 +15,39 @@ import { type NormalizedEditorOptions, normlizeEditorOptions, } from '../editor/editorOptions'; -import type { EditorSelection } from '../editor/editorSelection'; +import type { + EditorSelection, + EditorTextChange, +} from '../editor/editorSelection'; import { comparePosition, convertSelection, - fromWebSelectionDirection, getPrimarySelection, isCollapsedSelection, resolveIndentEdits, SelectionDirection, + selectionIntersects, toWebSelectionDirection, } from '../editor/editorSelection'; -import { - createTextareaSnippet, - matchesTextareaState, - resolveTextareaTextChange, - type TextareaState, -} from '../editor/editorTextareaState'; import { addEventListener, - coalesceMicrotask, createElement, extend, getLineIndentationUnit, getRootCssVariableValue, measureMonoFontWidth, } from '../editor/editorUtils'; +import { + createEditSnippet, + type EditSnippet, + resolveTextChange, +} from '../editor/editSnippet'; import { TextDocument, type TextEdit } from '../editor/textDocument'; import { getHighlighterIfLoaded, getSharedHighlighter, } from '../highlighter/shared_highlighter'; +import { areThemesAttached } from '../highlighter/themes/areThemesAttached'; import type { BaseCodeOptions, DiffsHighlighter, @@ -82,17 +85,13 @@ export class Editor { // state #isEditorElFocused?: boolean; #isTextareaElFocused?: boolean; - #textareaState?: TextareaState; - #pendingTextareaSnapshot?: { - value: string; - selectionStart: number; - selectionEnd: number; - selectionDirection: HTMLTextAreaElement['selectionDirection']; - }; - #typingFlushTimeout?: number; + #editSnippet?: EditSnippet; + #typingBuffer?: { text: string; line: number }; + #typingBufferFlushTimeout?: ReturnType; #selections?: EditorSelection[]; #reservedSelections?: EditorSelection[]; #languageLoadRequestId = 0; + #ignoreSelectionChange = false; #disposes?: (() => void)[]; @@ -102,6 +101,9 @@ export class Editor { 'normal ' + this.#options.fontSize + 'px ' + this.#options.fontFamily ); this.#gutterWidth = 0; + this.#highlighter = areThemesAttached(options.theme ?? DEFAULT_THEMES) + ? getHighlighterIfLoaded() + : undefined; } get options(): EditorOptions { @@ -126,7 +128,7 @@ export class Editor { setTextDocument(textDocument: TextDocument): void { this.#textDocument = textDocument; - this.#textareaState = undefined; + this.#editSnippet = undefined; this.#reservedSelections = undefined; this.#selections = undefined; this.#renderText(textDocument); @@ -144,39 +146,33 @@ export class Editor { if (this.#editorEl !== undefined) { this.cleanUp(); } - const { tabIndex = -1 } = this.#options; - const queueTextareaStateSync = coalesceMicrotask(() => - this.#syncTextareaState() - ); - this.#editorEl = extend( - createElement('div', { - style: { - position: 'relative', - boxSizing: 'border-box', - paddingTop: `${this.#options.paddingY}px`, - paddingBottom: `${this.#options.paddingY}px`, - fontFamily: this.#options.fontFamily, - fontFeatureSettings: 'var(--diffs-font-features)', - isolation: 'isolate', - }, - }), - { - tabIndex, - } - ); - this.#styleEl = createElement('style', undefined, this.#editorEl); - this.#textareaEl = extend( - createElement('textarea', { class: 'ť' }, this.#editorEl), - { - autocapitalize: 'off', - autocomplete: 'off', - autocorrect: false, - spellcheck: false, - wrap: 'off', - } - ); + const editorEl = createElement('div', { + style: { + position: 'relative', + boxSizing: 'border-box', + paddingTop: `${this.#options.paddingY}px`, + paddingBottom: `${this.#options.paddingY}px`, + fontFamily: this.#options.fontFamily, + fontFeatureSettings: 'var(--diffs-font-features)', + isolation: 'isolate', + }, + }); + const textareaEl = createElement('textarea', { class: 'ť' }, editorEl); + this.#editorEl = extend(editorEl, { tabIndex: this.#options.tabIndex }); + this.#styleEl = createElement('style', undefined, editorEl); + this.#textareaEl = extend(textareaEl, { + autocapitalize: 'off', + autocomplete: 'off', + autocorrect: false, + spellcheck: false, + wrap: 'off', + }); this.#disposes = [ addEventListener(document, 'selectionchange', () => { + if (this.#ignoreSelectionChange) { + return; + } + const selectionRaw = document.getSelection(); if ( selectionRaw !== null && @@ -184,55 +180,71 @@ export class Editor { ) { const selection = convertSelection(selectionRaw); if (selection !== null) { + console.log('\n~~~~~~~~~', Math.round(Date.now() / 1000)); + console.log('document: selectionchange', selection); + const reservedSelections = this.#reservedSelections; + if (reservedSelections === undefined) { + this.#restoreSelections([selection]); + return; + } this.#restoreSelections([ - ...(this.#reservedSelections ?? []), + ...reservedSelections.filter( + (reservedSelection) => + !selectionIntersects(reservedSelection, selection) + ), selection, ]); } } }), - addEventListener(this.#editorEl, 'mousedown', (e) => { - if (e.button === 0 && getPrimaryModifier(e)) { + + addEventListener(editorEl, 'mousedown', (e) => { + if (e.button === 0 && isPrimaryModifier(e)) { this.#reservedSelections = this.#selections?.map((selection) => ({ ...selection, })); + } else { + this.#reservedSelections = undefined; } }), - addEventListener(document, 'mouseup', () => { + + addEventListener(editorEl, 'mouseup', () => { this.#reservedSelections = undefined; }), - addEventListener(this.#editorEl, 'focus', () => { - this.#isEditorElFocused = true; - }), - addEventListener(this.#editorEl, 'blur', () => { - this.#isEditorElFocused = false; - }), - addEventListener(this.#textareaEl, 'focus', () => { - this.#isTextareaElFocused = true; - }), - addEventListener(this.#textareaEl, 'blur', () => { - this.#isTextareaElFocused = false; - this.#flushPendingTextareaChanges(); - }), - addEventListener(document, 'keydown', (e) => { - if (!this.#hasFocusWithinEditor()) { - return; - } + + addEventListener(editorEl, 'keydown', (e) => { if (this.#isTextareaElFocused !== true) { const command = resolveEditorCommandFromKeyboardEvent(e); if (command !== undefined) { this.#flushPendingTextareaChanges(); e.preventDefault(); - console.log('keydown', command); void this.#runCommand(command); return; } } if (this.#isEditorElFocused === true) { - this.#textareaEl?.focus(); + textareaEl.focus(); } }), - addEventListener(this.#textareaEl, 'keydown', (e) => { + + addEventListener(editorEl, 'focus', () => { + this.#isEditorElFocused = true; + }), + + addEventListener(editorEl, 'blur', () => { + this.#isEditorElFocused = false; + }), + + addEventListener(textareaEl, 'focus', () => { + this.#isTextareaElFocused = true; + }), + + addEventListener(textareaEl, 'blur', () => { + this.#isTextareaElFocused = false; + this.#flushPendingTextareaChanges(); + }), + + addEventListener(textareaEl, 'keydown', (e) => { const command = resolveEditorCommandFromKeyboardEvent(e); if (command !== undefined) { this.#flushPendingTextareaChanges(); @@ -240,52 +252,57 @@ export class Editor { void this.#runCommand(command); } }), - addEventListener(this.#textareaEl, 'input', queueTextareaStateSync), - addEventListener(this.#textareaEl, 'selectionchange', () => { - if ( - this.#textareaState !== undefined && - this.#textareaEl !== undefined && - matchesTextareaState(this.#textareaState, this.#textareaEl) - ) { + + // addEventListener(textareaEl, "input", () => { + // if (this.#ignoreSelectionChange) { + // return; + // } + // console.log("\n~~~~~~~~~", Math.round(Date.now() / 1000)); + // console.log("textarea: input"); + // this.#syncTextareaState(); + // }), + + addEventListener(textareaEl, 'selectionchange', () => { + if (this.#ignoreSelectionChange) { return; } - queueTextareaStateSync(); + console.log('\n~~~~~~~~~', Math.round(Date.now() / 1000)); + console.log('textarea: selectionchange'); + this.#syncTextareaState(); }), ]; - this.#highlighter = - getHighlighterIfLoaded() ?? - getSharedHighlighter( - getHighlighterOptions(undefined, this.#options) - ).then((highlighter) => { - this.#highlighter = highlighter; - this.#updateStyle(); - return highlighter; - }); + this.#highlighter ??= getSharedHighlighter( + getHighlighterOptions(undefined, this.#options) + ).then((highlighter) => { + this.#highlighter = highlighter; + this.#updateStyle(); + return highlighter; + }); this.#updateStyle(); if (this.#textDocument !== undefined) { this.#renderText(this.#textDocument, this.#selections); } - editorContainer.appendChild(this.#editorEl); + editorContainer.appendChild(editorEl); } public cleanUp(): void { - this.#clearTypingFlushTimeout(); + this.#flushPendingTextareaChanges(); this.#textLineEls?.clear(); this.#selectionEls?.clear(); this.#disposes?.forEach((dispose) => dispose()); this.#editorEl?.remove(); - this.#editorEl = undefined; - this.#styleEl = undefined; - this.#textareaEl = undefined; this.#activeLineEl = undefined; - this.#textLineEls = undefined; - this.#selectionEls = undefined; this.#disposes = undefined; + this.#editorEl = undefined; this.#isEditorElFocused = false; this.#isTextareaElFocused = false; - this.#textareaState = undefined; - this.#pendingTextareaSnapshot = undefined; this.#reservedSelections = undefined; + this.#selections = undefined; + this.#selectionEls = undefined; + this.#styleEl = undefined; + this.#textareaEl = undefined; + this.#editSnippet = undefined; + this.#textLineEls = undefined; } #updateStyle() { @@ -352,7 +369,7 @@ export class Editor { ? `background-color:${lineHighlightBackground}` : `border:2px solid ${selectionBackground}`) + ';pointer-events:none}') + - ('.ť{position:absolute;z-index:-20;width:100%;padding:0;' + + ('.ť{position:absolute;left:var(--diffs-editor-gutter-width);z-index:-20;width:calc(100% - var(--diffs-editor-gutter-width));padding:0;' + `line-height:${lineHeight}px;` + 'font:inherit;background-color:transparent;color:transparent;opacity:0;border:none;outline:none;resize:none}') + `.ń{display:inline-block;text-align:right;width:var(--diffs-editor-line-number-width);padding:0 ${this.#monoCharWidth}px;color:${lineNumberForeground};user-select:none;pointer-events:none;cursor:default}` + @@ -363,25 +380,33 @@ export class Editor { '}'; } - #renderText( - textDocument: TextDocument, - selections?: EditorSelection[] - ): void { - const totalLines = textDocument.lineCount; - const languageId = textDocument.languageId; - - // update gutter width + // update gutter width + #updateGutterWidth(totalLines: number) { const lineNumberDigits = totalLines.toString().length; const lineNumberWidth = Math.round( Math.max(this.#options.minNumberColumnWidth, lineNumberDigits) * this.#monoCharWidth ); const lineNumberPadding = 2 * this.#monoCharWidth; + this.#gutterWidth = lineNumberWidth + lineNumberPadding; this.#editorEl?.style.setProperty( '--diffs-editor-line-number-width', lineNumberWidth + 'px' ); - this.#gutterWidth = lineNumberWidth + lineNumberPadding; + this.#editorEl?.style.setProperty( + '--diffs-editor-gutter-width', + this.#gutterWidth + 'px' + ); + } + + #renderText( + textDocument: TextDocument, + selections?: EditorSelection[] + ): void { + const totalLines = textDocument.lineCount; + const languageId = textDocument.languageId; + + this.#updateGutterWidth(totalLines); let grammar: IGrammar | undefined; const highlighter = this.#highlighter; @@ -408,6 +433,7 @@ export class Editor { const lineEls = new Map(); for (let line = 0, ruleStack = INITIAL; line < totalLines; line++) { const lineText = textDocument.getLineText(line) ?? ''; + const lineLength = lineText.length; const preEl = createElement('pre', undefined, this.#editorEl); // oxlint-disable-next-line typescript/no-explicit-any (preEl as any).LINE = line; @@ -417,7 +443,7 @@ export class Editor { lineNumberEl.textContent = (line + 1).toString(); if (grammar === undefined) { - if (lineText.length === 0) { + if (lineLength === 0) { createElement('br', undefined, preEl); continue; } @@ -429,8 +455,13 @@ export class Editor { } const result = grammar.tokenizeLine2(lineText, ruleStack); + if (result.stoppedEarly) { + console.warn( + `Time limit reached when tokenizing line: ${lineText.substring(0, 100)}` + ); + } + const tokens = result.tokens; - const lineLength = lineText.length; const tokensLength = tokens.length / 2; for (let j = 0; j < tokensLength; j++) { const offset = tokens[2 * j]; @@ -472,174 +503,83 @@ export class Editor { } } + #renderLine(line: string, offset: number) { + console.log({ line, offset }); + } + #syncTextareaState() { + console.log('syncTextareaState'); const textDocument = this.#textDocument; const textareaEl = this.#textareaEl; - const textareaState = this.#textareaState; + const editSnippet = this.#editSnippet; if ( textDocument === undefined || textareaEl === undefined || - textareaState === undefined + editSnippet === undefined ) { return; } - const { - selections: selectionsBefore, - primarySelection: selectionBefore, - snippet: textareaSnippet, - value: originalValue, - } = textareaState; - const pendingSnapshot = this.#pendingTextareaSnapshot; - const { selectionStart, selectionEnd, selectionDirection, value } = - pendingSnapshot ?? textareaEl; - const snippetStartOffset = textDocument.offsetAt({ - line: textareaSnippet.firstLine, - character: 0, - }); - if (value !== originalValue) { - const { - start: oldChangedStart, - end: oldChangedEnd, - text: newChangedText, - selectionStart: nextSelectionStart, - selectionEnd: nextSelectionEnd, - } = resolveTextareaTextChange({ - documentValue: textDocument.getText(), - originalValue, - value, - originalSelectionStart: textareaSnippet.selectionStart, - originalSelectionEnd: textareaSnippet.selectionEnd, - selectionStart, - selectionEnd, - }); - const { edits, nextSelections } = mapSelectionTextChange( - textDocument, - selectionsBefore, - { - start: snippetStartOffset + oldChangedStart, - end: snippetStartOffset + oldChangedEnd, - text: newChangedText, - selectionStart: snippetStartOffset + nextSelectionStart, - selectionEnd: snippetStartOffset + nextSelectionEnd, - direction: fromWebSelectionDirection(selectionDirection), - } - ); - const isBufferedTypingChange = - pendingSnapshot === undefined && - selectionsBefore.length === 1 && - isCollapsedSelection(selectionBefore) && - nextSelections.length === 1 && - isCollapsedSelection(nextSelections[0]) && - selectionStart === selectionEnd; - if (isBufferedTypingChange) { - this.#pendingTextareaSnapshot = { - value, - selectionStart, - selectionEnd, - selectionDirection, - }; - this.#scheduleTypingFlush(); - return; + const { selectionStart, selectionEnd, value } = textareaEl; + if (value !== editSnippet.text) { + if ( + value.split('\n').length !== editSnippet.lines || + editSnippet.lines !== 3 + ) { + const change = resolveTextChange(editSnippet, value); + this.#applyTextChange(change); + } else { + const line = value.split('\n')[1]; + this.#renderLine(line, editSnippet.offset + selectionStart); + this.#typingBuffer = { text: value, line: editSnippet.startLine }; + this.#typingBufferFlushTimeout = setTimeout(() => { + this.#flushPendingTextareaChanges(); + }, 500); } - this.#applyResolvedTextareaChange( - edits, - selectionsBefore, - nextSelections - ); - // if (newChangedText.trim() && nextSelections.length === 1 && isCollapsedSelection(nextSelections[0]!)) { - // this.#langs.get(textDocument.languageId)?.lspDriver?.doComplete(textDocument, nextSelections[0]!.end); - // } - } else if (selectionStart === selectionEnd) { + } else if ( + selectionStart === selectionEnd && + this.#selections !== undefined + ) { this.#restoreSelections( mapSelectionMove( textDocument, - selectionsBefore, - textDocument.positionAt(snippetStartOffset + selectionStart) + this.#selections, + textDocument.positionAt(editSnippet.offset + selectionStart) ) ); } } - #scheduleTypingFlush() { - this.#clearTypingFlushTimeout(); - this.#typingFlushTimeout = window.setTimeout(() => { - this.#typingFlushTimeout = undefined; - this.#flushPendingTextareaChanges(); - }, 300); - } - - #clearTypingFlushTimeout() { - if (this.#typingFlushTimeout !== undefined) { - window.clearTimeout(this.#typingFlushTimeout); - this.#typingFlushTimeout = undefined; - } - } - #flushPendingTextareaChanges() { - const textDocument = this.#textDocument; - const textareaState = this.#textareaState; - const pendingSnapshot = this.#pendingTextareaSnapshot; - if ( - textDocument === undefined || - textareaState === undefined || - pendingSnapshot === undefined - ) { - return; + console.log('flushPendingTextareaChanges'); + if (this.#typingBufferFlushTimeout !== undefined) { + window.clearTimeout(this.#typingBufferFlushTimeout); + this.#typingBufferFlushTimeout = undefined; + } + if (this.#editSnippet !== undefined && this.#typingBuffer !== undefined) { + const change = resolveTextChange( + this.#editSnippet, + this.#typingBuffer.text + ); + this.#typingBuffer = undefined; + this.#applyTextChange(change); } - this.#clearTypingFlushTimeout(); - const { - selections: selectionsBefore, - snippet: textareaSnippet, - value: originalValue, - } = textareaState; - const { value, selectionStart, selectionEnd, selectionDirection } = - pendingSnapshot; - const snippetStartOffset = textDocument.offsetAt({ - line: textareaSnippet.firstLine, - character: 0, - }); - const { - start: oldChangedStart, - end: oldChangedEnd, - text: newChangedText, - selectionStart: nextSelectionStart, - selectionEnd: nextSelectionEnd, - } = resolveTextareaTextChange({ - documentValue: textDocument.getText(), - originalValue, - value, - originalSelectionStart: textareaSnippet.selectionStart, - originalSelectionEnd: textareaSnippet.selectionEnd, - selectionStart, - selectionEnd, - }); - const { edits, nextSelections } = mapSelectionTextChange( - textDocument, - selectionsBefore, - { - start: snippetStartOffset + oldChangedStart, - end: snippetStartOffset + oldChangedEnd, - text: newChangedText, - selectionStart: snippetStartOffset + nextSelectionStart, - selectionEnd: snippetStartOffset + nextSelectionEnd, - direction: fromWebSelectionDirection(selectionDirection), - } - ); - this.#pendingTextareaSnapshot = undefined; - this.#applyResolvedTextareaChange(edits, selectionsBefore, nextSelections); } - #applyResolvedTextareaChange( - edits: TextEdit[], - selectionsBefore: EditorSelection[], - nextSelections: EditorSelection[] - ) { - const textDocument = this.#textDocument; - if (textDocument === undefined) { - return; + #applyTextChange(change: EditorTextChange) { + if (this.#textDocument !== undefined && this.#selections !== undefined) { + const { edits, nextSelections: newSelections } = mapSelectionTextChange( + this.#textDocument, + this.#selections, + change + ); + this.#textDocument.applyEdits( + edits, + true, + this.#selections, + newSelections + ); + this.#renderText(this.#textDocument, newSelections); } - textDocument.applyEdits(edits, true, selectionsBefore, nextSelections); - this.#renderText(textDocument, nextSelections); } #restoreSelections(selections: EditorSelection[]) { @@ -662,7 +602,33 @@ export class Editor { this.#selectionEls?.forEach((el) => el.remove()); this.#selectionEls?.clear(); this.#selectionEls = selectionEls; - this.#updateTextarea(primarySelection, selections); + this.#updateTextarea(primarySelection); + } + + #updateTextarea(primarySelection: EditorSelection) { + console.log('updateTextarea'); + const textDocument = this.#textDocument; + const textareaEl = this.#textareaEl; + if (textDocument === undefined || textareaEl === undefined) { + return; + } + const editSnippet = createEditSnippet(textDocument, primarySelection); + this.#editSnippet = editSnippet; + this.#ignoreSelectionChange = true; + textareaEl.style.top = + this.#getLineY(primarySelection.start.line - 1) + 'px'; + textareaEl.style.height = + editSnippet.lines * this.#options.lineHeight + 'px'; + textareaEl.value = editSnippet.text; + textareaEl.setSelectionRange( + editSnippet.selectionStart, + editSnippet.selectionEnd, + toWebSelectionDirection(primarySelection.direction) + ); + setTimeout(() => { + console.log('^'); + this.#ignoreSelectionChange = false; + }, 0); } #renderHighlightLine( @@ -756,39 +722,6 @@ export class Editor { this.#activeLineEl = activeLineEl; } - #updateTextarea( - primarySelection: EditorSelection, - selections: EditorSelection[] - ) { - const textDocument = this.#textDocument; - const textareaEl = this.#textareaEl; - if (textDocument === undefined || textareaEl === undefined) { - return; - } - const textareaSnippet = createTextareaSnippet( - textDocument, - primarySelection - ); - this.#textareaState = { - selections, - primarySelection, - snippet: textareaSnippet, - value: textareaSnippet.text, - }; - this.#pendingTextareaSnapshot = undefined; - textareaEl.value = textareaSnippet.text; - textareaEl.setSelectionRange( - textareaSnippet.selectionStart, - textareaSnippet.selectionEnd, - toWebSelectionDirection(primarySelection.direction) - ); - textareaEl.style.left = this.#gutterWidth + 'px'; - textareaEl.style.width = `calc(100% - ${this.#gutterWidth}px)`; - textareaEl.style.top = this.#getLineY(textareaSnippet.firstLine) + 'px'; - textareaEl.style.height = - textareaSnippet.text.split('\n').length * this.#options.lineHeight + 'px'; - } - async #runCommand(command: EditorCommand) { switch (command) { case 'selectAll': @@ -952,11 +885,6 @@ export class Editor { start: textDocument.offsetAt(selection.start), end: textDocument.offsetAt(selection.end), text: normalizedText, - selectionStart: - textDocument.offsetAt(selection.start) + normalizedText.length, - selectionEnd: - textDocument.offsetAt(selection.start) + normalizedText.length, - direction: SelectionDirection.None, }); textDocument.applyEdits(edits, true, selections); this.#renderText(textDocument, nextSelections); @@ -1046,24 +974,15 @@ export class Editor { return column; } - // check if the active element has focus within editor - #hasFocusWithinEditor() { - const activeElement = document.activeElement; - if (activeElement === null) { - return false; - } - return ( - activeElement === this.#editorEl || - activeElement === this.#textareaEl || - this.#editorEl?.contains(activeElement) === true - ); - } - // check if the web selection belongs to editor #selectionBelongsToEditor(selection: Selection) { + const editorEl = this.#editorEl; return ( - this.#editorEl?.contains(selection.anchorNode) === true && - this.#editorEl?.contains(selection.focusNode) === true + editorEl !== undefined && + editorEl.contains(selection.anchorNode) === true && + editorEl !== selection.anchorNode && + editorEl.contains(selection.focusNode) === true && + editorEl !== selection.focusNode ); } } diff --git a/packages/diffs/src/editor/editHistory.ts b/packages/diffs/src/editor/editHistory.ts index 6958b6533..378e4ea82 100644 --- a/packages/diffs/src/editor/editHistory.ts +++ b/packages/diffs/src/editor/editHistory.ts @@ -1,16 +1,10 @@ -import { type EditorSelection } from './editorSelection'; - -export type ResolvedEdit = { - start: number; - end: number; - text: string; -}; +import { type EditorSelection, type EditorTextChange } from './editorSelection'; export type HistoryEntry = { /** Forward offset edits from the entry's base text to its final text. */ - forwardEdits: ResolvedEdit[]; + forwardEdits: EditorTextChange[]; /** Inverse offset edits from the entry's final text back to its base text. */ - inverseEdits: ResolvedEdit[]; + inverseEdits: EditorTextChange[]; /** Base text length before the entry is applied. */ textLengthBefore: number; /** Final text length after the entry is applied. */ @@ -40,7 +34,7 @@ export class EditHistory { push( textBefore: string, - resolvedEdits: ResolvedEdit[], + resolvedEdits: EditorTextChange[], selectionsBefore: EditorSelection[], selectionsAfter?: EditorSelection[] ): void { @@ -66,6 +60,15 @@ export class EditHistory { this.#redo.length = 0; } + setLastUndoSelectionsAfter(selections: EditorSelection[]): void { + const lastEntry = this.#undo[this.#undo.length - 1]; + if (lastEntry !== undefined) { + lastEntry.selectionsAfter = selections.map((selection) => ({ + ...selection, + })); + } + } + /** Moves the latest undo entry to the redo stack and returns it, or `undefined` if empty. */ popUndoToRedo(): HistoryEntry | void { const entry = this.#undo.pop(); @@ -85,7 +88,10 @@ export class EditHistory { } } -export function applyOffsetEdits(base: string, edits: ResolvedEdit[]): string { +export function applyOffsetEdits( + base: string, + edits: EditorTextChange[] +): string { const sortedEdits = [...edits].sort((a, b) => b.start - a.start); for (let i = 0; i < sortedEdits.length - 1; i++) { if (sortedEdits[i + 1].end > sortedEdits[i].start) { @@ -101,9 +107,9 @@ export function applyOffsetEdits(base: string, edits: ResolvedEdit[]): string { export function buildInverseOffsetEdits( textBefore: string, - ascending: ResolvedEdit[] -): ResolvedEdit[] { - const inverse: ResolvedEdit[] = []; + ascending: EditorTextChange[] +): EditorTextChange[] { + const inverse: EditorTextChange[] = []; for (let i = 0; i < ascending.length; i++) { const edit = ascending[i]; const replacedText = textBefore.slice(edit.start, edit.end); diff --git a/packages/diffs/src/editor/editSnippet.ts b/packages/diffs/src/editor/editSnippet.ts new file mode 100644 index 000000000..d9b6349ed --- /dev/null +++ b/packages/diffs/src/editor/editSnippet.ts @@ -0,0 +1,89 @@ +import { type EditorSelection, type EditorTextChange } from './editorSelection'; +import type { TextDocument } from './textDocument'; + +export interface EditSnippet { + readonly startLine: number; + readonly offset: number; + readonly selectionStart: number; + readonly selectionEnd: number; + readonly lines: number; + readonly text: string; +} + +export function createEditSnippet( + textDocument: TextDocument, + primarySelection: EditorSelection +): EditSnippet { + const startLine = Math.max(0, primarySelection.start.line - 1); + const endLine = Math.min( + textDocument.lineCount - 1, + primarySelection.end.line + 1 + ); + const lines: string[] = []; + let offset = 0; + let selectionStart = 0; + let selectionEnd = 0; + + for (let line = startLine; line <= endLine; line++) { + const lineText = textDocument.getLineText(line); + if (lineText === undefined) { + throw new Error(`Line ${line} is out of bounds`); + } + if (line === primarySelection.start.line) { + selectionStart = offset + primarySelection.start.character; + } + if (line === primarySelection.end.line) { + selectionEnd = offset + primarySelection.end.character; + } + lines.push(lineText); + offset += lineText.length; + if (line < endLine) { + offset++; + } + } + + return { + startLine, + offset: textDocument.offsetAt({ line: startLine, character: 0 }), + selectionStart, + selectionEnd, + lines: lines.length, + text: lines.join('\n'), + }; +} + +export function resolveTextChange( + editSnippet: EditSnippet, + newView: string +): EditorTextChange { + const original = editSnippet.text; + const originalLength = original.length; + const nextLength = newView.length; + + let prefix = 0; + while ( + prefix < originalLength && + prefix < nextLength && + original[prefix] === newView[prefix] + ) { + prefix++; + } + + let suffix = 0; + while ( + suffix < originalLength - prefix && + suffix < nextLength - prefix && + original[originalLength - 1 - suffix] === newView[nextLength - 1 - suffix] + ) { + suffix++; + } + + const originalStart = prefix; + const originalEnd = originalLength - suffix; + + return { + start: editSnippet.offset + originalStart, + end: editSnippet.offset + originalEnd, + text: newView.slice(prefix, nextLength - suffix), + }; +} diff --git a/packages/diffs/src/editor/editorCommand.ts b/packages/diffs/src/editor/editorCommand.ts index 11ae996fa..ed77f85cd 100644 --- a/packages/diffs/src/editor/editorCommand.ts +++ b/packages/diffs/src/editor/editorCommand.ts @@ -21,7 +21,7 @@ function isMacLike(): boolean { return /macOS|MacIntel|iPhone|iPad|iPod/i.test(getPlatform()); } -export function getPrimaryModifier(event: MouseEvent | KeyboardEvent): boolean { +export function isPrimaryModifier(event: MouseEvent | KeyboardEvent): boolean { return isMacLike() ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey; @@ -35,7 +35,7 @@ export function resolveEditorCommandFromKeyboardEvent( } const key = event.key.length === 1 ? event.key.toLowerCase() : event.key; - const hasPrimaryModifier = getPrimaryModifier(event); + const hasPrimaryModifier = isPrimaryModifier(event); const isMac = isMacLike(); if (!hasPrimaryModifier && key === 'Tab') { diff --git a/packages/diffs/src/editor/editorMultiSelections.ts b/packages/diffs/src/editor/editorMultiSelections.ts index a5af46bcd..bddb9085b 100644 --- a/packages/diffs/src/editor/editorMultiSelections.ts +++ b/packages/diffs/src/editor/editorMultiSelections.ts @@ -1,5 +1,9 @@ import { applyOffsetEdits } from './editHistory'; -import { type EditorSelection, SelectionDirection } from './editorSelection'; +import { + type EditorSelection, + type EditorTextChange, + SelectionDirection, +} from './editorSelection'; import { type Position, TextDocument, type TextEdit } from './textDocument'; type SelectionEditMapping = { @@ -7,15 +11,6 @@ type SelectionEditMapping = { nextSelections: EditorSelection[]; }; -type SelectionTextChange = { - start: number; - end: number; - text: string; - selectionStart: number; - selectionEnd: number; - direction: SelectionDirection; -}; - export function mapSelectionMove( textDocument: TextDocument, selections: readonly EditorSelection[], @@ -60,7 +55,7 @@ export function mapSelectionMove( export function mapSelectionTextChange( textDocument: TextDocument, selections: readonly EditorSelection[], - change: SelectionTextChange + change: EditorTextChange ): SelectionEditMapping { const primarySelection = selections[selections.length - 1]; if (primarySelection === undefined) { @@ -70,8 +65,6 @@ export function mapSelectionTextChange( const primaryEndOffset = textDocument.offsetAt(primarySelection.end); const relativeStart = change.start - primaryStartOffset; const relativeEnd = change.end - primaryEndOffset; - const postSelectionStartOffset = change.selectionStart - change.start; - const postSelectionEndOffset = change.selectionEnd - change.start; const ordered = selections .map((selection, index) => ({ selection, @@ -115,8 +108,8 @@ export function mapSelectionTextChange( newText: change.text, }); const nextOffsets: [number, number] = [ - mergedGroup.start + offsetDelta + postSelectionStartOffset, - mergedGroup.start + offsetDelta + postSelectionEndOffset, + mergedGroup.start + offsetDelta + change.text.length, + mergedGroup.start + offsetDelta + change.text.length, ]; for (const index of mergedGroup.indices) { nextSelectionOffsets[index] = nextOffsets; diff --git a/packages/diffs/src/editor/editorSelection.ts b/packages/diffs/src/editor/editorSelection.ts index 78c5f3e48..aa418d5b6 100644 --- a/packages/diffs/src/editor/editorSelection.ts +++ b/packages/diffs/src/editor/editorSelection.ts @@ -11,13 +11,10 @@ export type EditorSelection = Range & { direction: SelectionDirection; }; -export type EditorSelectionTextChange = { +export type EditorTextChange = { start: number; end: number; text: string; - selectionStart: number; - selectionEnd: number; - direction: SelectionDirection; }; /** @@ -124,6 +121,32 @@ export function isCollapsedSelection(selection: EditorSelection): boolean { ); } +export function selectionIntersects( + a: EditorSelection, + b: EditorSelection +): boolean { + const aCollapsed = isCollapsedSelection(a); + const bCollapsed = isCollapsedSelection(b); + if (aCollapsed && bCollapsed) { + return comparePosition(a.start, b.start) === 0; + } + if (aCollapsed) { + return ( + comparePosition(b.start, a.start) <= 0 && + comparePosition(a.start, b.end) <= 0 + ); + } + if (bCollapsed) { + return ( + comparePosition(a.start, b.start) <= 0 && + comparePosition(b.start, a.end) <= 0 + ); + } + return ( + comparePosition(a.start, b.end) < 0 && comparePosition(b.start, a.end) < 0 + ); +} + export function getPrimarySelection( selections: readonly EditorSelection[] ): EditorSelection | undefined { diff --git a/packages/diffs/src/editor/editorTextareaState.ts b/packages/diffs/src/editor/editorTextareaState.ts deleted file mode 100644 index 43603ec94..000000000 --- a/packages/diffs/src/editor/editorTextareaState.ts +++ /dev/null @@ -1,256 +0,0 @@ -import { - type EditorSelection, - fromWebSelectionDirection, -} from './editorSelection'; -import { getLineIndentation } from './editorUtils'; - -export type TextareaState = { - selections: EditorSelection[]; - primarySelection: EditorSelection; - snippet: TextareaSnippet; - value: string; -}; - -type TextLineSource = { - lineCount: number; - getLineText(line: number): string | undefined; -}; - -interface TextareaSnippet { - firstLine: number; - lastLine: number; - text: string; - selectionStart: number; - selectionEnd: number; -} - -type TextareaTextChange = { - start: number; - end: number; - text: string; - selectionStart: number; - selectionEnd: number; -}; - -type ResolveTextareaTextChangeOptions = { - documentValue?: string; - originalValue: string; - value: string; - originalSelectionStart: number; - originalSelectionEnd: number; - selectionStart: number; - selectionEnd: number; -}; - -type TextareaSnapshot = Pick< - HTMLTextAreaElement, - 'value' | 'selectionStart' | 'selectionEnd' | 'selectionDirection' ->; - -function gcd(a: number, b: number): number { - let x = Math.abs(a); - let y = Math.abs(b); - while (y !== 0) { - const t = y; - y = x % y; - x = t; - } - return x; -} - -function detectIndentUnit(text: string): string { - const lines = text.split('\n'); - const spaceIndentLengths: number[] = []; - let tabIndentedLineCount = 0; - for (const line of lines) { - if (line.trim() === '') { - continue; - } - const indentation = getLineIndentation(line); - if (indentation === '') { - continue; - } - if (indentation.startsWith('\t')) { - tabIndentedLineCount++; - continue; - } - spaceIndentLengths.push(indentation.length); - } - if (spaceIndentLengths.length > 0) { - const unitLength = spaceIndentLengths.reduce((acc, length) => - gcd(acc, length) - ); - if (unitLength > 1) { - return ' '.repeat(unitLength); - } - return ' '.repeat(Math.min(...spaceIndentLengths)); - } - if (tabIndentedLineCount > 0) { - return '\t'; - } - return ' '; -} - -export function createTextareaSnippet( - textLineSource: TextLineSource, - selection: EditorSelection -): TextareaSnippet { - const firstLine = Math.max(0, selection.start.line - 1); - const lastLine = Math.min( - textLineSource.lineCount - 1, - selection.end.line + 1 - ); - const lines: string[] = []; - let offset = 0; - let selectionStart = 0; - let selectionEnd = 0; - - for (let line = firstLine; line <= lastLine; line++) { - const lineText = textLineSource.getLineText(line); - if (lineText === undefined) { - throw new Error(`Line ${line} is out of bounds`); - } - if (line === selection.start.line) { - selectionStart = offset + selection.start.character; - } - if (line === selection.end.line) { - selectionEnd = offset + selection.end.character; - } - lines.push(lineText); - offset += lineText.length; - if (line < lastLine) { - offset++; - } - } - - return { - firstLine, - lastLine, - text: lines.join('\n'), - selectionStart, - selectionEnd, - }; -} - -export function matchesTextareaState( - textareaState: TextareaState, - { value, selectionStart, selectionEnd, selectionDirection }: TextareaSnapshot -): boolean { - return ( - value === textareaState.value && - selectionStart === textareaState.snippet.selectionStart && - selectionEnd === textareaState.snippet.selectionEnd && - fromWebSelectionDirection(selectionDirection) === - textareaState.primarySelection.direction - ); -} - -export function resolveTextareaTextChange({ - documentValue, - originalValue, - value, - originalSelectionStart, - originalSelectionEnd, - selectionStart, - selectionEnd, -}: ResolveTextareaTextChangeOptions): TextareaTextChange { - let prefixLength = 0; - const prefixLimit = Math.min(originalSelectionStart, selectionStart); - while ( - prefixLength < prefixLimit && - originalValue[prefixLength] === value[prefixLength] - ) { - prefixLength++; - } - - let suffixLength = 0; - const suffixLimit = Math.min( - originalValue.length - originalSelectionEnd, - value.length - selectionEnd - ); - while ( - suffixLength < suffixLimit && - originalValue[originalValue.length - 1 - suffixLength] === - value[value.length - 1 - suffixLength] - ) { - suffixLength++; - } - - const start = prefixLength; - const end = originalValue.length - suffixLength; - let text = value.slice(prefixLength, value.length - suffixLength); - let nextSelectionStart = selectionStart; - let nextSelectionEnd = selectionEnd; - const getLineBounds = (offset: number) => { - const lineStart = - originalValue.lastIndexOf('\n', Math.max(0, offset - 1)) + 1; - const lineEnd = originalValue.indexOf('\n', offset); - return { - lineStart, - lineEnd: lineEnd === -1 ? originalValue.length : lineEnd, - }; - }; - - if ( - originalSelectionStart === originalSelectionEnd && - selectionStart === selectionEnd && - text === '\n' && - end === start - ) { - const { lineStart, lineEnd } = getLineBounds(start); - const lineText = originalValue.slice(lineStart, lineEnd); - const indentation = getLineIndentation(lineText); - if (indentation.length > 0) { - text += indentation; - const delta = indentation.length; - nextSelectionStart += delta; - nextSelectionEnd += delta; - } - } - - if ( - originalSelectionStart === originalSelectionEnd && - selectionStart === selectionEnd && - text === '' && - end - start === 1 && - selectionStart === originalSelectionStart - 1 - ) { - const { lineStart, lineEnd } = getLineBounds(originalSelectionStart); - const lineText = originalValue.slice(lineStart, lineEnd); - const indentation = getLineIndentation(lineText); - if ( - indentation.length > 0 && - indentation.length === lineText.length && - end === lineEnd - ) { - const indentUnit = detectIndentUnit(documentValue ?? originalValue); - const deletedIndentLength = indentation.startsWith('\t') - ? 1 - : Math.min( - indentUnit === '\t' ? 1 : indentUnit.length, - indentation.length - ); - const expandedStart = Math.max(lineStart, end - deletedIndentLength); - const delta = start - expandedStart; - if (delta > 0) { - nextSelectionStart -= delta; - nextSelectionEnd -= delta; - } - return { - start: expandedStart, - end, - text, - selectionStart: nextSelectionStart, - selectionEnd: nextSelectionEnd, - }; - } - } - - return { - start, - end, - text, - selectionStart: nextSelectionStart, - selectionEnd: nextSelectionEnd, - }; -} diff --git a/packages/diffs/src/editor/textDocument.ts b/packages/diffs/src/editor/textDocument.ts index b4a263b98..97f695ce2 100644 --- a/packages/diffs/src/editor/textDocument.ts +++ b/packages/diffs/src/editor/textDocument.ts @@ -1,9 +1,5 @@ -import { - applyOffsetEdits, - EditHistory, - type ResolvedEdit, -} from './editHistory'; -import { type EditorSelection } from './editorSelection'; +import { applyOffsetEdits, EditHistory } from './editHistory'; +import { type EditorSelection, type EditorTextChange } from './editorSelection'; /** * Position in a text document expressed as zero-based line and character offset. @@ -180,6 +176,10 @@ export class TextDocument { this.#setDocumentText(newText); } + setLastUndoSelectionsAfter(selections: EditorSelection[]): void { + this.#history.setLastUndoSelectionsAfter(selections); + } + undo(): EditorSelection[] | undefined { const entry = this.#history.popUndoToRedo(); if (entry === undefined) { @@ -202,7 +202,7 @@ export class TextDocument { : undefined; } - #resolveEdit(edit: TextEdit): ResolvedEdit { + #resolveEdit(edit: TextEdit): EditorTextChange { let start = this.offsetAt(edit.range.start); let end = this.offsetAt(edit.range.end); if (start > end) { diff --git a/packages/diffs/test/editSnippet.test.ts b/packages/diffs/test/editSnippet.test.ts new file mode 100644 index 000000000..a7ca7a988 --- /dev/null +++ b/packages/diffs/test/editSnippet.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test } from 'bun:test'; + +import { + type EditorSelection, + SelectionDirection, +} from '../src/editor/editorSelection'; +import { + createEditSnippet, + resolveTextChange, +} from '../src/editor/editSnippet'; +import { TextDocument } from '../src/editor/textDocument'; + +function createSelection( + startLine: number, + startCharacter: number, + endLine: number, + endCharacter: number, + direction: SelectionDirection = SelectionDirection.None +): EditorSelection { + return { + start: { line: startLine, character: startCharacter }, + end: { line: endLine, character: endCharacter }, + direction, + }; +} + +describe('resolveTextChange', () => { + test('replaces selected text with a shorter typed value', () => { + const textDocument = new TextDocument('inmemory://1', 'abc'); + const snippet = createEditSnippet( + textDocument, + createSelection(0, 0, 0, 3, SelectionDirection.Forward) + ); + + expect(resolveTextChange(snippet, '1')).toEqual({ + start: 0, + end: 3, + text: '1', + }); + }); + + test('keeps pure deletion as an empty replacement', () => { + const textDocument = new TextDocument('inmemory://1', 'abc'); + const snippet = createEditSnippet( + textDocument, + createSelection(0, 2, 0, 2) + ); + + expect(resolveTextChange(snippet, 'ac')).toEqual({ + start: 1, + end: 2, + text: '', + }); + }); +}); diff --git a/packages/diffs/test/editorMultiSelections.test.ts b/packages/diffs/test/editorMultiSelections.test.ts index 36a8472f6..433398a02 100644 --- a/packages/diffs/test/editorMultiSelections.test.ts +++ b/packages/diffs/test/editorMultiSelections.test.ts @@ -38,9 +38,6 @@ describe('mapSelectionTextChange', () => { start: 5, end: 5, text: '!', - selectionStart: 6, - selectionEnd: 6, - direction: SelectionDirection.None, } ); @@ -68,9 +65,6 @@ describe('mapSelectionTextChange', () => { start: 8, end: 11, text: 'x', - selectionStart: 9, - selectionEnd: 9, - direction: SelectionDirection.None, } ); @@ -98,9 +92,6 @@ describe('mapSelectionTextChange', () => { start: 6, end: 7, text: '', - selectionStart: 6, - selectionEnd: 6, - direction: SelectionDirection.None, } ); @@ -127,9 +118,6 @@ describe('mapSelectionTextChange', () => { start: 0, end: 2, text: '', - selectionStart: 0, - selectionEnd: 0, - direction: SelectionDirection.None, } ); diff --git a/packages/diffs/test/editorSelection.test.ts b/packages/diffs/test/editorSelection.test.ts index eaed3bfb1..a930b5e4d 100644 --- a/packages/diffs/test/editorSelection.test.ts +++ b/packages/diffs/test/editorSelection.test.ts @@ -2,7 +2,9 @@ import { describe, expect, test } from 'bun:test'; import { convertSelection, + type EditorSelection, SelectionDirection, + selectionIntersects, } from '../src/editor/editorSelection'; type MockNode = { @@ -36,6 +38,19 @@ function selection( } as Selection; } +function editorSelection( + startLine: number, + startCharacter: number, + endLine: number, + endCharacter: number +): EditorSelection { + return { + start: { line: startLine, character: startCharacter }, + end: { line: endLine, character: endCharacter }, + direction: SelectionDirection.Forward, + }; +} + function pre(line: number, children: MockElement[] = []): MockElement { const element: MockElement = { nodeType: 1, @@ -203,3 +218,74 @@ describe('convertSelection', () => { }); }); }); + +describe('selectionIntersects', () => { + test('detects overlapping ranges on the same line', () => { + expect( + selectionIntersects( + editorSelection(0, 2, 0, 6), + editorSelection(0, 4, 0, 8) + ) + ).toBe(true); + }); + + test('detects overlapping ranges across lines', () => { + expect( + selectionIntersects( + editorSelection(0, 2, 2, 3), + editorSelection(1, 0, 3, 1) + ) + ).toBe(true); + }); + + test('does not treat adjacent range boundaries as intersections', () => { + expect( + selectionIntersects( + editorSelection(0, 2, 0, 6), + editorSelection(0, 6, 0, 8) + ) + ).toBe(false); + }); + + test('does not intersect separated ranges', () => { + expect( + selectionIntersects( + editorSelection(0, 2, 0, 4), + editorSelection(1, 0, 1, 2) + ) + ).toBe(false); + }); + + test('treats a caret inside a range as an intersection', () => { + expect( + selectionIntersects( + editorSelection(0, 2, 0, 6), + editorSelection(0, 4, 0, 4) + ) + ).toBe(true); + }); + + test('treats a caret on a range boundary as an intersection', () => { + expect( + selectionIntersects( + editorSelection(0, 2, 0, 6), + editorSelection(0, 6, 0, 6) + ) + ).toBe(true); + }); + + test('matches collapsed selections only at the same position', () => { + expect( + selectionIntersects( + editorSelection(0, 2, 0, 2), + editorSelection(0, 2, 0, 2) + ) + ).toBe(true); + expect( + selectionIntersects( + editorSelection(0, 2, 0, 2), + editorSelection(0, 3, 0, 3) + ) + ).toBe(false); + }); +}); diff --git a/packages/diffs/test/editorTextareaState.test.ts b/packages/diffs/test/editorTextareaState.test.ts deleted file mode 100644 index 887980549..000000000 --- a/packages/diffs/test/editorTextareaState.test.ts +++ /dev/null @@ -1,275 +0,0 @@ -import { describe, expect, test } from 'bun:test'; - -import { - type EditorSelection, - SelectionDirection, - toWebSelectionDirection, -} from '../src/editor/editorSelection'; -import { - createTextareaSnippet, - matchesTextareaState, - resolveTextareaTextChange, -} from '../src/editor/editorTextareaState'; -import { TextDocument } from '../src/editor/textDocument'; - -type TextareaSnippetCase = { - name: string; - text: string; - selection: EditorSelection; - expected: { - firstLine: number; - lastLine: number; - text: string; - selectionStart: number; - selectionEnd: number; - }; -}; - -function createSelection( - startLine: number, - startCharacter: number, - endLine: number, - endCharacter: number, - direction: SelectionDirection = SelectionDirection.None -): EditorSelection { - return { - start: { line: startLine, character: startCharacter }, - end: { line: endLine, character: endCharacter }, - direction, - }; -} - -const textareaSnippetCases: TextareaSnippetCase[] = [ - { - name: 'includes only next context on the first line', - text: 'alpha\nbeta', - selection: createSelection(0, 0, 0, 0, SelectionDirection.None), - expected: { - firstLine: 0, - lastLine: 1, - text: 'alpha\nbeta', - selectionStart: 0, - selectionEnd: 0, - }, - }, - { - name: 'includes both surrounding context lines for a middle-line selection', - text: 'alpha\nbravo\ncharlie\ndelta', - selection: createSelection(1, 1, 1, 4, SelectionDirection.Forward), - expected: { - firstLine: 0, - lastLine: 2, - text: 'alpha\nbravo\ncharlie', - selectionStart: 7, - selectionEnd: 10, - }, - }, - { - name: 'clamps trailing context at the last line for multi-line selections', - text: 'alpha\nbravo\ncharlie', - selection: createSelection(1, 2, 2, 7, SelectionDirection.Forward), - expected: { - firstLine: 0, - lastLine: 2, - text: 'alpha\nbravo\ncharlie', - selectionStart: 8, - selectionEnd: 19, - }, - }, - { - name: 'preserves empty selected and context lines', - text: 'top\n\nbottom\n', - selection: createSelection(1, 0, 3, 0, SelectionDirection.Forward), - expected: { - firstLine: 0, - lastLine: 3, - text: 'top\n\nbottom\n', - selectionStart: 4, - selectionEnd: 12, - }, - }, - { - name: 'handles a single empty line selection', - text: 'a\n\nc', - selection: createSelection(1, 0, 1, 0, SelectionDirection.None), - expected: { - firstLine: 0, - lastLine: 2, - text: 'a\n\nc', - selectionStart: 2, - selectionEnd: 2, - }, - }, -]; - -function applyTextareaChange( - text: string, - selection: ReturnType, - value: string, - selectionStart: number, - selectionEnd = selectionStart, - documentValue = text -) { - const textDocument = new TextDocument('inmemory://1', text, 'plain'); - const snippet = createTextareaSnippet(textDocument, selection); - const change = resolveTextareaTextChange({ - documentValue, - originalValue: snippet.text, - value, - originalSelectionStart: snippet.selectionStart, - originalSelectionEnd: snippet.selectionEnd, - selectionStart, - selectionEnd, - }); - const snippetStartOffset = textDocument.offsetAt({ - line: snippet.firstLine, - character: 0, - }); - const start = textDocument.positionAt(snippetStartOffset + change.start); - const end = textDocument.positionAt(snippetStartOffset + change.end); - textDocument.applyEdits([ - { - range: { - start, - end, - }, - newText: change.text, - }, - ]); - return textDocument.getText(); -} - -describe('createTextareaSnippet', () => { - for (const { name, text, selection, expected } of textareaSnippetCases) { - test(name, () => { - const textDocument = new TextDocument('inmemory://1', text, 'plain'); - expect(createTextareaSnippet(textDocument, selection)).toEqual(expected); - }); - } -}); - -describe('resolveTextareaTextChange', () => { - test('inserts a newline before an existing empty line', () => { - const text = 'a\n\nb'; - const selection = createSelection(1, 0, 1, 0, SelectionDirection.None); - - expect(applyTextareaChange(text, selection, 'a\n\n\nb', 3)).toBe( - 'a\n\n\nb' - ); - }); - - test('deletes the nearest newline from consecutive empty lines', () => { - const text = 'a\n\n\nb'; - const selection = createSelection(2, 0, 2, 0, SelectionDirection.None); - - expect(applyTextareaChange(text, selection, '\nb', 0)).toBe('a\n\nb'); - }); - - test('keeps line indentation when inserting a newline', () => { - const text = ' foo'; - const selection = createSelection(0, 5, 0, 5, SelectionDirection.None); - - expect(applyTextareaChange(text, selection, ' foo\n', 6)).toBe( - ' foo\n ' - ); - }); - - test('backspace removes one document indent unit on a spaces-only line', () => { - const text = ' alpha\n \n beta'; - const selection = createSelection(1, 4, 1, 4, SelectionDirection.None); - - expect( - applyTextareaChange(text, selection, ' alpha\n \n beta', 11) - ).toBe(' alpha\n \n beta'); - }); - - test('backspace removes one document indent unit on a tabs-only line', () => { - const text = '\talpha\n\t\t\n\tbeta'; - const selection = createSelection(1, 2, 1, 2, SelectionDirection.None); - - expect(applyTextareaChange(text, selection, '\talpha\n\t\n\tbeta', 8)).toBe( - '\talpha\n\t\n\tbeta' - ); - }); - - test('backspace removes two spaces at a time from six-space line with wider nearby indent', () => { - const text = ' root\n alpha\n \n beta'; - const selection = createSelection(2, 6, 2, 6, SelectionDirection.None); - - expect( - applyTextareaChange( - text, - selection, - ' alpha\n \n beta', - 15, - 15, - ' root\n child\n leaf' - ) - ).toBe(' root\n alpha\n \n beta'); - }); - - test('backspace removes four spaces when document indent is four spaces', () => { - const text = ' alpha\n \n beta'; - const selection = createSelection(1, 8, 1, 8, SelectionDirection.None); - - expect( - applyTextareaChange(text, selection, ' alpha\n \n beta', 17) - ).toBe(' alpha\n \n beta'); - }); -}); - -describe('matchesTextareaState', () => { - test('matches the textarea state produced for a rendered selection', () => { - const textDocument = new TextDocument( - 'inmemory://1', - 'alpha\nbravo\ncharlie', - 'plain' - ); - const selection = createSelection(1, 1, 1, 4, SelectionDirection.Forward); - const snippet = createTextareaSnippet(textDocument, selection); - - expect( - matchesTextareaState( - { - selections: [selection], - primarySelection: selection, - snippet, - value: snippet.text, - }, - { - value: snippet.text, - selectionStart: snippet.selectionStart, - selectionEnd: snippet.selectionEnd, - selectionDirection: toWebSelectionDirection(selection.direction), - } - ) - ).toBe(true); - }); - - test('returns false once the user changes the textarea selection', () => { - const textDocument = new TextDocument( - 'inmemory://1', - 'alpha\nbravo\ncharlie', - 'plain' - ); - const selection = createSelection(1, 1, 1, 4, SelectionDirection.Forward); - const snippet = createTextareaSnippet(textDocument, selection); - - expect( - matchesTextareaState( - { - selections: [selection], - primarySelection: selection, - snippet, - value: snippet.text, - }, - { - value: snippet.text, - selectionStart: snippet.selectionStart + 1, - selectionEnd: snippet.selectionEnd + 1, - selectionDirection: toWebSelectionDirection(selection.direction), - } - ) - ).toBe(false); - }); -}); From 84d5df67b6c0ad7a5036144b8345abd59a2f7cb4 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Sun, 26 Apr 2026 01:03:09 +0800 Subject: [PATCH 017/138] Rename `EditSnippet` type to `TextareaSnapshot` --- packages/diffs/src/components/Editor.ts | 77 ++++++++++--------- ...itSnippet.ts => editorTextareaSnapshot.ts} | 14 ++-- ...test.ts => editorTextareaSnapshot.test.ts} | 8 +- 3 files changed, 53 insertions(+), 46 deletions(-) rename packages/diffs/src/editor/{editSnippet.ts => editorTextareaSnapshot.ts} (88%) rename packages/diffs/test/{editSnippet.test.ts => editorTextareaSnapshot.test.ts} (88%) diff --git a/packages/diffs/src/components/Editor.ts b/packages/diffs/src/components/Editor.ts index 3f2f1223b..d6bce3376 100644 --- a/packages/diffs/src/components/Editor.ts +++ b/packages/diffs/src/components/Editor.ts @@ -29,6 +29,11 @@ import { selectionIntersects, toWebSelectionDirection, } from '../editor/editorSelection'; +import { + createTextareaSnapshot, + resolveTextChange, + type TextareaSnapshot, +} from '../editor/editorTextareaSnapshot'; import { addEventListener, createElement, @@ -37,11 +42,6 @@ import { getRootCssVariableValue, measureMonoFontWidth, } from '../editor/editorUtils'; -import { - createEditSnippet, - type EditSnippet, - resolveTextChange, -} from '../editor/editSnippet'; import { TextDocument, type TextEdit } from '../editor/textDocument'; import { getHighlighterIfLoaded, @@ -85,7 +85,7 @@ export class Editor { // state #isEditorElFocused?: boolean; #isTextareaElFocused?: boolean; - #editSnippet?: EditSnippet; + #textareaSnapshot?: TextareaSnapshot; #typingBuffer?: { text: string; line: number }; #typingBufferFlushTimeout?: ReturnType; #selections?: EditorSelection[]; @@ -128,7 +128,7 @@ export class Editor { setTextDocument(textDocument: TextDocument): void { this.#textDocument = textDocument; - this.#editSnippet = undefined; + this.#textareaSnapshot = undefined; this.#reservedSelections = undefined; this.#selections = undefined; this.#renderText(textDocument); @@ -216,8 +216,8 @@ export class Editor { if (this.#isTextareaElFocused !== true) { const command = resolveEditorCommandFromKeyboardEvent(e); if (command !== undefined) { - this.#flushPendingTextareaChanges(); e.preventDefault(); + this.#flushPendingTextareaChanges(); void this.#runCommand(command); return; } @@ -253,14 +253,14 @@ export class Editor { } }), - // addEventListener(textareaEl, "input", () => { - // if (this.#ignoreSelectionChange) { - // return; - // } - // console.log("\n~~~~~~~~~", Math.round(Date.now() / 1000)); - // console.log("textarea: input"); - // this.#syncTextareaState(); - // }), + addEventListener(textareaEl, 'input', () => { + if (this.#ignoreSelectionChange) { + return; + } + console.log('\n~~~~~~~~~', Math.round(Date.now() / 1000)); + console.log('textarea: input'); + this.#syncTextareaState(); + }), addEventListener(textareaEl, 'selectionchange', () => { if (this.#ignoreSelectionChange) { @@ -301,7 +301,7 @@ export class Editor { this.#selectionEls = undefined; this.#styleEl = undefined; this.#textareaEl = undefined; - this.#editSnippet = undefined; + this.#textareaSnapshot = undefined; this.#textLineEls = undefined; } @@ -511,27 +511,28 @@ export class Editor { console.log('syncTextareaState'); const textDocument = this.#textDocument; const textareaEl = this.#textareaEl; - const editSnippet = this.#editSnippet; + const textareaSnapshot = this.#textareaSnapshot; if ( textDocument === undefined || textareaEl === undefined || - editSnippet === undefined + textareaSnapshot === undefined ) { return; } const { selectionStart, selectionEnd, value } = textareaEl; - if (value !== editSnippet.text) { + if (value !== textareaSnapshot.text) { if ( - value.split('\n').length !== editSnippet.lines || - editSnippet.lines !== 3 + value.split('\n').length !== textareaSnapshot.lines || + textareaSnapshot.lines !== 3 ) { - const change = resolveTextChange(editSnippet, value); + const change = resolveTextChange(textareaSnapshot, value); this.#applyTextChange(change); } else { const line = value.split('\n')[1]; - this.#renderLine(line, editSnippet.offset + selectionStart); - this.#typingBuffer = { text: value, line: editSnippet.startLine }; + this.#renderLine(line, textareaSnapshot.offset + selectionStart); + this.#typingBuffer = { text: value, line: textareaSnapshot.startLine }; this.#typingBufferFlushTimeout = setTimeout(() => { + this.#typingBufferFlushTimeout = undefined; this.#flushPendingTextareaChanges(); }, 500); } @@ -543,21 +544,23 @@ export class Editor { mapSelectionMove( textDocument, this.#selections, - textDocument.positionAt(editSnippet.offset + selectionStart) + textDocument.positionAt(textareaSnapshot.offset + selectionStart) ) ); } } #flushPendingTextareaChanges() { - console.log('flushPendingTextareaChanges'); if (this.#typingBufferFlushTimeout !== undefined) { window.clearTimeout(this.#typingBufferFlushTimeout); this.#typingBufferFlushTimeout = undefined; } - if (this.#editSnippet !== undefined && this.#typingBuffer !== undefined) { + if ( + this.#textareaSnapshot !== undefined && + this.#typingBuffer !== undefined + ) { const change = resolveTextChange( - this.#editSnippet, + this.#textareaSnapshot, this.#typingBuffer.text ); this.#typingBuffer = undefined; @@ -566,6 +569,7 @@ export class Editor { } #applyTextChange(change: EditorTextChange) { + console.log('applyTextChange', change); if (this.#textDocument !== undefined && this.#selections !== undefined) { const { edits, nextSelections: newSelections } = mapSelectionTextChange( this.#textDocument, @@ -612,17 +616,20 @@ export class Editor { if (textDocument === undefined || textareaEl === undefined) { return; } - const editSnippet = createEditSnippet(textDocument, primarySelection); - this.#editSnippet = editSnippet; + const textareaSnapshot = createTextareaSnapshot( + textDocument, + primarySelection + ); + this.#textareaSnapshot = textareaSnapshot; this.#ignoreSelectionChange = true; textareaEl.style.top = this.#getLineY(primarySelection.start.line - 1) + 'px'; textareaEl.style.height = - editSnippet.lines * this.#options.lineHeight + 'px'; - textareaEl.value = editSnippet.text; + textareaSnapshot.lines * this.#options.lineHeight + 'px'; + textareaEl.value = textareaSnapshot.text; textareaEl.setSelectionRange( - editSnippet.selectionStart, - editSnippet.selectionEnd, + textareaSnapshot.selectionStart, + textareaSnapshot.selectionEnd, toWebSelectionDirection(primarySelection.direction) ); setTimeout(() => { diff --git a/packages/diffs/src/editor/editSnippet.ts b/packages/diffs/src/editor/editorTextareaSnapshot.ts similarity index 88% rename from packages/diffs/src/editor/editSnippet.ts rename to packages/diffs/src/editor/editorTextareaSnapshot.ts index d9b6349ed..70695ef52 100644 --- a/packages/diffs/src/editor/editSnippet.ts +++ b/packages/diffs/src/editor/editorTextareaSnapshot.ts @@ -1,7 +1,7 @@ import { type EditorSelection, type EditorTextChange } from './editorSelection'; import type { TextDocument } from './textDocument'; -export interface EditSnippet { +export interface TextareaSnapshot { readonly startLine: number; readonly offset: number; readonly selectionStart: number; @@ -10,10 +10,10 @@ export interface EditSnippet { readonly text: string; } -export function createEditSnippet( +export function createTextareaSnapshot( textDocument: TextDocument, primarySelection: EditorSelection -): EditSnippet { +): TextareaSnapshot { const startLine = Math.max(0, primarySelection.start.line - 1); const endLine = Math.min( textDocument.lineCount - 1, @@ -53,10 +53,10 @@ export function createEditSnippet( } export function resolveTextChange( - editSnippet: EditSnippet, + textareaSnapshot: TextareaSnapshot, newView: string ): EditorTextChange { - const original = editSnippet.text; + const original = textareaSnapshot.text; const originalLength = original.length; const nextLength = newView.length; @@ -82,8 +82,8 @@ export function resolveTextChange( const originalEnd = originalLength - suffix; return { - start: editSnippet.offset + originalStart, - end: editSnippet.offset + originalEnd, + start: textareaSnapshot.offset + originalStart, + end: textareaSnapshot.offset + originalEnd, text: newView.slice(prefix, nextLength - suffix), }; } diff --git a/packages/diffs/test/editSnippet.test.ts b/packages/diffs/test/editorTextareaSnapshot.test.ts similarity index 88% rename from packages/diffs/test/editSnippet.test.ts rename to packages/diffs/test/editorTextareaSnapshot.test.ts index a7ca7a988..47140d26b 100644 --- a/packages/diffs/test/editSnippet.test.ts +++ b/packages/diffs/test/editorTextareaSnapshot.test.ts @@ -5,9 +5,9 @@ import { SelectionDirection, } from '../src/editor/editorSelection'; import { - createEditSnippet, + createTextareaSnapshot, resolveTextChange, -} from '../src/editor/editSnippet'; +} from '../src/editor/editorTextareaSnapshot'; import { TextDocument } from '../src/editor/textDocument'; function createSelection( @@ -27,7 +27,7 @@ function createSelection( describe('resolveTextChange', () => { test('replaces selected text with a shorter typed value', () => { const textDocument = new TextDocument('inmemory://1', 'abc'); - const snippet = createEditSnippet( + const snippet = createTextareaSnapshot( textDocument, createSelection(0, 0, 0, 3, SelectionDirection.Forward) ); @@ -41,7 +41,7 @@ describe('resolveTextChange', () => { test('keeps pure deletion as an empty replacement', () => { const textDocument = new TextDocument('inmemory://1', 'abc'); - const snippet = createEditSnippet( + const snippet = createTextareaSnapshot( textDocument, createSelection(0, 2, 0, 2) ); From 996db61f8a6d1a564db5e6deeb9e89f1ab5d6e9a Mon Sep 17 00:00:00 2001 From: Je Xia Date: Mon, 27 Apr 2026 17:13:42 +0800 Subject: [PATCH 018/138] Remove `Editor` component, introduce the `Editor` class for `File` component --- apps/demo/src/main.ts | 136 ++- packages/diffs/src/components/Editor.ts | 995 ------------------ packages/diffs/src/components/File.ts | 24 + packages/diffs/src/editor/constants.ts | 58 + .../diffs/src/editor/editorMultiSelections.ts | 71 +- packages/diffs/src/editor/editorOptions.ts | 90 -- packages/diffs/src/editor/editorSelection.ts | 143 +-- packages/diffs/src/editor/editorUtils.ts | 107 +- packages/diffs/src/editor/index.ts | 813 ++++++++++++++ packages/diffs/src/editor/textDocument.ts | 8 +- packages/diffs/src/index.ts | 3 +- .../diffs/src/managers/InteractionManager.ts | 2 +- .../diffs/test/editorMultiSelections.test.ts | 80 +- packages/diffs/test/editorSelection.test.ts | 178 ++-- packages/diffs/test/editorUtils.test.ts | 49 - 15 files changed, 1295 insertions(+), 1462 deletions(-) delete mode 100644 packages/diffs/src/components/Editor.ts create mode 100644 packages/diffs/src/editor/constants.ts delete mode 100644 packages/diffs/src/editor/editorOptions.ts create mode 100644 packages/diffs/src/editor/index.ts delete mode 100644 packages/diffs/test/editorUtils.test.ts diff --git a/apps/demo/src/main.ts b/apps/demo/src/main.ts index 21d0a66d7..cbcec86b2 100644 --- a/apps/demo/src/main.ts +++ b/apps/demo/src/main.ts @@ -77,7 +77,6 @@ const diffInstances: ( | VirtualizedFileDiff )[] = []; const fileInstances: File[] = []; -const editorInstances: Editor[] = []; const streamingInstances: FileStream[] = []; const conflictInstances: UnresolvedFile[] = []; @@ -91,7 +90,6 @@ function cleanupInstances(container: HTMLElement) { for (const instances of [ diffInstances, fileInstances, - editorInstances, streamingInstances, conflictInstances, ]) { @@ -629,7 +627,6 @@ function toggleTheme() { for (const instances of [ diffInstances, - editorInstances, fileInstances, streamingInstances, conflictInstances, @@ -804,15 +801,138 @@ if (renderFileButton != null) { const renderEditorButton = document.getElementById('render-editor'); if (renderEditorButton != null) { // oxlint-disable-next-line @typescript-oxlint/no-misused-promises - renderEditorButton.addEventListener('click', () => { + renderEditorButton.addEventListener('click', async () => { + const file = await fileExample; const wrapper = document.getElementById('wrapper'); if (wrapper == null) return; cleanupInstances(wrapper); - const editor = new Editor({ theme: DEMO_THEME, themeType: getThemeType() }); - editor.render({ editorContainer: wrapper }); - editor.setText(tsContent, 'tsx'); - editorInstances.push(editor); + virtualizer?.setup(globalThis.document); + const wrap = getWrapped(); + const fileContainer = document.createElement(DIFFS_TAG_NAME); + wrapper.appendChild(fileContainer); + let instance: + | File + | VirtualizedFile; + const options: FileOptions = { + overflow: wrap ? 'wrap' : 'scroll', + theme: DEMO_THEME, + themeType: getThemeType(), + renderAnnotation, + renderCustomMetadata() { + return createCollapsedToggle( + instance?.options.collapsed ?? false, + (checked) => { + instance?.setOptions({ + ...instance.options, + collapsed: checked, + }); + if (!VIRTUALIZE) { + void instance.rerender(); + } + } + ); + }, + + // Line selection stuff + enableLineSelection: true, + // onLineClick(props) { + // console.log('onLineClick', props); + // }, + // onLineNumberClick(props) { + // console.info('onLineNumberClick', props); + // }, + // onLineSelected(props) { + // console.log('onLineSelected', props); + // }, + // onLineSelectionStart(props) { + // console.log('onLineSelectionStart', props); + // }, + // onLineSelectionChange(props) { + // console.log('onLineSelectionChange', props); + // }, + // onLineSelectionEnd(props) { + // console.log('onLineSelectionEnd', props); + // }, + // Super noisy, but for debuggin + // onLineEnter(props) { + // console.log('onLineEnter', props); + // }, + // onLineLeave(props) { + // console.log('onLineLeave', props); + // }, + + // Hover Decoration Snippets + enableGutterUtility: true, + // onGutterUtilityClick(event) { + // console.log('onGutterUtilityClick', event); + // }, + // renderGutterUtility(getHoveredLine) { + // const el = document.createElement('div'); + // el.style.width = '20px'; + // el.style.height = '20px'; + // el.style.backgroundColor = 'blue'; + // el.style.borderRadius = '2px'; + // el.style.marginRight = '-10px'; + // el.style.textAlign = 'center'; + // el.style.color = 'white'; + // el.innerText = '+'; + // el.addEventListener('click', (event) => { + // event.stopPropagation(); + // console.log('ZZZZ - clicked', getHoveredLine()); + // }); + // el.addEventListener('mousedown', (event) => { + // event.stopPropagation(); + // }); + // return el; + // }, + + // Token Testing Helpers + // onTokenEnter(props) { + // console.log( + // 'enter', + // props.tokenText, + // props.lineNumber, + // props.lineCharStart + // ); + // props.tokenElement.style.backgroundColor = 'light-dark(black, white)'; + // props.tokenElement.style.color = 'light-dark(white, black)'; + // props.tokenElement.style.borderRadius = '2px'; + // }, + // onTokenLeave(props) { + // console.log( + // 'leave', + // props.tokenText, + // props.lineNumber, + // props.lineCharStart + // ); + // props.tokenElement.style.backgroundColor = ''; + // props.tokenElement.style.color = ''; + // props.tokenElement.style.borderRadius = ''; + // }, + }; + + instance = (() => { + if (virtualizer != null) { + return new VirtualizedFile( + options, + virtualizer, + undefined, + poolManager + ); + } else { + return new File(options, poolManager); + } + })(); + instance.render({ + file, + lineAnnotations: FAKE_LINE_ANNOTATIONS, + fileContainer, + }); + fileInstances.push(instance); + + const editor = new Editor(); + editor.edit(instance); }); } diff --git a/packages/diffs/src/components/Editor.ts b/packages/diffs/src/components/Editor.ts deleted file mode 100644 index d6bce3376..000000000 --- a/packages/diffs/src/components/Editor.ts +++ /dev/null @@ -1,995 +0,0 @@ -import { EncodedTokenMetadata, type IGrammar, INITIAL } from 'shiki/textmate'; - -import { DEFAULT_THEMES } from '../constants'; -import { - type EditorCommand, - isPrimaryModifier, - resolveEditorCommandFromKeyboardEvent, -} from '../editor/editorCommand'; -import { - mapSelectionMove, - mapSelectionTextChange, - mapSelectionTextReplace, -} from '../editor/editorMultiSelections'; -import { - type NormalizedEditorOptions, - normlizeEditorOptions, -} from '../editor/editorOptions'; -import type { - EditorSelection, - EditorTextChange, -} from '../editor/editorSelection'; -import { - comparePosition, - convertSelection, - getPrimarySelection, - isCollapsedSelection, - resolveIndentEdits, - SelectionDirection, - selectionIntersects, - toWebSelectionDirection, -} from '../editor/editorSelection'; -import { - createTextareaSnapshot, - resolveTextChange, - type TextareaSnapshot, -} from '../editor/editorTextareaSnapshot'; -import { - addEventListener, - createElement, - extend, - getLineIndentationUnit, - getRootCssVariableValue, - measureMonoFontWidth, -} from '../editor/editorUtils'; -import { TextDocument, type TextEdit } from '../editor/textDocument'; -import { - getHighlighterIfLoaded, - getSharedHighlighter, -} from '../highlighter/shared_highlighter'; -import { areThemesAttached } from '../highlighter/themes/areThemesAttached'; -import type { - BaseCodeOptions, - DiffsHighlighter, - ThemeRegistrationResolved, -} from '../types'; -import { getHighlighterOptions } from '../utils/getHighlighterOptions'; - -export interface EditorOptions extends BaseCodeOptions { - fontFamily?: string; - fontSize?: number; - lineHeight?: number; - paddingY?: number; - tabIndex?: number; - tabSize?: number; - minNumberColumnWidth?: number; -} - -export class Editor { - #options: NormalizedEditorOptions; - #highlighter?: DiffsHighlighter | Promise; - #textDocument?: TextDocument; - - // computed width values - #monoCharWidth: number; - #gutterWidth: number; - - // dom elements - #editorEl?: HTMLElement; - #styleEl?: HTMLStyleElement; - #textareaEl?: HTMLTextAreaElement; - #activeLineEl?: HTMLElement; - #textLineEls?: Map; - #selectionEls?: Map; - - // state - #isEditorElFocused?: boolean; - #isTextareaElFocused?: boolean; - #textareaSnapshot?: TextareaSnapshot; - #typingBuffer?: { text: string; line: number }; - #typingBufferFlushTimeout?: ReturnType; - #selections?: EditorSelection[]; - #reservedSelections?: EditorSelection[]; - #languageLoadRequestId = 0; - #ignoreSelectionChange = false; - - #disposes?: (() => void)[]; - - constructor(options: EditorOptions = {}) { - this.#options = normlizeEditorOptions(options); - this.#monoCharWidth = measureMonoFontWidth( - 'normal ' + this.#options.fontSize + 'px ' + this.#options.fontFamily - ); - this.#gutterWidth = 0; - this.#highlighter = areThemesAttached(options.theme ?? DEFAULT_THEMES) - ? getHighlighterIfLoaded() - : undefined; - } - - get options(): EditorOptions { - return this.#options; - } - - get text(): string | undefined { - return this.#textDocument?.getText(); - } - - get textDocument(): TextDocument | undefined { - return this.#textDocument; - } - - get #hasSelection(): boolean { - return this.#selections !== undefined && this.#selections.length > 0; - } - - setText(text: string, lang = 'plaintext'): void { - this.setTextDocument(new TextDocument('inmemory://1', text, lang)); - } - - setTextDocument(textDocument: TextDocument): void { - this.#textDocument = textDocument; - this.#textareaSnapshot = undefined; - this.#reservedSelections = undefined; - this.#selections = undefined; - this.#renderText(textDocument); - } - - setThemeType(themeType: 'dark' | 'light' | 'system'): void { - this.#options.themeType = themeType; - this.#updateStyle(); - if (this.#textDocument !== undefined) { - this.#renderText(this.#textDocument, this.#selections); - } - } - - render({ editorContainer }: { editorContainer: HTMLElement }): void { - if (this.#editorEl !== undefined) { - this.cleanUp(); - } - const editorEl = createElement('div', { - style: { - position: 'relative', - boxSizing: 'border-box', - paddingTop: `${this.#options.paddingY}px`, - paddingBottom: `${this.#options.paddingY}px`, - fontFamily: this.#options.fontFamily, - fontFeatureSettings: 'var(--diffs-font-features)', - isolation: 'isolate', - }, - }); - const textareaEl = createElement('textarea', { class: 'ť' }, editorEl); - this.#editorEl = extend(editorEl, { tabIndex: this.#options.tabIndex }); - this.#styleEl = createElement('style', undefined, editorEl); - this.#textareaEl = extend(textareaEl, { - autocapitalize: 'off', - autocomplete: 'off', - autocorrect: false, - spellcheck: false, - wrap: 'off', - }); - this.#disposes = [ - addEventListener(document, 'selectionchange', () => { - if (this.#ignoreSelectionChange) { - return; - } - - const selectionRaw = document.getSelection(); - if ( - selectionRaw !== null && - this.#selectionBelongsToEditor(selectionRaw) - ) { - const selection = convertSelection(selectionRaw); - if (selection !== null) { - console.log('\n~~~~~~~~~', Math.round(Date.now() / 1000)); - console.log('document: selectionchange', selection); - const reservedSelections = this.#reservedSelections; - if (reservedSelections === undefined) { - this.#restoreSelections([selection]); - return; - } - this.#restoreSelections([ - ...reservedSelections.filter( - (reservedSelection) => - !selectionIntersects(reservedSelection, selection) - ), - selection, - ]); - } - } - }), - - addEventListener(editorEl, 'mousedown', (e) => { - if (e.button === 0 && isPrimaryModifier(e)) { - this.#reservedSelections = this.#selections?.map((selection) => ({ - ...selection, - })); - } else { - this.#reservedSelections = undefined; - } - }), - - addEventListener(editorEl, 'mouseup', () => { - this.#reservedSelections = undefined; - }), - - addEventListener(editorEl, 'keydown', (e) => { - if (this.#isTextareaElFocused !== true) { - const command = resolveEditorCommandFromKeyboardEvent(e); - if (command !== undefined) { - e.preventDefault(); - this.#flushPendingTextareaChanges(); - void this.#runCommand(command); - return; - } - } - if (this.#isEditorElFocused === true) { - textareaEl.focus(); - } - }), - - addEventListener(editorEl, 'focus', () => { - this.#isEditorElFocused = true; - }), - - addEventListener(editorEl, 'blur', () => { - this.#isEditorElFocused = false; - }), - - addEventListener(textareaEl, 'focus', () => { - this.#isTextareaElFocused = true; - }), - - addEventListener(textareaEl, 'blur', () => { - this.#isTextareaElFocused = false; - this.#flushPendingTextareaChanges(); - }), - - addEventListener(textareaEl, 'keydown', (e) => { - const command = resolveEditorCommandFromKeyboardEvent(e); - if (command !== undefined) { - this.#flushPendingTextareaChanges(); - e.preventDefault(); - void this.#runCommand(command); - } - }), - - addEventListener(textareaEl, 'input', () => { - if (this.#ignoreSelectionChange) { - return; - } - console.log('\n~~~~~~~~~', Math.round(Date.now() / 1000)); - console.log('textarea: input'); - this.#syncTextareaState(); - }), - - addEventListener(textareaEl, 'selectionchange', () => { - if (this.#ignoreSelectionChange) { - return; - } - console.log('\n~~~~~~~~~', Math.round(Date.now() / 1000)); - console.log('textarea: selectionchange'); - this.#syncTextareaState(); - }), - ]; - this.#highlighter ??= getSharedHighlighter( - getHighlighterOptions(undefined, this.#options) - ).then((highlighter) => { - this.#highlighter = highlighter; - this.#updateStyle(); - return highlighter; - }); - this.#updateStyle(); - if (this.#textDocument !== undefined) { - this.#renderText(this.#textDocument, this.#selections); - } - editorContainer.appendChild(editorEl); - } - - public cleanUp(): void { - this.#flushPendingTextareaChanges(); - this.#textLineEls?.clear(); - this.#selectionEls?.clear(); - this.#disposes?.forEach((dispose) => dispose()); - this.#editorEl?.remove(); - this.#activeLineEl = undefined; - this.#disposes = undefined; - this.#editorEl = undefined; - this.#isEditorElFocused = false; - this.#isTextareaElFocused = false; - this.#reservedSelections = undefined; - this.#selections = undefined; - this.#selectionEls = undefined; - this.#styleEl = undefined; - this.#textareaEl = undefined; - this.#textareaSnapshot = undefined; - this.#textLineEls = undefined; - } - - #updateStyle() { - const editorEl = this.#editorEl; - const styleEl = this.#styleEl; - const options = this.#options; - if (editorEl === undefined || styleEl === undefined) { - return; - } - - let themeName: string | undefined; - let theme: ThemeRegistrationResolved | undefined; - let colorMap: string[] | undefined; - if (typeof options.theme === 'string') { - themeName = options.theme; - } else if (typeof options.theme === 'object' && options.theme !== null) { - let themeType = options.themeType ?? 'system'; - if (themeType === 'system') { - themeType = window.matchMedia('(prefers-color-scheme: dark)').matches - ? 'dark' - : 'light'; - } - themeName = options.theme[themeType]; - } - if ( - this.#highlighter !== undefined && - !(this.#highlighter instanceof Promise) - ) { - themeName ??= this.#highlighter.getLoadedThemes()[0]; - ({ theme, colorMap } = this.#highlighter.setTheme(themeName)); - } - - const colors = theme?.colors ?? {}; - const foreground = - theme?.fg ?? - colors['editor.foreground'] ?? - getRootCssVariableValue('--diffs-fg') ?? - ''; - const background = - theme?.bg ?? - colors['editor.background'] ?? - getRootCssVariableValue('--diffs-bg') ?? - ''; - const selectionBackground = - colors['editor.selectionBackground'] ?? 'rgba(128,128,128,0.05)'; - const lineNumberForeground = - colors['editorLineNumber.foreground'] ?? colors.foreground; - const lineHighlightBackground = colors['editor.lineHighlightBackground']; - const { lineHeight, fontSize, tabSize } = this.#options; - - extend(editorEl.style, { - color: foreground, - backgroundColor: background, - }); - styleEl.textContent = - '@scope{' + - '::selection{background-color:transparent}' + - '@keyframes blinking{0%{opacity:0.9}50%{opacity:0}100%{opacity:0.9}}' + - `pre{position:relative;margin:0;font:inherit;font-size:${fontSize}px;line-height:${lineHeight}px;cursor:text;white-space:pre;tab-size:${tabSize}}` + - `.ī{position:absolute;width:2px;height:${lineHeight}px;background-color:${foreground};pointer-events:none;animation:blinking 1.2s infinite;animation-delay:0.6s}` + - `.š{position:absolute;z-index:-10;height:${lineHeight}px;background-color:${selectionBackground};pointer-events:none}` + - (`.ħ{box-sizing:border-box;position:absolute;z-index:-10;width:100%;height:${lineHeight}px;` + - (lineHighlightBackground !== undefined - ? `background-color:${lineHighlightBackground}` - : `border:2px solid ${selectionBackground}`) + - ';pointer-events:none}') + - ('.ť{position:absolute;left:var(--diffs-editor-gutter-width);z-index:-20;width:calc(100% - var(--diffs-editor-gutter-width));padding:0;' + - `line-height:${lineHeight}px;` + - 'font:inherit;background-color:transparent;color:transparent;opacity:0;border:none;outline:none;resize:none}') + - `.ń{display:inline-block;text-align:right;width:var(--diffs-editor-line-number-width);padding:0 ${this.#monoCharWidth}px;color:${lineNumberForeground};user-select:none;pointer-events:none;cursor:default}` + - `.ǎ>.ń,.ǎ>.ď,.ǎ>.đ{color:${foreground}}` + - (colorMap ?? []) - .map((color, i) => `.ċ${i.toString(36)}{color:${color}}`) - .join('') + - '}'; - } - - // update gutter width - #updateGutterWidth(totalLines: number) { - const lineNumberDigits = totalLines.toString().length; - const lineNumberWidth = Math.round( - Math.max(this.#options.minNumberColumnWidth, lineNumberDigits) * - this.#monoCharWidth - ); - const lineNumberPadding = 2 * this.#monoCharWidth; - this.#gutterWidth = lineNumberWidth + lineNumberPadding; - this.#editorEl?.style.setProperty( - '--diffs-editor-line-number-width', - lineNumberWidth + 'px' - ); - this.#editorEl?.style.setProperty( - '--diffs-editor-gutter-width', - this.#gutterWidth + 'px' - ); - } - - #renderText( - textDocument: TextDocument, - selections?: EditorSelection[] - ): void { - const totalLines = textDocument.lineCount; - const languageId = textDocument.languageId; - - this.#updateGutterWidth(totalLines); - - let grammar: IGrammar | undefined; - const highlighter = this.#highlighter; - if (highlighter !== undefined) { - const loadLanguage = async (highlighter: DiffsHighlighter) => { - const requestId = ++this.#languageLoadRequestId; - await highlighter.loadLanguage(languageId); - if ( - requestId === this.#languageLoadRequestId && - this.#textDocument === textDocument - ) { - this.#renderText(textDocument, selections); - } - }; - if (highlighter instanceof Promise) { - void highlighter.then(loadLanguage); - } else if (highlighter.getLoadedLanguages().includes(languageId)) { - grammar = highlighter.getLanguage(languageId); - } else { - void loadLanguage(highlighter); - } - } - - const lineEls = new Map(); - for (let line = 0, ruleStack = INITIAL; line < totalLines; line++) { - const lineText = textDocument.getLineText(line) ?? ''; - const lineLength = lineText.length; - const preEl = createElement('pre', undefined, this.#editorEl); - // oxlint-disable-next-line typescript/no-explicit-any - (preEl as any).LINE = line; - lineEls.set(line, preEl); - - const lineNumberEl = createElement('span', { class: 'ń' }, preEl); - lineNumberEl.textContent = (line + 1).toString(); - - if (grammar === undefined) { - if (lineLength === 0) { - createElement('br', undefined, preEl); - continue; - } - const span = createElement('span', undefined, preEl); - span.textContent = lineText; - // oxlint-disable-next-line typescript/no-explicit-any - (span as any).CHAR = 0; - continue; - } - - const result = grammar.tokenizeLine2(lineText, ruleStack); - if (result.stoppedEarly) { - console.warn( - `Time limit reached when tokenizing line: ${lineText.substring(0, 100)}` - ); - } - - const tokens = result.tokens; - const tokensLength = tokens.length / 2; - for (let j = 0; j < tokensLength; j++) { - const offset = tokens[2 * j]; - const nextOffset = - j + 1 < tokensLength ? tokens[2 * j + 2] : lineLength; - if (offset === nextOffset) { - createElement('br', undefined, preEl); - continue; - } - const metadata = tokens[2 * j + 1]; - const span = createElement( - 'span', - { - class: - 'ċ' + EncodedTokenMetadata.getForeground(metadata).toString(36), - }, - preEl - ); - // oxlint-disable-next-line typescript/no-explicit-any - (span as any).CHAR = offset; - span.textContent = lineText.slice(offset, nextOffset); - } - - ruleStack = result.ruleStack; - } - - // clear previous line elements - this.#textLineEls?.forEach((el) => { - el.remove(); - el.onmouseover = null; - el.onmouseleave = null; - }); - this.#textLineEls?.clear(); - this.#textLineEls = lineEls; - this.#activeLineEl = undefined; - - if (selections !== undefined) { - this.#restoreSelections(selections); - } - } - - #renderLine(line: string, offset: number) { - console.log({ line, offset }); - } - - #syncTextareaState() { - console.log('syncTextareaState'); - const textDocument = this.#textDocument; - const textareaEl = this.#textareaEl; - const textareaSnapshot = this.#textareaSnapshot; - if ( - textDocument === undefined || - textareaEl === undefined || - textareaSnapshot === undefined - ) { - return; - } - const { selectionStart, selectionEnd, value } = textareaEl; - if (value !== textareaSnapshot.text) { - if ( - value.split('\n').length !== textareaSnapshot.lines || - textareaSnapshot.lines !== 3 - ) { - const change = resolveTextChange(textareaSnapshot, value); - this.#applyTextChange(change); - } else { - const line = value.split('\n')[1]; - this.#renderLine(line, textareaSnapshot.offset + selectionStart); - this.#typingBuffer = { text: value, line: textareaSnapshot.startLine }; - this.#typingBufferFlushTimeout = setTimeout(() => { - this.#typingBufferFlushTimeout = undefined; - this.#flushPendingTextareaChanges(); - }, 500); - } - } else if ( - selectionStart === selectionEnd && - this.#selections !== undefined - ) { - this.#restoreSelections( - mapSelectionMove( - textDocument, - this.#selections, - textDocument.positionAt(textareaSnapshot.offset + selectionStart) - ) - ); - } - } - - #flushPendingTextareaChanges() { - if (this.#typingBufferFlushTimeout !== undefined) { - window.clearTimeout(this.#typingBufferFlushTimeout); - this.#typingBufferFlushTimeout = undefined; - } - if ( - this.#textareaSnapshot !== undefined && - this.#typingBuffer !== undefined - ) { - const change = resolveTextChange( - this.#textareaSnapshot, - this.#typingBuffer.text - ); - this.#typingBuffer = undefined; - this.#applyTextChange(change); - } - } - - #applyTextChange(change: EditorTextChange) { - console.log('applyTextChange', change); - if (this.#textDocument !== undefined && this.#selections !== undefined) { - const { edits, nextSelections: newSelections } = mapSelectionTextChange( - this.#textDocument, - this.#selections, - change - ); - this.#textDocument.applyEdits( - edits, - true, - this.#selections, - newSelections - ); - this.#renderText(this.#textDocument, newSelections); - } - } - - #restoreSelections(selections: EditorSelection[]) { - const primarySelection = getPrimarySelection(selections); - if (primarySelection === undefined) { - return; - } - this.#selections = selections; - const selectionEls = new Map(); - this.#setActiveLine(primarySelection); - if (isCollapsedSelection(primarySelection)) { - this.#renderHighlightLine(primarySelection, selectionEls); - } - selections.forEach((selection) => { - if (!isCollapsedSelection(selection)) { - this.#renderSelectionRange(selection, selectionEls); - } - this.#renderCursor(selection, selectionEls); - }); - this.#selectionEls?.forEach((el) => el.remove()); - this.#selectionEls?.clear(); - this.#selectionEls = selectionEls; - this.#updateTextarea(primarySelection); - } - - #updateTextarea(primarySelection: EditorSelection) { - console.log('updateTextarea'); - const textDocument = this.#textDocument; - const textareaEl = this.#textareaEl; - if (textDocument === undefined || textareaEl === undefined) { - return; - } - const textareaSnapshot = createTextareaSnapshot( - textDocument, - primarySelection - ); - this.#textareaSnapshot = textareaSnapshot; - this.#ignoreSelectionChange = true; - textareaEl.style.top = - this.#getLineY(primarySelection.start.line - 1) + 'px'; - textareaEl.style.height = - textareaSnapshot.lines * this.#options.lineHeight + 'px'; - textareaEl.value = textareaSnapshot.text; - textareaEl.setSelectionRange( - textareaSnapshot.selectionStart, - textareaSnapshot.selectionEnd, - toWebSelectionDirection(primarySelection.direction) - ); - setTimeout(() => { - console.log('^'); - this.#ignoreSelectionChange = false; - }, 0); - } - - #renderHighlightLine( - selection: EditorSelection, - selectionEls: Map - ) { - const hlEl = createElement( - 'div', - { - class: 'ħ', - style: { - top: this.#getLineY(selection.start.line) + 'px', - }, - }, - this.#editorEl - ); - hlEl.scrollIntoView({ block: 'nearest' }); - selectionEls.set(`highlightLine-${selection.start.line}`, hlEl); - } - - #renderSelectionRange( - selection: EditorSelection, - selectionEls: Map - ) { - const { start, end } = selection; - for (let ln = start.line; ln <= end.line; ln++) { - const lineText = this.#textDocument!.getLineText(ln) ?? ''; - const lineLength = lineText.length; - const startCharacter = ln === start.line ? start.character : 0; - const endCharacter = ln === end.line ? end.character : lineLength; - const startColumn = this.#getVisualColumn(lineText, startCharacter); - const endColumns = this.#getVisualColumn(lineText, endCharacter); - const startX = this.#getCharacterX(ln, startCharacter, startColumn); - const endX = this.#getCharacterX(ln, endCharacter, endColumns); - const spacing = - endCharacter === startCharacter || ln === end.line ? 0 : 4; - const style = { - top: this.#getLineY(ln) + 'px', - left: startX + 'px', - width: Math.max(endX - startX, 1) + spacing + 'px', - }; - const selectionEl = createElement( - 'div', - { class: 'š', style }, - this.#editorEl - ); - selectionEls.set( - `selection-${ln}-${startCharacter}-${endCharacter}`, - selectionEl - ); - } - } - - #renderCursor( - selection: EditorSelection, - selectionEls: Map - ) { - const { start, end, direction } = selection; - const isBackward = direction === SelectionDirection.Backward; - const lineText = - this.#textDocument?.getLineText(isBackward ? start.line : end.line) ?? ''; - const line = isBackward ? start.line : end.line; - const character = isBackward ? start.character : end.character; - const column = this.#getVisualColumn(lineText, character); - const left = this.#getCharacterX(line, character, column); - const cursorEl = createElement( - 'div', - { - class: 'ī', - style: { - top: this.#getLineY(line) + 'px', - left: left + 'px', - }, - }, - this.#editorEl - ); - selectionEls.set( - 'cursor-' + line + '-' + character + '-' + direction, - cursorEl - ); - } - - #setActiveLine(selection: EditorSelection) { - this.#activeLineEl?.classList.remove('ǎ'); - const activeLine = - selection.direction === SelectionDirection.Backward - ? selection.start.line - : selection.end.line; - const activeLineEl = this.#textLineEls?.get(activeLine); - activeLineEl?.classList.add('ǎ'); - this.#activeLineEl = activeLineEl; - } - - async #runCommand(command: EditorCommand) { - switch (command) { - case 'selectAll': - this.#restoreSelections([this.#getFullSelection()]); - break; - - case 'copy': - case 'cut': - if (this.#hasSelection && this.#textDocument !== undefined) { - try { - // todo: use navigator.clipboard.write() for multiple selections copy - await navigator.clipboard.writeText( - this.#getSelectionText(this.#selections!) - ); - } catch { - return; - } - if (command === 'cut') { - this.#replaceSelectionText(''); - } - } - break; - - case 'paste': { - let text: string | string[]; - try { - // todo: use navigator.clipboard.read() for multiple segments paste - text = await navigator.clipboard.readText(); - } catch { - return; - } - this.#replaceSelectionText(text); - break; - } - - case 'indent': - case 'outdent': - if (this.#hasSelection && this.#textDocument !== undefined) { - const edits: TextEdit[] = []; - const nextSelections: EditorSelection[] = []; - for (const selection of this.#selections!) { - const startLine = selection.start.line; - const lineText = this.#textDocument.getLineText(startLine); - if (lineText !== undefined) { - const outdent = command === 'outdent'; - if (startLine !== selection.end.line || outdent) { - const ret = resolveIndentEdits( - this.#textDocument, - selection, - this.#options.tabSize, - outdent - ); - edits.push(...ret[0]); - nextSelections.push(ret[1]); - } else { - const indentUnit = getLineIndentationUnit( - lineText, - this.#options.tabSize - ); - this.#replaceSelectionText(indentUnit); - } - } - } - if (edits.length > 0) { - this.#textDocument.applyEdits( - edits, - true, - this.#selections, - nextSelections - ); - this.#renderText(this.#textDocument, nextSelections); - } - } - break; - - case 'documentStart': - case 'documentEnd': - this.#restoreSelections([ - this.#getDocumentBoundarySelection(command === 'documentEnd'), - ]); - break; - - case 'undo': - if (this.#textDocument?.canUndo === true) { - this.#renderText(this.#textDocument, this.#textDocument.undo()); - } - break; - - case 'redo': - if (this.#textDocument?.canRedo === true) { - this.#renderText(this.#textDocument, this.#textDocument.redo()); - } - break; - } - } - - // for select all command - #getFullSelection(): EditorSelection { - const textDocument = this.#textDocument; - if (textDocument === undefined) { - throw new Error('Editor has no text document'); - } - const lastLine = textDocument.lineCount - 1; - const lastCharacter = textDocument.getLineText(lastLine)?.length ?? 0; - return { - start: { line: 0, character: 0 }, - end: { line: lastLine, character: lastCharacter }, - direction: SelectionDirection.Forward, - }; - } - - // for documentStart/documentEnd commands - #getDocumentBoundarySelection(atEnd: boolean): EditorSelection { - const textDocument = this.#textDocument; - if (textDocument === undefined) { - throw new Error('Editor has no text document'); - } - const line = atEnd ? textDocument.lineCount - 1 : 0; - const character = atEnd ? (textDocument.getLineText(line)?.length ?? 0) : 0; - const start = { line, character }; - return { - start: start, - end: start, - direction: SelectionDirection.Forward, - }; - } - - #getSelectionText(selections: readonly EditorSelection[]): string { - if (this.#textDocument === undefined) { - return ''; - } - return [...selections] - .sort((a, b) => { - const startOrder = comparePosition(a.start, b.start); - if (startOrder !== 0) { - return startOrder; - } - return comparePosition(a.end, b.end); - }) - .map((selection) => this.#textDocument!.getText(selection)) - .join(this.#textDocument.EOF); - } - - // replace the selection text - #replaceSelectionText(text: string | string[]) { - const selections = this.#selections; - if (selections === undefined) { - return; - } - const textDocument = this.#textDocument; - const selection = getPrimarySelection(selections); - if (textDocument == null || selection == null) { - return; - } - const normalizedText = Array.isArray(text) - ? text.map((value) => value.replace(/\r\n?|\n/g, textDocument.EOF)) - : text.replace(/\r\n?|\n/g, textDocument.EOF); - const { edits, nextSelections } = Array.isArray(normalizedText) - ? mapSelectionTextReplace(textDocument, selections, normalizedText) - : mapSelectionTextChange(textDocument, selections, { - start: textDocument.offsetAt(selection.start), - end: textDocument.offsetAt(selection.end), - text: normalizedText, - }); - textDocument.applyEdits(edits, true, selections); - this.#renderText(textDocument, nextSelections); - } - - // get line Y position - #getLineY(line: number) { - return line * this.#options.lineHeight + this.#options.paddingY; - } - - // get character X position - // todo: support emoji/non-ascii chars - #getCharacterX(line: number, character: number, visualColumn: number) { - const fallbackLeft = this.#gutterWidth + visualColumn * this.#monoCharWidth; - const lineEl = this.#textLineEls?.get(line); - const editorEl = this.#editorEl; - if (lineEl === undefined || editorEl === undefined) { - return fallbackLeft; - } - - let targetSpan: HTMLElement | undefined; - let targetOffset = 0; - let lastSpan: HTMLElement | undefined; - let lastEnd = 0; - const children = lineEl.children; - for (let i = 0; i < children.length; i++) { - const child = children[i]; - if (!(child instanceof HTMLElement) || child.tagName !== 'SPAN') { - continue; - } - // oxlint-disable-next-line typescript/no-explicit-any - const start = (child as any).CHAR as number | undefined; - if (start === undefined) { - continue; - } - const textLength = child.textContent?.length ?? 0; - const end = start + textLength; - if (character >= start && character <= end) { - targetSpan = child; - targetOffset = character - start; - break; - } - if (end >= lastEnd) { - lastSpan = child; - lastEnd = end; - } - } - - const range = document.createRange(); - if (targetSpan !== undefined) { - const textNode = targetSpan.firstChild; - if (textNode === null) { - return fallbackLeft; - } - const nodeLength = textNode.textContent?.length ?? 0; - const boundedOffset = Math.max(0, Math.min(targetOffset, nodeLength)); - range.setStart(textNode, boundedOffset); - range.setEnd(textNode, boundedOffset); - } else if (lastSpan !== undefined) { - const textNode = lastSpan.firstChild; - if (textNode === null) { - return fallbackLeft; - } - const nodeLength = textNode.textContent?.length ?? 0; - range.setStart(textNode, nodeLength); - range.setEnd(textNode, nodeLength); - } else { - return fallbackLeft; - } - - const editorRect = editorEl.getBoundingClientRect(); - const pointRect = range.getBoundingClientRect(); - return pointRect.left - editorRect.left; - } - - #getVisualColumn(text: string, character: number): number { - const tabSize = this.#options.tabSize; - let column = 0; - for (let i = 0; i < Math.min(character, text.length); i++) { - if (text.charCodeAt(i) === /* \t */ 9) { - const remainder = column % tabSize; - column += remainder === 0 ? tabSize : tabSize - remainder; - continue; - } - column++; - } - return column; - } - - // check if the web selection belongs to editor - #selectionBelongsToEditor(selection: Selection) { - const editorEl = this.#editorEl; - return ( - editorEl !== undefined && - editorEl.contains(selection.anchorNode) === true && - editorEl !== selection.anchorNode && - editorEl.contains(selection.focusNode) === true && - editorEl !== selection.focusNode - ); - } -} diff --git a/packages/diffs/src/components/File.ts b/packages/diffs/src/components/File.ts index 25b372c23..8597e6115 100644 --- a/packages/diffs/src/components/File.ts +++ b/packages/diffs/src/components/File.ts @@ -172,6 +172,28 @@ export class File { this.workerManager?.subscribeToThemeChanges(this); } + private __onEditableHandler: + | ((file: FileContents, fileContainer: HTMLElement) => void) + | undefined; + public __onEditable( + callback: (fileContents: FileContents, fileContainer: HTMLElement) => void + ): void { + if (this.fileContainer !== undefined && this.file !== undefined) { + callback(this.file, this.fileContainer); + } + this.__onEditableHandler = callback; + } + public __rerender(file: FileContents): void { + this.file = file; + const fileResult = this.fileRenderer.renderFile(file, this.renderRange); + if (fileResult == null || this.pre == null) { + return; + } + console.log('__rerender', fileResult); + this.applyFullRender(fileResult, this.pre); + this.__onEditableHandler?.(file, this.fileContainer!); + } + private handleHighlightRender = (): void => { this.rerender(); }; @@ -463,6 +485,7 @@ export class File { if (!preventEmit) { this.emitPostRender(); } + this.__onEditableHandler?.(file, fileContainer); return true; } @@ -513,6 +536,7 @@ export class File { if (!preventEmit) { this.emitPostRender(); } + this.__onEditableHandler?.(file, fileContainer); return true; } diff --git a/packages/diffs/src/editor/constants.ts b/packages/diffs/src/editor/constants.ts new file mode 100644 index 000000000..5daebdffc --- /dev/null +++ b/packages/diffs/src/editor/constants.ts @@ -0,0 +1,58 @@ +export const EDITOR_CSS = /* CSS */ ` + ::selection { + background-color: transparent; + } + @keyframes blinking { + 0% { opacity: 0.9; } + 50% { opacity: 0; } + 100% { opacity: 0.9; } + } + [data-line] { + background-color: transparent; + } + [data-line-annotation] { + user-select: none; + } + [data-content] { + position: relative; + } + [data-textarea], [data-caret], [data-line-highlight], [data-selection-range] { + position: absolute; + left: 0; + z-index: -10; + height: 1lh; + line-height: var(--diffs-line-height); + pointer-events: none; + } + [data-textarea] { + font: inherit; + padding: 0; + padding-inline: 1ch; + transform: translateY(-1lh); + border: none; + outline: none; + resize: none; + field-sizing: content; + } + [data-overflow='scroll'] [data-textarea] { + white-space: pre; + min-height: 1lh; + } + [data-overflow='wrap'] [data-textarea] { + white-space: pre-wrap; + word-break: break-word; + } + [data-caret] { + width: 2px; + background-color: var(--fg); + animation: blinking 1.2s infinite; + animation-delay: 0.6s; + } + [data-line-highlight] { + width: 100%; + background-color: var(--diffs-bg-selection); + } + [data-selection-range] { + background-color: var(--diffs-bg-selection); + } +`; diff --git a/packages/diffs/src/editor/editorMultiSelections.ts b/packages/diffs/src/editor/editorMultiSelections.ts index bddb9085b..63e068865 100644 --- a/packages/diffs/src/editor/editorMultiSelections.ts +++ b/packages/diffs/src/editor/editorMultiSelections.ts @@ -1,4 +1,3 @@ -import { applyOffsetEdits } from './editHistory'; import { type EditorSelection, type EditorTextChange, @@ -6,11 +5,6 @@ import { } from './editorSelection'; import { type Position, TextDocument, type TextEdit } from './textDocument'; -type SelectionEditMapping = { - edits: TextEdit[]; - nextSelections: EditorSelection[]; -}; - export function mapSelectionMove( textDocument: TextDocument, selections: readonly EditorSelection[], @@ -52,14 +46,14 @@ export function mapSelectionMove( }); } -export function mapSelectionTextChange( +export function applySelectionTextChange( textDocument: TextDocument, - selections: readonly EditorSelection[], + selections: EditorSelection[], change: EditorTextChange -): SelectionEditMapping { +): EditorSelection[] { const primarySelection = selections[selections.length - 1]; if (primarySelection === undefined) { - return { edits: [], nextSelections: [] }; + return []; } const primaryStartOffset = textDocument.offsetAt(primarySelection.start); const primaryEndOffset = textDocument.offsetAt(primarySelection.end); @@ -133,31 +127,19 @@ export function mapSelectionTextChange( }; } finalizeMergedGroup(); - const nextDocument = new TextDocument( - textDocument.uri, - applyOffsetEdits( - textDocument.getText(), - edits.map((edit) => ({ - start: textDocument.offsetAt(edit.range.start), - end: textDocument.offsetAt(edit.range.end), - text: edit.newText, - })) - ), - textDocument.languageId + textDocument.applyEdits(edits, true, selections); + const nextSelections = nextSelectionOffsets.map((offsets) => + createSelectionFromAnchorAndFocusOffsets(textDocument, ...offsets) ); - return { - edits, - nextSelections: nextSelectionOffsets.map((offsets) => - createSelectionFromAnchorAndFocusOffsets(nextDocument, ...offsets) - ), - }; + textDocument.setLastUndoSelectionsAfter(nextSelections); + return nextSelections; } -export function mapSelectionTextReplace( +export function applySelectionTextReplace( textDocument: TextDocument, - selections: readonly EditorSelection[], + selections: EditorSelection[], texts: readonly string[] -): SelectionEditMapping { +): EditorSelection[] { if (selections.length !== texts.length) { throw new Error( 'Selection text replacements must match the selection count' @@ -203,31 +185,12 @@ export function mapSelectionTextReplace( entry.start + offsetDelta + entry.text.length; offsetDelta += entry.text.length - (entry.end - entry.start); } - const nextDocument = createTextDocumentAfterEdits(textDocument, edits); - return { - edits, - nextSelections: nextSelectionOffsets.map((offset) => - createSelectionFromAnchorAndFocusOffsets(nextDocument, offset, offset) - ), - }; -} - -function createTextDocumentAfterEdits( - textDocument: TextDocument, - edits: readonly TextEdit[] -) { - return new TextDocument( - textDocument.uri, - applyOffsetEdits( - textDocument.getText(), - edits.map((edit) => ({ - start: textDocument.offsetAt(edit.range.start), - end: textDocument.offsetAt(edit.range.end), - text: edit.newText, - })) - ), - textDocument.languageId + textDocument.applyEdits(edits, true, selections); + const nextSelections = nextSelectionOffsets.map((offset) => + createSelectionFromAnchorAndFocusOffsets(textDocument, offset, offset) ); + textDocument.setLastUndoSelectionsAfter(nextSelections); + return nextSelections; } function createSelectionFromAnchorAndFocusOffsets( diff --git a/packages/diffs/src/editor/editorOptions.ts b/packages/diffs/src/editor/editorOptions.ts deleted file mode 100644 index a8654b245..000000000 --- a/packages/diffs/src/editor/editorOptions.ts +++ /dev/null @@ -1,90 +0,0 @@ -import type { EditorOptions } from '../components/Editor'; -import { getRootCssVariableValue, parseCssValue } from './editorUtils'; - -const DEFAULT_FONT_FAMILY = - "'SF Mono', Monaco, Consolas, 'Ubuntu Mono', 'Liberation Mono', 'Courier New', monospace"; -const DEFAULT_FONT_SIZE = 14; -const DEFAULT_LINE_HEIGHT = 20; -const DEFAULT_PADDING_Y = 10; -const DEFAULT_MIN_NUMBER_COLUMN_WIDTH = 3; - -export interface NormalizedEditorOptions extends EditorOptions { - fontFamily: string; - fontSize: number; - lineHeight: number; - paddingY: number; - tabSize: number; - minNumberColumnWidth: number; -} - -export function normlizeEditorOptions( - options: EditorOptions = {} -): NormalizedEditorOptions { - const fontFamily = - options.fontFamily ?? - getRootCssVariableValue('--diffs-font-family') ?? - getRootCssVariableValue('--diffs-font-fallback') ?? - DEFAULT_FONT_FAMILY; - const fontSize = Math.max( - 10, - options.fontSize ?? - getCssVariableAsNumber('--diffs-font-size') ?? - DEFAULT_FONT_SIZE - ); - const lineHeight = Math.max( - 12, - options.lineHeight ?? - getLineHeightFromCssVariable('--diffs-line-height', fontSize) ?? - DEFAULT_LINE_HEIGHT - ); - const paddingY = Math.max(0, options.paddingY ?? DEFAULT_PADDING_Y); - const tabSize = Math.max( - 1, - Math.floor( - options.tabSize ?? getCssVariableAsNumber('--diffs-tab-size') ?? 2 - ) - ); - const minNumberColumnWidth = Math.max( - 1, - getCssVariableAsNumber('--diffs-min-number-column-width') ?? - options.minNumberColumnWidth ?? - DEFAULT_MIN_NUMBER_COLUMN_WIDTH - ); - - return { - ...options, - fontFamily, - fontSize, - lineHeight, - paddingY, - tabSize, - minNumberColumnWidth, - }; -} - -function getCssVariableAsNumber(variableName: string): number | undefined { - const cssPropertyValue = getRootCssVariableValue(variableName); - if (cssPropertyValue === '' || cssPropertyValue === undefined) { - return undefined; - } - return parseCssValue(cssPropertyValue)[0]; -} - -function getLineHeightFromCssVariable( - variableName: string, - fontSize: number -): number | undefined { - const cssPropertyValue = getRootCssVariableValue(variableName); - if (cssPropertyValue === '' || cssPropertyValue === undefined) { - return undefined; - } - const [value, unit] = parseCssValue(cssPropertyValue); - if (unit === 'px') { - return value; - } - if (unit === '' || unit === 'em') { - return value * fontSize; - } - // unsupported units - return undefined; -} diff --git a/packages/diffs/src/editor/editorSelection.ts b/packages/diffs/src/editor/editorSelection.ts index aa418d5b6..601eb734c 100644 --- a/packages/diffs/src/editor/editorSelection.ts +++ b/packages/diffs/src/editor/editorSelection.ts @@ -19,33 +19,20 @@ export type EditorTextChange = { /** * Converts a selection from a web selection to an editor selection. - * @param selection - The web selection to convert. - * @returns The converted editor selection. */ -export function convertSelection({ - rangeCount, - anchorNode, - focusNode, - anchorOffset, - focusOffset, -}: Selection): EditorSelection | null { - if (rangeCount === 0 || anchorNode === null || focusNode === null) { +export function convertSelection( + composedRanges: StaticRange[], + direction: SelectionDirection = SelectionDirection.None +): EditorSelection | null { + const range = composedRanges[composedRanges.length - 1]; + if (range === undefined) { return null; } - const anchor = boundaryToPosition(anchorNode, anchorOffset); - const focus = boundaryToPosition(focusNode, focusOffset); - if (anchor === null || focus === null) { + const start = boundaryToPosition(range.startContainer, range.startOffset); + const end = boundaryToPosition(range.endContainer, range.endOffset); + if (start === null || end === null) { return null; } - const order = comparePosition(anchor, focus); - const direction = - order === 0 - ? SelectionDirection.None - : order < 0 - ? SelectionDirection.Forward - : SelectionDirection.Backward; - const start = direction === SelectionDirection.Forward ? anchor : focus; - const end = direction === SelectionDirection.Forward ? focus : anchor; return { start, end, @@ -63,12 +50,12 @@ export function resolveIndentEdits( return [[], selection]; } const { start, end } = selection; + const edits: TextEdit[] = []; + let newSelection: EditorSelection = { ...selection }; let endLine = end.line; if (start.line < end.line && end.character === 0) { endLine--; } - const edits: TextEdit[] = []; - const newSelection: EditorSelection = { ...selection }; for (let line = start.line; line <= endLine; line++) { const lineText = textDocument.getLineText(line); if (lineText === undefined) { @@ -99,15 +86,21 @@ export function resolveIndentEdits( }); const delte = newText.length - deleteLength; if (line === start.line) { - newSelection.start = { - ...start, - character: Math.max(0, start.character + delte), + newSelection = { + ...newSelection, + start: { + ...start, + character: Math.max(0, start.character + delte), + }, }; } if (line === end.line) { - newSelection.end = { - ...end, - character: Math.max(0, end.character + delte), + newSelection = { + ...newSelection, + end: { + ...end, + character: Math.max(0, end.character + delte), + }, }; } } @@ -147,6 +140,7 @@ export function selectionIntersects( ); } +/** Get the primary(last) selection from the list of selections */ export function getPrimarySelection( selections: readonly EditorSelection[] ): EditorSelection | undefined { @@ -154,26 +148,6 @@ export function getPrimarySelection( return selection !== undefined ? { ...selection } : undefined; } -export function toWebSelectionDirection( - direction: SelectionDirection -): 'none' | 'forward' | 'backward' { - return direction === SelectionDirection.None - ? 'none' - : direction === SelectionDirection.Forward - ? 'forward' - : 'backward'; -} - -export function fromWebSelectionDirection( - direction: 'none' | 'forward' | 'backward' -): SelectionDirection { - return direction === 'none' - ? SelectionDirection.None - : direction === 'forward' - ? SelectionDirection.Forward - : SelectionDirection.Backward; -} - export function comparePosition(a: Position, b: Position): number { if (a.line !== b.line) { return a.line - b.line; @@ -187,13 +161,24 @@ function boundaryToPosition(node: Node, offset: number): Position | null { if (parent === null) { return null; } + if (parent.tagName === 'DIV') { + const childIndex = Array.prototype.indexOf.call(parent.childNodes, node); + const position = getPositionWithinPre(parent, childIndex); + return position === null + ? null + : { + ...position, + character: + position.character + getTextOffset(node.textContent, offset), + }; + } if (parent.tagName === 'SPAN') { const pre = parent.parentElement; - if (pre === null || pre.tagName !== 'PRE') { + if (pre === null || pre.tagName !== 'DIV') { return null; } - const line = getLineProp(pre); - const base = getCharacterProp(parent); + const line = getLineIndex(pre); + const base = getCharacterIndex(parent); if (line !== undefined && base !== undefined) { return { line, character: base + offset }; } @@ -206,26 +191,26 @@ function boundaryToPosition(node: Node, offset: number): Position | null { } if (node.nodeType === 1) { const el = node as HTMLElement; - if (el.tagName === 'PRE') { + if (el.tagName === 'DIV') { return getPositionWithinPre(el, offset); } if (el.tagName === 'BR') { const pre = el.parentElement; - if (pre === null || pre.tagName !== 'PRE') { + if (pre === null || pre.tagName !== 'DIV') { return null; } - const line = getLineProp(pre); + const line = getLineIndex(pre); if (line !== undefined) { return { line, character: 0 }; } } if (el.tagName === 'SPAN') { const pre = el.parentElement; - if (pre === null || pre.tagName !== 'PRE') { + if (pre === null || pre.tagName !== 'DIV') { return null; } - const line = getLineProp(pre); - const base = getCharacterProp(el); + const line = getLineIndex(pre); + const base = getCharacterIndex(el); if (line !== undefined && base !== undefined) { let character = base; for (let i = 0; i < offset; i++) { @@ -246,16 +231,20 @@ function getPositionWithinPre( pre: HTMLElement, offset: number ): Position | null { - const line = getLineProp(pre); + const line = getLineIndex(pre); if (line === undefined) { return null; } let character = 0; for (let i = 0; i < offset; i++) { - const c = pre.children[i]; - if (c?.tagName === 'SPAN') { + const c = pre.childNodes[i]; + if (c?.nodeType === 3) { + character += getTextOffset(c.textContent, c.textContent?.length ?? 0); + continue; + } + if (c?.nodeType === 1 && (c as HTMLElement).tagName === 'SPAN') { const span = c as HTMLElement; - const o = getCharacterProp(span); + const o = getCharacterIndex(span); if (o === undefined) { continue; } @@ -272,11 +261,11 @@ function getDirectPreChild( let current = node.nodeType === 1 ? (node as HTMLElement) : node.parentElement; while (current !== null && current.parentElement !== null) { - if (current.parentElement.tagName === 'PRE') { + if (current.parentElement.tagName === 'DIV') { return { pre: current.parentElement, childIndex: Array.prototype.indexOf.call( - current.parentElement.children, + current.parentElement.childNodes, current ), }; @@ -286,12 +275,24 @@ function getDirectPreChild( return null; } -function getLineProp(el: HTMLElement): number | undefined { - // oxlint-disable-next-line typescript/no-explicit-any - return (el as any).LINE as number | undefined; +function getLineIndex(el: HTMLElement): number | undefined { + const { lineIndex } = el.dataset; + return lineIndex !== undefined ? parseInt(lineIndex) : undefined; +} + +function getCharacterIndex(el: HTMLElement): number | undefined { + const { char } = el.dataset; + return char !== undefined ? parseInt(char) : undefined; } -function getCharacterProp(el: HTMLElement): number | undefined { - // oxlint-disable-next-line typescript/no-explicit-any - return (el as any).CHAR as number | undefined; +function getTextOffset( + text: string | null | undefined, + offset: number +): number { + const value = text ?? ''; + const lineBreakIndex = value.search(/[\r\n]/); + return Math.min( + offset, + lineBreakIndex === -1 ? value.length : lineBreakIndex + ); } diff --git a/packages/diffs/src/editor/editorUtils.ts b/packages/diffs/src/editor/editorUtils.ts index e723801c6..cef5f08df 100644 --- a/packages/diffs/src/editor/editorUtils.ts +++ b/packages/diffs/src/editor/editorUtils.ts @@ -1,21 +1,38 @@ export function createElement( tagName: K, - props?: { + props: { id?: string; class?: string; - style?: Record; - }, - parent?: Element + style?: Partial; + dataset?: DOMStringMap | string[] | string; + textContent?: string; + } = {}, + parent?: Element | ShadowRoot ): HTMLElementTagNameMap[K] { const el = document.createElement(tagName); - if (props?.class) { - el.className = props.class; + const { id, class: className, style, dataset, textContent } = props; + if (id) { + el.id = id; } - if (props?.style !== undefined) { - Object.assign(el.style, props.style); + if (className !== undefined) { + el.className = className; } - if (props?.id) { - el.id = props.id; + if (style !== undefined) { + Object.assign(el.style, style); + } + if (dataset !== undefined) { + if (typeof dataset === 'string') { + el.dataset[dataset] = ''; + } else if (Array.isArray(dataset)) { + dataset.forEach((key) => { + el.dataset[key] = ''; + }); + } else { + Object.assign(el.dataset, dataset); + } + } + if (textContent !== undefined) { + el.textContent = textContent; } if (parent !== undefined) { parent.appendChild(el); @@ -39,7 +56,7 @@ export function addEventListener( listener: (this: Window, evt: WindowEventMap[K]) => void ): () => void; export function addEventListener( - el: HTMLElement | Document | Window, + el: HTMLElement | Document | ShadowRoot | Window, event: string, listener: EventListener ) { @@ -49,65 +66,14 @@ export function addEventListener( }; } -export function getRootCssVariableValue( - variableName: string -): string | undefined { - const value = getComputedStyle(document.documentElement) - .getPropertyValue(variableName) - .trim(); - return value !== '' ? value : undefined; -} - -export function parseCssValue(value: string): [value: number, unit: string] { - const parsedValue = Number.parseFloat(value); - if (!Number.isFinite(parsedValue)) { - return [0, '']; - } - let unitStartIndex = -1; - for (let i = 0; i < value.length; i++) { - const code = value.charCodeAt(i); - if ( - code !== /*.*/ 46 && - (code < /*0*/ 48 || code > /*9*/ 57) && - i !== 0 && - i !== value.length - 1 - ) { - unitStartIndex = i; - break; - } - } - return [parsedValue, unitStartIndex > 0 ? value.slice(unitStartIndex) : '']; -} - -export function coalesceMicrotask(run: () => void): () => void { - let queued = false; - return () => { - if (queued) { - return; - } - queued = true; - queueMicrotask(() => { - queued = false; - run(); - }); - }; -} - -export function measureMonoFontWidth(font: string): number { - const canvas = createElement('canvas'); - const context = canvas.getContext('2d'); - if (context === null) { - throw new Error('measureMonoFontWidth: Failed to get canvas context'); +export function isCodeLineTarget(target?: EventTarget): target is HTMLElement { + if (target === undefined || !(target instanceof HTMLElement)) { + return false; } - context.font = font; - const width = context.measureText('0').width; - for (let i = 1; i < 16; i++) { - const w = context.measureText(i.toString(16)).width; - if (w !== width) { - throw new Error(`The font "${font}" isn't a monospace font`); - } - } - return width; + return ( + (target.tagName === 'DIV' && target.dataset.line !== undefined) || + (target.tagName === 'SPAN' && target.dataset.char !== undefined) + ); } export function getLineIndentation(lineText: string): string { @@ -130,9 +96,6 @@ export function getLineIndentationUnit( if (lineText.startsWith('\t')) { return '\t'; } - if (lineText.startsWith(' ')) { - return ' '.repeat(Math.max(1, Math.min(tabSize, lineText.length))); - } return ' '.repeat(tabSize); } diff --git a/packages/diffs/src/editor/index.ts b/packages/diffs/src/editor/index.ts new file mode 100644 index 000000000..ad197261f --- /dev/null +++ b/packages/diffs/src/editor/index.ts @@ -0,0 +1,813 @@ +import type { File } from '../components/File'; +import { + type EditorCommand, + isPrimaryModifier, + resolveEditorCommandFromKeyboardEvent, +} from '../editor/editorCommand'; +import { + applySelectionTextChange, + applySelectionTextReplace, + mapSelectionMove, +} from '../editor/editorMultiSelections'; +import type { + EditorSelection, + EditorTextChange, +} from '../editor/editorSelection'; +import { + comparePosition, + convertSelection, + getPrimarySelection, + isCollapsedSelection, + resolveIndentEdits, + SelectionDirection, + selectionIntersects, +} from '../editor/editorSelection'; +import { + createTextareaSnapshot, + resolveTextChange, + type TextareaSnapshot, +} from '../editor/editorTextareaSnapshot'; +import { + addEventListener, + createElement, + extend, + getLineIndentationUnit, + isCodeLineTarget, +} from '../editor/editorUtils'; +import { TextDocument, type TextEdit } from '../editor/textDocument'; +import type { FileContents } from '../types'; +import { EDITOR_CSS } from './constants'; + +export class Editor { + #file?: File; + #fileContents?: FileContents; + #textDocument?: TextDocument; + #onChange?: (file: FileContents) => void; + + // dom elements + #contentEl?: HTMLElement; + #styleEl?: HTMLStyleElement; + #textareaEl?: HTMLTextAreaElement; + #selectionEls?: Map; + + // state + #selectionLineHeight = 20; + #selectionStartX = 0; + #selectionStartY = 0; + #selectionEndX = 0; + #selectionEndY = 0; + #textareSelectionStart = 0; + #shouldIgnoreSelectionChange = false; + #textareaBuffer?: { text: string; line: number }; + #textareaBufferFlushTimeout?: ReturnType; + #textareaSnapshot?: TextareaSnapshot; + #selections?: EditorSelection[]; + #reservedSelections?: EditorSelection[]; + + #disposes?: (() => void)[]; + + get text(): string | undefined { + return this.#textDocument?.getText(); + } + + edit(file: File, onChange?: (file: FileContents) => void): () => void { + file.__onEditable((fileContents, fileContainer) => { + this.#onEditable(fileContents, fileContainer); + }); + this.#file = file; + this.#onChange = onChange; + return this.cleanUp.bind(this); + } + + cleanUp(): void { + this.#disposes?.forEach((dispose) => dispose()); + this.#disposes = undefined; + this.#textDocument = undefined; + + this.#contentEl = undefined; + this.#styleEl?.remove(); + this.#styleEl = undefined; + this.#textareaEl?.remove(); + this.#textareaEl = undefined; + this.#selectionEls?.forEach((el) => el.remove()); + this.#selectionEls?.clear(); + this.#selectionEls = undefined; + + this.#shouldIgnoreSelectionChange = false; + this.#textareaBuffer = undefined; + this.#textareaBufferFlushTimeout = undefined; + this.#textareaSnapshot = undefined; + this.#selections = undefined; + this.#reservedSelections = undefined; + } + + #onEditable(fileContents: FileContents, fileContainer: HTMLElement): void { + this.#fileContents ??= fileContents; + this.#textDocument ??= new TextDocument( + fileContents.name, + fileContents.contents, + fileContents.lang + ); + + const shadowRoot = + fileContainer.shadowRoot ?? fileContainer.attachShadow({ mode: 'open' }); + this.#contentEl = shadowRoot.querySelector('[data-content]') ?? undefined; + if (this.#contentEl === undefined) { + throw new Error('could not edit the file.'); + } + this.#textareaEl ??= extend( + createElement('textarea', { dataset: 'textarea' }), + { + autocapitalize: 'off', + autocomplete: 'off', + autocorrect: false, + spellcheck: false, + wrap: 'off', + } + ); + this.#contentEl.appendChild(this.#textareaEl); + this.#styleEl ??= createElement( + 'style', + { dataset: 'editorCss', textContent: EDITOR_CSS }, + shadowRoot + ); + this.#disposes ??= [ + addEventListener(document, 'selectionchange', () => { + if (this.#shouldIgnoreSelectionChange) { + return; + } + + // if caret position changes in textarea, sync the textarea state. + if ( + this.#textareaEl !== undefined && + this.#textareaSnapshot !== undefined + ) { + const { selectionStart } = this.#textareaEl; + console.log(selectionStart, this.#textareSelectionStart); + if ( + this.#textareSelectionStart !== selectionStart && + this.#textareaSnapshot.text === this.#textareaEl.value + ) { + console.log('\n~~~~~~~~~', Math.round(Date.now() / 1000)); + console.log('textarea: selectionchange'); + this.#textareSelectionStart = selectionStart; + this.#syncTextareaState(); + return; + } + } + + const selectionRaw = document.getSelection(); + const composedRanges = selectionRaw?.getComposedRanges({ + shadowRoots: [shadowRoot], + }); + + if ( + composedRanges === undefined || + !this.#selectionBelongsToEditor(composedRanges) + ) { + return; + } + const selection = convertSelection( + composedRanges, + this.#computeMouseSelectionDirection() + ); + if (selection !== null) { + console.log('\n~~~~~~~~~', Math.round(Date.now() / 1000)); + console.log('document: selectionchange', selection); + const reservedSelections = this.#reservedSelections; + if (reservedSelections !== undefined) { + this.#restoreSelections([ + ...reservedSelections.filter( + (reservedSelection) => + !selectionIntersects(reservedSelection, selection) + ), + selection, + ]); + } else { + this.#restoreSelections([selection]); + } + } + }), + + addEventListener(document, 'mousedown', (e) => { + if (!isCodeLineTarget(e.composedPath()[0])) { + return; + } + + if (e.button === 0 && isPrimaryModifier(e)) { + this.#reservedSelections = this.#selections?.map((selection) => ({ + ...selection, + })); + } else { + this.#reservedSelections = undefined; + } + + this.#selectionLineHeight = this.#getLineHeight(); + this.#selectionStartY = e.clientY; + this.#selectionStartX = e.clientX; + this.#selectionEndX = e.clientX; + this.#selectionEndY = e.clientY; + }), + + addEventListener(document, 'mouseup', (e) => { + if (!isCodeLineTarget(e.composedPath()[0])) { + return; + } + + this.#reservedSelections = undefined; + this.#textareaEl?.focus(); + }), + + // Selection.getComposedRanges currently does not preserve the drag direction. + // The workaround is to check the mousemove event to determine the direction of the drag operation. + addEventListener(document, 'mousemove', (e) => { + if ((e.buttons & 1) !== 1) { + return; + } + this.#selectionEndX = e.clientX; + this.#selectionEndY = e.clientY; + }), + + addEventListener(this.#textareaEl, 'keydown', (e) => { + const command = resolveEditorCommandFromKeyboardEvent(e); + if (command !== undefined) { + this.#flushPendingTextareaChanges(); + e.preventDefault(); + void this.#runCommand(command); + } + }), + + addEventListener(this.#textareaEl, 'input', () => { + console.log('input'); + if (this.#shouldIgnoreSelectionChange) { + return; + } + console.log('\n~~~~~~~~~', Math.round(Date.now() / 1000)); + console.log('textarea: input'); + this.#syncTextareaState(); + }), + ]; + if (this.#selections !== undefined) { + this.#restoreSelections(this.#selections); + this.#textareaEl.focus(); + } + } + + #computeMouseSelectionDirection(): SelectionDirection { + const startLine = Math.ceil( + this.#selectionStartY / this.#selectionLineHeight + ); + const endLine = Math.ceil(this.#selectionEndY / this.#selectionLineHeight); + if (endLine !== startLine) { + return endLine > startLine + ? SelectionDirection.Forward + : SelectionDirection.Backward; + } + if (this.#selectionEndX !== this.#selectionStartX) { + return this.#selectionEndX > this.#selectionStartX + ? SelectionDirection.Forward + : SelectionDirection.Backward; + } + return SelectionDirection.None; + } + + #rerender(textDocument: TextDocument, nextSelections?: EditorSelection[]) { + if (this.#fileContents === undefined || this.#file === undefined) { + return; + } + const newFile: FileContents = { + ...this.#fileContents, + contents: textDocument.getText(), + }; + this.#file.__rerender(newFile); + this.#onChange?.(newFile); + if (nextSelections !== undefined) { + this.#restoreSelections(nextSelections); + } + } + + #renderLine(line: string, offset: number) { + console.log({ line, offset }); + } + + #syncTextareaState() { + console.log('syncTextareaState'); + const textDocument = this.#textDocument; + const textareaEl = this.#textareaEl; + const textareaSnapshot = this.#textareaSnapshot; + if ( + textDocument === undefined || + textareaEl === undefined || + textareaSnapshot === undefined + ) { + return; + } + const { selectionStart, selectionEnd, value } = textareaEl; + if (value !== textareaSnapshot.text) { + if ( + value.split('\n').length !== textareaSnapshot.lines || + textareaSnapshot.lines !== 3 + ) { + const change = resolveTextChange(textareaSnapshot, value); + this.#applyTextChange(change); + } else { + const line = value.split('\n')[1]; + this.#renderLine(line, textareaSnapshot.offset + selectionStart); + this.#textareaBuffer = { + text: value, + line: textareaSnapshot.startLine, + }; + this.#textareaBufferFlushTimeout = setTimeout(() => { + this.#textareaBufferFlushTimeout = undefined; + this.#flushPendingTextareaChanges(); + }, 500); + } + } else if ( + selectionStart === selectionEnd && + this.#selections !== undefined + ) { + this.#restoreSelections( + mapSelectionMove( + textDocument, + this.#selections, + textDocument.positionAt(textareaSnapshot.offset + selectionStart) + ) + ); + } + } + + #flushPendingTextareaChanges() { + if (this.#textareaBufferFlushTimeout !== undefined) { + window.clearTimeout(this.#textareaBufferFlushTimeout); + this.#textareaBufferFlushTimeout = undefined; + } + if ( + this.#textareaSnapshot !== undefined && + this.#textareaBuffer !== undefined + ) { + const change = resolveTextChange( + this.#textareaSnapshot, + this.#textareaBuffer.text + ); + this.#textareaBuffer = undefined; + this.#applyTextChange(change); + } + } + + #applyTextChange(change: EditorTextChange) { + console.log('applyTextChange', change); + if (this.#textDocument !== undefined && this.#selections !== undefined) { + const newSelections = applySelectionTextChange( + this.#textDocument, + this.#selections, + change + ); + this.#rerender(this.#textDocument, newSelections); + } + } + + #restoreSelections(selections: EditorSelection[]) { + const primarySelection = getPrimarySelection(selections); + if (primarySelection === undefined) { + return; + } + this.#selections = selections; + this.#file?.setSelectedLines(null); + const selectionEls = new Map(); + if (isCollapsedSelection(primarySelection)) { + this.#renderLineHighlight(primarySelection, selectionEls); + } + const ch = this.#chToPx(); + selections.forEach((selection) => { + if (selections.length > 1 || !isCollapsedSelection(selection)) { + this.#renderSelectionRange(selection, ch, selectionEls); + } + this.#renderCaret(selection, selectionEls); + }); + this.#selectionEls?.forEach((el) => el.remove()); + this.#selectionEls?.clear(); + this.#selectionEls = selectionEls; + this.#updateTextarea(primarySelection); + } + + #updateTextarea(primarySelection: EditorSelection) { + console.log('updateTextarea'); + const textDocument = this.#textDocument; + const textareaEl = this.#textareaEl; + if (textDocument === undefined || textareaEl === undefined) { + return; + } + const textareaSnapshot = createTextareaSnapshot( + textDocument, + primarySelection + ); + this.#shouldIgnoreSelectionChange = true; + this.#textareSelectionStart = textareaSnapshot.selectionStart; + this.#textareaSnapshot = textareaSnapshot; + textareaEl.style.top = this.#getLineY(primarySelection.start.line) + 'px'; + textareaEl.style.height = textareaSnapshot.lines + 'lh'; + textareaEl.value = textareaSnapshot.text; + textareaEl.setSelectionRange( + textareaSnapshot.selectionStart, + textareaSnapshot.selectionEnd + ); + setTimeout(() => { + console.log('^'); + this.#shouldIgnoreSelectionChange = false; + }, 0); + } + + #renderLineHighlight( + selection: EditorSelection, + cacheMap: Map + ) { + const hlEl = createElement( + 'div', + { + dataset: 'lineHighlight', + style: { + top: this.#getLineY(selection.start.line) + 'px', + }, + }, + this.#contentEl + ); + this.#file?.setSelectedLines({ + start: selection.start.line + 1, + end: selection.end.line + 1, + }); + // hlEl.scrollIntoView({ block: "nearest" }); + cacheMap.set(`lineHighlight-${selection.start.line}`, hlEl); + } + + #renderSelectionRange( + selection: EditorSelection, + ch: number, + cacheMap: Map + ) { + const { start, end } = selection; + for (let ln = start.line; ln <= end.line; ln++) { + const lineText = this.#textDocument?.getLineText(ln); + if (lineText === undefined) { + // ignore out of bounds line + continue; + } + const lineLength = lineText.length; + const startChar = ln === start.line ? start.character : 0; + const endChar = ln === end.line ? end.character : lineLength; + let left = 0; + let width = 0; + if (startChar === endChar && startChar === 0) { + left = ch; + } else { + const startX = this.#getCharacterX(ln, startChar); + const endX = + endChar === startChar ? startX : this.#getCharacterX(ln, endChar); + left = startX; + width = endX - startX; + } + const spacing = ln === end.line ? 0 : ch; + const style = { + top: this.#getLineY(ln) + 'px', + left: left + 'px', + width: width + spacing + 'px', + }; + const selectionEl = createElement( + 'div', + { dataset: 'selectionRange', style }, + this.#contentEl + ); + cacheMap.set(`selection-${ln}-${startChar}-${endChar}`, selectionEl); + } + } + + #renderCaret(selection: EditorSelection, cacheMap: Map) { + const { start, end, direction } = selection; + const isBackward = direction === SelectionDirection.Backward; + const line = isBackward ? start.line : end.line; + const character = isBackward ? start.character : end.character; + const left = this.#getCharacterX(line, character); + const caretEl = createElement( + 'div', + { + dataset: 'caret', + style: { + top: this.#getLineY(line) + 'px', + left: left + 'px', + }, + }, + this.#contentEl + ); + cacheMap.set('caret-' + line + '-' + character + '-' + direction, caretEl); + } + + async #runCommand(command: EditorCommand) { + switch (command) { + case 'selectAll': + this.#restoreSelections([this.#getFullSelection()]); + break; + + case 'copy': + case 'cut': + if ( + this.#selections !== undefined && + this.#textDocument !== undefined + ) { + try { + // todo: use navigator.clipboard.write() for multiple selections copy + await navigator.clipboard.writeText( + this.#getSelectionText(this.#selections) + ); + } catch { + return; + } + if (command === 'cut') { + this.#replaceSelectionText(''); + } + } + break; + + case 'paste': { + let text: string | string[]; + try { + // todo: use navigator.clipboard.read() for multiple segments paste + text = await navigator.clipboard.readText(); + } catch { + return; + } + this.#replaceSelectionText(text); + break; + } + + case 'indent': + case 'outdent': + if ( + this.#selections !== undefined && + this.#textDocument !== undefined + ) { + const edits: TextEdit[] = []; + const nextSelections: EditorSelection[] = []; + const tabSize = this.#getTabSize(); + for (const selection of this.#selections) { + const startLine = selection.start.line; + const lineText = this.#textDocument.getLineText(startLine); + if (lineText !== undefined) { + const outdent = command === 'outdent'; + if (startLine !== selection.end.line || outdent) { + const ret = resolveIndentEdits( + this.#textDocument, + selection, + tabSize, + outdent + ); + edits.push(...ret[0]); + nextSelections.push(ret[1]); + } else { + const indentUnit = getLineIndentationUnit(lineText, tabSize); + this.#replaceSelectionText(indentUnit); + } + } + } + if (edits.length > 0) { + this.#textDocument.applyEdits( + edits, + true, + this.#selections, + nextSelections + ); + this.#rerender(this.#textDocument, nextSelections); + } + } + break; + + case 'documentStart': + case 'documentEnd': + this.#restoreSelections([ + this.#getDocumentBoundarySelection(command === 'documentEnd'), + ]); + break; + + case 'undo': + if (this.#textDocument?.canUndo === true) { + this.#rerender(this.#textDocument, this.#textDocument.undo()); + } + break; + + case 'redo': + if (this.#textDocument?.canRedo === true) { + this.#rerender(this.#textDocument, this.#textDocument.redo()); + } + break; + } + } + + // for select all command + #getFullSelection(): EditorSelection { + const textDocument = this.#textDocument; + if (textDocument === undefined) { + throw new Error('Editor has no text document'); + } + const lastLine = textDocument.lineCount - 1; + const lastCharacter = textDocument.getLineText(lastLine)?.length ?? 0; + return { + start: { line: 0, character: 0 }, + end: { line: lastLine, character: lastCharacter }, + direction: SelectionDirection.Forward, + }; + } + + // for documentStart/documentEnd commands + #getDocumentBoundarySelection(atEnd: boolean): EditorSelection { + const textDocument = this.#textDocument; + if (textDocument === undefined) { + throw new Error('Editor has no text document'); + } + const line = atEnd ? textDocument.lineCount - 1 : 0; + const character = atEnd ? (textDocument.getLineText(line)?.length ?? 0) : 0; + const start = { line, character }; + return { + start: start, + end: start, + direction: SelectionDirection.Forward, + }; + } + + #getSelectionText(selections: readonly EditorSelection[]): string { + if (this.#textDocument === undefined) { + return ''; + } + return [...selections] + .sort((a, b) => { + const startOrder = comparePosition(a.start, b.start); + if (startOrder !== 0) { + return startOrder; + } + return comparePosition(a.end, b.end); + }) + .map((selection) => this.#textDocument!.getText(selection)) + .join(this.#textDocument.EOF); + } + + // replace the selection text + #replaceSelectionText(text: string | string[]) { + const selections = this.#selections; + if (selections === undefined) { + return; + } + const textDocument = this.#textDocument; + const selection = getPrimarySelection(selections); + if (textDocument == null || selection == null) { + return; + } + const normalizedText = Array.isArray(text) + ? text.map((value) => value.replace(/\r\n?|\n/g, textDocument.EOF)) + : text.replace(/\r\n?|\n/g, textDocument.EOF); + const nextSelections = Array.isArray(normalizedText) + ? applySelectionTextReplace(textDocument, selections, normalizedText) + : applySelectionTextChange(textDocument, selections, { + start: textDocument.offsetAt(selection.start), + end: textDocument.offsetAt(selection.end), + text: normalizedText, + }); + this.#rerender(textDocument, nextSelections); + } + + #getLineElement(line: number) { + return ( + this.#contentEl?.querySelector( + `[data-line-index="${line}"]` + ) ?? undefined + ); + } + + #getTabSize(): number { + const tabSize = this.#contentEl?.computedStyleMap().get('tab-size'); + if ( + tabSize !== undefined && + tabSize instanceof CSSUnitValue && + tabSize.unit === 'number' + ) { + return tabSize.value; + } + return 2; + } + + #getLineHeight(): number { + const lineHeight = this.#contentEl?.computedStyleMap().get('line-height'); + if ( + lineHeight !== undefined && + lineHeight instanceof CSSUnitValue && + lineHeight.unit === 'px' + ) { + return Number(lineHeight.value); + } + return 20; + } + + #chToPx(): number { + if (this.#contentEl !== undefined) { + const el = document.createElement('div'); + el.style.width = '1ch'; + el.style.position = 'absolute'; + el.style.visibility = 'hidden'; + this.#contentEl.appendChild(el); + const px = el.offsetWidth; + el.remove(); + return px; + } + return 0; + } + + // get line Y position + #getLineY(line: number) { + return this.#getLineElement(line)?.offsetTop ?? 0; + } + + // get character X position + #getCharacterX(line: number, character: number) { + const contentEl = this.#contentEl; + const lineEl = this.#getLineElement(line); + if ( + contentEl === undefined || + lineEl === undefined || + !lineEl.hasChildNodes() + ) { + return 0; + } + + const children = lineEl.children; + if (children.length === 1 && children[0] instanceof Text) { + return 0; + } + + let targetSpan: HTMLElement | undefined; + let targetOffset = 0; + let lastSpan: HTMLElement | undefined; + let lastEnd = 0; + for (const child of children) { + if (!(child instanceof HTMLElement) || child.tagName !== 'SPAN') { + continue; + } + const dataChar = child.dataset.char; + if (dataChar === undefined) { + continue; + } + const start = Number(dataChar); + const textLength = child.textContent?.length ?? 0; + const end = start + textLength; + if (character >= start && character <= end) { + targetSpan = child; + targetOffset = character - start; + break; + } + if (end >= lastEnd) { + lastSpan = child; + lastEnd = end; + } + } + + const range = document.createRange(); + if (targetSpan !== undefined) { + const textNode = targetSpan.firstChild; + if (textNode === null) { + return 0; + } + const nodeLength = textNode.textContent?.length ?? 0; + const boundedOffset = Math.max(0, Math.min(targetOffset, nodeLength)); + range.setStart(textNode, boundedOffset); + range.setEnd(textNode, boundedOffset); + } else if (lastSpan !== undefined) { + const textNode = lastSpan.firstChild; + if (textNode === null) { + return 0; + } + const nodeLength = textNode.textContent?.length ?? 0; + range.setStart(textNode, nodeLength); + range.setEnd(textNode, nodeLength); + } else { + return 0; + } + + const editorRect = contentEl.getBoundingClientRect(); + const pointRect = range.getBoundingClientRect(); + return pointRect.left - editorRect.left; + } + + // check if the web selection belongs to editor + #selectionBelongsToEditor(composedRanges: StaticRange[]) { + const contentEl = this.#contentEl; + if (contentEl === undefined) { + return false; + } + return composedRanges.every((range) => { + return ( + contentEl.contains(range.startContainer) && + contentEl.contains(range.endContainer) + ); + }); + } +} + +export function edit(file: File): void { + const editor = new Editor(); + editor.edit(file); +} diff --git a/packages/diffs/src/editor/textDocument.ts b/packages/diffs/src/editor/textDocument.ts index 97f695ce2..19ff52c7d 100644 --- a/packages/diffs/src/editor/textDocument.ts +++ b/packages/diffs/src/editor/textDocument.ts @@ -51,11 +51,11 @@ export interface Range { /** * The range's start position. */ - start: Position; + readonly start: Position; /** * The range's end position. */ - end: Position; + readonly end: Position; } /** @@ -66,12 +66,12 @@ export interface TextEdit { * The range of the text document to be manipulated. To insert * text into a document create a range where start === end. */ - range: Range; + readonly range: Range; /** * The string to be inserted. For delete operations use an * empty string. */ - newText: string; + readonly newText: string; } type LineOffsets = number[] & { diff --git a/packages/diffs/src/index.ts b/packages/diffs/src/index.ts index 260de7740..fc1db46b0 100644 --- a/packages/diffs/src/index.ts +++ b/packages/diffs/src/index.ts @@ -3,7 +3,6 @@ import { createCssVariablesTheme as createCSSVariablesTheme, } from 'shiki'; -export * from './components/Editor'; export * from './components/File'; export * from './components/FileDiff'; export * from './components/FileStream'; @@ -12,7 +11,7 @@ export * from './components/VirtualizedFile'; export * from './components/VirtualizedFileDiff'; export * from './components/Virtualizer'; export * from './constants'; -export * from './editor/textDocument'; +export * from './editor'; export * from './highlighter/languages/areLanguagesAttached'; export * from './highlighter/languages/attachResolvedLanguages'; export * from './highlighter/languages/cleanUpResolvedLanguages'; diff --git a/packages/diffs/src/managers/InteractionManager.ts b/packages/diffs/src/managers/InteractionManager.ts index e8bcd3e76..c746e72c3 100644 --- a/packages/diffs/src/managers/InteractionManager.ts +++ b/packages/diffs/src/managers/InteractionManager.ts @@ -1209,7 +1209,7 @@ export class InteractionManager { for (const code of codeElements) { const [gutter, content] = code.children; const len = content.children.length; - if (len !== gutter.children.length) { + if (len < gutter.children.length) { throw new Error( 'InteractionManager.renderSelection: gutter and content children dont match, something is wrong' ); diff --git a/packages/diffs/test/editorMultiSelections.test.ts b/packages/diffs/test/editorMultiSelections.test.ts index 433398a02..6579fcabe 100644 --- a/packages/diffs/test/editorMultiSelections.test.ts +++ b/packages/diffs/test/editorMultiSelections.test.ts @@ -1,9 +1,9 @@ import { describe, expect, test } from 'bun:test'; import { + applySelectionTextChange, + applySelectionTextReplace, mapSelectionMove, - mapSelectionTextChange, - mapSelectionTextReplace, } from '../src/editor/editorMultiSelections'; import type { EditorSelection } from '../src/editor/editorSelection'; import { SelectionDirection } from '../src/editor/editorSelection'; @@ -31,17 +31,11 @@ describe('mapSelectionTextChange', () => { createSelection(1, 1, 1, 1), createSelection(2, 1, 2, 1), ]; - const { edits, nextSelections } = mapSelectionTextChange( - textDocument, - selections, - { - start: 5, - end: 5, - text: '!', - } - ); - - textDocument.applyEdits(edits); + const nextSelections = applySelectionTextChange(textDocument, selections, { + start: 5, + end: 5, + text: '!', + }); expect(textDocument.getText()).toBe('a!\nb!\nc!'); expect(nextSelections).toEqual([ @@ -58,17 +52,11 @@ describe('mapSelectionTextChange', () => { createSelection(0, 4, 0, 7, SelectionDirection.Forward), createSelection(0, 8, 0, 11, SelectionDirection.Forward), ]; - const { edits, nextSelections } = mapSelectionTextChange( - textDocument, - selections, - { - start: 8, - end: 11, - text: 'x', - } - ); - - textDocument.applyEdits(edits); + const nextSelections = applySelectionTextChange(textDocument, selections, { + start: 8, + end: 11, + text: 'x', + }); expect(textDocument.getText()).toBe('x x x'); expect(nextSelections).toEqual([ @@ -85,17 +73,11 @@ describe('mapSelectionTextChange', () => { createSelection(1, 1, 1, 1), createSelection(2, 1, 2, 1), ]; - const { edits, nextSelections } = mapSelectionTextChange( - textDocument, - selections, - { - start: 6, - end: 7, - text: '', - } - ); - - textDocument.applyEdits(edits); + const nextSelections = applySelectionTextChange(textDocument, selections, { + start: 6, + end: 7, + text: '', + }); expect(textDocument.getText()).toBe('x\nx\nx'); expect(nextSelections).toEqual([ @@ -111,17 +93,11 @@ describe('mapSelectionTextChange', () => { createSelection(0, 1, 0, 1), createSelection(0, 2, 0, 2), ]; - const { edits, nextSelections } = mapSelectionTextChange( - textDocument, - selections, - { - start: 0, - end: 2, - text: '', - } - ); - - textDocument.applyEdits(edits); + const nextSelections = applySelectionTextChange(textDocument, selections, { + start: 0, + end: 2, + text: '', + }); expect(textDocument.getText()).toBe(' '); expect(nextSelections).toEqual([ @@ -173,13 +149,11 @@ describe('mapSelectionTextReplace', () => { createSelection(1, 1, 1, 1), createSelection(2, 1, 2, 1), ]; - const { edits, nextSelections } = mapSelectionTextReplace( - textDocument, - selections, - ['a', 'b', 'c'] - ); - - textDocument.applyEdits(edits); + const nextSelections = applySelectionTextReplace(textDocument, selections, [ + 'a', + 'b', + 'c', + ]); expect(textDocument.getText()).toBe('xa\nyb\nzc'); expect(nextSelections).toEqual([ diff --git a/packages/diffs/test/editorSelection.test.ts b/packages/diffs/test/editorSelection.test.ts index a930b5e4d..4d27353cb 100644 --- a/packages/diffs/test/editorSelection.test.ts +++ b/packages/diffs/test/editorSelection.test.ts @@ -21,21 +21,24 @@ type MockElement = MockNode & { parentElement?: MockElement | null; children: MockElement[]; childNodes: MockNode[]; + dataset: Record; }; -function selection( - anchorNode: Node, - anchorOffset: number, - focusNode: Node, - focusOffset: number -): Selection { - return { - rangeCount: 1, - anchorNode, - anchorOffset, - focusNode, - focusOffset, - } as Selection; +function composedRange( + startContainer: Node, + startOffset: number, + endContainer = startContainer, + endOffset = startOffset +): StaticRange[] { + return [ + { + startContainer, + startOffset, + endContainer, + endOffset, + collapsed: startContainer === endContainer && startOffset === endOffset, + } as StaticRange, + ]; } function editorSelection( @@ -54,19 +57,41 @@ function editorSelection( function pre(line: number, children: MockElement[] = []): MockElement { const element: MockElement = { nodeType: 1, - tagName: 'PRE', + tagName: 'DIV', parentElement: null, children, childNodes: children, textContent: null, + dataset: { lineIndex: String(line) }, }; - Reflect.set(element, 'LINE', line); for (const child of children) { child.parentElement = element; } return element; } +function text(textContent: string): MockNode { + return { + nodeType: 3, + textContent, + }; +} + +function line(line: number, childNodes: MockNode[]): MockElement { + const element = pre( + line, + childNodes.filter((child): child is MockElement => child.nodeType === 1) + ); + element.childNodes = childNodes; + element.textContent = childNodes + .map((child) => child.textContent ?? '') + .join(''); + for (const child of childNodes) { + child.parentElement = element; + } + return element; +} + function br(): MockElement { return { nodeType: 1, @@ -75,6 +100,7 @@ function br(): MockElement { children: [], childNodes: [], textContent: '', + dataset: {}, }; } @@ -90,10 +116,11 @@ function span(text: string, char?: number): MockElement { children: [], childNodes: [textNode], textContent: text, + dataset: {}, }; textNode.parentElement = element; if (char !== undefined) { - Reflect.set(element, 'CHAR', char); + element.dataset.char = String(char); } return element; } @@ -110,6 +137,7 @@ function button(text: string): MockElement { children: [], childNodes: [textNode], textContent: text, + dataset: {}, }; textNode.parentElement = element; return element; @@ -125,6 +153,7 @@ function element(tagName: string, children: MockNode[] = []): MockElement { ), childNodes: children, textContent: children.map((child) => child.textContent ?? '').join(''), + dataset: {}, }; for (const child of children) { child.parentElement = el; @@ -135,61 +164,88 @@ function element(tagName: string, children: MockNode[] = []): MockElement { describe('convertSelection', () => { test('maps a caret on an empty rendered line to character zero', () => { const line = pre(1, [br()]); - expect( - convertSelection( - selection(line as unknown as Node, 0, line as unknown as Node, 0) - ) - ).toEqual({ - start: { line: 1, character: 0 }, - end: { line: 1, character: 0 }, - direction: SelectionDirection.None, - }); + expect(convertSelection(composedRange(line as unknown as Node, 0))).toEqual( + { + start: { line: 1, character: 0 }, + end: { line: 1, character: 0 }, + direction: SelectionDirection.None, + } + ); }); test('treats a placeholder br boundary as the start of the line', () => { const line = pre(2, [br()]); - expect( - convertSelection( - selection(line as unknown as Node, 1, line as unknown as Node, 1) - ) - ).toEqual({ - start: { line: 2, character: 0 }, - end: { line: 2, character: 0 }, - direction: SelectionDirection.None, - }); + expect(convertSelection(composedRange(line as unknown as Node, 1))).toEqual( + { + start: { line: 2, character: 0 }, + end: { line: 2, character: 0 }, + direction: SelectionDirection.None, + } + ); }); test('ignores the line number gutter span on an empty line', () => { const line = pre(3, [span('4'), br()]); + expect(convertSelection(composedRange(line as unknown as Node, 1))).toEqual( + { + start: { line: 3, character: 0 }, + end: { line: 3, character: 0 }, + direction: SelectionDirection.None, + } + ); + expect(convertSelection(composedRange(line as unknown as Node, 2))).toEqual( + { + start: { line: 3, character: 0 }, + end: { line: 3, character: 0 }, + direction: SelectionDirection.None, + } + ); + }); + + test('ignores the fold toggle button in the gutter', () => { + const line = pre(4, [span('5'), button('>'), span('color', 0)]); + expect(convertSelection(composedRange(line as unknown as Node, 2))).toEqual( + { + start: { line: 4, character: 0 }, + end: { line: 4, character: 0 }, + direction: SelectionDirection.None, + } + ); + }); + + test('maps a direct line text node to its character offset', () => { + const textNode = text('abcdef'); + line(6, [textNode]); expect( - convertSelection( - selection(line as unknown as Node, 1, line as unknown as Node, 1) - ) + convertSelection(composedRange(textNode as unknown as Node, 2)) ).toEqual({ - start: { line: 3, character: 0 }, - end: { line: 3, character: 0 }, + start: { line: 6, character: 2 }, + end: { line: 6, character: 2 }, direction: SelectionDirection.None, }); + }); + + test('maps a span text node from its data-char base', () => { + const token = span('abcdef', 10); + const textNode = token.childNodes[0]; + pre(7, [token]); expect( - convertSelection( - selection(line as unknown as Node, 2, line as unknown as Node, 2) - ) + convertSelection(composedRange(textNode as unknown as Node, 3)) ).toEqual({ - start: { line: 3, character: 0 }, - end: { line: 3, character: 0 }, + start: { line: 7, character: 13 }, + end: { line: 7, character: 13 }, direction: SelectionDirection.None, }); }); - test('ignores the fold toggle button in the gutter', () => { - const line = pre(4, [span('5'), button('>'), span('color', 0)]); + test('ignores newline placeholders in direct line text nodes', () => { + const textNode = text('\n'); + line(8, [textNode]); expect( - convertSelection( - selection(line as unknown as Node, 2, line as unknown as Node, 2) - ) + convertSelection(composedRange(textNode as unknown as Node, 1)) ).toEqual({ - start: { line: 4, character: 0 }, - end: { line: 4, character: 0 }, + start: { line: 8, character: 0 }, + end: { line: 8, character: 0 }, direction: SelectionDirection.None, }); }); @@ -199,23 +255,19 @@ describe('convertSelection', () => { const toggle = element('BUTTON', [icon]); pre(5, [span('6'), toggle, br()]); expect( - convertSelection( - selection(toggle as unknown as Node, 0, toggle as unknown as Node, 0) - ) - ).toEqual({ - start: { line: 5, character: 0 }, - end: { line: 5, character: 0 }, - direction: SelectionDirection.None, - }); - expect( - convertSelection( - selection(icon as unknown as Node, 0, icon as unknown as Node, 0) - ) + convertSelection(composedRange(toggle as unknown as Node, 0)) ).toEqual({ start: { line: 5, character: 0 }, end: { line: 5, character: 0 }, direction: SelectionDirection.None, }); + expect(convertSelection(composedRange(icon as unknown as Node, 0))).toEqual( + { + start: { line: 5, character: 0 }, + end: { line: 5, character: 0 }, + direction: SelectionDirection.None, + } + ); }); }); diff --git a/packages/diffs/test/editorUtils.test.ts b/packages/diffs/test/editorUtils.test.ts deleted file mode 100644 index 07bb3d3b2..000000000 --- a/packages/diffs/test/editorUtils.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { describe, expect, test } from 'bun:test'; - -import { coalesceMicrotask, parseCssValue } from '../src/editor/editorUtils'; - -describe('parseCssValue', () => { - const cases: Array<[string, [number, string]]> = [ - ['abc', [0, '']], - ['14', [14, '']], - ['1.5', [1.5, '']], - ['14px', [14, 'px']], - ['1.25rem', [1.25, 'rem']], - ['-2em', [-2, 'em']], - ]; - - test.each(cases)('parses %p as %p', (value, expected) => { - expect(parseCssValue(value)).toEqual(expected); - }); -}); - -describe('coalesceMicrotask', () => { - test('runs once for repeated calls in the same tick', async () => { - let callCount = 0; - const run = coalesceMicrotask(() => { - callCount++; - }); - - run(); - run(); - run(); - expect(callCount).toBe(0); - - await Promise.resolve(); - expect(callCount).toBe(1); - }); - - test('allows a later tick to run again', async () => { - let callCount = 0; - const run = coalesceMicrotask(() => { - callCount++; - }); - - run(); - await Promise.resolve(); - run(); - await Promise.resolve(); - - expect(callCount).toBe(2); - }); -}); From f95df57e98a94368bca6805d201b3902570129d2 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Mon, 27 Apr 2026 17:17:39 +0800 Subject: [PATCH 019/138] Update demo --- apps/demo/src/main.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/demo/src/main.ts b/apps/demo/src/main.ts index cbcec86b2..0867bac1e 100644 --- a/apps/demo/src/main.ts +++ b/apps/demo/src/main.ts @@ -835,7 +835,7 @@ if (renderEditorButton != null) { }, // Line selection stuff - enableLineSelection: true, + // enableLineSelection: true, // onLineClick(props) { // console.log('onLineClick', props); // }, From 18f5422ab22208e07234f5f1c55b23d6bef18c99 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Mon, 27 Apr 2026 17:23:22 +0800 Subject: [PATCH 020/138] Update editor constants to set text and background color to transparent --- packages/diffs/src/editor/constants.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/diffs/src/editor/constants.ts b/packages/diffs/src/editor/constants.ts index 5daebdffc..e01e5cceb 100644 --- a/packages/diffs/src/editor/constants.ts +++ b/packages/diffs/src/editor/constants.ts @@ -29,6 +29,9 @@ export const EDITOR_CSS = /* CSS */ ` padding: 0; padding-inline: 1ch; transform: translateY(-1lh); + color: transparent; + color: transparent; + background-color: transparent; border: none; outline: none; resize: none; From 0194e41cc36d4357d4acfa394288a8ba1b8f4d2e Mon Sep 17 00:00:00 2001 From: Je Xia Date: Mon, 27 Apr 2026 23:37:24 +0800 Subject: [PATCH 021/138] Rewrite rerender logic --- packages/diffs/src/components/File.ts | 46 +++--- packages/diffs/src/editor/index.ts | 227 ++++++++++++++++++-------- packages/diffs/src/types.ts | 11 ++ 3 files changed, 194 insertions(+), 90 deletions(-) diff --git a/packages/diffs/src/components/File.ts b/packages/diffs/src/components/File.ts index 8597e6115..206a13ef7 100644 --- a/packages/diffs/src/components/File.ts +++ b/packages/diffs/src/components/File.ts @@ -24,6 +24,7 @@ import { SVGSpriteSheet } from '../sprite'; import type { AppliedThemeStyleCache, BaseCodeOptions, + EditorHook, FileContents, LineAnnotation, PrePropertiesConfig, @@ -172,26 +173,13 @@ export class File { this.workerManager?.subscribeToThemeChanges(this); } - private __onEditableHandler: - | ((file: FileContents, fileContainer: HTMLElement) => void) - | undefined; - public __onEditable( - callback: (fileContents: FileContents, fileContainer: HTMLElement) => void - ): void { - if (this.fileContainer !== undefined && this.file !== undefined) { - callback(this.file, this.fileContainer); - } - this.__onEditableHandler = callback; - } - public __rerender(file: FileContents): void { - this.file = file; - const fileResult = this.fileRenderer.renderFile(file, this.renderRange); - if (fileResult == null || this.pre == null) { - return; + private __editorHook: EditorHook | undefined; + + public __addEditorHook(hook: EditorHook): void { + if (this.fileContainer != null && this.file != null) { + hook(this.fileRenderer, this.fileContainer, this.file, this.renderRange); } - console.log('__rerender', fileResult); - this.applyFullRender(fileResult, this.pre); - this.__onEditableHandler?.(file, this.fileContainer!); + this.__editorHook = hook; } private handleHighlightRender = (): void => { @@ -301,8 +289,7 @@ export class File { // pre-render and we should kick off a render. if (this.pre == null && this.headerElement == null) { this.render({ ...props, preventEmit: true }); - } - // Otherwise orchestrate our setup. + } // Otherwise orchestrate our setup. else { this.hydrationSetup({ file, lineAnnotations }); } @@ -485,7 +472,12 @@ export class File { if (!preventEmit) { this.emitPostRender(); } - this.__onEditableHandler?.(file, fileContainer); + this.__editorHook?.( + this.fileRenderer, + fileContainer, + file, + this.renderRange + ); return true; } @@ -536,7 +528,12 @@ export class File { if (!preventEmit) { this.emitPostRender(); } - this.__onEditableHandler?.(file, fileContainer); + this.__editorHook?.( + this.fileRenderer, + fileContainer, + file, + this.renderRange + ); return true; } @@ -1177,8 +1174,7 @@ export class File { this.appliedPreAttributes = undefined; this.code = undefined; shadowRoot.appendChild(this.pre); - } - // If we have a new parent container for the pre element, lets go ahead and + } // If we have a new parent container for the pre element, lets go ahead and // move it into the new container else if (this.pre.parentNode !== shadowRoot) { container.shadowRoot?.appendChild(this.pre); diff --git a/packages/diffs/src/editor/index.ts b/packages/diffs/src/editor/index.ts index ad197261f..e8bf5371b 100644 --- a/packages/diffs/src/editor/index.ts +++ b/packages/diffs/src/editor/index.ts @@ -1,3 +1,4 @@ +import { areThemesAttached, DEFAULT_THEMES, getHighlighterIfLoaded } from '..'; import type { File } from '../components/File'; import { type EditorCommand, @@ -35,11 +36,15 @@ import { isCodeLineTarget, } from '../editor/editorUtils'; import { TextDocument, type TextEdit } from '../editor/textDocument'; -import type { FileContents } from '../types'; +import type { FileRenderer } from '../renderers/FileRenderer'; +import type { FileContents, RenderRange } from '../types'; +import { renderFileWithHighlighter } from '../utils/renderFileWithHighlighter'; import { EDITOR_CSS } from './constants'; -export class Editor { - #file?: File; +export class Editor { + #file?: File; + #fileRenderer?: FileRenderer; + #renderRange?: RenderRange; #fileContents?: FileContents; #textDocument?: TextDocument; #onChange?: (file: FileContents) => void; @@ -70,10 +75,20 @@ export class Editor { return this.#textDocument?.getText(); } - edit(file: File, onChange?: (file: FileContents) => void): () => void { - file.__onEditable((fileContents, fileContainer) => { - this.#onEditable(fileContents, fileContainer); - }); + edit( + file: File, + onChange?: (file: FileContents) => void + ): () => void { + file.__addEditorHook( + (fileRenderer, fileContainer, fileContents, renderRange) => { + this.#initialize( + fileRenderer, + fileContainer, + fileContents, + renderRange + ); + } + ); this.#file = file; this.#onChange = onChange; return this.cleanUp.bind(this); @@ -101,13 +116,28 @@ export class Editor { this.#reservedSelections = undefined; } - #onEditable(fileContents: FileContents, fileContainer: HTMLElement): void { - this.#fileContents ??= fileContents; - this.#textDocument ??= new TextDocument( - fileContents.name, - fileContents.contents, - fileContents.lang - ); + #initialize( + fileRenderer: FileRenderer, + fileContainer: HTMLElement, + fileContents: FileContents, + renderRange: RenderRange | undefined + ): void { + console.log('[editor] initialize, renderRange:', renderRange); + + this.#fileRenderer ??= fileRenderer; + + if ( + this.#fileContents === undefined || + this.#fileContents.contents !== fileContents.contents + ) { + this.#fileContents = fileContents; + this.#textDocument = new TextDocument( + fileContents.name, + fileContents.contents, + fileContents.lang + ); + } + this.#renderRange = renderRange; const shadowRoot = fileContainer.shadowRoot ?? fileContainer.attachShadow({ mode: 'open' }); @@ -143,13 +173,10 @@ export class Editor { this.#textareaSnapshot !== undefined ) { const { selectionStart } = this.#textareaEl; - console.log(selectionStart, this.#textareSelectionStart); if ( this.#textareSelectionStart !== selectionStart && this.#textareaSnapshot.text === this.#textareaEl.value ) { - console.log('\n~~~~~~~~~', Math.round(Date.now() / 1000)); - console.log('textarea: selectionchange'); this.#textareSelectionStart = selectionStart; this.#syncTextareaState(); return; @@ -172,11 +199,9 @@ export class Editor { this.#computeMouseSelectionDirection() ); if (selection !== null) { - console.log('\n~~~~~~~~~', Math.round(Date.now() / 1000)); - console.log('document: selectionchange', selection); const reservedSelections = this.#reservedSelections; if (reservedSelections !== undefined) { - this.#restoreSelections([ + this.#setSelections([ ...reservedSelections.filter( (reservedSelection) => !selectionIntersects(reservedSelection, selection) @@ -184,7 +209,7 @@ export class Editor { selection, ]); } else { - this.#restoreSelections([selection]); + this.#setSelections([selection]); } } }), @@ -238,17 +263,14 @@ export class Editor { }), addEventListener(this.#textareaEl, 'input', () => { - console.log('input'); if (this.#shouldIgnoreSelectionChange) { return; } - console.log('\n~~~~~~~~~', Math.round(Date.now() / 1000)); - console.log('textarea: input'); this.#syncTextareaState(); }), ]; if (this.#selections !== undefined) { - this.#restoreSelections(this.#selections); + this.#setSelections(this.#selections); this.#textareaEl.focus(); } } @@ -275,23 +297,96 @@ export class Editor { if (this.#fileContents === undefined || this.#file === undefined) { return; } - const newFile: FileContents = { - ...this.#fileContents, - contents: textDocument.getText(), - }; - this.#file.__rerender(newFile); - this.#onChange?.(newFile); - if (nextSelections !== undefined) { - this.#restoreSelections(nextSelections); - } - } - #renderLine(line: string, offset: number) { - console.log({ line, offset }); + this.#fileContents.contents = textDocument.getText(); + this.#onChange?.({ ...this.#fileContents }); + + const highlighter = areThemesAttached( + this.#file.options.theme ?? DEFAULT_THEMES + ) + ? getHighlighterIfLoaded() + : undefined; + if (highlighter !== undefined) { + let t = performance.now(); + const { theme = DEFAULT_THEMES, tokenizeMaxLineLength = 1000 } = + this.#file.options; + const { startingLine = 0, totalLines = textDocument.lineCount } = + this.#renderRange ?? {}; + const text = textDocument.getText({ + start: { line: startingLine, character: 0 }, + end: { line: startingLine + totalLines, character: 0 }, + }); + const result = renderFileWithHighlighter( + { ...this.#fileContents, contents: text }, + highlighter, + { + theme, + tokenizeMaxLineLength, + useTokenTransformer: true, // get `data-char` on token span + } + ); + console.log( + '[editor] renderFileWithHighlighter time:', + performance.now() - t + ); + + const lineElMap = new Map(); + for (const child of this.#contentEl?.children ?? []) { + const divEl = child as HTMLDivElement; + if (divEl.dataset.lineIndex === undefined) { + continue; + } + lineElMap.set(Number(divEl.dataset.lineIndex), divEl); + } + + for (const line of result.code) { + if (line.type === 'element') { + const lineIndex = line.properties['data-line-index']; + if (typeof lineIndex === 'number') { + const oldLineEl = lineElMap.get(lineIndex); + if (oldLineEl !== undefined) { + const newLineEl = createElement( + line.tagName as keyof HTMLElementTagNameMap, + { + dataset: { + line: String(lineIndex + 1), + lineIndex: String(lineIndex), + lineType: String(line.properties['data-line-type']), + }, + } + ); + for (const span of line.children) { + if (span.type === 'element') { + const token = span.children[0]; + createElement( + span.tagName as keyof HTMLElementTagNameMap, + { + dataset: { + char: String(span.properties['data-char']), + }, + style: { + cssText: span.properties['style'] as string | undefined, + }, + textContent: + token.type === 'text' ? token.value : undefined, + }, + newLineEl + ); + } + } + oldLineEl.replaceWith(newLineEl); + } + } + } + } + + if (nextSelections !== undefined) { + this.#setSelections(nextSelections); + } + } } #syncTextareaState() { - console.log('syncTextareaState'); const textDocument = this.#textDocument; const textareaEl = this.#textareaEl; const textareaSnapshot = this.#textareaSnapshot; @@ -304,29 +399,32 @@ export class Editor { } const { selectionStart, selectionEnd, value } = textareaEl; if (value !== textareaSnapshot.text) { - if ( - value.split('\n').length !== textareaSnapshot.lines || - textareaSnapshot.lines !== 3 - ) { - const change = resolveTextChange(textareaSnapshot, value); - this.#applyTextChange(change); - } else { - const line = value.split('\n')[1]; - this.#renderLine(line, textareaSnapshot.offset + selectionStart); - this.#textareaBuffer = { - text: value, - line: textareaSnapshot.startLine, - }; - this.#textareaBufferFlushTimeout = setTimeout(() => { - this.#textareaBufferFlushTimeout = undefined; - this.#flushPendingTextareaChanges(); - }, 500); - } + // if (value.split('\n').length !== textareaSnapshot.lines) { + // // new lines have been added, or the number of lines has changed. + // // we need to apply the change to the text document, and rerender the file. + + // } else { + // // text has been changed in a single line + // // rerender the line, and schedule a flush of the pending changes. + // const lineText = value.split('\n')[1]; + // this.#rerenderLine(lineText, textareaSnapshot.startLine + 2); + // this.#textareaBuffer = { + // text: value, + // line: textareaSnapshot.startLine, + // }; + // this.#textareaBufferFlushTimeout = setTimeout(() => { + // this.#textareaBufferFlushTimeout = undefined; + // this.#flushPendingTextareaChanges(); + // }, 500); + // } + const change = resolveTextChange(textareaSnapshot, value); + this.#applyTextChange(change); } else if ( selectionStart === selectionEnd && this.#selections !== undefined ) { - this.#restoreSelections( + // caret in the textarea has been moved, but no text change has been made. + this.#setSelections( mapSelectionMove( textDocument, this.#selections, @@ -355,7 +453,6 @@ export class Editor { } #applyTextChange(change: EditorTextChange) { - console.log('applyTextChange', change); if (this.#textDocument !== undefined && this.#selections !== undefined) { const newSelections = applySelectionTextChange( this.#textDocument, @@ -366,7 +463,7 @@ export class Editor { } } - #restoreSelections(selections: EditorSelection[]) { + #setSelections(selections: EditorSelection[]) { const primarySelection = getPrimarySelection(selections); if (primarySelection === undefined) { return; @@ -391,7 +488,6 @@ export class Editor { } #updateTextarea(primarySelection: EditorSelection) { - console.log('updateTextarea'); const textDocument = this.#textDocument; const textareaEl = this.#textareaEl; if (textDocument === undefined || textareaEl === undefined) { @@ -412,7 +508,6 @@ export class Editor { textareaSnapshot.selectionEnd ); setTimeout(() => { - console.log('^'); this.#shouldIgnoreSelectionChange = false; }, 0); } @@ -503,7 +598,7 @@ export class Editor { async #runCommand(command: EditorCommand) { switch (command) { case 'selectAll': - this.#restoreSelections([this.#getFullSelection()]); + this.#setSelections([this.#getFullSelection()]); break; case 'copy': @@ -581,20 +676,22 @@ export class Editor { case 'documentStart': case 'documentEnd': - this.#restoreSelections([ + this.#setSelections([ this.#getDocumentBoundarySelection(command === 'documentEnd'), ]); break; case 'undo': if (this.#textDocument?.canUndo === true) { - this.#rerender(this.#textDocument, this.#textDocument.undo()); + const undoSelections = this.#textDocument.undo(); + this.#rerender(this.#textDocument, undoSelections); } break; case 'redo': if (this.#textDocument?.canRedo === true) { - this.#rerender(this.#textDocument, this.#textDocument.redo()); + const redoSelections = this.#textDocument.redo(); + this.#rerender(this.#textDocument, redoSelections); } break; } diff --git a/packages/diffs/src/types.ts b/packages/diffs/src/types.ts index f31db98a2..4254ee117 100644 --- a/packages/diffs/src/types.ts +++ b/packages/diffs/src/types.ts @@ -12,6 +12,8 @@ import type { ThemeRegistrationResolved, } from 'shiki'; +import type { FileRenderer } from './renderers/FileRenderer'; + export type { CreatePatchOptionsNonabortable }; /** @@ -645,6 +647,15 @@ export interface RenderFileResult { options: RenderFileOptions; } +export interface EditorHook { + ( + fileRenderer: FileRenderer, + fileContainer: HTMLElement, + file: FileContents, + renderRange: RenderRange | undefined + ): void; +} + export interface RenderDiffResult { result: ThemedDiffResult; options: RenderDiffOptions; From ef525f17ddd569d837b70076e0e73f716a95fcc8 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Tue, 28 Apr 2026 00:26:50 +0800 Subject: [PATCH 022/138] Format --- packages/diffs/src/components/File.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/diffs/src/components/File.ts b/packages/diffs/src/components/File.ts index 206a13ef7..e5b9141eb 100644 --- a/packages/diffs/src/components/File.ts +++ b/packages/diffs/src/components/File.ts @@ -289,7 +289,8 @@ export class File { // pre-render and we should kick off a render. if (this.pre == null && this.headerElement == null) { this.render({ ...props, preventEmit: true }); - } // Otherwise orchestrate our setup. + } + // Otherwise orchestrate our setup. else { this.hydrationSetup({ file, lineAnnotations }); } @@ -1174,7 +1175,8 @@ export class File { this.appliedPreAttributes = undefined; this.code = undefined; shadowRoot.appendChild(this.pre); - } // If we have a new parent container for the pre element, lets go ahead and + } + // If we have a new parent container for the pre element, lets go ahead and // move it into the new container else if (this.pre.parentNode !== shadowRoot) { container.shadowRoot?.appendChild(this.pre); From dd85b08e68a66b2781e55381637150a543a04ff2 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Tue, 28 Apr 2026 00:30:40 +0800 Subject: [PATCH 023/138] Remove dead code --- packages/diffs/src/editor/index.ts | 41 ------------------------------ 1 file changed, 41 deletions(-) diff --git a/packages/diffs/src/editor/index.ts b/packages/diffs/src/editor/index.ts index e8bf5371b..5c3872b7a 100644 --- a/packages/diffs/src/editor/index.ts +++ b/packages/diffs/src/editor/index.ts @@ -63,8 +63,6 @@ export class Editor { #selectionEndY = 0; #textareSelectionStart = 0; #shouldIgnoreSelectionChange = false; - #textareaBuffer?: { text: string; line: number }; - #textareaBufferFlushTimeout?: ReturnType; #textareaSnapshot?: TextareaSnapshot; #selections?: EditorSelection[]; #reservedSelections?: EditorSelection[]; @@ -109,8 +107,6 @@ export class Editor { this.#selectionEls = undefined; this.#shouldIgnoreSelectionChange = false; - this.#textareaBuffer = undefined; - this.#textareaBufferFlushTimeout = undefined; this.#textareaSnapshot = undefined; this.#selections = undefined; this.#reservedSelections = undefined; @@ -256,7 +252,6 @@ export class Editor { addEventListener(this.#textareaEl, 'keydown', (e) => { const command = resolveEditorCommandFromKeyboardEvent(e); if (command !== undefined) { - this.#flushPendingTextareaChanges(); e.preventDefault(); void this.#runCommand(command); } @@ -399,24 +394,6 @@ export class Editor { } const { selectionStart, selectionEnd, value } = textareaEl; if (value !== textareaSnapshot.text) { - // if (value.split('\n').length !== textareaSnapshot.lines) { - // // new lines have been added, or the number of lines has changed. - // // we need to apply the change to the text document, and rerender the file. - - // } else { - // // text has been changed in a single line - // // rerender the line, and schedule a flush of the pending changes. - // const lineText = value.split('\n')[1]; - // this.#rerenderLine(lineText, textareaSnapshot.startLine + 2); - // this.#textareaBuffer = { - // text: value, - // line: textareaSnapshot.startLine, - // }; - // this.#textareaBufferFlushTimeout = setTimeout(() => { - // this.#textareaBufferFlushTimeout = undefined; - // this.#flushPendingTextareaChanges(); - // }, 500); - // } const change = resolveTextChange(textareaSnapshot, value); this.#applyTextChange(change); } else if ( @@ -434,24 +411,6 @@ export class Editor { } } - #flushPendingTextareaChanges() { - if (this.#textareaBufferFlushTimeout !== undefined) { - window.clearTimeout(this.#textareaBufferFlushTimeout); - this.#textareaBufferFlushTimeout = undefined; - } - if ( - this.#textareaSnapshot !== undefined && - this.#textareaBuffer !== undefined - ) { - const change = resolveTextChange( - this.#textareaSnapshot, - this.#textareaBuffer.text - ); - this.#textareaBuffer = undefined; - this.#applyTextChange(change); - } - } - #applyTextChange(change: EditorTextChange) { if (this.#textDocument !== undefined && this.#selections !== undefined) { const newSelections = applySelectionTextChange( From 1ae5008b681eb0689c8ef5d4619a71b6c37bfa37 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Tue, 28 Apr 2026 10:07:20 +0800 Subject: [PATCH 024/138] Fix caret postion on empty line --- packages/diffs/src/editor/index.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/diffs/src/editor/index.ts b/packages/diffs/src/editor/index.ts index 5c3872b7a..21ab586c7 100644 --- a/packages/diffs/src/editor/index.ts +++ b/packages/diffs/src/editor/index.ts @@ -438,7 +438,7 @@ export class Editor { if (selections.length > 1 || !isCollapsedSelection(selection)) { this.#renderSelectionRange(selection, ch, selectionEls); } - this.#renderCaret(selection, selectionEls); + this.#renderCaret(selection, ch, selectionEls); }); this.#selectionEls?.forEach((el) => el.remove()); this.#selectionEls?.clear(); @@ -534,12 +534,16 @@ export class Editor { } } - #renderCaret(selection: EditorSelection, cacheMap: Map) { + #renderCaret( + selection: EditorSelection, + ch: number, + cacheMap: Map + ) { const { start, end, direction } = selection; const isBackward = direction === SelectionDirection.Backward; const line = isBackward ? start.line : end.line; const character = isBackward ? start.character : end.character; - const left = this.#getCharacterX(line, character); + const left = Math.max(ch, this.#getCharacterX(line, character)); const caretEl = createElement( 'div', { From a8b41bc2af88288dd906d0ecb61f663df8826424 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Tue, 28 Apr 2026 10:36:34 +0800 Subject: [PATCH 025/138] Improve `renderSelectionRange` performance by using cached DOM elements --- packages/diffs/src/editor/index.ts | 74 ++++++++++++++++++++---------- 1 file changed, 49 insertions(+), 25 deletions(-) diff --git a/packages/diffs/src/editor/index.ts b/packages/diffs/src/editor/index.ts index 21ab586c7..3024108e3 100644 --- a/packages/diffs/src/editor/index.ts +++ b/packages/diffs/src/editor/index.ts @@ -473,7 +473,7 @@ export class Editor { #renderLineHighlight( selection: EditorSelection, - cacheMap: Map + markMap: Map ) { const hlEl = createElement( 'div', @@ -490,54 +490,78 @@ export class Editor { end: selection.end.line + 1, }); // hlEl.scrollIntoView({ block: "nearest" }); - cacheMap.set(`lineHighlight-${selection.start.line}`, hlEl); + markMap.set(`lineHighlight-${selection.start.line}`, hlEl); } #renderSelectionRange( selection: EditorSelection, ch: number, - cacheMap: Map + markMap: Map ) { + const selectionEls = this.#selectionEls; const { start, end } = selection; + for (let ln = start.line; ln <= end.line; ln++) { const lineText = this.#textDocument?.getLineText(ln); if (lineText === undefined) { // ignore out of bounds line continue; } + const lineLength = lineText.length; const startChar = ln === start.line ? start.character : 0; const endChar = ln === end.line ? end.character : lineLength; + const spacing = ln === end.line ? 0 : ch; + const cacheKey = `selection-${ln}-${startChar}-${endChar}`; + let left = 0; - let width = 0; - if (startChar === endChar && startChar === 0) { - left = ch; + let width = spacing; + let rangeEl: HTMLElement | undefined; + + if (selectionEls?.has(cacheKey) === true) { + console.log('use cached selection range', cacheKey); + rangeEl = selectionEls.get(cacheKey)!; + selectionEls.delete(cacheKey); } else { - const startX = this.#getCharacterX(ln, startChar); - const endX = - endChar === startChar ? startX : this.#getCharacterX(ln, endChar); - left = startX; - width = endX - startX; + if (startChar === endChar && startChar === 0) { + left = ch; + } else { + const startX = this.#getCharacterX(ln, startChar); + const endX = + endChar === startChar ? startX : this.#getCharacterX(ln, endChar); + left = startX; + width = endX - startX; + } + + for (const [key, el] of selectionEls?.entries() ?? []) { + if (key.startsWith(`selection-${ln}-`)) { + rangeEl = el; + selectionEls?.delete(key); + el.style.left = left + 'px'; + el.style.width = width + 'px'; + break; + } + } + + rangeEl ??= createElement('div', { + dataset: 'selectionRange', + style: { + top: this.#getLineY(ln) + 'px', + left: left + 'px', + width: width + 'px', + }, + }); } - const spacing = ln === end.line ? 0 : ch; - const style = { - top: this.#getLineY(ln) + 'px', - left: left + 'px', - width: width + spacing + 'px', - }; - const selectionEl = createElement( - 'div', - { dataset: 'selectionRange', style }, - this.#contentEl - ); - cacheMap.set(`selection-${ln}-${startChar}-${endChar}`, selectionEl); + + this.#contentEl?.append(rangeEl); + markMap.set(cacheKey, rangeEl); } } #renderCaret( selection: EditorSelection, ch: number, - cacheMap: Map + markMap: Map ) { const { start, end, direction } = selection; const isBackward = direction === SelectionDirection.Backward; @@ -555,7 +579,7 @@ export class Editor { }, this.#contentEl ); - cacheMap.set('caret-' + line + '-' + character + '-' + direction, caretEl); + markMap.set('caret-' + line + '-' + character + '-' + direction, caretEl); } async #runCommand(command: EditorCommand) { From e647f74ce97092f7863529ef8a03a899ad7b438c Mon Sep 17 00:00:00 2001 From: Je Xia Date: Tue, 28 Apr 2026 11:21:27 +0800 Subject: [PATCH 026/138] Support range selection in textarea --- packages/diffs/src/components/File.ts | 20 +-- .../diffs/src/editor/editorMultiSelections.ts | 40 +++++- packages/diffs/src/editor/index.ts | 132 +++++++++++------- packages/diffs/src/types.ts | 11 +- .../diffs/test/editorMultiSelections.test.ts | 43 ++++++ 5 files changed, 173 insertions(+), 73 deletions(-) diff --git a/packages/diffs/src/components/File.ts b/packages/diffs/src/components/File.ts index e5b9141eb..a2c4cdefe 100644 --- a/packages/diffs/src/components/File.ts +++ b/packages/diffs/src/components/File.ts @@ -173,11 +173,11 @@ export class File { this.workerManager?.subscribeToThemeChanges(this); } - private __editorHook: EditorHook | undefined; + private __editorHook: EditorHook | undefined; - public __addEditorHook(hook: EditorHook): void { + public __addEditorHook(hook: EditorHook): void { if (this.fileContainer != null && this.file != null) { - hook(this.fileRenderer, this.fileContainer, this.file, this.renderRange); + hook(this.fileContainer, this.file); } this.__editorHook = hook; } @@ -473,12 +473,7 @@ export class File { if (!preventEmit) { this.emitPostRender(); } - this.__editorHook?.( - this.fileRenderer, - fileContainer, - file, - this.renderRange - ); + this.__editorHook?.(fileContainer, file); return true; } @@ -529,12 +524,7 @@ export class File { if (!preventEmit) { this.emitPostRender(); } - this.__editorHook?.( - this.fileRenderer, - fileContainer, - file, - this.renderRange - ); + this.__editorHook?.(fileContainer, file); return true; } diff --git a/packages/diffs/src/editor/editorMultiSelections.ts b/packages/diffs/src/editor/editorMultiSelections.ts index 63e068865..c77d073db 100644 --- a/packages/diffs/src/editor/editorMultiSelections.ts +++ b/packages/diffs/src/editor/editorMultiSelections.ts @@ -46,6 +46,33 @@ export function mapSelectionMove( }); } +export function mapSelectionRangeMove( + textDocument: TextDocument, + selections: readonly EditorSelection[], + nextAnchor: Position, + nextFocus: Position +): EditorSelection[] { + const primarySelection = selections[selections.length - 1]; + if (primarySelection === undefined) { + return []; + } + const [primaryAnchorOffset, primaryFocusOffset] = + getSelectionAnchorAndFocusOffsets(textDocument, primarySelection); + const anchorDelta = textDocument.offsetAt(nextAnchor) - primaryAnchorOffset; + const focusDelta = textDocument.offsetAt(nextFocus) - primaryFocusOffset; + return selections.map((selection) => { + const [anchorOffset, focusOffset] = getSelectionAnchorAndFocusOffsets( + textDocument, + selection + ); + return createSelectionFromAnchorAndFocusOffsets( + textDocument, + anchorOffset + anchorDelta, + focusOffset + focusDelta + ); + }); +} + export function applySelectionTextChange( textDocument: TextDocument, selections: EditorSelection[], @@ -193,7 +220,7 @@ export function applySelectionTextReplace( return nextSelections; } -function createSelectionFromAnchorAndFocusOffsets( +export function createSelectionFromAnchorAndFocusOffsets( textDocument: TextDocument, anchorOffset: number, focusOffset: number @@ -212,3 +239,14 @@ function createSelectionFromAnchorAndFocusOffsets( direction, }; } + +function getSelectionAnchorAndFocusOffsets( + textDocument: TextDocument, + selection: EditorSelection +): [anchorOffset: number, focusOffset: number] { + const isBackward = selection.direction === SelectionDirection.Backward; + return [ + textDocument.offsetAt(isBackward ? selection.end : selection.start), + textDocument.offsetAt(isBackward ? selection.start : selection.end), + ]; +} diff --git a/packages/diffs/src/editor/index.ts b/packages/diffs/src/editor/index.ts index 3024108e3..451362b64 100644 --- a/packages/diffs/src/editor/index.ts +++ b/packages/diffs/src/editor/index.ts @@ -9,6 +9,7 @@ import { applySelectionTextChange, applySelectionTextReplace, mapSelectionMove, + mapSelectionRangeMove, } from '../editor/editorMultiSelections'; import type { EditorSelection, @@ -36,14 +37,12 @@ import { isCodeLineTarget, } from '../editor/editorUtils'; import { TextDocument, type TextEdit } from '../editor/textDocument'; -import type { FileRenderer } from '../renderers/FileRenderer'; import type { FileContents, RenderRange } from '../types'; import { renderFileWithHighlighter } from '../utils/renderFileWithHighlighter'; import { EDITOR_CSS } from './constants'; export class Editor { #file?: File; - #fileRenderer?: FileRenderer; #renderRange?: RenderRange; #fileContents?: FileContents; #textDocument?: TextDocument; @@ -61,7 +60,10 @@ export class Editor { #selectionStartY = 0; #selectionEndX = 0; #selectionEndY = 0; - #textareSelectionStart = 0; + #textareaSelectionStart = 0; + #textareaSelectionEnd = 0; + #textareaSelectionDirection: HTMLTextAreaElement['selectionDirection'] = + 'none'; #shouldIgnoreSelectionChange = false; #textareaSnapshot?: TextareaSnapshot; #selections?: EditorSelection[]; @@ -77,16 +79,9 @@ export class Editor { file: File, onChange?: (file: FileContents) => void ): () => void { - file.__addEditorHook( - (fileRenderer, fileContainer, fileContents, renderRange) => { - this.#initialize( - fileRenderer, - fileContainer, - fileContents, - renderRange - ); - } - ); + file.__addEditorHook((fileContainer, fileContents) => { + this.#initialize(fileContainer, fileContents); + }); this.#file = file; this.#onChange = onChange; return this.cleanUp.bind(this); @@ -112,15 +107,8 @@ export class Editor { this.#reservedSelections = undefined; } - #initialize( - fileRenderer: FileRenderer, - fileContainer: HTMLElement, - fileContents: FileContents, - renderRange: RenderRange | undefined - ): void { - console.log('[editor] initialize, renderRange:', renderRange); - - this.#fileRenderer ??= fileRenderer; + #initialize(fileContainer: HTMLElement, fileContents: FileContents): void { + console.log('[editor] initialize'); if ( this.#fileContents === undefined || @@ -133,7 +121,6 @@ export class Editor { fileContents.lang ); } - this.#renderRange = renderRange; const shadowRoot = fileContainer.shadowRoot ?? fileContainer.attachShadow({ mode: 'open' }); @@ -168,12 +155,17 @@ export class Editor { this.#textareaEl !== undefined && this.#textareaSnapshot !== undefined ) { - const { selectionStart } = this.#textareaEl; + const { selectionStart, selectionEnd, selectionDirection } = + this.#textareaEl; if ( - this.#textareSelectionStart !== selectionStart && + (this.#textareaSelectionStart !== selectionStart || + this.#textareaSelectionEnd !== selectionEnd || + this.#textareaSelectionDirection !== selectionDirection) && this.#textareaSnapshot.text === this.#textareaEl.value ) { - this.#textareSelectionStart = selectionStart; + this.#textareaSelectionStart = selectionStart; + this.#textareaSelectionEnd = selectionEnd; + this.#textareaSelectionDirection = selectionDirection; this.#syncTextareaState(); return; } @@ -394,20 +386,38 @@ export class Editor { } const { selectionStart, selectionEnd, value } = textareaEl; if (value !== textareaSnapshot.text) { + // Text in the textarea has been changed. const change = resolveTextChange(textareaSnapshot, value); this.#applyTextChange(change); - } else if ( - selectionStart === selectionEnd && - this.#selections !== undefined - ) { - // caret in the textarea has been moved, but no text change has been made. - this.#setSelections( - mapSelectionMove( - textDocument, - this.#selections, - textDocument.positionAt(textareaSnapshot.offset + selectionStart) - ) - ); + } else if (this.#selections !== undefined) { + // Selection in the textarea changed, but no text change was made. + if (selectionStart === selectionEnd) { + this.#setSelections( + mapSelectionMove( + textDocument, + this.#selections, + textDocument.positionAt(textareaSnapshot.offset + selectionStart) + ) + ); + } else { + const isBackward = + getSelectionDirectionFromTextarea(textareaEl) === + SelectionDirection.Backward; + const anchorOffset = + textareaSnapshot.offset + + (isBackward ? selectionEnd : selectionStart); + const focusOffset = + textareaSnapshot.offset + + (isBackward ? selectionStart : selectionEnd); + this.#setSelections( + mapSelectionRangeMove( + textDocument, + this.#selections, + textDocument.positionAt(anchorOffset), + textDocument.positionAt(focusOffset) + ) + ); + } } } @@ -456,15 +466,20 @@ export class Editor { textDocument, primarySelection ); - this.#shouldIgnoreSelectionChange = true; - this.#textareSelectionStart = textareaSnapshot.selectionStart; + const textareaSelectionDirection = + getTextareaSelectionDirection(primarySelection); + this.#textareaSelectionStart = textareaSnapshot.selectionStart; + this.#textareaSelectionEnd = textareaSnapshot.selectionEnd; + this.#textareaSelectionDirection = textareaSelectionDirection; this.#textareaSnapshot = textareaSnapshot; + this.#shouldIgnoreSelectionChange = true; textareaEl.style.top = this.#getLineY(primarySelection.start.line) + 'px'; textareaEl.style.height = textareaSnapshot.lines + 'lh'; textareaEl.value = textareaSnapshot.text; textareaEl.setSelectionRange( textareaSnapshot.selectionStart, - textareaSnapshot.selectionEnd + textareaSnapshot.selectionEnd, + textareaSelectionDirection ); setTimeout(() => { this.#shouldIgnoreSelectionChange = false; @@ -544,13 +559,13 @@ export class Editor { } rangeEl ??= createElement('div', { - dataset: 'selectionRange', - style: { - top: this.#getLineY(ln) + 'px', - left: left + 'px', - width: width + 'px', - }, - }); + dataset: 'selectionRange', + style: { + top: this.#getLineY(ln) + 'px', + left: left + 'px', + width: width + 'px', + }, + }); } this.#contentEl?.append(rangeEl); @@ -891,6 +906,27 @@ export class Editor { } } +function getSelectionDirectionFromTextarea( + textareaEl: HTMLTextAreaElement +): SelectionDirection { + return textareaEl.selectionDirection === 'backward' + ? SelectionDirection.Backward + : SelectionDirection.Forward; +} + +function getTextareaSelectionDirection( + selection: EditorSelection +): HTMLTextAreaElement['selectionDirection'] { + switch (selection.direction) { + case SelectionDirection.Backward: + return 'backward'; + case SelectionDirection.Forward: + return 'forward'; + case SelectionDirection.None: + return 'none'; + } +} + export function edit(file: File): void { const editor = new Editor(); editor.edit(file); diff --git a/packages/diffs/src/types.ts b/packages/diffs/src/types.ts index 4254ee117..63e18f114 100644 --- a/packages/diffs/src/types.ts +++ b/packages/diffs/src/types.ts @@ -12,8 +12,6 @@ import type { ThemeRegistrationResolved, } from 'shiki'; -import type { FileRenderer } from './renderers/FileRenderer'; - export type { CreatePatchOptionsNonabortable }; /** @@ -647,13 +645,8 @@ export interface RenderFileResult { options: RenderFileOptions; } -export interface EditorHook { - ( - fileRenderer: FileRenderer, - fileContainer: HTMLElement, - file: FileContents, - renderRange: RenderRange | undefined - ): void; +export interface EditorHook { + (fileContainer: HTMLElement, file: FileContents): void; } export interface RenderDiffResult { diff --git a/packages/diffs/test/editorMultiSelections.test.ts b/packages/diffs/test/editorMultiSelections.test.ts index 6579fcabe..489362258 100644 --- a/packages/diffs/test/editorMultiSelections.test.ts +++ b/packages/diffs/test/editorMultiSelections.test.ts @@ -4,6 +4,7 @@ import { applySelectionTextChange, applySelectionTextReplace, mapSelectionMove, + mapSelectionRangeMove, } from '../src/editor/editorMultiSelections'; import type { EditorSelection } from '../src/editor/editorSelection'; import { SelectionDirection } from '../src/editor/editorSelection'; @@ -141,6 +142,48 @@ describe('mapSelectionMove', () => { }); }); +describe('mapSelectionRangeMove', () => { + test('extends all carets when the primary textarea selection becomes a range', () => { + const textDocument = new TextDocument('inmemory://1', 'abcd\nefgh'); + const selections = [ + createSelection(0, 1, 0, 1), + createSelection(1, 1, 1, 1), + ]; + + expect( + mapSelectionRangeMove( + textDocument, + selections, + { line: 1, character: 1 }, + { line: 1, character: 3 } + ) + ).toEqual([ + createSelection(0, 1, 0, 3, SelectionDirection.Forward), + createSelection(1, 1, 1, 3, SelectionDirection.Forward), + ]); + }); + + test('preserves backward selection direction from the textarea focus', () => { + const textDocument = new TextDocument('inmemory://1', 'abcd\nefgh'); + const selections = [ + createSelection(0, 2, 0, 2), + createSelection(1, 2, 1, 2), + ]; + + expect( + mapSelectionRangeMove( + textDocument, + selections, + { line: 1, character: 2 }, + { line: 1, character: 0 } + ) + ).toEqual([ + createSelection(0, 0, 0, 2, SelectionDirection.Backward), + createSelection(1, 0, 1, 2, SelectionDirection.Backward), + ]); + }); +}); + describe('mapSelectionTextReplace', () => { test('replaces each selection with its own pasted text', () => { const textDocument = new TextDocument('inmemory://1', 'x\ny\nz'); From d08abe397f945dbf4bccb47f40c373195ddfc397 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Wed, 29 Apr 2026 00:23:30 +0800 Subject: [PATCH 027/138] Improve rerender performance --- packages/diffs/src/components/File.ts | 6 +- packages/diffs/src/editor/constants.ts | 3 + packages/diffs/src/editor/editHistory.ts | 42 +- .../diffs/src/editor/editorMultiSelections.ts | 17 +- packages/diffs/src/editor/editorSelection.ts | 6 - ...rTextareaSnapshot.ts => editorTextarea.ts} | 56 +- packages/diffs/src/editor/index.ts | 542 +++++++++++++----- packages/diffs/src/editor/textDocument.ts | 278 ++++++--- packages/diffs/src/types.ts | 6 +- packages/diffs/test/editHistory.test.ts | 44 +- .../diffs/test/editorMultiSelections.test.ts | 104 +++- .../diffs/test/editorTextareaSnapshot.test.ts | 36 +- packages/diffs/test/textDocument.test.ts | 79 ++- 13 files changed, 843 insertions(+), 376 deletions(-) rename packages/diffs/src/editor/{editorTextareaSnapshot.ts => editorTextarea.ts} (57%) diff --git a/packages/diffs/src/components/File.ts b/packages/diffs/src/components/File.ts index a2c4cdefe..256620adb 100644 --- a/packages/diffs/src/components/File.ts +++ b/packages/diffs/src/components/File.ts @@ -177,7 +177,7 @@ export class File { public __addEditorHook(hook: EditorHook): void { if (this.fileContainer != null && this.file != null) { - hook(this.fileContainer, this.file); + hook(this.fileContainer, this.file, this.renderRange); } this.__editorHook = hook; } @@ -473,7 +473,7 @@ export class File { if (!preventEmit) { this.emitPostRender(); } - this.__editorHook?.(fileContainer, file); + this.__editorHook?.(fileContainer, file, renderRange); return true; } @@ -524,7 +524,7 @@ export class File { if (!preventEmit) { this.emitPostRender(); } - this.__editorHook?.(fileContainer, file); + this.__editorHook?.(fileContainer, file, renderRange); return true; } diff --git a/packages/diffs/src/editor/constants.ts b/packages/diffs/src/editor/constants.ts index e01e5cceb..2b092e14c 100644 --- a/packages/diffs/src/editor/constants.ts +++ b/packages/diffs/src/editor/constants.ts @@ -1,3 +1,6 @@ +export const TOKENIZE_TIME_LIMIT = 500; +export const TOKENIZE_MAX_LINE_LENGTH = 1000; + export const EDITOR_CSS = /* CSS */ ` ::selection { background-color: transparent; diff --git a/packages/diffs/src/editor/editHistory.ts b/packages/diffs/src/editor/editHistory.ts index 378e4ea82..7e1ebfb48 100644 --- a/packages/diffs/src/editor/editHistory.ts +++ b/packages/diffs/src/editor/editHistory.ts @@ -1,10 +1,11 @@ -import { type EditorSelection, type EditorTextChange } from './editorSelection'; +import type { EditorSelection } from './editorSelection'; +import type { ResolvedTextEdit } from './textDocument'; export type HistoryEntry = { /** Forward offset edits from the entry's base text to its final text. */ - forwardEdits: EditorTextChange[]; + forwardEdits: ResolvedTextEdit[]; /** Inverse offset edits from the entry's final text back to its base text. */ - inverseEdits: EditorTextChange[]; + inverseEdits: ResolvedTextEdit[]; /** Base text length before the entry is applied. */ textLengthBefore: number; /** Final text length after the entry is applied. */ @@ -34,7 +35,7 @@ export class EditHistory { push( textBefore: string, - resolvedEdits: EditorTextChange[], + resolvedEdits: ResolvedTextEdit[], selectionsBefore: EditorSelection[], selectionsAfter?: EditorSelection[] ): void { @@ -88,42 +89,21 @@ export class EditHistory { } } -export function applyOffsetEdits( - base: string, - edits: EditorTextChange[] -): string { - const sortedEdits = [...edits].sort((a, b) => b.start - a.start); - for (let i = 0; i < sortedEdits.length - 1; i++) { - if (sortedEdits[i + 1].end > sortedEdits[i].start) { - throw new Error('Overlapping text edits are not supported'); - } - } - let text = base; - for (const { start, end, text: insert } of sortedEdits) { - text = text.slice(0, start) + insert + text.slice(end); - } - return text; -} - export function buildInverseOffsetEdits( textBefore: string, - ascending: EditorTextChange[] -): EditorTextChange[] { - const inverse: EditorTextChange[] = []; - for (let i = 0; i < ascending.length; i++) { + ascending: ResolvedTextEdit[] +): ResolvedTextEdit[] { + const inverse: ResolvedTextEdit[] = []; + for (let i = 0, offsetDelta = 0; i < ascending.length; i++) { const edit = ascending[i]; const replacedText = textBefore.slice(edit.start, edit.end); - let startAfterEdit = edit.start; - for (let j = 0; j < i; j++) { - const previousEdit = ascending[j]; - startAfterEdit += - previousEdit.text.length - (previousEdit.end - previousEdit.start); - } + const startAfterEdit = edit.start + offsetDelta; inverse.push({ start: startAfterEdit, end: startAfterEdit + edit.text.length, text: replacedText, }); + offsetDelta += edit.text.length - (edit.end - edit.start); } return inverse; } diff --git a/packages/diffs/src/editor/editorMultiSelections.ts b/packages/diffs/src/editor/editorMultiSelections.ts index c77d073db..dec6d5673 100644 --- a/packages/diffs/src/editor/editorMultiSelections.ts +++ b/packages/diffs/src/editor/editorMultiSelections.ts @@ -1,9 +1,10 @@ +import { type EditorSelection, SelectionDirection } from './editorSelection'; import { - type EditorSelection, - type EditorTextChange, - SelectionDirection, -} from './editorSelection'; -import { type Position, TextDocument, type TextEdit } from './textDocument'; + type Position, + type ResolvedTextEdit, + TextDocument, + type TextEdit, +} from './textDocument'; export function mapSelectionMove( textDocument: TextDocument, @@ -73,10 +74,10 @@ export function mapSelectionRangeMove( }); } -export function applySelectionTextChange( +export function applyTextChangeToSelections( textDocument: TextDocument, selections: EditorSelection[], - change: EditorTextChange + change: ResolvedTextEdit ): EditorSelection[] { const primarySelection = selections[selections.length - 1]; if (primarySelection === undefined) { @@ -162,7 +163,7 @@ export function applySelectionTextChange( return nextSelections; } -export function applySelectionTextReplace( +export function applyTextReplaceToSelections( textDocument: TextDocument, selections: EditorSelection[], texts: readonly string[] diff --git a/packages/diffs/src/editor/editorSelection.ts b/packages/diffs/src/editor/editorSelection.ts index 601eb734c..4211fbb95 100644 --- a/packages/diffs/src/editor/editorSelection.ts +++ b/packages/diffs/src/editor/editorSelection.ts @@ -11,12 +11,6 @@ export type EditorSelection = Range & { direction: SelectionDirection; }; -export type EditorTextChange = { - start: number; - end: number; - text: string; -}; - /** * Converts a selection from a web selection to an editor selection. */ diff --git a/packages/diffs/src/editor/editorTextareaSnapshot.ts b/packages/diffs/src/editor/editorTextarea.ts similarity index 57% rename from packages/diffs/src/editor/editorTextareaSnapshot.ts rename to packages/diffs/src/editor/editorTextarea.ts index 70695ef52..f71f63ea1 100644 --- a/packages/diffs/src/editor/editorTextareaSnapshot.ts +++ b/packages/diffs/src/editor/editorTextarea.ts @@ -1,5 +1,5 @@ -import { type EditorSelection, type EditorTextChange } from './editorSelection'; -import type { TextDocument } from './textDocument'; +import { type EditorSelection, SelectionDirection } from './editorSelection'; +import type { ResolvedTextEdit, TextDocument } from './textDocument'; export interface TextareaSnapshot { readonly startLine: number; @@ -52,13 +52,38 @@ export function createTextareaSnapshot( }; } -export function resolveTextChange( +export function resolveTextareaChange( textareaSnapshot: TextareaSnapshot, - newView: string -): EditorTextChange { + newView: string, + selectionStart?: number, + selectionEnd?: number +): ResolvedTextEdit { const original = textareaSnapshot.text; const originalLength = original.length; const nextLength = newView.length; + if ( + selectionStart !== undefined && + selectionEnd !== undefined && + selectionStart === selectionEnd && + textareaSnapshot.selectionStart === textareaSnapshot.selectionEnd + ) { + const lengthDelta = nextLength - originalLength; + const start = selectionStart - Math.max(lengthDelta, 0); + const end = start + Math.max(-lengthDelta, 0); + const text = newView.slice(start, selectionStart); + if ( + lengthDelta !== 0 && + start >= 0 && + end <= originalLength && + original.slice(0, start) + text + original.slice(end) === newView + ) { + return { + start: textareaSnapshot.offset + start, + end: textareaSnapshot.offset + end, + text, + }; + } + } let prefix = 0; while ( @@ -87,3 +112,24 @@ export function resolveTextChange( text: newView.slice(prefix, nextLength - suffix), }; } + +export function getSelectionDirectionFromTextarea( + textareaEl: HTMLTextAreaElement +): SelectionDirection { + return textareaEl.selectionDirection === 'backward' + ? SelectionDirection.Backward + : SelectionDirection.Forward; +} + +export function getTextareaSelectionDirection( + selection: EditorSelection +): HTMLTextAreaElement['selectionDirection'] { + switch (selection.direction) { + case SelectionDirection.Backward: + return 'backward'; + case SelectionDirection.Forward: + return 'forward'; + case SelectionDirection.None: + return 'none'; + } +} diff --git a/packages/diffs/src/editor/index.ts b/packages/diffs/src/editor/index.ts index 451362b64..5846935fa 100644 --- a/packages/diffs/src/editor/index.ts +++ b/packages/diffs/src/editor/index.ts @@ -1,4 +1,11 @@ -import { areThemesAttached, DEFAULT_THEMES, getHighlighterIfLoaded } from '..'; +import { EncodedTokenMetadata, INITIAL, type StateStack } from 'shiki/textmate'; + +import { + areThemesAttached, + DEFAULT_THEMES, + getFiletypeFromFileName, + getHighlighterIfLoaded, +} from '..'; import type { File } from '../components/File'; import { type EditorCommand, @@ -6,15 +13,12 @@ import { resolveEditorCommandFromKeyboardEvent, } from '../editor/editorCommand'; import { - applySelectionTextChange, - applySelectionTextReplace, + applyTextChangeToSelections, + applyTextReplaceToSelections, mapSelectionMove, mapSelectionRangeMove, } from '../editor/editorMultiSelections'; -import type { - EditorSelection, - EditorTextChange, -} from '../editor/editorSelection'; +import type { EditorSelection } from '../editor/editorSelection'; import { comparePosition, convertSelection, @@ -24,11 +28,6 @@ import { SelectionDirection, selectionIntersects, } from '../editor/editorSelection'; -import { - createTextareaSnapshot, - resolveTextChange, - type TextareaSnapshot, -} from '../editor/editorTextareaSnapshot'; import { addEventListener, createElement, @@ -36,18 +35,39 @@ import { getLineIndentationUnit, isCodeLineTarget, } from '../editor/editorUtils'; -import { TextDocument, type TextEdit } from '../editor/textDocument'; -import type { FileContents, RenderRange } from '../types'; -import { renderFileWithHighlighter } from '../utils/renderFileWithHighlighter'; -import { EDITOR_CSS } from './constants'; +import { + type ResolvedTextEdit, + TextDocument, + type TextEdit, +} from '../editor/textDocument'; +import type { DiffsHighlighter, FileContents, RenderRange } from '../types'; +import { + EDITOR_CSS, + TOKENIZE_MAX_LINE_LENGTH, + TOKENIZE_TIME_LIMIT, +} from './constants'; +import { + createTextareaSnapshot, + getSelectionDirectionFromTextarea, + getTextareaSelectionDirection, + resolveTextareaChange, + type TextareaSnapshot, +} from './editorTextarea'; export class Editor { + #disposes?: (() => void)[]; + #file?: File; - #renderRange?: RenderRange; #fileContents?: FileContents; #textDocument?: TextDocument; + #textLinesCache?: string[]; + #renderRange?: RenderRange; #onChange?: (file: FileContents) => void; + #highlighter?: DiffsHighlighter; + #colorMap?: Map; + #stateStackCache?: StateStack[]; + // dom elements #contentEl?: HTMLElement; #styleEl?: HTMLStyleElement; @@ -66,23 +86,22 @@ export class Editor { 'none'; #shouldIgnoreSelectionChange = false; #textareaSnapshot?: TextareaSnapshot; - #selections?: EditorSelection[]; #reservedSelections?: EditorSelection[]; - - #disposes?: (() => void)[]; - - get text(): string | undefined { - return this.#textDocument?.getText(); - } + #selections?: EditorSelection[]; edit( file: File, onChange?: (file: FileContents) => void ): () => void { - file.__addEditorHook((fileContainer, fileContents) => { - this.#initialize(fileContainer, fileContents); + file.__addEditorHook((fileContainer, fileContents, renderRange) => { + this.#initialize(fileContainer, fileContents, renderRange); }); this.#file = file; + this.#highlighter ??= areThemesAttached( + file.options.theme ?? DEFAULT_THEMES + ) + ? getHighlighterIfLoaded() + : undefined; this.#onChange = onChange; return this.cleanUp.bind(this); } @@ -90,7 +109,17 @@ export class Editor { cleanUp(): void { this.#disposes?.forEach((dispose) => dispose()); this.#disposes = undefined; + + this.#file = undefined; + this.#fileContents = undefined; this.#textDocument = undefined; + this.#textLinesCache = undefined; + this.#renderRange = undefined; + this.#onChange = undefined; + + this.#highlighter = undefined; + this.#colorMap = undefined; + this.#stateStackCache = undefined; this.#contentEl = undefined; this.#styleEl?.remove(); @@ -107,27 +136,42 @@ export class Editor { this.#reservedSelections = undefined; } - #initialize(fileContainer: HTMLElement, fileContents: FileContents): void { - console.log('[editor] initialize'); + #initialize( + fileContainer: HTMLElement, + fileContents: FileContents, + renderRange: RenderRange | undefined + ): void { + console.log('Editor initialized, renderRange:', renderRange); if ( + this.#textDocument === undefined || this.#fileContents === undefined || - this.#fileContents.contents !== fileContents.contents + this.#fileContents.contents !== fileContents.contents || + this.#fileContents.lang !== fileContents.lang ) { this.#fileContents = fileContents; this.#textDocument = new TextDocument( fileContents.name, fileContents.contents, - fileContents.lang + fileContents.lang ?? getFiletypeFromFileName(fileContents.name) ); + this.#textLinesCache = this.#textDocument.lines; + this.#stateStackCache = undefined; + this.#selections = undefined; } + this.#renderRange = renderRange; + setTimeout(() => { + this.#prebuildStateStackCache(); + }, 500); + const shadowRoot = fileContainer.shadowRoot ?? fileContainer.attachShadow({ mode: 'open' }); this.#contentEl = shadowRoot.querySelector('[data-content]') ?? undefined; if (this.#contentEl === undefined) { throw new Error('could not edit the file.'); } + this.#textareaEl ??= extend( createElement('textarea', { dataset: 'textarea' }), { @@ -139,11 +183,13 @@ export class Editor { } ); this.#contentEl.appendChild(this.#textareaEl); + this.#styleEl ??= createElement( 'style', { dataset: 'editorCss', textContent: EDITOR_CSS }, shadowRoot ); + this.#disposes ??= [ addEventListener(document, 'selectionchange', () => { if (this.#shouldIgnoreSelectionChange) { @@ -189,7 +235,7 @@ export class Editor { if (selection !== null) { const reservedSelections = this.#reservedSelections; if (reservedSelections !== undefined) { - this.#setSelections([ + this.#renderSelections([ ...reservedSelections.filter( (reservedSelection) => !selectionIntersects(reservedSelection, selection) @@ -197,7 +243,7 @@ export class Editor { selection, ]); } else { - this.#setSelections([selection]); + this.#renderSelections([selection]); } } }), @@ -256,8 +302,11 @@ export class Editor { this.#syncTextareaState(); }), ]; + if (this.#selections !== undefined) { - this.#setSelections(this.#selections); + this.#selectionEls?.forEach((el) => el.remove()); + this.#selectionEls?.clear(); + this.#renderSelections(this.#selections); this.#textareaEl.focus(); } } @@ -281,96 +330,284 @@ export class Editor { } #rerender(textDocument: TextDocument, nextSelections?: EditorSelection[]) { - if (this.#fileContents === undefined || this.#file === undefined) { + const file = this.#file; + const fileContents = this.#fileContents; + const contentEl = this.#contentEl; + if ( + file === undefined || + fileContents === undefined || + contentEl === undefined + ) { return; } - this.#fileContents.contents = textDocument.getText(); - this.#onChange?.({ ...this.#fileContents }); + if (this.#highlighter !== undefined) { + const t = performance.now(); + const { theme = DEFAULT_THEMES } = file.options; - const highlighter = areThemesAttached( - this.#file.options.theme ?? DEFAULT_THEMES - ) - ? getHighlighterIfLoaded() - : undefined; - if (highlighter !== undefined) { - let t = performance.now(); - const { theme = DEFAULT_THEMES, tokenizeMaxLineLength = 1000 } = - this.#file.options; - const { startingLine = 0, totalLines = textDocument.lineCount } = + const prevLines = this.#textLinesCache ?? []; + const { startingLine = 0, totalLines = Infinity } = this.#renderRange ?? {}; - const text = textDocument.getText({ - start: { line: startingLine, character: 0 }, - end: { line: startingLine + totalLines, character: 0 }, - }); - const result = renderFileWithHighlighter( - { ...this.#fileContents, contents: text }, - highlighter, - { - theme, - tokenizeMaxLineLength, - useTokenTransformer: true, // get `data-char` on token span + const endLine = + totalLines === Infinity + ? textDocument.lineCount + : Math.min(startingLine + totalLines, textDocument.lineCount); + const prevEndLine = + totalLines === Infinity + ? prevLines.length + : Math.min(startingLine + totalLines, prevLines.length); + const compareEndLine = Math.max(endLine, prevEndLine); + const dirtyLines = new Set(); + const linesChange = textDocument.lineCount - prevLines.length; + let dirtyLineStart = -1; + let dirtyLineEnd = -1; + for (let line = startingLine; line < compareEndLine; line++) { + const prevLine = line < prevLines.length ? prevLines[line] : undefined; + const nextLine = + line < textDocument.lineCount + ? textDocument.getLineText(line, false) + : undefined; + if (prevLine !== nextLine) { + if (dirtyLineStart === -1) { + dirtyLineStart = line; + } + dirtyLineEnd = line; + if (line < endLine) { + dirtyLines.add(line); + } } - ); - console.log( - '[editor] renderFileWithHighlighter time:', - performance.now() - t - ); + } + for (let line = endLine; line < prevEndLine; line++) { + this.#getLineElement(line)?.remove(); + } + + let themeName: string; + let themeType = file.options.themeType ?? 'system'; + if (themeType === 'system') { + themeType = window.matchMedia('(prefers-color-scheme: dark)').matches + ? 'dark' + : 'light'; + } + if (typeof theme === 'string') { + themeName = theme; + } else { + themeName = theme[themeType]; + } + let colorMap = this.#colorMap?.get(themeName); + if (colorMap === undefined) { + const ret = this.#highlighter.setTheme(themeName); + colorMap = ret.colorMap; + (this.#colorMap ?? (this.#colorMap = new Map())).set( + themeName, + ret.colorMap ?? [] + ); + } + + const grammar = this.#highlighter.getLanguage(textDocument.languageId); + const previousStateStackCache = this.#stateStackCache; + if (dirtyLineStart !== -1) { + this.#stateStackCache = previousStateStackCache?.slice( + 0, + dirtyLineStart + 1 + ); + } - const lineElMap = new Map(); - for (const child of this.#contentEl?.children ?? []) { - const divEl = child as HTMLDivElement; - if (divEl.dataset.lineIndex === undefined) { + const updateLineEl = (line: number, children: Element[]) => { + const lineEl = createElement('div', { + dataset: { + line: String(line + 1), + lineIndex: String(line), + lineType: 'context', + }, + }); + lineEl.replaceChildren(...children); + const prevLineEl = contentEl.querySelector( + `[data-line-index="${line}"]` + ); + if (prevLineEl !== null) { + prevLineEl.replaceWith(lineEl); + } else { + contentEl.insertBefore(lineEl, this.#textareaEl ?? null); + } + }; + + let state = + dirtyLineStart === -1 + ? INITIAL + : this.#buildStateStackCache(textDocument, grammar, dirtyLineStart); + for (let line = dirtyLineStart; line >= 0 && line < endLine; line++) { + const isDirty = dirtyLines.has(line); + const previousState = previousStateStackCache?.[line]; + const didLineStateChange = + previousState !== undefined && !state.equals(previousState); + const shouldUpdateLineEl = + isDirty || + didLineStateChange || + (line > dirtyLineEnd && previousState === undefined); + const lineText = textDocument.getLineText(line); + this.#stateStackCache ??= [INITIAL]; + this.#stateStackCache[line] = state; + + if (lineText.length > TOKENIZE_MAX_LINE_LENGTH) { + if (shouldUpdateLineEl) { + console.warn( + `[diffs] Line(${line}) too long to tokenize: ${lineText.length}` + ); + updateLineEl(line, [ + createElement('span', { textContent: lineText }), + ]); + } + this.#stateStackCache[line + 1] = state; + if ( + line >= dirtyLineEnd && + this.#isStateStackCacheSettled(previousStateStackCache, line, state) + ) { + break; + } continue; } - lineElMap.set(Number(divEl.dataset.lineIndex), divEl); - } - for (const line of result.code) { - if (line.type === 'element') { - const lineIndex = line.properties['data-line-index']; - if (typeof lineIndex === 'number') { - const oldLineEl = lineElMap.get(lineIndex); - if (oldLineEl !== undefined) { - const newLineEl = createElement( - line.tagName as keyof HTMLElementTagNameMap, - { - dataset: { - line: String(lineIndex + 1), - lineIndex: String(lineIndex), - lineType: String(line.properties['data-line-type']), - }, - } - ); - for (const span of line.children) { - if (span.type === 'element') { - const token = span.children[0]; - createElement( - span.tagName as keyof HTMLElementTagNameMap, - { - dataset: { - char: String(span.properties['data-char']), - }, - style: { - cssText: span.properties['style'] as string | undefined, - }, - textContent: - token.type === 'text' ? token.value : undefined, - }, - newLineEl - ); - } - } - oldLineEl.replaceWith(newLineEl); + if (lineText === '' || lineText.trim() === '') { + if (shouldUpdateLineEl) { + updateLineEl(line, [ + createElement('span', { + textContent: lineText === '' ? ' ' : lineText, + }), + ]); + } + this.#stateStackCache[line + 1] = state; + if ( + line >= dirtyLineEnd && + this.#isStateStackCacheSettled(previousStateStackCache, line, state) + ) { + break; + } + continue; + } + + // even the line is NOT dirty, we still need to tokenize it to get the new state + const result = grammar.tokenizeLine2( + lineText, + state, + TOKENIZE_TIME_LIMIT + ); + if (result.stoppedEarly) { + console.warn( + `[diffs] Time limit reached when tokenizing line: ${lineText.substring(0, 100)}` + ); + } + if (shouldUpdateLineEl) { + const tokens = result.tokens; + const lineLength = lineText.length; + const tokensLength = tokens.length / 2; + const spans: Element[] = []; + for (let j = 0; j < tokensLength; j++) { + const offset = tokens[2 * j]; + const nextOffset = + j + 1 < tokensLength ? tokens[2 * j + 2] : lineLength; + if (offset === nextOffset) { + // empty token ? + continue; } + const metadata = tokens[2 * j + 1]; + const fg = colorMap[EncodedTokenMetadata.getForeground(metadata)]; + const tokenText = lineText.slice(offset, nextOffset); + spans.push( + createElement('span', { + dataset: { char: String(offset) }, + style: { cssText: `--diffs-token-${themeType}:${fg}` }, + textContent: tokenText, + }) + ); } + updateLineEl(line, spans); + } + state = result.ruleStack; + this.#stateStackCache[line + 1] = state; + if ( + line >= dirtyLineEnd && + this.#isStateStackCacheSettled(previousStateStackCache, line, state) + ) { + break; } } + console.log( + `[diffs] re-render time: ${Math.round((performance.now() - t) * 1000) / 1000}ms`, + 'dirtyLines:', + dirtyLines.size, + 'linesChange:', + linesChange + ); + if (nextSelections !== undefined) { - this.#setSelections(nextSelections); + this.#renderSelections(nextSelections); + } + } + + this.#textLinesCache = textDocument.lines; + if (this.#onChange !== undefined) { + this.#onChange({ ...fileContents, contents: textDocument.getText() }); + } + } + + #buildStateStackCache( + textDocument: TextDocument, + grammar: ReturnType, + endLine: number + ): StateStack { + const stateStackCache = (this.#stateStackCache ??= [INITIAL]); + const boundedEndLine = Math.min( + Math.max(0, endLine), + textDocument.lineCount + ); + let line = Math.min(stateStackCache.length - 1, boundedEndLine); + let state = stateStackCache[line] ?? INITIAL; + for (; line < boundedEndLine; line++) { + stateStackCache[line] = state; + const lineText = textDocument.getLineText(line); + if ( + lineText.length <= TOKENIZE_MAX_LINE_LENGTH && + lineText !== '' && + lineText.trim() !== '' + ) { + state = grammar.tokenizeLine2( + lineText, + state, + TOKENIZE_TIME_LIMIT + ).ruleStack; } + stateStackCache[line + 1] = state; + } + return stateStackCache[boundedEndLine] ?? INITIAL; + } + + #isStateStackCacheSettled( + previousStateStackCache: StateStack[] | undefined, + line: number, + state: StateStack + ) { + const previousNextState = previousStateStackCache?.[line + 1]; + return previousNextState !== undefined && state.equals(previousNextState); + } + + #prebuildStateStackCache() { + const textDocument = this.#textDocument; + if (textDocument === undefined) { + return; + } + const { startingLine = 0, totalLines = Infinity } = this.#renderRange ?? {}; + const endLine = Math.min( + totalLines === Infinity ? Infinity : startingLine + totalLines, + textDocument.lineCount + ); + + const grammar = this.#highlighter?.getLanguage(textDocument.languageId); + if (grammar === undefined) { + return; } + + this.#buildStateStackCache(textDocument, grammar, endLine); } #syncTextareaState() { @@ -387,12 +624,17 @@ export class Editor { const { selectionStart, selectionEnd, value } = textareaEl; if (value !== textareaSnapshot.text) { // Text in the textarea has been changed. - const change = resolveTextChange(textareaSnapshot, value); + const change = resolveTextareaChange( + textareaSnapshot, + value, + selectionStart, + selectionEnd + ); this.#applyTextChange(change); } else if (this.#selections !== undefined) { // Selection in the textarea changed, but no text change was made. if (selectionStart === selectionEnd) { - this.#setSelections( + this.#renderSelections( mapSelectionMove( textDocument, this.#selections, @@ -409,7 +651,7 @@ export class Editor { const focusOffset = textareaSnapshot.offset + (isBackward ? selectionStart : selectionEnd); - this.#setSelections( + this.#renderSelections( mapSelectionRangeMove( textDocument, this.#selections, @@ -421,18 +663,18 @@ export class Editor { } } - #applyTextChange(change: EditorTextChange) { + #applyTextChange(change: ResolvedTextEdit) { if (this.#textDocument !== undefined && this.#selections !== undefined) { - const newSelections = applySelectionTextChange( + const nextSelections = applyTextChangeToSelections( this.#textDocument, this.#selections, change ); - this.#rerender(this.#textDocument, newSelections); + this.#rerender(this.#textDocument, nextSelections); } } - #setSelections(selections: EditorSelection[]) { + #renderSelections(selections: EditorSelection[]) { const primarySelection = getPrimarySelection(selections); if (primarySelection === undefined) { return; @@ -486,10 +728,27 @@ export class Editor { }, 0); } + // Check whether a selection overlaps the currently rendered line window. + #isSelectionVisible(selection: EditorSelection): boolean { + if (this.#renderRange === undefined) { + return true; + } + const { start, end } = selection; + const { startingLine, totalLines } = this.#renderRange; + if (totalLines === Infinity) { + return end.line >= startingLine; + } + const endLine = startingLine + totalLines; + return start.line < endLine && end.line >= startingLine; + } + #renderLineHighlight( selection: EditorSelection, markMap: Map ) { + if (!this.#isSelectionVisible(selection)) { + return; + } const hlEl = createElement( 'div', { @@ -500,6 +759,7 @@ export class Editor { }, this.#contentEl ); + this.#file?.setSelectedLines({ start: selection.start.line + 1, end: selection.end.line + 1, @@ -513,6 +773,10 @@ export class Editor { ch: number, markMap: Map ) { + if (!this.#isSelectionVisible(selection)) { + return; + } + const selectionEls = this.#selectionEls; const { start, end } = selection; @@ -526,20 +790,19 @@ export class Editor { const lineLength = lineText.length; const startChar = ln === start.line ? start.character : 0; const endChar = ln === end.line ? end.character : lineLength; - const spacing = ln === end.line ? 0 : ch; + const spacing = ln === end.line || startChar === endChar ? 0 : ch; const cacheKey = `selection-${ln}-${startChar}-${endChar}`; - let left = 0; - let width = spacing; let rangeEl: HTMLElement | undefined; - if (selectionEls?.has(cacheKey) === true) { - console.log('use cached selection range', cacheKey); rangeEl = selectionEls.get(cacheKey)!; selectionEls.delete(cacheKey); } else { + let left = 0; + let width = 0; if (startChar === endChar && startChar === 0) { left = ch; + width = ch; } else { const startX = this.#getCharacterX(ln, startChar); const endX = @@ -553,7 +816,7 @@ export class Editor { rangeEl = el; selectionEls?.delete(key); el.style.left = left + 'px'; - el.style.width = width + 'px'; + el.style.width = width + spacing + 'px'; break; } } @@ -563,7 +826,7 @@ export class Editor { style: { top: this.#getLineY(ln) + 'px', left: left + 'px', - width: width + 'px', + width: width + spacing + 'px', }, }); } @@ -578,6 +841,10 @@ export class Editor { ch: number, markMap: Map ) { + if (!this.#isSelectionVisible(selection)) { + return; + } + const { start, end, direction } = selection; const isBackward = direction === SelectionDirection.Backward; const line = isBackward ? start.line : end.line; @@ -600,7 +867,7 @@ export class Editor { async #runCommand(command: EditorCommand) { switch (command) { case 'selectAll': - this.#setSelections([this.#getFullSelection()]); + this.#renderSelections([this.#getFullSelection()]); break; case 'copy': @@ -678,22 +945,22 @@ export class Editor { case 'documentStart': case 'documentEnd': - this.#setSelections([ + this.#renderSelections([ this.#getDocumentBoundarySelection(command === 'documentEnd'), ]); break; case 'undo': if (this.#textDocument?.canUndo === true) { - const undoSelections = this.#textDocument.undo(); - this.#rerender(this.#textDocument, undoSelections); + const nextSelections = this.#textDocument.undo(); + this.#rerender(this.#textDocument, nextSelections); } break; case 'redo': if (this.#textDocument?.canRedo === true) { - const redoSelections = this.#textDocument.redo(); - this.#rerender(this.#textDocument, redoSelections); + const nextSelections = this.#textDocument.redo(); + this.#rerender(this.#textDocument, nextSelections); } break; } @@ -761,8 +1028,8 @@ export class Editor { ? text.map((value) => value.replace(/\r\n?|\n/g, textDocument.EOF)) : text.replace(/\r\n?|\n/g, textDocument.EOF); const nextSelections = Array.isArray(normalizedText) - ? applySelectionTextReplace(textDocument, selections, normalizedText) - : applySelectionTextChange(textDocument, selections, { + ? applyTextReplaceToSelections(textDocument, selections, normalizedText) + : applyTextChangeToSelections(textDocument, selections, { start: textDocument.offsetAt(selection.start), end: textDocument.offsetAt(selection.end), text: normalizedText, @@ -906,27 +1173,6 @@ export class Editor { } } -function getSelectionDirectionFromTextarea( - textareaEl: HTMLTextAreaElement -): SelectionDirection { - return textareaEl.selectionDirection === 'backward' - ? SelectionDirection.Backward - : SelectionDirection.Forward; -} - -function getTextareaSelectionDirection( - selection: EditorSelection -): HTMLTextAreaElement['selectionDirection'] { - switch (selection.direction) { - case SelectionDirection.Backward: - return 'backward'; - case SelectionDirection.Forward: - return 'forward'; - case SelectionDirection.None: - return 'none'; - } -} - export function edit(file: File): void { const editor = new Editor(); editor.edit(file); diff --git a/packages/diffs/src/editor/textDocument.ts b/packages/diffs/src/editor/textDocument.ts index 19ff52c7d..3bb7a379e 100644 --- a/packages/diffs/src/editor/textDocument.ts +++ b/packages/diffs/src/editor/textDocument.ts @@ -1,5 +1,6 @@ -import { applyOffsetEdits, EditHistory } from './editHistory'; -import { type EditorSelection, type EditorTextChange } from './editorSelection'; +import { splitFileContents } from '../utils/splitFileContents'; +import { EditHistory } from './editHistory'; +import { type EditorSelection } from './editorSelection'; /** * Position in a text document expressed as zero-based line and character offset. @@ -74,19 +75,46 @@ export interface TextEdit { readonly newText: string; } -type LineOffsets = number[] & { - hasCRLF?: boolean; +/** Different with `TextEdit`, the range has been resolved to offsets. */ +export type ResolvedTextEdit = { + /** The start offset of the text change. */ + readonly start: number; + /** The end offset of the text change. */ + readonly end: number; + /** + * The string to be inserted. For delete operations use an + * empty string. + */ + readonly text: string; }; +/** + * A line buffer is a line of text with its offset. + */ +class LineBuffer { + constructor( + public readonly offset: number, + public readonly text: string + ) {} +} + /** * A vscode-languageserver-textdocument compatible text document. */ export class TextDocument { + static trimEOL(text: string): string { + let end = text.length; + while (end > 0 && isEOL(text.charCodeAt(end - 1))) { + end--; + } + return text.slice(0, end); + } + #uri: string; - #text: string; #languageId: string; #version: number; - #lineOffsets: LineOffsets; + #lines: LineBuffer[] = []; + #hasCRLF = false; #history = new EditHistory(); constructor( @@ -96,10 +124,9 @@ export class TextDocument { version = 0 ) { this.#uri = new URL(uri, 'file://').toString(); - this.#text = text; - this.#lineOffsets = computeLineOffsets(text); this.#languageId = languageId; this.#version = version; + this.#setLineBuffers(text, false); } get uri(): string { @@ -115,7 +142,11 @@ export class TextDocument { } get lineCount(): number { - return this.#lineOffsets.length; + return this.#lines.length; + } + + get lines(): string[] { + return this.#lines.map((line) => line.text); } get canUndo(): boolean { @@ -127,30 +158,24 @@ export class TextDocument { } get EOF(): string { - return this.#lineOffsets.hasCRLF === true ? '\r\n' : '\n'; + return this.#hasCRLF ? '\r\n' : '\n'; } getText(range?: Range): string { if (range !== undefined) { const start = this.offsetAt(range.start); const end = this.offsetAt(range.end); - return this.#text.slice(start, end); + return this.#sliceText(start, end); } - return this.#text; + return this.#lines.map((line) => line.text).join(''); } - getLineText(line: number): string | undefined { - if (line < 0 || line >= this.#lineOffsets.length) { - return undefined; + getLineText(line: number, trimEOL = true): string { + if (line < 0 || line >= this.#lines.length) { + throw new Error(`Line index out of range: ${line}`); } - const start = this.#lineOffsets[line]; - const end = this.#lineOffsets[line + 1] ?? this.#text.length; - return this.#text.slice(start, this.#ensureBeforeEOL(end, start)); - } - - setText(text: string): void { - this.#history.clear(); - this.#setDocumentText(text); + const text = this.#lines[line].text; + return trimEOL ? TextDocument.trimEOL(text) : text; } applyEdits( @@ -163,8 +188,7 @@ export class TextDocument { return; } const resolvedEdits = edits.map((edit) => this.#resolveEdit(edit)); - const textBefore = this.#text; - const newText = applyOffsetEdits(textBefore, resolvedEdits); + const textBefore = this.getText(); if (updateHistory && selectionsBefore !== undefined) { this.#history.push( textBefore, @@ -173,7 +197,8 @@ export class TextDocument { selectionsAfter ); } - this.#setDocumentText(newText); + this.#applyResolvedEdits(resolvedEdits); + this.#version++; } setLastUndoSelectionsAfter(selections: EditorSelection[]): void { @@ -185,7 +210,7 @@ export class TextDocument { if (entry === undefined) { return undefined; } - this.#setDocumentText(applyOffsetEdits(this.#text, entry.inverseEdits)); + this.#setDocumentText(applyTextEdits(this.getText(), entry.inverseEdits)); return entry.selectionsBefore !== undefined ? entry.selectionsBefore.map((selection) => ({ ...selection })) : undefined; @@ -196,13 +221,39 @@ export class TextDocument { if (entry === undefined) { return undefined; } - this.#setDocumentText(applyOffsetEdits(this.#text, entry.forwardEdits)); + this.#setDocumentText(applyTextEdits(this.getText(), entry.forwardEdits)); return entry.selectionsAfter !== undefined ? entry.selectionsAfter.map((selection) => ({ ...selection })) : undefined; } - #resolveEdit(edit: TextEdit): EditorTextChange { + positionAt(offset: number): Position { + const documentLength = this.#getDocumentLength(); + const clampedOffset = Math.max(Math.min(offset, documentLength), 0); + const line = this.#lineAtOffset(clampedOffset); + const lineStart = this.#lines[line].offset; + const lineLength = lineLengthWithoutEOL(this.#lines[line].text); + const character = Math.min(clampedOffset - lineStart, lineLength); + return { line, character }; + } + + offsetAt(position: Position): number { + const { line, character } = position; + const documentLength = this.#getDocumentLength(); + if (line >= this.#lines.length) { + return documentLength; + } else if (line < 0) { + return 0; + } + const lineOffset = this.#lines[line].offset; + if (character <= 0) { + return lineOffset; + } + const lineLength = lineLengthWithoutEOL(this.#lines[line].text); + return Math.min(lineOffset + character, lineOffset + lineLength); + } + + #resolveEdit(edit: TextEdit): ResolvedTextEdit { let start = this.offsetAt(edit.range.start); let end = this.offsetAt(edit.range.end); if (start > end) { @@ -213,60 +264,114 @@ export class TextDocument { return { start, end, text: edit.newText }; } - #setDocumentText(text: string, incrementVersion = true) { - this.#text = text; - this.#lineOffsets = computeLineOffsets(text); + #setDocumentText(text: string, incrementVersion = true): void { + this.#setLineBuffers(text, incrementVersion); + } + + #setLineBuffers(text: string, incrementVersion: boolean): void { + let offset = 0; + let hasCRLF = false; + const parts = splitFileContents(text); + const lines = parts.map((part) => { + const line = new LineBuffer(offset, part); + if (part.endsWith('\r\n')) { + hasCRLF = true; + } + offset += part.length; + return line; + }); + this.#lines = lines; + this.#hasCRLF = hasCRLF; if (incrementVersion) { this.#version++; } } - positionAt(offset: number): Position { - const columnOffset = Math.max(Math.min(offset, this.#text.length), 0); - const lineOffsets = this.#lineOffsets; - let lo = 0; - let hi = lineOffsets.length - 1; - if (hi === 0) { - return { line: 0, character: columnOffset }; + #getDocumentLength(): number { + if (this.#lines.length === 0) { + return 0; } + const lastLine = this.#lines[this.#lines.length - 1]; + return lastLine.offset + lastLine.text.length; + } + + #lineAtOffset(offset: number): number { + let lo = 0; + let hi = this.#lines.length - 1; while (lo < hi) { const mid = lo + Math.floor((hi - lo + 1) / 2); - if (lineOffsets[mid] <= columnOffset) { + if (this.#lines[mid].offset <= offset) { lo = mid; } else { hi = mid - 1; } } - const line = lo; - const character = - this.#ensureBeforeEOL(columnOffset, lineOffsets[line]) - lineOffsets[lo]; - return { line, character }; + return lo; } - offsetAt(position: Position): number { - const { line, character } = position; - const textLength = this.#text.length; - const lineOffsets = this.#lineOffsets; - if (line >= lineOffsets.length) { - return textLength; - } else if (line < 0) { - return 0; + #sliceText(start: number, end: number): string { + if (start >= end) { + return ''; } - const lineOffset = lineOffsets[line]; - if (character <= 0) { - return lineOffset; + const startLine = this.#lineAtOffset(start); + const endLine = this.#lineAtOffset(Math.max(start, end - 1)); + if (startLine === endLine) { + const line = this.#lines[startLine]; + const localStart = start - line.offset; + const localEnd = end - line.offset; + return line.text.slice(localStart, localEnd); + } + + let result = ''; + for (let lineIndex = startLine; lineIndex <= endLine; lineIndex++) { + const line = this.#lines[lineIndex]; + const localStart = lineIndex === startLine ? start - line.offset : 0; + const localEnd = + lineIndex === endLine ? end - line.offset : line.text.length; + result += line.text.slice(localStart, localEnd); } - const nextLineOffset = - line + 1 < lineOffsets.length ? lineOffsets[line + 1] : textLength; - const offset = Math.min(lineOffset + character, nextLineOffset); - return this.#ensureBeforeEOL(offset, lineOffset); + return result; } - #ensureBeforeEOL(end: number, start: number) { - while (end > start && isEOL(this.#text.charCodeAt(end - 1))) { - end--; + #applyResolvedEdits(edits: ResolvedTextEdit[]): void { + const sortedEdits = [...edits].sort((a, b) => b.start - a.start); + for (let i = 0; i < sortedEdits.length - 1; i++) { + if (sortedEdits[i + 1].end > sortedEdits[i].start) { + throw new Error('Overlapping text edits are not supported'); + } + } + for (const edit of sortedEdits) { + this.#applySingleEdit(edit); + } + this.#hasCRLF = this.#lines.some((line) => line.text.includes('\r\n')); + } + + #applySingleEdit(edit: ResolvedTextEdit): void { + const start = this.positionAt(edit.start); + const end = this.positionAt(edit.end); + const startLine = start.line; + const endLine = end.line; + const startLineParts = splitLineEnding(this.#lines[startLine].text); + const endLineParts = splitLineEnding(this.#lines[endLine].text); + const head = startLineParts.content.slice(0, start.character); + const tail = endLineParts.content.slice(end.character) + endLineParts.eol; + const merged = `${head}${edit.text}${tail}`; + const nextLineTexts = splitFileContents(merged); + const nextLines: LineBuffer[] = nextLineTexts.map( + (text) => new LineBuffer(0, text) + ); + + this.#lines.splice(startLine, endLine - startLine + 1, ...nextLines); + let nextOffset = + startLine > 0 + ? this.#lines[startLine - 1].offset + + this.#lines[startLine - 1].text.length + : 0; + for (let i = startLine; i < this.#lines.length; i++) { + // @ts-ignore update the line offset + this.#lines[i].offset = nextOffset; + nextOffset += this.#lines[i].text.length; } - return end; } } @@ -274,21 +379,38 @@ function isEOL(char: number) { return char === /* \n */ 10 || char === 13 /* \r */; } -function computeLineOffsets(text: string): LineOffsets { - const offsets: LineOffsets = [0]; - for (let i = 0; i < text.length; i++) { - const char = text.charCodeAt(i); - if (isEOL(char)) { - if ( - char === 13 /* \r */ && - i + 1 < text.length && - text.charCodeAt(i + 1) === /* \n */ 10 - ) { - offsets.hasCRLF = true; - i++; - } - offsets.push(i + 1); +function lineLengthWithoutEOL(text: string): number { + let length = text.length; + while (length > 0 && isEOL(text.charCodeAt(length - 1))) { + length--; + } + return length; +} + +function splitLineEnding(text: string): { content: string; eol: string } { + let contentEnd = text.length; + while (contentEnd > 0 && isEOL(text.charCodeAt(contentEnd - 1))) { + contentEnd--; + } + return { + content: text.slice(0, contentEnd), + eol: text.slice(contentEnd), + }; +} + +export function applyTextEdits( + originalText: string, + edits: ResolvedTextEdit[] +): string { + const sortedEdits = [...edits].sort((a, b) => b.start - a.start); + for (let i = 0; i < sortedEdits.length - 1; i++) { + if (sortedEdits[i + 1].end > sortedEdits[i].start) { + throw new Error('Overlapping text edits are not supported'); } } - return offsets; + let text = originalText; + for (const { start, end, text: insert } of sortedEdits) { + text = text.slice(0, start) + insert + text.slice(end); + } + return text; } diff --git a/packages/diffs/src/types.ts b/packages/diffs/src/types.ts index 63e18f114..0b6797f6b 100644 --- a/packages/diffs/src/types.ts +++ b/packages/diffs/src/types.ts @@ -646,7 +646,11 @@ export interface RenderFileResult { } export interface EditorHook { - (fileContainer: HTMLElement, file: FileContents): void; + ( + fileContainer: HTMLElement, + file: FileContents, + renderRange: RenderRange | undefined + ): void; } export interface RenderDiffResult { diff --git a/packages/diffs/test/editHistory.test.ts b/packages/diffs/test/editHistory.test.ts index 844e03505..c7274193e 100644 --- a/packages/diffs/test/editHistory.test.ts +++ b/packages/diffs/test/editHistory.test.ts @@ -1,10 +1,6 @@ import { describe, expect, test } from 'bun:test'; -import { - applyOffsetEdits, - buildInverseOffsetEdits, - EditHistory, -} from '../src/editor/editHistory'; +import { EditHistory } from '../src/editor/editHistory'; import type { EditorSelection } from '../src/editor/editorSelection'; import { SelectionDirection } from '../src/editor/editorSelection'; @@ -26,44 +22,6 @@ function caret(character: number) { return createSelection(0, character, 0, character, SelectionDirection.None); } -describe('EditHistory helpers', () => { - test('applyOffsetEdits sorts edits and applies them in offset space', () => { - expect( - applyOffsetEdits('0123456789', [ - { start: 8, end: 10, text: 'YZ' }, - { start: 1, end: 4, text: 'AB' }, - ]) - ).toBe('0AB4567YZ'); - }); - - test('assertNonOverlappingDescending rejects overlapping edits', () => { - expect(() => - applyOffsetEdits('0123456789', [ - { start: 6, end: 8, text: 'X' }, - { start: 4, end: 7, text: 'Y' }, - ]) - ).toThrow('Overlapping text edits are not supported'); - }); - - test('buildInverseOffsetEdits restores the original text for mixed edits', () => { - const textBefore = 'abcde'; - const forwardEdits = [ - { start: 1, end: 2, text: 'XY' }, - { start: 4, end: 5, text: '' }, - ]; - - const textAfter = applyOffsetEdits(textBefore, forwardEdits); - const inverseEdits = buildInverseOffsetEdits(textBefore, forwardEdits); - - expect(textAfter).toBe('aXYcd'); - expect(inverseEdits).toEqual([ - { start: 1, end: 3, text: 'b' }, - { start: 5, end: 5, text: 'e' }, - ]); - expect(applyOffsetEdits(textAfter, inverseEdits)).toBe(textBefore); - }); -}); - describe('EditHistory', () => { test('push stores cloned selections and pop methods move entries between stacks', () => { const history = new EditHistory(); diff --git a/packages/diffs/test/editorMultiSelections.test.ts b/packages/diffs/test/editorMultiSelections.test.ts index 489362258..3b38d0a04 100644 --- a/packages/diffs/test/editorMultiSelections.test.ts +++ b/packages/diffs/test/editorMultiSelections.test.ts @@ -1,8 +1,8 @@ import { describe, expect, test } from 'bun:test'; import { - applySelectionTextChange, - applySelectionTextReplace, + applyTextChangeToSelections, + applyTextReplaceToSelections, mapSelectionMove, mapSelectionRangeMove, } from '../src/editor/editorMultiSelections'; @@ -32,11 +32,15 @@ describe('mapSelectionTextChange', () => { createSelection(1, 1, 1, 1), createSelection(2, 1, 2, 1), ]; - const nextSelections = applySelectionTextChange(textDocument, selections, { - start: 5, - end: 5, - text: '!', - }); + const nextSelections = applyTextChangeToSelections( + textDocument, + selections, + { + start: 5, + end: 5, + text: '!', + } + ); expect(textDocument.getText()).toBe('a!\nb!\nc!'); expect(nextSelections).toEqual([ @@ -53,11 +57,15 @@ describe('mapSelectionTextChange', () => { createSelection(0, 4, 0, 7, SelectionDirection.Forward), createSelection(0, 8, 0, 11, SelectionDirection.Forward), ]; - const nextSelections = applySelectionTextChange(textDocument, selections, { - start: 8, - end: 11, - text: 'x', - }); + const nextSelections = applyTextChangeToSelections( + textDocument, + selections, + { + start: 8, + end: 11, + text: 'x', + } + ); expect(textDocument.getText()).toBe('x x x'); expect(nextSelections).toEqual([ @@ -74,11 +82,15 @@ describe('mapSelectionTextChange', () => { createSelection(1, 1, 1, 1), createSelection(2, 1, 2, 1), ]; - const nextSelections = applySelectionTextChange(textDocument, selections, { - start: 6, - end: 7, - text: '', - }); + const nextSelections = applyTextChangeToSelections( + textDocument, + selections, + { + start: 6, + end: 7, + text: '', + } + ); expect(textDocument.getText()).toBe('x\nx\nx'); expect(nextSelections).toEqual([ @@ -94,11 +106,15 @@ describe('mapSelectionTextChange', () => { createSelection(0, 1, 0, 1), createSelection(0, 2, 0, 2), ]; - const nextSelections = applySelectionTextChange(textDocument, selections, { - start: 0, - end: 2, - text: '', - }); + const nextSelections = applyTextChangeToSelections( + textDocument, + selections, + { + start: 0, + end: 2, + text: '', + } + ); expect(textDocument.getText()).toBe(' '); expect(nextSelections).toEqual([ @@ -106,6 +122,40 @@ describe('mapSelectionTextChange', () => { createSelection(0, 0, 0, 0), ]); }); + + test('places the caret on the inserted blank line after Enter', () => { + const textDocument = new TextDocument('inmemory://1', 'foo\nbar'); + const selections = [createSelection(0, 3, 0, 3)]; + const nextSelections = applyTextChangeToSelections( + textDocument, + selections, + { + start: 3, + end: 3, + text: '\n', + } + ); + + expect(textDocument.getText()).toBe('foo\n\nbar'); + expect(nextSelections).toEqual([createSelection(1, 0, 1, 0)]); + }); + + test('moves the caret to the previous line end after deleting a line break', () => { + const textDocument = new TextDocument('inmemory://1', 'foo\n\nbar'); + const selections = [createSelection(1, 0, 1, 0)]; + const nextSelections = applyTextChangeToSelections( + textDocument, + selections, + { + start: 3, + end: 4, + text: '', + } + ); + + expect(textDocument.getText()).toBe('foo\nbar'); + expect(nextSelections).toEqual([createSelection(0, 3, 0, 3)]); + }); }); describe('mapSelectionMove', () => { @@ -192,11 +242,11 @@ describe('mapSelectionTextReplace', () => { createSelection(1, 1, 1, 1), createSelection(2, 1, 2, 1), ]; - const nextSelections = applySelectionTextReplace(textDocument, selections, [ - 'a', - 'b', - 'c', - ]); + const nextSelections = applyTextReplaceToSelections( + textDocument, + selections, + ['a', 'b', 'c'] + ); expect(textDocument.getText()).toBe('xa\nyb\nzc'); expect(nextSelections).toEqual([ diff --git a/packages/diffs/test/editorTextareaSnapshot.test.ts b/packages/diffs/test/editorTextareaSnapshot.test.ts index 47140d26b..8326a7dcc 100644 --- a/packages/diffs/test/editorTextareaSnapshot.test.ts +++ b/packages/diffs/test/editorTextareaSnapshot.test.ts @@ -6,8 +6,8 @@ import { } from '../src/editor/editorSelection'; import { createTextareaSnapshot, - resolveTextChange, -} from '../src/editor/editorTextareaSnapshot'; + resolveTextareaChange, +} from '../src/editor/editorTextarea'; import { TextDocument } from '../src/editor/textDocument'; function createSelection( @@ -32,7 +32,7 @@ describe('resolveTextChange', () => { createSelection(0, 0, 0, 3, SelectionDirection.Forward) ); - expect(resolveTextChange(snippet, '1')).toEqual({ + expect(resolveTextareaChange(snippet, '1')).toEqual({ start: 0, end: 3, text: '1', @@ -46,10 +46,38 @@ describe('resolveTextChange', () => { createSelection(0, 2, 0, 2) ); - expect(resolveTextChange(snippet, 'ac')).toEqual({ + expect(resolveTextareaChange(snippet, 'ac')).toEqual({ start: 1, end: 2, text: '', }); }); + + test('uses the caret to resolve Enter before an existing line break', () => { + const textDocument = new TextDocument('inmemory://1', 'foo\nbar'); + const snippet = createTextareaSnapshot( + textDocument, + createSelection(0, 3, 0, 3) + ); + + expect(resolveTextareaChange(snippet, 'foo\n\nbar', 4, 4)).toEqual({ + start: 3, + end: 3, + text: '\n', + }); + }); + + test('uses the caret to resolve Backspace at an empty line start', () => { + const textDocument = new TextDocument('inmemory://1', 'foo\n\nbar'); + const snippet = createTextareaSnapshot( + textDocument, + createSelection(1, 0, 1, 0) + ); + + expect(resolveTextareaChange(snippet, 'foo\nbar', 3, 3)).toEqual({ + start: 3, + end: 4, + text: '', + }); + }); }); diff --git a/packages/diffs/test/textDocument.test.ts b/packages/diffs/test/textDocument.test.ts index 0a1575659..a2579e397 100644 --- a/packages/diffs/test/textDocument.test.ts +++ b/packages/diffs/test/textDocument.test.ts @@ -71,6 +71,14 @@ describe('TextDocument', () => { expect(d.offsetAt({ line, character })).toBe(2); }); + test('positionAt maps initial line offsets from zero', () => { + const d = doc('first\nsecond\nthird'); + expect(d.positionAt(0)).toEqual({ line: 0, character: 0 }); + expect(d.positionAt(5)).toEqual({ line: 0, character: 5 }); + expect(d.positionAt(6)).toEqual({ line: 1, character: 0 }); + expect(d.offsetAt({ line: 2, character: 0 })).toBe(13); + }); + test('applyEdits single replacement', () => { const d = doc('hello world'); d.applyEdits([ @@ -121,6 +129,55 @@ describe('TextDocument', () => { expect(d.getText()).toBe('AA bb CC'); }); + test('applyEdits preserves line breaks around edited line', () => { + const d = doc('a\nb\nc'); + d.applyEdits([ + { + range: { + start: { line: 1, character: 0 }, + end: { line: 1, character: 1 }, + }, + newText: 'B', + }, + ]); + expect(d.getText()).toBe('a\nB\nc'); + expect(d.lineCount).toBe(3); + }); + + test('applyEdits preserves CRLF after middle-line edit', () => { + const d = doc('a\r\nb\r\nc'); + d.applyEdits([ + { + range: { + start: { line: 1, character: 0 }, + end: { line: 1, character: 1 }, + }, + newText: 'B', + }, + ]); + expect(d.getText()).toBe('a\r\nB\r\nc'); + expect(d.EOF).toBe('\r\n'); + }); + + test('getText(range) spans multiple lines correctly after edits', () => { + const d = doc('foo\nbar\nbaz'); + d.applyEdits([ + { + range: { + start: { line: 1, character: 0 }, + end: { line: 1, character: 3 }, + }, + newText: 'BAR', + }, + ]); + expect( + d.getText({ + start: { line: 0, character: 2 }, + end: { line: 2, character: 2 }, + }) + ).toBe('o\nBAR\nba'); + }); + test('undo restores batch with two disjoint edits', () => { const d = doc('aa bb cc'); d.applyEdits( @@ -322,28 +379,6 @@ describe('TextDocument', () => { expect(d.canRedo).toBe(false); }); - test('setText replaces content and clears history', () => { - const d = doc('a'); - d.applyEdits( - [ - { - range: { - start: { line: 0, character: 1 }, - end: { line: 0, character: 1 }, - }, - newText: 'b', - }, - ], - true, - [caret(0, 1)] - ); - expect(d.canUndo).toBe(true); - d.setText('fresh'); - expect(d.getText()).toBe('fresh'); - expect(d.canUndo).toBe(false); - expect(d.canRedo).toBe(false); - }); - test('undo on empty stack returns false', () => { const d = doc('z'); expect(d.undo()).toBeUndefined(); From b9495e7bdc3ad30ceabfb86660f2438f9f1323a8 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Wed, 29 Apr 2026 13:58:27 +0800 Subject: [PATCH 028/138] Use piece table data sturcture for the text document --- packages/diffs/src/editor/editHistory.ts | 8 + packages/diffs/src/editor/pieceTable.ts | 756 ++++++++++++++++++++++ packages/diffs/src/editor/textDocument.ts | 220 +------ packages/diffs/test/editHistory.test.ts | 42 +- packages/diffs/test/pieceTable.test.ts | 327 ++++++++++ packages/diffs/test/textDocument.test.ts | 38 +- 6 files changed, 1188 insertions(+), 203 deletions(-) create mode 100644 packages/diffs/src/editor/pieceTable.ts create mode 100644 packages/diffs/test/pieceTable.test.ts diff --git a/packages/diffs/src/editor/editHistory.ts b/packages/diffs/src/editor/editHistory.ts index 7e1ebfb48..bca1c5811 100644 --- a/packages/diffs/src/editor/editHistory.ts +++ b/packages/diffs/src/editor/editHistory.ts @@ -6,6 +6,10 @@ export type HistoryEntry = { forwardEdits: ResolvedTextEdit[]; /** Inverse offset edits from the entry's final text back to its base text. */ inverseEdits: ResolvedTextEdit[]; + /** Document version before the entry is applied. */ + versionBefore: number; + /** Document version after the entry is applied. */ + versionAfter: number; /** Base text length before the entry is applied. */ textLengthBefore: number; /** Final text length after the entry is applied. */ @@ -36,6 +40,8 @@ export class EditHistory { push( textBefore: string, resolvedEdits: ResolvedTextEdit[], + versionBefore: number, + versionAfter: number, selectionsBefore: EditorSelection[], selectionsAfter?: EditorSelection[] ): void { @@ -51,6 +57,8 @@ export class EditHistory { this.#undo.push({ forwardEdits: forwardEdits.map((edit) => ({ ...edit })), inverseEdits: inverseEdits, + versionBefore, + versionAfter, textLengthBefore, textLengthAfter, selectionsBefore: selectionsBefore?.map((selection) => ({ diff --git a/packages/diffs/src/editor/pieceTable.ts b/packages/diffs/src/editor/pieceTable.ts new file mode 100644 index 000000000..22c6ccaef --- /dev/null +++ b/packages/diffs/src/editor/pieceTable.ts @@ -0,0 +1,756 @@ +import type { Position, Range } from './textDocument'; + +type Piece = { + readonly source: PieceSourceType; + readonly offset: number; + readonly length: number; +}; + +type PieceSegment = { + readonly start: number; + readonly end: number; + readonly text: string; + readonly lineOffsets: number[]; +}; + +type LineOffset = { + readonly start: number; + readonly end: number; + readonly endBeforeEOL: number; +}; + +enum PieceSourceType { + Original = 0, + Added = 1, +} + +// A text buffer is a string with its line offsets. +class TextBuffer { + lineOffsets: number[]; + + constructor(public text: string) { + this.lineOffsets = createLineOffsets(text); + } + + // the append operation is efficient because it only appends + // elements to the lineOffsets array in the end + append(text: string): number { + const offset = this.text.length; + const appendedLineOffsets = createLineOffsets(text); + for (let i = 1; i < appendedLineOffsets.length; i++) { + this.lineOffsets.push(offset + appendedLineOffsets[i]); + } + this.text += text; + return offset; + } +} + +// A node in the piece tree, which is a red-black tree +class PieceNode { + static Red = 0; + static Black = 1; + + left: PieceNode | null = null; + right: PieceNode | null = null; + parent: PieceNode | null = null; + + constructor( + public piece: Piece, + public color: number = PieceNode.Red, + public subtreeLength: number = piece.length + ) {} + + updateSubtreeLength(): void { + this.subtreeLength = + (this.left?.subtreeLength ?? 0) + + this.piece.length + + (this.right?.subtreeLength ?? 0); + } +} + +/** + * A piece table is a data structure that allows for efficient insertion and deletion of text. + * It is a tree of pieces, where each piece is a segment of text that is either original or added. + * The tree is balanced to ensure that the operations are efficient. + * Inspired by https://code.visualstudio.com/blogs/2018/03/23/text-buffer-reimplementation + */ +export class PieceTable { + #original: TextBuffer; + #add = new TextBuffer(''); + #root: PieceNode | null = null; + #length = 0; + #lineCount = 0; + + constructor(originalText: string) { + this.#original = new TextBuffer(originalText); + this.#setPieces([ + { + source: PieceSourceType.Original, + offset: 0, + length: originalText.length, + }, + ]); + } + + get lineCount(): number { + return this.#lineCount; + } + + getText(range?: Range): string { + if (range === undefined) { + return this.#textFromPieces(); + } + const start = this.offsetAt(range.start); + const end = this.offsetAt(range.end); + return this.#sliceText(start, end); + } + + getLineText(line: number, trimEOL = true): string | undefined { + const info = this.#getLineOffset(line); + if (info === undefined) { + return undefined; + } + return this.#sliceText(info.start, trimEOL ? info.endBeforeEOL : info.end); + } + + includes(needle: string): boolean { + if (needle.length === 0) { + return true; + } + + const prefixTable = createPrefixTable(needle); + let matched = 0; + let found = false; + this.#forEachPieceSegment((segment) => { + for (let offset = segment.start; offset < segment.end; offset++) { + const charCode = segment.text.charCodeAt(offset); + while (matched > 0 && charCode !== needle.charCodeAt(matched)) { + matched = prefixTable[matched - 1]; + } + if (charCode === needle.charCodeAt(matched)) { + matched++; + } + if (matched === needle.length) { + found = true; + return false; + } + } + return true; + }); + return found; + } + + insert(text: string, offset: number): void { + if (text.length === 0) { + return; + } + + const insertOffset = clamp(offset, 0, this.#length); + const addOffset = this.#add.append(text); + const insertedPiece = { + source: PieceSourceType.Added, + offset: addOffset, + length: text.length, + }; + const pieces = this.#pieces(); + const nextPieces: Piece[] = []; + + let cursor = 0; + let inserted = false; + + for (const piece of pieces) { + const pieceEnd = cursor + piece.length; + if (!inserted && insertOffset <= pieceEnd) { + const splitOffset = insertOffset - cursor; + if (splitOffset > 0) { + nextPieces.push({ ...piece, length: splitOffset }); + } + nextPieces.push(insertedPiece); + if (splitOffset < piece.length) { + nextPieces.push({ + ...piece, + offset: piece.offset + splitOffset, + length: piece.length - splitOffset, + }); + } + inserted = true; + } else { + nextPieces.push(piece); + } + cursor = pieceEnd; + } + + if (!inserted) { + nextPieces.push(insertedPiece); + } + + this.#setPieces(nextPieces); + } + + delete(offset: number, length: number): void { + if (length <= 0 || this.#length === 0) { + return; + } + + const start = clamp(offset, 0, this.#length); + const end = clamp(start + length, start, this.#length); + if (start === end) { + return; + } + + const nextPieces: Piece[] = []; + let cursor = 0; + for (const piece of this.#pieces()) { + const pieceStart = cursor; + const pieceEnd = cursor + piece.length; + const keepBefore = clamp(start - pieceStart, 0, piece.length); + const keepAfter = clamp(pieceEnd - end, 0, piece.length); + + if (keepBefore > 0) { + nextPieces.push({ ...piece, length: keepBefore }); + } + if (keepAfter > 0) { + nextPieces.push({ + ...piece, + offset: piece.offset + piece.length - keepAfter, + length: keepAfter, + }); + } + cursor = pieceEnd; + } + + this.#setPieces(nextPieces); + } + + positionAt(offset: number): Position { + const clampedOffset = clamp(offset, 0, this.#length); + if (this.#length === 0) { + return { line: 0, character: 0 }; + } + + let position: Position | undefined; + const scan = this.#forEachLineBreak((lineBreak, line) => { + if (clampedOffset <= lineBreak.endBeforeEOL) { + position = { + line, + character: clampedOffset - lineBreak.start, + }; + return false; + } + + if (clampedOffset < lineBreak.end) { + position = { + line, + character: lineBreak.endBeforeEOL - lineBreak.start, + }; + return false; + } + return true; + }); + + if (position !== undefined) { + return position; + } + + return { + line: scan.nextLine, + character: + Math.min(clampedOffset, this.#length - scan.trailingEOLLength) - + scan.nextLineStart, + }; + } + + offsetAt(position: Position): number { + if (position.line < 0 || this.#length === 0) { + return 0; + } + const info = this.#getLineOffset(position.line); + if (info === undefined) { + return this.#length; + } + const character = clamp( + position.character, + 0, + info.endBeforeEOL - info.start + ); + return info.start + character; + } + + #sliceText(start: number, end: number): string { + if (start >= end) { + return ''; + } + + const chunks: string[] = []; + this.#appendSliceFromNode(this.#root, start, end, 0, chunks); + return chunks.join(''); + } + + #appendSliceFromNode( + node: PieceNode | null, + start: number, + end: number, + subtreeStart: number, + chunks: string[] + ): void { + if (node === null || start >= end) { + return; + } + + const subtreeEnd = subtreeStart + node.subtreeLength; + if (end <= subtreeStart || start >= subtreeEnd) { + return; + } + + const leftLength = node.left?.subtreeLength ?? 0; + const pieceStart = subtreeStart + leftLength; + const pieceEnd = pieceStart + node.piece.length; + + if (start < pieceStart) { + this.#appendSliceFromNode(node.left, start, end, subtreeStart, chunks); + } + + if (start < pieceEnd && end > pieceStart) { + const localStart = Math.max(start - pieceStart, 0); + const localEnd = Math.min(end - pieceStart, node.piece.length); + const buffer = this.#bufferFor(node.piece.source); + chunks.push( + buffer.text.slice( + node.piece.offset + localStart, + node.piece.offset + localEnd + ) + ); + } + + if (end > pieceEnd) { + this.#appendSliceFromNode(node.right, start, end, pieceEnd, chunks); + } + } + + #getLineOffset(line: number): LineOffset | undefined { + if (line < 0 || this.#length === 0) { + return undefined; + } + + let offset: LineOffset | undefined; + const scan = this.#forEachLineBreak((lineBreak, ln) => { + if (ln === line) { + offset = lineBreak; + return false; + } + return true; + }); + + if (offset !== undefined) { + return offset; + } + if (scan.nextLine !== line) { + return undefined; + } + return { + start: scan.nextLineStart, + end: this.#length, + endBeforeEOL: this.#length - scan.trailingEOLLength, + }; + } + + #textFromPieces(): string { + const chunks: string[] = []; + this.#forEachPieceSegment((segment) => { + chunks.push(segment.text.slice(segment.start, segment.end)); + }); + return chunks.join(''); + } + + #forEachPieceSegment( + callback: (segment: PieceSegment) => boolean | void + ): void { + this.#walk(this.#root, (node) => { + const buffer = this.#bufferFor(node.piece.source); + return callback({ + text: buffer.text, + lineOffsets: buffer.lineOffsets, + start: node.piece.offset, + end: node.piece.offset + node.piece.length, + }); + }); + } + + #forEachLineBreak( + callback: (lineBreak: LineOffset, line: number) => boolean | void + ): { + nextLine: number; + nextLineStart: number; + trailingEOLLength: number; + } { + let line = 0; + let lineStart = 0; + let documentOffset = 0; + let trailingEOLLength = 0; + + this.#forEachPieceSegment((segment) => { + const segmentDocumentOffset = documentOffset; + const lineOffsetStart = upperBound(segment.lineOffsets, segment.start); + const lineOffsetEnd = upperBound(segment.lineOffsets, segment.end); + for (let i = lineOffsetStart; i < lineOffsetEnd; i++) { + const bufferLineOffset = segment.lineOffsets[i]; + const endWithEOL = documentOffset + (bufferLineOffset - segment.start); + const eolLength = trailingEOLLengthBeforeOffset( + segment, + segmentDocumentOffset, + bufferLineOffset, + lineStart, + trailingEOLLength + ); + + if ( + callback( + { + start: lineStart, + end: endWithEOL, + endBeforeEOL: endWithEOL - eolLength, + }, + line + ) === false + ) { + return false; + } + + line++; + lineStart = endWithEOL; + trailingEOLLength = 0; + } + + documentOffset += segment.end - segment.start; + if (segment.end > segment.start) { + trailingEOLLength = trailingEOLLengthAtSegmentEnd( + segment, + segmentDocumentOffset, + lineStart, + trailingEOLLength + ); + } + return true; + }); + + return { nextLine: line, nextLineStart: lineStart, trailingEOLLength }; + } + + #bufferFor(source: PieceSourceType): TextBuffer { + return source === PieceSourceType.Original ? this.#original : this.#add; + } + + #pieces(): Piece[] { + const pieces: Piece[] = []; + this.#walk(this.#root, (node) => { + pieces.push(node.piece); + }); + return pieces; + } + + #setPieces(pieces: Piece[]): void { + const coalescedPieces = coalescePieces(pieces); + this.#root = null; + for (const piece of coalescedPieces) { + this.#insertRightmost(piece); + } + this.#recomputeSubtreeLength(this.#root); + this.#computeBufferMetadata(); + } + + #computeBufferMetadata(): void { + let length = 0; + let lineCount = 0; + + this.#forEachPieceSegment((segment) => { + length += segment.end - segment.start; + lineCount += lineFeedCount(segment); + }); + + this.#length = length; + this.#lineCount = length === 0 ? 0 : lineCount + 1; + } + + #recomputeSubtreeLength(node: PieceNode | null): number { + if (node === null) { + return 0; + } + + node.subtreeLength = + this.#recomputeSubtreeLength(node.left) + + node.piece.length + + this.#recomputeSubtreeLength(node.right); + return node.subtreeLength; + } + + #walk( + node: PieceNode | null, + visit: (node: PieceNode) => boolean | void + ): boolean { + if (node === null) { + return true; + } + if (!this.#walk(node.left, visit)) { + return false; + } + if (visit(node) === false) { + return false; + } + return this.#walk(node.right, visit); + } + + #insertRightmost(piece: Piece): void { + const node = new PieceNode(piece); + if (this.#root === null) { + node.color = PieceNode.Black; + this.#root = node; + return; + } + + let parent = this.#root; + while (parent.right !== null) { + parent = parent.right; + } + parent.right = node; + node.parent = parent; + + let current = node; + while (current.parent?.color === PieceNode.Red) { + const parent = current.parent; + const grandparent = parent.parent; + if (grandparent === null) { + break; + } + + if (parent === grandparent.left) { + const uncle = grandparent.right; + if (uncle?.color === PieceNode.Red) { + parent.color = PieceNode.Black; + uncle.color = PieceNode.Black; + grandparent.color = PieceNode.Red; + current = grandparent; + } else { + if (current === parent.right) { + current = parent; + this.#rotateLeft(current); + } + current.parent!.color = PieceNode.Black; + grandparent.color = PieceNode.Red; + this.#rotateRight(grandparent); + } + } else { + const uncle = grandparent.left; + if (uncle?.color === PieceNode.Red) { + parent.color = PieceNode.Black; + uncle.color = PieceNode.Black; + grandparent.color = PieceNode.Red; + current = grandparent; + } else { + if (current === parent.left) { + current = parent; + this.#rotateRight(current); + } + current.parent!.color = PieceNode.Black; + grandparent.color = PieceNode.Red; + this.#rotateLeft(grandparent); + } + } + } + + if (this.#root !== null) { + this.#root.color = PieceNode.Black; + } + } + + #rotateLeft(node: PieceNode): void { + const right = node.right; + if (right === null) { + return; + } + + node.right = right.left; + if (right.left !== null) { + right.left.parent = node; + } + right.parent = node.parent; + if (node.parent === null) { + this.#root = right; + } else if (node === node.parent.left) { + node.parent.left = right; + } else { + node.parent.right = right; + } + right.left = node; + node.parent = right; + node.updateSubtreeLength(); + right.updateSubtreeLength(); + } + + #rotateRight(node: PieceNode): void { + const left = node.left; + if (left === null) { + return; + } + + node.left = left.right; + if (left.right !== null) { + left.right.parent = node; + } + left.parent = node.parent; + if (node.parent === null) { + this.#root = left; + } else if (node === node.parent.right) { + node.parent.right = left; + } else { + node.parent.left = left; + } + left.right = node; + node.parent = left; + node.updateSubtreeLength(); + left.updateSubtreeLength(); + } +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} + +function createLineOffsets(text: string): number[] { + const offsets = [0]; + for (let i = 0; i < text.length; i++) { + if (text.charCodeAt(i) === 10) { + offsets.push(i + 1); + } + } + return offsets; +} + +function createPrefixTable(text: string): number[] { + const table = Array.from({ length: text.length }).fill(0); + let matched = 0; + for (let i = 1; i < text.length; i++) { + const charCode = text.charCodeAt(i); + while (matched > 0 && charCode !== text.charCodeAt(matched)) { + matched = table[matched - 1]; + } + if (charCode === text.charCodeAt(matched)) { + matched++; + } + table[i] = matched; + } + return table; +} + +// Keeps the table compact after repeated edits by joining neighboring pieces +// that already point at contiguous text in the same backing buffer. +function coalescePieces(pieces: Piece[]): Piece[] { + const coalescedPieces: Piece[] = []; + for (const piece of pieces) { + if (piece.length === 0) { + continue; + } + + const previous = coalescedPieces[coalescedPieces.length - 1]; + if ( + previous !== undefined && + previous.source === piece.source && + previous.offset + previous.length === piece.offset + ) { + coalescedPieces[coalescedPieces.length - 1] = { + ...previous, + length: previous.length + piece.length, + }; + continue; + } + + coalescedPieces.push(piece); + } + return coalescedPieces; +} + +function lineFeedCount(segment: PieceSegment): number { + return ( + upperBound(segment.lineOffsets, segment.end) - + upperBound(segment.lineOffsets, segment.start) + ); +} + +function trailingEOLLengthBeforeOffset( + segment: PieceSegment, + segmentDocumentOffset: number, + bufferOffset: number, + lineStart: number, + trailingBeforeSegment: number +): number { + const lineStartInSegment = Math.max( + segment.start, + segment.start + (lineStart - segmentDocumentOffset) + ); + let length = 0; + for (let offset = bufferOffset - 1; offset >= lineStartInSegment; offset--) { + if (!isEOL(segment.text.charCodeAt(offset))) { + return length; + } + length++; + } + + if ( + lineStart < segmentDocumentOffset && + lineStartInSegment === segment.start + ) { + return ( + length + + Math.min(trailingBeforeSegment, segmentDocumentOffset - lineStart) + ); + } + return length; +} + +function trailingEOLLengthAtSegmentEnd( + segment: PieceSegment, + segmentDocumentOffset: number, + lineStart: number, + trailingBeforeSegment: number +): number { + const lineStartInSegment = Math.max( + segment.start, + segment.start + (lineStart - segmentDocumentOffset) + ); + let length = 0; + for (let offset = segment.end - 1; offset >= lineStartInSegment; offset--) { + if (!isEOL(segment.text.charCodeAt(offset))) { + return length; + } + length++; + } + + if ( + lineStart < segmentDocumentOffset && + lineStartInSegment === segment.start + ) { + return ( + length + + Math.min(trailingBeforeSegment, segmentDocumentOffset - lineStart) + ); + } + return length; +} + +function isEOL(charCode: number): boolean { + return charCode === 10 || charCode === 13; +} + +// Returns the index of the first element in the array that is greater than the target. +function upperBound(values: number[], target: number): number { + let lo = 0; + let hi = values.length; + while (lo < hi) { + const mid = lo + Math.floor((hi - lo) / 2); + if (values[mid] <= target) { + lo = mid + 1; + } else { + hi = mid; + } + } + return lo; +} diff --git a/packages/diffs/src/editor/textDocument.ts b/packages/diffs/src/editor/textDocument.ts index 3bb7a379e..de603f525 100644 --- a/packages/diffs/src/editor/textDocument.ts +++ b/packages/diffs/src/editor/textDocument.ts @@ -1,6 +1,6 @@ -import { splitFileContents } from '../utils/splitFileContents'; import { EditHistory } from './editHistory'; import { type EditorSelection } from './editorSelection'; +import { PieceTable } from './pieceTable'; /** * Position in a text document expressed as zero-based line and character offset. @@ -88,32 +88,14 @@ export type ResolvedTextEdit = { readonly text: string; }; -/** - * A line buffer is a line of text with its offset. - */ -class LineBuffer { - constructor( - public readonly offset: number, - public readonly text: string - ) {} -} - /** * A vscode-languageserver-textdocument compatible text document. */ export class TextDocument { - static trimEOL(text: string): string { - let end = text.length; - while (end > 0 && isEOL(text.charCodeAt(end - 1))) { - end--; - } - return text.slice(0, end); - } - #uri: string; #languageId: string; #version: number; - #lines: LineBuffer[] = []; + #pieceTable: PieceTable; #hasCRLF = false; #history = new EditHistory(); @@ -126,7 +108,8 @@ export class TextDocument { this.#uri = new URL(uri, 'file://').toString(); this.#languageId = languageId; this.#version = version; - this.#setLineBuffers(text, false); + this.#pieceTable = new PieceTable(text); + this.#hasCRLF = this.#pieceTable.includes('\r\n'); } get uri(): string { @@ -142,11 +125,15 @@ export class TextDocument { } get lineCount(): number { - return this.#lines.length; + return this.#pieceTable.lineCount; } get lines(): string[] { - return this.#lines.map((line) => line.text); + const lines: string[] = []; + for (let line = 0; line < this.#pieceTable.lineCount; line++) { + lines.push(this.getLineText(line, false)); + } + return lines; } get canUndo(): boolean { @@ -162,20 +149,15 @@ export class TextDocument { } getText(range?: Range): string { - if (range !== undefined) { - const start = this.offsetAt(range.start); - const end = this.offsetAt(range.end); - return this.#sliceText(start, end); - } - return this.#lines.map((line) => line.text).join(''); + return this.#pieceTable.getText(range); } getLineText(line: number, trimEOL = true): string { - if (line < 0 || line >= this.#lines.length) { + const text = this.#pieceTable.getLineText(line, trimEOL); + if (text === undefined) { throw new Error(`Line index out of range: ${line}`); } - const text = this.#lines[line].text; - return trimEOL ? TextDocument.trimEOL(text) : text; + return text; } applyEdits( @@ -188,11 +170,13 @@ export class TextDocument { return; } const resolvedEdits = edits.map((edit) => this.#resolveEdit(edit)); - const textBefore = this.getText(); if (updateHistory && selectionsBefore !== undefined) { + const textBefore = this.getText(); this.#history.push( textBefore, resolvedEdits, + this.#version, + this.#version + 1, selectionsBefore, selectionsAfter ); @@ -210,7 +194,8 @@ export class TextDocument { if (entry === undefined) { return undefined; } - this.#setDocumentText(applyTextEdits(this.getText(), entry.inverseEdits)); + this.#applyResolvedEdits(entry.inverseEdits); + this.#version = entry.versionBefore; return entry.selectionsBefore !== undefined ? entry.selectionsBefore.map((selection) => ({ ...selection })) : undefined; @@ -221,36 +206,19 @@ export class TextDocument { if (entry === undefined) { return undefined; } - this.#setDocumentText(applyTextEdits(this.getText(), entry.forwardEdits)); + this.#applyResolvedEdits(entry.forwardEdits); + this.#version = entry.versionAfter; return entry.selectionsAfter !== undefined ? entry.selectionsAfter.map((selection) => ({ ...selection })) : undefined; } positionAt(offset: number): Position { - const documentLength = this.#getDocumentLength(); - const clampedOffset = Math.max(Math.min(offset, documentLength), 0); - const line = this.#lineAtOffset(clampedOffset); - const lineStart = this.#lines[line].offset; - const lineLength = lineLengthWithoutEOL(this.#lines[line].text); - const character = Math.min(clampedOffset - lineStart, lineLength); - return { line, character }; + return this.#pieceTable.positionAt(offset); } offsetAt(position: Position): number { - const { line, character } = position; - const documentLength = this.#getDocumentLength(); - if (line >= this.#lines.length) { - return documentLength; - } else if (line < 0) { - return 0; - } - const lineOffset = this.#lines[line].offset; - if (character <= 0) { - return lineOffset; - } - const lineLength = lineLengthWithoutEOL(this.#lines[line].text); - return Math.min(lineOffset + character, lineOffset + lineLength); + return this.#pieceTable.offsetAt(position); } #resolveEdit(edit: TextEdit): ResolvedTextEdit { @@ -264,75 +232,6 @@ export class TextDocument { return { start, end, text: edit.newText }; } - #setDocumentText(text: string, incrementVersion = true): void { - this.#setLineBuffers(text, incrementVersion); - } - - #setLineBuffers(text: string, incrementVersion: boolean): void { - let offset = 0; - let hasCRLF = false; - const parts = splitFileContents(text); - const lines = parts.map((part) => { - const line = new LineBuffer(offset, part); - if (part.endsWith('\r\n')) { - hasCRLF = true; - } - offset += part.length; - return line; - }); - this.#lines = lines; - this.#hasCRLF = hasCRLF; - if (incrementVersion) { - this.#version++; - } - } - - #getDocumentLength(): number { - if (this.#lines.length === 0) { - return 0; - } - const lastLine = this.#lines[this.#lines.length - 1]; - return lastLine.offset + lastLine.text.length; - } - - #lineAtOffset(offset: number): number { - let lo = 0; - let hi = this.#lines.length - 1; - while (lo < hi) { - const mid = lo + Math.floor((hi - lo + 1) / 2); - if (this.#lines[mid].offset <= offset) { - lo = mid; - } else { - hi = mid - 1; - } - } - return lo; - } - - #sliceText(start: number, end: number): string { - if (start >= end) { - return ''; - } - const startLine = this.#lineAtOffset(start); - const endLine = this.#lineAtOffset(Math.max(start, end - 1)); - if (startLine === endLine) { - const line = this.#lines[startLine]; - const localStart = start - line.offset; - const localEnd = end - line.offset; - return line.text.slice(localStart, localEnd); - } - - let result = ''; - for (let lineIndex = startLine; lineIndex <= endLine; lineIndex++) { - const line = this.#lines[lineIndex]; - const localStart = lineIndex === startLine ? start - line.offset : 0; - const localEnd = - lineIndex === endLine ? end - line.offset : line.text.length; - result += line.text.slice(localStart, localEnd); - } - return result; - } - #applyResolvedEdits(edits: ResolvedTextEdit[]): void { const sortedEdits = [...edits].sort((a, b) => b.start - a.start); for (let i = 0; i < sortedEdits.length - 1; i++) { @@ -341,76 +240,9 @@ export class TextDocument { } } for (const edit of sortedEdits) { - this.#applySingleEdit(edit); - } - this.#hasCRLF = this.#lines.some((line) => line.text.includes('\r\n')); - } - - #applySingleEdit(edit: ResolvedTextEdit): void { - const start = this.positionAt(edit.start); - const end = this.positionAt(edit.end); - const startLine = start.line; - const endLine = end.line; - const startLineParts = splitLineEnding(this.#lines[startLine].text); - const endLineParts = splitLineEnding(this.#lines[endLine].text); - const head = startLineParts.content.slice(0, start.character); - const tail = endLineParts.content.slice(end.character) + endLineParts.eol; - const merged = `${head}${edit.text}${tail}`; - const nextLineTexts = splitFileContents(merged); - const nextLines: LineBuffer[] = nextLineTexts.map( - (text) => new LineBuffer(0, text) - ); - - this.#lines.splice(startLine, endLine - startLine + 1, ...nextLines); - let nextOffset = - startLine > 0 - ? this.#lines[startLine - 1].offset + - this.#lines[startLine - 1].text.length - : 0; - for (let i = startLine; i < this.#lines.length; i++) { - // @ts-ignore update the line offset - this.#lines[i].offset = nextOffset; - nextOffset += this.#lines[i].text.length; - } - } -} - -function isEOL(char: number) { - return char === /* \n */ 10 || char === 13 /* \r */; -} - -function lineLengthWithoutEOL(text: string): number { - let length = text.length; - while (length > 0 && isEOL(text.charCodeAt(length - 1))) { - length--; - } - return length; -} - -function splitLineEnding(text: string): { content: string; eol: string } { - let contentEnd = text.length; - while (contentEnd > 0 && isEOL(text.charCodeAt(contentEnd - 1))) { - contentEnd--; - } - return { - content: text.slice(0, contentEnd), - eol: text.slice(contentEnd), - }; -} - -export function applyTextEdits( - originalText: string, - edits: ResolvedTextEdit[] -): string { - const sortedEdits = [...edits].sort((a, b) => b.start - a.start); - for (let i = 0; i < sortedEdits.length - 1; i++) { - if (sortedEdits[i + 1].end > sortedEdits[i].start) { - throw new Error('Overlapping text edits are not supported'); + this.#pieceTable.delete(edit.start, edit.end - edit.start); + this.#pieceTable.insert(edit.text, edit.start); } + this.#hasCRLF = this.#pieceTable.includes('\r\n'); } - let text = originalText; - for (const { start, end, text: insert } of sortedEdits) { - text = text.slice(0, start) + insert + text.slice(end); - } - return text; } diff --git a/packages/diffs/test/editHistory.test.ts b/packages/diffs/test/editHistory.test.ts index c7274193e..d920d5ad2 100644 --- a/packages/diffs/test/editHistory.test.ts +++ b/packages/diffs/test/editHistory.test.ts @@ -31,6 +31,8 @@ describe('EditHistory', () => { history.push( 'ab', [{ start: 1, end: 1, text: 'X' }], + 4, + 5, selectionBefore, selectionAfter ); @@ -46,6 +48,8 @@ describe('EditHistory', () => { expect(entry).toEqual({ forwardEdits: [{ start: 1, end: 1, text: 'X' }], inverseEdits: [{ start: 1, end: 2, text: '' }], + versionBefore: 4, + versionAfter: 5, textLengthBefore: 2, textLengthAfter: 3, selectionsBefore: [caret(0), caret(1)], @@ -66,6 +70,8 @@ describe('EditHistory', () => { history.push( 'a', [{ start: 1, end: 1, text: 'b' }], + 1, + 2, [caret(1)], [selectionAfter] ); @@ -79,15 +85,36 @@ describe('EditHistory', () => { test('push clears redo history when recording a new undo entry', () => { const history = new EditHistory(); - history.push('', [{ start: 0, end: 0, text: 'a' }], [caret(0)], undefined); - history.push('a', [{ start: 1, end: 1, text: 'b' }], [caret(1)], undefined); + history.push( + '', + [{ start: 0, end: 0, text: 'a' }], + 0, + 1, + [caret(0)], + undefined + ); + history.push( + 'a', + [{ start: 1, end: 1, text: 'b' }], + 1, + 2, + [caret(1)], + undefined + ); expect(history.popUndoToRedo()).toMatchObject({ forwardEdits: [{ start: 1, end: 1, text: 'b' }], }); expect(history.canRedo).toBe(true); - history.push('a', [{ start: 1, end: 1, text: 'c' }], [caret(1)], undefined); + history.push( + 'a', + [{ start: 1, end: 1, text: 'c' }], + 1, + 2, + [caret(1)], + undefined + ); expect(history.canRedo).toBe(false); expect(history.popUndoToRedo()).toMatchObject({ @@ -101,7 +128,14 @@ describe('EditHistory', () => { test('clear resets both undo and redo stacks', () => { const history = new EditHistory(); - history.push('', [{ start: 0, end: 0, text: 'a' }], [caret(0)], undefined); + history.push( + '', + [{ start: 0, end: 0, text: 'a' }], + 0, + 1, + [caret(0)], + undefined + ); history.popUndoToRedo(); history.clear(); diff --git a/packages/diffs/test/pieceTable.test.ts b/packages/diffs/test/pieceTable.test.ts new file mode 100644 index 000000000..58aa750bf --- /dev/null +++ b/packages/diffs/test/pieceTable.test.ts @@ -0,0 +1,327 @@ +import { describe, expect, test } from 'bun:test'; + +import { PieceTable } from '../src/editor/pieceTable'; +import type { Position } from '../src/editor/textDocument'; + +function lineTexts(text: string): string[] { + if (text === '') { + return []; + } + + const lines: string[] = []; + let start = 0; + for (let i = 0; i < text.length; i++) { + if (text.charCodeAt(i) === 10) { + lines.push(text.slice(start, i + 1)); + start = i + 1; + } + } + if (start <= text.length) { + lines.push(text.slice(start)); + } + return lines; +} + +function trimEOL(text: string): string { + return text.replace(/[\r\n]+$/, ''); +} + +function positionAt(text: string, offset: number): Position { + const clampedOffset = Math.min(Math.max(offset, 0), text.length); + let line = 0; + let lineStart = 0; + + for (let i = 0; i < text.length; i++) { + if (text.charCodeAt(i) !== 10) { + continue; + } + + let endWithoutEOL = i; + while ( + endWithoutEOL > lineStart && + /[\r\n]/.test(text[endWithoutEOL - 1]) + ) { + endWithoutEOL--; + } + if (clampedOffset <= endWithoutEOL) { + return { line, character: clampedOffset - lineStart }; + } + if (clampedOffset <= i) { + return { line, character: endWithoutEOL - lineStart }; + } + line++; + lineStart = i + 1; + } + + let endWithoutEOL = text.length; + while (endWithoutEOL > lineStart && /[\r\n]/.test(text[endWithoutEOL - 1])) { + endWithoutEOL--; + } + return { + line, + character: Math.min(clampedOffset, endWithoutEOL) - lineStart, + }; +} + +function offsetAt(text: string, position: Position): number { + if (position.line < 0 || text.length === 0) { + return 0; + } + + const lines = lineTexts(text); + if (position.line >= lines.length) { + return text.length; + } + + let offset = 0; + for (let i = 0; i < position.line; i++) { + offset += lines[i].length; + } + + const lineLength = trimEOL(lines[position.line]).length; + return offset + Math.min(Math.max(position.character, 0), lineLength); +} + +function expectTableToMatchText(table: PieceTable, text: string): void { + const lines = lineTexts(text); + + expect(table.getText()).toBe(text); + expect(table.lineCount).toBe(lines.length); + + for (let line = 0; line < lines.length; line++) { + expect(table.getLineText(line)).toBe(trimEOL(lines[line])); + expect(table.getLineText(line, false)).toBe(lines[line]); + } + + for (let offset = 0; offset <= text.length; offset++) { + expect(table.positionAt(offset)).toEqual(positionAt(text, offset)); + } + + for (let line = 0; line < lines.length; line++) { + const lineLength = trimEOL(lines[line]).length; + for (let character = 0; character <= lineLength; character++) { + expect(table.offsetAt({ line, character })).toBe( + offsetAt(text, { line, character }) + ); + } + } +} + +function createRandom(seed: number): () => number { + let state = seed; + return () => { + state = (state * 1664525 + 1013904223) >>> 0; + return state / 0x100000000; + }; +} + +describe('PieceTable', () => { + test('returns the original text', () => { + const table = new PieceTable('hello'); + + expect(table.getText()).toBe('hello'); + expect(table.lineCount).toBe(1); + }); + + test('reads text ranges by positions', () => { + const table = new PieceTable('aa\nbb\ncc'); + + expect( + table.getText({ + start: { line: 1, character: 0 }, + end: { line: 1, character: 2 }, + }) + ).toBe('bb'); + }); + + test('returns line text with optional line endings', () => { + const table = new PieceTable('first\r\nsecond\n'); + + expect(table.getLineText(0)).toBe('first'); + expect(table.getLineText(0, false)).toBe('first\r\n'); + expect(table.getLineText(1)).toBe('second'); + expect(table.getLineText(99)).toBeUndefined(); + }); + + test('maps between offsets and positions', () => { + const table = new PieceTable('ab\nc'); + + expect(table.positionAt(0)).toEqual({ line: 0, character: 0 }); + expect(table.positionAt(2)).toEqual({ line: 0, character: 2 }); + expect(table.positionAt(3)).toEqual({ line: 1, character: 0 }); + expect(table.positionAt(table.getText().length)).toEqual({ + line: 1, + character: 1, + }); + expect(table.offsetAt({ line: 1, character: 0 })).toBe(3); + expect(table.offsetAt({ line: 1, character: 99 })).toBe(4); + }); + + test('inserts at the start, middle, and end', () => { + const table = new PieceTable('bc'); + + table.insert('a', 0); + table.insert('X', 2); + table.insert('d', table.getText().length); + + expect(table.getText()).toBe('abXcd'); + }); + + test('deletes across original and added pieces', () => { + const table = new PieceTable('hello world'); + + table.insert(' brave', 5); + table.delete(5, 6); + + expect(table.getText()).toBe('hello world'); + }); + + test('handles mixed edits over multiple lines', () => { + const table = new PieceTable('one\ntwo\nthree'); + + table.insert(' zero', 3); + table.delete(9, 3); + table.insert('TWO', table.offsetAt({ line: 1, character: 0 })); + + expect(table.getText()).toBe('one zero\nTWO\nthree'); + expect(table.lineCount).toBe(3); + expect(table.getLineText(1)).toBe('TWO'); + }); + + test('handles CRLF split across piece boundaries', () => { + const table = new PieceTable('a\r\nb'); + + table.insert('X', 2); + table.delete(2, 1); + + expect(table.getText()).toBe('a\r\nb'); + expect(table.lineCount).toBe(2); + expect(table.getLineText(0)).toBe('a'); + expect(table.getLineText(0, false)).toBe('a\r\n'); + expect(table.positionAt(2)).toEqual({ line: 0, character: 1 }); + expect(table.positionAt(3)).toEqual({ line: 1, character: 0 }); + }); + + test('trims repeated line ending characters before line feed', () => { + const table = new PieceTable('a\r\r\nb\r'); + + expectTableToMatchText(table, 'a\r\r\nb\r'); + expect(table.getLineText(0)).toBe('a'); + expect(table.getLineText(1)).toBe('b'); + expect(table.positionAt(2)).toEqual({ line: 0, character: 1 }); + expect(table.offsetAt({ line: 1, character: 10 })).toBe(5); + }); + + test('trims line endings split across pieces', () => { + const table = new PieceTable('a\nb'); + + table.insert('\r\r', 1); + table.insert('\r', table.getText().length); + + expectTableToMatchText(table, 'a\r\r\nb\r'); + }); + + test('handles an empty document', () => { + const table = new PieceTable(''); + + expectTableToMatchText(table, ''); + expect(table.getLineText(0)).toBeUndefined(); + expect(table.positionAt(99)).toEqual({ line: 0, character: 0 }); + expect(table.offsetAt({ line: 99, character: 99 })).toBe(0); + }); + + test('clamps insert and delete offsets', () => { + const table = new PieceTable('middle'); + + table.insert('start-', -10); + table.insert('-end', 999); + table.delete(-10, 6); + table.delete(6, 999); + + expectTableToMatchText(table, 'middle'); + }); + + test('reads ranges spanning original and added pieces', () => { + const table = new PieceTable('abcd'); + + table.insert('XX', 2); + + expectTableToMatchText(table, 'abXXcd'); + expect( + table.getText({ + start: { line: 0, character: 1 }, + end: { line: 0, character: 5 }, + }) + ).toBe('bXXc'); + }); + + test('searches text across piece boundaries', () => { + const table = new PieceTable('a\nb'); + + table.insert('\r', 1); + + expect(table.includes('\r\n')).toBe(true); + expect(table.includes('missing')).toBe(false); + expect(table.includes('')).toBe(true); + }); + + test('tracks trailing newline as an empty final line', () => { + const table = new PieceTable('a\n'); + + expectTableToMatchText(table, 'a\n'); + expect(table.getLineText(1)).toBe(''); + expect(table.positionAt(2)).toEqual({ line: 1, character: 0 }); + }); + + test('updates line metadata for inserted multiline text', () => { + const table = new PieceTable('before\nafter'); + + table.insert('\ninserted\r\nlines', 6); + + expectTableToMatchText(table, 'before\ninserted\r\nlines\nafter'); + }); + + test('deletes across several pieces', () => { + const table = new PieceTable('0123456789'); + + table.insert('aa', 2); + table.insert('bb', 6); + table.insert('cc', 12); + table.delete(0, table.getText().length - 1); + + expectTableToMatchText(table, '9'); + }); + + test('deletes all content', () => { + const table = new PieceTable('a\nb'); + + table.insert('c', 1); + table.delete(0, table.getText().length); + + expectTableToMatchText(table, ''); + expect(table.getLineText(0)).toBeUndefined(); + }); + + test('matches plain string edits across many insertions and deletions', () => { + const table = new PieceTable('start\r\nmiddle\nend'); + const random = createRandom(42); + const inserts = ['a', 'BC', '\n', '\r\nx', '🙂', '']; + let text = 'start\r\nmiddle\nend'; + + for (let i = 0; i < 80; i++) { + if (random() < 0.6) { + const insert = inserts[Math.floor(random() * inserts.length)]; + const offset = Math.floor(random() * (text.length + 1)); + table.insert(insert, offset); + text = text.slice(0, offset) + insert + text.slice(offset); + } else { + const offset = Math.floor(random() * (text.length + 1)); + const length = Math.floor(random() * 5); + table.delete(offset, length); + text = text.slice(0, offset) + text.slice(offset + length); + } + } + + expectTableToMatchText(table, text); + }); +}); diff --git a/packages/diffs/test/textDocument.test.ts b/packages/diffs/test/textDocument.test.ts index a2579e397..c44d4aea2 100644 --- a/packages/diffs/test/textDocument.test.ts +++ b/packages/diffs/test/textDocument.test.ts @@ -42,8 +42,8 @@ describe('TextDocument', () => { const d = doc('first\nsecond'); expect(d.getLineText(0)).toBe('first'); expect(d.getLineText(1)).toBe('second'); - expect(d.getLineText(-1)).toBeUndefined(); - expect(d.getLineText(99)).toBeUndefined(); + expect(() => d.getLineText(-1)).toThrow('Line index out of range: -1'); + expect(() => d.getLineText(99)).toThrow('Line index out of range: 99'); }); test('EOF is LF for Unix newlines', () => { @@ -225,7 +225,7 @@ describe('TextDocument', () => { }); test('undo stack depth for sequential edits', () => { - const d = doc(''); + const d = doc('x'); const originalNow = Date.now; let now = 1000; Object.defineProperty(Date, 'now', { @@ -261,9 +261,9 @@ describe('TextDocument', () => { [caret(0, 1)] ); d.undo(); - expect(d.getText()).toBe('a'); + expect(d.getText()).toBe('ax'); d.undo(); - expect(d.getText()).toBe(''); + expect(d.getText()).toBe('x'); } finally { Object.defineProperty(Date, 'now', { configurable: true, @@ -346,6 +346,34 @@ describe('TextDocument', () => { expect(d.canRedo).toBe(false); }); + test('undo and redo restore history entry versions', () => { + const d = new TextDocument('inmemory://1', 'a', 'plain', 7); + expect(d.version).toBe(7); + + d.applyEdits( + [ + { + range: { + start: { line: 0, character: 1 }, + end: { line: 0, character: 1 }, + }, + newText: 'b', + }, + ], + true, + [caret(0, 1)] + ); + expect(d.version).toBe(8); + + d.undo(); + expect(d.getText()).toBe('a'); + expect(d.version).toBe(7); + + d.redo(); + expect(d.getText()).toBe('ab'); + expect(d.version).toBe(8); + }); + test('new edit after undo clears redo stack', () => { const d = doc('a'); d.applyEdits( From 6ea07084b47388e48ac3cbcb980af6f7807278e7 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Wed, 29 Apr 2026 16:00:25 +0800 Subject: [PATCH 029/138] refactor --- packages/diffs/src/editor/editorSelection.ts | 8 - packages/diffs/src/editor/index.ts | 168 ++++++++----------- 2 files changed, 70 insertions(+), 106 deletions(-) diff --git a/packages/diffs/src/editor/editorSelection.ts b/packages/diffs/src/editor/editorSelection.ts index 4211fbb95..8648f8a26 100644 --- a/packages/diffs/src/editor/editorSelection.ts +++ b/packages/diffs/src/editor/editorSelection.ts @@ -134,14 +134,6 @@ export function selectionIntersects( ); } -/** Get the primary(last) selection from the list of selections */ -export function getPrimarySelection( - selections: readonly EditorSelection[] -): EditorSelection | undefined { - const selection = selections[selections.length - 1]; - return selection !== undefined ? { ...selection } : undefined; -} - export function comparePosition(a: Position, b: Position): number { if (a.line !== b.line) { return a.line - b.line; diff --git a/packages/diffs/src/editor/index.ts b/packages/diffs/src/editor/index.ts index 5846935fa..9d38a67f5 100644 --- a/packages/diffs/src/editor/index.ts +++ b/packages/diffs/src/editor/index.ts @@ -22,7 +22,6 @@ import type { EditorSelection } from '../editor/editorSelection'; import { comparePosition, convertSelection, - getPrimarySelection, isCollapsedSelection, resolveIndentEdits, SelectionDirection, @@ -74,16 +73,15 @@ export class Editor { #textareaEl?: HTMLTextAreaElement; #selectionEls?: Map; + #charWidth = -1; + #lineHeight = 20; + #tabSize = 2; + // state - #selectionLineHeight = 20; #selectionStartX = 0; #selectionStartY = 0; #selectionEndX = 0; #selectionEndY = 0; - #textareaSelectionStart = 0; - #textareaSelectionEnd = 0; - #textareaSelectionDirection: HTMLTextAreaElement['selectionDirection'] = - 'none'; #shouldIgnoreSelectionChange = false; #textareaSnapshot?: TextareaSnapshot; #reservedSelections?: EditorSelection[]; @@ -196,27 +194,6 @@ export class Editor { return; } - // if caret position changes in textarea, sync the textarea state. - if ( - this.#textareaEl !== undefined && - this.#textareaSnapshot !== undefined - ) { - const { selectionStart, selectionEnd, selectionDirection } = - this.#textareaEl; - if ( - (this.#textareaSelectionStart !== selectionStart || - this.#textareaSelectionEnd !== selectionEnd || - this.#textareaSelectionDirection !== selectionDirection) && - this.#textareaSnapshot.text === this.#textareaEl.value - ) { - this.#textareaSelectionStart = selectionStart; - this.#textareaSelectionEnd = selectionEnd; - this.#textareaSelectionDirection = selectionDirection; - this.#syncTextareaState(); - return; - } - } - const selectionRaw = document.getSelection(); const composedRanges = selectionRaw?.getComposedRanges({ shadowRoots: [shadowRoot], @@ -261,15 +238,17 @@ export class Editor { this.#reservedSelections = undefined; } - this.#selectionLineHeight = this.#getLineHeight(); - this.#selectionStartY = e.clientY; - this.#selectionStartX = e.clientX; + if (!e.shiftKey) { + this.#selectionStartY = e.clientY; + this.#selectionStartX = e.clientX; + } this.#selectionEndX = e.clientX; this.#selectionEndY = e.clientY; }), addEventListener(document, 'mouseup', (e) => { - if (!isCodeLineTarget(e.composedPath()[0])) { + const target = e.composedPath()[0]; + if (!isCodeLineTarget(target)) { return; } @@ -295,10 +274,11 @@ export class Editor { } }), - addEventListener(this.#textareaEl, 'input', () => { + addEventListener(this.#textareaEl, 'keyup', () => { if (this.#shouldIgnoreSelectionChange) { return; } + this.#syncTextareaState(); }), ]; @@ -309,13 +289,13 @@ export class Editor { this.#renderSelections(this.#selections); this.#textareaEl.focus(); } + + this.#getCSSProperites(); } #computeMouseSelectionDirection(): SelectionDirection { - const startLine = Math.ceil( - this.#selectionStartY / this.#selectionLineHeight - ); - const endLine = Math.ceil(this.#selectionEndY / this.#selectionLineHeight); + const startLine = Math.ceil(this.#selectionStartY / this.#lineHeight); + const endLine = Math.ceil(this.#selectionEndY / this.#lineHeight); if (endLine !== startLine) { return endLine > startLine ? SelectionDirection.Forward @@ -675,7 +655,7 @@ export class Editor { } #renderSelections(selections: EditorSelection[]) { - const primarySelection = getPrimarySelection(selections); + const primarySelection = selections.at(-1); if (primarySelection === undefined) { return; } @@ -685,12 +665,11 @@ export class Editor { if (isCollapsedSelection(primarySelection)) { this.#renderLineHighlight(primarySelection, selectionEls); } - const ch = this.#chToPx(); selections.forEach((selection) => { if (selections.length > 1 || !isCollapsedSelection(selection)) { - this.#renderSelectionRange(selection, ch, selectionEls); + this.#renderSelectionRange(selection, selectionEls); } - this.#renderCaret(selection, ch, selectionEls); + this.#renderCaret(selection, selectionEls); }); this.#selectionEls?.forEach((el) => el.remove()); this.#selectionEls?.clear(); @@ -710,9 +689,6 @@ export class Editor { ); const textareaSelectionDirection = getTextareaSelectionDirection(primarySelection); - this.#textareaSelectionStart = textareaSnapshot.selectionStart; - this.#textareaSelectionEnd = textareaSnapshot.selectionEnd; - this.#textareaSelectionDirection = textareaSelectionDirection; this.#textareaSnapshot = textareaSnapshot; this.#shouldIgnoreSelectionChange = true; textareaEl.style.top = this.#getLineY(primarySelection.start.line) + 'px'; @@ -770,7 +746,6 @@ export class Editor { #renderSelectionRange( selection: EditorSelection, - ch: number, markMap: Map ) { if (!this.#isSelectionVisible(selection)) { @@ -790,7 +765,8 @@ export class Editor { const lineLength = lineText.length; const startChar = ln === start.line ? start.character : 0; const endChar = ln === end.line ? end.character : lineLength; - const spacing = ln === end.line || startChar === endChar ? 0 : ch; + const spacing = + ln === end.line || startChar === endChar ? 0 : this.#charWidth; const cacheKey = `selection-${ln}-${startChar}-${endChar}`; let rangeEl: HTMLElement | undefined; @@ -801,8 +777,8 @@ export class Editor { let left = 0; let width = 0; if (startChar === endChar && startChar === 0) { - left = ch; - width = ch; + left = this.#charWidth; + width = this.#charWidth; } else { const startX = this.#getCharacterX(ln, startChar); const endX = @@ -836,11 +812,7 @@ export class Editor { } } - #renderCaret( - selection: EditorSelection, - ch: number, - markMap: Map - ) { + #renderCaret(selection: EditorSelection, markMap: Map) { if (!this.#isSelectionVisible(selection)) { return; } @@ -849,7 +821,10 @@ export class Editor { const isBackward = direction === SelectionDirection.Backward; const line = isBackward ? start.line : end.line; const character = isBackward ? start.character : end.character; - const left = Math.max(ch, this.#getCharacterX(line, character)); + const left = Math.max( + this.#charWidth, + this.#getCharacterX(line, character) + ); const caretEl = createElement( 'div', { @@ -910,7 +885,6 @@ export class Editor { ) { const edits: TextEdit[] = []; const nextSelections: EditorSelection[] = []; - const tabSize = this.#getTabSize(); for (const selection of this.#selections) { const startLine = selection.start.line; const lineText = this.#textDocument.getLineText(startLine); @@ -920,13 +894,16 @@ export class Editor { const ret = resolveIndentEdits( this.#textDocument, selection, - tabSize, + this.#tabSize, outdent ); edits.push(...ret[0]); nextSelections.push(ret[1]); } else { - const indentUnit = getLineIndentationUnit(lineText, tabSize); + const indentUnit = getLineIndentationUnit( + lineText, + this.#tabSize + ); this.#replaceSelectionText(indentUnit); } } @@ -1020,8 +997,8 @@ export class Editor { return; } const textDocument = this.#textDocument; - const selection = getPrimarySelection(selections); - if (textDocument == null || selection == null) { + const primarySelection = selections.at(-1); + if (textDocument == null || primarySelection == null) { return; } const normalizedText = Array.isArray(text) @@ -1030,8 +1007,8 @@ export class Editor { const nextSelections = Array.isArray(normalizedText) ? applyTextReplaceToSelections(textDocument, selections, normalizedText) : applyTextChangeToSelections(textDocument, selections, { - start: textDocument.offsetAt(selection.start), - end: textDocument.offsetAt(selection.end), + start: textDocument.offsetAt(primarySelection.start), + end: textDocument.offsetAt(primarySelection.end), text: normalizedText, }); this.#rerender(textDocument, nextSelections); @@ -1045,44 +1022,6 @@ export class Editor { ); } - #getTabSize(): number { - const tabSize = this.#contentEl?.computedStyleMap().get('tab-size'); - if ( - tabSize !== undefined && - tabSize instanceof CSSUnitValue && - tabSize.unit === 'number' - ) { - return tabSize.value; - } - return 2; - } - - #getLineHeight(): number { - const lineHeight = this.#contentEl?.computedStyleMap().get('line-height'); - if ( - lineHeight !== undefined && - lineHeight instanceof CSSUnitValue && - lineHeight.unit === 'px' - ) { - return Number(lineHeight.value); - } - return 20; - } - - #chToPx(): number { - if (this.#contentEl !== undefined) { - const el = document.createElement('div'); - el.style.width = '1ch'; - el.style.position = 'absolute'; - el.style.visibility = 'hidden'; - this.#contentEl.appendChild(el); - const px = el.offsetWidth; - el.remove(); - return px; - } - return 0; - } - // get line Y position #getLineY(line: number) { return this.#getLineElement(line)?.offsetTop ?? 0; @@ -1158,6 +1097,39 @@ export class Editor { return pointRect.left - editorRect.left; } + #getCSSProperites() { + if (this.#contentEl === undefined) { + return; + } + + const styleMap = this.#contentEl.computedStyleMap(); + const tabSize = styleMap.get('tab-size'); + if ( + tabSize !== undefined && + tabSize instanceof CSSUnitValue && + tabSize.unit === 'number' + ) { + this.#tabSize = tabSize.value; + } + + const lineHeight = styleMap.get('line-height'); + if ( + lineHeight !== undefined && + lineHeight instanceof CSSUnitValue && + lineHeight.unit === 'px' + ) { + this.#lineHeight = Number(lineHeight.value); + } + + const el = document.createElement('div'); + el.style.width = '1ch'; + el.style.position = 'absolute'; + el.style.visibility = 'hidden'; + this.#contentEl.appendChild(el); + this.#charWidth = el.offsetWidth; + el.remove(); + } + // check if the web selection belongs to editor #selectionBelongsToEditor(composedRanges: StaticRange[]) { const contentEl = this.#contentEl; From e42e502649c472c748524dea78c8b74fcb57e36d Mon Sep 17 00:00:00 2001 From: Je Xia Date: Wed, 29 Apr 2026 21:24:04 +0800 Subject: [PATCH 030/138] Add public `setSelection` method for the `Editor` class --- packages/diffs/src/editor/editHistory.ts | 2 +- packages/diffs/src/editor/editorTextarea.ts | 17 ++- packages/diffs/src/editor/index.ts | 127 +++++++++++++------- 3 files changed, 92 insertions(+), 54 deletions(-) diff --git a/packages/diffs/src/editor/editHistory.ts b/packages/diffs/src/editor/editHistory.ts index bca1c5811..3802212ec 100644 --- a/packages/diffs/src/editor/editHistory.ts +++ b/packages/diffs/src/editor/editHistory.ts @@ -97,7 +97,7 @@ export class EditHistory { } } -export function buildInverseOffsetEdits( +function buildInverseOffsetEdits( textBefore: string, ascending: ResolvedTextEdit[] ): ResolvedTextEdit[] { diff --git a/packages/diffs/src/editor/editorTextarea.ts b/packages/diffs/src/editor/editorTextarea.ts index f71f63ea1..cd9b35f7c 100644 --- a/packages/diffs/src/editor/editorTextarea.ts +++ b/packages/diffs/src/editor/editorTextarea.ts @@ -2,12 +2,12 @@ import { type EditorSelection, SelectionDirection } from './editorSelection'; import type { ResolvedTextEdit, TextDocument } from './textDocument'; export interface TextareaSnapshot { - readonly startLine: number; - readonly offset: number; - readonly selectionStart: number; - readonly selectionEnd: number; - readonly lines: number; - readonly text: string; + startLine: number; + offset: number; + selectionStart: number; + selectionEnd: number; + lines: number; + text: string; } export function createTextareaSnapshot( @@ -26,9 +26,6 @@ export function createTextareaSnapshot( for (let line = startLine; line <= endLine; line++) { const lineText = textDocument.getLineText(line); - if (lineText === undefined) { - throw new Error(`Line ${line} is out of bounds`); - } if (line === primarySelection.start.line) { selectionStart = offset + primarySelection.start.character; } @@ -121,7 +118,7 @@ export function getSelectionDirectionFromTextarea( : SelectionDirection.Forward; } -export function getTextareaSelectionDirection( +export function toTextareaSelectionDirection( selection: EditorSelection ): HTMLTextAreaElement['selectionDirection'] { switch (selection.direction) { diff --git a/packages/diffs/src/editor/index.ts b/packages/diffs/src/editor/index.ts index 9d38a67f5..0a9c8a6f9 100644 --- a/packages/diffs/src/editor/index.ts +++ b/packages/diffs/src/editor/index.ts @@ -48,9 +48,9 @@ import { import { createTextareaSnapshot, getSelectionDirectionFromTextarea, - getTextareaSelectionDirection, resolveTextareaChange, type TextareaSnapshot, + toTextareaSelectionDirection, } from './editorTextarea'; export class Editor { @@ -104,6 +104,31 @@ export class Editor { return this.cleanUp.bind(this); } + setSelections(selections: EditorSelection[], resetTextarea = true): void { + const primarySelection = selections.at(-1); + if (primarySelection === undefined) { + return; + } + if (resetTextarea) { + this.#textareaSnapshot = undefined; + } + const shouldUpdateTextarea = + Math.max(0, primarySelection.start.line - 1) !== + this.#textareaSnapshot?.startLine; + this.#selections = selections; + this.#file?.setSelectedLines(null); + this.#renderSelections(selections, primarySelection); + if (shouldUpdateTextarea) { + this.#updateTextarea(primarySelection); + } else if ( + this.#textareaEl !== undefined && + this.#textareaSnapshot !== undefined && + this.#textareaSnapshot.text !== this.#textareaEl.value + ) { + this.#textareaSnapshot.text = this.#textareaEl.value; + } + } + cleanUp(): void { this.#disposes?.forEach((dispose) => dispose()); this.#disposes = undefined; @@ -194,6 +219,24 @@ export class Editor { return; } + // if caret position changes in textarea, sync the textarea state. + if ( + this.#textareaEl !== undefined && + this.#textareaSnapshot !== undefined + ) { + const { selectionStart, selectionEnd } = this.#textareaEl; + if ( + (this.#textareaSnapshot.selectionStart !== selectionStart || + this.#textareaSnapshot.selectionEnd !== selectionEnd) && + this.#textareaSnapshot.text === this.#textareaEl.value + ) { + this.#textareaSnapshot.selectionStart = selectionStart; + this.#textareaSnapshot.selectionEnd = selectionEnd; + this.#syncTextareaState(); + return; + } + } + const selectionRaw = document.getSelection(); const composedRanges = selectionRaw?.getComposedRanges({ shadowRoots: [shadowRoot], @@ -210,17 +253,17 @@ export class Editor { this.#computeMouseSelectionDirection() ); if (selection !== null) { - const reservedSelections = this.#reservedSelections; - if (reservedSelections !== undefined) { - this.#renderSelections([ - ...reservedSelections.filter( + this.#textareaSnapshot = undefined; + if (this.#reservedSelections !== undefined) { + this.setSelections([ + ...this.#reservedSelections.filter( (reservedSelection) => !selectionIntersects(reservedSelection, selection) ), selection, ]); } else { - this.#renderSelections([selection]); + this.setSelections([selection]); } } }), @@ -274,7 +317,7 @@ export class Editor { } }), - addEventListener(this.#textareaEl, 'keyup', () => { + addEventListener(this.#textareaEl, 'input', () => { if (this.#shouldIgnoreSelectionChange) { return; } @@ -286,7 +329,7 @@ export class Editor { if (this.#selections !== undefined) { this.#selectionEls?.forEach((el) => el.remove()); this.#selectionEls?.clear(); - this.#renderSelections(this.#selections); + this.setSelections(this.#selections); this.#textareaEl.focus(); } @@ -521,7 +564,7 @@ export class Editor { ); if (nextSelections !== undefined) { - this.#renderSelections(nextSelections); + this.setSelections(nextSelections, false); } } @@ -614,12 +657,13 @@ export class Editor { } else if (this.#selections !== undefined) { // Selection in the textarea changed, but no text change was made. if (selectionStart === selectionEnd) { - this.#renderSelections( + this.setSelections( mapSelectionMove( textDocument, this.#selections, textDocument.positionAt(textareaSnapshot.offset + selectionStart) - ) + ), + false ); } else { const isBackward = @@ -631,13 +675,14 @@ export class Editor { const focusOffset = textareaSnapshot.offset + (isBackward ? selectionStart : selectionEnd); - this.#renderSelections( + this.setSelections( mapSelectionRangeMove( textDocument, this.#selections, textDocument.positionAt(anchorOffset), textDocument.positionAt(focusOffset) - ) + ), + false ); } } @@ -654,30 +699,8 @@ export class Editor { } } - #renderSelections(selections: EditorSelection[]) { - const primarySelection = selections.at(-1); - if (primarySelection === undefined) { - return; - } - this.#selections = selections; - this.#file?.setSelectedLines(null); - const selectionEls = new Map(); - if (isCollapsedSelection(primarySelection)) { - this.#renderLineHighlight(primarySelection, selectionEls); - } - selections.forEach((selection) => { - if (selections.length > 1 || !isCollapsedSelection(selection)) { - this.#renderSelectionRange(selection, selectionEls); - } - this.#renderCaret(selection, selectionEls); - }); - this.#selectionEls?.forEach((el) => el.remove()); - this.#selectionEls?.clear(); - this.#selectionEls = selectionEls; - this.#updateTextarea(primarySelection); - } - #updateTextarea(primarySelection: EditorSelection) { + console.log('updateTextarea'); const textDocument = this.#textDocument; const textareaEl = this.#textareaEl; if (textDocument === undefined || textareaEl === undefined) { @@ -687,18 +710,17 @@ export class Editor { textDocument, primarySelection ); - const textareaSelectionDirection = - getTextareaSelectionDirection(primarySelection); - this.#textareaSnapshot = textareaSnapshot; - this.#shouldIgnoreSelectionChange = true; + const direction = toTextareaSelectionDirection(primarySelection); textareaEl.style.top = this.#getLineY(primarySelection.start.line) + 'px'; textareaEl.style.height = textareaSnapshot.lines + 'lh'; textareaEl.value = textareaSnapshot.text; textareaEl.setSelectionRange( textareaSnapshot.selectionStart, textareaSnapshot.selectionEnd, - textareaSelectionDirection + direction ); + this.#textareaSnapshot = textareaSnapshot; + this.#shouldIgnoreSelectionChange = true; setTimeout(() => { this.#shouldIgnoreSelectionChange = false; }, 0); @@ -718,6 +740,25 @@ export class Editor { return start.line < endLine && end.line >= startingLine; } + #renderSelections( + selections: EditorSelection[], + primarySelection: EditorSelection + ) { + const selectionEls = new Map(); + if (isCollapsedSelection(primarySelection)) { + this.#renderLineHighlight(primarySelection, selectionEls); + } + selections.forEach((selection) => { + if (selections.length > 1 || !isCollapsedSelection(selection)) { + this.#renderSelectionRange(selection, selectionEls); + } + this.#renderCaret(selection, selectionEls); + }); + this.#selectionEls?.forEach((el) => el.remove()); + this.#selectionEls?.clear(); + this.#selectionEls = selectionEls; + } + #renderLineHighlight( selection: EditorSelection, markMap: Map @@ -842,7 +883,7 @@ export class Editor { async #runCommand(command: EditorCommand) { switch (command) { case 'selectAll': - this.#renderSelections([this.#getFullSelection()]); + this.setSelections([this.#getFullSelection()]); break; case 'copy': @@ -922,7 +963,7 @@ export class Editor { case 'documentStart': case 'documentEnd': - this.#renderSelections([ + this.setSelections([ this.#getDocumentBoundarySelection(command === 'documentEnd'), ]); break; From 9a3eb6a0bc35e1a06ee87cc5b769648e1af2351b Mon Sep 17 00:00:00 2001 From: Je Xia Date: Thu, 30 Apr 2026 00:16:55 +0800 Subject: [PATCH 031/138] Add `FileContentsWithLineOffsets` interface and update related components to support line offsets and line count. Refactor file handling to utilize computed line offsets for rendering and iteration. --- packages/diffs/src/components/File.ts | 12 ++- .../diffs/src/components/VirtualizedFile.ts | 8 +- packages/diffs/src/renderers/FileRenderer.ts | 26 +++--- packages/diffs/src/types.ts | 12 ++- packages/diffs/src/utils/cleanLastNewline.ts | 9 +- .../diffs/src/utils/computeFileOffsets.ts | 36 ++++++++ packages/diffs/src/utils/getLineText.ts | 20 +++++ packages/diffs/src/utils/iterateOverFile.ts | 19 +++-- .../src/utils/renderFileWithHighlighter.ts | 27 +++--- .../diffs/src/worker/WorkerPoolManager.ts | 3 +- packages/diffs/test/fileLineUtils.test.ts | 83 +++++++++++++++++++ packages/diffs/test/iterateOverFile.test.ts | 61 +++++++++----- 12 files changed, 250 insertions(+), 66 deletions(-) create mode 100644 packages/diffs/src/utils/computeFileOffsets.ts create mode 100644 packages/diffs/src/utils/getLineText.ts create mode 100644 packages/diffs/test/fileLineUtils.test.ts diff --git a/packages/diffs/src/components/File.ts b/packages/diffs/src/components/File.ts index 256620adb..ea977e2ff 100644 --- a/packages/diffs/src/components/File.ts +++ b/packages/diffs/src/components/File.ts @@ -26,6 +26,7 @@ import type { BaseCodeOptions, EditorHook, FileContents, + FileContentsWithLineOffsets, LineAnnotation, PrePropertiesConfig, RenderFileMetadata, @@ -48,7 +49,12 @@ import { setPreNodeProperties } from '../utils/setWrapperNodeProps'; import type { WorkerPoolManager } from '../worker'; import { DiffsContainerLoaded } from './web-components'; -const EMPTY_STRINGS: string[] = []; +const EMPTY_FILE: FileContentsWithLineOffsets = { + name: '', + contents: '', + offsets: [], + lineCount: 0, +}; export interface FileRenderProps { file: FileContents; @@ -374,10 +380,10 @@ export class File { public getOrCreateLineCache( file: FileContents | undefined = this.file - ): string[] { + ): FileContentsWithLineOffsets { return file != null ? this.fileRenderer.getOrCreateLineCache(file) - : EMPTY_STRINGS; + : EMPTY_FILE; } public render({ diff --git a/packages/diffs/src/components/VirtualizedFile.ts b/packages/diffs/src/components/VirtualizedFile.ts index 1651963ab..5672f7836 100644 --- a/packages/diffs/src/components/VirtualizedFile.ts +++ b/packages/diffs/src/components/VirtualizedFile.ts @@ -186,10 +186,10 @@ export class VirtualizedFile< } if (overflow === 'scroll' && this.lineAnnotations.length === 0) { - this.height += this.getOrCreateLineCache(this.file).length * lineHeight; + this.height += lines.lineCount * lineHeight; } else { iterateOverFile({ - lines, + lines: lines, callback: ({ lineIndex }) => { this.height += this.getLineHeight(lineIndex, false); }, @@ -197,7 +197,7 @@ export class VirtualizedFile< } // Bottom padding - if (lines.length > 0) { + if (lines.lineCount > 0) { this.height += fileGap; } @@ -296,7 +296,7 @@ export class VirtualizedFile< const { diffHeaderHeight, fileGap, hunkLineCount, lineHeight } = this.metrics; const lines = this.getOrCreateLineCache(file); - const lineCount = lines.length; + const lineCount = lines.lineCount; const fileHeight = this.height; const headerRegion = disableFileHeader ? fileGap : diffHeaderHeight; diff --git a/packages/diffs/src/renderers/FileRenderer.ts b/packages/diffs/src/renderers/FileRenderer.ts index 98e6dff3b..7cab9b074 100644 --- a/packages/diffs/src/renderers/FileRenderer.ts +++ b/packages/diffs/src/renderers/FileRenderer.ts @@ -13,6 +13,7 @@ import type { BaseCodeOptions, DiffsHighlighter, FileContents, + FileContentsWithLineOffsets, FileHeaderRenderMode, LineAnnotation, RenderedFileASTCache, @@ -24,6 +25,7 @@ import type { } from '../types'; import { areRenderRangesEqual } from '../utils/areRenderRangesEqual'; import { areThemesEqual } from '../utils/areThemesEqual'; +import { computeLineOffsets } from '../utils/computeFileOffsets'; import { createAnnotationElement } from '../utils/createAnnotationElement'; import { createContentColumn } from '../utils/createContentColumn'; import { createFileHeaderElement } from '../utils/createFileHeaderElement'; @@ -42,7 +44,6 @@ import { isFilePlainText } from '../utils/isFilePlainText'; import { iterateOverFile } from '../utils/iterateOverFile'; import { renderFileWithHighlighter } from '../utils/renderFileWithHighlighter'; import { shouldUseTokenTransformer } from '../utils/shouldUseTokenTransformer'; -import { splitFileContents } from '../utils/splitFileContents'; import type { WorkerPoolManager } from '../worker'; type AnnotationLineMap = Record< @@ -69,11 +70,6 @@ export interface FileRenderResult { bufferAfter: number; } -interface LineCache { - cacheKey: string | undefined; - lines: string[]; -} - export interface FileRendererOptions extends BaseCodeOptions { headerRenderMode?: FileHeaderRenderMode; } @@ -87,7 +83,7 @@ export class FileRenderer { private renderCache: RenderedFileASTCache | undefined; private computedLang: SupportedLanguages = 'text'; private lineAnnotations: AnnotationLineMap = {}; - private lineCache: LineCache | undefined; + private lineCache: FileContentsWithLineOffsets | undefined; constructor( public options: FileRendererOptions = { theme: DEFAULT_THEMES }, @@ -181,23 +177,20 @@ export class FileRenderer { return { options, forceRender: false }; } - public getOrCreateLineCache(file: FileContents): string[] { + public getOrCreateLineCache(file: FileContents): FileContentsWithLineOffsets { // Uncached files will get split every time, not the greatest experience // tbh... but something people should try to optimize away if (file.cacheKey == null) { this.lineCache = undefined; - return splitFileContents(file.contents); + return computeLineOffsets(file); } let { lineCache } = this; if (lineCache == null || lineCache.cacheKey !== file.cacheKey) { - lineCache = { - cacheKey: file.cacheKey, - lines: splitFileContents(file.contents), - }; + lineCache = computeLineOffsets(file); } this.lineCache = lineCache; - return lineCache.lines; + return lineCache; } public renderFile( @@ -350,6 +343,7 @@ export class FileRenderer { const contentArray: ElementContent[] = []; const gutter = createGutterWrapper(); const lines = this.getOrCreateLineCache(file); + const totalLines = lines.lineCount; let rowCount = 0; iterateOverFile({ @@ -403,9 +397,9 @@ export class FileRenderer { return { gutterAST: gutter.children ?? [], contentAST: contentArray, - preAST: this.createPreElement(lines.length), + preAST: this.createPreElement(totalLines), headerAST: !disableFileHeader ? this.renderHeader(file) : undefined, - totalLines: lines.length, + totalLines, rowCount, themeStyles: themeStyles, baseThemeType, diff --git a/packages/diffs/src/types.ts b/packages/diffs/src/types.ts index 0b6797f6b..ae39c3d87 100644 --- a/packages/diffs/src/types.ts +++ b/packages/diffs/src/types.ts @@ -35,6 +35,16 @@ export interface FileContents { cacheKey?: string; } +/** + * Represents a file's contents with line offsets. + */ +export interface FileContentsWithLineOffsets extends FileContents { + /** The line offsets for the file contents. */ + readonly offsets: number[]; + /** The number of lines in the file. */ + readonly lineCount: number; +} + export type HighlighterTypes = 'shiki-js' | 'shiki-wasm'; export type { @@ -623,7 +633,7 @@ export interface ForceFilePlainTextOptions { startingLine?: number; totalLines?: number; // Pre-split lines for caching in windowing scenarios - lines?: string[]; + lines?: FileContentsWithLineOffsets; } export interface RenderFileOptions { diff --git a/packages/diffs/src/utils/cleanLastNewline.ts b/packages/diffs/src/utils/cleanLastNewline.ts index 7a6220247..f78b42cab 100644 --- a/packages/diffs/src/utils/cleanLastNewline.ts +++ b/packages/diffs/src/utils/cleanLastNewline.ts @@ -1,3 +1,10 @@ export function cleanLastNewline(contents: string): string { - return contents.replace(/\n$|\r\n$/, ''); + let end = contents.length; + if (contents.charAt(end - 1) === '\n') { + end--; + if (contents.charAt(end - 1) === '\r') { + end--; + } + } + return contents.slice(0, end); } diff --git a/packages/diffs/src/utils/computeFileOffsets.ts b/packages/diffs/src/utils/computeFileOffsets.ts new file mode 100644 index 000000000..3149361fc --- /dev/null +++ b/packages/diffs/src/utils/computeFileOffsets.ts @@ -0,0 +1,36 @@ +import type { FileContents, FileContentsWithLineOffsets } from '../types'; + +const LINE_FEED = 10; // \n +const CARRIAGE_RETURN = 13; // \r + +/** + * Computes the start offset of each renderable line plus a final end offset. + * A terminal newline remains part of the previous line, matching splitFileContents. + */ +export function computeLineOffsets( + file: FileContents +): FileContentsWithLineOffsets { + const { contents } = file; + const offsets = []; + if (contents.length > 0) { + offsets.push(0); + } + for (let i = 0; i < contents.length; i++) { + const char = contents.charCodeAt(i); + if (char === LINE_FEED || char === CARRIAGE_RETURN) { + if ( + char === CARRIAGE_RETURN && + i + 1 < contents.length && + contents.charCodeAt(i + 1) === LINE_FEED + ) { + i++; + } + offsets.push(i + 1); + } + } + return { + ...file, + offsets, + lineCount: offsets.length - 1, + }; +} diff --git a/packages/diffs/src/utils/getLineText.ts b/packages/diffs/src/utils/getLineText.ts new file mode 100644 index 000000000..1352c9a5d --- /dev/null +++ b/packages/diffs/src/utils/getLineText.ts @@ -0,0 +1,20 @@ +import type { FileContentsWithLineOffsets } from '../types'; + +/** + * Gets the text of a line in a file. + * @param file - The file to get the text of. + * @param lineIndex - The index of the line to get the text of. + * @returns The text of the line. + */ +export function getLineText( + file: FileContentsWithLineOffsets, + lineIndex: number +): string { + if (lineIndex < 0 || lineIndex >= file.lineCount) { + throw new Error(`Line index out of range: ${lineIndex}`); + } + return file.contents.slice( + file.offsets[lineIndex], + file.offsets[lineIndex + 1] ?? file.contents.length + ); +} diff --git a/packages/diffs/src/utils/iterateOverFile.ts b/packages/diffs/src/utils/iterateOverFile.ts index 60347eedf..752802949 100644 --- a/packages/diffs/src/utils/iterateOverFile.ts +++ b/packages/diffs/src/utils/iterateOverFile.ts @@ -1,5 +1,8 @@ +import type { FileContentsWithLineOffsets } from '../types'; +import { getLineText } from './getLineText'; + export interface IterateOverFileProps { - lines: string[]; + lines: FileContentsWithLineOffsets; startingLine?: number; totalLines?: number; callback: FileLineCallback; @@ -47,21 +50,25 @@ export function iterateOverFile({ totalLines = Infinity, callback, }: IterateOverFileProps): void { + const lineCount = lines.lineCount; + if (lineCount === 0) { + return; + } // Calculate viewport window - const len = Math.min(startingLine + totalLines, lines.length); + const len = Math.min(startingLine + totalLines, lineCount); // CLAUDE: DO NOT CHANGE THIS LOGIC UNDER ANY // CIRCUMSTANCE CHEESE N RICE const lastLineIndex = (() => { - const lastLine = lines.at(-1); + const lastLine = getLineText(lines, lineCount - 1); if ( lastLine === '' || lastLine === '\n' || lastLine === '\r\n' || lastLine === '\r' ) { - return Math.max(0, lines.length - 2); + return Math.max(0, lineCount - 2); } - return lines.length - 1; + return lineCount - 1; })(); // Iterate through windowed range @@ -71,7 +78,7 @@ export function iterateOverFile({ callback({ lineIndex, lineNumber: lineIndex + 1, - content: lines[lineIndex], + content: getLineText(lines, lineIndex), isLastLine, }) === true || isLastLine diff --git a/packages/diffs/src/utils/renderFileWithHighlighter.ts b/packages/diffs/src/utils/renderFileWithHighlighter.ts index 5b4f83656..7c11dc2ef 100644 --- a/packages/diffs/src/utils/renderFileWithHighlighter.ts +++ b/packages/diffs/src/utils/renderFileWithHighlighter.ts @@ -4,18 +4,18 @@ import type { DiffsHighlighter, DiffsThemeNames, FileContents, + FileContentsWithLineOffsets, ForceFilePlainTextOptions, RenderFileOptions, ThemedFileResult, } from '../types'; import { cleanLastNewline } from './cleanLastNewline'; +import { computeLineOffsets } from './computeFileOffsets'; import { createTransformerWithState } from './createTransformerWithState'; import { formatCSSVariablePrefix } from './formatCSSVariablePrefix'; import { getFiletypeFromFileName } from './getFiletypeFromFileName'; import { getHighlighterThemeStyles } from './getHighlighterThemeStyles'; import { getLineNodes } from './getLineNodes'; -import { iterateOverFile } from './iterateOverFile'; -import { splitFileContents } from './splitFileContents'; const DEFAULT_PLAIN_TEXT_OPTIONS: ForceFilePlainTextOptions = { forcePlainText: false, @@ -85,10 +85,12 @@ export function renderFileWithHighlighter( }; })(); const highlightedLines = getLineNodes( + // TODO(@ije): use `grammar.tokenizeLine2` to replace `codeToHast` for better performance, + // use lines.offsets for line text extraction without concatenating strings highlighter.codeToHast( isWindowedHighlight ? extractWindowedFileContent( - lines ?? splitFileContents(file.contents), + lines ?? computeLineOffsets(file), startingLine, totalLines ) @@ -107,18 +109,15 @@ export function renderFileWithHighlighter( } function extractWindowedFileContent( - lines: string[], + lines: FileContentsWithLineOffsets, startingLine: number, totalLines: number ): string { - let windowContent: string = ''; - iterateOverFile({ - lines, - startingLine, - totalLines, - callback({ content }) { - windowContent += content; - }, - }); - return windowContent; + if (lines.lineCount === 0) { + return ''; + } + const endLine = Math.min(startingLine + totalLines, lines.lineCount); + const startOffset = lines.offsets[startingLine] ?? lines.contents.length; + const endOffset = lines.offsets[endLine] ?? lines.contents.length; + return lines.contents.slice(startOffset, endOffset); } diff --git a/packages/diffs/src/worker/WorkerPoolManager.ts b/packages/diffs/src/worker/WorkerPoolManager.ts index 405659bfa..1367eede6 100644 --- a/packages/diffs/src/worker/WorkerPoolManager.ts +++ b/packages/diffs/src/worker/WorkerPoolManager.ts @@ -12,6 +12,7 @@ import { resolveThemes } from '../highlighter/themes/resolveThemes'; import type { DiffsHighlighter, FileContents, + FileContentsWithLineOffsets, FileDiffMetadata, HighlighterTypes, HunkExpansionRegion, @@ -546,7 +547,7 @@ export class WorkerPoolManager { file: FileContents, startingLine: number, totalLines: number, - lines?: string[] + lines?: FileContentsWithLineOffsets ): ThemedFileResult | undefined { if (this.highlighter == null) { this.queueInitialization(); diff --git a/packages/diffs/test/fileLineUtils.test.ts b/packages/diffs/test/fileLineUtils.test.ts new file mode 100644 index 000000000..b46a10fd0 --- /dev/null +++ b/packages/diffs/test/fileLineUtils.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, test } from 'bun:test'; + +import { computeLineOffsets } from '../src/utils/computeFileOffsets'; +import { getLineText } from '../src/utils/getLineText'; + +describe('computeLineOffsets', () => { + test('returns no offsets for empty contents', () => { + const result = computeLineOffsets({ + name: 'empty.ts', + contents: '', + }); + + expect(result.offsets).toEqual([]); + expect(result.lineCount).toBe(0); + }); + + test('computes offsets for single line without trailing newline', () => { + const result = computeLineOffsets({ + name: 'single.ts', + contents: 'hello', + }); + + expect(result.offsets).toEqual([0, 5]); + expect(result.lineCount).toBe(1); + }); + + test('computes offsets for LF files with and without terminal newline', () => { + const withTerminalNewline = computeLineOffsets({ + name: 'lf-terminal.ts', + contents: 'a\nb\n', + }); + const withoutTerminalNewline = computeLineOffsets({ + name: 'lf-no-terminal.ts', + contents: 'a\nb', + }); + + expect(withTerminalNewline.offsets).toEqual([0, 2, 4]); + expect(withTerminalNewline.lineCount).toBe(2); + expect(withoutTerminalNewline.offsets).toEqual([0, 2, 3]); + expect(withoutTerminalNewline.lineCount).toBe(2); + }); + + test('computes offsets for CRLF and lone CR line endings', () => { + const crlf = computeLineOffsets({ + name: 'crlf.ts', + contents: 'a\r\nb\r\n', + }); + const mixed = computeLineOffsets({ + name: 'mixed.ts', + contents: 'a\rb\r\nc\n', + }); + + expect(crlf.offsets).toEqual([0, 3, 6]); + expect(crlf.lineCount).toBe(2); + expect(mixed.offsets).toEqual([0, 2, 5, 7]); + expect(mixed.lineCount).toBe(3); + }); +}); + +describe('getLineText', () => { + test('returns line text using computed offsets', () => { + const lines = computeLineOffsets({ + name: 'lines.ts', + contents: 'first\nsecond\nthird', + }); + + expect(getLineText(lines, 0)).toBe('first\n'); + expect(getLineText(lines, 1)).toBe('second\n'); + expect(getLineText(lines, 2)).toBe('third'); + }); + + test('throws when line index is outside valid range', () => { + const lines = computeLineOffsets({ + name: 'bounds.ts', + contents: 'line', + }); + + expect(() => getLineText(lines, -1)).toThrow('Line index out of range: -1'); + expect(() => getLineText(lines, lines.lineCount)).toThrow( + `Line index out of range: ${lines.lineCount}` + ); + }); +}); diff --git a/packages/diffs/test/iterateOverFile.test.ts b/packages/diffs/test/iterateOverFile.test.ts index a55f4ddcc..07c915811 100644 --- a/packages/diffs/test/iterateOverFile.test.ts +++ b/packages/diffs/test/iterateOverFile.test.ts @@ -1,14 +1,17 @@ import { describe, expect, test } from 'bun:test'; +import { computeLineOffsets } from '../src/utils/computeFileOffsets'; import { type FileLineCallbackProps, iterateOverFile, } from '../src/utils/iterateOverFile'; -import { splitFileContents } from '../src/utils/splitFileContents'; describe('iterateOverFile', () => { test('basic iteration', () => { - const lines = splitFileContents('line1\nline2\nline3\nline4\nline5'); + const lines = computeLineOffsets({ + name: 'test.txt', + contents: 'line1\nline2\nline3\nline4\nline5', + }); const results: FileLineCallbackProps[] = []; iterateOverFile({ @@ -46,7 +49,7 @@ describe('iterateOverFile', () => { }); test('empty file', () => { - const lines = splitFileContents(''); + const lines = computeLineOffsets({ name: 'test.txt', contents: '' }); const results: FileLineCallbackProps[] = []; iterateOverFile({ @@ -60,7 +63,10 @@ describe('iterateOverFile', () => { }); test('single line file', () => { - const lines = splitFileContents('only line'); + const lines = computeLineOffsets({ + name: 'test.txt', + contents: 'only line', + }); const results: FileLineCallbackProps[] = []; iterateOverFile({ @@ -78,7 +84,10 @@ describe('iterateOverFile', () => { }); test('preserves empty lines', () => { - const lines = splitFileContents('line1\n\nline3\n\n\nline6'); + const lines = computeLineOffsets({ + name: 'test.txt', + contents: 'line1\n\nline3\n\n\nline6', + }); const results: string[] = []; iterateOverFile({ @@ -93,12 +102,13 @@ describe('iterateOverFile', () => { }); test('windowing', () => { - const lines = splitFileContents( - Array(100) + const lines = computeLineOffsets({ + name: 'test.txt', + contents: Array(100) .fill(0) .map((_, i) => `line${i}`) - .join('\n') - ); + .join('\n'), + }); // Windowing from start let results: number[] = []; @@ -125,7 +135,10 @@ describe('iterateOverFile', () => { expect(results).toEqual([50, 51, 52, 53, 54, 55, 56, 57, 58, 59]); // Windowing past end - request more lines than available - const shortLines = splitFileContents('line1\nline2\nline3\nline4\nline5'); + const shortLines = computeLineOffsets({ + name: 'test.txt', + contents: 'line1\nline2\nline3\nline4\nline5', + }); results = []; iterateOverFile({ lines: shortLines, @@ -151,7 +164,10 @@ describe('iterateOverFile', () => { }); test('last new line is not iterated over', () => { - const lines = splitFileContents('line1\nline2\nline3\n\n\n'); + const lines = computeLineOffsets({ + name: 'test.txt', + contents: 'line1\nline2\nline3\n\n\n', + }); const results: string[] = []; iterateOverFile({ @@ -167,12 +183,13 @@ describe('iterateOverFile', () => { }); test('isLastLine with windowing', () => { - const lines = splitFileContents( - Array(10) + const lines = computeLineOffsets({ + name: 'test.txt', + contents: Array(10) .fill(0) .map((_, i) => `line${i}`) - .join('\n') - ); + .join('\n'), + }); // Window lines 5-7 (not including the actual last line of the file) const results: FileLineCallbackProps[] = []; @@ -208,12 +225,13 @@ describe('iterateOverFile', () => { }); test('early termination', () => { - const lines = splitFileContents( - Array(100) + const lines = computeLineOffsets({ + name: 'test.txt', + contents: Array(100) .fill(0) .map((_, i) => `line${i}`) - .join('\n') - ); + .join('\n'), + }); // Returning true stops iteration let results: number[] = []; @@ -230,7 +248,10 @@ describe('iterateOverFile', () => { expect(results).toEqual([0, 1, 2, 3, 4]); // Returning false continues - const shortLines = splitFileContents('a\nb\nc\nd\ne'); + const shortLines = computeLineOffsets({ + name: 'test.txt', + contents: 'a\nb\nc\nd\ne', + }); results = []; iterateOverFile({ lines: shortLines, From 3b4d3b4ac2a07155069ec62a8e0412ee11c45cb0 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Thu, 30 Apr 2026 00:17:11 +0800 Subject: [PATCH 032/138] Add `updateRenderCacheAt` method to `FileRenderer` and `File` classes for improved rendering. Refactor theme handling in `Editor` to utilize a dedicated method for color map retrieval. --- packages/diffs/src/components/File.ts | 7 ++ packages/diffs/src/editor/index.ts | 73 +++++++++++--------- packages/diffs/src/renderers/FileRenderer.ts | 34 +++++++++ 3 files changed, 83 insertions(+), 31 deletions(-) diff --git a/packages/diffs/src/components/File.ts b/packages/diffs/src/components/File.ts index ea977e2ff..e9cb6c72f 100644 --- a/packages/diffs/src/components/File.ts +++ b/packages/diffs/src/components/File.ts @@ -386,6 +386,13 @@ export class File { : EMPTY_FILE; } + public updateRenderCacheAt( + line: number, + tokens: Array<[char: number, style: string, text: string]> + ): void { + this.fileRenderer.updateRenderCacheAt(line, tokens); + } + public render({ file, fileContainer, diff --git a/packages/diffs/src/editor/index.ts b/packages/diffs/src/editor/index.ts index 0a9c8a6f9..10ec59567 100644 --- a/packages/diffs/src/editor/index.ts +++ b/packages/diffs/src/editor/index.ts @@ -366,7 +366,6 @@ export class Editor { if (this.#highlighter !== undefined) { const t = performance.now(); - const { theme = DEFAULT_THEMES } = file.options; const prevLines = this.#textLinesCache ?? []; const { startingLine = 0, totalLines = Infinity } = @@ -382,6 +381,7 @@ export class Editor { const compareEndLine = Math.max(endLine, prevEndLine); const dirtyLines = new Set(); const linesChange = textDocument.lineCount - prevLines.length; + let dirtyLineStart = -1; let dirtyLineEnd = -1; for (let line = startingLine; line < compareEndLine; line++) { @@ -400,32 +400,11 @@ export class Editor { } } } + for (let line = endLine; line < prevEndLine; line++) { this.#getLineElement(line)?.remove(); } - let themeName: string; - let themeType = file.options.themeType ?? 'system'; - if (themeType === 'system') { - themeType = window.matchMedia('(prefers-color-scheme: dark)').matches - ? 'dark' - : 'light'; - } - if (typeof theme === 'string') { - themeName = theme; - } else { - themeName = theme[themeType]; - } - let colorMap = this.#colorMap?.get(themeName); - if (colorMap === undefined) { - const ret = this.#highlighter.setTheme(themeName); - colorMap = ret.colorMap; - (this.#colorMap ?? (this.#colorMap = new Map())).set( - themeName, - ret.colorMap ?? [] - ); - } - const grammar = this.#highlighter.getLanguage(textDocument.languageId); const previousStateStackCache = this.#stateStackCache; if (dirtyLineStart !== -1) { @@ -454,6 +433,11 @@ export class Editor { } }; + const colorMap = { + dark: this.#getThemeColorMap('dark'), + light: this.#getThemeColorMap('light'), + }; + let state = dirtyLineStart === -1 ? INITIAL @@ -520,30 +504,36 @@ export class Editor { ); } if (shouldUpdateLineEl) { - const tokens = result.tokens; + const rawTokens = result.tokens; const lineLength = lineText.length; - const tokensLength = tokens.length / 2; + const tokensLength = rawTokens.length / 2; + const tokens: [char: number, style: string, text: string][] = []; const spans: Element[] = []; for (let j = 0; j < tokensLength; j++) { - const offset = tokens[2 * j]; + const offset = rawTokens[2 * j]; const nextOffset = - j + 1 < tokensLength ? tokens[2 * j + 2] : lineLength; + j + 1 < tokensLength ? rawTokens[2 * j + 2] : lineLength; if (offset === nextOffset) { - // empty token ? + // should never reach here, skip if happens anyway continue; } - const metadata = tokens[2 * j + 1]; - const fg = colorMap[EncodedTokenMetadata.getForeground(metadata)]; + const metadata = rawTokens[2 * j + 1]; + const bg = EncodedTokenMetadata.getForeground(metadata); + const darkFG = colorMap.dark[bg]; + const lightFG = colorMap.light[bg]; + const cssText = `--diffs-token-dark:${darkFG};--diffs-token-light:${lightFG}`; const tokenText = lineText.slice(offset, nextOffset); + tokens.push([offset, cssText, tokenText]); spans.push( createElement('span', { dataset: { char: String(offset) }, - style: { cssText: `--diffs-token-${themeType}:${fg}` }, + style: { cssText }, textContent: tokenText, }) ); } updateLineEl(line, spans); + this.#file?.updateRenderCacheAt(line, tokens); } state = result.ruleStack; this.#stateStackCache[line + 1] = state; @@ -574,6 +564,27 @@ export class Editor { } } + #getThemeColorMap(themeType: 'dark' | 'light'): string[] { + if (this.#highlighter === undefined || this.#file === undefined) { + throw new Error('editor not initialized'); + } + let themeName: string; + const { theme = DEFAULT_THEMES } = this.#file.options; + if (typeof theme === 'string') { + themeName = theme; + } else { + themeName = theme[themeType]; + } + this.#colorMap ??= new Map(); + let colorMap = this.#colorMap.get(themeName); + if (colorMap === undefined) { + const ret = this.#highlighter.setTheme(themeName); + colorMap = ret.colorMap; + this.#colorMap.set(themeName, ret.colorMap ?? []); + } + return colorMap; + } + #buildStateStackCache( textDocument: TextDocument, grammar: ReturnType, diff --git a/packages/diffs/src/renderers/FileRenderer.ts b/packages/diffs/src/renderers/FileRenderer.ts index 7cab9b074..4c0555abf 100644 --- a/packages/diffs/src/renderers/FileRenderer.ts +++ b/packages/diffs/src/renderers/FileRenderer.ts @@ -193,6 +193,40 @@ export class FileRenderer { return lineCache; } + public updateRenderCacheAt( + line: number, + tokens: Array<[char: number, style: string, text: string]> + ): void { + console.log('updateRenderCacheAt', line, tokens); + if (this.renderCache != null && this.renderCache.result != null) { + this.renderCache.result.code[line] = { + type: 'element', + tagName: 'div', + properties: { + 'data-line': line + 1, + 'data-line-index': line, + 'data-line-type': 'context', + }, + children: tokens.map(([char, style, text]) => { + return { + type: 'element', + tagName: 'span', + properties: { + 'data-char': char, + style, + }, + children: [ + { + type: 'text', + value: text, + }, + ], + }; + }), + }; + } + } + public renderFile( file: FileContents | undefined = this.renderCache?.file, renderRange: RenderRange = DEFAULT_RENDER_RANGE From fc6a47ea48479a25707cd66fd0e7e872f99044d5 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Thu, 30 Apr 2026 00:41:07 +0800 Subject: [PATCH 033/138] Refactor file iteration logic by removing `iterateOverFile` utility and replacing it with direct loops in `VirtualizedFile` and `FileRenderer` components. Update line offset computation to exclude trailing newlines in multi-line files while maintaining correct line counts. Enhance tests to validate line counting behavior. --- .../diffs/src/components/VirtualizedFile.ts | 75 +++-- packages/diffs/src/editor/index.ts | 1 - packages/diffs/src/renderers/FileRenderer.ts | 95 +++--- .../diffs/src/utils/computeFileOffsets.ts | 36 ++- packages/diffs/src/utils/iterateOverFile.ts | 89 ------ packages/diffs/test/FileRenderer.ast.test.ts | 14 + packages/diffs/test/fileLineUtils.test.ts | 30 ++ packages/diffs/test/iterateOverFile.test.ts | 276 ------------------ 8 files changed, 158 insertions(+), 458 deletions(-) delete mode 100644 packages/diffs/src/utils/iterateOverFile.ts delete mode 100644 packages/diffs/test/iterateOverFile.test.ts diff --git a/packages/diffs/src/components/VirtualizedFile.ts b/packages/diffs/src/components/VirtualizedFile.ts index 5672f7836..6fe855afd 100644 --- a/packages/diffs/src/components/VirtualizedFile.ts +++ b/packages/diffs/src/components/VirtualizedFile.ts @@ -5,7 +5,6 @@ import type { RenderWindow, VirtualFileMetrics, } from '../types'; -import { iterateOverFile } from '../utils/iterateOverFile'; import type { WorkerPoolManager } from '../worker'; import { File, type FileOptions, type FileRenderProps } from './File'; import type { Virtualizer } from './Virtualizer'; @@ -188,12 +187,9 @@ export class VirtualizedFile< if (overflow === 'scroll' && this.lineAnnotations.length === 0) { this.height += lines.lineCount * lineHeight; } else { - iterateOverFile({ - lines: lines, - callback: ({ lineIndex }) => { - this.height += this.getLineHeight(lineIndex, false); - }, - }); + for (let lineIndex = 0; lineIndex < lines.lineCount; lineIndex++) { + this.height += this.getLineHeight(lineIndex, false); + } } // Bottom padding @@ -377,50 +373,45 @@ export class VirtualizedFile< let centerHunk: number | undefined; let overflowCounter: number | undefined; - iterateOverFile({ - lines, - callback: ({ lineIndex }) => { - const isAtHunkBoundary = currentLine % hunkLineCount === 0; + for (let lineIndex = 0; lineIndex < lineCount; lineIndex++) { + const isAtHunkBoundary = currentLine % hunkLineCount === 0; - if (isAtHunkBoundary) { - hunkOffsets.push(absoluteLineTop - (fileTop + headerRegion)); + if (isAtHunkBoundary) { + hunkOffsets.push(absoluteLineTop - (fileTop + headerRegion)); - if (overflowCounter != null) { - if (overflowCounter <= 0) { - return true; - } - overflowCounter--; + if (overflowCounter != null) { + if (overflowCounter <= 0) { + break; } + overflowCounter--; } + } - const lineHeight = this.getLineHeight(lineIndex, false); - const currentHunk = Math.floor(currentLine / hunkLineCount); - - // Track visible region - if (absoluteLineTop > top - lineHeight && absoluteLineTop < bottom) { - firstVisibleHunk ??= currentHunk; - } + const lineHeight = this.getLineHeight(lineIndex, false); + const currentHunk = Math.floor(currentLine / hunkLineCount); - // Track which hunk contains the viewport center - if (absoluteLineTop + lineHeight > viewportCenter) { - centerHunk ??= currentHunk; - } + // Track visible region + if (absoluteLineTop > top - lineHeight && absoluteLineTop < bottom) { + firstVisibleHunk ??= currentHunk; + } - // Start overflow when we are out of the viewport at a hunk boundary - if ( - overflowCounter == null && - absoluteLineTop >= bottom && - isAtHunkBoundary - ) { - overflowCounter = overflowHunks; - } + // Track which hunk contains the viewport center + if (absoluteLineTop + lineHeight > viewportCenter) { + centerHunk ??= currentHunk; + } - currentLine++; - absoluteLineTop += lineHeight; + // Start overflow when we are out of the viewport at a hunk boundary + if ( + overflowCounter == null && + absoluteLineTop >= bottom && + isAtHunkBoundary + ) { + overflowCounter = overflowHunks; + } - return false; - }, - }); + currentLine++; + absoluteLineTop += lineHeight; + } // No visible lines found if (firstVisibleHunk == null) { diff --git a/packages/diffs/src/editor/index.ts b/packages/diffs/src/editor/index.ts index 10ec59567..1f6d153e6 100644 --- a/packages/diffs/src/editor/index.ts +++ b/packages/diffs/src/editor/index.ts @@ -711,7 +711,6 @@ export class Editor { } #updateTextarea(primarySelection: EditorSelection) { - console.log('updateTextarea'); const textDocument = this.#textDocument; const textareaEl = this.#textareaEl; if (textDocument === undefined || textareaEl === undefined) { diff --git a/packages/diffs/src/renderers/FileRenderer.ts b/packages/diffs/src/renderers/FileRenderer.ts index 4c0555abf..eba1821a9 100644 --- a/packages/diffs/src/renderers/FileRenderer.ts +++ b/packages/diffs/src/renderers/FileRenderer.ts @@ -41,7 +41,6 @@ import { createHastElement, } from '../utils/hast_utils'; import { isFilePlainText } from '../utils/isFilePlainText'; -import { iterateOverFile } from '../utils/iterateOverFile'; import { renderFileWithHighlighter } from '../utils/renderFileWithHighlighter'; import { shouldUseTokenTransformer } from '../utils/shouldUseTokenTransformer'; import type { WorkerPoolManager } from '../worker'; @@ -197,7 +196,6 @@ export class FileRenderer { line: number, tokens: Array<[char: number, style: string, text: string]> ): void { - console.log('updateRenderCacheAt', line, tokens); if (this.renderCache != null && this.renderCache.result != null) { this.renderCache.result.code[line] = { type: 'element', @@ -378,53 +376,56 @@ export class FileRenderer { const gutter = createGutterWrapper(); const lines = this.getOrCreateLineCache(file); const totalLines = lines.lineCount; + const endLine = Math.min( + renderRange.startingLine + renderRange.totalLines, + lines.lineCount + ); let rowCount = 0; - iterateOverFile({ - lines, - startingLine: renderRange.startingLine, - totalLines: renderRange.totalLines, - callback: ({ lineIndex, lineNumber }) => { - // Sparse array - directly indexed by lineIndex - const line = code[lineIndex]; - if (line == null) { - const message = 'FileRenderer.processFileResult: Line doesnt exist'; - console.error(message, { - name: file.name, - lineIndex, - lineNumber, - lines, - }); - throw new Error(message); - } - - if (line != null) { - // Add gutter line number - gutter.children.push( - createGutterItem('context', lineNumber, `${lineIndex}`) - ); - contentArray.push(line); - rowCount++; - - // Check annotations using ACTUAL line number from file - const annotations = this.lineAnnotations[lineNumber]; - if (annotations != null) { - gutter.children.push(createGutterGap('context', 'annotation', 1)); - contentArray.push( - createAnnotationElement({ - type: 'annotation', - hunkIndex: 0, - lineIndex: lineNumber, - annotations: annotations.map((annotation) => - getLineAnnotationName(annotation) - ), - }) - ); - rowCount++; - } - } - }, - }); + for ( + let lineIndex = renderRange.startingLine; + lineIndex < endLine; + lineIndex++ + ) { + const lineNumber = lineIndex + 1; + + // Sparse array - directly indexed by lineIndex + const line = code[lineIndex]; + if (line == null) { + const message = 'FileRenderer.processFileResult: Line doesnt exist'; + console.error(message, { + name: file.name, + lineIndex, + lineNumber, + lines, + }); + throw new Error(message); + } + + // Add gutter line number + gutter.children.push( + createGutterItem('context', lineNumber, `${lineIndex}`) + ); + contentArray.push(line); + rowCount++; + + // Check annotations using ACTUAL line number from file + const annotations = this.lineAnnotations[lineNumber]; + if (annotations != null) { + gutter.children.push(createGutterGap('context', 'annotation', 1)); + contentArray.push( + createAnnotationElement({ + type: 'annotation', + hunkIndex: 0, + lineIndex: lineNumber, + annotations: annotations.map((annotation) => + getLineAnnotationName(annotation) + ), + }) + ); + rowCount++; + } + } // Finalize: wrap gutter and content gutter.properties.style = `grid-row: span ${rowCount}`; diff --git a/packages/diffs/src/utils/computeFileOffsets.ts b/packages/diffs/src/utils/computeFileOffsets.ts index 3149361fc..b108293b7 100644 --- a/packages/diffs/src/utils/computeFileOffsets.ts +++ b/packages/diffs/src/utils/computeFileOffsets.ts @@ -4,8 +4,9 @@ const LINE_FEED = 10; // \n const CARRIAGE_RETURN = 13; // \r /** - * Computes the start offset of each renderable line plus a final end offset. - * A terminal newline remains part of the previous line, matching splitFileContents. + * Computes line start offsets plus a final end offset for slicing line text. + * `lineCount` excludes the final newline-only parser row, except for files + * that contain only that row. */ export function computeLineOffsets( file: FileContents @@ -28,9 +29,38 @@ export function computeLineOffsets( offsets.push(i + 1); } } + if (offsets.length > 0 && offsets[offsets.length - 1] !== contents.length) { + offsets.push(contents.length); + } + const rawLineCount = Math.max(0, offsets.length - 1); + const lineCount = + rawLineCount > 1 && + isNewlineOnlyRange( + contents, + offsets[rawLineCount - 1], + offsets[rawLineCount] + ) + ? rawLineCount - 1 + : rawLineCount; + return { ...file, offsets, - lineCount: offsets.length - 1, + lineCount, }; } + +// Detects the synthetic final row produced by terminal newline characters. +function isNewlineOnlyRange( + contents: string, + startOffset = contents.length, + endOffset = contents.length +): boolean { + for (let offset = startOffset; offset < endOffset; offset++) { + const char = contents.charCodeAt(offset); + if (char !== LINE_FEED && char !== CARRIAGE_RETURN) { + return false; + } + } + return true; +} diff --git a/packages/diffs/src/utils/iterateOverFile.ts b/packages/diffs/src/utils/iterateOverFile.ts deleted file mode 100644 index 752802949..000000000 --- a/packages/diffs/src/utils/iterateOverFile.ts +++ /dev/null @@ -1,89 +0,0 @@ -import type { FileContentsWithLineOffsets } from '../types'; -import { getLineText } from './getLineText'; - -export interface IterateOverFileProps { - lines: FileContentsWithLineOffsets; - startingLine?: number; - totalLines?: number; - callback: FileLineCallback; -} - -export interface FileLineCallbackProps { - lineIndex: number; // 0-based index into lines array - lineNumber: number; // 1-based line number (for display) - content: string; // The line content string - isLastLine: boolean; // True if this is the last line -} - -export type FileLineCallback = (props: FileLineCallbackProps) => boolean | void; - -/** - * Iterates over lines in a file with optional windowing support. - * - * Similar to `iterateOverDiff` but simplified for linear file content. - * Supports viewport windowing for virtualization scenarios. - * - * @param props - Configuration for iteration - * @param props.lines - Pre-split array of lines (use splitFileContents() to create from string) - * @param props.startingLine - Optional starting line index (0-based, default: 0) - * @param props.totalLines - Optional max lines to iterate (default: Infinity) - * @param props.callback - Callback invoked for each line in the window. - * Return `true` to stop iteration early. - * - * @example - * ```typescript - * const lines = splitFileContents('line1\nline2\nline3'); - * iterateOverFile({ - * lines, - * startingLine: 0, - * totalLines: 10, - * callback: ({ lineIndex, lineNumber, content, isLastLine }) => { - * console.log(`Line ${lineNumber}: ${content}`); - * if (content.includes('stop')) return true; // Stop iteration - * } - * }); - * ``` - */ -export function iterateOverFile({ - lines, - startingLine = 0, - totalLines = Infinity, - callback, -}: IterateOverFileProps): void { - const lineCount = lines.lineCount; - if (lineCount === 0) { - return; - } - // Calculate viewport window - const len = Math.min(startingLine + totalLines, lineCount); - // CLAUDE: DO NOT CHANGE THIS LOGIC UNDER ANY - // CIRCUMSTANCE CHEESE N RICE - const lastLineIndex = (() => { - const lastLine = getLineText(lines, lineCount - 1); - if ( - lastLine === '' || - lastLine === '\n' || - lastLine === '\r\n' || - lastLine === '\r' - ) { - return Math.max(0, lineCount - 2); - } - return lineCount - 1; - })(); - - // Iterate through windowed range - for (let lineIndex = startingLine; lineIndex < len; lineIndex++) { - const isLastLine = lineIndex === lastLineIndex; - if ( - callback({ - lineIndex, - lineNumber: lineIndex + 1, - content: getLineText(lines, lineIndex), - isLastLine, - }) === true || - isLastLine - ) { - break; - } - } -} diff --git a/packages/diffs/test/FileRenderer.ast.test.ts b/packages/diffs/test/FileRenderer.ast.test.ts index 56fae37df..8a89fe640 100644 --- a/packages/diffs/test/FileRenderer.ast.test.ts +++ b/packages/diffs/test/FileRenderer.ast.test.ts @@ -147,6 +147,20 @@ describe('FileRenderer AST Structure', () => { expect(result2.totalLines).toBe(file2Lines); }); + test('should render a single line without a trailing newline', async () => { + const instance = new FileRenderer(); + const result = await instance.asyncRender({ + name: 'single-line.txt', + contents: 'hello', + }); + const [gutter, contentColumn] = instance.renderCodeAST(result) as Element[]; + + expect(result.totalLines).toBe(1); + expect(result.rowCount).toBe(1); + expect(gutter.children).toHaveLength(1); + expect(contentColumn.children).toHaveLength(1); + }); + test('should include CSS property in result', async () => { const instance = new FileRenderer(); const result = await instance.asyncRender(mockFiles.file2); diff --git a/packages/diffs/test/fileLineUtils.test.ts b/packages/diffs/test/fileLineUtils.test.ts index b46a10fd0..b37aa3e1f 100644 --- a/packages/diffs/test/fileLineUtils.test.ts +++ b/packages/diffs/test/fileLineUtils.test.ts @@ -81,3 +81,33 @@ describe('getLineText', () => { ); }); }); + +describe('renderable line count', () => { + test('keeps regular final lines', () => { + const lines = computeLineOffsets({ + name: 'lines.ts', + contents: 'first\nsecond', + }); + + expect(lines.lineCount).toBe(2); + }); + + test('excludes one final newline-only row from multi-line files', () => { + const lines = computeLineOffsets({ + name: 'lines.ts', + contents: 'first\nsecond\n\n', + }); + + expect(lines.offsets).toEqual([0, 6, 13, 14]); + expect(lines.lineCount).toBe(2); + }); + + test('keeps a newline-only row when it is the whole file', () => { + const lines = computeLineOffsets({ + name: 'blank.ts', + contents: '\n', + }); + + expect(lines.lineCount).toBe(1); + }); +}); diff --git a/packages/diffs/test/iterateOverFile.test.ts b/packages/diffs/test/iterateOverFile.test.ts deleted file mode 100644 index 07c915811..000000000 --- a/packages/diffs/test/iterateOverFile.test.ts +++ /dev/null @@ -1,276 +0,0 @@ -import { describe, expect, test } from 'bun:test'; - -import { computeLineOffsets } from '../src/utils/computeFileOffsets'; -import { - type FileLineCallbackProps, - iterateOverFile, -} from '../src/utils/iterateOverFile'; - -describe('iterateOverFile', () => { - test('basic iteration', () => { - const lines = computeLineOffsets({ - name: 'test.txt', - contents: 'line1\nline2\nline3\nline4\nline5', - }); - - const results: FileLineCallbackProps[] = []; - iterateOverFile({ - lines, - callback(props) { - results.push(props); - }, - }); - - expect(results).toHaveLength(5); - - // Verify all props on first line - expect(results[0]).toEqual({ - lineIndex: 0, // 0-based - lineNumber: 1, // 1-based - content: 'line1\n', - isLastLine: false, - }); - - // Verify middle line - expect(results[2]).toEqual({ - lineIndex: 2, - lineNumber: 3, - content: 'line3\n', - isLastLine: false, - }); - - // Verify last line (no trailing newline in source) - expect(results[4]).toEqual({ - lineIndex: 4, - lineNumber: 5, - content: 'line5', - isLastLine: true, - }); - }); - - test('empty file', () => { - const lines = computeLineOffsets({ name: 'test.txt', contents: '' }); - - const results: FileLineCallbackProps[] = []; - iterateOverFile({ - lines, - callback(props) { - results.push(props); - }, - }); - - expect(results).toHaveLength(0); - }); - - test('single line file', () => { - const lines = computeLineOffsets({ - name: 'test.txt', - contents: 'only line', - }); - - const results: FileLineCallbackProps[] = []; - iterateOverFile({ - lines, - callback(props) { - results.push(props); - }, - }); - - expect(results).toHaveLength(1); - expect(results[0].isLastLine).toBe(true); - expect(results[0].lineIndex).toBe(0); - expect(results[0].lineNumber).toBe(1); - expect(results[0].content).toBe('only line'); - }); - - test('preserves empty lines', () => { - const lines = computeLineOffsets({ - name: 'test.txt', - contents: 'line1\n\nline3\n\n\nline6', - }); - - const results: string[] = []; - iterateOverFile({ - lines, - callback({ content }) { - results.push(content); - }, - }); - - // Newlines are preserved except on last line - expect(results).toEqual(['line1\n', '\n', 'line3\n', '\n', '\n', 'line6']); - }); - - test('windowing', () => { - const lines = computeLineOffsets({ - name: 'test.txt', - contents: Array(100) - .fill(0) - .map((_, i) => `line${i}`) - .join('\n'), - }); - - // Windowing from start - let results: number[] = []; - iterateOverFile({ - lines, - startingLine: 0, - totalLines: 10, - callback({ lineIndex }) { - results.push(lineIndex); - }, - }); - expect(results).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); - - // Windowing from middle - results = []; - iterateOverFile({ - lines, - startingLine: 50, - totalLines: 10, - callback({ lineIndex }) { - results.push(lineIndex); - }, - }); - expect(results).toEqual([50, 51, 52, 53, 54, 55, 56, 57, 58, 59]); - - // Windowing past end - request more lines than available - const shortLines = computeLineOffsets({ - name: 'test.txt', - contents: 'line1\nline2\nline3\nline4\nline5', - }); - results = []; - iterateOverFile({ - lines: shortLines, - startingLine: 3, - totalLines: 100, - callback({ lineIndex }) { - results.push(lineIndex); - }, - }); - expect(results).toEqual([3, 4]); // Only lines 3 and 4 remain - - // Window starting beyond file end - results = []; - iterateOverFile({ - lines: shortLines, - startingLine: 100, - totalLines: 10, - callback({ lineIndex }) { - results.push(lineIndex); - }, - }); - expect(results).toHaveLength(0); - }); - - test('last new line is not iterated over', () => { - const lines = computeLineOffsets({ - name: 'test.txt', - contents: 'line1\nline2\nline3\n\n\n', - }); - - const results: string[] = []; - iterateOverFile({ - lines, - callback({ content }) { - results.push(content); - }, - }); - - // Split creates: ['line1\n', 'line2\n', 'line3\n', '\n', '\n'] - // Only skips the LAST line if it's a newline, not all trailing newlines - expect(results).toEqual(['line1\n', 'line2\n', 'line3\n', '\n']); - }); - - test('isLastLine with windowing', () => { - const lines = computeLineOffsets({ - name: 'test.txt', - contents: Array(10) - .fill(0) - .map((_, i) => `line${i}`) - .join('\n'), - }); - - // Window lines 5-7 (not including the actual last line of the file) - const results: FileLineCallbackProps[] = []; - iterateOverFile({ - lines, - startingLine: 5, - totalLines: 3, - callback(props) { - results.push(props); - }, - }); - - expect(results).toHaveLength(3); - // isLastLine should be relative to full file, not the window - expect(results[0].isLastLine).toBe(false); // Line 5 is not last in file - expect(results[1].isLastLine).toBe(false); // Line 6 is not last in file - expect(results[2].isLastLine).toBe(false); // Line 7 is not last in file - - // Window starting at actual last line - results.length = 0; - iterateOverFile({ - lines, - startingLine: 9, // Last line (0-indexed) - totalLines: 10, - callback(props) { - results.push(props); - }, - }); - - expect(results).toHaveLength(1); - expect(results[0].lineIndex).toBe(9); - expect(results[0].isLastLine).toBe(true); - }); - - test('early termination', () => { - const lines = computeLineOffsets({ - name: 'test.txt', - contents: Array(100) - .fill(0) - .map((_, i) => `line${i}`) - .join('\n'), - }); - - // Returning true stops iteration - let results: number[] = []; - iterateOverFile({ - lines, - callback: ({ lineIndex }) => { - results.push(lineIndex); - if (lineIndex === 4) { - return true; // Stop - } - return false; - }, - }); - expect(results).toEqual([0, 1, 2, 3, 4]); - - // Returning false continues - const shortLines = computeLineOffsets({ - name: 'test.txt', - contents: 'a\nb\nc\nd\ne', - }); - results = []; - iterateOverFile({ - lines: shortLines, - callback: ({ lineIndex }) => { - results.push(lineIndex); - return false; // Continue - }, - }); - expect(results).toEqual([0, 1, 2, 3, 4]); - - // Returning void continues - results = []; - iterateOverFile({ - lines: shortLines, - callback: ({ lineIndex }) => { - results.push(lineIndex); - // Implicit undefined return - continue - }, - }); - expect(results).toEqual([0, 1, 2, 3, 4]); - }); -}); From a6ec72849bad6a8e904424dd00314ecf6b131760 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Thu, 30 Apr 2026 17:58:47 +0800 Subject: [PATCH 034/138] Remove EOF field --- packages/diffs/src/editor/index.ts | 12 +++++------- packages/diffs/src/editor/textDocument.ts | 7 ------- packages/diffs/test/textDocument.test.ts | 9 --------- 3 files changed, 5 insertions(+), 23 deletions(-) diff --git a/packages/diffs/src/editor/index.ts b/packages/diffs/src/editor/index.ts index 1f6d153e6..8e3e84382 100644 --- a/packages/diffs/src/editor/index.ts +++ b/packages/diffs/src/editor/index.ts @@ -1038,7 +1038,7 @@ export class Editor { return comparePosition(a.end, b.end); }) .map((selection) => this.#textDocument!.getText(selection)) - .join(this.#textDocument.EOF); + .join('\n'); } // replace the selection text @@ -1052,15 +1052,13 @@ export class Editor { if (textDocument == null || primarySelection == null) { return; } - const normalizedText = Array.isArray(text) - ? text.map((value) => value.replace(/\r\n?|\n/g, textDocument.EOF)) - : text.replace(/\r\n?|\n/g, textDocument.EOF); - const nextSelections = Array.isArray(normalizedText) - ? applyTextReplaceToSelections(textDocument, selections, normalizedText) + // todo: normalize text with textDocument.EOF + const nextSelections = Array.isArray(text) + ? applyTextReplaceToSelections(textDocument, selections, text) : applyTextChangeToSelections(textDocument, selections, { start: textDocument.offsetAt(primarySelection.start), end: textDocument.offsetAt(primarySelection.end), - text: normalizedText, + text: text, }); this.#rerender(textDocument, nextSelections); } diff --git a/packages/diffs/src/editor/textDocument.ts b/packages/diffs/src/editor/textDocument.ts index de603f525..1499b688b 100644 --- a/packages/diffs/src/editor/textDocument.ts +++ b/packages/diffs/src/editor/textDocument.ts @@ -96,7 +96,6 @@ export class TextDocument { #languageId: string; #version: number; #pieceTable: PieceTable; - #hasCRLF = false; #history = new EditHistory(); constructor( @@ -109,7 +108,6 @@ export class TextDocument { this.#languageId = languageId; this.#version = version; this.#pieceTable = new PieceTable(text); - this.#hasCRLF = this.#pieceTable.includes('\r\n'); } get uri(): string { @@ -144,10 +142,6 @@ export class TextDocument { return this.#history.canRedo; } - get EOF(): string { - return this.#hasCRLF ? '\r\n' : '\n'; - } - getText(range?: Range): string { return this.#pieceTable.getText(range); } @@ -243,6 +237,5 @@ export class TextDocument { this.#pieceTable.delete(edit.start, edit.end - edit.start); this.#pieceTable.insert(edit.text, edit.start); } - this.#hasCRLF = this.#pieceTable.includes('\r\n'); } } diff --git a/packages/diffs/test/textDocument.test.ts b/packages/diffs/test/textDocument.test.ts index c44d4aea2..0c895f4c7 100644 --- a/packages/diffs/test/textDocument.test.ts +++ b/packages/diffs/test/textDocument.test.ts @@ -46,14 +46,6 @@ describe('TextDocument', () => { expect(() => d.getLineText(99)).toThrow('Line index out of range: 99'); }); - test('EOF is LF for Unix newlines', () => { - expect(doc('a\nb').EOF).toBe('\n'); - }); - - test('EOF is CRLF when text uses CRLF', () => { - expect(doc('a\r\nb').EOF).toBe('\r\n'); - }); - test('offsetAt clamps to line and document bounds', () => { const d = doc('ab\nc'); expect(d.offsetAt({ line: 0, character: 0 })).toBe(0); @@ -156,7 +148,6 @@ describe('TextDocument', () => { }, ]); expect(d.getText()).toBe('a\r\nB\r\nc'); - expect(d.EOF).toBe('\r\n'); }); test('getText(range) spans multiple lines correctly after edits', () => { From 44d631f567e066a40398f58983f3b42307fe816d Mon Sep 17 00:00:00 2001 From: Je Xia Date: Thu, 30 Apr 2026 18:35:24 +0800 Subject: [PATCH 035/138] Remove text length fields from HistoryEntry and related test cases in EditHistory --- packages/diffs/src/editor/editHistory.ts | 13 ------------- packages/diffs/test/editHistory.test.ts | 2 -- 2 files changed, 15 deletions(-) diff --git a/packages/diffs/src/editor/editHistory.ts b/packages/diffs/src/editor/editHistory.ts index 3802212ec..a3391e8ef 100644 --- a/packages/diffs/src/editor/editHistory.ts +++ b/packages/diffs/src/editor/editHistory.ts @@ -10,10 +10,6 @@ export type HistoryEntry = { versionBefore: number; /** Document version after the entry is applied. */ versionAfter: number; - /** Base text length before the entry is applied. */ - textLengthBefore: number; - /** Final text length after the entry is applied. */ - textLengthAfter: number; /** Selection before the transaction (restored on undo). */ selectionsBefore: EditorSelection[]; /** Selection after the transaction (restored on redo). */ @@ -47,20 +43,11 @@ export class EditHistory { ): void { const forwardEdits = [...resolvedEdits].sort((a, b) => a.start - b.start); const inverseEdits = buildInverseOffsetEdits(textBefore, forwardEdits); - const textLengthBefore = textBefore.length; - const textLengthAfter = - textLengthBefore + - forwardEdits.reduce( - (sum, edit) => sum + edit.text.length - (edit.end - edit.start), - 0 - ); this.#undo.push({ forwardEdits: forwardEdits.map((edit) => ({ ...edit })), inverseEdits: inverseEdits, versionBefore, versionAfter, - textLengthBefore, - textLengthAfter, selectionsBefore: selectionsBefore?.map((selection) => ({ ...selection, })), diff --git a/packages/diffs/test/editHistory.test.ts b/packages/diffs/test/editHistory.test.ts index d920d5ad2..14cce78ab 100644 --- a/packages/diffs/test/editHistory.test.ts +++ b/packages/diffs/test/editHistory.test.ts @@ -50,8 +50,6 @@ describe('EditHistory', () => { inverseEdits: [{ start: 1, end: 2, text: '' }], versionBefore: 4, versionAfter: 5, - textLengthBefore: 2, - textLengthAfter: 3, selectionsBefore: [caret(0), caret(1)], selectionsAfter: [caret(2), caret(3)], }); From 59f379ee9e4d49e768b8e1fed67258506f400d80 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Thu, 30 Apr 2026 18:49:00 +0800 Subject: [PATCH 036/138] Rename class `EditHistory` to `EditStack` --- .../editor/{editHistory.ts => editStack.ts} | 34 +++++----- packages/diffs/src/editor/textDocument.ts | 32 +++++----- ...{editHistory.test.ts => editStack.test.ts} | 62 +++++++++---------- 3 files changed, 64 insertions(+), 64 deletions(-) rename packages/diffs/src/editor/{editHistory.ts => editStack.ts} (80%) rename packages/diffs/test/{editHistory.test.ts => editStack.test.ts} (66%) diff --git a/packages/diffs/src/editor/editHistory.ts b/packages/diffs/src/editor/editStack.ts similarity index 80% rename from packages/diffs/src/editor/editHistory.ts rename to packages/diffs/src/editor/editStack.ts index a3391e8ef..3c509c3f8 100644 --- a/packages/diffs/src/editor/editHistory.ts +++ b/packages/diffs/src/editor/editStack.ts @@ -1,7 +1,7 @@ import type { EditorSelection } from './editorSelection'; import type { ResolvedTextEdit } from './textDocument'; -export type HistoryEntry = { +export type EditStackEntry = { /** Forward offset edits from the entry's base text to its final text. */ forwardEdits: ResolvedTextEdit[]; /** Inverse offset edits from the entry's final text back to its base text. */ @@ -16,21 +16,21 @@ export type HistoryEntry = { selectionsAfter?: EditorSelection[]; }; -export class EditHistory { - #undo: HistoryEntry[] = []; - #redo: HistoryEntry[] = []; +export class EditStack { + #undoStack: EditStackEntry[] = []; + #redoStack: EditStackEntry[] = []; get canUndo(): boolean { - return this.#undo.length > 0; + return this.#undoStack.length > 0; } get canRedo(): boolean { - return this.#redo.length > 0; + return this.#redoStack.length > 0; } clear(): void { - this.#undo.length = 0; - this.#redo.length = 0; + this.#undoStack.length = 0; + this.#redoStack.length = 0; } push( @@ -43,7 +43,7 @@ export class EditHistory { ): void { const forwardEdits = [...resolvedEdits].sort((a, b) => a.start - b.start); const inverseEdits = buildInverseOffsetEdits(textBefore, forwardEdits); - this.#undo.push({ + this.#undoStack.push({ forwardEdits: forwardEdits.map((edit) => ({ ...edit })), inverseEdits: inverseEdits, versionBefore, @@ -53,11 +53,11 @@ export class EditHistory { })), selectionsAfter: selectionsAfter?.map((selection) => ({ ...selection })), }); - this.#redo.length = 0; + this.#redoStack.length = 0; } setLastUndoSelectionsAfter(selections: EditorSelection[]): void { - const lastEntry = this.#undo[this.#undo.length - 1]; + const lastEntry = this.#undoStack[this.#undoStack.length - 1]; if (lastEntry !== undefined) { lastEntry.selectionsAfter = selections.map((selection) => ({ ...selection, @@ -66,19 +66,19 @@ export class EditHistory { } /** Moves the latest undo entry to the redo stack and returns it, or `undefined` if empty. */ - popUndoToRedo(): HistoryEntry | void { - const entry = this.#undo.pop(); + popUndoToRedo(): EditStackEntry | void { + const entry = this.#undoStack.pop(); if (entry !== undefined) { - this.#redo.push(entry); + this.#redoStack.push(entry); return entry; } } /** Moves the latest redo entry back to the undo stack and returns it, or `undefined` if empty. */ - popRedoToUndo(): HistoryEntry | void { - const entry = this.#redo.pop(); + popRedoToUndo(): EditStackEntry | void { + const entry = this.#redoStack.pop(); if (entry !== undefined) { - this.#undo.push(entry); + this.#undoStack.push(entry); return entry; } } diff --git a/packages/diffs/src/editor/textDocument.ts b/packages/diffs/src/editor/textDocument.ts index 1499b688b..515398405 100644 --- a/packages/diffs/src/editor/textDocument.ts +++ b/packages/diffs/src/editor/textDocument.ts @@ -1,5 +1,5 @@ -import { EditHistory } from './editHistory'; import { type EditorSelection } from './editorSelection'; +import { EditStack } from './editStack'; import { PieceTable } from './pieceTable'; /** @@ -96,7 +96,7 @@ export class TextDocument { #languageId: string; #version: number; #pieceTable: PieceTable; - #history = new EditHistory(); + #editStack = new EditStack(); constructor( uri: string, @@ -135,11 +135,19 @@ export class TextDocument { } get canUndo(): boolean { - return this.#history.canUndo; + return this.#editStack.canUndo; } get canRedo(): boolean { - return this.#history.canRedo; + return this.#editStack.canRedo; + } + + positionAt(offset: number): Position { + return this.#pieceTable.positionAt(offset); + } + + offsetAt(position: Position): number { + return this.#pieceTable.offsetAt(position); } getText(range?: Range): string { @@ -166,7 +174,7 @@ export class TextDocument { const resolvedEdits = edits.map((edit) => this.#resolveEdit(edit)); if (updateHistory && selectionsBefore !== undefined) { const textBefore = this.getText(); - this.#history.push( + this.#editStack.push( textBefore, resolvedEdits, this.#version, @@ -180,11 +188,11 @@ export class TextDocument { } setLastUndoSelectionsAfter(selections: EditorSelection[]): void { - this.#history.setLastUndoSelectionsAfter(selections); + this.#editStack.setLastUndoSelectionsAfter(selections); } undo(): EditorSelection[] | undefined { - const entry = this.#history.popUndoToRedo(); + const entry = this.#editStack.popUndoToRedo(); if (entry === undefined) { return undefined; } @@ -196,7 +204,7 @@ export class TextDocument { } redo(): EditorSelection[] | undefined { - const entry = this.#history.popRedoToUndo(); + const entry = this.#editStack.popRedoToUndo(); if (entry === undefined) { return undefined; } @@ -207,14 +215,6 @@ export class TextDocument { : undefined; } - positionAt(offset: number): Position { - return this.#pieceTable.positionAt(offset); - } - - offsetAt(position: Position): number { - return this.#pieceTable.offsetAt(position); - } - #resolveEdit(edit: TextEdit): ResolvedTextEdit { let start = this.offsetAt(edit.range.start); let end = this.offsetAt(edit.range.end); diff --git a/packages/diffs/test/editHistory.test.ts b/packages/diffs/test/editStack.test.ts similarity index 66% rename from packages/diffs/test/editHistory.test.ts rename to packages/diffs/test/editStack.test.ts index 14cce78ab..a0b7a4489 100644 --- a/packages/diffs/test/editHistory.test.ts +++ b/packages/diffs/test/editStack.test.ts @@ -1,8 +1,8 @@ import { describe, expect, test } from 'bun:test'; -import { EditHistory } from '../src/editor/editHistory'; import type { EditorSelection } from '../src/editor/editorSelection'; import { SelectionDirection } from '../src/editor/editorSelection'; +import { EditStack } from '../src/editor/editStack'; function createSelection( startLine: number, @@ -24,11 +24,11 @@ function caret(character: number) { describe('EditHistory', () => { test('push stores cloned selections and pop methods move entries between stacks', () => { - const history = new EditHistory(); + const editStack = new EditStack(); const selectionBefore = [caret(0), caret(1)]; const selectionAfter = [caret(2), caret(3)]; - history.push( + editStack.push( 'ab', [{ start: 1, end: 1, text: 'X' }], 4, @@ -40,10 +40,10 @@ describe('EditHistory', () => { selectionBefore[0] = caret(99); selectionAfter[0] = caret(99); - expect(history.canUndo).toBe(true); - expect(history.canRedo).toBe(false); + expect(editStack.canUndo).toBe(true); + expect(editStack.canRedo).toBe(false); - const entry = history.popUndoToRedo(); + const entry = editStack.popUndoToRedo(); expect(entry).toEqual({ forwardEdits: [{ start: 1, end: 1, text: 'X' }], @@ -53,19 +53,19 @@ describe('EditHistory', () => { selectionsBefore: [caret(0), caret(1)], selectionsAfter: [caret(2), caret(3)], }); - expect(history.canUndo).toBe(false); - expect(history.canRedo).toBe(true); + expect(editStack.canUndo).toBe(false); + expect(editStack.canRedo).toBe(true); - expect(history.popRedoToUndo()).toEqual(entry); - expect(history.canUndo).toBe(true); - expect(history.canRedo).toBe(false); + expect(editStack.popRedoToUndo()).toEqual(entry); + expect(editStack.canUndo).toBe(true); + expect(editStack.canRedo).toBe(false); }); test('setLastUndoSelectionsAfter stores cloned redo selections', () => { - const history = new EditHistory(); + const editStack = new EditStack(); let selectionAfter = caret(2); - history.push( + editStack.push( 'a', [{ start: 1, end: 1, text: 'b' }], 1, @@ -75,15 +75,15 @@ describe('EditHistory', () => { ); selectionAfter = caret(99); - expect(history.popUndoToRedo()).toMatchObject({ + expect(editStack.popUndoToRedo()).toMatchObject({ selectionsAfter: [caret(2)], }); }); test('push clears redo history when recording a new undo entry', () => { - const history = new EditHistory(); + const editStack = new EditStack(); - history.push( + editStack.push( '', [{ start: 0, end: 0, text: 'a' }], 0, @@ -91,7 +91,7 @@ describe('EditHistory', () => { [caret(0)], undefined ); - history.push( + editStack.push( 'a', [{ start: 1, end: 1, text: 'b' }], 1, @@ -100,12 +100,12 @@ describe('EditHistory', () => { undefined ); - expect(history.popUndoToRedo()).toMatchObject({ + expect(editStack.popUndoToRedo()).toMatchObject({ forwardEdits: [{ start: 1, end: 1, text: 'b' }], }); - expect(history.canRedo).toBe(true); + expect(editStack.canRedo).toBe(true); - history.push( + editStack.push( 'a', [{ start: 1, end: 1, text: 'c' }], 1, @@ -114,19 +114,19 @@ describe('EditHistory', () => { undefined ); - expect(history.canRedo).toBe(false); - expect(history.popUndoToRedo()).toMatchObject({ + expect(editStack.canRedo).toBe(false); + expect(editStack.popUndoToRedo()).toMatchObject({ forwardEdits: [{ start: 1, end: 1, text: 'c' }], }); - expect(history.popUndoToRedo()).toMatchObject({ + expect(editStack.popUndoToRedo()).toMatchObject({ forwardEdits: [{ start: 0, end: 0, text: 'a' }], }); }); test('clear resets both undo and redo stacks', () => { - const history = new EditHistory(); + const editStack = new EditStack(); - history.push( + editStack.push( '', [{ start: 0, end: 0, text: 'a' }], 0, @@ -134,12 +134,12 @@ describe('EditHistory', () => { [caret(0)], undefined ); - history.popUndoToRedo(); - history.clear(); + editStack.popUndoToRedo(); + editStack.clear(); - expect(history.canUndo).toBe(false); - expect(history.canRedo).toBe(false); - expect(history.popUndoToRedo()).toBeUndefined(); - expect(history.popRedoToUndo()).toBeUndefined(); + expect(editStack.canUndo).toBe(false); + expect(editStack.canRedo).toBe(false); + expect(editStack.popUndoToRedo()).toBeUndefined(); + expect(editStack.popRedoToUndo()).toBeUndefined(); }); }); From 0eceba20219e42ad95d5f3f84a42a7a1e0a09d62 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Thu, 30 Apr 2026 21:17:29 +0800 Subject: [PATCH 037/138] Refactor EditStack and PieceTable to use a unified text slice interface. --- packages/diffs/src/editor/editStack.ts | 16 +++--- packages/diffs/src/editor/pieceTable.ts | 60 ++++++++++++++--------- packages/diffs/src/editor/textDocument.ts | 13 +++-- packages/diffs/test/editStack.test.ts | 20 +++++--- packages/diffs/test/pieceTable.test.ts | 6 +-- packages/diffs/test/textDocument.test.ts | 4 +- 6 files changed, 72 insertions(+), 47 deletions(-) diff --git a/packages/diffs/src/editor/editStack.ts b/packages/diffs/src/editor/editStack.ts index 3c509c3f8..123ef2ef8 100644 --- a/packages/diffs/src/editor/editStack.ts +++ b/packages/diffs/src/editor/editStack.ts @@ -1,7 +1,11 @@ import type { EditorSelection } from './editorSelection'; import type { ResolvedTextEdit } from './textDocument'; -export type EditStackEntry = { +interface EditSource { + getTextSlice(start: number, end: number): string; +} + +interface EditStackEntry { /** Forward offset edits from the entry's base text to its final text. */ forwardEdits: ResolvedTextEdit[]; /** Inverse offset edits from the entry's final text back to its base text. */ @@ -14,7 +18,7 @@ export type EditStackEntry = { selectionsBefore: EditorSelection[]; /** Selection after the transaction (restored on redo). */ selectionsAfter?: EditorSelection[]; -}; +} export class EditStack { #undoStack: EditStackEntry[] = []; @@ -34,7 +38,7 @@ export class EditStack { } push( - textBefore: string, + source: EditSource, resolvedEdits: ResolvedTextEdit[], versionBefore: number, versionAfter: number, @@ -42,7 +46,7 @@ export class EditStack { selectionsAfter?: EditorSelection[] ): void { const forwardEdits = [...resolvedEdits].sort((a, b) => a.start - b.start); - const inverseEdits = buildInverseOffsetEdits(textBefore, forwardEdits); + const inverseEdits = buildInverseOffsetEdits(source, forwardEdits); this.#undoStack.push({ forwardEdits: forwardEdits.map((edit) => ({ ...edit })), inverseEdits: inverseEdits, @@ -85,13 +89,13 @@ export class EditStack { } function buildInverseOffsetEdits( - textBefore: string, + source: EditSource, ascending: ResolvedTextEdit[] ): ResolvedTextEdit[] { const inverse: ResolvedTextEdit[] = []; for (let i = 0, offsetDelta = 0; i < ascending.length; i++) { const edit = ascending[i]; - const replacedText = textBefore.slice(edit.start, edit.end); + const replacedText = source.getTextSlice(edit.start, edit.end); const startAfterEdit = edit.start + offsetDelta; inverse.push({ start: startAfterEdit, diff --git a/packages/diffs/src/editor/pieceTable.ts b/packages/diffs/src/editor/pieceTable.ts index 22c6ccaef..27457d0e8 100644 --- a/packages/diffs/src/editor/pieceTable.ts +++ b/packages/diffs/src/editor/pieceTable.ts @@ -102,15 +102,28 @@ export class PieceTable { } const start = this.offsetAt(range.start); const end = this.offsetAt(range.end); - return this.#sliceText(start, end); + return this.getTextSlice(start, end); } - getLineText(line: number, trimEOL = true): string | undefined { - const info = this.#getLineOffset(line); - if (info === undefined) { - return undefined; + getLineText(line: number, trimEOL = true): string { + const offset = this.#getLineOffset(line); + if (offset === undefined) { + throw new Error(`Line index out of range: ${line}`); } - return this.#sliceText(info.start, trimEOL ? info.endBeforeEOL : info.end); + return this.getTextSlice( + offset.start, + trimEOL ? offset.endBeforeEOL : offset.end + ); + } + + getTextSlice(start: number, end: number): string { + if (start >= end) { + return ''; + } + + const chunks: string[] = []; + this.#appendSliceFromNode(this.#root, start, end, 0, chunks); + return chunks.join(''); } includes(needle: string): boolean { @@ -264,26 +277,19 @@ export class PieceTable { if (position.line < 0 || this.#length === 0) { return 0; } - const info = this.#getLineOffset(position.line); - if (info === undefined) { - return this.#length; + if (position.line >= this.#lineCount) { + throw new Error(`Line index out of range: ${position.line}`); + } + const offset = this.#getLineOffset(position.line); + if (offset === undefined) { + throw new Error(`Line index out of range: ${position.line}`); } const character = clamp( position.character, 0, - info.endBeforeEOL - info.start + offset.endBeforeEOL - offset.start ); - return info.start + character; - } - - #sliceText(start: number, end: number): string { - if (start >= end) { - return ''; - } - - const chunks: string[] = []; - this.#appendSliceFromNode(this.#root, start, end, 0, chunks); - return chunks.join(''); + return offset.start + character; } #appendSliceFromNode( @@ -328,8 +334,14 @@ export class PieceTable { } #getLineOffset(line: number): LineOffset | undefined { - if (line < 0 || this.#length === 0) { - return undefined; + if (line < 0) { + throw new Error(`Line index out of range: ${line}`); + } + if (this.#length === 0) { + if (line === 0) { + return { start: 0, end: 0, endBeforeEOL: 0 }; + } + throw new Error(`Line index out of range: ${line}`); } let offset: LineOffset | undefined; @@ -345,7 +357,7 @@ export class PieceTable { return offset; } if (scan.nextLine !== line) { - return undefined; + throw new Error(`Line index out of range: ${line}`); } return { start: scan.nextLineStart, diff --git a/packages/diffs/src/editor/textDocument.ts b/packages/diffs/src/editor/textDocument.ts index 515398405..1bff37a8a 100644 --- a/packages/diffs/src/editor/textDocument.ts +++ b/packages/diffs/src/editor/textDocument.ts @@ -155,11 +155,11 @@ export class TextDocument { } getLineText(line: number, trimEOL = true): string { - const text = this.#pieceTable.getLineText(line, trimEOL); - if (text === undefined) { - throw new Error(`Line index out of range: ${line}`); - } - return text; + return this.#pieceTable.getLineText(line, trimEOL); + } + + getTextSlice(start: number, end: number): string { + return this.#pieceTable.getTextSlice(start, end); } applyEdits( @@ -173,9 +173,8 @@ export class TextDocument { } const resolvedEdits = edits.map((edit) => this.#resolveEdit(edit)); if (updateHistory && selectionsBefore !== undefined) { - const textBefore = this.getText(); this.#editStack.push( - textBefore, + this, resolvedEdits, this.#version, this.#version + 1, diff --git a/packages/diffs/test/editStack.test.ts b/packages/diffs/test/editStack.test.ts index a0b7a4489..4b754f529 100644 --- a/packages/diffs/test/editStack.test.ts +++ b/packages/diffs/test/editStack.test.ts @@ -22,6 +22,14 @@ function caret(character: number) { return createSelection(0, character, 0, character, SelectionDirection.None); } +function source(text: string) { + return { + getTextSlice(start: number, end: number): string { + return text.slice(start, end); + }, + }; +} + describe('EditHistory', () => { test('push stores cloned selections and pop methods move entries between stacks', () => { const editStack = new EditStack(); @@ -29,7 +37,7 @@ describe('EditHistory', () => { const selectionAfter = [caret(2), caret(3)]; editStack.push( - 'ab', + source('ab'), [{ start: 1, end: 1, text: 'X' }], 4, 5, @@ -66,7 +74,7 @@ describe('EditHistory', () => { let selectionAfter = caret(2); editStack.push( - 'a', + source('a'), [{ start: 1, end: 1, text: 'b' }], 1, 2, @@ -84,7 +92,7 @@ describe('EditHistory', () => { const editStack = new EditStack(); editStack.push( - '', + source(''), [{ start: 0, end: 0, text: 'a' }], 0, 1, @@ -92,7 +100,7 @@ describe('EditHistory', () => { undefined ); editStack.push( - 'a', + source('a'), [{ start: 1, end: 1, text: 'b' }], 1, 2, @@ -106,7 +114,7 @@ describe('EditHistory', () => { expect(editStack.canRedo).toBe(true); editStack.push( - 'a', + source('a'), [{ start: 1, end: 1, text: 'c' }], 1, 2, @@ -127,7 +135,7 @@ describe('EditHistory', () => { const editStack = new EditStack(); editStack.push( - '', + source(''), [{ start: 0, end: 0, text: 'a' }], 0, 1, diff --git a/packages/diffs/test/pieceTable.test.ts b/packages/diffs/test/pieceTable.test.ts index 58aa750bf..2c90b016b 100644 --- a/packages/diffs/test/pieceTable.test.ts +++ b/packages/diffs/test/pieceTable.test.ts @@ -140,7 +140,7 @@ describe('PieceTable', () => { expect(table.getLineText(0)).toBe('first'); expect(table.getLineText(0, false)).toBe('first\r\n'); expect(table.getLineText(1)).toBe('second'); - expect(table.getLineText(99)).toBeUndefined(); + expect(() => table.getLineText(99)).toThrow('Line index out of range: 99'); }); test('maps between offsets and positions', () => { @@ -225,7 +225,7 @@ describe('PieceTable', () => { const table = new PieceTable(''); expectTableToMatchText(table, ''); - expect(table.getLineText(0)).toBeUndefined(); + expect(table.getLineText(0)).toBe(''); expect(table.positionAt(99)).toEqual({ line: 0, character: 0 }); expect(table.offsetAt({ line: 99, character: 99 })).toBe(0); }); @@ -299,7 +299,7 @@ describe('PieceTable', () => { table.delete(0, table.getText().length); expectTableToMatchText(table, ''); - expect(table.getLineText(0)).toBeUndefined(); + expect(table.getLineText(0)).toBe(''); }); test('matches plain string edits across many insertions and deletions', () => { diff --git a/packages/diffs/test/textDocument.test.ts b/packages/diffs/test/textDocument.test.ts index 0c895f4c7..2755b97d4 100644 --- a/packages/diffs/test/textDocument.test.ts +++ b/packages/diffs/test/textDocument.test.ts @@ -51,7 +51,9 @@ describe('TextDocument', () => { expect(d.offsetAt({ line: 0, character: 0 })).toBe(0); expect(d.offsetAt({ line: 0, character: 99 })).toBe(2); expect(d.offsetAt({ line: 1, character: 0 })).toBe(3); - expect(d.offsetAt({ line: 99, character: 0 })).toBe(d.getText().length); + expect(() => d.offsetAt({ line: 99, character: 0 })).toThrow( + 'Line index out of range: 99' + ); }); test('positionAt is inverse of offsetAt for in-range columns', () => { From 43247d8feb7b874f970796d83b0c64f6b2bde64a Mon Sep 17 00:00:00 2001 From: Je Xia Date: Fri, 1 May 2026 11:04:59 +0800 Subject: [PATCH 038/138] Refactor PieceTable and TextDocument to improve line offset handling and remove unnecessary EOL trimming logic. --- packages/diffs/src/editor/pieceTable.ts | 153 +++------------------- packages/diffs/src/editor/textDocument.ts | 22 +++- packages/diffs/test/pieceTable.test.ts | 59 +++------ packages/diffs/test/textDocument.test.ts | 35 +++-- 4 files changed, 85 insertions(+), 184 deletions(-) diff --git a/packages/diffs/src/editor/pieceTable.ts b/packages/diffs/src/editor/pieceTable.ts index 27457d0e8..041546609 100644 --- a/packages/diffs/src/editor/pieceTable.ts +++ b/packages/diffs/src/editor/pieceTable.ts @@ -13,12 +13,6 @@ type PieceSegment = { readonly lineOffsets: number[]; }; -type LineOffset = { - readonly start: number; - readonly end: number; - readonly endBeforeEOL: number; -}; - enum PieceSourceType { Original = 0, Added = 1, @@ -105,15 +99,12 @@ export class PieceTable { return this.getTextSlice(start, end); } - getLineText(line: number, trimEOL = true): string { + getLineText(line: number): string { const offset = this.#getLineOffset(line); if (offset === undefined) { throw new Error(`Line index out of range: ${line}`); } - return this.getTextSlice( - offset.start, - trimEOL ? offset.endBeforeEOL : offset.end - ); + return this.getTextSlice(offset[0], offset[1]); } getTextSlice(start: number, end: number): string { @@ -243,18 +234,10 @@ export class PieceTable { let position: Position | undefined; const scan = this.#forEachLineBreak((lineBreak, line) => { - if (clampedOffset <= lineBreak.endBeforeEOL) { + if (clampedOffset < lineBreak[1]) { position = { line, - character: clampedOffset - lineBreak.start, - }; - return false; - } - - if (clampedOffset < lineBreak.end) { - position = { - line, - character: lineBreak.endBeforeEOL - lineBreak.start, + character: clampedOffset - lineBreak[0], }; return false; } @@ -267,9 +250,7 @@ export class PieceTable { return { line: scan.nextLine, - character: - Math.min(clampedOffset, this.#length - scan.trailingEOLLength) - - scan.nextLineStart, + character: clampedOffset - scan.nextLineStart, }; } @@ -284,12 +265,8 @@ export class PieceTable { if (offset === undefined) { throw new Error(`Line index out of range: ${position.line}`); } - const character = clamp( - position.character, - 0, - offset.endBeforeEOL - offset.start - ); - return offset.start + character; + const character = clamp(position.character, 0, offset[1] - offset[0]); + return offset[0] + character; } #appendSliceFromNode( @@ -333,18 +310,18 @@ export class PieceTable { } } - #getLineOffset(line: number): LineOffset | undefined { + #getLineOffset(line: number): [start: number, end: number] | undefined { if (line < 0) { throw new Error(`Line index out of range: ${line}`); } if (this.#length === 0) { if (line === 0) { - return { start: 0, end: 0, endBeforeEOL: 0 }; + return [0, 0]; } throw new Error(`Line index out of range: ${line}`); } - let offset: LineOffset | undefined; + let offset: [start: number, end: number] | undefined; const scan = this.#forEachLineBreak((lineBreak, ln) => { if (ln === line) { offset = lineBreak; @@ -359,11 +336,7 @@ export class PieceTable { if (scan.nextLine !== line) { throw new Error(`Line index out of range: ${line}`); } - return { - start: scan.nextLineStart, - end: this.#length, - endBeforeEOL: this.#length - scan.trailingEOLLength, - }; + return [scan.nextLineStart, this.#length]; } #textFromPieces(): string { @@ -389,63 +362,38 @@ export class PieceTable { } #forEachLineBreak( - callback: (lineBreak: LineOffset, line: number) => boolean | void + callback: ( + lineBreak: [start: number, end: number], + line: number + ) => boolean | void ): { nextLine: number; nextLineStart: number; - trailingEOLLength: number; } { let line = 0; let lineStart = 0; let documentOffset = 0; - let trailingEOLLength = 0; this.#forEachPieceSegment((segment) => { - const segmentDocumentOffset = documentOffset; const lineOffsetStart = upperBound(segment.lineOffsets, segment.start); const lineOffsetEnd = upperBound(segment.lineOffsets, segment.end); for (let i = lineOffsetStart; i < lineOffsetEnd; i++) { const bufferLineOffset = segment.lineOffsets[i]; const endWithEOL = documentOffset + (bufferLineOffset - segment.start); - const eolLength = trailingEOLLengthBeforeOffset( - segment, - segmentDocumentOffset, - bufferLineOffset, - lineStart, - trailingEOLLength - ); - - if ( - callback( - { - start: lineStart, - end: endWithEOL, - endBeforeEOL: endWithEOL - eolLength, - }, - line - ) === false - ) { + + if (callback([lineStart, endWithEOL], line) === false) { return false; } line++; lineStart = endWithEOL; - trailingEOLLength = 0; } documentOffset += segment.end - segment.start; - if (segment.end > segment.start) { - trailingEOLLength = trailingEOLLengthAtSegmentEnd( - segment, - segmentDocumentOffset, - lineStart, - trailingEOLLength - ); - } return true; }); - return { nextLine: line, nextLineStart: lineStart, trailingEOLLength }; + return { nextLine: line, nextLineStart: lineStart }; } #bufferFor(source: PieceSourceType): TextBuffer { @@ -687,71 +635,6 @@ function lineFeedCount(segment: PieceSegment): number { ); } -function trailingEOLLengthBeforeOffset( - segment: PieceSegment, - segmentDocumentOffset: number, - bufferOffset: number, - lineStart: number, - trailingBeforeSegment: number -): number { - const lineStartInSegment = Math.max( - segment.start, - segment.start + (lineStart - segmentDocumentOffset) - ); - let length = 0; - for (let offset = bufferOffset - 1; offset >= lineStartInSegment; offset--) { - if (!isEOL(segment.text.charCodeAt(offset))) { - return length; - } - length++; - } - - if ( - lineStart < segmentDocumentOffset && - lineStartInSegment === segment.start - ) { - return ( - length + - Math.min(trailingBeforeSegment, segmentDocumentOffset - lineStart) - ); - } - return length; -} - -function trailingEOLLengthAtSegmentEnd( - segment: PieceSegment, - segmentDocumentOffset: number, - lineStart: number, - trailingBeforeSegment: number -): number { - const lineStartInSegment = Math.max( - segment.start, - segment.start + (lineStart - segmentDocumentOffset) - ); - let length = 0; - for (let offset = segment.end - 1; offset >= lineStartInSegment; offset--) { - if (!isEOL(segment.text.charCodeAt(offset))) { - return length; - } - length++; - } - - if ( - lineStart < segmentDocumentOffset && - lineStartInSegment === segment.start - ) { - return ( - length + - Math.min(trailingBeforeSegment, segmentDocumentOffset - lineStart) - ); - } - return length; -} - -function isEOL(charCode: number): boolean { - return charCode === 10 || charCode === 13; -} - // Returns the index of the first element in the array that is greater than the target. function upperBound(values: number[], target: number): number { let lo = 0; diff --git a/packages/diffs/src/editor/textDocument.ts b/packages/diffs/src/editor/textDocument.ts index 1bff37a8a..f8c121182 100644 --- a/packages/diffs/src/editor/textDocument.ts +++ b/packages/diffs/src/editor/textDocument.ts @@ -147,15 +147,21 @@ export class TextDocument { } offsetAt(position: Position): number { + // todo: clamp EOL return this.#pieceTable.offsetAt(position); } getText(range?: Range): string { + // todo: clamp EOL return this.#pieceTable.getText(range); } - getLineText(line: number, trimEOL = true): string { - return this.#pieceTable.getLineText(line, trimEOL); + getLineText(line: number, stripEndings = true): string { + const text = this.#pieceTable.getLineText(line); + if (stripEndings) { + return stripLineEndings(text); + } + return text; } getTextSlice(start: number, end: number): string { @@ -238,3 +244,15 @@ export class TextDocument { } } } + +function stripLineEndings(text: string): string { + let end = text.length; + while (end > 0 && isEOL(text.charCodeAt(end - 1))) { + end--; + } + return text.slice(0, end); +} + +function isEOL(charCode: number): boolean { + return charCode === 10 || charCode === 13; +} diff --git a/packages/diffs/test/pieceTable.test.ts b/packages/diffs/test/pieceTable.test.ts index 2c90b016b..9173cea3a 100644 --- a/packages/diffs/test/pieceTable.test.ts +++ b/packages/diffs/test/pieceTable.test.ts @@ -22,10 +22,6 @@ function lineTexts(text: string): string[] { return lines; } -function trimEOL(text: string): string { - return text.replace(/[\r\n]+$/, ''); -} - function positionAt(text: string, offset: number): Position { const clampedOffset = Math.min(Math.max(offset, 0), text.length); let line = 0; @@ -36,30 +32,17 @@ function positionAt(text: string, offset: number): Position { continue; } - let endWithoutEOL = i; - while ( - endWithoutEOL > lineStart && - /[\r\n]/.test(text[endWithoutEOL - 1]) - ) { - endWithoutEOL--; - } - if (clampedOffset <= endWithoutEOL) { + const lineEnd = i + 1; + if (clampedOffset < lineEnd) { return { line, character: clampedOffset - lineStart }; } - if (clampedOffset <= i) { - return { line, character: endWithoutEOL - lineStart }; - } line++; - lineStart = i + 1; + lineStart = lineEnd; } - let endWithoutEOL = text.length; - while (endWithoutEOL > lineStart && /[\r\n]/.test(text[endWithoutEOL - 1])) { - endWithoutEOL--; - } return { line, - character: Math.min(clampedOffset, endWithoutEOL) - lineStart, + character: clampedOffset - lineStart, }; } @@ -78,7 +61,7 @@ function offsetAt(text: string, position: Position): number { offset += lines[i].length; } - const lineLength = trimEOL(lines[position.line]).length; + const lineLength = lines[position.line].length; return offset + Math.min(Math.max(position.character, 0), lineLength); } @@ -89,8 +72,7 @@ function expectTableToMatchText(table: PieceTable, text: string): void { expect(table.lineCount).toBe(lines.length); for (let line = 0; line < lines.length; line++) { - expect(table.getLineText(line)).toBe(trimEOL(lines[line])); - expect(table.getLineText(line, false)).toBe(lines[line]); + expect(table.getLineText(line)).toBe(lines[line]); } for (let offset = 0; offset <= text.length; offset++) { @@ -98,7 +80,7 @@ function expectTableToMatchText(table: PieceTable, text: string): void { } for (let line = 0; line < lines.length; line++) { - const lineLength = trimEOL(lines[line]).length; + const lineLength = lines[line].length; for (let character = 0; character <= lineLength; character++) { expect(table.offsetAt({ line, character })).toBe( offsetAt(text, { line, character }) @@ -134,12 +116,12 @@ describe('PieceTable', () => { ).toBe('bb'); }); - test('returns line text with optional line endings', () => { + test('returns raw line text', () => { const table = new PieceTable('first\r\nsecond\n'); - expect(table.getLineText(0)).toBe('first'); - expect(table.getLineText(0, false)).toBe('first\r\n'); - expect(table.getLineText(1)).toBe('second'); + expect(table.getLineText(0)).toBe('first\r\n'); + expect(table.getLineText(1)).toBe('second\n'); + expect(table.getLineText(2)).toBe(''); expect(() => table.getLineText(99)).toThrow('Line index out of range: 99'); }); @@ -185,7 +167,7 @@ describe('PieceTable', () => { expect(table.getText()).toBe('one zero\nTWO\nthree'); expect(table.lineCount).toBe(3); - expect(table.getLineText(1)).toBe('TWO'); + expect(table.getLineText(1)).toBe('TWO\n'); }); test('handles CRLF split across piece boundaries', () => { @@ -196,23 +178,22 @@ describe('PieceTable', () => { expect(table.getText()).toBe('a\r\nb'); expect(table.lineCount).toBe(2); - expect(table.getLineText(0)).toBe('a'); - expect(table.getLineText(0, false)).toBe('a\r\n'); - expect(table.positionAt(2)).toEqual({ line: 0, character: 1 }); + expect(table.getLineText(0)).toBe('a\r\n'); + expect(table.positionAt(2)).toEqual({ line: 0, character: 2 }); expect(table.positionAt(3)).toEqual({ line: 1, character: 0 }); }); - test('trims repeated line ending characters before line feed', () => { + test('keeps repeated line ending characters in line offsets', () => { const table = new PieceTable('a\r\r\nb\r'); expectTableToMatchText(table, 'a\r\r\nb\r'); - expect(table.getLineText(0)).toBe('a'); - expect(table.getLineText(1)).toBe('b'); - expect(table.positionAt(2)).toEqual({ line: 0, character: 1 }); - expect(table.offsetAt({ line: 1, character: 10 })).toBe(5); + expect(table.getLineText(0)).toBe('a\r\r\n'); + expect(table.getLineText(1)).toBe('b\r'); + expect(table.positionAt(2)).toEqual({ line: 0, character: 2 }); + expect(table.offsetAt({ line: 1, character: 10 })).toBe(6); }); - test('trims line endings split across pieces', () => { + test('keeps line endings split across pieces', () => { const table = new PieceTable('a\nb'); table.insert('\r\r', 1); diff --git a/packages/diffs/test/textDocument.test.ts b/packages/diffs/test/textDocument.test.ts index 2755b97d4..aec7b1278 100644 --- a/packages/diffs/test/textDocument.test.ts +++ b/packages/diffs/test/textDocument.test.ts @@ -46,16 +46,25 @@ describe('TextDocument', () => { expect(() => d.getLineText(99)).toThrow('Line index out of range: 99'); }); - test('offsetAt clamps to line and document bounds', () => { - const d = doc('ab\nc'); - expect(d.offsetAt({ line: 0, character: 0 })).toBe(0); - expect(d.offsetAt({ line: 0, character: 99 })).toBe(2); - expect(d.offsetAt({ line: 1, character: 0 })).toBe(3); - expect(() => d.offsetAt({ line: 99, character: 0 })).toThrow( - 'Line index out of range: 99' - ); + test('getLineText can include line endings', () => { + const d = doc('first\r\nsecond\n'); + expect(d.getLineText(0)).toBe('first'); + expect(d.getLineText(0, false)).toBe('first\r\n'); + expect(d.getLineText(1)).toBe('second'); + expect(d.getLineText(1, false)).toBe('second\n'); + expect(d.getLineText(2)).toBe(''); }); + // test('offsetAt clamps to line and document bounds', () => { + // const d = doc('ab\nc'); + // expect(d.offsetAt({ line: 0, character: 0 })).toBe(0); + // expect(d.offsetAt({ line: 0, character: 99 })).toBe(2); + // expect(d.offsetAt({ line: 1, character: 0 })).toBe(3); + // expect(() => d.offsetAt({ line: 99, character: 0 })).toThrow( + // 'Line index out of range: 99' + // ); + // }); + test('positionAt is inverse of offsetAt for in-range columns', () => { const d = doc('ab\nc'); expect(d.positionAt(0)).toEqual({ line: 0, character: 0 }); @@ -65,6 +74,16 @@ describe('TextDocument', () => { expect(d.offsetAt({ line, character })).toBe(2); }); + // test('positionAt and offsetAt clamp line endings', () => { + // const d = doc('a\r\r\nb\r'); + // expect(d.positionAt(2)).toEqual({ line: 0, character: 1 }); + // expect(d.positionAt(3)).toEqual({ line: 0, character: 1 }); + // expect(d.positionAt(4)).toEqual({ line: 1, character: 0 }); + // expect(d.positionAt(6)).toEqual({ line: 1, character: 1 }); + // expect(d.offsetAt({ line: 0, character: 10 })).toBe(1); + // expect(d.offsetAt({ line: 1, character: 10 })).toBe(5); + // }); + test('positionAt maps initial line offsets from zero', () => { const d = doc('first\nsecond\nthird'); expect(d.positionAt(0)).toEqual({ line: 0, character: 0 }); From 1b9042698fe15ed407b930f217c78b4ca6f9573a Mon Sep 17 00:00:00 2001 From: Je Xia Date: Fri, 1 May 2026 11:35:50 +0800 Subject: [PATCH 039/138] Refactor `Editor` to utilize new dirty line resolution logic, enhancing performance and accuracy in line tracking. --- packages/diffs/src/editor/editorUtils.ts | 75 ++++++++++++++++++ packages/diffs/src/editor/index.ts | 40 +++------- packages/diffs/src/editor/textDocument.ts | 97 +++++++++++++++++++---- packages/diffs/test/textDocument.test.ts | 75 +++++++++++++++++- 4 files changed, 239 insertions(+), 48 deletions(-) diff --git a/packages/diffs/src/editor/editorUtils.ts b/packages/diffs/src/editor/editorUtils.ts index cef5f08df..4d4ef3fb6 100644 --- a/packages/diffs/src/editor/editorUtils.ts +++ b/packages/diffs/src/editor/editorUtils.ts @@ -1,3 +1,5 @@ +import type { TextDocumentChange } from './textDocument'; + export function createElement( tagName: K, props: { @@ -99,6 +101,79 @@ export function getLineIndentationUnit( return ' '.repeat(tabSize); } +export function resolveDirtyLines( + change: TextDocumentChange | undefined, + startingLine: number, + endLine: number +): { + dirtyLines: Set; + dirtyLineStart: number; + dirtyLineEnd: number; + tokenizerStartLine: number; +} { + const dirtyLines = new Set(); + if (endLine <= startingLine) { + return { + dirtyLines, + dirtyLineStart: -1, + dirtyLineEnd: -1, + tokenizerStartLine: startingLine, + }; + } + + if (change === undefined) { + for (let line = startingLine; line < endLine; line++) { + dirtyLines.add(line); + } + return { + dirtyLines, + dirtyLineStart: startingLine, + dirtyLineEnd: endLine - 1, + tokenizerStartLine: startingLine, + }; + } + + const tokenizerStartLine = Math.max(0, change.startLine); + if (change.startLine >= endLine) { + return { + dirtyLines, + dirtyLineStart: -1, + dirtyLineEnd: -1, + tokenizerStartLine, + }; + } + + let dirtyLineStart = Math.max(change.startLine, startingLine); + let dirtyLineEnd = Math.min(change.endLine, endLine - 1); + let shouldMarkDirtyLines = true; + + if (change.lineDelta !== 0) { + dirtyLineEnd = endLine - 1; + } else if (change.endLine < startingLine) { + // No visible line text changed, but a tokenizer state change may flow in. + dirtyLineStart = startingLine; + dirtyLineEnd = startingLine; + shouldMarkDirtyLines = false; + } + + if (dirtyLineEnd < dirtyLineStart) { + dirtyLineEnd = dirtyLineStart; + } + + if (shouldMarkDirtyLines) { + for (let line = dirtyLineStart; line <= dirtyLineEnd; line++) { + dirtyLines.add(line); + } + } + + return { + dirtyLines, + dirtyLineStart, + dirtyLineEnd, + tokenizerStartLine, + }; +} + export function extend(obj: T, attrs: Partial): T { return Object.assign(obj, attrs); } diff --git a/packages/diffs/src/editor/index.ts b/packages/diffs/src/editor/index.ts index 8e3e84382..3684c347b 100644 --- a/packages/diffs/src/editor/index.ts +++ b/packages/diffs/src/editor/index.ts @@ -33,6 +33,7 @@ import { extend, getLineIndentationUnit, isCodeLineTarget, + resolveDirtyLines, } from '../editor/editorUtils'; import { type ResolvedTextEdit, @@ -59,7 +60,6 @@ export class Editor { #file?: File; #fileContents?: FileContents; #textDocument?: TextDocument; - #textLinesCache?: string[]; #renderRange?: RenderRange; #onChange?: (file: FileContents) => void; @@ -136,7 +136,6 @@ export class Editor { this.#file = undefined; this.#fileContents = undefined; this.#textDocument = undefined; - this.#textLinesCache = undefined; this.#renderRange = undefined; this.#onChange = undefined; @@ -178,7 +177,6 @@ export class Editor { fileContents.contents, fileContents.lang ?? getFiletypeFromFileName(fileContents.name) ); - this.#textLinesCache = this.#textDocument.lines; this.#stateStackCache = undefined; this.#selections = undefined; } @@ -367,39 +365,22 @@ export class Editor { if (this.#highlighter !== undefined) { const t = performance.now(); - const prevLines = this.#textLinesCache ?? []; + const lastChange = textDocument.lastChange; const { startingLine = 0, totalLines = Infinity } = this.#renderRange ?? {}; const endLine = totalLines === Infinity ? textDocument.lineCount : Math.min(startingLine + totalLines, textDocument.lineCount); + const previousLineCount = + lastChange?.previousLineCount ?? textDocument.lineCount; const prevEndLine = totalLines === Infinity - ? prevLines.length - : Math.min(startingLine + totalLines, prevLines.length); - const compareEndLine = Math.max(endLine, prevEndLine); - const dirtyLines = new Set(); - const linesChange = textDocument.lineCount - prevLines.length; - - let dirtyLineStart = -1; - let dirtyLineEnd = -1; - for (let line = startingLine; line < compareEndLine; line++) { - const prevLine = line < prevLines.length ? prevLines[line] : undefined; - const nextLine = - line < textDocument.lineCount - ? textDocument.getLineText(line, false) - : undefined; - if (prevLine !== nextLine) { - if (dirtyLineStart === -1) { - dirtyLineStart = line; - } - dirtyLineEnd = line; - if (line < endLine) { - dirtyLines.add(line); - } - } - } + ? previousLineCount + : Math.min(startingLine + totalLines, previousLineCount); + const { dirtyLines, dirtyLineStart, dirtyLineEnd, tokenizerStartLine } = + resolveDirtyLines(lastChange, startingLine, endLine); + const linesChange = lastChange?.lineDelta ?? 0; for (let line = endLine; line < prevEndLine; line++) { this.#getLineElement(line)?.remove(); @@ -410,7 +391,7 @@ export class Editor { if (dirtyLineStart !== -1) { this.#stateStackCache = previousStateStackCache?.slice( 0, - dirtyLineStart + 1 + tokenizerStartLine + 1 ); } @@ -558,7 +539,6 @@ export class Editor { } } - this.#textLinesCache = textDocument.lines; if (this.#onChange !== undefined) { this.#onChange({ ...fileContents, contents: textDocument.getText() }); } diff --git a/packages/diffs/src/editor/textDocument.ts b/packages/diffs/src/editor/textDocument.ts index f8c121182..5a6a24c1d 100644 --- a/packages/diffs/src/editor/textDocument.ts +++ b/packages/diffs/src/editor/textDocument.ts @@ -88,6 +88,19 @@ export type ResolvedTextEdit = { readonly text: string; }; +export type TextDocumentChange = { + /** First line whose rendered content or tokenizer state may have changed. */ + readonly startLine: number; + /** Last line whose rendered content may have changed after the edit. */ + readonly endLine: number; + /** Line count before the edit was applied. */ + readonly previousLineCount: number; + /** Line count after the edit was applied. */ + readonly lineCount: number; + /** Difference between the old and new line counts. */ + readonly lineDelta: number; +}; + /** * A vscode-languageserver-textdocument compatible text document. */ @@ -97,6 +110,7 @@ export class TextDocument { #version: number; #pieceTable: PieceTable; #editStack = new EditStack(); + #lastChange?: TextDocumentChange; constructor( uri: string, @@ -126,12 +140,8 @@ export class TextDocument { return this.#pieceTable.lineCount; } - get lines(): string[] { - const lines: string[] = []; - for (let line = 0; line < this.#pieceTable.lineCount; line++) { - lines.push(this.getLineText(line, false)); - } - return lines; + get lastChange(): TextDocumentChange | undefined { + return this.#lastChange; } get canUndo(): boolean { @@ -173,11 +183,14 @@ export class TextDocument { updateHistory = false, selectionsBefore?: EditorSelection[], selectionsAfter?: EditorSelection[] - ): void { + ): TextDocumentChange | undefined { if (edits.length === 0) { - return; + this.#lastChange = undefined; + return undefined; } - const resolvedEdits = edits.map((edit) => this.#resolveEdit(edit)); + const resolvedEdits = this.#sortAndValidateResolvedEdits( + edits.map((edit) => this.#resolveEdit(edit)) + ); if (updateHistory && selectionsBefore !== undefined) { this.#editStack.push( this, @@ -188,8 +201,9 @@ export class TextDocument { selectionsAfter ); } - this.#applyResolvedEdits(resolvedEdits); + this.#lastChange = this.#applyResolvedEdits(resolvedEdits); this.#version++; + return this.#lastChange; } setLastUndoSelectionsAfter(selections: EditorSelection[]): void { @@ -199,9 +213,10 @@ export class TextDocument { undo(): EditorSelection[] | undefined { const entry = this.#editStack.popUndoToRedo(); if (entry === undefined) { + this.#lastChange = undefined; return undefined; } - this.#applyResolvedEdits(entry.inverseEdits); + this.#lastChange = this.#applyResolvedEdits(entry.inverseEdits); this.#version = entry.versionBefore; return entry.selectionsBefore !== undefined ? entry.selectionsBefore.map((selection) => ({ ...selection })) @@ -211,9 +226,10 @@ export class TextDocument { redo(): EditorSelection[] | undefined { const entry = this.#editStack.popRedoToUndo(); if (entry === undefined) { + this.#lastChange = undefined; return undefined; } - this.#applyResolvedEdits(entry.forwardEdits); + this.#lastChange = this.#applyResolvedEdits(entry.forwardEdits); this.#version = entry.versionAfter; return entry.selectionsAfter !== undefined ? entry.selectionsAfter.map((selection) => ({ ...selection })) @@ -231,17 +247,56 @@ export class TextDocument { return { start, end, text: edit.newText }; } - #applyResolvedEdits(edits: ResolvedTextEdit[]): void { - const sortedEdits = [...edits].sort((a, b) => b.start - a.start); + #sortAndValidateResolvedEdits(edits: ResolvedTextEdit[]): ResolvedTextEdit[] { + const sortedEdits = [...edits].sort((a, b) => a.start - b.start); for (let i = 0; i < sortedEdits.length - 1; i++) { - if (sortedEdits[i + 1].end > sortedEdits[i].start) { + if (sortedEdits[i].end > sortedEdits[i + 1].start) { throw new Error('Overlapping text edits are not supported'); } } - for (const edit of sortedEdits) { + return sortedEdits; + } + + #applyResolvedEdits(edits: ResolvedTextEdit[]): TextDocumentChange { + const previousLineCount = this.#pieceTable.lineCount; + const changedLineRange = this.#computeChangedLineRange(edits); + for (let i = edits.length - 1; i >= 0; i--) { + const edit = edits[i]; this.#pieceTable.delete(edit.start, edit.end - edit.start); this.#pieceTable.insert(edit.text, edit.start); } + const lineCount = this.#pieceTable.lineCount; + return { + startLine: changedLineRange.startLine, + endLine: Math.min(changedLineRange.endLine, Math.max(0, lineCount - 1)), + previousLineCount, + lineCount, + lineDelta: lineCount - previousLineCount, + }; + } + + #computeChangedLineRange(edits: ResolvedTextEdit[]): { + startLine: number; + endLine: number; + } { + let startLine = Infinity; + let endLine = 0; + let lineDeltaBeforeEdit = 0; + for (const edit of edits) { + const editStartLine = this.positionAt(edit.start).line; + const editEndLine = this.positionAt(edit.end).line; + const insertedLineSpan = lineFeedCount(edit.text); + startLine = Math.min(startLine, editStartLine); + endLine = Math.max( + endLine, + editStartLine + lineDeltaBeforeEdit + insertedLineSpan + ); + lineDeltaBeforeEdit += insertedLineSpan - (editEndLine - editStartLine); + } + if (startLine === Infinity) { + return { startLine: 0, endLine: 0 }; + } + return { startLine, endLine }; } } @@ -256,3 +311,13 @@ function stripLineEndings(text: string): string { function isEOL(charCode: number): boolean { return charCode === 10 || charCode === 13; } + +function lineFeedCount(text: string): number { + let count = 0; + for (let i = 0; i < text.length; i++) { + if (text.charCodeAt(i) === 10) { + count++; + } + } + return count; +} diff --git a/packages/diffs/test/textDocument.test.ts b/packages/diffs/test/textDocument.test.ts index aec7b1278..970350859 100644 --- a/packages/diffs/test/textDocument.test.ts +++ b/packages/diffs/test/textDocument.test.ts @@ -94,7 +94,7 @@ describe('TextDocument', () => { test('applyEdits single replacement', () => { const d = doc('hello world'); - d.applyEdits([ + const change = d.applyEdits([ { range: { start: { line: 0, character: 6 }, @@ -104,6 +104,14 @@ describe('TextDocument', () => { }, ]); expect(d.getText()).toBe('hello you'); + expect(change).toEqual({ + startLine: 0, + endLine: 0, + previousLineCount: 1, + lineCount: 1, + lineDelta: 0, + }); + expect(d.lastChange).toBe(change); }); test('applyEdits swaps inverted start/end', () => { @@ -144,7 +152,7 @@ describe('TextDocument', () => { test('applyEdits preserves line breaks around edited line', () => { const d = doc('a\nb\nc'); - d.applyEdits([ + const change = d.applyEdits([ { range: { start: { line: 1, character: 0 }, @@ -155,6 +163,55 @@ describe('TextDocument', () => { ]); expect(d.getText()).toBe('a\nB\nc'); expect(d.lineCount).toBe(3); + expect(change).toEqual({ + startLine: 1, + endLine: 1, + previousLineCount: 3, + lineCount: 3, + lineDelta: 0, + }); + }); + + test('applyEdits reports inserted lines in lastChange', () => { + const d = doc('a'); + const change = d.applyEdits([ + { + range: { + start: { line: 0, character: 1 }, + end: { line: 0, character: 1 }, + }, + newText: '\nb', + }, + ]); + expect(d.getText()).toBe('a\nb'); + expect(change).toEqual({ + startLine: 0, + endLine: 1, + previousLineCount: 1, + lineCount: 2, + lineDelta: 1, + }); + }); + + test('applyEdits reports line deletions in lastChange', () => { + const d = doc('a\nb\nc'); + const change = d.applyEdits([ + { + range: { + start: { line: 0, character: 1 }, + end: { line: 2, character: 0 }, + }, + newText: '', + }, + ]); + expect(d.getText()).toBe('ac'); + expect(change).toEqual({ + startLine: 0, + endLine: 0, + previousLineCount: 3, + lineCount: 1, + lineDelta: -2, + }); }); test('applyEdits preserves CRLF after middle-line edit', () => { @@ -349,11 +406,25 @@ describe('TextDocument', () => { d.undo(); expect(d.getText()).toBe('a'); + expect(d.lastChange).toEqual({ + startLine: 0, + endLine: 0, + previousLineCount: 1, + lineCount: 1, + lineDelta: 0, + }); expect(d.canUndo).toBe(false); expect(d.canRedo).toBe(true); d.redo(); expect(d.getText()).toBe('ab'); + expect(d.lastChange).toEqual({ + startLine: 0, + endLine: 0, + previousLineCount: 1, + lineCount: 1, + lineDelta: 0, + }); expect(d.canUndo).toBe(true); expect(d.canRedo).toBe(false); }); From ec78938152da317849b75115cab9f13bbcdb4608 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Fri, 1 May 2026 11:48:00 +0800 Subject: [PATCH 040/138] Fix multi-cursor textarea sync --- packages/diffs/src/editor/index.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/diffs/src/editor/index.ts b/packages/diffs/src/editor/index.ts index 3684c347b..f507ae59f 100644 --- a/packages/diffs/src/editor/index.ts +++ b/packages/diffs/src/editor/index.ts @@ -122,10 +122,23 @@ export class Editor { this.#updateTextarea(primarySelection); } else if ( this.#textareaEl !== undefined && - this.#textareaSnapshot !== undefined && - this.#textareaSnapshot.text !== this.#textareaEl.value + this.#textDocument !== undefined && + this.#textareaSnapshot !== undefined ) { - this.#textareaSnapshot.text = this.#textareaEl.value; + const nextTextareaSnapshot = createTextareaSnapshot( + this.#textDocument, + primarySelection + ); + const shouldSyncTextarea = + nextTextareaSnapshot.text !== this.#textareaEl.value || + nextTextareaSnapshot.selectionStart !== + this.#textareaEl.selectionStart || + nextTextareaSnapshot.selectionEnd !== this.#textareaEl.selectionEnd; + if (shouldSyncTextarea) { + this.#updateTextarea(primarySelection); + } else { + this.#textareaSnapshot = nextTextareaSnapshot; + } } } From c56c600627aba399f2b962b986a459fade9c12e9 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Fri, 1 May 2026 15:42:29 +0800 Subject: [PATCH 041/138] Refactor Editor rendering logic for improved performance and reduce direct DOM manipulation. --- packages/diffs/src/editor/constants.ts | 1 + packages/diffs/src/editor/editorTextarea.ts | 2 - packages/diffs/src/editor/editorUtils.ts | 2 +- packages/diffs/src/editor/index.ts | 178 +++++++++++--------- 4 files changed, 100 insertions(+), 83 deletions(-) diff --git a/packages/diffs/src/editor/constants.ts b/packages/diffs/src/editor/constants.ts index 2b092e14c..f622ab825 100644 --- a/packages/diffs/src/editor/constants.ts +++ b/packages/diffs/src/editor/constants.ts @@ -21,6 +21,7 @@ export const EDITOR_CSS = /* CSS */ ` } [data-textarea], [data-caret], [data-line-highlight], [data-selection-range] { position: absolute; + top: 0; left: 0; z-index: -10; height: 1lh; diff --git a/packages/diffs/src/editor/editorTextarea.ts b/packages/diffs/src/editor/editorTextarea.ts index cd9b35f7c..78d63c3f5 100644 --- a/packages/diffs/src/editor/editorTextarea.ts +++ b/packages/diffs/src/editor/editorTextarea.ts @@ -6,7 +6,6 @@ export interface TextareaSnapshot { offset: number; selectionStart: number; selectionEnd: number; - lines: number; text: string; } @@ -44,7 +43,6 @@ export function createTextareaSnapshot( offset: textDocument.offsetAt({ line: startLine, character: 0 }), selectionStart, selectionEnd, - lines: lines.length, text: lines.join('\n'), }; } diff --git a/packages/diffs/src/editor/editorUtils.ts b/packages/diffs/src/editor/editorUtils.ts index 4d4ef3fb6..dfdab7410 100644 --- a/packages/diffs/src/editor/editorUtils.ts +++ b/packages/diffs/src/editor/editorUtils.ts @@ -9,7 +9,7 @@ export function createElement( dataset?: DOMStringMap | string[] | string; textContent?: string; } = {}, - parent?: Element | ShadowRoot + parent?: Element | ShadowRoot | DocumentFragment ): HTMLElementTagNameMap[K] { const el = document.createElement(tagName); const { id, class: className, style, dataset, textContent } = props; diff --git a/packages/diffs/src/editor/index.ts b/packages/diffs/src/editor/index.ts index f507ae59f..01e8ce1b5 100644 --- a/packages/diffs/src/editor/index.ts +++ b/packages/diffs/src/editor/index.ts @@ -73,6 +73,7 @@ export class Editor { #textareaEl?: HTMLTextAreaElement; #selectionEls?: Map; + #editorLeft = -1; #charWidth = -1; #lineHeight = 20; #tabSize = 2; @@ -116,7 +117,6 @@ export class Editor { Math.max(0, primarySelection.start.line - 1) !== this.#textareaSnapshot?.startLine; this.#selections = selections; - this.#file?.setSelectedLines(null); this.#renderSelections(selections, primarySelection); if (shouldUpdateTextarea) { this.#updateTextarea(primarySelection); @@ -176,8 +176,6 @@ export class Editor { fileContents: FileContents, renderRange: RenderRange | undefined ): void { - console.log('Editor initialized, renderRange:', renderRange); - if ( this.#textDocument === undefined || this.#fileContents === undefined || @@ -205,6 +203,7 @@ export class Editor { if (this.#contentEl === undefined) { throw new Error('could not edit the file.'); } + this.#editorLeft = -1; this.#textareaEl ??= extend( createElement('textarea', { dataset: 'textarea' }), @@ -345,6 +344,12 @@ export class Editor { } this.#getCSSProperites(); + console.log('Editor initialized.', { + renderRange, + tabSize: this.#tabSize, + lineHeight: this.#lineHeight, + charWidth: this.#charWidth, + }); } #computeMouseSelectionDirection(): SelectionDirection { @@ -714,9 +719,8 @@ export class Editor { primarySelection ); const direction = toTextareaSelectionDirection(primarySelection); - textareaEl.style.top = this.#getLineY(primarySelection.start.line) + 'px'; - textareaEl.style.height = textareaSnapshot.lines + 'lh'; textareaEl.value = textareaSnapshot.text; + textareaEl.style.transform = `translateY(${this.#getLineY(primarySelection.start.line)}px)`; textareaEl.setSelectionRange( textareaSnapshot.selectionStart, textareaSnapshot.selectionEnd, @@ -747,50 +751,62 @@ export class Editor { selections: EditorSelection[], primarySelection: EditorSelection ) { - const selectionEls = new Map(); + const fragment = document.createDocumentFragment(); + const cacheMap = new Map(); + this.#file?.setSelectedLines(null); if (isCollapsedSelection(primarySelection)) { - this.#renderLineHighlight(primarySelection, selectionEls); + this.#file?.setSelectedLines({ + start: primarySelection.start.line + 1, + end: primarySelection.end.line + 1, + }); + this.#renderLineHighlight(primarySelection, fragment, cacheMap); } selections.forEach((selection) => { if (selections.length > 1 || !isCollapsedSelection(selection)) { - this.#renderSelectionRange(selection, selectionEls); + this.#renderSelectionRange(selection, fragment, cacheMap); } - this.#renderCaret(selection, selectionEls); + this.#renderCaret(selection, fragment, cacheMap); }); + this.#contentEl?.append(fragment); this.#selectionEls?.forEach((el) => el.remove()); this.#selectionEls?.clear(); - this.#selectionEls = selectionEls; + this.#selectionEls = cacheMap; } #renderLineHighlight( selection: EditorSelection, - markMap: Map + fragment: DocumentFragment, + cacheMap: Map ) { if (!this.#isSelectionVisible(selection)) { return; } + + const cacheKey = `lineHighlight-${selection.start.line}`; + if (this.#selectionEls?.has(cacheKey) === true) { + const el = this.#selectionEls.get(cacheKey)!; + this.#selectionEls.delete(cacheKey); + cacheMap.set(cacheKey, el); + return; + } + const hlEl = createElement( 'div', { dataset: 'lineHighlight', style: { - top: this.#getLineY(selection.start.line) + 'px', + transform: `translateY(${this.#getLineY(selection.start.line)}px)`, }, }, - this.#contentEl + fragment ); - - this.#file?.setSelectedLines({ - start: selection.start.line + 1, - end: selection.end.line + 1, - }); - // hlEl.scrollIntoView({ block: "nearest" }); - markMap.set(`lineHighlight-${selection.start.line}`, hlEl); + cacheMap.set(cacheKey, hlEl); } #renderSelectionRange( selection: EditorSelection, - markMap: Map + fragment: DocumentFragment, + cacheMap: Map ) { if (!this.#isSelectionVisible(selection)) { return; @@ -817,46 +833,53 @@ export class Editor { if (selectionEls?.has(cacheKey) === true) { rangeEl = selectionEls.get(cacheKey)!; selectionEls.delete(cacheKey); + cacheMap.set(cacheKey, rangeEl); + // already in view, skip + continue; + } + + let left = 0; + let width = 0; + if (startChar === endChar && startChar === 0) { + left = this.#charWidth; + width = this.#charWidth; } else { - let left = 0; - let width = 0; - if (startChar === endChar && startChar === 0) { - left = this.#charWidth; - width = this.#charWidth; - } else { - const startX = this.#getCharacterX(ln, startChar); - const endX = - endChar === startChar ? startX : this.#getCharacterX(ln, endChar); - left = startX; - width = endX - startX; - } + const startX = this.#getCharacterX(ln, startChar); + const endX = + endChar === startChar ? startX : this.#getCharacterX(ln, endChar); + left = startX; + width = endX - startX; + } - for (const [key, el] of selectionEls?.entries() ?? []) { - if (key.startsWith(`selection-${ln}-`)) { - rangeEl = el; - selectionEls?.delete(key); - el.style.left = left + 'px'; - el.style.width = width + spacing + 'px'; - break; - } + const css = `width: ${width + spacing}px; transform: translateY(${this.#getLineY(ln)}px) translateX(${left}px);`; + + for (const [key, el] of selectionEls?.entries() ?? []) { + if (key.startsWith(`selection-${ln}-`)) { + rangeEl = el; + selectionEls?.delete(key); + el.style.cssText = css; + break; } + } - rangeEl ??= createElement('div', { + rangeEl ??= createElement( + 'div', + { dataset: 'selectionRange', - style: { - top: this.#getLineY(ln) + 'px', - left: left + 'px', - width: width + spacing + 'px', - }, - }); - } + style: { cssText: css }, + }, + fragment + ); - this.#contentEl?.append(rangeEl); - markMap.set(cacheKey, rangeEl); + cacheMap.set(cacheKey, rangeEl); } } - #renderCaret(selection: EditorSelection, markMap: Map) { + #renderCaret( + selection: EditorSelection, + fragment: DocumentFragment, + cacheMap: Map + ) { if (!this.#isSelectionVisible(selection)) { return; } @@ -874,13 +897,12 @@ export class Editor { { dataset: 'caret', style: { - top: this.#getLineY(line) + 'px', - left: left + 'px', + transform: `translateY(${this.#getLineY(line)}px) translateX(${left}px)`, }, }, - this.#contentEl + fragment ); - markMap.set('caret-' + line + '-' + character + '-' + direction, caretEl); + cacheMap.set('caret-' + line + '-' + character, caretEl); } async #runCommand(command: EditorCommand) { @@ -1134,9 +1156,12 @@ export class Editor { return 0; } - const editorRect = contentEl.getBoundingClientRect(); + const editorLeft = + this.#editorLeft > -1 + ? this.#editorLeft + : (this.#editorLeft = contentEl.getBoundingClientRect().left); const pointRect = range.getBoundingClientRect(); - return pointRect.left - editorRect.left; + return pointRect.left - editorLeft; } #getCSSProperites() { @@ -1144,32 +1169,25 @@ export class Editor { return; } - const styleMap = this.#contentEl.computedStyleMap(); - const tabSize = styleMap.get('tab-size'); - if ( - tabSize !== undefined && - tabSize instanceof CSSUnitValue && - tabSize.unit === 'number' - ) { - this.#tabSize = tabSize.value; + const { fontFamily, fontSize, lineHeight, tabSize } = getComputedStyle( + this.#contentEl + ); + + const el = document.createElement('canvas'); + const ctx = el.getContext('2d'); + if (ctx !== null) { + ctx.font = fontSize + ' ' + fontFamily; + this.#charWidth = Math.round(ctx.measureText('0').width * 1000) / 1000; } - const lineHeight = styleMap.get('line-height'); - if ( - lineHeight !== undefined && - lineHeight instanceof CSSUnitValue && - lineHeight.unit === 'px' - ) { - this.#lineHeight = Number(lineHeight.value); + if (lineHeight.endsWith('px')) { + this.#lineHeight = Number(lineHeight.slice(0, -2)); + } else if (fontSize.endsWith('px')) { + this.#lineHeight = + Number(fontSize.slice(0, -2)) * Number(lineHeight.slice(0, -2)); } - const el = document.createElement('div'); - el.style.width = '1ch'; - el.style.position = 'absolute'; - el.style.visibility = 'hidden'; - this.#contentEl.appendChild(el); - this.#charWidth = el.offsetWidth; - el.remove(); + this.#tabSize = Number(tabSize); } // check if the web selection belongs to editor From 2a74aaf1b2bb293cec675d48cf3d6895c029335c Mon Sep 17 00:00:00 2001 From: Je Xia Date: Fri, 1 May 2026 16:04:18 +0800 Subject: [PATCH 042/138] Add grammer cache --- packages/diffs/src/editor/index.ts | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/diffs/src/editor/index.ts b/packages/diffs/src/editor/index.ts index 01e8ce1b5..05e491091 100644 --- a/packages/diffs/src/editor/index.ts +++ b/packages/diffs/src/editor/index.ts @@ -1,4 +1,9 @@ -import { EncodedTokenMetadata, INITIAL, type StateStack } from 'shiki/textmate'; +import { + EncodedTokenMetadata, + type IGrammar, + INITIAL, + type StateStack, +} from 'shiki/textmate'; import { areThemesAttached, @@ -64,6 +69,7 @@ export class Editor { #onChange?: (file: FileContents) => void; #highlighter?: DiffsHighlighter; + #grammar?: IGrammar; #colorMap?: Map; #stateStackCache?: StateStack[]; @@ -188,6 +194,7 @@ export class Editor { fileContents.contents, fileContents.lang ?? getFiletypeFromFileName(fileContents.name) ); + this.#grammar = undefined; this.#stateStackCache = undefined; this.#selections = undefined; } @@ -199,11 +206,12 @@ export class Editor { const shadowRoot = fileContainer.shadowRoot ?? fileContainer.attachShadow({ mode: 'open' }); + + this.#editorLeft = -1; this.#contentEl = shadowRoot.querySelector('[data-content]') ?? undefined; if (this.#contentEl === undefined) { throw new Error('could not edit the file.'); } - this.#editorLeft = -1; this.#textareaEl ??= extend( createElement('textarea', { dataset: 'textarea' }), @@ -258,6 +266,7 @@ export class Editor { ) { return; } + const selection = convertSelection( composedRanges, this.#computeMouseSelectionDirection() @@ -344,6 +353,7 @@ export class Editor { } this.#getCSSProperites(); + console.log('Editor initialized.', { renderRange, tabSize: this.#tabSize, @@ -404,7 +414,9 @@ export class Editor { this.#getLineElement(line)?.remove(); } - const grammar = this.#highlighter.getLanguage(textDocument.languageId); + const grammar = (this.#grammar ??= this.#highlighter.getLanguage( + textDocument.languageId + )); const previousStateStackCache = this.#stateStackCache; if (dirtyLineStart !== -1) { this.#stateStackCache = previousStateStackCache?.slice( @@ -585,7 +597,7 @@ export class Editor { #buildStateStackCache( textDocument: TextDocument, - grammar: ReturnType, + grammar: IGrammar, endLine: number ): StateStack { const stateStackCache = (this.#stateStackCache ??= [INITIAL]); From d456fa90aaa2248e6957e5d4060bdc308ef92297 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Fri, 1 May 2026 16:19:22 +0800 Subject: [PATCH 043/138] Enhance line position caching in Editor for improved performance and accuracy. --- packages/diffs/src/editor/index.ts | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/packages/diffs/src/editor/index.ts b/packages/diffs/src/editor/index.ts index 05e491091..6f34a7911 100644 --- a/packages/diffs/src/editor/index.ts +++ b/packages/diffs/src/editor/index.ts @@ -71,7 +71,9 @@ export class Editor { #highlighter?: DiffsHighlighter; #grammar?: IGrammar; #colorMap?: Map; + #stateStackCache?: StateStack[]; + #lineYCache = new Map(); // dom elements #contentEl?: HTMLElement; @@ -161,6 +163,7 @@ export class Editor { this.#highlighter = undefined; this.#colorMap = undefined; this.#stateStackCache = undefined; + this.#lineYCache.clear(); this.#contentEl = undefined; this.#styleEl?.remove(); @@ -208,6 +211,7 @@ export class Editor { fileContainer.shadowRoot ?? fileContainer.attachShadow({ mode: 'open' }); this.#editorLeft = -1; + this.#lineYCache.clear(); this.#contentEl = shadowRoot.querySelector('[data-content]') ?? undefined; if (this.#contentEl === undefined) { throw new Error('could not edit the file.'); @@ -411,6 +415,7 @@ export class Editor { const linesChange = lastChange?.lineDelta ?? 0; for (let line = endLine; line < prevEndLine; line++) { + this.#lineYCache.delete(line); this.#getLineElement(line)?.remove(); } @@ -1098,12 +1103,19 @@ export class Editor { ); } - // get line Y position + // get line top position #getLineY(line: number) { - return this.#getLineElement(line)?.offsetTop ?? 0; + const cachedY = this.#lineYCache.get(line); + if (cachedY !== undefined) { + return cachedY; + } + + const y = this.#getLineElement(line)?.offsetTop ?? 0; + this.#lineYCache.set(line, y); + return y; } - // get character X position + // get character left position in line #getCharacterX(line: number, character: number) { const contentEl = this.#contentEl; const lineEl = this.#getLineElement(line); @@ -1112,12 +1124,12 @@ export class Editor { lineEl === undefined || !lineEl.hasChildNodes() ) { - return 0; + return this.#charWidth; // padding-inline: 1ch } const children = lineEl.children; if (children.length === 1 && children[0] instanceof Text) { - return 0; + return this.#charWidth; // padding-inline: 1ch } let targetSpan: HTMLElement | undefined; From c446ad9454b2fc319ffb5e481e9a80734a217a78 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Fri, 1 May 2026 16:59:09 +0800 Subject: [PATCH 044/138] Refactor indentation handling in Editor and remove unused utility function for improved clarity and performance. --- packages/diffs/src/editor/editorSelection.ts | 3 +- packages/diffs/src/editor/editorUtils.ts | 10 -- packages/diffs/src/editor/index.ts | 38 ++++--- packages/diffs/src/editor/pieceTable.ts | 107 +++++++++++++------ packages/diffs/src/editor/textDocument.ts | 9 ++ packages/diffs/test/pieceTable.test.ts | 15 +++ 6 files changed, 119 insertions(+), 63 deletions(-) diff --git a/packages/diffs/src/editor/editorSelection.ts b/packages/diffs/src/editor/editorSelection.ts index 8648f8a26..d7d8dc66a 100644 --- a/packages/diffs/src/editor/editorSelection.ts +++ b/packages/diffs/src/editor/editorSelection.ts @@ -1,4 +1,3 @@ -import { getLineIndentationUnit } from './editorUtils'; import type { Position, Range, TextDocument, TextEdit } from './textDocument'; export enum SelectionDirection { @@ -55,7 +54,7 @@ export function resolveIndentEdits( if (lineText === undefined) { continue; } - const indentUnit = getLineIndentationUnit(lineText, tabSize); + const indentUnit = lineText.startsWith('\t') ? '\t' : ' '.repeat(tabSize); let deleteLength = 0; let newText = indentUnit; if (outdent) { diff --git a/packages/diffs/src/editor/editorUtils.ts b/packages/diffs/src/editor/editorUtils.ts index dfdab7410..856732f68 100644 --- a/packages/diffs/src/editor/editorUtils.ts +++ b/packages/diffs/src/editor/editorUtils.ts @@ -91,16 +91,6 @@ export function getLineIndentation(lineText: string): string { return indentation; } -export function getLineIndentationUnit( - lineText: string, - tabSize: number -): string { - if (lineText.startsWith('\t')) { - return '\t'; - } - return ' '.repeat(tabSize); -} - export function resolveDirtyLines( change: TextDocumentChange | undefined, startingLine: number, diff --git a/packages/diffs/src/editor/index.ts b/packages/diffs/src/editor/index.ts index 6f34a7911..f7ec60009 100644 --- a/packages/diffs/src/editor/index.ts +++ b/packages/diffs/src/editor/index.ts @@ -36,7 +36,6 @@ import { addEventListener, createElement, extend, - getLineIndentationUnit, isCodeLineTarget, resolveDirtyLines, } from '../editor/editorUtils'; @@ -970,25 +969,24 @@ export class Editor { const nextSelections: EditorSelection[] = []; for (const selection of this.#selections) { const startLine = selection.start.line; - const lineText = this.#textDocument.getLineText(startLine); - if (lineText !== undefined) { - const outdent = command === 'outdent'; - if (startLine !== selection.end.line || outdent) { - const ret = resolveIndentEdits( - this.#textDocument, - selection, - this.#tabSize, - outdent - ); - edits.push(...ret[0]); - nextSelections.push(ret[1]); - } else { - const indentUnit = getLineIndentationUnit( - lineText, - this.#tabSize - ); - this.#replaceSelectionText(indentUnit); - } + const outdent = command === 'outdent'; + if (startLine !== selection.end.line || outdent) { + const ret = resolveIndentEdits( + this.#textDocument, + selection, + this.#tabSize, + outdent + ); + edits.push(...ret[0]); + nextSelections.push(ret[1]); + } else { + const lineChar0 = this.#textDocument.charAt({ + line: startLine, + character: 0, + }); + this.#replaceSelectionText( + lineChar0 === '\t' ? '\t' : ' '.repeat(this.#tabSize) + ); } } if (edits.length > 0) { diff --git a/packages/diffs/src/editor/pieceTable.ts b/packages/diffs/src/editor/pieceTable.ts index 041546609..99100d87d 100644 --- a/packages/diffs/src/editor/pieceTable.ts +++ b/packages/diffs/src/editor/pieceTable.ts @@ -13,6 +13,11 @@ type PieceSegment = { readonly lineOffsets: number[]; }; +type PieceLocation = { + readonly node: PieceNode; + readonly offsetInPiece: number; +}; + enum PieceSourceType { Original = 0, Added = 1, @@ -112,11 +117,50 @@ export class PieceTable { return ''; } + const sliceStart = clamp(start, 0, this.#length); + const sliceEnd = clamp(end, sliceStart, this.#length); + if (sliceStart >= sliceEnd) { + return ''; + } + + const location = this.#findPieceAtOffset(sliceStart); + if (location === undefined) { + return ''; + } + const chunks: string[] = []; - this.#appendSliceFromNode(this.#root, start, end, 0, chunks); + let node: PieceNode | null = location.node; + let offsetInPiece = location.offsetInPiece; + let remaining = sliceEnd - sliceStart; + while (node !== null && remaining > 0) { + const takeLength = Math.min(node.piece.length - offsetInPiece, remaining); + const buffer = this.#bufferFor(node.piece.source); + chunks.push( + buffer.text.slice( + node.piece.offset + offsetInPiece, + node.piece.offset + offsetInPiece + takeLength + ) + ); + remaining -= takeLength; + offsetInPiece = 0; + node = this.#nextNode(node); + } + return chunks.join(''); } + charAt(offset: number): string { + const location = this.#findPieceAtOffset(offset); + if (location === undefined) { + return ''; + } + + const buffer = this.#bufferFor(location.node.piece.source); + return buffer.text.charAt( + location.node.piece.offset + location.offsetInPiece + ); + } + includes(needle: string): boolean { if (needle.length === 0) { return true; @@ -269,45 +313,46 @@ export class PieceTable { return offset[0] + character; } - #appendSliceFromNode( - node: PieceNode | null, - start: number, - end: number, - subtreeStart: number, - chunks: string[] - ): void { - if (node === null || start >= end) { - return; + #findPieceAtOffset(offset: number): PieceLocation | undefined { + if (offset < 0 || offset >= this.#length) { + return undefined; } - const subtreeEnd = subtreeStart + node.subtreeLength; - if (end <= subtreeStart || start >= subtreeEnd) { - return; - } + let node = this.#root; + let remaining = offset; + while (node !== null) { + const leftLength = node.left?.subtreeLength ?? 0; + if (remaining < leftLength) { + node = node.left; + continue; + } - const leftLength = node.left?.subtreeLength ?? 0; - const pieceStart = subtreeStart + leftLength; - const pieceEnd = pieceStart + node.piece.length; + remaining -= leftLength; + if (remaining < node.piece.length) { + return { node, offsetInPiece: remaining }; + } - if (start < pieceStart) { - this.#appendSliceFromNode(node.left, start, end, subtreeStart, chunks); + remaining -= node.piece.length; + node = node.right; } - if (start < pieceEnd && end > pieceStart) { - const localStart = Math.max(start - pieceStart, 0); - const localEnd = Math.min(end - pieceStart, node.piece.length); - const buffer = this.#bufferFor(node.piece.source); - chunks.push( - buffer.text.slice( - node.piece.offset + localStart, - node.piece.offset + localEnd - ) - ); + return undefined; + } + + #nextNode(node: PieceNode): PieceNode | null { + if (node.right !== null) { + let next = node.right; + while (next.left !== null) { + next = next.left; + } + return next; } - if (end > pieceEnd) { - this.#appendSliceFromNode(node.right, start, end, pieceEnd, chunks); + let current = node; + while (current.parent !== null && current === current.parent.right) { + current = current.parent; } + return current.parent; } #getLineOffset(line: number): [start: number, end: number] | undefined { diff --git a/packages/diffs/src/editor/textDocument.ts b/packages/diffs/src/editor/textDocument.ts index 5a6a24c1d..7cfb69d0d 100644 --- a/packages/diffs/src/editor/textDocument.ts +++ b/packages/diffs/src/editor/textDocument.ts @@ -174,6 +174,15 @@ export class TextDocument { return text; } + charAt(offset: number): string; + charAt(position: Position): string; + charAt(positionOrOffset: Position | number): string { + if (typeof positionOrOffset === 'number') { + return this.#pieceTable.charAt(positionOrOffset); + } + return this.#pieceTable.charAt(this.offsetAt(positionOrOffset)); + } + getTextSlice(start: number, end: number): string { return this.#pieceTable.getTextSlice(start, end); } diff --git a/packages/diffs/test/pieceTable.test.ts b/packages/diffs/test/pieceTable.test.ts index 9173cea3a..163a3d0ba 100644 --- a/packages/diffs/test/pieceTable.test.ts +++ b/packages/diffs/test/pieceTable.test.ts @@ -236,6 +236,21 @@ describe('PieceTable', () => { ).toBe('bXXc'); }); + test('reads single characters from piece boundaries', () => { + const table = new PieceTable('ab\nef'); + + table.insert('CD', 3); + + expect(table.charAt(0)).toBe('a'); + expect(table.charAt(3)).toBe('C'); + expect(table.charAt(4)).toBe('D'); + expect(table.charAt(5)).toBe('e'); + expect(table.charAt(1, 0)).toBe('C'); + expect(table.charAt(1, 2)).toBe('e'); + expect(table.charAt(-1)).toBe(''); + expect(table.charAt(table.getText().length)).toBe(''); + }); + test('searches text across piece boundaries', () => { const table = new PieceTable('a\nb'); From 3e14a928ea307f713c26d85d970837927fca9286 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Fri, 1 May 2026 16:59:24 +0800 Subject: [PATCH 045/138] Fix testing types --- packages/diffs/test/pieceTable.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/diffs/test/pieceTable.test.ts b/packages/diffs/test/pieceTable.test.ts index 163a3d0ba..fe1b8ce13 100644 --- a/packages/diffs/test/pieceTable.test.ts +++ b/packages/diffs/test/pieceTable.test.ts @@ -245,8 +245,6 @@ describe('PieceTable', () => { expect(table.charAt(3)).toBe('C'); expect(table.charAt(4)).toBe('D'); expect(table.charAt(5)).toBe('e'); - expect(table.charAt(1, 0)).toBe('C'); - expect(table.charAt(1, 2)).toBe('e'); expect(table.charAt(-1)).toBe(''); expect(table.charAt(table.getText().length)).toBe(''); }); From d656e4a3f1a1e89065ba4f0184fe470d22ef43ea Mon Sep 17 00:00:00 2001 From: Je Xia Date: Fri, 1 May 2026 17:58:14 +0800 Subject: [PATCH 046/138] Improve performance of the `getCharacterX` method --- packages/diffs/src/editor/editorUtils.ts | 9 ++ packages/diffs/src/editor/index.ts | 151 ++++++++++------------- packages/diffs/src/editor/pieceTable.ts | 21 ++-- 3 files changed, 84 insertions(+), 97 deletions(-) diff --git a/packages/diffs/src/editor/editorUtils.ts b/packages/diffs/src/editor/editorUtils.ts index 856732f68..833ac8079 100644 --- a/packages/diffs/src/editor/editorUtils.ts +++ b/packages/diffs/src/editor/editorUtils.ts @@ -91,6 +91,15 @@ export function getLineIndentation(lineText: string): string { return indentation; } +export function isAsciiOnly(text: string): boolean { + for (let i = 0; i < text.length; i++) { + if (text.charCodeAt(i) > 127) { + return false; + } + } + return true; +} + export function resolveDirtyLines( change: TextDocumentChange | undefined, startingLine: number, diff --git a/packages/diffs/src/editor/index.ts b/packages/diffs/src/editor/index.ts index f7ec60009..295867a89 100644 --- a/packages/diffs/src/editor/index.ts +++ b/packages/diffs/src/editor/index.ts @@ -36,6 +36,7 @@ import { addEventListener, createElement, extend, + isAsciiOnly, isCodeLineTarget, resolveDirtyLines, } from '../editor/editorUtils'; @@ -60,17 +61,26 @@ import { export class Editor { #disposes?: (() => void)[]; + #onChange?: (file: FileContents) => void; + + // css properties + #measureCtx?: CanvasRenderingContext2D; + #charWidth = -1; + #lineHeight = 20; + #tabSize = 2; + // file #file?: File; #fileContents?: FileContents; #textDocument?: TextDocument; - #renderRange?: RenderRange; - #onChange?: (file: FileContents) => void; + // highlighter #highlighter?: DiffsHighlighter; - #grammar?: IGrammar; #colorMap?: Map; + #grammar?: IGrammar; + #renderRange?: RenderRange; + // cache #stateStackCache?: StateStack[]; #lineYCache = new Map(); @@ -80,11 +90,6 @@ export class Editor { #textareaEl?: HTMLTextAreaElement; #selectionEls?: Map; - #editorLeft = -1; - #charWidth = -1; - #lineHeight = 20; - #tabSize = 2; - // state #selectionStartX = 0; #selectionStartY = 0; @@ -97,7 +102,9 @@ export class Editor { edit( file: File, - onChange?: (file: FileContents) => void + options?: { + onChange?: (file: FileContents) => void; + } ): () => void { file.__addEditorHook((fileContainer, fileContents, renderRange) => { this.#initialize(fileContainer, fileContents, renderRange); @@ -108,7 +115,7 @@ export class Editor { ) ? getHighlighterIfLoaded() : undefined; - this.#onChange = onChange; + this.#onChange = options?.onChange; return this.cleanUp.bind(this); } @@ -152,15 +159,19 @@ export class Editor { cleanUp(): void { this.#disposes?.forEach((dispose) => dispose()); this.#disposes = undefined; + this.#onChange = undefined; + + this.#measureCtx = undefined; this.#file = undefined; this.#fileContents = undefined; this.#textDocument = undefined; - this.#renderRange = undefined; - this.#onChange = undefined; this.#highlighter = undefined; this.#colorMap = undefined; + this.#grammar = undefined; + this.#renderRange = undefined; + this.#stateStackCache = undefined; this.#lineYCache.clear(); @@ -209,7 +220,6 @@ export class Editor { const shadowRoot = fileContainer.shadowRoot ?? fileContainer.attachShadow({ mode: 'open' }); - this.#editorLeft = -1; this.#lineYCache.clear(); this.#contentEl = shadowRoot.querySelector('[data-content]') ?? undefined; if (this.#contentEl === undefined) { @@ -241,18 +251,17 @@ export class Editor { } // if caret position changes in textarea, sync the textarea state. - if ( - this.#textareaEl !== undefined && - this.#textareaSnapshot !== undefined - ) { - const { selectionStart, selectionEnd } = this.#textareaEl; + const textareaEl = this.#textareaEl; + const textareaSnapshot = this.#textareaSnapshot; + if (textareaEl !== undefined && textareaSnapshot !== undefined) { + const { selectionStart, selectionEnd } = textareaEl; if ( - (this.#textareaSnapshot.selectionStart !== selectionStart || - this.#textareaSnapshot.selectionEnd !== selectionEnd) && - this.#textareaSnapshot.text === this.#textareaEl.value + (textareaSnapshot.selectionStart !== selectionStart || + textareaSnapshot.selectionEnd !== selectionEnd) && + textareaSnapshot.text === textareaEl.value ) { - this.#textareaSnapshot.selectionStart = selectionStart; - this.#textareaSnapshot.selectionEnd = selectionEnd; + textareaSnapshot.selectionStart = selectionStart; + textareaSnapshot.selectionEnd = selectionEnd; this.#syncTextareaState(); return; } @@ -1115,75 +1124,46 @@ export class Editor { // get character left position in line #getCharacterX(line: number, character: number) { - const contentEl = this.#contentEl; - const lineEl = this.#getLineElement(line); - if ( - contentEl === undefined || - lineEl === undefined || - !lineEl.hasChildNodes() - ) { + const lineText = this.#textDocument?.getLineText(line); + if (lineText === undefined || lineText.length === 0) { return this.#charWidth; // padding-inline: 1ch } - const children = lineEl.children; - if (children.length === 1 && children[0] instanceof Text) { - return this.#charWidth; // padding-inline: 1ch + const boundedCharacter = Math.max(0, Math.min(character, lineText.length)); + const textBeforeCharacter = lineText.slice(0, boundedCharacter); + if ( + isAsciiOnly(textBeforeCharacter) || + this.#file?.options.overflow === 'wrap' + ) { + return ( + this.#charWidth + this.#getExpandedAsciiTextWidth(textBeforeCharacter) + ); } - let targetSpan: HTMLElement | undefined; - let targetOffset = 0; - let lastSpan: HTMLElement | undefined; - let lastEnd = 0; - for (const child of children) { - if (!(child instanceof HTMLElement) || child.tagName !== 'SPAN') { - continue; - } - const dataChar = child.dataset.char; - if (dataChar === undefined) { - continue; - } - const start = Number(dataChar); - const textLength = child.textContent?.length ?? 0; - const end = start + textLength; - if (character >= start && character <= end) { - targetSpan = child; - targetOffset = character - start; - break; - } - if (end >= lastEnd) { - lastSpan = child; - lastEnd = end; - } - } + return this.#charWidth + this.#measureTextWidth(textBeforeCharacter); + } - const range = document.createRange(); - if (targetSpan !== undefined) { - const textNode = targetSpan.firstChild; - if (textNode === null) { - return 0; - } - const nodeLength = textNode.textContent?.length ?? 0; - const boundedOffset = Math.max(0, Math.min(targetOffset, nodeLength)); - range.setStart(textNode, boundedOffset); - range.setEnd(textNode, boundedOffset); - } else if (lastSpan !== undefined) { - const textNode = lastSpan.firstChild; - if (textNode === null) { - return 0; - } - const nodeLength = textNode.textContent?.length ?? 0; - range.setStart(textNode, nodeLength); - range.setEnd(textNode, nodeLength); - } else { - return 0; + #getExpandedAsciiTextWidth(text: string) { + let columns = 0; + for (let i = 0; i < text.length; i++) { + columns += text.charCodeAt(i) === /* '\t' */ 9 ? this.#tabSize : 1; } + return columns * this.#charWidth; + } - const editorLeft = - this.#editorLeft > -1 - ? this.#editorLeft - : (this.#editorLeft = contentEl.getBoundingClientRect().left); - const pointRect = range.getBoundingClientRect(); - return pointRect.left - editorLeft; + #measureTextWidth(text: string) { + if (this.#measureCtx === undefined) { + return this.#getExpandedAsciiTextWidth(text); + } + const textWithExpandedTabs = text.replaceAll( + '\t', + ' '.repeat(this.#tabSize) + ); + console.log( + textWithExpandedTabs, + this.#measureCtx.measureText(textWithExpandedTabs).width + ); + return this.#measureCtx.measureText(textWithExpandedTabs).width; } #getCSSProperites() { @@ -1199,7 +1179,10 @@ export class Editor { const ctx = el.getContext('2d'); if (ctx !== null) { ctx.font = fontSize + ' ' + fontFamily; + this.#measureCtx = ctx; this.#charWidth = Math.round(ctx.measureText('0').width * 1000) / 1000; + } else { + this.#measureCtx = undefined; } if (lineHeight.endsWith('px')) { diff --git a/packages/diffs/src/editor/pieceTable.ts b/packages/diffs/src/editor/pieceTable.ts index 99100d87d..b87704fdf 100644 --- a/packages/diffs/src/editor/pieceTable.ts +++ b/packages/diffs/src/editor/pieceTable.ts @@ -13,11 +13,6 @@ type PieceSegment = { readonly lineOffsets: number[]; }; -type PieceLocation = { - readonly node: PieceNode; - readonly offsetInPiece: number; -}; - enum PieceSourceType { Original = 0, Added = 1, @@ -129,8 +124,7 @@ export class PieceTable { } const chunks: string[] = []; - let node: PieceNode | null = location.node; - let offsetInPiece = location.offsetInPiece; + let [node, offsetInPiece] = location as [PieceNode | null, number]; let remaining = sliceEnd - sliceStart; while (node !== null && remaining > 0) { const takeLength = Math.min(node.piece.length - offsetInPiece, remaining); @@ -155,10 +149,9 @@ export class PieceTable { return ''; } - const buffer = this.#bufferFor(location.node.piece.source); - return buffer.text.charAt( - location.node.piece.offset + location.offsetInPiece - ); + const [node, offsetInPiece] = location; + const buffer = this.#bufferFor(node.piece.source); + return buffer.text.charAt(node.piece.offset + offsetInPiece); } includes(needle: string): boolean { @@ -313,7 +306,9 @@ export class PieceTable { return offset[0] + character; } - #findPieceAtOffset(offset: number): PieceLocation | undefined { + #findPieceAtOffset( + offset: number + ): [node: PieceNode, offsetInPiece: number] | undefined { if (offset < 0 || offset >= this.#length) { return undefined; } @@ -329,7 +324,7 @@ export class PieceTable { remaining -= leftLength; if (remaining < node.piece.length) { - return { node, offsetInPiece: remaining }; + return [node, remaining]; } remaining -= node.piece.length; From a8961f442712a434cfe1d9863b658476f8f12c8e Mon Sep 17 00:00:00 2001 From: Je Xia Date: Fri, 1 May 2026 21:38:20 +0800 Subject: [PATCH 047/138] Improve caching mechanism for enhanced performance. --- packages/diffs/src/editor/constants.ts | 5 +- packages/diffs/src/editor/editorUtils.ts | 9 -- packages/diffs/src/editor/index.ts | 129 ++++++++++++----------- 3 files changed, 71 insertions(+), 72 deletions(-) diff --git a/packages/diffs/src/editor/constants.ts b/packages/diffs/src/editor/constants.ts index f622ab825..401bf4876 100644 --- a/packages/diffs/src/editor/constants.ts +++ b/packages/diffs/src/editor/constants.ts @@ -6,9 +6,9 @@ export const EDITOR_CSS = /* CSS */ ` background-color: transparent; } @keyframes blinking { - 0% { opacity: 0.9; } + 0% { opacity: 0.85; } 50% { opacity: 0; } - 100% { opacity: 0.9; } + 100% { opacity: 0.85; } } [data-line] { background-color: transparent; @@ -32,7 +32,6 @@ export const EDITOR_CSS = /* CSS */ ` font: inherit; padding: 0; padding-inline: 1ch; - transform: translateY(-1lh); color: transparent; color: transparent; background-color: transparent; diff --git a/packages/diffs/src/editor/editorUtils.ts b/packages/diffs/src/editor/editorUtils.ts index 833ac8079..856732f68 100644 --- a/packages/diffs/src/editor/editorUtils.ts +++ b/packages/diffs/src/editor/editorUtils.ts @@ -91,15 +91,6 @@ export function getLineIndentation(lineText: string): string { return indentation; } -export function isAsciiOnly(text: string): boolean { - for (let i = 0; i < text.length; i++) { - if (text.charCodeAt(i) > 127) { - return false; - } - } - return true; -} - export function resolveDirtyLines( change: TextDocumentChange | undefined, startingLine: number, diff --git a/packages/diffs/src/editor/index.ts b/packages/diffs/src/editor/index.ts index 295867a89..33624f476 100644 --- a/packages/diffs/src/editor/index.ts +++ b/packages/diffs/src/editor/index.ts @@ -36,7 +36,6 @@ import { addEventListener, createElement, extend, - isAsciiOnly, isCodeLineTarget, resolveDirtyLines, } from '../editor/editorUtils'; @@ -83,6 +82,7 @@ export class Editor { // cache #stateStackCache?: StateStack[]; #lineYCache = new Map(); + #lastCharX?: [line: number, character: number, x: number]; // dom elements #contentEl?: HTMLElement; @@ -174,6 +174,7 @@ export class Editor { this.#stateStackCache = undefined; this.#lineYCache.clear(); + this.#lastCharX = undefined; this.#contentEl = undefined; this.#styleEl?.remove(); @@ -220,7 +221,6 @@ export class Editor { const shadowRoot = fileContainer.shadowRoot ?? fileContainer.attachShadow({ mode: 'open' }); - this.#lineYCache.clear(); this.#contentEl = shadowRoot.querySelector('[data-content]') ?? undefined; if (this.#contentEl === undefined) { throw new Error('could not edit the file.'); @@ -357,6 +357,9 @@ export class Editor { }), ]; + this.#lineYCache.clear(); + this.#lastCharX = undefined; + if (this.#selections !== undefined) { this.#selectionEls?.forEach((el) => el.remove()); this.#selectionEls?.clear(); @@ -833,20 +836,18 @@ export class Editor { fragment: DocumentFragment, cacheMap: Map ) { - if (!this.#isSelectionVisible(selection)) { + if ( + this.#textDocument === undefined || + !this.#isSelectionVisible(selection) + ) { return; } - const selectionEls = this.#selectionEls; const { start, end } = selection; + const selectionEls = this.#selectionEls; for (let ln = start.line; ln <= end.line; ln++) { - const lineText = this.#textDocument?.getLineText(ln); - if (lineText === undefined) { - // ignore out of bounds line - continue; - } - + const lineText = this.#textDocument.getLineText(ln); const lineLength = lineText.length; const startChar = ln === start.line ? start.character : 0; const endChar = ln === end.line ? end.character : lineLength; @@ -854,47 +855,42 @@ export class Editor { ln === end.line || startChar === endChar ? 0 : this.#charWidth; const cacheKey = `selection-${ln}-${startChar}-${endChar}`; - let rangeEl: HTMLElement | undefined; - if (selectionEls?.has(cacheKey) === true) { - rangeEl = selectionEls.get(cacheKey)!; - selectionEls.delete(cacheKey); - cacheMap.set(cacheKey, rangeEl); - // already in view, skip - continue; - } - let left = 0; let width = 0; + let rangeEl: HTMLElement | undefined; if (startChar === endChar && startChar === 0) { left = this.#charWidth; - width = this.#charWidth; + width = ln === end.line ? 0 : this.#charWidth; } else { - const startX = this.#getCharacterX(ln, startChar); - const endX = - endChar === startChar ? startX : this.#getCharacterX(ln, endChar); - left = startX; - width = endX - startX; + left = this.#getCharX(ln, startChar); + width = endChar === startChar ? 0 : this.#getCharX(ln, endChar) - left; } const css = `width: ${width + spacing}px; transform: translateY(${this.#getLineY(ln)}px) translateX(${left}px);`; - for (const [key, el] of selectionEls?.entries() ?? []) { - if (key.startsWith(`selection-${ln}-`)) { - rangeEl = el; - selectionEls?.delete(key); - el.style.cssText = css; - break; + if (selectionEls?.has(cacheKey) === true) { + rangeEl = selectionEls.get(cacheKey)!; + selectionEls.delete(cacheKey); + rangeEl.style.cssText = css; + } else { + for (const [key, el] of selectionEls?.entries() ?? []) { + if (key.startsWith(`selection-${ln}-`)) { + rangeEl = el; + selectionEls?.delete(key); + el.style.cssText = css; + break; + } } } rangeEl ??= createElement( - 'div', - { - dataset: 'selectionRange', - style: { cssText: css }, - }, - fragment - ); + 'div', + { + dataset: 'selectionRange', + style: { cssText: css }, + }, + fragment + ); cacheMap.set(cacheKey, rangeEl); } @@ -913,16 +909,13 @@ export class Editor { const isBackward = direction === SelectionDirection.Backward; const line = isBackward ? start.line : end.line; const character = isBackward ? start.character : end.character; - const left = Math.max( - this.#charWidth, - this.#getCharacterX(line, character) - ); + const left = Math.max(this.#charWidth, this.#getCharX(line, character)); const caretEl = createElement( 'div', { dataset: 'caret', style: { - transform: `translateY(${this.#getLineY(line)}px) translateX(${left}px)`, + transform: `translateY(${this.#getLineY(line)}px) translateX(${left - 1}px)`, }, }, fragment @@ -1123,29 +1116,49 @@ export class Editor { } // get character left position in line - #getCharacterX(line: number, character: number) { + #getCharX(line: number, character: number) { + if ( + this.#lastCharX !== undefined && + this.#lastCharX[0] === line && + this.#lastCharX[1] === character + ) { + return this.#lastCharX[2]; + } + const lineText = this.#textDocument?.getLineText(line); - if (lineText === undefined || lineText.length === 0) { - return this.#charWidth; // padding-inline: 1ch + const paddingInline = this.#charWidth; // align to diff css: padding-inline: 1ch + if (lineText === undefined || lineText.length === 0 || character <= 0) { + return paddingInline; } - const boundedCharacter = Math.max(0, Math.min(character, lineText.length)); + const boundedCharacter = Math.min(character, lineText.length); const textBeforeCharacter = lineText.slice(0, boundedCharacter); - if ( - isAsciiOnly(textBeforeCharacter) || - this.#file?.options.overflow === 'wrap' - ) { - return ( - this.#charWidth + this.#getExpandedAsciiTextWidth(textBeforeCharacter) - ); + const asciiWidth = this.#getExpandedAsciiTextWidth(textBeforeCharacter); + + let left = 0; + if (asciiWidth !== -1 || this.#file?.options.overflow === 'wrap') { + left = paddingInline + asciiWidth; + } else { + left = paddingInline + this.#measureTextWidth(textBeforeCharacter); } - return this.#charWidth + this.#measureTextWidth(textBeforeCharacter); + if (this.#lastCharX !== undefined) { + this.#lastCharX[0] = line; + this.#lastCharX[1] = character; + this.#lastCharX[2] = left; + } else { + this.#lastCharX = [line, character, left]; + } + + return left; } #getExpandedAsciiTextWidth(text: string) { let columns = 0; for (let i = 0; i < text.length; i++) { + if (text.charCodeAt(i) > 127) { + return -1; + } columns += text.charCodeAt(i) === /* '\t' */ 9 ? this.#tabSize : 1; } return columns * this.#charWidth; @@ -1153,16 +1166,12 @@ export class Editor { #measureTextWidth(text: string) { if (this.#measureCtx === undefined) { - return this.#getExpandedAsciiTextWidth(text); + throw new Error('Measure context not initialized'); } const textWithExpandedTabs = text.replaceAll( '\t', ' '.repeat(this.#tabSize) ); - console.log( - textWithExpandedTabs, - this.#measureCtx.measureText(textWithExpandedTabs).width - ); return this.#measureCtx.measureText(textWithExpandedTabs).width; } From 738e2b71f8888f611c3ecbbad26ba040d83d0d63 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Fri, 1 May 2026 22:21:14 +0800 Subject: [PATCH 048/138] Add maxEntries feature to EditStack for managing undo history size --- packages/diffs/src/editor/editStack.ts | 17 +++++++++++++++++ packages/diffs/src/editor/textDocument.ts | 6 ++++-- packages/diffs/test/editStack.test.ts | 21 +++++++++++++++++++++ 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/packages/diffs/src/editor/editStack.ts b/packages/diffs/src/editor/editStack.ts index 123ef2ef8..d59609939 100644 --- a/packages/diffs/src/editor/editStack.ts +++ b/packages/diffs/src/editor/editStack.ts @@ -1,6 +1,10 @@ import type { EditorSelection } from './editorSelection'; import type { ResolvedTextEdit } from './textDocument'; +/** Largest number of undo or redo entries kept; oldest entries drop first once exceeded. */ +const DEFAULT_EDIT_STACK_MAX_ENTRIES = 100; +const MINIMUM_EDIT_STACK_MAX_ENTRIES = 10; + interface EditSource { getTextSlice(start: number, end: number): string; } @@ -20,9 +24,19 @@ interface EditStackEntry { selectionsAfter?: EditorSelection[]; } +export interface EditStackOptions { + maxEntries?: number; +} + export class EditStack { #undoStack: EditStackEntry[] = []; #redoStack: EditStackEntry[] = []; + #maxEntries: number; + + constructor(options?: EditStackOptions) { + const maxEntries = options?.maxEntries ?? DEFAULT_EDIT_STACK_MAX_ENTRIES; + this.#maxEntries = Math.max(MINIMUM_EDIT_STACK_MAX_ENTRIES, maxEntries); + } get canUndo(): boolean { return this.#undoStack.length > 0; @@ -58,6 +72,9 @@ export class EditStack { selectionsAfter: selectionsAfter?.map((selection) => ({ ...selection })), }); this.#redoStack.length = 0; + if (this.#undoStack.length > this.#maxEntries) { + this.#undoStack.shift(); + } } setLastUndoSelectionsAfter(selections: EditorSelection[]): void { diff --git a/packages/diffs/src/editor/textDocument.ts b/packages/diffs/src/editor/textDocument.ts index 7cfb69d0d..efe4a6624 100644 --- a/packages/diffs/src/editor/textDocument.ts +++ b/packages/diffs/src/editor/textDocument.ts @@ -109,19 +109,21 @@ export class TextDocument { #languageId: string; #version: number; #pieceTable: PieceTable; - #editStack = new EditStack(); + #editStack: EditStack; #lastChange?: TextDocumentChange; constructor( uri: string, text: string, languageId = 'plaintext', - version = 0 + version = 0, + editStack: EditStack = new EditStack() ) { this.#uri = new URL(uri, 'file://').toString(); this.#languageId = languageId; this.#version = version; this.#pieceTable = new PieceTable(text); + this.#editStack = editStack; } get uri(): string { diff --git a/packages/diffs/test/editStack.test.ts b/packages/diffs/test/editStack.test.ts index 4b754f529..c68de2d55 100644 --- a/packages/diffs/test/editStack.test.ts +++ b/packages/diffs/test/editStack.test.ts @@ -131,6 +131,27 @@ describe('EditHistory', () => { }); }); + test('maxEntries drops oldest undo history first', () => { + const editStack = new EditStack({ maxEntries: 3 }); + + for (let i = 0; i < 4; i++) { + editStack.push( + source(''), + [{ start: 0, end: 0, text: `${i}` }], + i, + i + 1, + [caret(0)], + undefined + ); + } + + const third = editStack.popUndoToRedo(); + expect(third?.forwardEdits[0]?.text).toBe('3'); + expect(editStack.popUndoToRedo()?.forwardEdits[0]?.text).toBe('2'); + expect(editStack.popUndoToRedo()?.forwardEdits[0]?.text).toBe('1'); + expect(editStack.popUndoToRedo()).toBeUndefined(); + }); + test('clear resets both undo and redo stacks', () => { const editStack = new EditStack(); From aaedff487f598a45039207a8ddf6068bf22139a3 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Fri, 1 May 2026 22:32:14 +0800 Subject: [PATCH 049/138] Refactor --- packages/diffs/src/editor/editStack.ts | 7 ++++--- packages/diffs/src/editor/textDocument.ts | 6 ++---- packages/diffs/test/textDocument.test.ts | 16 ++++++++-------- 3 files changed, 14 insertions(+), 15 deletions(-) diff --git a/packages/diffs/src/editor/editStack.ts b/packages/diffs/src/editor/editStack.ts index d59609939..1277b25ef 100644 --- a/packages/diffs/src/editor/editStack.ts +++ b/packages/diffs/src/editor/editStack.ts @@ -3,7 +3,6 @@ import type { ResolvedTextEdit } from './textDocument'; /** Largest number of undo or redo entries kept; oldest entries drop first once exceeded. */ const DEFAULT_EDIT_STACK_MAX_ENTRIES = 100; -const MINIMUM_EDIT_STACK_MAX_ENTRIES = 10; interface EditSource { getTextSlice(start: number, end: number): string; @@ -34,8 +33,10 @@ export class EditStack { #maxEntries: number; constructor(options?: EditStackOptions) { - const maxEntries = options?.maxEntries ?? DEFAULT_EDIT_STACK_MAX_ENTRIES; - this.#maxEntries = Math.max(MINIMUM_EDIT_STACK_MAX_ENTRIES, maxEntries); + this.#maxEntries = Math.max( + 1, + options?.maxEntries ?? DEFAULT_EDIT_STACK_MAX_ENTRIES + ); } get canUndo(): boolean { diff --git a/packages/diffs/src/editor/textDocument.ts b/packages/diffs/src/editor/textDocument.ts index efe4a6624..5198ccb98 100644 --- a/packages/diffs/src/editor/textDocument.ts +++ b/packages/diffs/src/editor/textDocument.ts @@ -194,10 +194,9 @@ export class TextDocument { updateHistory = false, selectionsBefore?: EditorSelection[], selectionsAfter?: EditorSelection[] - ): TextDocumentChange | undefined { + ): void { if (edits.length === 0) { - this.#lastChange = undefined; - return undefined; + return; } const resolvedEdits = this.#sortAndValidateResolvedEdits( edits.map((edit) => this.#resolveEdit(edit)) @@ -214,7 +213,6 @@ export class TextDocument { } this.#lastChange = this.#applyResolvedEdits(resolvedEdits); this.#version++; - return this.#lastChange; } setLastUndoSelectionsAfter(selections: EditorSelection[]): void { diff --git a/packages/diffs/test/textDocument.test.ts b/packages/diffs/test/textDocument.test.ts index 970350859..5e4b08aa4 100644 --- a/packages/diffs/test/textDocument.test.ts +++ b/packages/diffs/test/textDocument.test.ts @@ -94,7 +94,7 @@ describe('TextDocument', () => { test('applyEdits single replacement', () => { const d = doc('hello world'); - const change = d.applyEdits([ + d.applyEdits([ { range: { start: { line: 0, character: 6 }, @@ -103,6 +103,7 @@ describe('TextDocument', () => { newText: 'you', }, ]); + const change = d.lastChange; expect(d.getText()).toBe('hello you'); expect(change).toEqual({ startLine: 0, @@ -111,7 +112,6 @@ describe('TextDocument', () => { lineCount: 1, lineDelta: 0, }); - expect(d.lastChange).toBe(change); }); test('applyEdits swaps inverted start/end', () => { @@ -152,7 +152,7 @@ describe('TextDocument', () => { test('applyEdits preserves line breaks around edited line', () => { const d = doc('a\nb\nc'); - const change = d.applyEdits([ + d.applyEdits([ { range: { start: { line: 1, character: 0 }, @@ -163,7 +163,7 @@ describe('TextDocument', () => { ]); expect(d.getText()).toBe('a\nB\nc'); expect(d.lineCount).toBe(3); - expect(change).toEqual({ + expect(d.lastChange).toEqual({ startLine: 1, endLine: 1, previousLineCount: 3, @@ -174,7 +174,7 @@ describe('TextDocument', () => { test('applyEdits reports inserted lines in lastChange', () => { const d = doc('a'); - const change = d.applyEdits([ + d.applyEdits([ { range: { start: { line: 0, character: 1 }, @@ -184,7 +184,7 @@ describe('TextDocument', () => { }, ]); expect(d.getText()).toBe('a\nb'); - expect(change).toEqual({ + expect(d.lastChange).toEqual({ startLine: 0, endLine: 1, previousLineCount: 1, @@ -195,7 +195,7 @@ describe('TextDocument', () => { test('applyEdits reports line deletions in lastChange', () => { const d = doc('a\nb\nc'); - const change = d.applyEdits([ + d.applyEdits([ { range: { start: { line: 0, character: 1 }, @@ -205,7 +205,7 @@ describe('TextDocument', () => { }, ]); expect(d.getText()).toBe('ac'); - expect(change).toEqual({ + expect(d.lastChange).toEqual({ startLine: 0, endLine: 0, previousLineCount: 3, From fe150a0f4f269267b881aacc6cb79239c0641b3a Mon Sep 17 00:00:00 2001 From: Je Xia Date: Sun, 3 May 2026 14:02:03 +0800 Subject: [PATCH 050/138] Enhance PieceTable and TextDocument to trim line endings in getLineText method, improving text handling consistency. Update related tests for accuracy. --- packages/diffs/src/editor/constants.ts | 1 + packages/diffs/src/editor/pieceTable.ts | 30 +++++++++++++++++------ packages/diffs/src/editor/textDocument.ts | 24 +++--------------- packages/diffs/test/pieceTable.test.ts | 29 ++++++++++++++++------ packages/diffs/test/textDocument.test.ts | 16 +++++++++--- 5 files changed, 61 insertions(+), 39 deletions(-) diff --git a/packages/diffs/src/editor/constants.ts b/packages/diffs/src/editor/constants.ts index 401bf4876..1e067d408 100644 --- a/packages/diffs/src/editor/constants.ts +++ b/packages/diffs/src/editor/constants.ts @@ -38,6 +38,7 @@ export const EDITOR_CSS = /* CSS */ ` border: none; outline: none; resize: none; + overflow: hidden; field-sizing: content; } [data-overflow='scroll'] [data-textarea] { diff --git a/packages/diffs/src/editor/pieceTable.ts b/packages/diffs/src/editor/pieceTable.ts index b87704fdf..38cce7aff 100644 --- a/packages/diffs/src/editor/pieceTable.ts +++ b/packages/diffs/src/editor/pieceTable.ts @@ -74,6 +74,7 @@ export class PieceTable { #root: PieceNode | null = null; #length = 0; #lineCount = 0; + #lastVisitedLine: [number, string] | null = null; constructor(originalText: string) { this.#original = new TextBuffer(originalText); @@ -100,14 +101,19 @@ export class PieceTable { } getLineText(line: number): string { + if (this.#lastVisitedLine !== null && this.#lastVisitedLine[0] === line) { + return this.#lastVisitedLine[1]; + } const offset = this.#getLineOffset(line); if (offset === undefined) { throw new Error(`Line index out of range: ${line}`); } - return this.getTextSlice(offset[0], offset[1]); + const text = this.getTextSlice(offset[0], offset[1], true); + this.#lastVisitedLine = [line, text]; + return text; } - getTextSlice(start: number, end: number): string { + getTextSlice(start: number, end: number, trimEOF = false): string { if (start >= end) { return ''; } @@ -129,12 +135,14 @@ export class PieceTable { while (node !== null && remaining > 0) { const takeLength = Math.min(node.piece.length - offsetInPiece, remaining); const buffer = this.#bufferFor(node.piece.source); - chunks.push( - buffer.text.slice( - node.piece.offset + offsetInPiece, - node.piece.offset + offsetInPiece + takeLength - ) - ); + const start = node.piece.offset + offsetInPiece; + let end = start + takeLength; + if (trimEOF) { + while (end > start && isEOL(buffer.text.charCodeAt(end - 1))) { + end--; + } + } + chunks.push(buffer.text.slice(start, end)); remaining -= takeLength; offsetInPiece = 0; node = this.#nextNode(node); @@ -226,6 +234,7 @@ export class PieceTable { } this.#setPieces(nextPieces); + this.#lastVisitedLine = null; } delete(offset: number, length: number): void { @@ -261,6 +270,7 @@ export class PieceTable { } this.#setPieces(nextPieces); + this.#lastVisitedLine = null; } positionAt(offset: number): Position { @@ -611,6 +621,10 @@ export class PieceTable { } } +function isEOL(charCode: number): boolean { + return charCode === /* \n */ 10 || charCode === /* \r */ 13; +} + function clamp(value: number, min: number, max: number): number { return Math.min(Math.max(value, min), max); } diff --git a/packages/diffs/src/editor/textDocument.ts b/packages/diffs/src/editor/textDocument.ts index 5198ccb98..4090de01d 100644 --- a/packages/diffs/src/editor/textDocument.ts +++ b/packages/diffs/src/editor/textDocument.ts @@ -159,21 +159,17 @@ export class TextDocument { } offsetAt(position: Position): number { - // todo: clamp EOL + // todo: clamp return this.#pieceTable.offsetAt(position); } getText(range?: Range): string { - // todo: clamp EOL + // todo: clamp return this.#pieceTable.getText(range); } - getLineText(line: number, stripEndings = true): string { - const text = this.#pieceTable.getLineText(line); - if (stripEndings) { - return stripLineEndings(text); - } - return text; + getLineText(line: number): string { + return this.#pieceTable.getLineText(line); } charAt(offset: number): string; @@ -309,18 +305,6 @@ export class TextDocument { } } -function stripLineEndings(text: string): string { - let end = text.length; - while (end > 0 && isEOL(text.charCodeAt(end - 1))) { - end--; - } - return text.slice(0, end); -} - -function isEOL(charCode: number): boolean { - return charCode === 10 || charCode === 13; -} - function lineFeedCount(text: string): number { let count = 0; for (let i = 0; i < text.length; i++) { diff --git a/packages/diffs/test/pieceTable.test.ts b/packages/diffs/test/pieceTable.test.ts index fe1b8ce13..4719d818f 100644 --- a/packages/diffs/test/pieceTable.test.ts +++ b/packages/diffs/test/pieceTable.test.ts @@ -22,6 +22,19 @@ function lineTexts(text: string): string[] { return lines; } +/** Trailing CR/LF removed, matching `PieceTable.getLineText` / `getTextSlice(..., true)`. */ +function trimLineEndings(text: string): string { + let end = text.length; + while (end > 0 && isLineEnding(text.charCodeAt(end - 1))) { + end--; + } + return text.slice(0, end); +} + +function isLineEnding(c: number): boolean { + return c === 10 || c === 13; +} + function positionAt(text: string, offset: number): Position { const clampedOffset = Math.min(Math.max(offset, 0), text.length); let line = 0; @@ -72,7 +85,7 @@ function expectTableToMatchText(table: PieceTable, text: string): void { expect(table.lineCount).toBe(lines.length); for (let line = 0; line < lines.length; line++) { - expect(table.getLineText(line)).toBe(lines[line]); + expect(table.getLineText(line)).toBe(trimLineEndings(lines[line])); } for (let offset = 0; offset <= text.length; offset++) { @@ -116,11 +129,11 @@ describe('PieceTable', () => { ).toBe('bb'); }); - test('returns raw line text', () => { + test('getLineText omits trailing CR/LF', () => { const table = new PieceTable('first\r\nsecond\n'); - expect(table.getLineText(0)).toBe('first\r\n'); - expect(table.getLineText(1)).toBe('second\n'); + expect(table.getLineText(0)).toBe('first'); + expect(table.getLineText(1)).toBe('second'); expect(table.getLineText(2)).toBe(''); expect(() => table.getLineText(99)).toThrow('Line index out of range: 99'); }); @@ -167,7 +180,7 @@ describe('PieceTable', () => { expect(table.getText()).toBe('one zero\nTWO\nthree'); expect(table.lineCount).toBe(3); - expect(table.getLineText(1)).toBe('TWO\n'); + expect(table.getLineText(1)).toBe('TWO'); }); test('handles CRLF split across piece boundaries', () => { @@ -178,7 +191,7 @@ describe('PieceTable', () => { expect(table.getText()).toBe('a\r\nb'); expect(table.lineCount).toBe(2); - expect(table.getLineText(0)).toBe('a\r\n'); + expect(table.getLineText(0)).toBe('a'); expect(table.positionAt(2)).toEqual({ line: 0, character: 2 }); expect(table.positionAt(3)).toEqual({ line: 1, character: 0 }); }); @@ -187,8 +200,8 @@ describe('PieceTable', () => { const table = new PieceTable('a\r\r\nb\r'); expectTableToMatchText(table, 'a\r\r\nb\r'); - expect(table.getLineText(0)).toBe('a\r\r\n'); - expect(table.getLineText(1)).toBe('b\r'); + expect(table.getLineText(0)).toBe('a'); + expect(table.getLineText(1)).toBe('b'); expect(table.positionAt(2)).toEqual({ line: 0, character: 2 }); expect(table.offsetAt({ line: 1, character: 10 })).toBe(6); }); diff --git a/packages/diffs/test/textDocument.test.ts b/packages/diffs/test/textDocument.test.ts index 5e4b08aa4..d857de934 100644 --- a/packages/diffs/test/textDocument.test.ts +++ b/packages/diffs/test/textDocument.test.ts @@ -46,13 +46,23 @@ describe('TextDocument', () => { expect(() => d.getLineText(99)).toThrow('Line index out of range: 99'); }); - test('getLineText can include line endings', () => { + test('getLineText trims line endings; getText range still includes them', () => { const d = doc('first\r\nsecond\n'); expect(d.getLineText(0)).toBe('first'); - expect(d.getLineText(0, false)).toBe('first\r\n'); expect(d.getLineText(1)).toBe('second'); - expect(d.getLineText(1, false)).toBe('second\n'); expect(d.getLineText(2)).toBe(''); + expect( + d.getText({ + start: { line: 0, character: 0 }, + end: { line: 1, character: 0 }, + }) + ).toBe('first\r\n'); + expect( + d.getText({ + start: { line: 1, character: 0 }, + end: { line: 2, character: 0 }, + }) + ).toBe('second\n'); }); // test('offsetAt clamps to line and document bounds', () => { From 45b71a101c765bded99ccc58d39ab38841a89648 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Sun, 3 May 2026 15:17:15 +0800 Subject: [PATCH 051/138] Refactor --- packages/diffs/src/editor/editorTextarea.ts | 9 ++-- packages/diffs/src/editor/index.ts | 43 +++++++++++-------- .../diffs/test/editorTextareaSnapshot.test.ts | 28 ------------ 3 files changed, 30 insertions(+), 50 deletions(-) diff --git a/packages/diffs/src/editor/editorTextarea.ts b/packages/diffs/src/editor/editorTextarea.ts index 78d63c3f5..f8665cc21 100644 --- a/packages/diffs/src/editor/editorTextarea.ts +++ b/packages/diffs/src/editor/editorTextarea.ts @@ -7,6 +7,7 @@ export interface TextareaSnapshot { selectionStart: number; selectionEnd: number; text: string; + lineCount: number; } export function createTextareaSnapshot( @@ -44,21 +45,21 @@ export function createTextareaSnapshot( selectionStart, selectionEnd, text: lines.join('\n'), + lineCount: lines.length, }; } export function resolveTextareaChange( textareaSnapshot: TextareaSnapshot, newView: string, - selectionStart?: number, - selectionEnd?: number + selectionStart: number, + selectionEnd: number ): ResolvedTextEdit { const original = textareaSnapshot.text; const originalLength = original.length; const nextLength = newView.length; + if ( - selectionStart !== undefined && - selectionEnd !== undefined && selectionStart === selectionEnd && textareaSnapshot.selectionStart === textareaSnapshot.selectionEnd ) { diff --git a/packages/diffs/src/editor/index.ts b/packages/diffs/src/editor/index.ts index 33624f476..f28e0b4f8 100644 --- a/packages/diffs/src/editor/index.ts +++ b/packages/diffs/src/editor/index.ts @@ -393,14 +393,16 @@ export class Editor { return SelectionDirection.None; } - #rerender(textDocument: TextDocument, nextSelections?: EditorSelection[]) { + #rerender() { const file = this.#file; const fileContents = this.#fileContents; + const textDocument = this.#textDocument; const contentEl = this.#contentEl; if ( file === undefined || fileContents === undefined || - contentEl === undefined + contentEl === undefined || + textDocument === undefined ) { return; } @@ -579,10 +581,6 @@ export class Editor { 'linesChange:', linesChange ); - - if (nextSelections !== undefined) { - this.setSelections(nextSelections, false); - } } if (this.#onChange !== undefined) { @@ -732,7 +730,8 @@ export class Editor { this.#selections, change ); - this.#rerender(this.#textDocument, nextSelections); + this.#rerender(); + this.setSelections(nextSelections, false); } } @@ -884,13 +883,13 @@ export class Editor { } rangeEl ??= createElement( - 'div', - { - dataset: 'selectionRange', - style: { cssText: css }, - }, - fragment - ); + 'div', + { + dataset: 'selectionRange', + style: { cssText: css }, + }, + fragment + ); cacheMap.set(cacheKey, rangeEl); } @@ -998,7 +997,8 @@ export class Editor { this.#selections, nextSelections ); - this.#rerender(this.#textDocument, nextSelections); + this.#rerender(); + this.setSelections(nextSelections, false); } } break; @@ -1013,14 +1013,20 @@ export class Editor { case 'undo': if (this.#textDocument?.canUndo === true) { const nextSelections = this.#textDocument.undo(); - this.#rerender(this.#textDocument, nextSelections); + this.#rerender(); + if (nextSelections !== undefined) { + this.setSelections(nextSelections, false); + } } break; case 'redo': if (this.#textDocument?.canRedo === true) { const nextSelections = this.#textDocument.redo(); - this.#rerender(this.#textDocument, nextSelections); + this.#rerender(); + if (nextSelections !== undefined) { + this.setSelections(nextSelections, false); + } } break; } @@ -1092,7 +1098,8 @@ export class Editor { end: textDocument.offsetAt(primarySelection.end), text: text, }); - this.#rerender(textDocument, nextSelections); + this.#rerender(); + this.setSelections(nextSelections, false); } #getLineElement(line: number) { diff --git a/packages/diffs/test/editorTextareaSnapshot.test.ts b/packages/diffs/test/editorTextareaSnapshot.test.ts index 8326a7dcc..7258d2258 100644 --- a/packages/diffs/test/editorTextareaSnapshot.test.ts +++ b/packages/diffs/test/editorTextareaSnapshot.test.ts @@ -25,34 +25,6 @@ function createSelection( } describe('resolveTextChange', () => { - test('replaces selected text with a shorter typed value', () => { - const textDocument = new TextDocument('inmemory://1', 'abc'); - const snippet = createTextareaSnapshot( - textDocument, - createSelection(0, 0, 0, 3, SelectionDirection.Forward) - ); - - expect(resolveTextareaChange(snippet, '1')).toEqual({ - start: 0, - end: 3, - text: '1', - }); - }); - - test('keeps pure deletion as an empty replacement', () => { - const textDocument = new TextDocument('inmemory://1', 'abc'); - const snippet = createTextareaSnapshot( - textDocument, - createSelection(0, 2, 0, 2) - ); - - expect(resolveTextareaChange(snippet, 'ac')).toEqual({ - start: 1, - end: 2, - text: '', - }); - }); - test('uses the caret to resolve Enter before an existing line break', () => { const textDocument = new TextDocument('inmemory://1', 'foo\nbar'); const snippet = createTextareaSnapshot( From a685b750e777b9c75863c415f65250a19677ff49 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Sun, 3 May 2026 17:46:44 +0800 Subject: [PATCH 052/138] Add `BackgroundTokenzier` class --- packages/diffs/src/components/File.ts | 17 +- .../diffs/src/editor/backgroundTokenzier.ts | 127 +++++++ packages/diffs/src/editor/index.ts | 347 ++++++++---------- packages/diffs/src/renderers/FileRenderer.ts | 110 +++--- packages/diffs/src/types.ts | 6 +- .../diffs/src/utils/computeFileOffsets.ts | 15 +- packages/diffs/src/utils/getLineText.ts | 20 - .../src/utils/renderFileWithHighlighter.ts | 20 +- .../diffs/src/worker/WorkerPoolManager.ts | 6 +- packages/diffs/test/fileLineUtils.test.ts | 71 +--- 10 files changed, 382 insertions(+), 357 deletions(-) create mode 100644 packages/diffs/src/editor/backgroundTokenzier.ts delete mode 100644 packages/diffs/src/utils/getLineText.ts diff --git a/packages/diffs/src/components/File.ts b/packages/diffs/src/components/File.ts index e9cb6c72f..df673f5e7 100644 --- a/packages/diffs/src/components/File.ts +++ b/packages/diffs/src/components/File.ts @@ -26,8 +26,8 @@ import type { BaseCodeOptions, EditorHook, FileContents, - FileContentsWithLineOffsets, LineAnnotation, + LineOffsets, PrePropertiesConfig, RenderFileMetadata, RenderRange, @@ -49,9 +49,7 @@ import { setPreNodeProperties } from '../utils/setWrapperNodeProps'; import type { WorkerPoolManager } from '../worker'; import { DiffsContainerLoaded } from './web-components'; -const EMPTY_FILE: FileContentsWithLineOffsets = { - name: '', - contents: '', +const EMPTY_FILE: LineOffsets = { offsets: [], lineCount: 0, }; @@ -380,17 +378,16 @@ export class File { public getOrCreateLineCache( file: FileContents | undefined = this.file - ): FileContentsWithLineOffsets { + ): LineOffsets { return file != null - ? this.fileRenderer.getOrCreateLineCache(file) + ? this.fileRenderer.getOrCreateLineOffsets(file) : EMPTY_FILE; } - public updateRenderCacheAt( - line: number, - tokens: Array<[char: number, style: string, text: string]> + public updateRenderCache( + changes: Parameters[0] ): void { - this.fileRenderer.updateRenderCacheAt(line, tokens); + this.fileRenderer.updateRenderCache(changes); } public render({ diff --git a/packages/diffs/src/editor/backgroundTokenzier.ts b/packages/diffs/src/editor/backgroundTokenzier.ts new file mode 100644 index 000000000..17a38a969 --- /dev/null +++ b/packages/diffs/src/editor/backgroundTokenzier.ts @@ -0,0 +1,127 @@ +import { + EncodedTokenMetadata, + type IGrammar, + type StateStack, +} from 'shiki/textmate'; + +import type { HighlightedToken } from '../types'; +import type { TextDocument } from './textDocument'; + +export interface BackgroundTokenzierOptions { + grammar: IGrammar; + colorMap: { dark: string[]; light: string[] }; + textDocument: TextDocument; + onTokenize: (result: { lines: Map> }) => void; + linesPreTokenize?: number; // default to 100 +} + +/** Stopable background tokenzier */ +export class BackgroundTokenzier { + #grammar: IGrammar; + #colorMap: { dark: string[]; light: string[] }; + #textDocument: TextDocument; + #onTokenize: (result: { + lines: Map>; + }) => void; + #linesPreTokenize: number; + #isFinished: boolean = true; + + constructor({ + grammar, + colorMap, + textDocument, + onTokenize, + linesPreTokenize = 100, + }: BackgroundTokenzierOptions) { + this.#grammar = grammar; + this.#colorMap = colorMap; + this.#textDocument = textDocument; + this.#onTokenize = onTokenize; + this.#linesPreTokenize = linesPreTokenize; + } + + scheduleTokenize(startLine: number, state: StateStack): void { + this.#isFinished = false; + requestAnimationFrame(() => { + this.#doTokenize(startLine, state); + }); + } + + cancelBackgroundTask(): void { + this.#isFinished = true; + } + + #doTokenize(startLine: number, state: StateStack): void { + if (this.#isFinished) { + return; + } + const lines = new Map>(); + const endLine = Math.min( + startLine + this.#linesPreTokenize, + this.#textDocument.lineCount + ); + let line = startLine; + for (; line < endLine; line++) { + const lineText = this.#textDocument.getLineText(line); + const ret = tokenizeLine( + this.#grammar, + this.#colorMap, + lineText, + state, + 50 + ); + lines.set(line, ret.resolvedTokens); + state = ret.ruleStack; + } + this.#onTokenize({ lines }); + if (line >= endLine) { + this.#isFinished = true; + return; + } + // schedule the next tokenize + requestAnimationFrame(() => { + this.#doTokenize(line, state); + }); + } +} + +export function tokenizeLine( + grammar: IGrammar, + colorMap: { dark: string[]; light: string[] }, + lineText: string, + stateStack: StateStack, + timeLimit?: number +): { + ruleStack: StateStack; + resolvedTokens: Array; +} { + const result = grammar.tokenizeLine2(lineText, stateStack, timeLimit); + if (result.stoppedEarly) { + console.warn( + `[diffs] Time limit reached when tokenizing line: ${lineText.substring(0, 100)}` + ); + } + const rawTokens = result.tokens; + const tokensLength = rawTokens.length / 2; + const resolvedTokens: Array = []; + for (let j = 0; j < tokensLength; j++) { + const offset = rawTokens[2 * j]; + const nextOffset = + j + 1 < tokensLength ? rawTokens[2 * j + 2] : lineText.length; + if (offset === nextOffset) { + // should never reach here, skip if happens anyway + continue; + } + const metadata = rawTokens[2 * j + 1]; + const bg = EncodedTokenMetadata.getForeground(metadata); + const darkFG = colorMap.dark[bg]; + const lightFG = colorMap.light[bg]; + const cssText = `--diffs-token-dark:${darkFG};--diffs-token-light:${lightFG}`; + const tokenText = lineText.slice(offset, nextOffset); + resolvedTokens.push([offset, cssText, tokenText]); + } + return { + ruleStack: result.ruleStack, + resolvedTokens, + }; +} diff --git a/packages/diffs/src/editor/index.ts b/packages/diffs/src/editor/index.ts index f28e0b4f8..86cd25b8d 100644 --- a/packages/diffs/src/editor/index.ts +++ b/packages/diffs/src/editor/index.ts @@ -1,9 +1,4 @@ -import { - EncodedTokenMetadata, - type IGrammar, - INITIAL, - type StateStack, -} from 'shiki/textmate'; +import { type IGrammar, INITIAL, type StateStack } from 'shiki/textmate'; import { areThemesAttached, @@ -44,7 +39,14 @@ import { TextDocument, type TextEdit, } from '../editor/textDocument'; -import type { DiffsHighlighter, FileContents, RenderRange } from '../types'; +import type { + DiffsHighlighter, + FileContents, + HighlightedToken, + LineAnnotation, + RenderRange, +} from '../types'; +import { tokenizeLine } from './backgroundTokenzier'; import { EDITOR_CSS, TOKENIZE_MAX_LINE_LENGTH, @@ -59,8 +61,11 @@ import { } from './editorTextarea'; export class Editor { + #onChange?: ( + file: FileContents, + lineAnnotations?: LineAnnotation[] + ) => void; #disposes?: (() => void)[]; - #onChange?: (file: FileContents) => void; // css properties #measureCtx?: CanvasRenderingContext2D; @@ -71,12 +76,12 @@ export class Editor { // file #file?: File; #fileContents?: FileContents; + #lineAnnotations?: LineAnnotation[]; #textDocument?: TextDocument; // highlighter #highlighter?: DiffsHighlighter; #colorMap?: Map; - #grammar?: IGrammar; #renderRange?: RenderRange; // cache @@ -103,7 +108,10 @@ export class Editor { edit( file: File, options?: { - onChange?: (file: FileContents) => void; + onChange?: ( + file: FileContents, + lineAnnotations?: LineAnnotation[] + ) => void; } ): () => void { file.__addEditorHook((fileContainer, fileContents, renderRange) => { @@ -157,9 +165,9 @@ export class Editor { } cleanUp(): void { + this.#onChange = undefined; this.#disposes?.forEach((dispose) => dispose()); this.#disposes = undefined; - this.#onChange = undefined; this.#measureCtx = undefined; @@ -169,7 +177,6 @@ export class Editor { this.#highlighter = undefined; this.#colorMap = undefined; - this.#grammar = undefined; this.#renderRange = undefined; this.#stateStackCache = undefined; @@ -208,7 +215,6 @@ export class Editor { fileContents.contents, fileContents.lang ?? getFiletypeFromFileName(fileContents.name) ); - this.#grammar = undefined; this.#stateStackCache = undefined; this.#selections = undefined; } @@ -394,197 +400,147 @@ export class Editor { } #rerender() { + const contentEl = this.#contentEl; + const highlighter = this.#highlighter; const file = this.#file; const fileContents = this.#fileContents; const textDocument = this.#textDocument; - const contentEl = this.#contentEl; + const lastChange = textDocument?.lastChange; if ( + contentEl === undefined || + highlighter === undefined || file === undefined || fileContents === undefined || - contentEl === undefined || - textDocument === undefined + textDocument === undefined || + lastChange === undefined ) { return; } - if (this.#highlighter !== undefined) { - const t = performance.now(); - - const lastChange = textDocument.lastChange; - const { startingLine = 0, totalLines = Infinity } = - this.#renderRange ?? {}; - const endLine = - totalLines === Infinity - ? textDocument.lineCount - : Math.min(startingLine + totalLines, textDocument.lineCount); - const previousLineCount = - lastChange?.previousLineCount ?? textDocument.lineCount; - const prevEndLine = - totalLines === Infinity - ? previousLineCount - : Math.min(startingLine + totalLines, previousLineCount); - const { dirtyLines, dirtyLineStart, dirtyLineEnd, tokenizerStartLine } = - resolveDirtyLines(lastChange, startingLine, endLine); - const linesChange = lastChange?.lineDelta ?? 0; - - for (let line = endLine; line < prevEndLine; line++) { - this.#lineYCache.delete(line); - this.#getLineElement(line)?.remove(); - } + const t = performance.now(); + const grammar = highlighter.getLanguage(textDocument.languageId); + const colorMap = { + dark: this.#getThemeColorMap('dark'), + light: this.#getThemeColorMap('light'), + }; - const grammar = (this.#grammar ??= this.#highlighter.getLanguage( - textDocument.languageId - )); - const previousStateStackCache = this.#stateStackCache; - if (dirtyLineStart !== -1) { - this.#stateStackCache = previousStateStackCache?.slice( - 0, - tokenizerStartLine + 1 - ); - } + const { startingLine = 0, totalLines = Infinity } = this.#renderRange ?? {}; + const endLine = + totalLines === Infinity + ? textDocument.lineCount + : Math.min(startingLine + totalLines, textDocument.lineCount); + const previousLineCount = + lastChange?.previousLineCount ?? textDocument.lineCount; + const prevEndLine = + totalLines === Infinity + ? previousLineCount + : Math.min(startingLine + totalLines, previousLineCount); + const { dirtyLines, dirtyLineStart, dirtyLineEnd, tokenizerStartLine } = + resolveDirtyLines(lastChange, startingLine, endLine); + const linesChange = lastChange?.lineDelta ?? 0; + + for (let line = endLine; line < prevEndLine; line++) { + this.#lineYCache.delete(line); + this.#getLineElement(line)?.remove(); + } - const updateLineEl = (line: number, children: Element[]) => { - const lineEl = createElement('div', { - dataset: { - line: String(line + 1), - lineIndex: String(line), - lineType: 'context', - }, - }); - lineEl.replaceChildren(...children); - const prevLineEl = contentEl.querySelector( - `[data-line-index="${line}"]` - ); - if (prevLineEl !== null) { - prevLineEl.replaceWith(lineEl); - } else { - contentEl.insertBefore(lineEl, this.#textareaEl ?? null); - } - }; - - const colorMap = { - dark: this.#getThemeColorMap('dark'), - light: this.#getThemeColorMap('light'), - }; - - let state = - dirtyLineStart === -1 - ? INITIAL - : this.#buildStateStackCache(textDocument, grammar, dirtyLineStart); - for (let line = dirtyLineStart; line >= 0 && line < endLine; line++) { - const isDirty = dirtyLines.has(line); - const previousState = previousStateStackCache?.[line]; - const didLineStateChange = - previousState !== undefined && !state.equals(previousState); - const shouldUpdateLineEl = - isDirty || - didLineStateChange || - (line > dirtyLineEnd && previousState === undefined); - const lineText = textDocument.getLineText(line); - this.#stateStackCache ??= [INITIAL]; - this.#stateStackCache[line] = state; - - if (lineText.length > TOKENIZE_MAX_LINE_LENGTH) { - if (shouldUpdateLineEl) { - console.warn( - `[diffs] Line(${line}) too long to tokenize: ${lineText.length}` - ); - updateLineEl(line, [ - createElement('span', { textContent: lineText }), - ]); - } - this.#stateStackCache[line + 1] = state; - if ( - line >= dirtyLineEnd && - this.#isStateStackCacheSettled(previousStateStackCache, line, state) - ) { - break; - } - continue; - } + const previousStateStackCache = this.#stateStackCache; + if (dirtyLineStart !== -1) { + this.#stateStackCache = previousStateStackCache?.slice( + 0, + tokenizerStartLine + 1 + ); + } - if (lineText === '' || lineText.trim() === '') { - if (shouldUpdateLineEl) { - updateLineEl(line, [ - createElement('span', { - textContent: lineText === '' ? ' ' : lineText, - }), - ]); - } - this.#stateStackCache[line + 1] = state; - if ( - line >= dirtyLineEnd && - this.#isStateStackCacheSettled(previousStateStackCache, line, state) - ) { - break; - } - continue; - } + const changes: Map> = new Map(); - // even the line is NOT dirty, we still need to tokenize it to get the new state - const result = grammar.tokenizeLine2( - lineText, - state, - TOKENIZE_TIME_LIMIT - ); - if (result.stoppedEarly) { + const isStateStackCacheSettled = (line: number, state: StateStack) => { + const previousNextState = previousStateStackCache?.[line + 1]; + return previousNextState !== undefined && state.equals(previousNextState); + }; + + let state = + dirtyLineStart === -1 + ? INITIAL + : this.#buildStateStackCache(textDocument, grammar, dirtyLineStart); + for (let line = dirtyLineStart; line >= 0 && line < endLine; line++) { + const isDirty = dirtyLines.has(line); + const previousState = previousStateStackCache?.[line]; + const didLineStateChange = + previousState !== undefined && !state.equals(previousState); + const shouldUpdateLineEl = + isDirty || + didLineStateChange || + (line > dirtyLineEnd && previousState === undefined); + const lineText = textDocument.getLineText(line); + this.#stateStackCache ??= [INITIAL]; + this.#stateStackCache[line] = state; + + if (lineText.length > TOKENIZE_MAX_LINE_LENGTH) { + if (shouldUpdateLineEl) { console.warn( - `[diffs] Time limit reached when tokenizing line: ${lineText.substring(0, 100)}` + `[diffs] Line(${line}) too long to tokenize: ${lineText.length}` ); + changes.set(line, [[0, '', lineText]]); + } + this.#stateStackCache[line + 1] = state; + if (line >= dirtyLineEnd && isStateStackCacheSettled(line, state)) { + break; } + continue; + } + + if (lineText === '' || lineText.trim() === '') { if (shouldUpdateLineEl) { - const rawTokens = result.tokens; - const lineLength = lineText.length; - const tokensLength = rawTokens.length / 2; - const tokens: [char: number, style: string, text: string][] = []; - const spans: Element[] = []; - for (let j = 0; j < tokensLength; j++) { - const offset = rawTokens[2 * j]; - const nextOffset = - j + 1 < tokensLength ? rawTokens[2 * j + 2] : lineLength; - if (offset === nextOffset) { - // should never reach here, skip if happens anyway - continue; - } - const metadata = rawTokens[2 * j + 1]; - const bg = EncodedTokenMetadata.getForeground(metadata); - const darkFG = colorMap.dark[bg]; - const lightFG = colorMap.light[bg]; - const cssText = `--diffs-token-dark:${darkFG};--diffs-token-light:${lightFG}`; - const tokenText = lineText.slice(offset, nextOffset); - tokens.push([offset, cssText, tokenText]); - spans.push( - createElement('span', { - dataset: { char: String(offset) }, - style: { cssText }, - textContent: tokenText, - }) - ); - } - updateLineEl(line, spans); - this.#file?.updateRenderCacheAt(line, tokens); + changes.set(line, [[0, '', lineText === '' ? ' ' : lineText]]); } - state = result.ruleStack; this.#stateStackCache[line + 1] = state; - if ( - line >= dirtyLineEnd && - this.#isStateStackCacheSettled(previousStateStackCache, line, state) - ) { + if (line >= dirtyLineEnd && isStateStackCacheSettled(line, state)) { break; } + continue; } - console.log( - `[diffs] re-render time: ${Math.round((performance.now() - t) * 1000) / 1000}ms`, - 'dirtyLines:', - dirtyLines.size, - 'linesChange:', - linesChange + const result = tokenizeLine( + grammar, + colorMap, + lineText, + state, + TOKENIZE_TIME_LIMIT ); + if (shouldUpdateLineEl) { + changes.set(line, result.resolvedTokens); + } + state = result.ruleStack; + this.#stateStackCache[line + 1] = state; + if (line >= dirtyLineEnd && isStateStackCacheSettled(line, state)) { + break; + } } + console.log('changes', changes); + + this.#file?.updateRenderCache({ + dirtyLines: changes, + lineCount: lastChange?.lineCount, + }); + + console.log( + `[diffs] re-render time: ${Math.round((performance.now() - t) * 1000) / 1000}ms`, + 'dirtyLines:', + dirtyLines.size, + 'linesChange:', + linesChange + ); + if (this.#onChange !== undefined) { - this.#onChange({ ...fileContents, contents: textDocument.getText() }); + const { contents: _, ...file } = fileContents; + Object.defineProperty(file, 'contents', { + get() { + return textDocument.getText(); + }, + }); + this.#onChange(file as FileContents, this.#lineAnnotations); } } @@ -600,13 +556,13 @@ export class Editor { themeName = theme[themeType]; } this.#colorMap ??= new Map(); - let colorMap = this.#colorMap.get(themeName); - if (colorMap === undefined) { + let colors = this.#colorMap.get(themeName); + if (colors === undefined) { const ret = this.#highlighter.setTheme(themeName); - colorMap = ret.colorMap; + colors = ret.colorMap; this.#colorMap.set(themeName, ret.colorMap ?? []); } - return colorMap; + return colors; } #buildStateStackCache( @@ -640,15 +596,6 @@ export class Editor { return stateStackCache[boundedEndLine] ?? INITIAL; } - #isStateStackCacheSettled( - previousStateStackCache: StateStack[] | undefined, - line: number, - state: StateStack - ) { - const previousNextState = previousStateStackCache?.[line + 1]; - return previousNextState !== undefined && state.equals(previousNextState); - } - #prebuildStateStackCache() { const textDocument = this.#textDocument; if (textDocument === undefined) { @@ -1102,12 +1049,22 @@ export class Editor { this.setSelections(nextSelections, false); } - #getLineElement(line: number) { - return ( - this.#contentEl?.querySelector( - `[data-line-index="${line}"]` - ) ?? undefined - ); + #getLineElement(line: number): HTMLElement | undefined { + const children = this.#contentEl?.children; + if (children === undefined) { + return undefined; + } + const { startingLine = 0 } = this.#renderRange ?? {}; + for (let i = line - startingLine; i <= children.length; i++) { + const child = children[i] as HTMLElement; + if ( + child.dataset.lineIndex !== undefined && + Number(child.dataset.lineIndex) === line + ) { + return child; + } + } + return undefined; } // get line top position diff --git a/packages/diffs/src/renderers/FileRenderer.ts b/packages/diffs/src/renderers/FileRenderer.ts index eba1821a9..ba3a855de 100644 --- a/packages/diffs/src/renderers/FileRenderer.ts +++ b/packages/diffs/src/renderers/FileRenderer.ts @@ -13,9 +13,10 @@ import type { BaseCodeOptions, DiffsHighlighter, FileContents, - FileContentsWithLineOffsets, FileHeaderRenderMode, + HighlightedToken, LineAnnotation, + LineOffsets, RenderedFileASTCache, RenderFileOptions, RenderFileResult, @@ -82,7 +83,8 @@ export class FileRenderer { private renderCache: RenderedFileASTCache | undefined; private computedLang: SupportedLanguages = 'text'; private lineAnnotations: AnnotationLineMap = {}; - private lineCache: FileContentsWithLineOffsets | undefined; + private lineOffsets: LineOffsets | undefined; + private lineOffsetsCacheKey: string | undefined; constructor( public options: FileRendererOptions = { theme: DEFAULT_THEMES }, @@ -120,7 +122,7 @@ export class FileRenderer { this.highlighter = undefined; this.workerManager = undefined; this.onRenderUpdate = undefined; - this.lineCache = undefined; + this.lineOffsets = undefined; } public hydrate(file: FileContents): void { @@ -176,53 +178,71 @@ export class FileRenderer { return { options, forceRender: false }; } - public getOrCreateLineCache(file: FileContents): FileContentsWithLineOffsets { + public getOrCreateLineOffsets(file: FileContents): LineOffsets { // Uncached files will get split every time, not the greatest experience // tbh... but something people should try to optimize away if (file.cacheKey == null) { - this.lineCache = undefined; - return computeLineOffsets(file); + this.lineOffsets = undefined; + return computeLineOffsets(file.contents); } - let { lineCache } = this; - if (lineCache == null || lineCache.cacheKey !== file.cacheKey) { - lineCache = computeLineOffsets(file); + let { lineOffsets } = this; + if (lineOffsets == null || this.lineOffsetsCacheKey !== file.cacheKey) { + lineOffsets = computeLineOffsets(file.contents); } - this.lineCache = lineCache; - return lineCache; + this.lineOffsets = lineOffsets; + this.lineOffsetsCacheKey = file.cacheKey; + return lineOffsets; } - public updateRenderCacheAt( - line: number, - tokens: Array<[char: number, style: string, text: string]> - ): void { - if (this.renderCache != null && this.renderCache.result != null) { - this.renderCache.result.code[line] = { - type: 'element', - tagName: 'div', - properties: { - 'data-line': line + 1, - 'data-line-index': line, - 'data-line-type': 'context', - }, - children: tokens.map(([char, style, text]) => { - return { - type: 'element', - tagName: 'span', - properties: { - 'data-char': char, - style, - }, - children: [ - { - type: 'text', - value: text, + public updateRenderCache({ + dirtyLines, + lineCount, + }: { + dirtyLines: Map>; + lineCount: number; + }): FileRenderResult { + const renderCache = this.renderCache; + if (renderCache == null || renderCache.result == null) { + throw new Error('Render cache is not set'); + } + if (renderCache != null && renderCache.result != null) { + const code = renderCache.result.code; + for (const [line, tokens] of dirtyLines) { + code[line] = { + type: 'element', + tagName: 'div', + properties: { + 'data-line': line + 1, + 'data-line-index': line, + 'data-line-type': 'context', + }, + children: tokens.map(([char, style, text]) => { + return { + type: 'element', + tagName: 'span', + properties: { + 'data-char': char, + style, }, - ], - }; - }), - }; + children: [ + { + type: 'text', + value: text, + }, + ], + }; + }), + }; + } + code.length = lineCount; } + + return this.processFileResult( + renderCache.file, + renderCache.renderRange ?? DEFAULT_RENDER_RANGE, + renderCache.result + ); } public renderFile( @@ -262,7 +282,7 @@ export class FileRenderer { file, renderRange.startingLine, renderRange.totalLines, - this.getOrCreateLineCache(file) + this.getOrCreateLineOffsets(file) ); this.renderCache.renderRange = renderRange; } @@ -374,11 +394,11 @@ export class FileRenderer { const { disableFileHeader = false } = this.options; const contentArray: ElementContent[] = []; const gutter = createGutterWrapper(); - const lines = this.getOrCreateLineCache(file); - const totalLines = lines.lineCount; + const lineOffsets = this.getOrCreateLineOffsets(file); + const totalLines = lineOffsets.lineCount; const endLine = Math.min( renderRange.startingLine + renderRange.totalLines, - lines.lineCount + lineOffsets.lineCount ); let rowCount = 0; @@ -397,7 +417,7 @@ export class FileRenderer { name: file.name, lineIndex, lineNumber, - lines, + lineOffsets: lineOffsets, }); throw new Error(message); } diff --git a/packages/diffs/src/types.ts b/packages/diffs/src/types.ts index ae39c3d87..404421239 100644 --- a/packages/diffs/src/types.ts +++ b/packages/diffs/src/types.ts @@ -38,7 +38,7 @@ export interface FileContents { /** * Represents a file's contents with line offsets. */ -export interface FileContentsWithLineOffsets extends FileContents { +export interface LineOffsets { /** The line offsets for the file contents. */ readonly offsets: number[]; /** The number of lines in the file. */ @@ -47,6 +47,8 @@ export interface FileContentsWithLineOffsets extends FileContents { export type HighlighterTypes = 'shiki-js' | 'shiki-wasm'; +export type HighlightedToken = [char: number, style: string, text: string]; + export type { BundledLanguage, CodeToHastOptions, @@ -633,7 +635,7 @@ export interface ForceFilePlainTextOptions { startingLine?: number; totalLines?: number; // Pre-split lines for caching in windowing scenarios - lines?: FileContentsWithLineOffsets; + lineOffsets?: LineOffsets; } export interface RenderFileOptions { diff --git a/packages/diffs/src/utils/computeFileOffsets.ts b/packages/diffs/src/utils/computeFileOffsets.ts index b108293b7..ff96ac48b 100644 --- a/packages/diffs/src/utils/computeFileOffsets.ts +++ b/packages/diffs/src/utils/computeFileOffsets.ts @@ -1,4 +1,4 @@ -import type { FileContents, FileContentsWithLineOffsets } from '../types'; +import type { LineOffsets } from '../types'; const LINE_FEED = 10; // \n const CARRIAGE_RETURN = 13; // \r @@ -8,11 +8,8 @@ const CARRIAGE_RETURN = 13; // \r * `lineCount` excludes the final newline-only parser row, except for files * that contain only that row. */ -export function computeLineOffsets( - file: FileContents -): FileContentsWithLineOffsets { - const { contents } = file; - const offsets = []; +export function computeLineOffsets(contents: string): LineOffsets { + const offsets: number[] = []; if (contents.length > 0) { offsets.push(0); } @@ -43,11 +40,7 @@ export function computeLineOffsets( ? rawLineCount - 1 : rawLineCount; - return { - ...file, - offsets, - lineCount, - }; + return { offsets, lineCount }; } // Detects the synthetic final row produced by terminal newline characters. diff --git a/packages/diffs/src/utils/getLineText.ts b/packages/diffs/src/utils/getLineText.ts deleted file mode 100644 index 1352c9a5d..000000000 --- a/packages/diffs/src/utils/getLineText.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { FileContentsWithLineOffsets } from '../types'; - -/** - * Gets the text of a line in a file. - * @param file - The file to get the text of. - * @param lineIndex - The index of the line to get the text of. - * @returns The text of the line. - */ -export function getLineText( - file: FileContentsWithLineOffsets, - lineIndex: number -): string { - if (lineIndex < 0 || lineIndex >= file.lineCount) { - throw new Error(`Line index out of range: ${lineIndex}`); - } - return file.contents.slice( - file.offsets[lineIndex], - file.offsets[lineIndex + 1] ?? file.contents.length - ); -} diff --git a/packages/diffs/src/utils/renderFileWithHighlighter.ts b/packages/diffs/src/utils/renderFileWithHighlighter.ts index 7c11dc2ef..8856dac8a 100644 --- a/packages/diffs/src/utils/renderFileWithHighlighter.ts +++ b/packages/diffs/src/utils/renderFileWithHighlighter.ts @@ -4,8 +4,8 @@ import type { DiffsHighlighter, DiffsThemeNames, FileContents, - FileContentsWithLineOffsets, ForceFilePlainTextOptions, + LineOffsets, RenderFileOptions, ThemedFileResult, } from '../types'; @@ -33,7 +33,7 @@ export function renderFileWithHighlighter( forcePlainText, startingLine, totalLines, - lines, + lineOffsets, }: ForceFilePlainTextOptions = DEFAULT_PLAIN_TEXT_OPTIONS ): ThemedFileResult { if (forcePlainText) { @@ -90,7 +90,8 @@ export function renderFileWithHighlighter( highlighter.codeToHast( isWindowedHighlight ? extractWindowedFileContent( - lines ?? computeLineOffsets(file), + file, + lineOffsets ?? computeLineOffsets(file.contents), startingLine, totalLines ) @@ -109,15 +110,16 @@ export function renderFileWithHighlighter( } function extractWindowedFileContent( - lines: FileContentsWithLineOffsets, + file: FileContents, + lineOffsets: LineOffsets, startingLine: number, totalLines: number ): string { - if (lines.lineCount === 0) { + if (lineOffsets.lineCount === 0) { return ''; } - const endLine = Math.min(startingLine + totalLines, lines.lineCount); - const startOffset = lines.offsets[startingLine] ?? lines.contents.length; - const endOffset = lines.offsets[endLine] ?? lines.contents.length; - return lines.contents.slice(startOffset, endOffset); + const endLine = Math.min(startingLine + totalLines, lineOffsets.lineCount); + const startOffset = lineOffsets.offsets[startingLine] ?? file.contents.length; + const endOffset = lineOffsets.offsets[endLine] ?? file.contents.length; + return file.contents.slice(startOffset, endOffset); } diff --git a/packages/diffs/src/worker/WorkerPoolManager.ts b/packages/diffs/src/worker/WorkerPoolManager.ts index 1367eede6..f941fe48d 100644 --- a/packages/diffs/src/worker/WorkerPoolManager.ts +++ b/packages/diffs/src/worker/WorkerPoolManager.ts @@ -12,10 +12,10 @@ import { resolveThemes } from '../highlighter/themes/resolveThemes'; import type { DiffsHighlighter, FileContents, - FileContentsWithLineOffsets, FileDiffMetadata, HighlighterTypes, HunkExpansionRegion, + LineOffsets, RenderDiffOptions, RenderDiffResult, RenderFileOptions, @@ -547,7 +547,7 @@ export class WorkerPoolManager { file: FileContents, startingLine: number, totalLines: number, - lines?: FileContentsWithLineOffsets + lineOffsets: LineOffsets ): ThemedFileResult | undefined { if (this.highlighter == null) { this.queueInitialization(); @@ -557,7 +557,7 @@ export class WorkerPoolManager { file, this.highlighter, this.renderOptions, - { forcePlainText: true, startingLine, totalLines, lines } + { forcePlainText: true, startingLine, totalLines, lineOffsets } ); } diff --git a/packages/diffs/test/fileLineUtils.test.ts b/packages/diffs/test/fileLineUtils.test.ts index b37aa3e1f..063a56ef1 100644 --- a/packages/diffs/test/fileLineUtils.test.ts +++ b/packages/diffs/test/fileLineUtils.test.ts @@ -1,38 +1,25 @@ import { describe, expect, test } from 'bun:test'; import { computeLineOffsets } from '../src/utils/computeFileOffsets'; -import { getLineText } from '../src/utils/getLineText'; describe('computeLineOffsets', () => { test('returns no offsets for empty contents', () => { - const result = computeLineOffsets({ - name: 'empty.ts', - contents: '', - }); + const result = computeLineOffsets(''); expect(result.offsets).toEqual([]); expect(result.lineCount).toBe(0); }); test('computes offsets for single line without trailing newline', () => { - const result = computeLineOffsets({ - name: 'single.ts', - contents: 'hello', - }); + const result = computeLineOffsets('hello'); expect(result.offsets).toEqual([0, 5]); expect(result.lineCount).toBe(1); }); test('computes offsets for LF files with and without terminal newline', () => { - const withTerminalNewline = computeLineOffsets({ - name: 'lf-terminal.ts', - contents: 'a\nb\n', - }); - const withoutTerminalNewline = computeLineOffsets({ - name: 'lf-no-terminal.ts', - contents: 'a\nb', - }); + const withTerminalNewline = computeLineOffsets('a\nb\n'); + const withoutTerminalNewline = computeLineOffsets('a\nb'); expect(withTerminalNewline.offsets).toEqual([0, 2, 4]); expect(withTerminalNewline.lineCount).toBe(2); @@ -41,14 +28,8 @@ describe('computeLineOffsets', () => { }); test('computes offsets for CRLF and lone CR line endings', () => { - const crlf = computeLineOffsets({ - name: 'crlf.ts', - contents: 'a\r\nb\r\n', - }); - const mixed = computeLineOffsets({ - name: 'mixed.ts', - contents: 'a\rb\r\nc\n', - }); + const crlf = computeLineOffsets('a\r\nb\r\n'); + const mixed = computeLineOffsets('a\rb\r\nc\n'); expect(crlf.offsets).toEqual([0, 3, 6]); expect(crlf.lineCount).toBe(2); @@ -57,56 +38,22 @@ describe('computeLineOffsets', () => { }); }); -describe('getLineText', () => { - test('returns line text using computed offsets', () => { - const lines = computeLineOffsets({ - name: 'lines.ts', - contents: 'first\nsecond\nthird', - }); - - expect(getLineText(lines, 0)).toBe('first\n'); - expect(getLineText(lines, 1)).toBe('second\n'); - expect(getLineText(lines, 2)).toBe('third'); - }); - - test('throws when line index is outside valid range', () => { - const lines = computeLineOffsets({ - name: 'bounds.ts', - contents: 'line', - }); - - expect(() => getLineText(lines, -1)).toThrow('Line index out of range: -1'); - expect(() => getLineText(lines, lines.lineCount)).toThrow( - `Line index out of range: ${lines.lineCount}` - ); - }); -}); - describe('renderable line count', () => { test('keeps regular final lines', () => { - const lines = computeLineOffsets({ - name: 'lines.ts', - contents: 'first\nsecond', - }); + const lines = computeLineOffsets('first\nsecond'); expect(lines.lineCount).toBe(2); }); test('excludes one final newline-only row from multi-line files', () => { - const lines = computeLineOffsets({ - name: 'lines.ts', - contents: 'first\nsecond\n\n', - }); + const lines = computeLineOffsets('first\nsecond\n\n'); expect(lines.offsets).toEqual([0, 6, 13, 14]); expect(lines.lineCount).toBe(2); }); test('keeps a newline-only row when it is the whole file', () => { - const lines = computeLineOffsets({ - name: 'blank.ts', - contents: '\n', - }); + const lines = computeLineOffsets('\n'); expect(lines.lineCount).toBe(1); }); From acbde05a5508f14f3fc464b54e088be6dec90845 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Mon, 4 May 2026 20:13:09 +0800 Subject: [PATCH 053/138] Improve performance --- apps/demo/src/main.ts | 2 +- packages/diffs/src/components/File.ts | 85 ++++- .../diffs/src/components/VirtualizedFile.ts | 24 +- packages/diffs/src/editor/constants.ts | 15 +- packages/diffs/src/editor/editorUtils.ts | 117 ++----- packages/diffs/src/editor/index.ts | 320 +++++++++++------- .../{backgroundTokenzier.ts => tokenzier.ts} | 40 ++- .../diffs/src/managers/InteractionManager.ts | 10 +- packages/diffs/src/renderers/FileRenderer.ts | 151 +++++---- packages/diffs/src/types.ts | 12 +- .../diffs/src/utils/computeFileOffsets.ts | 37 +- .../src/utils/renderFileWithHighlighter.ts | 14 +- .../diffs/src/worker/WorkerPoolManager.ts | 3 +- packages/diffs/test/FileRenderer.ast.test.ts | 12 +- .../__snapshots__/FileRenderer.test.ts.snap | 47 ++- packages/diffs/test/fileLineUtils.test.ts | 40 +-- packages/diffs/test/mocks.ts | 6 +- 17 files changed, 557 insertions(+), 378 deletions(-) rename packages/diffs/src/editor/{backgroundTokenzier.ts => tokenzier.ts} (77%) diff --git a/apps/demo/src/main.ts b/apps/demo/src/main.ts index 0867bac1e..cbcec86b2 100644 --- a/apps/demo/src/main.ts +++ b/apps/demo/src/main.ts @@ -835,7 +835,7 @@ if (renderEditorButton != null) { }, // Line selection stuff - // enableLineSelection: true, + enableLineSelection: true, // onLineClick(props) { // console.log('onLineClick', props); // }, diff --git a/packages/diffs/src/components/File.ts b/packages/diffs/src/components/File.ts index df673f5e7..14408a6ca 100644 --- a/packages/diffs/src/components/File.ts +++ b/packages/diffs/src/components/File.ts @@ -26,8 +26,8 @@ import type { BaseCodeOptions, EditorHook, FileContents, + HighlightedToken, LineAnnotation, - LineOffsets, PrePropertiesConfig, RenderFileMetadata, RenderRange, @@ -49,11 +49,6 @@ import { setPreNodeProperties } from '../utils/setWrapperNodeProps'; import type { WorkerPoolManager } from '../worker'; import { DiffsContainerLoaded } from './web-components'; -const EMPTY_FILE: LineOffsets = { - offsets: [], - lineCount: 0, -}; - export interface FileRenderProps { file: FileContents; fileContainer?: HTMLElement; @@ -186,6 +181,10 @@ export class File { this.__editorHook = hook; } + public __isEditorAttached(): boolean { + return this.__editorHook != null; + } + private handleHighlightRender = (): void => { this.rerender(); }; @@ -376,18 +375,73 @@ export class File { this.resizeManager.setup(this.pre, overflow === 'wrap'); } - public getOrCreateLineCache( + public getOrCreateLineOffSets( file: FileContents | undefined = this.file - ): LineOffsets { - return file != null - ? this.fileRenderer.getOrCreateLineOffsets(file) - : EMPTY_FILE; + ): number[] { + return file != null ? this.fileRenderer.getOrCreateLineOffsets(file) : []; + } + + public getLineCount(file: FileContents | undefined = this.file): number { + return file != null ? this.fileRenderer.getLineCount(file) : 0; } - public updateRenderCache( - changes: Parameters[0] + public emitLineAnnotationsChange( + lineAnnotations: LineAnnotation[] ): void { - this.fileRenderer.updateRenderCache(changes); + const renderRange = this.renderRange; + const result = this.fileRenderer.emitLineAnnotationsChange( + lineAnnotations, + this.renderRange + ); + // check if the new lineAnnotations are in the renderRange, + // if it is, skip the re-render + let isVisible = false; + if (renderRange != null) { + const { startingLine, totalLines } = renderRange; + const endLine = + totalLines === Infinity + ? this.getLineCount() + : startingLine + totalLines; + isVisible = lineAnnotations.some( + (annotation) => + annotation.lineNumber >= startingLine && + annotation.lineNumber < endLine + ); + } + if (result != null && this.code != null && isVisible) { + const { gutterAST, contentAST, rowCount } = result; + const columns = this.getColumns(this.code); + if (columns != null) { + columns.content.innerHTML = + this.fileRenderer.renderPartialHTML(contentAST); + columns.gutter.innerHTML = + this.fileRenderer.renderPartialHTML(gutterAST); + columns.content.style.gridRow = `span ${rowCount}`; + columns.gutter.style.gridRow = `span ${rowCount}`; + this.renderAnnotations(); + } + } + } + + public emitLineCountChange(lineCount: number): void { + const result = this.fileRenderer.emitLineCountChange( + lineCount, + this.renderRange + ); + if (result != null && this.code != null) { + const { gutterAST, rowCount } = result; + const columns = this.getColumns(this.code); + if (columns != null) { + columns.gutter.innerHTML = + this.fileRenderer.renderPartialHTML(gutterAST); + columns.content.style.gridRow = `span ${rowCount}`; + columns.gutter.style.gridRow = `span ${rowCount}`; + } + } + } + + public emitTokenize(lines: Map>): void { + this.fileRenderer.emitTokenize(lines); } public render({ @@ -483,7 +537,6 @@ export class File { if (!preventEmit) { this.emitPostRender(); } - this.__editorHook?.(fileContainer, file, renderRange); return true; } @@ -522,6 +575,7 @@ export class File { this.resizeManager.setup(pre, overflow === 'wrap'); this.renderAnnotations(); this.renderGutterUtility(); + this.__editorHook?.(fileContainer, file, nextRenderRange); } catch (error: unknown) { if (disableErrorHandling) { throw error; @@ -534,7 +588,6 @@ export class File { if (!preventEmit) { this.emitPostRender(); } - this.__editorHook?.(fileContainer, file, renderRange); return true; } diff --git a/packages/diffs/src/components/VirtualizedFile.ts b/packages/diffs/src/components/VirtualizedFile.ts index 6fe855afd..253bffc80 100644 --- a/packages/diffs/src/components/VirtualizedFile.ts +++ b/packages/diffs/src/components/VirtualizedFile.ts @@ -1,6 +1,7 @@ import { DEFAULT_VIRTUAL_FILE_METRICS } from '../constants'; import type { FileContents, + LineAnnotation, RenderRange, RenderWindow, VirtualFileMetrics, @@ -172,7 +173,7 @@ export class VirtualizedFile< overflow = 'scroll', } = this.options; const { diffHeaderHeight, fileGap, lineHeight } = this.metrics; - const lines = this.getOrCreateLineCache(this.file); + const lineCount = this.getLineCount(this.file); // Header or initial padding if (!disableFileHeader) { @@ -185,15 +186,15 @@ export class VirtualizedFile< } if (overflow === 'scroll' && this.lineAnnotations.length === 0) { - this.height += lines.lineCount * lineHeight; + this.height += lineCount * lineHeight; } else { - for (let lineIndex = 0; lineIndex < lines.lineCount; lineIndex++) { + for (let lineIndex = 0; lineIndex < lineCount; lineIndex++) { this.height += this.getLineHeight(lineIndex, false); } } // Bottom padding - if (lines.lineCount > 0) { + if (lineCount > 0) { this.height += fileGap; } @@ -235,6 +236,18 @@ export class VirtualizedFile< } } + override emitLineAnnotationsChange( + lineAnnotations: LineAnnotation[] + ): void { + super.emitLineAnnotationsChange(lineAnnotations); + this.computeApproximateSize(); + } + + override emitLineCountChange(lineCount: number): void { + super.emitLineCountChange(lineCount); + this.computeApproximateSize(); + } + override render({ fileContainer, file, @@ -291,8 +304,7 @@ export class VirtualizedFile< const { disableFileHeader = false, overflow = 'scroll' } = this.options; const { diffHeaderHeight, fileGap, hunkLineCount, lineHeight } = this.metrics; - const lines = this.getOrCreateLineCache(file); - const lineCount = lines.lineCount; + const lineCount = this.getLineCount(file); const fileHeight = this.height; const headerRegion = disableFileHeader ? fileGap : diffHeaderHeight; diff --git a/packages/diffs/src/editor/constants.ts b/packages/diffs/src/editor/constants.ts index 1e067d408..217eefecf 100644 --- a/packages/diffs/src/editor/constants.ts +++ b/packages/diffs/src/editor/constants.ts @@ -1,19 +1,21 @@ export const TOKENIZE_TIME_LIMIT = 500; export const TOKENIZE_MAX_LINE_LENGTH = 1000; +export const TOKENIZE_LINES_PRE_TOKENIZE = 50; export const EDITOR_CSS = /* CSS */ ` ::selection { background-color: transparent; } @keyframes blinking { - 0% { opacity: 0.85; } + 0% { opacity: 1; } 50% { opacity: 0; } - 100% { opacity: 0.85; } + 100% { opacity: 1; } } [data-line] { background-color: transparent; + cursor: text; } - [data-line-annotation] { + [data-gutter], [data-line-annotation] { user-select: none; } [data-content] { @@ -51,9 +53,14 @@ export const EDITOR_CSS = /* CSS */ ` } [data-caret] { width: 2px; - background-color: var(--fg); + background-color: rgb(128,128,128); animation: blinking 1.2s infinite; animation-delay: 0.6s; + visibility: hidden; + z-index: 0; + } + [data-textarea][data-state='focus'] ~ [data-caret] { + visibility: visible; } [data-line-highlight] { width: 100%; diff --git a/packages/diffs/src/editor/editorUtils.ts b/packages/diffs/src/editor/editorUtils.ts index 856732f68..ba99e3642 100644 --- a/packages/diffs/src/editor/editorUtils.ts +++ b/packages/diffs/src/editor/editorUtils.ts @@ -1,18 +1,26 @@ -import type { TextDocumentChange } from './textDocument'; - export function createElement( tagName: K, props: { id?: string; class?: string; - style?: Partial; + style?: string | Partial; dataset?: DOMStringMap | string[] | string; + children?: (Node | string)[]; textContent?: string; + html?: string; } = {}, parent?: Element | ShadowRoot | DocumentFragment ): HTMLElementTagNameMap[K] { const el = document.createElement(tagName); - const { id, class: className, style, dataset, textContent } = props; + const { + id, + class: className, + style, + dataset, + textContent, + html, + children, + } = props; if (id) { el.id = id; } @@ -20,7 +28,11 @@ export function createElement( el.className = className; } if (style !== undefined) { - Object.assign(el.style, style); + if (typeof style === 'string') { + el.style.cssText = style; + } else { + Object.assign(el.style, style); + } } if (dataset !== undefined) { if (typeof dataset === 'string') { @@ -36,9 +48,15 @@ export function createElement( if (textContent !== undefined) { el.textContent = textContent; } + if (html !== undefined) { + el.innerHTML = html; + } if (parent !== undefined) { parent.appendChild(el); } + if (children !== undefined) { + el.replaceChildren(...children); + } return el; } @@ -72,9 +90,10 @@ export function isCodeLineTarget(target?: EventTarget): target is HTMLElement { if (target === undefined || !(target instanceof HTMLElement)) { return false; } + const { tagName, dataset } = target; return ( - (target.tagName === 'DIV' && target.dataset.line !== undefined) || - (target.tagName === 'SPAN' && target.dataset.char !== undefined) + (tagName === 'DIV' && dataset.line !== undefined) || + (tagName === 'SPAN' && dataset.char !== undefined) ); } @@ -91,79 +110,17 @@ export function getLineIndentation(lineText: string): string { return indentation; } -export function resolveDirtyLines( - change: TextDocumentChange | undefined, - startingLine: number, - endLine: number -): { - dirtyLines: Set; - dirtyLineStart: number; - dirtyLineEnd: number; - tokenizerStartLine: number; -} { - const dirtyLines = new Set(); - if (endLine <= startingLine) { - return { - dirtyLines, - dirtyLineStart: -1, - dirtyLineEnd: -1, - tokenizerStartLine: startingLine, - }; - } - - if (change === undefined) { - for (let line = startingLine; line < endLine; line++) { - dirtyLines.add(line); - } - return { - dirtyLines, - dirtyLineStart: startingLine, - dirtyLineEnd: endLine - 1, - tokenizerStartLine: startingLine, - }; - } - - const tokenizerStartLine = Math.max(0, change.startLine); - if (change.startLine >= endLine) { - return { - dirtyLines, - dirtyLineStart: -1, - dirtyLineEnd: -1, - tokenizerStartLine, - }; - } - - let dirtyLineStart = Math.max(change.startLine, startingLine); - let dirtyLineEnd = Math.min(change.endLine, endLine - 1); - let shouldMarkDirtyLines = true; - - if (change.lineDelta !== 0) { - dirtyLineEnd = endLine - 1; - } else if (change.endLine < startingLine) { - // No visible line text changed, but a tokenizer state change may flow in. - dirtyLineStart = startingLine; - dirtyLineEnd = startingLine; - shouldMarkDirtyLines = false; - } - - if (dirtyLineEnd < dirtyLineStart) { - dirtyLineEnd = dirtyLineStart; - } - - if (shouldMarkDirtyLines) { - for (let line = dirtyLineStart; line <= dirtyLineEnd; line++) { - dirtyLines.add(line); - } - } - - return { - dirtyLines, - dirtyLineStart, - dirtyLineEnd, - tokenizerStartLine, - }; -} - export function extend(obj: T, attrs: Partial): T { return Object.assign(obj, attrs); } + +export function debounce void>( + func: T, + wait: number +): (...args: Parameters) => void { + let timeout: ReturnType; + return function (this: ThisType, ...args: Parameters) { + clearTimeout(timeout); + timeout = setTimeout(() => func.apply(this, args), wait); + }; +} diff --git a/packages/diffs/src/editor/index.ts b/packages/diffs/src/editor/index.ts index 86cd25b8d..323329b33 100644 --- a/packages/diffs/src/editor/index.ts +++ b/packages/diffs/src/editor/index.ts @@ -30,9 +30,9 @@ import { import { addEventListener, createElement, + debounce, extend, isCodeLineTarget, - resolveDirtyLines, } from '../editor/editorUtils'; import { type ResolvedTextEdit, @@ -46,7 +46,6 @@ import type { LineAnnotation, RenderRange, } from '../types'; -import { tokenizeLine } from './backgroundTokenzier'; import { EDITOR_CSS, TOKENIZE_MAX_LINE_LENGTH, @@ -59,6 +58,7 @@ import { type TextareaSnapshot, toTextareaSelectionDirection, } from './editorTextarea'; +import { BackgroundTokenzier, tokenizeLine } from './tokenzier'; export class Editor { #onChange?: ( @@ -83,12 +83,12 @@ export class Editor { #highlighter?: DiffsHighlighter; #colorMap?: Map; #renderRange?: RenderRange; + #backgroundTokenzier?: BackgroundTokenzier; // cache #stateStackCache?: StateStack[]; #lineYCache = new Map(); #lastCharX?: [line: number, character: number, x: number]; - // dom elements #contentEl?: HTMLElement; #styleEl?: HTMLStyleElement; @@ -105,6 +105,26 @@ export class Editor { #reservedSelections?: EditorSelection[]; #selections?: EditorSelection[]; + #prebuildStateStackCache = debounce(() => { + const textDocument = this.#textDocument; + if (textDocument === undefined) { + return; + } + + const grammar = this.#highlighter?.getLanguage(textDocument.languageId); + if (grammar === undefined) { + return; + } + + const { startingLine = 0, totalLines = Infinity } = this.#renderRange ?? {}; + const endLine = Math.min( + totalLines === Infinity ? Infinity : startingLine + totalLines, + textDocument.lineCount + ); + + this.#buildStateStackCache(textDocument, grammar, endLine); + }, 500); + edit( file: File, options?: { @@ -127,7 +147,11 @@ export class Editor { return this.cleanUp.bind(this); } - setSelections(selections: EditorSelection[], resetTextarea = true): void { + setSelections( + selections: EditorSelection[], + resetTextarea = true, + setSelectedLines = true + ): void { const primarySelection = selections.at(-1); if (primarySelection === undefined) { return; @@ -135,6 +159,16 @@ export class Editor { if (resetTextarea) { this.#textareaSnapshot = undefined; } + if (setSelectedLines) { + this.#file?.setSelectedLines(null); + if (isCollapsedSelection(primarySelection)) { + const line = primarySelection.end.line + 1; + this.#file?.setSelectedLines({ + start: line, + end: line, + }); + } + } const shouldUpdateTextarea = Math.max(0, primarySelection.start.line - 1) !== this.#textareaSnapshot?.startLine; @@ -220,9 +254,7 @@ export class Editor { } this.#renderRange = renderRange; - setTimeout(() => { - this.#prebuildStateStackCache(); - }, 500); + this.#prebuildStateStackCache(); const shadowRoot = fileContainer.shadowRoot ?? fileContainer.attachShadow({ mode: 'open' }); @@ -346,6 +378,14 @@ export class Editor { this.#selectionEndY = e.clientY; }), + addEventListener(this.#textareaEl, 'focus', () => { + this.#textareaEl!.dataset.state = 'focus'; + }), + + addEventListener(this.#textareaEl, 'blur', () => { + this.#textareaEl!.dataset.state = 'blur'; + }), + addEventListener(this.#textareaEl, 'keydown', (e) => { const command = resolveEditorCommandFromKeyboardEvent(e); if (command !== undefined) { @@ -375,12 +415,11 @@ export class Editor { this.#getCSSProperites(); - console.log('Editor initialized.', { + console.log( + 'Editor initialized.', renderRange, - tabSize: this.#tabSize, - lineHeight: this.#lineHeight, - charWidth: this.#charWidth, - }); + this.#textDocument.lineCount + ); } #computeMouseSelectionDirection(): SelectionDirection { @@ -400,6 +439,9 @@ export class Editor { } #rerender() { + // cancel previous background tokenzier task + this.#backgroundTokenzier?.cancelBackgroundTask(); + const contentEl = this.#contentEl; const highlighter = this.#highlighter; const file = this.#file; @@ -425,77 +467,48 @@ export class Editor { }; const { startingLine = 0, totalLines = Infinity } = this.#renderRange ?? {}; - const endLine = + const renderRangeEndLine = totalLines === Infinity ? textDocument.lineCount : Math.min(startingLine + totalLines, textDocument.lineCount); - const previousLineCount = - lastChange?.previousLineCount ?? textDocument.lineCount; - const prevEndLine = - totalLines === Infinity - ? previousLineCount - : Math.min(startingLine + totalLines, previousLineCount); - const { dirtyLines, dirtyLineStart, dirtyLineEnd, tokenizerStartLine } = - resolveDirtyLines(lastChange, startingLine, endLine); - const linesChange = lastChange?.lineDelta ?? 0; - - for (let line = endLine; line < prevEndLine; line++) { - this.#lineYCache.delete(line); - this.#getLineElement(line)?.remove(); - } - - const previousStateStackCache = this.#stateStackCache; - if (dirtyLineStart !== -1) { - this.#stateStackCache = previousStateStackCache?.slice( - 0, - tokenizerStartLine + 1 - ); - } - - const changes: Map> = new Map(); const isStateStackCacheSettled = (line: number, state: StateStack) => { - const previousNextState = previousStateStackCache?.[line + 1]; - return previousNextState !== undefined && state.equals(previousNextState); + const nextState = this.#stateStackCache?.[line + 1]; + return nextState !== undefined && state.equals(nextState); }; + const dirtyLines: Map> = new Map(); - let state = - dirtyLineStart === -1 - ? INITIAL - : this.#buildStateStackCache(textDocument, grammar, dirtyLineStart); - for (let line = dirtyLineStart; line >= 0 && line < endLine; line++) { - const isDirty = dirtyLines.has(line); - const previousState = previousStateStackCache?.[line]; - const didLineStateChange = - previousState !== undefined && !state.equals(previousState); - const shouldUpdateLineEl = - isDirty || - didLineStateChange || - (line > dirtyLineEnd && previousState === undefined); + let line = lastChange.startLine; + let state = this.#buildStateStackCache( + textDocument, + grammar, + lastChange.startLine + ); + let isSettled = false; + for (; line < renderRangeEndLine; line++) { const lineText = textDocument.getLineText(line); - this.#stateStackCache ??= [INITIAL]; - this.#stateStackCache[line] = state; + const isDirty = line >= lastChange.startLine && line < lastChange.endLine; + + this.#stateStackCache![line] = state; + isSettled = + !isDirty && + lastChange.lineDelta === 0 && + isStateStackCacheSettled(line, state); if (lineText.length > TOKENIZE_MAX_LINE_LENGTH) { - if (shouldUpdateLineEl) { - console.warn( - `[diffs] Line(${line}) too long to tokenize: ${lineText.length}` - ); - changes.set(line, [[0, '', lineText]]); - } - this.#stateStackCache[line + 1] = state; - if (line >= dirtyLineEnd && isStateStackCacheSettled(line, state)) { + console.warn( + `[diffs] Line(${line}) too long to tokenize: ${lineText.length}` + ); + dirtyLines.set(line, [[0, '', lineText]]); + if (isSettled) { break; } continue; } if (lineText === '' || lineText.trim() === '') { - if (shouldUpdateLineEl) { - changes.set(line, [[0, '', lineText === '' ? ' ' : lineText]]); - } - this.#stateStackCache[line + 1] = state; - if (line >= dirtyLineEnd && isStateStackCacheSettled(line, state)) { + dirtyLines.set(line, [[0, '', lineText === '' ? ' ' : lineText]]); + if (isSettled) { break; } continue; @@ -508,40 +521,140 @@ export class Editor { state, TOKENIZE_TIME_LIMIT ); - if (shouldUpdateLineEl) { - changes.set(line, result.resolvedTokens); - } + dirtyLines.set(line, result.resolvedTokens); state = result.ruleStack; - this.#stateStackCache[line + 1] = state; - if (line >= dirtyLineEnd && isStateStackCacheSettled(line, state)) { + if (isSettled) { break; } } + this.#stateStackCache![line] = state; + + // update line elements that have been changed in the document + // create new line elements for new lines + if (dirtyLines.size > 0) { + const children = contentEl.children; + const dirtyLineIndexes = new Set(dirtyLines.keys()); + for ( + let i = lastChange.startLine - startingLine; + i < children.length; + i++ + ) { + if (dirtyLineIndexes.size === 0) { + break; + } + const child = children[i] as HTMLElement; + if (child.dataset.lineIndex !== undefined) { + const lineIndex = Number(child.dataset.lineIndex); + if (dirtyLines.has(lineIndex)) { + const tokens = dirtyLines.get(lineIndex)!; + child.replaceChildren( + ...tokens.map(([char, style, textContent]) => { + if (char === 0 && style === '') { + return textContent; + } + return createElement('span', { + dataset: { + char: char.toString(), + }, + style, + textContent: textContent, + }); + }) + ); + dirtyLineIndexes.delete(lineIndex); + } + } + } + if (dirtyLineIndexes.size > 0) { + for (const lineIndex of dirtyLineIndexes) { + const tokens = dirtyLines.get(lineIndex)!; + createElement( + 'div', + { + dataset: { + line: (lineIndex + 1).toString(), + lineType: 'context', + lineIndex: lineIndex.toString(), + }, + children: tokens.map(([char, style, textContent]) => { + if (char === 0 && style === '') { + return textContent; + } + return createElement('span', { + dataset: { + char: char.toString(), + }, + style, + textContent, + }); + }), + }, + contentEl + ); + } + } + } - console.log('changes', changes); + // remove line elements that have been deleted in the document + if (lastChange.lineDelta < 0) { + const children = contentEl.children; + for (let i = children.length - 1; i >= 0; i--) { + const child = children[i] as HTMLElement; + const { lineIndex, lineAnnotation } = child.dataset; + if (lineIndex !== undefined || lineAnnotation !== undefined) { + const lineIndexNum = Number( + lineAnnotation !== undefined + ? lineAnnotation.split(',')[1] + : lineIndex + ); + if (lineIndexNum < lastChange.lineCount) { + break; + } + child.remove(); + } + } + } - this.#file?.updateRenderCache({ - dirtyLines: changes, - lineCount: lastChange?.lineCount, - }); + file.emitTokenize(dirtyLines); + if (lastChange.lineDelta !== 0) { + file.emitLineCountChange(lastChange.lineCount); + } + + if (!isSettled && line < textDocument.lineCount) { + requestAnimationFrame(() => { + this.#backgroundTokenzier = new BackgroundTokenzier({ + grammar, + colorMap, + textDocument, + onTokenize: (result) => { + file.emitTokenize(result.lines); + }, + }); + this.#backgroundTokenzier.scheduleTokenize(line, state); + }); + } + + if (this.#onChange !== undefined) { + // TODO(@ije): use debounce + requestAnimationFrame(() => { + const { contents: _, ...file } = fileContents; + Object.defineProperty(file, 'contents', { + get() { + return textDocument.getText(); + }, + }); + this.#onChange!(file as FileContents, this.#lineAnnotations); + }); + } console.log( `[diffs] re-render time: ${Math.round((performance.now() - t) * 1000) / 1000}ms`, + 'lastChange:', + lastChange, 'dirtyLines:', dirtyLines.size, - 'linesChange:', - linesChange + isSettled ? '(are settled)' : '' ); - - if (this.#onChange !== undefined) { - const { contents: _, ...file } = fileContents; - Object.defineProperty(file, 'contents', { - get() { - return textDocument.getText(); - }, - }); - this.#onChange(file as FileContents, this.#lineAnnotations); - } } #getThemeColorMap(themeType: 'dark' | 'light'): string[] { @@ -591,30 +704,11 @@ export class Editor { TOKENIZE_TIME_LIMIT ).ruleStack; } - stateStackCache[line + 1] = state; } + stateStackCache[line] = state; return stateStackCache[boundedEndLine] ?? INITIAL; } - #prebuildStateStackCache() { - const textDocument = this.#textDocument; - if (textDocument === undefined) { - return; - } - const { startingLine = 0, totalLines = Infinity } = this.#renderRange ?? {}; - const endLine = Math.min( - totalLines === Infinity ? Infinity : startingLine + totalLines, - textDocument.lineCount - ); - - const grammar = this.#highlighter?.getLanguage(textDocument.languageId); - if (grammar === undefined) { - return; - } - - this.#buildStateStackCache(textDocument, grammar, endLine); - } - #syncTextareaState() { const textDocument = this.#textDocument; const textareaEl = this.#textareaEl; @@ -727,12 +821,7 @@ export class Editor { ) { const fragment = document.createDocumentFragment(); const cacheMap = new Map(); - this.#file?.setSelectedLines(null); if (isCollapsedSelection(primarySelection)) { - this.#file?.setSelectedLines({ - start: primarySelection.start.line + 1, - end: primarySelection.end.line + 1, - }); this.#renderLineHighlight(primarySelection, fragment, cacheMap); } selections.forEach((selection) => { @@ -1056,8 +1145,9 @@ export class Editor { } const { startingLine = 0 } = this.#renderRange ?? {}; for (let i = line - startingLine; i <= children.length; i++) { - const child = children[i] as HTMLElement; + const child = children[i] as HTMLElement | undefined; if ( + child !== undefined && child.dataset.lineIndex !== undefined && Number(child.dataset.lineIndex) === line ) { diff --git a/packages/diffs/src/editor/backgroundTokenzier.ts b/packages/diffs/src/editor/tokenzier.ts similarity index 77% rename from packages/diffs/src/editor/backgroundTokenzier.ts rename to packages/diffs/src/editor/tokenzier.ts index 17a38a969..7ec8d6e7f 100644 --- a/packages/diffs/src/editor/backgroundTokenzier.ts +++ b/packages/diffs/src/editor/tokenzier.ts @@ -5,6 +5,11 @@ import { } from 'shiki/textmate'; import type { HighlightedToken } from '../types'; +import { + TOKENIZE_LINES_PRE_TOKENIZE, + TOKENIZE_MAX_LINE_LENGTH, + TOKENIZE_TIME_LIMIT, +} from './constants'; import type { TextDocument } from './textDocument'; export interface BackgroundTokenzierOptions { @@ -12,7 +17,7 @@ export interface BackgroundTokenzierOptions { colorMap: { dark: string[]; light: string[] }; textDocument: TextDocument; onTokenize: (result: { lines: Map> }) => void; - linesPreTokenize?: number; // default to 100 + linesPreTokenize?: number; // default to 50 } /** Stopable background tokenzier */ @@ -25,13 +30,14 @@ export class BackgroundTokenzier { }) => void; #linesPreTokenize: number; #isFinished: boolean = true; + #nextFrameId: number | null = null; constructor({ grammar, colorMap, textDocument, onTokenize, - linesPreTokenize = 100, + linesPreTokenize = TOKENIZE_LINES_PRE_TOKENIZE, }: BackgroundTokenzierOptions) { this.#grammar = grammar; this.#colorMap = colorMap; @@ -42,44 +48,66 @@ export class BackgroundTokenzier { scheduleTokenize(startLine: number, state: StateStack): void { this.#isFinished = false; - requestAnimationFrame(() => { + this.#nextFrameId = requestAnimationFrame(() => { this.#doTokenize(startLine, state); }); } cancelBackgroundTask(): void { + if (this.#nextFrameId !== null) { + cancelAnimationFrame(this.#nextFrameId); + this.#nextFrameId = null; + } this.#isFinished = true; } #doTokenize(startLine: number, state: StateStack): void { + this.#nextFrameId = null; if (this.#isFinished) { return; } + const lines = new Map>(); const endLine = Math.min( startLine + this.#linesPreTokenize, this.#textDocument.lineCount ); + let line = startLine; for (; line < endLine; line++) { const lineText = this.#textDocument.getLineText(line); + if (lineText.length > TOKENIZE_MAX_LINE_LENGTH) { + console.warn( + `[diffs] Line(${line}) too long to tokenize: ${lineText.length}` + ); + lines.set(line, [[0, '', lineText]]); + continue; + } + + if (lineText === '' || lineText.trim() === '') { + lines.set(line, [[0, '', lineText === '' ? ' ' : lineText]]); + continue; + } + const ret = tokenizeLine( this.#grammar, this.#colorMap, lineText, state, - 50 + TOKENIZE_TIME_LIMIT ); lines.set(line, ret.resolvedTokens); state = ret.ruleStack; } + this.#onTokenize({ lines }); - if (line >= endLine) { + if (line >= this.#textDocument.lineCount) { this.#isFinished = true; return; } + // schedule the next tokenize - requestAnimationFrame(() => { + this.#nextFrameId = requestAnimationFrame(() => { this.#doTokenize(line, state); }); } diff --git a/packages/diffs/src/managers/InteractionManager.ts b/packages/diffs/src/managers/InteractionManager.ts index c746e72c3..d42410b73 100644 --- a/packages/diffs/src/managers/InteractionManager.ts +++ b/packages/diffs/src/managers/InteractionManager.ts @@ -1209,11 +1209,11 @@ export class InteractionManager { for (const code of codeElements) { const [gutter, content] = code.children; const len = content.children.length; - if (len < gutter.children.length) { - throw new Error( - 'InteractionManager.renderSelection: gutter and content children dont match, something is wrong' - ); - } + // if (len !== gutter.children.length) { + // throw new Error( + // 'InteractionManager.renderSelection: gutter and content children dont match, something is wrong' + // ); + // } for (let i = 0; i < len; i++) { const contentElement = content.children[i]; const gutterElement = gutter.children[i]; diff --git a/packages/diffs/src/renderers/FileRenderer.ts b/packages/diffs/src/renderers/FileRenderer.ts index ba3a855de..78986a797 100644 --- a/packages/diffs/src/renderers/FileRenderer.ts +++ b/packages/diffs/src/renderers/FileRenderer.ts @@ -16,7 +16,6 @@ import type { FileHeaderRenderMode, HighlightedToken, LineAnnotation, - LineOffsets, RenderedFileASTCache, RenderFileOptions, RenderFileResult, @@ -83,8 +82,8 @@ export class FileRenderer { private renderCache: RenderedFileASTCache | undefined; private computedLang: SupportedLanguages = 'text'; private lineAnnotations: AnnotationLineMap = {}; - private lineOffsets: LineOffsets | undefined; - private lineOffsetsCacheKey: string | undefined; + private lineOffsets = new WeakMap(); + private alternateLineCount = new WeakMap(); constructor( public options: FileRendererOptions = { theme: DEFAULT_THEMES }, @@ -122,7 +121,6 @@ export class FileRenderer { this.highlighter = undefined; this.workerManager = undefined; this.onRenderUpdate = undefined; - this.lineOffsets = undefined; } public hydrate(file: FileContents): void { @@ -178,73 +176,108 @@ export class FileRenderer { return { options, forceRender: false }; } - public getOrCreateLineOffsets(file: FileContents): LineOffsets { - // Uncached files will get split every time, not the greatest experience - // tbh... but something people should try to optimize away - if (file.cacheKey == null) { - this.lineOffsets = undefined; - return computeLineOffsets(file.contents); + public getOrCreateLineOffsets(file: FileContents): number[] { + let offsets = this.lineOffsets.get(file); + if (offsets == null) { + offsets = computeLineOffsets(file.contents); + this.lineOffsets.set(file, offsets); } + return offsets; + } - let { lineOffsets } = this; - if (lineOffsets == null || this.lineOffsetsCacheKey !== file.cacheKey) { - lineOffsets = computeLineOffsets(file.contents); + public getLineCount(file: FileContents): number { + return ( + this.alternateLineCount.get(file) ?? + this.getOrCreateLineOffsets(file).length + ); + } + + public emitLineAnnotationsChange( + lineAnnotations: LineAnnotation[], + renderRange: RenderRange = DEFAULT_RENDER_RANGE + ): FileRenderResult | undefined { + const renderCache = this.renderCache; + if (renderCache == null || renderCache.result == null) { + return undefined; } - this.lineOffsets = lineOffsets; - this.lineOffsetsCacheKey = file.cacheKey; - return lineOffsets; + this.setLineAnnotations(lineAnnotations); + return this.processFileResult( + renderCache.file, + renderRange, + renderCache.result + ); } - public updateRenderCache({ - dirtyLines, - lineCount, - }: { - dirtyLines: Map>; - lineCount: number; - }): FileRenderResult { + public emitLineCountChange( + lineCount: number, + renderRange: RenderRange = DEFAULT_RENDER_RANGE + ): FileRenderResult | undefined { const renderCache = this.renderCache; if (renderCache == null || renderCache.result == null) { - throw new Error('Render cache is not set'); + return undefined; } - if (renderCache != null && renderCache.result != null) { - const code = renderCache.result.code; - for (const [line, tokens] of dirtyLines) { - code[line] = { - type: 'element', - tagName: 'div', - properties: { - 'data-line': line + 1, - 'data-line-index': line, - 'data-line-type': 'context', - }, - children: tokens.map(([char, style, text]) => { - return { - type: 'element', - tagName: 'span', - properties: { - 'data-char': char, - style, - }, - children: [ - { - type: 'text', - value: text, - }, - ], - }; - }), - }; - } - code.length = lineCount; + const prevCodeLines = renderCache.result.code.length; + renderCache.result.code.length = lineCount; + for (let i = prevCodeLines; i < lineCount; i++) { + renderCache.result.code[i] = { + type: 'element', + tagName: 'div', + properties: { + 'data-line': i + 1, + 'data-line-type': 'context', + 'data-line-index': i, + }, + children: [{ type: 'text', value: ' ' }], + }; } - + this.alternateLineCount.set(renderCache.file, lineCount); return this.processFileResult( renderCache.file, - renderCache.renderRange ?? DEFAULT_RENDER_RANGE, + renderRange, renderCache.result ); } + public emitTokenize(lines: Map>): void { + const renderCache = this.renderCache; + if (renderCache == null || renderCache.result == null) { + return; + } + for (const [line, tokens] of lines) { + renderCache.result.code[line] = { + type: 'element', + tagName: 'div', + properties: { + 'data-line': line + 1, + 'data-line-type': 'context', + 'data-line-index': line, + }, + children: tokens.map(([char, style, text]) => { + if (char === 0 && style === '') { + return { + type: 'text', + value: text, + }; + } + return { + type: 'element', + tagName: 'span', + properties: { + 'data-char': char, + style, + }, + children: [ + { + type: 'text', + value: text, + }, + ], + }; + }), + }; + } + } + public renderFile( file: FileContents | undefined = this.renderCache?.file, renderRange: RenderRange = DEFAULT_RENDER_RANGE @@ -391,14 +424,13 @@ export class FileRenderer { renderRange: RenderRange, { code, themeStyles, baseThemeType }: ThemedFileResult ): FileRenderResult { + const totalLines = this.getLineCount(file); const { disableFileHeader = false } = this.options; const contentArray: ElementContent[] = []; const gutter = createGutterWrapper(); - const lineOffsets = this.getOrCreateLineOffsets(file); - const totalLines = lineOffsets.lineCount; const endLine = Math.min( renderRange.startingLine + renderRange.totalLines, - lineOffsets.lineCount + totalLines ); let rowCount = 0; @@ -417,7 +449,6 @@ export class FileRenderer { name: file.name, lineIndex, lineNumber, - lineOffsets: lineOffsets, }); throw new Error(message); } diff --git a/packages/diffs/src/types.ts b/packages/diffs/src/types.ts index 404421239..1df40db85 100644 --- a/packages/diffs/src/types.ts +++ b/packages/diffs/src/types.ts @@ -35,16 +35,6 @@ export interface FileContents { cacheKey?: string; } -/** - * Represents a file's contents with line offsets. - */ -export interface LineOffsets { - /** The line offsets for the file contents. */ - readonly offsets: number[]; - /** The number of lines in the file. */ - readonly lineCount: number; -} - export type HighlighterTypes = 'shiki-js' | 'shiki-wasm'; export type HighlightedToken = [char: number, style: string, text: string]; @@ -635,7 +625,7 @@ export interface ForceFilePlainTextOptions { startingLine?: number; totalLines?: number; // Pre-split lines for caching in windowing scenarios - lineOffsets?: LineOffsets; + lineOffsets?: number[]; } export interface RenderFileOptions { diff --git a/packages/diffs/src/utils/computeFileOffsets.ts b/packages/diffs/src/utils/computeFileOffsets.ts index ff96ac48b..6d6a21f63 100644 --- a/packages/diffs/src/utils/computeFileOffsets.ts +++ b/packages/diffs/src/utils/computeFileOffsets.ts @@ -1,5 +1,3 @@ -import type { LineOffsets } from '../types'; - const LINE_FEED = 10; // \n const CARRIAGE_RETURN = 13; // \r @@ -8,11 +6,8 @@ const CARRIAGE_RETURN = 13; // \r * `lineCount` excludes the final newline-only parser row, except for files * that contain only that row. */ -export function computeLineOffsets(contents: string): LineOffsets { - const offsets: number[] = []; - if (contents.length > 0) { - offsets.push(0); - } +export function computeLineOffsets(contents: string): number[] { + const offsets: number[] = [0]; for (let i = 0; i < contents.length; i++) { const char = contents.charCodeAt(i); if (char === LINE_FEED || char === CARRIAGE_RETURN) { @@ -29,31 +24,5 @@ export function computeLineOffsets(contents: string): LineOffsets { if (offsets.length > 0 && offsets[offsets.length - 1] !== contents.length) { offsets.push(contents.length); } - const rawLineCount = Math.max(0, offsets.length - 1); - const lineCount = - rawLineCount > 1 && - isNewlineOnlyRange( - contents, - offsets[rawLineCount - 1], - offsets[rawLineCount] - ) - ? rawLineCount - 1 - : rawLineCount; - - return { offsets, lineCount }; -} - -// Detects the synthetic final row produced by terminal newline characters. -function isNewlineOnlyRange( - contents: string, - startOffset = contents.length, - endOffset = contents.length -): boolean { - for (let offset = startOffset; offset < endOffset; offset++) { - const char = contents.charCodeAt(offset); - if (char !== LINE_FEED && char !== CARRIAGE_RETURN) { - return false; - } - } - return true; + return offsets; } diff --git a/packages/diffs/src/utils/renderFileWithHighlighter.ts b/packages/diffs/src/utils/renderFileWithHighlighter.ts index 8856dac8a..46a58de33 100644 --- a/packages/diffs/src/utils/renderFileWithHighlighter.ts +++ b/packages/diffs/src/utils/renderFileWithHighlighter.ts @@ -5,11 +5,9 @@ import type { DiffsThemeNames, FileContents, ForceFilePlainTextOptions, - LineOffsets, RenderFileOptions, ThemedFileResult, } from '../types'; -import { cleanLastNewline } from './cleanLastNewline'; import { computeLineOffsets } from './computeFileOffsets'; import { createTransformerWithState } from './createTransformerWithState'; import { formatCSSVariablePrefix } from './formatCSSVariablePrefix'; @@ -95,7 +93,7 @@ export function renderFileWithHighlighter( startingLine, totalLines ) - : cleanLastNewline(file.contents), + : file.contents, hastConfig ) ); @@ -111,15 +109,15 @@ export function renderFileWithHighlighter( function extractWindowedFileContent( file: FileContents, - lineOffsets: LineOffsets, + lineOffsets: number[], startingLine: number, totalLines: number ): string { - if (lineOffsets.lineCount === 0) { + if (lineOffsets.length === 0) { return ''; } - const endLine = Math.min(startingLine + totalLines, lineOffsets.lineCount); - const startOffset = lineOffsets.offsets[startingLine] ?? file.contents.length; - const endOffset = lineOffsets.offsets[endLine] ?? file.contents.length; + const endLine = Math.min(startingLine + totalLines, lineOffsets.length); + const startOffset = lineOffsets[startingLine] ?? file.contents.length; + const endOffset = lineOffsets[endLine] ?? file.contents.length; return file.contents.slice(startOffset, endOffset); } diff --git a/packages/diffs/src/worker/WorkerPoolManager.ts b/packages/diffs/src/worker/WorkerPoolManager.ts index f941fe48d..1c33c9e4f 100644 --- a/packages/diffs/src/worker/WorkerPoolManager.ts +++ b/packages/diffs/src/worker/WorkerPoolManager.ts @@ -15,7 +15,6 @@ import type { FileDiffMetadata, HighlighterTypes, HunkExpansionRegion, - LineOffsets, RenderDiffOptions, RenderDiffResult, RenderFileOptions, @@ -547,7 +546,7 @@ export class WorkerPoolManager { file: FileContents, startingLine: number, totalLines: number, - lineOffsets: LineOffsets + lineOffsets: number[] ): ThemedFileResult | undefined { if (this.highlighter == null) { this.queueInitialization(); diff --git a/packages/diffs/test/FileRenderer.ast.test.ts b/packages/diffs/test/FileRenderer.ast.test.ts index 8a89fe640..11f284f94 100644 --- a/packages/diffs/test/FileRenderer.ast.test.ts +++ b/packages/diffs/test/FileRenderer.ast.test.ts @@ -147,18 +147,18 @@ describe('FileRenderer AST Structure', () => { expect(result2.totalLines).toBe(file2Lines); }); - test('should render a single line without a trailing newline', async () => { + test('should render one content line when the buffer ends with a newline', async () => { const instance = new FileRenderer(); const result = await instance.asyncRender({ name: 'single-line.txt', - contents: 'hello', + contents: 'hello\n', }); const [gutter, contentColumn] = instance.renderCodeAST(result) as Element[]; - expect(result.totalLines).toBe(1); - expect(result.rowCount).toBe(1); - expect(gutter.children).toHaveLength(1); - expect(contentColumn.children).toHaveLength(1); + expect(result.totalLines).toBe(2); + expect(result.rowCount).toBe(2); + expect(gutter.children).toHaveLength(2); + expect(contentColumn.children).toHaveLength(2); }); test('should include CSS property in result', async () => { diff --git a/packages/diffs/test/__snapshots__/FileRenderer.test.ts.snap b/packages/diffs/test/__snapshots__/FileRenderer.test.ts.snap index 3ec26b069..471a92e2b 100644 --- a/packages/diffs/test/__snapshots__/FileRenderer.test.ts.snap +++ b/packages/diffs/test/__snapshots__/FileRenderer.test.ts.snap @@ -388,10 +388,34 @@ exports[`FileRenderer should render TypeScript code to AST matching snapshot 1`] "tagName": "div", "type": "element", }, + { + "children": [ + { + "children": [ + { + "type": "text", + "value": "17", + }, + ], + "properties": { + "data-line-number-content": "", + }, + "tagName": "span", + "type": "element", + }, + ], + "properties": { + "data-column-number": 17, + "data-line-index": "16", + "data-line-type": "context", + }, + "tagName": "div", + "type": "element", + }, ], "properties": { "data-gutter": "", - "style": "grid-row: span 16", + "style": "grid-row: span 17", }, "tagName": "div", "type": "element", @@ -1598,10 +1622,29 @@ exports[`FileRenderer should render TypeScript code to AST matching snapshot 1`] "tagName": "div", "type": "element", }, + { + "children": [ + { + "type": "text", + "value": +" +" +, + }, + ], + "properties": { + "data-alt-line": undefined, + "data-line": 17, + "data-line-index": 16, + "data-line-type": "context", + }, + "tagName": "div", + "type": "element", + }, ], "properties": { "data-content": "", - "style": "grid-row: span 16", + "style": "grid-row: span 17", }, "tagName": "div", "type": "element", diff --git a/packages/diffs/test/fileLineUtils.test.ts b/packages/diffs/test/fileLineUtils.test.ts index 063a56ef1..9bb26fd0b 100644 --- a/packages/diffs/test/fileLineUtils.test.ts +++ b/packages/diffs/test/fileLineUtils.test.ts @@ -3,58 +3,58 @@ import { describe, expect, test } from 'bun:test'; import { computeLineOffsets } from '../src/utils/computeFileOffsets'; describe('computeLineOffsets', () => { - test('returns no offsets for empty contents', () => { + test('returns a single start offset for empty contents', () => { const result = computeLineOffsets(''); - expect(result.offsets).toEqual([]); - expect(result.lineCount).toBe(0); + expect([...result]).toEqual([0]); + expect(result.length).toBe(1); }); test('computes offsets for single line without trailing newline', () => { const result = computeLineOffsets('hello'); - expect(result.offsets).toEqual([0, 5]); - expect(result.lineCount).toBe(1); + expect([...result]).toEqual([0, 5]); + expect(result.length).toBe(2); }); test('computes offsets for LF files with and without terminal newline', () => { const withTerminalNewline = computeLineOffsets('a\nb\n'); const withoutTerminalNewline = computeLineOffsets('a\nb'); - expect(withTerminalNewline.offsets).toEqual([0, 2, 4]); - expect(withTerminalNewline.lineCount).toBe(2); - expect(withoutTerminalNewline.offsets).toEqual([0, 2, 3]); - expect(withoutTerminalNewline.lineCount).toBe(2); + expect([...withTerminalNewline]).toEqual([0, 2, 4]); + expect(withTerminalNewline.length).toBe(3); + expect([...withoutTerminalNewline]).toEqual([0, 2, 3]); + expect(withoutTerminalNewline.length).toBe(3); }); test('computes offsets for CRLF and lone CR line endings', () => { const crlf = computeLineOffsets('a\r\nb\r\n'); const mixed = computeLineOffsets('a\rb\r\nc\n'); - expect(crlf.offsets).toEqual([0, 3, 6]); - expect(crlf.lineCount).toBe(2); - expect(mixed.offsets).toEqual([0, 2, 5, 7]); - expect(mixed.lineCount).toBe(3); + expect([...crlf]).toEqual([0, 3, 6]); + expect(crlf.length).toBe(3); + expect([...mixed]).toEqual([0, 2, 5, 7]); + expect(mixed.length).toBe(4); }); }); describe('renderable line count', () => { - test('keeps regular final lines', () => { + test('counts row slots including end offset for two lines without terminal newline', () => { const lines = computeLineOffsets('first\nsecond'); - expect(lines.lineCount).toBe(2); + expect(lines.length).toBe(3); }); - test('excludes one final newline-only row from multi-line files', () => { + test('includes trailing blank line segment in offset array length', () => { const lines = computeLineOffsets('first\nsecond\n\n'); - expect(lines.offsets).toEqual([0, 6, 13, 14]); - expect(lines.lineCount).toBe(2); + expect([...lines]).toEqual([0, 6, 13, 14]); + expect(lines.length).toBe(4); }); - test('keeps a newline-only row when it is the whole file', () => { + test('treats newline-only contents as two offset boundaries', () => { const lines = computeLineOffsets('\n'); - expect(lines.lineCount).toBe(1); + expect(lines.length).toBe(2); }); }); diff --git a/packages/diffs/test/mocks.ts b/packages/diffs/test/mocks.ts index 9cf5c0a99..693d2b54d 100644 --- a/packages/diffs/test/mocks.ts +++ b/packages/diffs/test/mocks.ts @@ -36,7 +36,8 @@ export function areThemesEqual( return themeA === themeB; } return themeA.dark === themeB.dark && themeA.light === themeB.light; -}`, +} +`, }, file2: { name: 'file2.js', @@ -47,7 +48,8 @@ export function areThemesEqual( return total; } -export default calculateTotal;`, +export default calculateTotal; +`, }, }; From 209999f36e2211947a09f680818a2bc8fcbd16ae Mon Sep 17 00:00:00 2001 From: Je Xia Date: Mon, 4 May 2026 21:59:21 +0800 Subject: [PATCH 054/138] Fix hightlight bug --- packages/diffs/src/editor/index.ts | 69 ++++++++++++++---------------- 1 file changed, 33 insertions(+), 36 deletions(-) diff --git a/packages/diffs/src/editor/index.ts b/packages/diffs/src/editor/index.ts index 323329b33..5b936de54 100644 --- a/packages/diffs/src/editor/index.ts +++ b/packages/diffs/src/editor/index.ts @@ -417,8 +417,13 @@ export class Editor { console.log( 'Editor initialized.', - renderRange, - this.#textDocument.lineCount + 'renderRange:', + (renderRange?.startingLine ?? 0) + + '-' + + (renderRange?.totalLines ?? Infinity), + 'of', + this.#textDocument.lineCount, + 'lines' ); } @@ -472,10 +477,6 @@ export class Editor { ? textDocument.lineCount : Math.min(startingLine + totalLines, textDocument.lineCount); - const isStateStackCacheSettled = (line: number, state: StateStack) => { - const nextState = this.#stateStackCache?.[line + 1]; - return nextState !== undefined && state.equals(nextState); - }; const dirtyLines: Map> = new Map(); let line = lastChange.startLine; @@ -484,50 +485,46 @@ export class Editor { grammar, lastChange.startLine ); - let isSettled = false; + let settled = false; for (; line < renderRangeEndLine; line++) { const lineText = textDocument.getLineText(line); - const isDirty = line >= lastChange.startLine && line < lastChange.endLine; this.#stateStackCache![line] = state; - isSettled = - !isDirty && - lastChange.lineDelta === 0 && - isStateStackCacheSettled(line, state); if (lineText.length > TOKENIZE_MAX_LINE_LENGTH) { console.warn( `[diffs] Line(${line}) too long to tokenize: ${lineText.length}` ); dirtyLines.set(line, [[0, '', lineText]]); - if (isSettled) { - break; - } - continue; - } - - if (lineText === '' || lineText.trim() === '') { + } else if (lineText === '' || lineText.trim() === '') { dirtyLines.set(line, [[0, '', lineText === '' ? ' ' : lineText]]); - if (isSettled) { - break; - } - continue; + } else { + const result = tokenizeLine( + grammar, + colorMap, + lineText, + state, + TOKENIZE_TIME_LIMIT + ); + dirtyLines.set(line, result.resolvedTokens); + state = result.ruleStack; } - const result = tokenizeLine( - grammar, - colorMap, - lineText, - state, - TOKENIZE_TIME_LIMIT - ); - dirtyLines.set(line, result.resolvedTokens); - state = result.ruleStack; - if (isSettled) { + settled = + line >= lastChange.endLine && + lastChange.lineDelta === 0 && + this.#stateStackCache![line + 1] !== undefined && + state.equals(this.#stateStackCache![line + 1]); + + if (settled) { break; } } - this.#stateStackCache![line] = state; + if (line < renderRangeEndLine) { + this.#stateStackCache![line + 1] = state; + } else { + this.#stateStackCache![line] = state; + } // update line elements that have been changed in the document // create new line elements for new lines @@ -620,7 +617,7 @@ export class Editor { file.emitLineCountChange(lastChange.lineCount); } - if (!isSettled && line < textDocument.lineCount) { + if (!settled && line < textDocument.lineCount) { requestAnimationFrame(() => { this.#backgroundTokenzier = new BackgroundTokenzier({ grammar, @@ -653,7 +650,7 @@ export class Editor { lastChange, 'dirtyLines:', dirtyLines.size, - isSettled ? '(are settled)' : '' + settled ? '(are settled)' : '' ); } From 2b8cb0953ac626f6b7cb820521a7fc68f8a55949 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Mon, 4 May 2026 22:00:03 +0800 Subject: [PATCH 055/138] Add `--diffs-bg-caret` css property --- packages/diffs/src/editor/constants.ts | 2 +- packages/diffs/src/style.css | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/diffs/src/editor/constants.ts b/packages/diffs/src/editor/constants.ts index 217eefecf..5bc2ca205 100644 --- a/packages/diffs/src/editor/constants.ts +++ b/packages/diffs/src/editor/constants.ts @@ -53,7 +53,7 @@ export const EDITOR_CSS = /* CSS */ ` } [data-caret] { width: 2px; - background-color: rgb(128,128,128); + background-color: var(--diffs-bg-caret); animation: blinking 1.2s infinite; animation-delay: 0.6s; visibility: hidden; diff --git a/packages/diffs/src/style.css b/packages/diffs/src/style.css index 6ad2f2666..2fd1549eb 100644 --- a/packages/diffs/src/style.css +++ b/packages/diffs/src/style.css @@ -25,6 +25,7 @@ --diffs-bg-hover-override --diffs-bg-context-override --diffs-bg-separator-override + --diffs-bg-caret-override --diffs-fg-number-override --diffs-fg-number-addition-override @@ -237,6 +238,14 @@ var(--diffs-fg-number) ); + --diffs-bg-caret: var( + --diffs-bg-caret-override, + light-dark( + color-mix(in lab, var(--diffs-fg) 50%, var(--diffs-bg)), + color-mix(in lab, var(--diffs-fg) 75%, var(--diffs-bg)) + ) + ); + --diffs-deletion-base: var( --diffs-deletion-color-override, light-dark( From 6f3659a6e061e0bbe5ec3e5ead3a01dcaeba645c Mon Sep 17 00:00:00 2001 From: Je Xia Date: Mon, 4 May 2026 22:50:52 +0800 Subject: [PATCH 056/138] Fix input --- packages/diffs/src/editor/editorTextarea.ts | 23 ++++++++++++++++--- .../diffs/test/editorTextareaSnapshot.test.ts | 21 +++++++++++++++++ 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/packages/diffs/src/editor/editorTextarea.ts b/packages/diffs/src/editor/editorTextarea.ts index f8665cc21..dcb6031df 100644 --- a/packages/diffs/src/editor/editorTextarea.ts +++ b/packages/diffs/src/editor/editorTextarea.ts @@ -1,5 +1,5 @@ import { type EditorSelection, SelectionDirection } from './editorSelection'; -import type { ResolvedTextEdit, TextDocument } from './textDocument'; +import type { Position, ResolvedTextEdit, TextDocument } from './textDocument'; export interface TextareaSnapshot { startLine: number; @@ -24,13 +24,22 @@ export function createTextareaSnapshot( let selectionStart = 0; let selectionEnd = 0; + const startCharacter = normalizeCharacterForDocument( + textDocument, + primarySelection.start + ); + const endCharacter = normalizeCharacterForDocument( + textDocument, + primarySelection.end + ); + for (let line = startLine; line <= endLine; line++) { const lineText = textDocument.getLineText(line); if (line === primarySelection.start.line) { - selectionStart = offset + primarySelection.start.character; + selectionStart = offset + startCharacter; } if (line === primarySelection.end.line) { - selectionEnd = offset + primarySelection.end.character; + selectionEnd = offset + endCharacter; } lines.push(lineText); offset += lineText.length; @@ -129,3 +138,11 @@ export function toTextareaSelectionDirection( return 'none'; } } + +/** Aligns a column with `TextDocument.offsetAt` / `positionAt` so textarea indices match backing text (DOM may report past end for empty lines that render a placeholder space). */ +function normalizeCharacterForDocument( + textDocument: TextDocument, + position: Position +): number { + return textDocument.positionAt(textDocument.offsetAt(position)).character; +} diff --git a/packages/diffs/test/editorTextareaSnapshot.test.ts b/packages/diffs/test/editorTextareaSnapshot.test.ts index 7258d2258..be04c8866 100644 --- a/packages/diffs/test/editorTextareaSnapshot.test.ts +++ b/packages/diffs/test/editorTextareaSnapshot.test.ts @@ -52,4 +52,25 @@ describe('resolveTextChange', () => { text: '', }); }); + + test('clamps caret column on empty lines so textarea slice matches the document', () => { + const textDocument = new TextDocument('inmemory://1', 'a\n\nb'); + const valid = createTextareaSnapshot( + textDocument, + createSelection(1, 0, 1, 0) + ); + const oversizedColumnFromDomPlaceholder = createTextareaSnapshot( + textDocument, + createSelection(1, 1, 1, 1) + ); + + expect(oversizedColumnFromDomPlaceholder.selectionStart).toBe( + valid.selectionStart + ); + expect(oversizedColumnFromDomPlaceholder.selectionEnd).toBe( + valid.selectionEnd + ); + expect(oversizedColumnFromDomPlaceholder.text).toBe(valid.text); + expect(oversizedColumnFromDomPlaceholder.offset).toBe(valid.offset); + }); }); From 0686fac7141ad13192d0bf5fc1659926d8d0efc9 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Mon, 4 May 2026 22:54:52 +0800 Subject: [PATCH 057/138] Fix selection range rendering --- packages/diffs/src/editor/index.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/diffs/src/editor/index.ts b/packages/diffs/src/editor/index.ts index 5b936de54..4d580181f 100644 --- a/packages/diffs/src/editor/index.ts +++ b/packages/diffs/src/editor/index.ts @@ -884,7 +884,9 @@ export class Editor { const startChar = ln === start.line ? start.character : 0; const endChar = ln === end.line ? end.character : lineLength; const spacing = - ln === end.line || startChar === endChar ? 0 : this.#charWidth; + ln === end.line || (startChar === endChar && ln !== start.line) + ? 0 + : this.#charWidth; const cacheKey = `selection-${ln}-${startChar}-${endChar}`; let left = 0; @@ -898,7 +900,7 @@ export class Editor { width = endChar === startChar ? 0 : this.#getCharX(ln, endChar) - left; } - const css = `width: ${width + spacing}px; transform: translateY(${this.#getLineY(ln)}px) translateX(${left}px);`; + const css = `width:${width + spacing}px;transform:translateY(${this.#getLineY(ln)}px) translateX(${left}px);`; if (selectionEls?.has(cacheKey) === true) { rangeEl = selectionEls.get(cacheKey)!; From cefee500f922acbae24baf2a65d048c8b737c213 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Mon, 4 May 2026 23:04:12 +0800 Subject: [PATCH 058/138] Fix prebuildStateStackCache funciton --- packages/diffs/src/editor/index.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/diffs/src/editor/index.ts b/packages/diffs/src/editor/index.ts index 4d580181f..f5546e0bd 100644 --- a/packages/diffs/src/editor/index.ts +++ b/packages/diffs/src/editor/index.ts @@ -105,13 +105,18 @@ export class Editor { #reservedSelections?: EditorSelection[]; #selections?: EditorSelection[]; - #prebuildStateStackCache = debounce(() => { + #prebuildStateStackCache = debounce(async () => { const textDocument = this.#textDocument; - if (textDocument === undefined) { + const highlighter = this.#highlighter; + if (textDocument === undefined || highlighter === undefined) { return; } - const grammar = this.#highlighter?.getLanguage(textDocument.languageId); + if (!highlighter.getLoadedLanguages().includes(textDocument.languageId)) { + await highlighter.loadLanguage(textDocument.languageId); + } + + const grammar = highlighter.getLanguage(textDocument.languageId); if (grammar === undefined) { return; } From af0e5086961d150d37df4a1d87246f01213c8d30 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Mon, 4 May 2026 23:08:41 +0800 Subject: [PATCH 059/138] Update `TOKENIZE_MAX_LINE_LENGTH` to 10,000 --- packages/diffs/src/editor/constants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/diffs/src/editor/constants.ts b/packages/diffs/src/editor/constants.ts index 5bc2ca205..c0e3d8cfc 100644 --- a/packages/diffs/src/editor/constants.ts +++ b/packages/diffs/src/editor/constants.ts @@ -1,5 +1,5 @@ export const TOKENIZE_TIME_LIMIT = 500; -export const TOKENIZE_MAX_LINE_LENGTH = 1000; +export const TOKENIZE_MAX_LINE_LENGTH = 10000; export const TOKENIZE_LINES_PRE_TOKENIZE = 50; export const EDITOR_CSS = /* CSS */ ` From d9da211a7e27d47da1566aae9cf4c63f447003a7 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Tue, 5 May 2026 00:31:15 +0800 Subject: [PATCH 060/138] Add `DiffsEditor` interface --- apps/demo/src/main.ts | 2 +- packages/diffs/src/components/File.ts | 26 +++++---- packages/diffs/src/editor/index.ts | 76 +++++++++++++++------------ packages/diffs/src/types.ts | 17 +++--- 4 files changed, 69 insertions(+), 52 deletions(-) diff --git a/apps/demo/src/main.ts b/apps/demo/src/main.ts index cbcec86b2..9b35e766b 100644 --- a/apps/demo/src/main.ts +++ b/apps/demo/src/main.ts @@ -48,7 +48,7 @@ import { const DEMO_THEME: DiffsThemeNames | ThemesType = DEFAULT_THEMES; const WORKER_POOL = true; const VIRTUALIZE = true; -const CRAZY_FILE = false; +const CRAZY_FILE = true; const LARGE_CONFLICT_FILE = false; const FileStreamCodeConfigs: FileStreamCodeConfigsItem[] = [ diff --git a/packages/diffs/src/components/File.ts b/packages/diffs/src/components/File.ts index 14408a6ca..d9c332dc3 100644 --- a/packages/diffs/src/components/File.ts +++ b/packages/diffs/src/components/File.ts @@ -24,7 +24,7 @@ import { SVGSpriteSheet } from '../sprite'; import type { AppliedThemeStyleCache, BaseCodeOptions, - EditorHook, + DiffsEditor, FileContents, HighlightedToken, LineAnnotation, @@ -172,17 +172,18 @@ export class File { this.workerManager?.subscribeToThemeChanges(this); } - private __editorHook: EditorHook | undefined; + private __editor: DiffsEditor | undefined; - public __addEditorHook(hook: EditorHook): void { + public __setEditor(editor: DiffsEditor): void { if (this.fileContainer != null && this.file != null) { - hook(this.fileContainer, this.file, this.renderRange); + editor.triggerEdit( + this.fileContainer, + this.file, + this.lineAnnotations, + this.renderRange + ); } - this.__editorHook = hook; - } - - public __isEditorAttached(): boolean { - return this.__editorHook != null; + this.__editor = editor; } private handleHighlightRender = (): void => { @@ -575,7 +576,12 @@ export class File { this.resizeManager.setup(pre, overflow === 'wrap'); this.renderAnnotations(); this.renderGutterUtility(); - this.__editorHook?.(fileContainer, file, nextRenderRange); + this.__editor?.triggerEdit( + fileContainer, + file, + lineAnnotations, + nextRenderRange + ); } catch (error: unknown) { if (disableErrorHandling) { throw error; diff --git a/packages/diffs/src/editor/index.ts b/packages/diffs/src/editor/index.ts index f5546e0bd..e37053954 100644 --- a/packages/diffs/src/editor/index.ts +++ b/packages/diffs/src/editor/index.ts @@ -40,6 +40,7 @@ import { type TextEdit, } from '../editor/textDocument'; import type { + DiffsEditor, DiffsHighlighter, FileContents, HighlightedToken, @@ -60,7 +61,7 @@ import { } from './editorTextarea'; import { BackgroundTokenzier, tokenizeLine } from './tokenzier'; -export class Editor { +export class Editor implements DiffsEditor { #onChange?: ( file: FileContents, lineAnnotations?: LineAnnotation[] @@ -139,9 +140,7 @@ export class Editor { ) => void; } ): () => void { - file.__addEditorHook((fileContainer, fileContents, renderRange) => { - this.#initialize(fileContainer, fileContents, renderRange); - }); + file.__setEditor(this); this.#file = file; this.#highlighter ??= areThemesAttached( file.options.theme ?? DEFAULT_THEMES @@ -152,11 +151,7 @@ export class Editor { return this.cleanUp.bind(this); } - setSelections( - selections: EditorSelection[], - resetTextarea = true, - setSelectedLines = true - ): void { + setSelections(selections: EditorSelection[], resetTextarea = true): void { const primarySelection = selections.at(-1); if (primarySelection === undefined) { return; @@ -164,15 +159,13 @@ export class Editor { if (resetTextarea) { this.#textareaSnapshot = undefined; } - if (setSelectedLines) { - this.#file?.setSelectedLines(null); - if (isCollapsedSelection(primarySelection)) { - const line = primarySelection.end.line + 1; - this.#file?.setSelectedLines({ - start: line, - end: line, - }); - } + this.#file?.setSelectedLines(null); + if (isCollapsedSelection(primarySelection)) { + const line = primarySelection.end.line + 1; + this.#file?.setSelectedLines({ + start: line, + end: line, + }); } const shouldUpdateTextarea = Math.max(0, primarySelection.start.line - 1) !== @@ -237,9 +230,10 @@ export class Editor { this.#reservedSelections = undefined; } - #initialize( + triggerEdit( fileContainer: HTMLElement, fileContents: FileContents, + lineAnnotations: LineAnnotation[] | undefined, renderRange: RenderRange | undefined ): void { if ( @@ -258,6 +252,7 @@ export class Editor { this.#selections = undefined; } + this.#lineAnnotations = lineAnnotations; this.#renderRange = renderRange; this.#prebuildStateStackCache(); @@ -269,6 +264,7 @@ export class Editor { throw new Error('could not edit the file.'); } + // TODO(@ije): place the textarea inside the pre (as a child). this.#textareaEl ??= extend( createElement('textarea', { dataset: 'textarea' }), { @@ -449,7 +445,7 @@ export class Editor { } #rerender() { - // cancel previous background tokenzier task + // cancel existing background tokenzier task this.#backgroundTokenzier?.cancelBackgroundTask(); const contentEl = this.#contentEl; @@ -636,19 +632,6 @@ export class Editor { }); } - if (this.#onChange !== undefined) { - // TODO(@ije): use debounce - requestAnimationFrame(() => { - const { contents: _, ...file } = fileContents; - Object.defineProperty(file, 'contents', { - get() { - return textDocument.getText(); - }, - }); - this.#onChange!(file as FileContents, this.#lineAnnotations); - }); - } - console.log( `[diffs] re-render time: ${Math.round((performance.now() - t) * 1000) / 1000}ms`, 'lastChange:', @@ -774,10 +757,33 @@ export class Editor { change ); this.#rerender(); + this.#emitChange(); this.setSelections(nextSelections, false); } } + #emitChange() { + const fileContents = this.#fileContents; + const textDocument = this.#textDocument; + const onChange = this.#onChange; + if ( + fileContents !== undefined && + textDocument !== undefined && + onChange !== undefined + ) { + // TODO(@ije): use debounce + requestAnimationFrame(() => { + const { contents: _, ...file } = fileContents; + Object.defineProperty(file, 'contents', { + get() { + return textDocument.getText(); + }, + }); + onChange(file as FileContents, this.#lineAnnotations); + }); + } + } + #updateTextarea(primarySelection: EditorSelection) { const textDocument = this.#textDocument; const textareaEl = this.#textareaEl; @@ -1038,6 +1044,7 @@ export class Editor { nextSelections ); this.#rerender(); + this.#emitChange(); this.setSelections(nextSelections, false); } } @@ -1054,6 +1061,7 @@ export class Editor { if (this.#textDocument?.canUndo === true) { const nextSelections = this.#textDocument.undo(); this.#rerender(); + this.#emitChange(); if (nextSelections !== undefined) { this.setSelections(nextSelections, false); } @@ -1064,6 +1072,7 @@ export class Editor { if (this.#textDocument?.canRedo === true) { const nextSelections = this.#textDocument.redo(); this.#rerender(); + this.#emitChange(); if (nextSelections !== undefined) { this.setSelections(nextSelections, false); } @@ -1139,6 +1148,7 @@ export class Editor { text: text, }); this.#rerender(); + this.#emitChange(); this.setSelections(nextSelections, false); } diff --git a/packages/diffs/src/types.ts b/packages/diffs/src/types.ts index 1df40db85..36eedfbb7 100644 --- a/packages/diffs/src/types.ts +++ b/packages/diffs/src/types.ts @@ -647,14 +647,6 @@ export interface RenderFileResult { options: RenderFileOptions; } -export interface EditorHook { - ( - fileContainer: HTMLElement, - file: FileContents, - renderRange: RenderRange | undefined - ): void; -} - export interface RenderDiffResult { result: ThemedDiffResult; options: RenderDiffOptions; @@ -750,3 +742,12 @@ export interface AppliedThemeStyleCache { themeType: ThemeTypes; baseThemeType: 'light' | 'dark' | undefined; } + +export interface DiffsEditor { + triggerEdit( + fileContainer: HTMLElement, + file: FileContents, + lineAnnotations: LineAnnotation[] | undefined, + renderRange: RenderRange | undefined + ): void; +} From 092486ed363adef0e5ea6ffc8563e8b3bf22a12a Mon Sep 17 00:00:00 2001 From: Je Xia Date: Tue, 5 May 2026 01:35:19 +0800 Subject: [PATCH 061/138] Fix `lineAnnotations` argument on `triggerEdit` invoke --- packages/diffs/src/components/File.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/diffs/src/components/File.ts b/packages/diffs/src/components/File.ts index d9c332dc3..75b452274 100644 --- a/packages/diffs/src/components/File.ts +++ b/packages/diffs/src/components/File.ts @@ -579,7 +579,7 @@ export class File { this.__editor?.triggerEdit( fileContainer, file, - lineAnnotations, + this.lineAnnotations, nextRenderRange ); } catch (error: unknown) { From 4dcc108161d35dfdb05d267b9b6aefa1244f099e Mon Sep 17 00:00:00 2001 From: Je Xia Date: Tue, 5 May 2026 01:50:14 +0800 Subject: [PATCH 062/138] Refactor editor edit method to accept onChange callback directly and update demo to log file changes --- apps/demo/src/main.ts | 8 +++++--- packages/diffs/src/editor/index.ts | 13 ++++++------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/apps/demo/src/main.ts b/apps/demo/src/main.ts index 9b35e766b..023879515 100644 --- a/apps/demo/src/main.ts +++ b/apps/demo/src/main.ts @@ -835,7 +835,7 @@ if (renderEditorButton != null) { }, // Line selection stuff - enableLineSelection: true, + // enableLineSelection: true, // onLineClick(props) { // console.log('onLineClick', props); // }, @@ -863,7 +863,7 @@ if (renderEditorButton != null) { // }, // Hover Decoration Snippets - enableGutterUtility: true, + // enableGutterUtility: true, // onGutterUtilityClick(event) { // console.log('onGutterUtilityClick', event); // }, @@ -932,7 +932,9 @@ if (renderEditorButton != null) { fileInstances.push(instance); const editor = new Editor(); - editor.edit(instance); + editor.edit(instance, (file) => { + console.log('onChange', file); + }); }); } diff --git a/packages/diffs/src/editor/index.ts b/packages/diffs/src/editor/index.ts index e37053954..49fef6cb6 100644 --- a/packages/diffs/src/editor/index.ts +++ b/packages/diffs/src/editor/index.ts @@ -133,12 +133,10 @@ export class Editor implements DiffsEditor { edit( file: File, - options?: { - onChange?: ( - file: FileContents, - lineAnnotations?: LineAnnotation[] - ) => void; - } + onChange?: ( + file: FileContents, + lineAnnotations?: LineAnnotation[] + ) => void ): () => void { file.__setEditor(this); this.#file = file; @@ -147,7 +145,7 @@ export class Editor implements DiffsEditor { ) ? getHighlighterIfLoaded() : undefined; - this.#onChange = options?.onChange; + this.#onChange = onChange; return this.cleanUp.bind(this); } @@ -766,6 +764,7 @@ export class Editor implements DiffsEditor { const fileContents = this.#fileContents; const textDocument = this.#textDocument; const onChange = this.#onChange; + console.log('emitChange:', fileContents, textDocument, onChange); if ( fileContents !== undefined && textDocument !== undefined && From a2cf5ccf5b93963e9ae560bd98a318ab9ecff8aa Mon Sep 17 00:00:00 2001 From: Je Xia Date: Tue, 5 May 2026 01:51:25 +0800 Subject: [PATCH 063/138] Clean up --- packages/diffs/src/editor/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/diffs/src/editor/index.ts b/packages/diffs/src/editor/index.ts index 49fef6cb6..a592b9cc4 100644 --- a/packages/diffs/src/editor/index.ts +++ b/packages/diffs/src/editor/index.ts @@ -146,7 +146,7 @@ export class Editor implements DiffsEditor { ? getHighlighterIfLoaded() : undefined; this.#onChange = onChange; - return this.cleanUp.bind(this); + return () => this.cleanUp(); } setSelections(selections: EditorSelection[], resetTextarea = true): void { From dbbc2b14eb04fedf755dd760fb28d8534014d2b9 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Tue, 5 May 2026 02:15:04 +0800 Subject: [PATCH 064/138] typo --- packages/diffs/src/editor/index.ts | 11 +++++------ packages/diffs/src/editor/tokenzier.ts | 8 ++++---- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/packages/diffs/src/editor/index.ts b/packages/diffs/src/editor/index.ts index a592b9cc4..bc9763194 100644 --- a/packages/diffs/src/editor/index.ts +++ b/packages/diffs/src/editor/index.ts @@ -59,7 +59,7 @@ import { type TextareaSnapshot, toTextareaSelectionDirection, } from './editorTextarea'; -import { BackgroundTokenzier, tokenizeLine } from './tokenzier'; +import { BackgroundTokenizer, tokenizeLine } from './tokenzier'; export class Editor implements DiffsEditor { #onChange?: ( @@ -84,7 +84,7 @@ export class Editor implements DiffsEditor { #highlighter?: DiffsHighlighter; #colorMap?: Map; #renderRange?: RenderRange; - #backgroundTokenzier?: BackgroundTokenzier; + #backgroundTokenizer?: BackgroundTokenizer; // cache #stateStackCache?: StateStack[]; @@ -444,7 +444,7 @@ export class Editor implements DiffsEditor { #rerender() { // cancel existing background tokenzier task - this.#backgroundTokenzier?.cancelBackgroundTask(); + this.#backgroundTokenizer?.cancelBackgroundTask(); const contentEl = this.#contentEl; const highlighter = this.#highlighter; @@ -618,7 +618,7 @@ export class Editor implements DiffsEditor { if (!settled && line < textDocument.lineCount) { requestAnimationFrame(() => { - this.#backgroundTokenzier = new BackgroundTokenzier({ + this.#backgroundTokenizer = new BackgroundTokenizer({ grammar, colorMap, textDocument, @@ -626,7 +626,7 @@ export class Editor implements DiffsEditor { file.emitTokenize(result.lines); }, }); - this.#backgroundTokenzier.scheduleTokenize(line, state); + this.#backgroundTokenizer.scheduleTokenize(line, state); }); } @@ -764,7 +764,6 @@ export class Editor implements DiffsEditor { const fileContents = this.#fileContents; const textDocument = this.#textDocument; const onChange = this.#onChange; - console.log('emitChange:', fileContents, textDocument, onChange); if ( fileContents !== undefined && textDocument !== undefined && diff --git a/packages/diffs/src/editor/tokenzier.ts b/packages/diffs/src/editor/tokenzier.ts index 7ec8d6e7f..f6f130ca2 100644 --- a/packages/diffs/src/editor/tokenzier.ts +++ b/packages/diffs/src/editor/tokenzier.ts @@ -12,7 +12,7 @@ import { } from './constants'; import type { TextDocument } from './textDocument'; -export interface BackgroundTokenzierOptions { +export interface BackgroundTokenizerOptions { grammar: IGrammar; colorMap: { dark: string[]; light: string[] }; textDocument: TextDocument; @@ -20,8 +20,8 @@ export interface BackgroundTokenzierOptions { linesPreTokenize?: number; // default to 50 } -/** Stopable background tokenzier */ -export class BackgroundTokenzier { +/** Stopable background tokenizer */ +export class BackgroundTokenizer { #grammar: IGrammar; #colorMap: { dark: string[]; light: string[] }; #textDocument: TextDocument; @@ -38,7 +38,7 @@ export class BackgroundTokenzier { textDocument, onTokenize, linesPreTokenize = TOKENIZE_LINES_PRE_TOKENIZE, - }: BackgroundTokenzierOptions) { + }: BackgroundTokenizerOptions) { this.#grammar = grammar; this.#colorMap = colorMap; this.#textDocument = textDocument; From a187ff8e15bddf611ad8f967368b7f3eea398751 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Tue, 5 May 2026 14:29:05 +0800 Subject: [PATCH 065/138] Refactor BackgroundTokenizer to use message-based scheduling. --- packages/diffs/src/editor/tokenzier.ts | 58 ++++++++++++++------------ 1 file changed, 32 insertions(+), 26 deletions(-) diff --git a/packages/diffs/src/editor/tokenzier.ts b/packages/diffs/src/editor/tokenzier.ts index f6f130ca2..3f7dab698 100644 --- a/packages/diffs/src/editor/tokenzier.ts +++ b/packages/diffs/src/editor/tokenzier.ts @@ -20,60 +20,67 @@ export interface BackgroundTokenizerOptions { linesPreTokenize?: number; // default to 50 } -/** Stopable background tokenizer */ +/** Stoppable background tokenizer */ export class BackgroundTokenizer { #grammar: IGrammar; #colorMap: { dark: string[]; light: string[] }; #textDocument: TextDocument; + #messageKey: string; + #onMessage: (event: MessageEvent) => void; #onTokenize: (result: { lines: Map>; }) => void; - #linesPreTokenize: number; + + // state #isFinished: boolean = true; - #nextFrameId: number | null = null; + #lastLine: number = -1; + #lastState: StateStack | null = null; constructor({ grammar, colorMap, textDocument, onTokenize, - linesPreTokenize = TOKENIZE_LINES_PRE_TOKENIZE, + linesPreTokenize, }: BackgroundTokenizerOptions) { this.#grammar = grammar; this.#colorMap = colorMap; this.#textDocument = textDocument; this.#onTokenize = onTokenize; - this.#linesPreTokenize = linesPreTokenize; + this.#onMessage = ({ data }: MessageEvent) => { + if (data === this.#messageKey) { + this.#doTokenize(linesPreTokenize); + } + }; + this.#messageKey = 'tokenize-' + Date.now().toString(16); + addEventListener('message', this.#onMessage); } scheduleTokenize(startLine: number, state: StateStack): void { this.#isFinished = false; - this.#nextFrameId = requestAnimationFrame(() => { - this.#doTokenize(startLine, state); - }); + this.#lastLine = startLine; + this.#lastState = state; + postMessage(this.#messageKey); } cancelBackgroundTask(): void { - if (this.#nextFrameId !== null) { - cancelAnimationFrame(this.#nextFrameId); - this.#nextFrameId = null; - } + removeEventListener('message', this.#onMessage); this.#isFinished = true; + this.#lastLine = -1; + this.#lastState = null; } - #doTokenize(startLine: number, state: StateStack): void { - this.#nextFrameId = null; - if (this.#isFinished) { + #doTokenize(linesPreTokenize: number = TOKENIZE_LINES_PRE_TOKENIZE): void { + if (this.#isFinished || this.#lastState === null) { return; } const lines = new Map>(); - const endLine = Math.min( - startLine + this.#linesPreTokenize, - this.#textDocument.lineCount - ); + const totalLines = this.#textDocument.lineCount; + const endLine = Math.min(this.#lastLine + linesPreTokenize, totalLines); - let line = startLine; + let line = this.#lastLine; + let state = this.#lastState; for (; line < endLine; line++) { const lineText = this.#textDocument.getLineText(line); if (lineText.length > TOKENIZE_MAX_LINE_LENGTH) { @@ -101,15 +108,14 @@ export class BackgroundTokenizer { } this.#onTokenize({ lines }); - if (line >= this.#textDocument.lineCount) { - this.#isFinished = true; + if (line >= totalLines) { + this.cancelBackgroundTask(); return; } - // schedule the next tokenize - this.#nextFrameId = requestAnimationFrame(() => { - this.#doTokenize(line, state); - }); + this.#lastLine = line; + this.#lastState = state; + postMessage(this.#messageKey); } } From d69e59bce518dea6d083462eee9ecad16a498783 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Tue, 5 May 2026 14:40:46 +0800 Subject: [PATCH 066/138] Refactor editor focus handling by removing redundant event listeners and updating CSS selectors for caret visibility. --- packages/diffs/src/editor/constants.ts | 4 ++-- packages/diffs/src/editor/index.ts | 8 -------- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/packages/diffs/src/editor/constants.ts b/packages/diffs/src/editor/constants.ts index c0e3d8cfc..997c775bd 100644 --- a/packages/diffs/src/editor/constants.ts +++ b/packages/diffs/src/editor/constants.ts @@ -35,7 +35,6 @@ export const EDITOR_CSS = /* CSS */ ` padding: 0; padding-inline: 1ch; color: transparent; - color: transparent; background-color: transparent; border: none; outline: none; @@ -59,7 +58,8 @@ export const EDITOR_CSS = /* CSS */ ` visibility: hidden; z-index: 0; } - [data-textarea][data-state='focus'] ~ [data-caret] { + [data-file]:focus [data-caret], + [data-textarea]:focus ~ [data-caret] { visibility: visible; } [data-line-highlight] { diff --git a/packages/diffs/src/editor/index.ts b/packages/diffs/src/editor/index.ts index bc9763194..73cc3eeec 100644 --- a/packages/diffs/src/editor/index.ts +++ b/packages/diffs/src/editor/index.ts @@ -377,14 +377,6 @@ export class Editor implements DiffsEditor { this.#selectionEndY = e.clientY; }), - addEventListener(this.#textareaEl, 'focus', () => { - this.#textareaEl!.dataset.state = 'focus'; - }), - - addEventListener(this.#textareaEl, 'blur', () => { - this.#textareaEl!.dataset.state = 'blur'; - }), - addEventListener(this.#textareaEl, 'keydown', (e) => { const command = resolveEditorCommandFromKeyboardEvent(e); if (command !== undefined) { From 24f899aef2b98eb34633494fc80404250382088f Mon Sep 17 00:00:00 2001 From: Je Xia Date: Tue, 5 May 2026 17:35:51 +0800 Subject: [PATCH 067/138] Refactor --- packages/diffs/src/editor/constants.ts | 13 +- packages/diffs/src/editor/index.ts | 177 +++++++++---------------- packages/diffs/src/editor/tokenzier.ts | 12 +- 3 files changed, 77 insertions(+), 125 deletions(-) diff --git a/packages/diffs/src/editor/constants.ts b/packages/diffs/src/editor/constants.ts index 997c775bd..3a08a1918 100644 --- a/packages/diffs/src/editor/constants.ts +++ b/packages/diffs/src/editor/constants.ts @@ -12,20 +12,21 @@ export const EDITOR_CSS = /* CSS */ ` 100% { opacity: 1; } } [data-line] { - background-color: transparent; cursor: text; } + [data-line]:not([data-selected-line]) { + background-color: transparent; + } [data-gutter], [data-line-annotation] { user-select: none; } [data-content] { position: relative; } - [data-textarea], [data-caret], [data-line-highlight], [data-selection-range] { + [data-textarea], [data-caret], [data-selection-range] { position: absolute; top: 0; left: 0; - z-index: -10; height: 1lh; line-height: var(--diffs-line-height); pointer-events: none; @@ -56,17 +57,13 @@ export const EDITOR_CSS = /* CSS */ ` animation: blinking 1.2s infinite; animation-delay: 0.6s; visibility: hidden; - z-index: 0; } [data-file]:focus [data-caret], [data-textarea]:focus ~ [data-caret] { visibility: visible; } - [data-line-highlight] { - width: 100%; - background-color: var(--diffs-bg-selection); - } [data-selection-range] { + z-index: -10; background-color: var(--diffs-bg-selection); } `; diff --git a/packages/diffs/src/editor/index.ts b/packages/diffs/src/editor/index.ts index 73cc3eeec..68d634c81 100644 --- a/packages/diffs/src/editor/index.ts +++ b/packages/diffs/src/editor/index.ts @@ -62,14 +62,13 @@ import { import { BackgroundTokenizer, tokenizeLine } from './tokenzier'; export class Editor implements DiffsEditor { + #disposes?: (() => void)[]; #onChange?: ( file: FileContents, lineAnnotations?: LineAnnotation[] ) => void; - #disposes?: (() => void)[]; // css properties - #measureCtx?: CanvasRenderingContext2D; #charWidth = -1; #lineHeight = 20; #tabSize = 2; @@ -90,11 +89,13 @@ export class Editor implements DiffsEditor { #stateStackCache?: StateStack[]; #lineYCache = new Map(); #lastCharX?: [line: number, character: number, x: number]; + // dom elements #contentEl?: HTMLElement; #styleEl?: HTMLStyleElement; #textareaEl?: HTMLTextAreaElement; #selectionEls?: Map; + #measureCtx?: CanvasRenderingContext2D; // state #selectionStartX = 0; @@ -169,7 +170,7 @@ export class Editor implements DiffsEditor { Math.max(0, primarySelection.start.line - 1) !== this.#textareaSnapshot?.startLine; this.#selections = selections; - this.#renderSelections(selections, primarySelection); + this.#renderSelections(selections); if (shouldUpdateTextarea) { this.#updateTextarea(primarySelection); } else if ( @@ -195,19 +196,20 @@ export class Editor implements DiffsEditor { } cleanUp(): void { - this.#onChange = undefined; this.#disposes?.forEach((dispose) => dispose()); this.#disposes = undefined; - - this.#measureCtx = undefined; + this.#onChange = undefined; this.#file = undefined; this.#fileContents = undefined; + this.#lineAnnotations = undefined; this.#textDocument = undefined; this.#highlighter = undefined; this.#colorMap = undefined; this.#renderRange = undefined; + this.#backgroundTokenizer?.stop(); + this.#backgroundTokenizer = undefined; this.#stateStackCache = undefined; this.#lineYCache.clear(); @@ -221,6 +223,7 @@ export class Editor implements DiffsEditor { this.#selectionEls?.forEach((el) => el.remove()); this.#selectionEls?.clear(); this.#selectionEls = undefined; + this.#measureCtx = undefined; this.#shouldIgnoreSelectionChange = false; this.#textareaSnapshot = undefined; @@ -234,35 +237,53 @@ export class Editor implements DiffsEditor { lineAnnotations: LineAnnotation[] | undefined, renderRange: RenderRange | undefined ): void { + const shadowRoot = + fileContainer.shadowRoot ?? fileContainer.attachShadow({ mode: 'open' }); + this.#contentEl = shadowRoot.querySelector('[data-content]') ?? undefined; + if (this.#contentEl === undefined) { + throw new Error('could not edit the file.'); + } + + // measure the font width, line height, and tab size + const { fontFamily, fontSize, lineHeight, tabSize } = getComputedStyle( + this.#contentEl + ); + this.#tabSize = Number(tabSize); + this.#measureCtx ??= + document.createElement('canvas').getContext('2d') ?? undefined; + if (this.#measureCtx !== undefined) { + this.#measureCtx.font = fontSize + ' ' + fontFamily; + this.#charWidth = + Math.round(this.#measureCtx.measureText('0').width * 1000) / 1000; + } + if (lineHeight.endsWith('px')) { + this.#lineHeight = Number(lineHeight.slice(0, -2)); + } else if (fontSize.endsWith('px')) { + this.#lineHeight = + Number(fontSize.slice(0, -2)) * Number(lineHeight.slice(0, -2)); + } + if ( this.#textDocument === undefined || this.#fileContents === undefined || this.#fileContents.contents !== fileContents.contents || this.#fileContents.lang !== fileContents.lang ) { + if (this.#textDocument !== undefined) { + this.cleanUp(); + } this.#fileContents = fileContents; this.#textDocument = new TextDocument( fileContents.name, fileContents.contents, fileContents.lang ?? getFiletypeFromFileName(fileContents.name) ); - this.#stateStackCache = undefined; - this.#selections = undefined; } this.#lineAnnotations = lineAnnotations; this.#renderRange = renderRange; this.#prebuildStateStackCache(); - const shadowRoot = - fileContainer.shadowRoot ?? fileContainer.attachShadow({ mode: 'open' }); - - this.#contentEl = shadowRoot.querySelector('[data-content]') ?? undefined; - if (this.#contentEl === undefined) { - throw new Error('could not edit the file.'); - } - - // TODO(@ije): place the textarea inside the pre (as a child). this.#textareaEl ??= extend( createElement('textarea', { dataset: 'textarea' }), { @@ -398,16 +419,14 @@ export class Editor implements DiffsEditor { this.#lastCharX = undefined; if (this.#selections !== undefined) { - this.#selectionEls?.forEach((el) => el.remove()); - this.#selectionEls?.clear(); + // this.#selectionEls?.forEach((el) => el.remove()); + // this.#selectionEls?.clear(); this.setSelections(this.#selections); this.#textareaEl.focus(); } - this.#getCSSProperites(); - console.log( - 'Editor initialized.', + '[triggerEdit]', 'renderRange:', (renderRange?.startingLine ?? 0) + '-' + @@ -436,7 +455,7 @@ export class Editor implements DiffsEditor { #rerender() { // cancel existing background tokenzier task - this.#backgroundTokenizer?.cancelBackgroundTask(); + this.#backgroundTokenizer?.stop(); const contentEl = this.#contentEl; const highlighter = this.#highlighter; @@ -530,8 +549,8 @@ export class Editor implements DiffsEditor { if (dirtyLineIndexes.size === 0) { break; } - const child = children[i] as HTMLElement; - if (child.dataset.lineIndex !== undefined) { + const child = children[i] as HTMLElement | undefined; + if (child?.dataset.lineIndex !== undefined) { const lineIndex = Number(child.dataset.lineIndex); if (dirtyLines.has(lineIndex)) { const tokens = dirtyLines.get(lineIndex)!; @@ -628,7 +647,7 @@ export class Editor implements DiffsEditor { lastChange, 'dirtyLines:', dirtyLines.size, - settled ? '(are settled)' : '' + settled ? '(settled)' : '' ); } @@ -799,29 +818,9 @@ export class Editor implements DiffsEditor { }, 0); } - // Check whether a selection overlaps the currently rendered line window. - #isSelectionVisible(selection: EditorSelection): boolean { - if (this.#renderRange === undefined) { - return true; - } - const { start, end } = selection; - const { startingLine, totalLines } = this.#renderRange; - if (totalLines === Infinity) { - return end.line >= startingLine; - } - const endLine = startingLine + totalLines; - return start.line < endLine && end.line >= startingLine; - } - - #renderSelections( - selections: EditorSelection[], - primarySelection: EditorSelection - ) { + #renderSelections(selections: EditorSelection[]) { const fragment = document.createDocumentFragment(); const cacheMap = new Map(); - if (isCollapsedSelection(primarySelection)) { - this.#renderLineHighlight(primarySelection, fragment, cacheMap); - } selections.forEach((selection) => { if (selections.length > 1 || !isCollapsedSelection(selection)) { this.#renderSelectionRange(selection, fragment, cacheMap); @@ -834,45 +833,12 @@ export class Editor implements DiffsEditor { this.#selectionEls = cacheMap; } - #renderLineHighlight( - selection: EditorSelection, - fragment: DocumentFragment, - cacheMap: Map - ) { - if (!this.#isSelectionVisible(selection)) { - return; - } - - const cacheKey = `lineHighlight-${selection.start.line}`; - if (this.#selectionEls?.has(cacheKey) === true) { - const el = this.#selectionEls.get(cacheKey)!; - this.#selectionEls.delete(cacheKey); - cacheMap.set(cacheKey, el); - return; - } - - const hlEl = createElement( - 'div', - { - dataset: 'lineHighlight', - style: { - transform: `translateY(${this.#getLineY(selection.start.line)}px)`, - }, - }, - fragment - ); - cacheMap.set(cacheKey, hlEl); - } - #renderSelectionRange( selection: EditorSelection, fragment: DocumentFragment, cacheMap: Map ) { - if ( - this.#textDocument === undefined || - !this.#isSelectionVisible(selection) - ) { + if (this.#textDocument === undefined) { return; } @@ -880,6 +846,9 @@ export class Editor implements DiffsEditor { const selectionEls = this.#selectionEls; for (let ln = start.line; ln <= end.line; ln++) { + if (!this.#isLineVisible(ln)) { + continue; + } const lineText = this.#textDocument.getLineText(ln); const lineLength = lineText.length; const startChar = ln === start.line ? start.character : 0; @@ -936,7 +905,7 @@ export class Editor implements DiffsEditor { fragment: DocumentFragment, cacheMap: Map ) { - if (!this.#isSelectionVisible(selection)) { + if (!this.#isLineVisible(selection.start.line)) { return; } @@ -958,6 +927,21 @@ export class Editor implements DiffsEditor { cacheMap.set('caret-' + line + '-' + character, caretEl); } + // Check whether a line is visible in the currently rendered line window. + #isLineVisible(line: number): boolean { + if (this.#renderRange === undefined) { + return true; + } + const { startingLine, totalLines } = this.#renderRange; + if (line < startingLine) { + return false; + } + if (totalLines === Infinity) { + return true; + } + return line < startingLine + totalLines; + } + async #runCommand(command: EditorCommand) { switch (command) { case 'selectAll': @@ -1233,35 +1217,6 @@ export class Editor implements DiffsEditor { return this.#measureCtx.measureText(textWithExpandedTabs).width; } - #getCSSProperites() { - if (this.#contentEl === undefined) { - return; - } - - const { fontFamily, fontSize, lineHeight, tabSize } = getComputedStyle( - this.#contentEl - ); - - const el = document.createElement('canvas'); - const ctx = el.getContext('2d'); - if (ctx !== null) { - ctx.font = fontSize + ' ' + fontFamily; - this.#measureCtx = ctx; - this.#charWidth = Math.round(ctx.measureText('0').width * 1000) / 1000; - } else { - this.#measureCtx = undefined; - } - - if (lineHeight.endsWith('px')) { - this.#lineHeight = Number(lineHeight.slice(0, -2)); - } else if (fontSize.endsWith('px')) { - this.#lineHeight = - Number(fontSize.slice(0, -2)) * Number(lineHeight.slice(0, -2)); - } - - this.#tabSize = Number(tabSize); - } - // check if the web selection belongs to editor #selectionBelongsToEditor(composedRanges: StaticRange[]) { const contentEl = this.#contentEl; diff --git a/packages/diffs/src/editor/tokenzier.ts b/packages/diffs/src/editor/tokenzier.ts index 3f7dab698..3f7939c48 100644 --- a/packages/diffs/src/editor/tokenzier.ts +++ b/packages/diffs/src/editor/tokenzier.ts @@ -32,7 +32,7 @@ export class BackgroundTokenizer { }) => void; // state - #isFinished: boolean = true; + #isStopped: boolean = true; #lastLine: number = -1; #lastState: StateStack | null = null; @@ -57,21 +57,21 @@ export class BackgroundTokenizer { } scheduleTokenize(startLine: number, state: StateStack): void { - this.#isFinished = false; + this.#isStopped = false; this.#lastLine = startLine; this.#lastState = state; postMessage(this.#messageKey); } - cancelBackgroundTask(): void { + stop(): void { removeEventListener('message', this.#onMessage); - this.#isFinished = true; + this.#isStopped = true; this.#lastLine = -1; this.#lastState = null; } #doTokenize(linesPreTokenize: number = TOKENIZE_LINES_PRE_TOKENIZE): void { - if (this.#isFinished || this.#lastState === null) { + if (this.#isStopped || this.#lastState === null) { return; } @@ -109,7 +109,7 @@ export class BackgroundTokenizer { this.#onTokenize({ lines }); if (line >= totalLines) { - this.cancelBackgroundTask(); + this.stop(); return; } From 4634c3d77668bbb96995bed1bb66b18eec2a25af Mon Sep 17 00:00:00 2001 From: Je Xia Date: Tue, 5 May 2026 20:22:18 +0800 Subject: [PATCH 068/138] Fix `toTextareaSelectionDirection` function --- packages/diffs/src/editor/editorTextarea.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/diffs/src/editor/editorTextarea.ts b/packages/diffs/src/editor/editorTextarea.ts index dcb6031df..040a334ec 100644 --- a/packages/diffs/src/editor/editorTextarea.ts +++ b/packages/diffs/src/editor/editorTextarea.ts @@ -131,9 +131,9 @@ export function toTextareaSelectionDirection( ): HTMLTextAreaElement['selectionDirection'] { switch (selection.direction) { case SelectionDirection.Backward: - return 'backward'; - case SelectionDirection.Forward: return 'forward'; + case SelectionDirection.Forward: + return 'backward'; case SelectionDirection.None: return 'none'; } From 229e21e23449f71828275e21d185399542045a6e Mon Sep 17 00:00:00 2001 From: Je Xia Date: Tue, 5 May 2026 20:25:36 +0800 Subject: [PATCH 069/138] Refactor --- packages/diffs/src/editor/editorUtils.ts | 4 +++ packages/diffs/src/editor/index.ts | 42 +++++++++++++++--------- 2 files changed, 30 insertions(+), 16 deletions(-) diff --git a/packages/diffs/src/editor/editorUtils.ts b/packages/diffs/src/editor/editorUtils.ts index ba99e3642..217156d62 100644 --- a/packages/diffs/src/editor/editorUtils.ts +++ b/packages/diffs/src/editor/editorUtils.ts @@ -124,3 +124,7 @@ export function debounce void>( timeout = setTimeout(() => func.apply(this, args), wait); }; } + +export function round(value: number, precision: number = 1000): number { + return Math.round(value * precision) / precision; +} diff --git a/packages/diffs/src/editor/index.ts b/packages/diffs/src/editor/index.ts index 68d634c81..ccc381c14 100644 --- a/packages/diffs/src/editor/index.ts +++ b/packages/diffs/src/editor/index.ts @@ -33,6 +33,7 @@ import { debounce, extend, isCodeLineTarget, + round, } from '../editor/editorUtils'; import { type ResolvedTextEdit, @@ -241,26 +242,40 @@ export class Editor implements DiffsEditor { fileContainer.shadowRoot ?? fileContainer.attachShadow({ mode: 'open' }); this.#contentEl = shadowRoot.querySelector('[data-content]') ?? undefined; if (this.#contentEl === undefined) { - throw new Error('could not edit the file.'); + throw new Error('Could not edit the file.'); } // measure the font width, line height, and tab size - const { fontFamily, fontSize, lineHeight, tabSize } = getComputedStyle( + // purge the lineY cache if the line height or line annotations change + const { lineHeight, fontSize, fontFamily, tabSize } = getComputedStyle( this.#contentEl ); + let lineHeighPx = 20; + if (lineHeight.endsWith('px')) { + lineHeighPx = Number(lineHeight.slice(0, -2)); + } else if (fontSize.endsWith('px')) { + lineHeighPx = round( + Number(fontSize.slice(0, -2)) * Number(lineHeight.slice(0, -2)) + ); + } + if ( + lineHeighPx !== this.#lineHeight || + lineAnnotations !== this.#lineAnnotations + ) { + this.#lineYCache.clear(); + } + this.#lastCharX = undefined; + this.#lineHeight = lineHeighPx; this.#tabSize = Number(tabSize); this.#measureCtx ??= document.createElement('canvas').getContext('2d') ?? undefined; - if (this.#measureCtx !== undefined) { + if ( + this.#measureCtx !== undefined && + (this.#measureCtx.font !== fontSize + ' ' + fontFamily || + this.#charWidth === -1) + ) { this.#measureCtx.font = fontSize + ' ' + fontFamily; - this.#charWidth = - Math.round(this.#measureCtx.measureText('0').width * 1000) / 1000; - } - if (lineHeight.endsWith('px')) { - this.#lineHeight = Number(lineHeight.slice(0, -2)); - } else if (fontSize.endsWith('px')) { - this.#lineHeight = - Number(fontSize.slice(0, -2)) * Number(lineHeight.slice(0, -2)); + this.#charWidth = round(this.#measureCtx.measureText('0').width); } if ( @@ -415,12 +430,7 @@ export class Editor implements DiffsEditor { }), ]; - this.#lineYCache.clear(); - this.#lastCharX = undefined; - if (this.#selections !== undefined) { - // this.#selectionEls?.forEach((el) => el.remove()); - // this.#selectionEls?.clear(); this.setSelections(this.#selections); this.#textareaEl.focus(); } From febadf7f7e1280ba73c5f9d0548bcaf459e1cdca Mon Sep 17 00:00:00 2001 From: Je Xia Date: Tue, 5 May 2026 22:28:33 +0800 Subject: [PATCH 070/138] Update `DiffsEditor` types --- packages/diffs/src/components/File.ts | 14 +++++++++----- packages/diffs/src/types.ts | 1 + 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/diffs/src/components/File.ts b/packages/diffs/src/components/File.ts index 75b452274..b2e307356 100644 --- a/packages/diffs/src/components/File.ts +++ b/packages/diffs/src/components/File.ts @@ -154,6 +154,8 @@ export class File { protected file: FileContents | undefined; protected renderRange: RenderRange | undefined; + protected editor: DiffsEditor | undefined; + constructor( public options: FileOptions = { theme: DEFAULT_THEMES }, private workerManager?: WorkerPoolManager | undefined, @@ -172,9 +174,7 @@ export class File { this.workerManager?.subscribeToThemeChanges(this); } - private __editor: DiffsEditor | undefined; - - public __setEditor(editor: DiffsEditor): void { + public setEditor(editor: DiffsEditor): void { if (this.fileContainer != null && this.file != null) { editor.triggerEdit( this.fileContainer, @@ -183,7 +183,7 @@ export class File { this.renderRange ); } - this.__editor = editor; + this.editor = editor; } private handleHighlightRender = (): void => { @@ -278,6 +278,10 @@ export class File { this.unsafeCSSStyle = undefined; this.appliedUnsafeCSS = undefined; this.placeHolder = undefined; + + // Clean up the editor + this.editor?.cleanUp(); + this.editor = undefined; } public hydrate(props: FileHydrateProps): void { @@ -576,7 +580,7 @@ export class File { this.resizeManager.setup(pre, overflow === 'wrap'); this.renderAnnotations(); this.renderGutterUtility(); - this.__editor?.triggerEdit( + this.editor?.triggerEdit( fileContainer, file, this.lineAnnotations, diff --git a/packages/diffs/src/types.ts b/packages/diffs/src/types.ts index 36eedfbb7..082d57680 100644 --- a/packages/diffs/src/types.ts +++ b/packages/diffs/src/types.ts @@ -750,4 +750,5 @@ export interface DiffsEditor { lineAnnotations: LineAnnotation[] | undefined, renderRange: RenderRange | undefined ): void; + cleanUp(): void; } From 1002da8ce7ca849bfa17b0cfd6cc427644bd9686 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Tue, 5 May 2026 23:15:20 +0800 Subject: [PATCH 071/138] Add line annotation handling --- packages/diffs/src/components/File.ts | 37 ++++---- packages/diffs/src/editor/editStack.ts | 17 +++- .../diffs/src/editor/editorLineAnnotations.ts | 56 +++++++++++ .../diffs/src/editor/editorMultiSelections.ts | 56 ++++++++--- packages/diffs/src/editor/index.ts | 92 +++++++++++-------- packages/diffs/src/editor/textDocument.ts | 52 ++++++++--- packages/diffs/src/index.ts | 1 + packages/diffs/src/renderers/FileRenderer.ts | 4 +- .../src/utils/hasVisibleLineAnnotation.ts | 20 ++++ .../diffs/test/editorLineAnnotations.test.ts | 90 ++++++++++++++++++ .../diffs/test/editorMultiSelections.test.ts | 45 +++++++-- .../test/hasVisibleLineAnnotation.test.ts | 50 ++++++++++ packages/diffs/test/textDocument.test.ts | 8 +- 13 files changed, 438 insertions(+), 90 deletions(-) create mode 100644 packages/diffs/src/editor/editorLineAnnotations.ts create mode 100644 packages/diffs/src/utils/hasVisibleLineAnnotation.ts create mode 100644 packages/diffs/test/editorLineAnnotations.test.ts create mode 100644 packages/diffs/test/hasVisibleLineAnnotation.test.ts diff --git a/packages/diffs/src/components/File.ts b/packages/diffs/src/components/File.ts index b2e307356..a4446d5ce 100644 --- a/packages/diffs/src/components/File.ts +++ b/packages/diffs/src/components/File.ts @@ -43,6 +43,7 @@ import { createUnsafeCSSStyleNode } from '../utils/createUnsafeCSSStyleNode'; import { wrapThemeCSS, wrapUnsafeCSS } from '../utils/cssWrappers'; import { getLineAnnotationName } from '../utils/getLineAnnotationName'; import { getOrCreateCodeNode } from '../utils/getOrCreateCodeNode'; +import { hasVisibleLineAnnotation } from '../utils/hasVisibleLineAnnotation'; import { upsertHostThemeStyle } from '../utils/hostTheme'; import { prerenderHTMLIfNecessary } from '../utils/prerenderHTMLIfNecessary'; import { setPreNodeProperties } from '../utils/setWrapperNodeProps'; @@ -391,29 +392,23 @@ export class File { } public emitLineAnnotationsChange( - lineAnnotations: LineAnnotation[] + newLineAnnotations: LineAnnotation[] ): void { + const previousLineAnnotations = this.lineAnnotations; const renderRange = this.renderRange; const result = this.fileRenderer.emitLineAnnotationsChange( - lineAnnotations, + newLineAnnotations, this.renderRange ); - // check if the new lineAnnotations are in the renderRange, - // if it is, skip the re-render - let isVisible = false; - if (renderRange != null) { - const { startingLine, totalLines } = renderRange; - const endLine = - totalLines === Infinity - ? this.getLineCount() - : startingLine + totalLines; - isVisible = lineAnnotations.some( - (annotation) => - annotation.lineNumber >= startingLine && - annotation.lineNumber < endLine - ); + for (const { element } of this.annotationCache.values()) { + element.remove(); } - if (result != null && this.code != null && isVisible) { + this.annotationCache.clear(); + this.lineAnnotations = newLineAnnotations; + const hasVisibleAnnotationChange = + hasVisibleLineAnnotation(previousLineAnnotations, renderRange) || + hasVisibleLineAnnotation(newLineAnnotations, renderRange); + if (result != null && this.code != null && hasVisibleAnnotationChange) { const { gutterAST, contentAST, rowCount } = result; const columns = this.getColumns(this.code); if (columns != null) { @@ -424,6 +419,14 @@ export class File { columns.content.style.gridRow = `span ${rowCount}`; columns.gutter.style.gridRow = `span ${rowCount}`; this.renderAnnotations(); + if (this.fileContainer != null && this.file != null) { + this.editor?.triggerEdit( + this.fileContainer, + this.file, + this.lineAnnotations, + this.renderRange + ); + } } } } diff --git a/packages/diffs/src/editor/editStack.ts b/packages/diffs/src/editor/editStack.ts index 1277b25ef..097d76a75 100644 --- a/packages/diffs/src/editor/editStack.ts +++ b/packages/diffs/src/editor/editStack.ts @@ -21,6 +21,10 @@ interface EditStackEntry { selectionsBefore: EditorSelection[]; /** Selection after the transaction (restored on redo). */ selectionsAfter?: EditorSelection[]; + /** Line annotations before the transaction (restored on undo). */ + lineAnnotationsBefore?: unknown[]; + /** Line annotations after the transaction (restored on redo). */ + lineAnnotationsAfter?: unknown[]; } export interface EditStackOptions { @@ -58,7 +62,9 @@ export class EditStack { versionBefore: number, versionAfter: number, selectionsBefore: EditorSelection[], - selectionsAfter?: EditorSelection[] + selectionsAfter?: EditorSelection[], + lineAnnotationsBefore?: unknown[], + lineAnnotationsAfter?: unknown[] ): void { const forwardEdits = [...resolvedEdits].sort((a, b) => a.start - b.start); const inverseEdits = buildInverseOffsetEdits(source, forwardEdits); @@ -71,6 +77,8 @@ export class EditStack { ...selection, })), selectionsAfter: selectionsAfter?.map((selection) => ({ ...selection })), + lineAnnotationsBefore: lineAnnotationsBefore?.slice(), + lineAnnotationsAfter: lineAnnotationsAfter?.slice(), }); this.#redoStack.length = 0; if (this.#undoStack.length > this.#maxEntries) { @@ -87,6 +95,13 @@ export class EditStack { } } + setLastUndoLineAnnotationsAfter(lineAnnotations: unknown[]): void { + const lastEntry = this.#undoStack[this.#undoStack.length - 1]; + if (lastEntry !== undefined) { + lastEntry.lineAnnotationsAfter = lineAnnotations.slice(); + } + } + /** Moves the latest undo entry to the redo stack and returns it, or `undefined` if empty. */ popUndoToRedo(): EditStackEntry | void { const entry = this.#undoStack.pop(); diff --git a/packages/diffs/src/editor/editorLineAnnotations.ts b/packages/diffs/src/editor/editorLineAnnotations.ts new file mode 100644 index 000000000..ba7422dd8 --- /dev/null +++ b/packages/diffs/src/editor/editorLineAnnotations.ts @@ -0,0 +1,56 @@ +import type { LineAnnotation } from '../types'; +import type { TextDocumentChange } from './textDocument'; + +// Updates 1-based line annotations after the document has applied an edit, +// returning undefined when no annotation moved or was deleted. +export function applyDocumentChangeToLineAnnotations( + lastChange: TextDocumentChange, + lineAnnotations: readonly LineAnnotation[] +): LineAnnotation[] | undefined { + if (lastChange.lineDelta === 0) { + return undefined; + } + + const startCharacter = lastChange.startCharacter ?? 0; + const removedLineCount = Math.max(0, -lastChange.lineDelta); + const deletedStartLine = + removedLineCount === 0 + ? undefined + : lastChange.startLine + (startCharacter === 0 ? 0 : 1); + const deletedEndLine = + deletedStartLine === undefined + ? undefined + : deletedStartLine + removedLineCount; + const shiftFromLine = + removedLineCount > 0 + ? lastChange.startLine + removedLineCount + : lastChange.startLine + (startCharacter === 0 ? 0 : 1); + const nextLineAnnotations: LineAnnotation[] = []; + let changed = false; + + for (const annotation of lineAnnotations) { + const line = annotation.lineNumber - 1; + if ( + deletedStartLine !== undefined && + deletedEndLine !== undefined && + line >= deletedStartLine && + line < deletedEndLine + ) { + changed = true; + continue; + } + + if (line >= shiftFromLine) { + nextLineAnnotations.push({ + ...annotation, + lineNumber: line + lastChange.lineDelta + 1, + }); + changed = true; + continue; + } + + nextLineAnnotations.push(annotation); + } + + return changed ? nextLineAnnotations : undefined; +} diff --git a/packages/diffs/src/editor/editorMultiSelections.ts b/packages/diffs/src/editor/editorMultiSelections.ts index dec6d5673..0e7c8744f 100644 --- a/packages/diffs/src/editor/editorMultiSelections.ts +++ b/packages/diffs/src/editor/editorMultiSelections.ts @@ -1,3 +1,5 @@ +import type { LineAnnotation } from '../types'; +import { applyDocumentChangeToLineAnnotations } from './editorLineAnnotations'; import { type EditorSelection, SelectionDirection } from './editorSelection'; import { type Position, @@ -74,14 +76,18 @@ export function mapSelectionRangeMove( }); } -export function applyTextChangeToSelections( +export function applyTextChangeToSelections( textDocument: TextDocument, selections: EditorSelection[], - change: ResolvedTextEdit -): EditorSelection[] { + change: ResolvedTextEdit, + lineAnnotations?: LineAnnotation[] +): { + nextSelections: EditorSelection[]; + newLineAnnotations: LineAnnotation[] | undefined; +} { const primarySelection = selections[selections.length - 1]; if (primarySelection === undefined) { - return []; + return { nextSelections: [], newLineAnnotations: undefined }; } const primaryStartOffset = textDocument.offsetAt(primarySelection.start); const primaryEndOffset = textDocument.offsetAt(primarySelection.end); @@ -155,19 +161,35 @@ export function applyTextChangeToSelections( }; } finalizeMergedGroup(); - textDocument.applyEdits(edits, true, selections); + textDocument.applyEdits(edits, true, selections, undefined, lineAnnotations); const nextSelections = nextSelectionOffsets.map((offsets) => createSelectionFromAnchorAndFocusOffsets(textDocument, ...offsets) ); textDocument.setLastUndoSelectionsAfter(nextSelections); - return nextSelections; + + let newLineAnnotations: LineAnnotation[] | undefined; + if (lineAnnotations !== undefined && textDocument.lastChange !== undefined) { + newLineAnnotations = applyDocumentChangeToLineAnnotations( + textDocument.lastChange, + lineAnnotations + ); + if (newLineAnnotations !== undefined) { + textDocument.setLastUndoLineAnnotationsAfter(newLineAnnotations); + } + } + + return { nextSelections, newLineAnnotations }; } -export function applyTextReplaceToSelections( +export function applyTextReplaceToSelections( textDocument: TextDocument, selections: EditorSelection[], - texts: readonly string[] -): EditorSelection[] { + texts: readonly string[], + lineAnnotations?: LineAnnotation[] +): { + nextSelections: EditorSelection[]; + newLineAnnotations: LineAnnotation[] | undefined; +} { if (selections.length !== texts.length) { throw new Error( 'Selection text replacements must match the selection count' @@ -213,12 +235,24 @@ export function applyTextReplaceToSelections( entry.start + offsetDelta + entry.text.length; offsetDelta += entry.text.length - (entry.end - entry.start); } - textDocument.applyEdits(edits, true, selections); + textDocument.applyEdits(edits, true, selections, undefined, lineAnnotations); const nextSelections = nextSelectionOffsets.map((offset) => createSelectionFromAnchorAndFocusOffsets(textDocument, offset, offset) ); textDocument.setLastUndoSelectionsAfter(nextSelections); - return nextSelections; + + let newLineAnnotations: LineAnnotation[] | undefined; + if (lineAnnotations !== undefined && textDocument.lastChange !== undefined) { + newLineAnnotations = applyDocumentChangeToLineAnnotations( + textDocument.lastChange, + lineAnnotations + ); + if (newLineAnnotations !== undefined) { + textDocument.setLastUndoLineAnnotationsAfter(newLineAnnotations); + } + } + + return { nextSelections, newLineAnnotations }; } export function createSelectionFromAnchorAndFocusOffsets( diff --git a/packages/diffs/src/editor/index.ts b/packages/diffs/src/editor/index.ts index ccc381c14..eb4efd547 100644 --- a/packages/diffs/src/editor/index.ts +++ b/packages/diffs/src/editor/index.ts @@ -35,11 +35,7 @@ import { isCodeLineTarget, round, } from '../editor/editorUtils'; -import { - type ResolvedTextEdit, - TextDocument, - type TextEdit, -} from '../editor/textDocument'; +import { TextDocument, type TextEdit } from '../editor/textDocument'; import type { DiffsEditor, DiffsHighlighter, @@ -140,7 +136,7 @@ export class Editor implements DiffsEditor { lineAnnotations?: LineAnnotation[] ) => void ): () => void { - file.__setEditor(this); + file.setEditor(this); this.#file = file; this.#highlighter ??= areThemesAttached( file.options.theme ?? DEFAULT_THEMES @@ -463,7 +459,7 @@ export class Editor implements DiffsEditor { return SelectionDirection.None; } - #rerender() { + #rerender(newLineAnnotations?: LineAnnotation[] | undefined) { // cancel existing background tokenzier task this.#backgroundTokenizer?.stop(); @@ -636,6 +632,10 @@ export class Editor implements DiffsEditor { if (lastChange.lineDelta !== 0) { file.emitLineCountChange(lastChange.lineCount); } + if (newLineAnnotations !== undefined) { + file.emitLineAnnotationsChange(newLineAnnotations); + this.#lineAnnotations = newLineAnnotations; + } if (!settled && line < textDocument.lineCount) { requestAnimationFrame(() => { @@ -733,7 +733,19 @@ export class Editor implements DiffsEditor { selectionStart, selectionEnd ); - this.#applyTextChange(change); + const lineAnnotations = this.#lineAnnotations; + if (this.#selections !== undefined) { + const { nextSelections, newLineAnnotations } = + applyTextChangeToSelections( + textDocument, + this.#selections, + change, + lineAnnotations + ); + this.#rerender(newLineAnnotations); + this.#emitChange(); + this.setSelections(nextSelections, false); + } } else if (this.#selections !== undefined) { // Selection in the textarea changed, but no text change was made. if (selectionStart === selectionEnd) { @@ -768,19 +780,6 @@ export class Editor implements DiffsEditor { } } - #applyTextChange(change: ResolvedTextEdit) { - if (this.#textDocument !== undefined && this.#selections !== undefined) { - const nextSelections = applyTextChangeToSelections( - this.#textDocument, - this.#selections, - change - ); - this.#rerender(); - this.#emitChange(); - this.setSelections(nextSelections, false); - } - } - #emitChange() { const fileContents = this.#fileContents; const textDocument = this.#textDocument; @@ -1043,22 +1042,30 @@ export class Editor implements DiffsEditor { case 'undo': if (this.#textDocument?.canUndo === true) { - const nextSelections = this.#textDocument.undo(); - this.#rerender(); + const undoResult = this.#textDocument.undo(); + this.#rerender( + undoResult?.lineAnnotations as + | LineAnnotation[] + | undefined + ); this.#emitChange(); - if (nextSelections !== undefined) { - this.setSelections(nextSelections, false); + if (undoResult?.selections !== undefined) { + this.setSelections(undoResult.selections, false); } } break; case 'redo': if (this.#textDocument?.canRedo === true) { - const nextSelections = this.#textDocument.redo(); - this.#rerender(); + const redoResult = this.#textDocument.redo(); + this.#rerender( + redoResult?.lineAnnotations as + | LineAnnotation[] + | undefined + ); this.#emitChange(); - if (nextSelections !== undefined) { - this.setSelections(nextSelections, false); + if (redoResult?.selections !== undefined) { + this.setSelections(redoResult.selections, false); } } break; @@ -1124,14 +1131,25 @@ export class Editor implements DiffsEditor { return; } // todo: normalize text with textDocument.EOF - const nextSelections = Array.isArray(text) - ? applyTextReplaceToSelections(textDocument, selections, text) - : applyTextChangeToSelections(textDocument, selections, { - start: textDocument.offsetAt(primarySelection.start), - end: textDocument.offsetAt(primarySelection.end), - text: text, - }); - this.#rerender(); + const lineAnnotations = this.#lineAnnotations; + const { nextSelections, newLineAnnotations } = Array.isArray(text) + ? applyTextReplaceToSelections( + textDocument, + selections, + text, + lineAnnotations + ) + : applyTextChangeToSelections( + textDocument, + selections, + { + start: textDocument.offsetAt(primarySelection.start), + end: textDocument.offsetAt(primarySelection.end), + text: text, + }, + lineAnnotations + ); + this.#rerender(newLineAnnotations); this.#emitChange(); this.setSelections(nextSelections, false); } diff --git a/packages/diffs/src/editor/textDocument.ts b/packages/diffs/src/editor/textDocument.ts index 4090de01d..cde80ff27 100644 --- a/packages/diffs/src/editor/textDocument.ts +++ b/packages/diffs/src/editor/textDocument.ts @@ -91,6 +91,8 @@ export type ResolvedTextEdit = { export type TextDocumentChange = { /** First line whose rendered content or tokenizer state may have changed. */ readonly startLine: number; + /** Character on the first changed line where the edit began. */ + readonly startCharacter?: number; /** Last line whose rendered content may have changed after the edit. */ readonly endLine: number; /** Line count before the edit was applied. */ @@ -189,7 +191,9 @@ export class TextDocument { edits: TextEdit[], updateHistory = false, selectionsBefore?: EditorSelection[], - selectionsAfter?: EditorSelection[] + selectionsAfter?: EditorSelection[], + lineAnnotationsBefore?: unknown[], + lineAnnotationsAfter?: unknown[] ): void { if (edits.length === 0) { return; @@ -204,7 +208,9 @@ export class TextDocument { this.#version, this.#version + 1, selectionsBefore, - selectionsAfter + selectionsAfter, + lineAnnotationsBefore, + lineAnnotationsAfter ); } this.#lastChange = this.#applyResolvedEdits(resolvedEdits); @@ -215,7 +221,13 @@ export class TextDocument { this.#editStack.setLastUndoSelectionsAfter(selections); } - undo(): EditorSelection[] | undefined { + setLastUndoLineAnnotationsAfter(lineAnnotations: unknown[]): void { + this.#editStack.setLastUndoLineAnnotationsAfter(lineAnnotations); + } + + undo(): + | { selections?: EditorSelection[]; lineAnnotations?: unknown[] } + | undefined { const entry = this.#editStack.popUndoToRedo(); if (entry === undefined) { this.#lastChange = undefined; @@ -223,12 +235,15 @@ export class TextDocument { } this.#lastChange = this.#applyResolvedEdits(entry.inverseEdits); this.#version = entry.versionBefore; - return entry.selectionsBefore !== undefined - ? entry.selectionsBefore.map((selection) => ({ ...selection })) - : undefined; + return { + selections: cloneSelections(entry.selectionsBefore), + lineAnnotations: entry.lineAnnotationsBefore?.slice(), + }; } - redo(): EditorSelection[] | undefined { + redo(): + | { selections?: EditorSelection[]; lineAnnotations?: unknown[] } + | undefined { const entry = this.#editStack.popRedoToUndo(); if (entry === undefined) { this.#lastChange = undefined; @@ -236,9 +251,13 @@ export class TextDocument { } this.#lastChange = this.#applyResolvedEdits(entry.forwardEdits); this.#version = entry.versionAfter; - return entry.selectionsAfter !== undefined - ? entry.selectionsAfter.map((selection) => ({ ...selection })) - : undefined; + return { + selections: + entry.selectionsAfter !== undefined + ? cloneSelections(entry.selectionsAfter) + : undefined, + lineAnnotations: entry.lineAnnotationsAfter?.slice(), + }; } #resolveEdit(edit: TextEdit): ResolvedTextEdit { @@ -265,19 +284,24 @@ export class TextDocument { #applyResolvedEdits(edits: ResolvedTextEdit[]): TextDocumentChange { const previousLineCount = this.#pieceTable.lineCount; const changedLineRange = this.#computeChangedLineRange(edits); + const startPosition = this.positionAt(edits[0].start); for (let i = edits.length - 1; i >= 0; i--) { const edit = edits[i]; this.#pieceTable.delete(edit.start, edit.end - edit.start); this.#pieceTable.insert(edit.text, edit.start); } const lineCount = this.#pieceTable.lineCount; - return { + const change = { startLine: changedLineRange.startLine, endLine: Math.min(changedLineRange.endLine, Math.max(0, lineCount - 1)), previousLineCount, lineCount, lineDelta: lineCount - previousLineCount, }; + Object.defineProperty(change, 'startCharacter', { + value: startPosition.character, + }); + return change as TextDocumentChange; } #computeChangedLineRange(edits: ResolvedTextEdit[]): { @@ -314,3 +338,9 @@ function lineFeedCount(text: string): number { } return count; } + +function cloneSelections( + selections: readonly EditorSelection[] +): EditorSelection[] { + return selections.map((selection) => ({ ...selection })); +} diff --git a/packages/diffs/src/index.ts b/packages/diffs/src/index.ts index fc1db46b0..20197f8c3 100644 --- a/packages/diffs/src/index.ts +++ b/packages/diffs/src/index.ts @@ -87,6 +87,7 @@ export * from './utils/getSingularPatch'; export * from './utils/getThemes'; export * from './utils/getTotalLineCountFromHunks'; export * from './utils/hast_utils'; +export * from './utils/hasVisibleLineAnnotation'; export * from './utils/isDefaultRenderRange'; export * from './utils/isWorkerContext'; export * from './utils/parseDiffDecorations'; diff --git a/packages/diffs/src/renderers/FileRenderer.ts b/packages/diffs/src/renderers/FileRenderer.ts index 78986a797..5904e2e72 100644 --- a/packages/diffs/src/renderers/FileRenderer.ts +++ b/packages/diffs/src/renderers/FileRenderer.ts @@ -193,14 +193,14 @@ export class FileRenderer { } public emitLineAnnotationsChange( - lineAnnotations: LineAnnotation[], + newLineAnnotations: LineAnnotation[], renderRange: RenderRange = DEFAULT_RENDER_RANGE ): FileRenderResult | undefined { const renderCache = this.renderCache; if (renderCache == null || renderCache.result == null) { return undefined; } - this.setLineAnnotations(lineAnnotations); + this.setLineAnnotations(newLineAnnotations); return this.processFileResult( renderCache.file, renderRange, diff --git a/packages/diffs/src/utils/hasVisibleLineAnnotation.ts b/packages/diffs/src/utils/hasVisibleLineAnnotation.ts new file mode 100644 index 000000000..dcb1eee80 --- /dev/null +++ b/packages/diffs/src/utils/hasVisibleLineAnnotation.ts @@ -0,0 +1,20 @@ +import type { LineAnnotation, RenderRange } from '../types'; + +export function hasVisibleLineAnnotation( + lineAnnotations: readonly LineAnnotation[], + renderRange: RenderRange | undefined +): boolean { + if (lineAnnotations.length === 0) { + return false; + } + if (renderRange == null) { + return true; + } + const { startingLine, totalLines } = renderRange; + const endLine = + totalLines === Infinity ? Infinity : startingLine + totalLines; + return lineAnnotations.some((annotation) => { + const lineIndex = annotation.lineNumber - 1; + return lineIndex >= startingLine && lineIndex < endLine; + }); +} diff --git a/packages/diffs/test/editorLineAnnotations.test.ts b/packages/diffs/test/editorLineAnnotations.test.ts new file mode 100644 index 000000000..68134d707 --- /dev/null +++ b/packages/diffs/test/editorLineAnnotations.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, test } from 'bun:test'; + +import { applyDocumentChangeToLineAnnotations } from '../src/editor/editorLineAnnotations'; +import { TextDocument } from '../src/editor/textDocument'; +import type { LineAnnotation } from '../src/types'; + +describe('applyDocumentChangeToLineAnnotations', () => { + test('deletes annotations attached to deleted lines', () => { + const textDocument = new TextDocument('inmemory://1', 'one\ntwo\nthree'); + const annotations: LineAnnotation[] = [ + { lineNumber: 1, metadata: 'one' }, + { lineNumber: 2, metadata: 'two' }, + { lineNumber: 3, metadata: 'three' }, + ]; + + textDocument.applyEdits([ + { + range: { + start: { line: 1, character: 0 }, + end: { line: 2, character: 0 }, + }, + newText: '', + }, + ]); + + expect( + applyDocumentChangeToLineAnnotations( + textDocument.lastChange!, + annotations + ) + ).toEqual([ + { lineNumber: 1, metadata: 'one' }, + { lineNumber: 2, metadata: 'three' }, + ]); + }); + + test('moves annotations down when lines are inserted above them', () => { + const textDocument = new TextDocument('inmemory://1', 'one\ntwo\nthree'); + const annotations: LineAnnotation[] = [ + { lineNumber: 1, metadata: 'one' }, + { lineNumber: 2, metadata: 'two' }, + { lineNumber: 3, metadata: 'three' }, + ]; + + textDocument.applyEdits([ + { + range: { + start: { line: 1, character: 0 }, + end: { line: 1, character: 0 }, + }, + newText: 'inserted\n', + }, + ]); + + expect( + applyDocumentChangeToLineAnnotations( + textDocument.lastChange!, + annotations + ) + ).toEqual([ + { lineNumber: 1, metadata: 'one' }, + { lineNumber: 3, metadata: 'two' }, + { lineNumber: 4, metadata: 'three' }, + ]); + }); + + test('returns null when annotations do not move', () => { + const textDocument = new TextDocument('inmemory://1', 'one\ntwo\nthree'); + const annotations: LineAnnotation[] = [ + { lineNumber: 1, metadata: 'one' }, + ]; + + textDocument.applyEdits([ + { + range: { + start: { line: 2, character: 0 }, + end: { line: 2, character: 0 }, + }, + newText: 'inserted\n', + }, + ]); + + expect( + applyDocumentChangeToLineAnnotations( + textDocument.lastChange!, + annotations + ) + ).toBe(undefined); + }); +}); diff --git a/packages/diffs/test/editorMultiSelections.test.ts b/packages/diffs/test/editorMultiSelections.test.ts index 3b38d0a04..5853bef57 100644 --- a/packages/diffs/test/editorMultiSelections.test.ts +++ b/packages/diffs/test/editorMultiSelections.test.ts @@ -9,6 +9,7 @@ import { import type { EditorSelection } from '../src/editor/editorSelection'; import { SelectionDirection } from '../src/editor/editorSelection'; import { TextDocument } from '../src/editor/textDocument'; +import type { LineAnnotation } from '../src/types'; function createSelection( startLine: number, @@ -32,7 +33,7 @@ describe('mapSelectionTextChange', () => { createSelection(1, 1, 1, 1), createSelection(2, 1, 2, 1), ]; - const nextSelections = applyTextChangeToSelections( + const { nextSelections } = applyTextChangeToSelections( textDocument, selections, { @@ -57,7 +58,7 @@ describe('mapSelectionTextChange', () => { createSelection(0, 4, 0, 7, SelectionDirection.Forward), createSelection(0, 8, 0, 11, SelectionDirection.Forward), ]; - const nextSelections = applyTextChangeToSelections( + const { nextSelections } = applyTextChangeToSelections( textDocument, selections, { @@ -82,7 +83,7 @@ describe('mapSelectionTextChange', () => { createSelection(1, 1, 1, 1), createSelection(2, 1, 2, 1), ]; - const nextSelections = applyTextChangeToSelections( + const { nextSelections } = applyTextChangeToSelections( textDocument, selections, { @@ -106,7 +107,7 @@ describe('mapSelectionTextChange', () => { createSelection(0, 1, 0, 1), createSelection(0, 2, 0, 2), ]; - const nextSelections = applyTextChangeToSelections( + const { nextSelections } = applyTextChangeToSelections( textDocument, selections, { @@ -126,7 +127,7 @@ describe('mapSelectionTextChange', () => { test('places the caret on the inserted blank line after Enter', () => { const textDocument = new TextDocument('inmemory://1', 'foo\nbar'); const selections = [createSelection(0, 3, 0, 3)]; - const nextSelections = applyTextChangeToSelections( + const { nextSelections } = applyTextChangeToSelections( textDocument, selections, { @@ -143,7 +144,7 @@ describe('mapSelectionTextChange', () => { test('moves the caret to the previous line end after deleting a line break', () => { const textDocument = new TextDocument('inmemory://1', 'foo\n\nbar'); const selections = [createSelection(1, 0, 1, 0)]; - const nextSelections = applyTextChangeToSelections( + const { nextSelections } = applyTextChangeToSelections( textDocument, selections, { @@ -242,7 +243,7 @@ describe('mapSelectionTextReplace', () => { createSelection(1, 1, 1, 1), createSelection(2, 1, 2, 1), ]; - const nextSelections = applyTextReplaceToSelections( + const { nextSelections } = applyTextReplaceToSelections( textDocument, selections, ['a', 'b', 'c'] @@ -255,4 +256,34 @@ describe('mapSelectionTextReplace', () => { createSelection(2, 2, 2, 2), ]); }); + + test('updates line annotations after replacements that insert lines', () => { + const textDocument = new TextDocument('inmemory://1', 'x\ny\nz'); + const selections = [createSelection(0, 1, 0, 1)]; + const annotations: LineAnnotation[] = [ + { lineNumber: 1, metadata: 'x' }, + { lineNumber: 2, metadata: 'y' }, + ]; + + const { newLineAnnotations } = applyTextReplaceToSelections( + textDocument, + selections, + ['\ninserted'], + annotations + ); + + expect(textDocument.getText()).toBe('x\ninserted\ny\nz'); + expect(newLineAnnotations).toEqual([ + { lineNumber: 1, metadata: 'x' }, + { lineNumber: 3, metadata: 'y' }, + ]); + expect(textDocument.undo()).toEqual({ + selections, + lineAnnotations: annotations, + }); + expect(textDocument.redo()).toEqual({ + selections: [createSelection(1, 8, 1, 8)], + lineAnnotations: newLineAnnotations, + }); + }); }); diff --git a/packages/diffs/test/hasVisibleLineAnnotation.test.ts b/packages/diffs/test/hasVisibleLineAnnotation.test.ts new file mode 100644 index 000000000..a6816a610 --- /dev/null +++ b/packages/diffs/test/hasVisibleLineAnnotation.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from 'bun:test'; + +import type { LineAnnotation, RenderRange } from '../src/types'; +import { hasVisibleLineAnnotation } from '../src/utils/hasVisibleLineAnnotation'; + +const annotations: LineAnnotation[] = [ + { lineNumber: 1, metadata: 'first' }, + { lineNumber: 3, metadata: 'third' }, + { lineNumber: 5, metadata: 'fifth' }, +]; + +function createRenderRange( + startingLine: number, + totalLines: number +): RenderRange { + return { + startingLine, + totalLines, + bufferBefore: 0, + bufferAfter: 0, + }; +} + +describe('hasVisibleLineAnnotation', () => { + test('returns false when there are no annotations', () => { + expect(hasVisibleLineAnnotation([], undefined)).toBe(false); + }); + + test('returns true for any annotation without a render range', () => { + expect(hasVisibleLineAnnotation(annotations, undefined)).toBe(true); + }); + + test('matches annotations inside a zero-based render range', () => { + expect(hasVisibleLineAnnotation(annotations, createRenderRange(2, 2))).toBe( + true + ); + }); + + test('treats the render range end as exclusive', () => { + expect(hasVisibleLineAnnotation(annotations, createRenderRange(1, 1))).toBe( + false + ); + }); + + test('supports infinite render ranges', () => { + expect( + hasVisibleLineAnnotation(annotations, createRenderRange(3, Infinity)) + ).toBe(true); + }); +}); diff --git a/packages/diffs/test/textDocument.test.ts b/packages/diffs/test/textDocument.test.ts index d857de934..05ba36d27 100644 --- a/packages/diffs/test/textDocument.test.ts +++ b/packages/diffs/test/textDocument.test.ts @@ -529,8 +529,8 @@ describe('TextDocument', () => { [selectionAfter] ); - expect(d.undo()).toEqual([selectionBefore]); - expect(d.redo()).toEqual([selectionAfter]); + expect(d.undo()).toEqual({ selections: [selectionBefore] }); + expect(d.redo()).toEqual({ selections: [selectionAfter] }); }); test('undo and redo preserve multiple selections', () => { @@ -559,7 +559,7 @@ describe('TextDocument', () => { selectionsAfter ); - expect(d.undo()).toEqual(selectionsBefore); - expect(d.redo()).toEqual(selectionsAfter); + expect(d.undo()).toEqual({ selections: selectionsBefore }); + expect(d.redo()).toEqual({ selections: selectionsAfter }); }); }); From a9b710654a6865a1e3601132742679600d771473 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Tue, 5 May 2026 23:17:10 +0800 Subject: [PATCH 072/138] Add documentation for `hasVisibleLineAnnotation` function. --- packages/diffs/src/utils/hasVisibleLineAnnotation.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/diffs/src/utils/hasVisibleLineAnnotation.ts b/packages/diffs/src/utils/hasVisibleLineAnnotation.ts index dcb1eee80..e5385f8b4 100644 --- a/packages/diffs/src/utils/hasVisibleLineAnnotation.ts +++ b/packages/diffs/src/utils/hasVisibleLineAnnotation.ts @@ -1,5 +1,11 @@ import type { LineAnnotation, RenderRange } from '../types'; +/** + * Checks if any line annotations are visible within the given render range. + * @param lineAnnotations - The array of line annotations to check. + * @param renderRange - The render range to check against. + * @returns True if any line annotations are visible, false otherwise. + */ export function hasVisibleLineAnnotation( lineAnnotations: readonly LineAnnotation[], renderRange: RenderRange | undefined From 264a4ed09fd3d6d7093fc0d89af7c092d41ab66e Mon Sep 17 00:00:00 2001 From: Je Xia Date: Wed, 6 May 2026 16:48:21 +0800 Subject: [PATCH 073/138] Get rid of enum --- .../diffs/src/editor/editorMultiSelections.ts | 17 ++++-- packages/diffs/src/editor/editorSelection.ts | 19 +++--- packages/diffs/src/editor/editorTextarea.ts | 18 ++++-- packages/diffs/src/editor/editorUtils.ts | 4 +- packages/diffs/src/editor/index.ts | 24 ++++---- packages/diffs/src/editor/pieceTable.ts | 61 +++++++------------ packages/diffs/test/editStack.test.ts | 9 ++- .../diffs/test/editorMultiSelections.test.ts | 31 ++++++---- packages/diffs/test/editorSelection.test.ts | 25 ++++---- .../diffs/test/editorTextareaSnapshot.test.ts | 5 +- packages/diffs/test/textDocument.test.ts | 4 +- 11 files changed, 111 insertions(+), 106 deletions(-) diff --git a/packages/diffs/src/editor/editorMultiSelections.ts b/packages/diffs/src/editor/editorMultiSelections.ts index 0e7c8744f..cdadee642 100644 --- a/packages/diffs/src/editor/editorMultiSelections.ts +++ b/packages/diffs/src/editor/editorMultiSelections.ts @@ -1,6 +1,11 @@ import type { LineAnnotation } from '../types'; import { applyDocumentChangeToLineAnnotations } from './editorLineAnnotations'; -import { type EditorSelection, SelectionDirection } from './editorSelection'; +import { + DirectionBackward, + DirectionForward, + DirectionNone, + type EditorSelection, +} from './editorSelection'; import { type Position, type ResolvedTextEdit, @@ -44,7 +49,7 @@ export function mapSelectionMove( return { start: newPosition, end: newPosition, - direction: SelectionDirection.None, + direction: DirectionNone, }; }); } @@ -262,10 +267,10 @@ export function createSelectionFromAnchorAndFocusOffsets( ): EditorSelection { const direction = anchorOffset === focusOffset - ? SelectionDirection.None + ? DirectionNone : anchorOffset < focusOffset - ? SelectionDirection.Forward - : SelectionDirection.Backward; + ? DirectionForward + : DirectionBackward; const start = Math.min(anchorOffset, focusOffset); const end = Math.max(anchorOffset, focusOffset); return { @@ -279,7 +284,7 @@ function getSelectionAnchorAndFocusOffsets( textDocument: TextDocument, selection: EditorSelection ): [anchorOffset: number, focusOffset: number] { - const isBackward = selection.direction === SelectionDirection.Backward; + const isBackward = selection.direction === DirectionBackward; return [ textDocument.offsetAt(isBackward ? selection.end : selection.start), textDocument.offsetAt(isBackward ? selection.start : selection.end), diff --git a/packages/diffs/src/editor/editorSelection.ts b/packages/diffs/src/editor/editorSelection.ts index d7d8dc66a..ca700ce54 100644 --- a/packages/diffs/src/editor/editorSelection.ts +++ b/packages/diffs/src/editor/editorSelection.ts @@ -1,21 +1,24 @@ import type { Position, Range, TextDocument, TextEdit } from './textDocument'; -export enum SelectionDirection { - Backward = -1, - None = 0, - Forward = 1, -} +export const DirectionBackward = -1; +export const DirectionNone = 0; +export const DirectionForward = 1; + +export type SelectionDirection = + | typeof DirectionBackward + | typeof DirectionNone + | typeof DirectionForward; -export type EditorSelection = Range & { +export interface EditorSelection extends Range { direction: SelectionDirection; -}; +} /** * Converts a selection from a web selection to an editor selection. */ export function convertSelection( composedRanges: StaticRange[], - direction: SelectionDirection = SelectionDirection.None + direction: SelectionDirection = DirectionNone ): EditorSelection | null { const range = composedRanges[composedRanges.length - 1]; if (range === undefined) { diff --git a/packages/diffs/src/editor/editorTextarea.ts b/packages/diffs/src/editor/editorTextarea.ts index 040a334ec..053874ed6 100644 --- a/packages/diffs/src/editor/editorTextarea.ts +++ b/packages/diffs/src/editor/editorTextarea.ts @@ -1,4 +1,10 @@ -import { type EditorSelection, SelectionDirection } from './editorSelection'; +import { + DirectionBackward, + DirectionForward, + DirectionNone, + type EditorSelection, + type SelectionDirection, +} from './editorSelection'; import type { Position, ResolvedTextEdit, TextDocument } from './textDocument'; export interface TextareaSnapshot { @@ -122,19 +128,19 @@ export function getSelectionDirectionFromTextarea( textareaEl: HTMLTextAreaElement ): SelectionDirection { return textareaEl.selectionDirection === 'backward' - ? SelectionDirection.Backward - : SelectionDirection.Forward; + ? DirectionBackward + : DirectionForward; } export function toTextareaSelectionDirection( selection: EditorSelection ): HTMLTextAreaElement['selectionDirection'] { switch (selection.direction) { - case SelectionDirection.Backward: + case DirectionBackward: return 'forward'; - case SelectionDirection.Forward: + case DirectionForward: return 'backward'; - case SelectionDirection.None: + case DirectionNone: return 'none'; } } diff --git a/packages/diffs/src/editor/editorUtils.ts b/packages/diffs/src/editor/editorUtils.ts index 217156d62..cff57bca1 100644 --- a/packages/diffs/src/editor/editorUtils.ts +++ b/packages/diffs/src/editor/editorUtils.ts @@ -81,9 +81,7 @@ export function addEventListener( listener: EventListener ) { el.addEventListener(event, listener); - return () => { - el.removeEventListener(event, listener); - }; + return () => el.removeEventListener(event, listener); } export function isCodeLineTarget(target?: EventTarget): target is HTMLElement { diff --git a/packages/diffs/src/editor/index.ts b/packages/diffs/src/editor/index.ts index eb4efd547..20be127a4 100644 --- a/packages/diffs/src/editor/index.ts +++ b/packages/diffs/src/editor/index.ts @@ -22,9 +22,12 @@ import type { EditorSelection } from '../editor/editorSelection'; import { comparePosition, convertSelection, + DirectionBackward, + DirectionForward, + DirectionNone, isCollapsedSelection, resolveIndentEdits, - SelectionDirection, + type SelectionDirection, selectionIntersects, } from '../editor/editorSelection'; import { @@ -447,16 +450,14 @@ export class Editor implements DiffsEditor { const startLine = Math.ceil(this.#selectionStartY / this.#lineHeight); const endLine = Math.ceil(this.#selectionEndY / this.#lineHeight); if (endLine !== startLine) { - return endLine > startLine - ? SelectionDirection.Forward - : SelectionDirection.Backward; + return endLine > startLine ? DirectionForward : DirectionBackward; } if (this.#selectionEndX !== this.#selectionStartX) { return this.#selectionEndX > this.#selectionStartX - ? SelectionDirection.Forward - : SelectionDirection.Backward; + ? DirectionForward + : DirectionBackward; } - return SelectionDirection.None; + return DirectionNone; } #rerender(newLineAnnotations?: LineAnnotation[] | undefined) { @@ -759,8 +760,7 @@ export class Editor implements DiffsEditor { ); } else { const isBackward = - getSelectionDirectionFromTextarea(textareaEl) === - SelectionDirection.Backward; + getSelectionDirectionFromTextarea(textareaEl) === DirectionBackward; const anchorOffset = textareaSnapshot.offset + (isBackward ? selectionEnd : selectionStart); @@ -919,7 +919,7 @@ export class Editor implements DiffsEditor { } const { start, end, direction } = selection; - const isBackward = direction === SelectionDirection.Backward; + const isBackward = direction === DirectionBackward; const line = isBackward ? start.line : end.line; const character = isBackward ? start.character : end.character; const left = Math.max(this.#charWidth, this.#getCharX(line, character)); @@ -1083,7 +1083,7 @@ export class Editor implements DiffsEditor { return { start: { line: 0, character: 0 }, end: { line: lastLine, character: lastCharacter }, - direction: SelectionDirection.Forward, + direction: DirectionForward, }; } @@ -1099,7 +1099,7 @@ export class Editor implements DiffsEditor { return { start: start, end: start, - direction: SelectionDirection.Forward, + direction: DirectionForward, }; } diff --git a/packages/diffs/src/editor/pieceTable.ts b/packages/diffs/src/editor/pieceTable.ts index 38cce7aff..5d9781081 100644 --- a/packages/diffs/src/editor/pieceTable.ts +++ b/packages/diffs/src/editor/pieceTable.ts @@ -1,21 +1,14 @@ import type { Position, Range } from './textDocument'; -type Piece = { - readonly source: PieceSourceType; - readonly offset: number; - readonly length: number; -}; - -type PieceSegment = { - readonly start: number; - readonly end: number; - readonly text: string; - readonly lineOffsets: number[]; -}; - -enum PieceSourceType { - Original = 0, - Added = 1, +// A piece is a segment of text that is either original or added. +class Piece { + static Original = 0; + static Added = 1; + constructor( + public readonly source: number, + public readonly offset: number, + public readonly length: number + ) {} } // A text buffer is a string with its line offsets. @@ -78,13 +71,7 @@ export class PieceTable { constructor(originalText: string) { this.#original = new TextBuffer(originalText); - this.#setPieces([ - { - source: PieceSourceType.Original, - offset: 0, - length: originalText.length, - }, - ]); + this.#setPieces([new Piece(Piece.Original, 0, originalText.length)]); } get lineCount(): number { @@ -196,11 +183,7 @@ export class PieceTable { const insertOffset = clamp(offset, 0, this.#length); const addOffset = this.#add.append(text); - const insertedPiece = { - source: PieceSourceType.Added, - offset: addOffset, - length: text.length, - }; + const insertedPiece = new Piece(Piece.Added, addOffset, text.length); const pieces = this.#pieces(); const nextPieces: Piece[] = []; @@ -398,7 +381,12 @@ export class PieceTable { } #forEachPieceSegment( - callback: (segment: PieceSegment) => boolean | void + callback: (segment: { + readonly start: number; + readonly end: number; + readonly text: string; + readonly lineOffsets: number[]; + }) => boolean | void ): void { this.#walk(this.#root, (node) => { const buffer = this.#bufferFor(node.piece.source); @@ -446,8 +434,8 @@ export class PieceTable { return { nextLine: line, nextLineStart: lineStart }; } - #bufferFor(source: PieceSourceType): TextBuffer { - return source === PieceSourceType.Original ? this.#original : this.#add; + #bufferFor(source: number): TextBuffer { + return source === Piece.Original ? this.#original : this.#add; } #pieces(): Piece[] { @@ -474,7 +462,9 @@ export class PieceTable { this.#forEachPieceSegment((segment) => { length += segment.end - segment.start; - lineCount += lineFeedCount(segment); + lineCount += + upperBound(segment.lineOffsets, segment.end) - + upperBound(segment.lineOffsets, segment.start); }); this.#length = length; @@ -682,13 +672,6 @@ function coalescePieces(pieces: Piece[]): Piece[] { return coalescedPieces; } -function lineFeedCount(segment: PieceSegment): number { - return ( - upperBound(segment.lineOffsets, segment.end) - - upperBound(segment.lineOffsets, segment.start) - ); -} - // Returns the index of the first element in the array that is greater than the target. function upperBound(values: number[], target: number): number { let lo = 0; diff --git a/packages/diffs/test/editStack.test.ts b/packages/diffs/test/editStack.test.ts index c68de2d55..ee6ef1549 100644 --- a/packages/diffs/test/editStack.test.ts +++ b/packages/diffs/test/editStack.test.ts @@ -1,7 +1,10 @@ import { describe, expect, test } from 'bun:test'; import type { EditorSelection } from '../src/editor/editorSelection'; -import { SelectionDirection } from '../src/editor/editorSelection'; +import { + DirectionNone, + type SelectionDirection, +} from '../src/editor/editorSelection'; import { EditStack } from '../src/editor/editStack'; function createSelection( @@ -9,7 +12,7 @@ function createSelection( startCharacter: number, endLine: number, endCharacter: number, - direction: SelectionDirection = SelectionDirection.None + direction: SelectionDirection = DirectionNone ): EditorSelection { return { start: { line: startLine, character: startCharacter }, @@ -19,7 +22,7 @@ function createSelection( } function caret(character: number) { - return createSelection(0, character, 0, character, SelectionDirection.None); + return createSelection(0, character, 0, character, DirectionNone); } function source(text: string) { diff --git a/packages/diffs/test/editorMultiSelections.test.ts b/packages/diffs/test/editorMultiSelections.test.ts index 5853bef57..109a7b0c3 100644 --- a/packages/diffs/test/editorMultiSelections.test.ts +++ b/packages/diffs/test/editorMultiSelections.test.ts @@ -7,7 +7,12 @@ import { mapSelectionRangeMove, } from '../src/editor/editorMultiSelections'; import type { EditorSelection } from '../src/editor/editorSelection'; -import { SelectionDirection } from '../src/editor/editorSelection'; +import { + DirectionBackward, + DirectionForward, + DirectionNone, + type SelectionDirection, +} from '../src/editor/editorSelection'; import { TextDocument } from '../src/editor/textDocument'; import type { LineAnnotation } from '../src/types'; @@ -16,7 +21,7 @@ function createSelection( startCharacter: number, endLine: number, endCharacter: number, - direction: SelectionDirection = SelectionDirection.None + direction: SelectionDirection = DirectionNone ): EditorSelection { return { start: { line: startLine, character: startCharacter }, @@ -54,9 +59,9 @@ describe('mapSelectionTextChange', () => { test('replaces each selected range with the typed text', () => { const textDocument = new TextDocument('inmemory://1', 'foo bar baz'); const selections = [ - createSelection(0, 0, 0, 3, SelectionDirection.Forward), - createSelection(0, 4, 0, 7, SelectionDirection.Forward), - createSelection(0, 8, 0, 11, SelectionDirection.Forward), + createSelection(0, 0, 0, 3, DirectionForward), + createSelection(0, 4, 0, 7, DirectionForward), + createSelection(0, 8, 0, 11, DirectionForward), ]; const { nextSelections } = applyTextChangeToSelections( textDocument, @@ -180,15 +185,15 @@ describe('mapSelectionMove', () => { test('extends all selections when the primary selection grows', () => { const textDocument = new TextDocument('inmemory://1', 'abcd\nefgh'); const selections = [ - createSelection(0, 1, 0, 2, SelectionDirection.Forward), - createSelection(1, 1, 1, 2, SelectionDirection.Forward), + createSelection(0, 1, 0, 2, DirectionForward), + createSelection(1, 1, 1, 2, DirectionForward), ]; expect( mapSelectionMove(textDocument, selections, { line: 1, character: 1 }) ).toEqual([ - createSelection(0, 1, 0, 1, SelectionDirection.None), - createSelection(1, 1, 1, 1, SelectionDirection.None), + createSelection(0, 1, 0, 1, DirectionNone), + createSelection(1, 1, 1, 1, DirectionNone), ]); }); }); @@ -209,8 +214,8 @@ describe('mapSelectionRangeMove', () => { { line: 1, character: 3 } ) ).toEqual([ - createSelection(0, 1, 0, 3, SelectionDirection.Forward), - createSelection(1, 1, 1, 3, SelectionDirection.Forward), + createSelection(0, 1, 0, 3, DirectionForward), + createSelection(1, 1, 1, 3, DirectionForward), ]); }); @@ -229,8 +234,8 @@ describe('mapSelectionRangeMove', () => { { line: 1, character: 0 } ) ).toEqual([ - createSelection(0, 0, 0, 2, SelectionDirection.Backward), - createSelection(1, 0, 1, 2, SelectionDirection.Backward), + createSelection(0, 0, 0, 2, DirectionBackward), + createSelection(1, 0, 1, 2, DirectionBackward), ]); }); }); diff --git a/packages/diffs/test/editorSelection.test.ts b/packages/diffs/test/editorSelection.test.ts index 4d27353cb..3bc182051 100644 --- a/packages/diffs/test/editorSelection.test.ts +++ b/packages/diffs/test/editorSelection.test.ts @@ -2,8 +2,9 @@ import { describe, expect, test } from 'bun:test'; import { convertSelection, + DirectionForward, + DirectionNone, type EditorSelection, - SelectionDirection, selectionIntersects, } from '../src/editor/editorSelection'; @@ -50,7 +51,7 @@ function editorSelection( return { start: { line: startLine, character: startCharacter }, end: { line: endLine, character: endCharacter }, - direction: SelectionDirection.Forward, + direction: DirectionForward, }; } @@ -168,7 +169,7 @@ describe('convertSelection', () => { { start: { line: 1, character: 0 }, end: { line: 1, character: 0 }, - direction: SelectionDirection.None, + direction: DirectionNone, } ); }); @@ -179,7 +180,7 @@ describe('convertSelection', () => { { start: { line: 2, character: 0 }, end: { line: 2, character: 0 }, - direction: SelectionDirection.None, + direction: DirectionNone, } ); }); @@ -190,14 +191,14 @@ describe('convertSelection', () => { { start: { line: 3, character: 0 }, end: { line: 3, character: 0 }, - direction: SelectionDirection.None, + direction: DirectionNone, } ); expect(convertSelection(composedRange(line as unknown as Node, 2))).toEqual( { start: { line: 3, character: 0 }, end: { line: 3, character: 0 }, - direction: SelectionDirection.None, + direction: DirectionNone, } ); }); @@ -208,7 +209,7 @@ describe('convertSelection', () => { { start: { line: 4, character: 0 }, end: { line: 4, character: 0 }, - direction: SelectionDirection.None, + direction: DirectionNone, } ); }); @@ -221,7 +222,7 @@ describe('convertSelection', () => { ).toEqual({ start: { line: 6, character: 2 }, end: { line: 6, character: 2 }, - direction: SelectionDirection.None, + direction: DirectionNone, }); }); @@ -234,7 +235,7 @@ describe('convertSelection', () => { ).toEqual({ start: { line: 7, character: 13 }, end: { line: 7, character: 13 }, - direction: SelectionDirection.None, + direction: DirectionNone, }); }); @@ -246,7 +247,7 @@ describe('convertSelection', () => { ).toEqual({ start: { line: 8, character: 0 }, end: { line: 8, character: 0 }, - direction: SelectionDirection.None, + direction: DirectionNone, }); }); @@ -259,13 +260,13 @@ describe('convertSelection', () => { ).toEqual({ start: { line: 5, character: 0 }, end: { line: 5, character: 0 }, - direction: SelectionDirection.None, + direction: DirectionNone, }); expect(convertSelection(composedRange(icon as unknown as Node, 0))).toEqual( { start: { line: 5, character: 0 }, end: { line: 5, character: 0 }, - direction: SelectionDirection.None, + direction: DirectionNone, } ); }); diff --git a/packages/diffs/test/editorTextareaSnapshot.test.ts b/packages/diffs/test/editorTextareaSnapshot.test.ts index be04c8866..72dd96478 100644 --- a/packages/diffs/test/editorTextareaSnapshot.test.ts +++ b/packages/diffs/test/editorTextareaSnapshot.test.ts @@ -1,8 +1,9 @@ import { describe, expect, test } from 'bun:test'; import { + DirectionNone, type EditorSelection, - SelectionDirection, + type SelectionDirection, } from '../src/editor/editorSelection'; import { createTextareaSnapshot, @@ -15,7 +16,7 @@ function createSelection( startCharacter: number, endLine: number, endCharacter: number, - direction: SelectionDirection = SelectionDirection.None + direction: SelectionDirection = DirectionNone ): EditorSelection { return { start: { line: startLine, character: startCharacter }, diff --git a/packages/diffs/test/textDocument.test.ts b/packages/diffs/test/textDocument.test.ts index 05ba36d27..5dcfb5223 100644 --- a/packages/diffs/test/textDocument.test.ts +++ b/packages/diffs/test/textDocument.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from 'bun:test'; import type { EditorSelection } from '../src/editor/editorSelection'; -import { SelectionDirection } from '../src/editor/editorSelection'; +import { DirectionNone } from '../src/editor/editorSelection'; import { TextDocument, type TextEdit } from '../src/editor/textDocument'; function doc(text: string) { @@ -13,7 +13,7 @@ function caret(line: number, character: number) { return { start: position, end: position, - direction: SelectionDirection.None, + direction: DirectionNone, } satisfies EditorSelection; } From 55219672457c6796364f99a718284301af3f12c5 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Wed, 6 May 2026 16:55:24 +0800 Subject: [PATCH 074/138] Clean up --- packages/diffs/src/editor/editorCommand.ts | 21 +++++++++++---------- packages/diffs/src/editor/pieceTable.ts | 1 + packages/diffs/src/editor/textDocument.ts | 8 ++++---- 3 files changed, 16 insertions(+), 14 deletions(-) diff --git a/packages/diffs/src/editor/editorCommand.ts b/packages/diffs/src/editor/editorCommand.ts index ed77f85cd..d9478ec96 100644 --- a/packages/diffs/src/editor/editorCommand.ts +++ b/packages/diffs/src/editor/editorCommand.ts @@ -17,16 +17,6 @@ const SHORTCUTS: Partial> = { x: 'cut', }; -function isMacLike(): boolean { - return /macOS|MacIntel|iPhone|iPad|iPod/i.test(getPlatform()); -} - -export function isPrimaryModifier(event: MouseEvent | KeyboardEvent): boolean { - return isMacLike() - ? event.metaKey && !event.ctrlKey - : event.ctrlKey && !event.metaKey; -} - export function resolveEditorCommandFromKeyboardEvent( event: KeyboardEvent ): EditorCommand | undefined { @@ -65,6 +55,17 @@ export function resolveEditorCommandFromKeyboardEvent( return SHORTCUTS[key]; } +export function isPrimaryModifier({ + metaKey, + ctrlKey, +}: MouseEvent | KeyboardEvent): boolean { + return isMacLike() ? metaKey && !ctrlKey : ctrlKey && !metaKey; +} + +function isMacLike(): boolean { + return /macOS|MacIntel|iPhone|iPad|iPod/i.test(getPlatform()); +} + function getPlatform(): string { const navigator = globalThis.navigator as Navigator & { userAgentData?: { platform?: string }; diff --git a/packages/diffs/src/editor/pieceTable.ts b/packages/diffs/src/editor/pieceTable.ts index 5d9781081..51df0d5f3 100644 --- a/packages/diffs/src/editor/pieceTable.ts +++ b/packages/diffs/src/editor/pieceTable.ts @@ -4,6 +4,7 @@ import type { Position, Range } from './textDocument'; class Piece { static Original = 0; static Added = 1; + constructor( public readonly source: number, public readonly offset: number, diff --git a/packages/diffs/src/editor/textDocument.ts b/packages/diffs/src/editor/textDocument.ts index cde80ff27..b6fb0b2b3 100644 --- a/packages/diffs/src/editor/textDocument.ts +++ b/packages/diffs/src/editor/textDocument.ts @@ -76,7 +76,7 @@ export interface TextEdit { } /** Different with `TextEdit`, the range has been resolved to offsets. */ -export type ResolvedTextEdit = { +export interface ResolvedTextEdit { /** The start offset of the text change. */ readonly start: number; /** The end offset of the text change. */ @@ -86,9 +86,9 @@ export type ResolvedTextEdit = { * empty string. */ readonly text: string; -}; +} -export type TextDocumentChange = { +export interface TextDocumentChange { /** First line whose rendered content or tokenizer state may have changed. */ readonly startLine: number; /** Character on the first changed line where the edit began. */ @@ -101,7 +101,7 @@ export type TextDocumentChange = { readonly lineCount: number; /** Difference between the old and new line counts. */ readonly lineDelta: number; -}; +} /** * A vscode-languageserver-textdocument compatible text document. From d83eaa61bbb3b1241a1d53b8d8de583891eab863 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Wed, 6 May 2026 20:41:02 +0800 Subject: [PATCH 075/138] Refactor --- packages/diffs/src/editor/editorUtils.ts | 24 ---- packages/diffs/src/editor/index.ts | 146 +++++++++++++---------- 2 files changed, 84 insertions(+), 86 deletions(-) diff --git a/packages/diffs/src/editor/editorUtils.ts b/packages/diffs/src/editor/editorUtils.ts index cff57bca1..777775e9c 100644 --- a/packages/diffs/src/editor/editorUtils.ts +++ b/packages/diffs/src/editor/editorUtils.ts @@ -84,30 +84,6 @@ export function addEventListener( return () => el.removeEventListener(event, listener); } -export function isCodeLineTarget(target?: EventTarget): target is HTMLElement { - if (target === undefined || !(target instanceof HTMLElement)) { - return false; - } - const { tagName, dataset } = target; - return ( - (tagName === 'DIV' && dataset.line !== undefined) || - (tagName === 'SPAN' && dataset.char !== undefined) - ); -} - -export function getLineIndentation(lineText: string): string { - let indentation = ''; - for (let i = 0; i < lineText.length; i++) { - const char = lineText[i]; - if (char === ' ' || char === '\t') { - indentation += char; - } else { - break; - } - } - return indentation; -} - export function extend(obj: T, attrs: Partial): T { return Object.assign(obj, attrs); } diff --git a/packages/diffs/src/editor/index.ts b/packages/diffs/src/editor/index.ts index 20be127a4..4fddbc2ac 100644 --- a/packages/diffs/src/editor/index.ts +++ b/packages/diffs/src/editor/index.ts @@ -35,7 +35,6 @@ import { createElement, debounce, extend, - isCodeLineTarget, round, } from '../editor/editorUtils'; import { TextDocument, type TextEdit } from '../editor/textDocument'; @@ -139,7 +138,6 @@ export class Editor implements DiffsEditor { lineAnnotations?: LineAnnotation[] ) => void ): () => void { - file.setEditor(this); this.#file = file; this.#highlighter ??= areThemesAttached( file.options.theme ?? DEFAULT_THEMES @@ -147,6 +145,8 @@ export class Editor implements DiffsEditor { ? getHighlighterIfLoaded() : undefined; this.#onChange = onChange; + this.#initialize(); + file.setEditor(this); return () => this.cleanUp(); } @@ -246,9 +246,8 @@ export class Editor implements DiffsEditor { // measure the font width, line height, and tab size // purge the lineY cache if the line height or line annotations change - const { lineHeight, fontSize, fontFamily, tabSize } = getComputedStyle( - this.#contentEl - ); + const style = getComputedStyle(this.#contentEl); + const { fontSize, fontFamily, tabSize, lineHeight } = style; let lineHeighPx = 20; if (lineHeight.endsWith('px')) { lineHeighPx = Number(lineHeight.slice(0, -2)); @@ -257,10 +256,7 @@ export class Editor implements DiffsEditor { Number(fontSize.slice(0, -2)) * Number(lineHeight.slice(0, -2)) ); } - if ( - lineHeighPx !== this.#lineHeight || - lineAnnotations !== this.#lineAnnotations - ) { + if (lineHeighPx !== this.#lineHeight) { this.#lineYCache.clear(); } this.#lastCharX = undefined; @@ -268,12 +264,12 @@ export class Editor implements DiffsEditor { this.#tabSize = Number(tabSize); this.#measureCtx ??= document.createElement('canvas').getContext('2d') ?? undefined; + const font = fontSize + ' ' + fontFamily; if ( this.#measureCtx !== undefined && - (this.#measureCtx.font !== fontSize + ' ' + fontFamily || - this.#charWidth === -1) + (this.#measureCtx.font !== font || this.#charWidth === -1) ) { - this.#measureCtx.font = fontSize + ' ' + fontFamily; + this.#measureCtx.font = font; this.#charWidth = round(this.#measureCtx.measureText('0').width); } @@ -283,22 +279,68 @@ export class Editor implements DiffsEditor { this.#fileContents.contents !== fileContents.contents || this.#fileContents.lang !== fileContents.lang ) { - if (this.#textDocument !== undefined) { - this.cleanUp(); - } this.#fileContents = fileContents; this.#textDocument = new TextDocument( fileContents.name, fileContents.contents, fileContents.lang ?? getFiletypeFromFileName(fileContents.name) ); + this.#stateStackCache = undefined; + this.#lineYCache.clear(); + this.#lastCharX = undefined; + this.#shouldIgnoreSelectionChange = false; + this.#textareaSnapshot = undefined; + this.#selections = undefined; + this.#reservedSelections = undefined; + } + + if (this.#lineAnnotations !== lineAnnotations) { + this.#lineAnnotations = lineAnnotations; + this.#lineYCache.clear(); } - this.#lineAnnotations = lineAnnotations; this.#renderRange = renderRange; this.#prebuildStateStackCache(); - this.#textareaEl ??= extend( + if (this.#styleEl !== undefined) { + shadowRoot.appendChild(this.#styleEl); + } + if (this.#textareaEl !== undefined) { + this.#contentEl?.appendChild(this.#textareaEl); + } + if (this.#selections !== undefined) { + this.setSelections(this.#selections); + this.#textareaEl?.focus(); + } + + console.log( + '[triggerEdit]', + 'renderRange:', + (renderRange?.startingLine ?? 0) + + '-' + + (renderRange?.totalLines ?? Infinity), + 'of', + this.#textDocument.lineCount, + 'lines' + ); + } + + #initialize(): void { + const isCodeLineTarget = (target?: EventTarget): target is HTMLElement => { + if (target === undefined || !(target instanceof HTMLElement)) { + return false; + } + const { tagName, dataset } = target; + return ( + (tagName === 'DIV' && dataset.line !== undefined) || + (tagName === 'SPAN' && dataset.char !== undefined) + ); + }; + this.#styleEl = createElement('style', { + dataset: 'editorCss', + textContent: EDITOR_CSS, + }); + this.#textareaEl = extend( createElement('textarea', { dataset: 'textarea' }), { autocapitalize: 'off', @@ -308,17 +350,14 @@ export class Editor implements DiffsEditor { wrap: 'off', } ); - this.#contentEl.appendChild(this.#textareaEl); - - this.#styleEl ??= createElement( - 'style', - { dataset: 'editorCss', textContent: EDITOR_CSS }, - shadowRoot - ); - - this.#disposes ??= [ + this.#disposes = [ addEventListener(document, 'selectionchange', () => { - if (this.#shouldIgnoreSelectionChange) { + const shadowRoot = this.#contentEl?.getRootNode(); + if ( + this.#shouldIgnoreSelectionChange || + shadowRoot === undefined || + !(shadowRoot instanceof ShadowRoot) + ) { return; } @@ -428,22 +467,6 @@ export class Editor implements DiffsEditor { this.#syncTextareaState(); }), ]; - - if (this.#selections !== undefined) { - this.setSelections(this.#selections); - this.#textareaEl.focus(); - } - - console.log( - '[triggerEdit]', - 'renderRange:', - (renderRange?.startingLine ?? 0) + - '-' + - (renderRange?.totalLines ?? Infinity), - 'of', - this.#textDocument.lineCount, - 'lines' - ); } #computeMouseSelectionDirection(): SelectionDirection { @@ -487,26 +510,27 @@ export class Editor implements DiffsEditor { dark: this.#getThemeColorMap('dark'), light: this.#getThemeColorMap('light'), }; + const stateStackCache = this.#buildStateStackCache( + textDocument, + grammar, + lastChange.startLine + ); + const { lineCount } = textDocument; const { startingLine = 0, totalLines = Infinity } = this.#renderRange ?? {}; const renderRangeEndLine = totalLines === Infinity - ? textDocument.lineCount - : Math.min(startingLine + totalLines, textDocument.lineCount); - - const dirtyLines: Map> = new Map(); + ? lineCount + : Math.min(startingLine + totalLines, lineCount); let line = lastChange.startLine; - let state = this.#buildStateStackCache( - textDocument, - grammar, - lastChange.startLine - ); + let state = stateStackCache[line]; let settled = false; + let dirtyLines: Map> = new Map(); for (; line < renderRangeEndLine; line++) { const lineText = textDocument.getLineText(line); - this.#stateStackCache![line] = state; + stateStackCache[line] = state; if (lineText.length > TOKENIZE_MAX_LINE_LENGTH) { console.warn( @@ -530,17 +554,16 @@ export class Editor implements DiffsEditor { settled = line >= lastChange.endLine && lastChange.lineDelta === 0 && - this.#stateStackCache![line + 1] !== undefined && - state.equals(this.#stateStackCache![line + 1]); - + stateStackCache[line + 1] !== undefined && + state.equals(stateStackCache[line + 1]); if (settled) { break; } } if (line < renderRangeEndLine) { - this.#stateStackCache![line + 1] = state; + stateStackCache[line + 1] = state; } else { - this.#stateStackCache![line] = state; + stateStackCache[line] = state; } // update line elements that have been changed in the document @@ -635,10 +658,9 @@ export class Editor implements DiffsEditor { } if (newLineAnnotations !== undefined) { file.emitLineAnnotationsChange(newLineAnnotations); - this.#lineAnnotations = newLineAnnotations; } - if (!settled && line < textDocument.lineCount) { + if (!settled && line < lineCount) { requestAnimationFrame(() => { this.#backgroundTokenizer = new BackgroundTokenizer({ grammar, @@ -687,7 +709,7 @@ export class Editor implements DiffsEditor { textDocument: TextDocument, grammar: IGrammar, endLine: number - ): StateStack { + ): StateStack[] { const stateStackCache = (this.#stateStackCache ??= [INITIAL]); const boundedEndLine = Math.min( Math.max(0, endLine), @@ -711,7 +733,7 @@ export class Editor implements DiffsEditor { } } stateStackCache[line] = state; - return stateStackCache[boundedEndLine] ?? INITIAL; + return stateStackCache; } #syncTextareaState() { From c69028bf3c6130666cde568e27b366c9dfee9720 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Wed, 6 May 2026 22:37:45 +0800 Subject: [PATCH 076/138] Update editor CSS --- packages/diffs/src/editor/constants.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/diffs/src/editor/constants.ts b/packages/diffs/src/editor/constants.ts index 3a08a1918..ad497811a 100644 --- a/packages/diffs/src/editor/constants.ts +++ b/packages/diffs/src/editor/constants.ts @@ -27,14 +27,14 @@ export const EDITOR_CSS = /* CSS */ ` position: absolute; top: 0; left: 0; - height: 1lh; line-height: var(--diffs-line-height); pointer-events: none; } [data-textarea] { - font: inherit; + top: -1lh; padding: 0; padding-inline: 1ch; + font: inherit; color: transparent; background-color: transparent; border: none; @@ -48,11 +48,13 @@ export const EDITOR_CSS = /* CSS */ ` min-height: 1lh; } [data-overflow='wrap'] [data-textarea] { + width: 100%; white-space: pre-wrap; word-break: break-word; } [data-caret] { width: 2px; + height: 1lh; background-color: var(--diffs-bg-caret); animation: blinking 1.2s infinite; animation-delay: 0.6s; @@ -63,6 +65,7 @@ export const EDITOR_CSS = /* CSS */ ` visibility: visible; } [data-selection-range] { + height: 1lh; z-index: -10; background-color: var(--diffs-bg-selection); } From 4b26cba99bbc3a8f2b6e8869de8fe1d5f02d1f80 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Thu, 7 May 2026 14:57:43 +0800 Subject: [PATCH 077/138] Support text wrap --- packages/diffs/src/editor/constants.ts | 9 +- packages/diffs/src/editor/index.ts | 576 ++++++++++++++++++++----- 2 files changed, 464 insertions(+), 121 deletions(-) diff --git a/packages/diffs/src/editor/constants.ts b/packages/diffs/src/editor/constants.ts index ad497811a..0446b6573 100644 --- a/packages/diffs/src/editor/constants.ts +++ b/packages/diffs/src/editor/constants.ts @@ -3,9 +3,6 @@ export const TOKENIZE_MAX_LINE_LENGTH = 10000; export const TOKENIZE_LINES_PRE_TOKENIZE = 50; export const EDITOR_CSS = /* CSS */ ` - ::selection { - background-color: transparent; - } @keyframes blinking { 0% { opacity: 1; } 50% { opacity: 0; } @@ -17,12 +14,12 @@ export const EDITOR_CSS = /* CSS */ ` [data-line]:not([data-selected-line]) { background-color: transparent; } - [data-gutter], [data-line-annotation] { - user-select: none; - } [data-content] { position: relative; } + [data-content]::selection { + background-color: transparent; + } [data-textarea], [data-caret], [data-selection-range] { position: absolute; top: 0; diff --git a/packages/diffs/src/editor/index.ts b/packages/diffs/src/editor/index.ts index 4fddbc2ac..948523cca 100644 --- a/packages/diffs/src/editor/index.ts +++ b/packages/diffs/src/editor/index.ts @@ -71,6 +71,7 @@ export class Editor implements DiffsEditor { #charWidth = -1; #lineHeight = 20; #tabSize = 2; + #wrap = false; // file #file?: File; @@ -87,7 +88,8 @@ export class Editor implements DiffsEditor { // cache #stateStackCache?: StateStack[]; #lineYCache = new Map(); - #lastCharX?: [line: number, character: number, x: number]; + #wrapLineOffsetsCache = new Map(); + #lastCharX?: [line: number, character: number, x: number, wrapLine: number]; // dom elements #contentEl?: HTMLElement; @@ -139,6 +141,7 @@ export class Editor implements DiffsEditor { ) => void ): () => void { this.#file = file; + this.#wrap = file.options.overflow === 'wrap'; this.#highlighter ??= areThemesAttached( file.options.theme ?? DEFAULT_THEMES ) @@ -175,14 +178,14 @@ export class Editor implements DiffsEditor { this.#updateTextarea(primarySelection); } else if ( this.#textareaEl !== undefined && - this.#textDocument !== undefined && - this.#textareaSnapshot !== undefined + this.#textDocument !== undefined ) { const nextTextareaSnapshot = createTextareaSnapshot( this.#textDocument, primarySelection ); const shouldSyncTextarea = + this.#textareaSnapshot === undefined || nextTextareaSnapshot.text !== this.#textareaEl.value || nextTextareaSnapshot.selectionStart !== this.#textareaEl.selectionStart || @@ -213,6 +216,7 @@ export class Editor implements DiffsEditor { this.#stateStackCache = undefined; this.#lineYCache.clear(); + this.#wrapLineOffsetsCache.clear(); this.#lastCharX = undefined; this.#contentEl = undefined; @@ -256,12 +260,10 @@ export class Editor implements DiffsEditor { Number(fontSize.slice(0, -2)) * Number(lineHeight.slice(0, -2)) ); } - if (lineHeighPx !== this.#lineHeight) { - this.#lineYCache.clear(); - } this.#lastCharX = undefined; this.#lineHeight = lineHeighPx; this.#tabSize = Number(tabSize); + this.#wrap = this.#file?.options.overflow === 'wrap'; this.#measureCtx ??= document.createElement('canvas').getContext('2d') ?? undefined; const font = fontSize + ' ' + fontFamily; @@ -286,19 +288,17 @@ export class Editor implements DiffsEditor { fileContents.lang ?? getFiletypeFromFileName(fileContents.name) ); this.#stateStackCache = undefined; - this.#lineYCache.clear(); - this.#lastCharX = undefined; this.#shouldIgnoreSelectionChange = false; this.#textareaSnapshot = undefined; this.#selections = undefined; this.#reservedSelections = undefined; } - if (this.#lineAnnotations !== lineAnnotations) { - this.#lineAnnotations = lineAnnotations; - this.#lineYCache.clear(); - } + this.#lineYCache.clear(); + this.#wrapLineOffsetsCache.clear(); + this.#lastCharX = undefined; + this.#lineAnnotations = lineAnnotations; this.#renderRange = renderRange; this.#prebuildStateStackCache(); @@ -310,7 +310,7 @@ export class Editor implements DiffsEditor { } if (this.#selections !== undefined) { this.setSelections(this.#selections); - this.#textareaEl?.focus(); + // this.#focusTextarea(); } console.log( @@ -356,26 +356,17 @@ export class Editor implements DiffsEditor { if ( this.#shouldIgnoreSelectionChange || shadowRoot === undefined || - !(shadowRoot instanceof ShadowRoot) + !(shadowRoot instanceof ShadowRoot) || + shadowRoot.activeElement === null ) { return; } - // if caret position changes in textarea, sync the textarea state. - const textareaEl = this.#textareaEl; - const textareaSnapshot = this.#textareaSnapshot; - if (textareaEl !== undefined && textareaSnapshot !== undefined) { - const { selectionStart, selectionEnd } = textareaEl; - if ( - (textareaSnapshot.selectionStart !== selectionStart || - textareaSnapshot.selectionEnd !== selectionEnd) && - textareaSnapshot.text === textareaEl.value - ) { - textareaSnapshot.selectionStart = selectionStart; - textareaSnapshot.selectionEnd = selectionEnd; - this.#syncTextareaState(); - return; - } + // Chrome-based browsers fire document selectionchange when the + // textarea caret moves inside the shadow root. + if (shadowRoot.activeElement === this.#textareaEl) { + this.#onTextareaSelectionChange(); + return; } const selectionRaw = document.getSelection(); @@ -438,7 +429,7 @@ export class Editor implements DiffsEditor { } this.#reservedSelections = undefined; - this.#textareaEl?.focus(); + this.#focusTextarea(); }), // Selection.getComposedRanges currently does not preserve the drag direction. @@ -466,9 +457,17 @@ export class Editor implements DiffsEditor { this.#syncTextareaState(); }), + + // Chrome-based browsers ignore selectionchange on textarea elements. + addEventListener(this.#textareaEl, 'selectionchange', () => { + this.#onTextareaSelectionChange(); + }), ]; } + // Shadow DOM selection ranges do not expose direction, so track mouse + // movement as a workaround. + // See https://github.com/mfreed7/shadow-dom-selection#part-1-add-selectiongetcomposedrange-and-selectiondirection #computeMouseSelectionDirection(): SelectionDirection { const startLine = Math.ceil(this.#selectionStartY / this.#lineHeight); const endLine = Math.ceil(this.#selectionEndY / this.#lineHeight); @@ -504,6 +503,25 @@ export class Editor implements DiffsEditor { return; } + // Invalidate layout caches touched by the edit. + // - line inserts/deletes shift line numbers, so clear from startLine onward + // - wrapped edits can change visual height, which shifts downstream line Y + if (lastChange.lineDelta !== 0) { + for (const line of this.#wrapLineOffsetsCache.keys()) { + if (line >= lastChange.startLine) { + this.#wrapLineOffsetsCache.delete(line); + } + } + for (const line of this.#lineYCache.keys()) { + if (line >= lastChange.startLine) { + this.#lineYCache.delete(line); + } + } + } else { + this.#wrapLineOffsetsCache.delete(lastChange.startLine); + this.#lineYCache.delete(lastChange.startLine); + } + const t = performance.now(); const grammar = highlighter.getLanguage(textDocument.languageId); const colorMap = { @@ -740,16 +758,19 @@ export class Editor implements DiffsEditor { const textDocument = this.#textDocument; const textareaEl = this.#textareaEl; const textareaSnapshot = this.#textareaSnapshot; + const selections = this.#selections; if ( textDocument === undefined || textareaEl === undefined || - textareaSnapshot === undefined + textareaSnapshot === undefined || + selections === undefined ) { return; } const { selectionStart, selectionEnd, value } = textareaEl; + + // Text in the textarea has been changed. if (value !== textareaSnapshot.text) { - // Text in the textarea has been changed. const change = resolveTextareaChange( textareaSnapshot, value, @@ -757,48 +778,76 @@ export class Editor implements DiffsEditor { selectionEnd ); const lineAnnotations = this.#lineAnnotations; - if (this.#selections !== undefined) { - const { nextSelections, newLineAnnotations } = - applyTextChangeToSelections( - textDocument, - this.#selections, - change, - lineAnnotations - ); - this.#rerender(newLineAnnotations); - this.#emitChange(); - this.setSelections(nextSelections, false); - } - } else if (this.#selections !== undefined) { - // Selection in the textarea changed, but no text change was made. - if (selectionStart === selectionEnd) { - this.setSelections( - mapSelectionMove( - textDocument, - this.#selections, - textDocument.positionAt(textareaSnapshot.offset + selectionStart) - ), - false - ); - } else { - const isBackward = - getSelectionDirectionFromTextarea(textareaEl) === DirectionBackward; - const anchorOffset = - textareaSnapshot.offset + - (isBackward ? selectionEnd : selectionStart); - const focusOffset = - textareaSnapshot.offset + - (isBackward ? selectionStart : selectionEnd); - this.setSelections( - mapSelectionRangeMove( - textDocument, - this.#selections, - textDocument.positionAt(anchorOffset), - textDocument.positionAt(focusOffset) - ), - false + const { nextSelections, newLineAnnotations } = + applyTextChangeToSelections( + textDocument, + selections, + change, + lineAnnotations ); - } + this.#rerender(newLineAnnotations); + this.#emitChange(); + this.setSelections(nextSelections, false); + return; + } + + // Selection in the textarea changed, but no text change was made. + if (selectionStart === selectionEnd) { + this.setSelections( + mapSelectionMove( + textDocument, + selections, + textDocument.positionAt(textareaSnapshot.offset + selectionStart) + ), + false + ); + } else { + const isBackward = + getSelectionDirectionFromTextarea(textareaEl) === DirectionBackward; + const anchorOffset = + textareaSnapshot.offset + (isBackward ? selectionEnd : selectionStart); + const focusOffset = + textareaSnapshot.offset + (isBackward ? selectionStart : selectionEnd); + this.setSelections( + mapSelectionRangeMove( + textDocument, + selections, + textDocument.positionAt(anchorOffset), + textDocument.positionAt(focusOffset) + ), + false + ); + } + } + + #focusTextarea(): void { + this.#shouldIgnoreSelectionChange = true; + this.#textareaEl?.focus(); + setTimeout(() => { + this.#shouldIgnoreSelectionChange = false; + }, 0); + } + + #onTextareaSelectionChange() { + const textareaEl = this.#textareaEl; + const textareaSnapshot = this.#textareaSnapshot; + if ( + textareaEl === undefined || + textareaSnapshot === undefined || + this.#shouldIgnoreSelectionChange + ) { + return; + } + + const { selectionStart, selectionEnd } = textareaEl; + if ( + (textareaSnapshot.selectionStart !== selectionStart || + textareaSnapshot.selectionEnd !== selectionEnd) && + textareaSnapshot.text === textareaEl.value + ) { + textareaSnapshot.selectionStart = selectionStart; + textareaSnapshot.selectionEnd = selectionEnd; + this.#syncTextareaState(); } } @@ -854,7 +903,7 @@ export class Editor implements DiffsEditor { const cacheMap = new Map(); selections.forEach((selection) => { if (selections.length > 1 || !isCollapsedSelection(selection)) { - this.#renderSelectionRange(selection, fragment, cacheMap); + this.#renderSelection(selection, fragment, cacheMap); } this.#renderCaret(selection, fragment, cacheMap); }); @@ -864,7 +913,7 @@ export class Editor implements DiffsEditor { this.#selectionEls = cacheMap; } - #renderSelectionRange( + #renderSelection( selection: EditorSelection, fragment: DocumentFragment, cacheMap: Map @@ -874,51 +923,103 @@ export class Editor implements DiffsEditor { } const { start, end } = selection; - const selectionEls = this.#selectionEls; for (let ln = start.line; ln <= end.line; ln++) { if (!this.#isLineVisible(ln)) { continue; } + const lineText = this.#textDocument.getLineText(ln); - const lineLength = lineText.length; const startChar = ln === start.line ? start.character : 0; - const endChar = ln === end.line ? end.character : lineLength; - const spacing = - ln === end.line || (startChar === endChar && ln !== start.line) - ? 0 - : this.#charWidth; - const cacheKey = `selection-${ln}-${startChar}-${endChar}`; + const endChar = ln === end.line ? end.character : lineText.length; + + if (this.#wrap) { + const paddingInline = this.#charWidth; // 1ch, align to diff css: padding-inline: 1ch + const contentWidth = this.#getContentWidth(); + const textWidth = 2 * paddingInline + this.#measureTextWidth(lineText); + if (textWidth > contentWidth) { + this.#renderWrappedSelection( + selection, + ln, + lineText, + startChar, + endChar, + paddingInline, + fragment, + cacheMap + ); + continue; + } + } let left = 0; let width = 0; - let rangeEl: HTMLElement | undefined; if (startChar === endChar && startChar === 0) { left = this.#charWidth; width = ln === end.line ? 0 : this.#charWidth; } else { - left = this.#getCharX(ln, startChar); - width = endChar === startChar ? 0 : this.#getCharX(ln, endChar) - left; + left = this.#getCharX(ln, startChar)[0]; + width = + endChar === startChar ? 0 : this.#getCharX(ln, endChar)[0] - left; } + this.#renderSelectionRange( + selection, + ln, + 0, + startChar, + endChar, + width, + left, + fragment, + cacheMap + ); + } + } - const css = `width:${width + spacing}px;transform:translateY(${this.#getLineY(ln)}px) translateX(${left}px);`; + // Render one selection range div for a single visual line. `applyEolSpacing` + // controls whether the trailing one-character "line continuation" marker is + // appended at the end. For wrapped logical lines this must be false on every + // visual segment except the last one, since an intra-line wrap is not a real + // newline and shouldn't visually extend past the wrapped content. + #renderSelectionRange( + selection: EditorSelection, + ln: number, + wrapLine: number, + startChar: number, + endChar: number, + width: number, + left: number, + fragment: DocumentFragment, + cacheMap: Map, + applyEolSpacing = true + ) { + const spacing = + !applyEolSpacing || + selection.end.line === ln || + (startChar === endChar && ln !== selection.start.line) + ? 0 + : this.#charWidth; + const css = `width:${width + spacing}px;transform:translateY(${this.#getLineY(ln) + wrapLine * this.#lineHeight}px) translateX(${left}px);`; + const cacheKey = 'selection-range-' + css; + const selectionEls = this.#selectionEls; - if (selectionEls?.has(cacheKey) === true) { - rangeEl = selectionEls.get(cacheKey)!; - selectionEls.delete(cacheKey); - rangeEl.style.cssText = css; - } else { - for (const [key, el] of selectionEls?.entries() ?? []) { - if (key.startsWith(`selection-${ln}-`)) { - rangeEl = el; - selectionEls?.delete(key); - el.style.cssText = css; - break; - } + let rangeEl: HTMLElement | undefined; + if (selectionEls?.has(cacheKey) === true) { + rangeEl = selectionEls.get(cacheKey)!; + selectionEls.delete(cacheKey); + } else { + for (const [key, el] of selectionEls?.entries() ?? []) { + if (key.startsWith(`selection-${ln}-`)) { + rangeEl = el; + selectionEls?.delete(key); + el.style.cssText = css; + break; } } + } - rangeEl ??= createElement( + if (rangeEl === undefined) { + rangeEl = createElement( 'div', { dataset: 'selectionRange', @@ -926,8 +1027,99 @@ export class Editor implements DiffsEditor { }, fragment ); + } else if (rangeEl.parentElement !== this.#contentEl) { + fragment.appendChild(rangeEl); + } + + cacheMap.set(cacheKey, rangeEl); + } + + // Render the selection on a wrapped logical line by splitting it into one + // selection-range div per visual sub-line. For each wrap segment, we compute + // the intersection with the line's selection range and render the slice in + // segment-local coordinates so left/width line up with the visually wrapped + // text. Zero-width slices that fall on intermediate segment boundaries are + // skipped to avoid duplicate markers across consecutive visual lines. + #renderWrappedSelection( + selection: EditorSelection, + line: number, + lineText: string, + startChar: number, + endChar: number, + paddingInline: number, + fragment: DocumentFragment, + cacheMap: Map + ) { + const wrapOffsets = this.#wrapLineText(line); + const segmentCount = wrapOffsets.length - 1; + const lastSegmentIndex = segmentCount - 1; + + for (let w = 0; w < segmentCount; w++) { + const segmentStart = wrapOffsets[w]; + const segmentEnd = wrapOffsets[w + 1]; + const wrapStartChar = Math.max(startChar, segmentStart); + const wrapEndChar = Math.min(endChar, segmentEnd); + + // Selection range doesn't reach this visual segment. + if (wrapStartChar > wrapEndChar) { + continue; + } + + // Zero-width slices on segment boundaries can appear on two consecutive + // segments (end of one, start of the next). Only render at the natural + // anchor positions: the very beginning of the first visual line, or the + // very end of the last visual line. + if (wrapStartChar === wrapEndChar) { + const isAtLineStart = wrapStartChar === 0 && w === 0; + const isAtLineEnd = + wrapEndChar === lineText.length && w === lastSegmentIndex; + if (!isAtLineStart && !isAtLineEnd) { + continue; + } + } + + let segmentLeft: number; + let segmentWidth: number; + if (wrapStartChar === 0 && wrapEndChar === 0) { + // Empty range pinned to line start (e.g. multi-line selection ending + // with end.character === 0). Mirrors the non-wrap path. + segmentLeft = paddingInline; + segmentWidth = line === selection.end.line ? 0 : paddingInline; + } else { + const prefixInSegment = lineText.slice(segmentStart, wrapStartChar); + const prefixAsciiWidth = + this.#getExpandedAsciiTextWidth(prefixInSegment); + segmentLeft = + paddingInline + + (prefixAsciiWidth !== -1 + ? prefixAsciiWidth + : this.#measureTextWidth(prefixInSegment)); + + if (wrapStartChar === wrapEndChar) { + segmentWidth = 0; + } else { + const selectionInSegment = lineText.slice(wrapStartChar, wrapEndChar); + const selectionAsciiWidth = + this.#getExpandedAsciiTextWidth(selectionInSegment); + segmentWidth = + selectionAsciiWidth !== -1 + ? selectionAsciiWidth + : this.#measureTextWidth(selectionInSegment); + } + } - cacheMap.set(cacheKey, rangeEl); + this.#renderSelectionRange( + selection, + line, + w, + wrapStartChar, + wrapEndChar, + segmentWidth, + segmentLeft, + fragment, + cacheMap, + w === lastSegmentIndex + ); } } @@ -944,13 +1136,13 @@ export class Editor implements DiffsEditor { const isBackward = direction === DirectionBackward; const line = isBackward ? start.line : end.line; const character = isBackward ? start.character : end.character; - const left = Math.max(this.#charWidth, this.#getCharX(line, character)); + const [left, wrapLine] = this.#getCharX(line, character); const caretEl = createElement( 'div', { dataset: 'caret', style: { - transform: `translateY(${this.#getLineY(line)}px) translateX(${left - 1}px)`, + transform: `translateY(${this.#getLineY(line) + wrapLine * this.#lineHeight}px) translateX(${left - 1}px)`, }, }, fragment @@ -1202,47 +1394,78 @@ export class Editor implements DiffsEditor { return cachedY; } + // cold(slow) path: measure line top position from DOM causes reflow const y = this.#getLineElement(line)?.offsetTop ?? 0; this.#lineYCache.set(line, y); return y; } - // get character left position in line - #getCharX(line: number, character: number) { + // Return the visual position for a character. Wrapped lines include the + // visual line index so carets can be placed on the correct row. + #getCharX(line: number, char: number): [x: number, wrapLine: number] { if ( this.#lastCharX !== undefined && this.#lastCharX[0] === line && - this.#lastCharX[1] === character + this.#lastCharX[1] === char ) { - return this.#lastCharX[2]; + return [this.#lastCharX[2], this.#lastCharX[3]]; } const lineText = this.#textDocument?.getLineText(line); - const paddingInline = this.#charWidth; // align to diff css: padding-inline: 1ch - if (lineText === undefined || lineText.length === 0 || character <= 0) { - return paddingInline; + const paddingInline = this.#charWidth; + if (lineText === undefined || lineText.length === 0 || char <= 0) { + return [paddingInline, 0]; } - const boundedCharacter = Math.min(character, lineText.length); + const boundedCharacter = Math.min(char, lineText.length); const textBeforeCharacter = lineText.slice(0, boundedCharacter); const asciiWidth = this.#getExpandedAsciiTextWidth(textBeforeCharacter); let left = 0; - if (asciiWidth !== -1 || this.#file?.options.overflow === 'wrap') { + let wrapLine = 0; + if (asciiWidth !== -1) { left = paddingInline + asciiWidth; } else { left = paddingInline + this.#measureTextWidth(textBeforeCharacter); } + if (this.#wrap) { + const contentWidth = this.#getContentWidth(); + const width = 2 * paddingInline + this.#measureTextWidth(lineText); + if (width > contentWidth) { + const wrapOffsets = this.#wrapLineText(line); + for (let w = 0; w + 1 < wrapOffsets.length; w++) { + const segmentStart = wrapOffsets[w]; + const segmentEnd = wrapOffsets[w + 1]; + if (boundedCharacter <= segmentEnd) { + wrapLine = w; + const prefixInSegment = lineText.slice( + segmentStart, + boundedCharacter + ); + const segmentAsciiWidth = + this.#getExpandedAsciiTextWidth(prefixInSegment); + if (segmentAsciiWidth !== -1) { + left = paddingInline + segmentAsciiWidth; + } else { + left = paddingInline + this.#measureTextWidth(prefixInSegment); + } + break; + } + } + } + } + if (this.#lastCharX !== undefined) { this.#lastCharX[0] = line; - this.#lastCharX[1] = character; + this.#lastCharX[1] = char; this.#lastCharX[2] = left; + this.#lastCharX[3] = wrapLine; } else { - this.#lastCharX = [line, character, left]; + this.#lastCharX = [line, char, left, wrapLine]; } - return left; + return [left, wrapLine]; } #getExpandedAsciiTextWidth(text: string) { @@ -1267,6 +1490,129 @@ export class Editor implements DiffsEditor { return this.#measureCtx.measureText(textWithExpandedTabs).width; } + #getContentWidth() { + const diffsColumnContentWidth = + this.#contentEl?.parentElement?.style.getPropertyValue( + '--diffs-column-content-width' + ) ?? ''; + if ( + diffsColumnContentWidth.length > 2 && + diffsColumnContentWidth.endsWith('px') + ) { + return Number(diffsColumnContentWidth.slice(0, -2)); + } + return this.#contentEl?.offsetWidth ?? 0; + } + + // Compute how a logical line of text is broken into visual lines when line + // wrapping is enabled. + #wrapLineText(line: number): Uint32Array { + const cachedOffsets = this.#wrapLineOffsetsCache.get(line); + if (cachedOffsets !== undefined) { + return cachedOffsets; + } + + const lineText = this.#textDocument?.getLineText(line); + if (lineText === undefined || lineText.length === 0) { + const offsets = new Uint32Array([0]); + this.#wrapLineOffsetsCache.set(line, offsets); + return offsets; + } + + const div = createElement( + 'div', + { + style: { + position: 'absolute', + top: '0', + left: '0', + width: '100%', + visibility: 'hidden', + pointerEvents: 'none', + whiteSpace: 'pre-wrap', + wordBreak: 'break-word', + paddingInline: '1ch', + font: 'inherit', + }, + textContent: lineText, + }, + this.#contentEl + ); + const textNode = div.firstChild as Text; + const range = document.createRange(); + const starts: number[] = []; + const ends: number[] = []; + const hasNonWhitespace: boolean[] = []; + + try { + let currentHasNonWhitespace = false; + let lastTop = Number.NEGATIVE_INFINITY; + + for (let i = 0; i < lineText.length; i++) { + range.setStart(textNode, i); + range.setEnd(textNode, i + 1); + + // A new visual line starts whenever the character's top edge moves + // below the previous character's top edge. + const { top } = range.getBoundingClientRect(); + if (top > lastTop) { + if (starts.length > 0) { + ends.push(i); + hasNonWhitespace.push(currentHasNonWhitespace); + } + starts.push(i); + currentHasNonWhitespace = false; + lastTop = top; + } + + const ch = lineText.charAt(i); + if (ch !== ' ' && ch !== '\t') { + currentHasNonWhitespace = true; + } + } + + ends.push(lineText.length); + hasNonWhitespace.push(currentHasNonWhitespace); + + // The browser treats leading indentation before an unbreakable token as + // its own visual line (the indentation sits on line N, the broken word + // begins on line N+1). For wrap-line accounting we want the indentation + // to stay attached to the content it precedes, so merge any + // whitespace-only line into the line that follows it. + const mergedStarts: number[] = []; + const mergedEnds: number[] = []; + const mergedWhitespaceOnly: boolean[] = []; + for (let i = 0; i < starts.length; i++) { + const start = starts[i]; + const end = ends[i]; + const isWhitespaceOnly = !hasNonWhitespace[i] && end > start; + + const prevIndex = mergedStarts.length - 1; + if (prevIndex >= 0) { + if (mergedWhitespaceOnly[prevIndex] === true) { + mergedEnds[prevIndex] = end; + mergedWhitespaceOnly[prevIndex] = isWhitespaceOnly; + continue; + } + } + + mergedStarts.push(start); + mergedEnds.push(end); + mergedWhitespaceOnly.push(isWhitespaceOnly); + } + + const offsets = new Uint32Array(mergedStarts.length + 1); + for (let i = 0; i < mergedStarts.length; i++) { + offsets[i] = mergedStarts[i]!; + } + offsets[mergedStarts.length] = lineText.length; + this.#wrapLineOffsetsCache.set(line, offsets); + return offsets; + } finally { + div.remove(); + } + } + // check if the web selection belongs to editor #selectionBelongsToEditor(composedRanges: StaticRange[]) { const contentEl = this.#contentEl; From 7fc5f61c1222da0a5f3723f696838a24a8dea7ea Mon Sep 17 00:00:00 2001 From: Je Xia Date: Thu, 7 May 2026 15:11:34 +0800 Subject: [PATCH 078/138] Clean up --- packages/diffs/src/editor/index.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/diffs/src/editor/index.ts b/packages/diffs/src/editor/index.ts index 948523cca..52b352bbe 100644 --- a/packages/diffs/src/editor/index.ts +++ b/packages/diffs/src/editor/index.ts @@ -1,12 +1,7 @@ import { type IGrammar, INITIAL, type StateStack } from 'shiki/textmate'; -import { - areThemesAttached, - DEFAULT_THEMES, - getFiletypeFromFileName, - getHighlighterIfLoaded, -} from '..'; import type { File } from '../components/File'; +import { DEFAULT_THEMES } from '../constants'; import { type EditorCommand, isPrimaryModifier, @@ -38,6 +33,8 @@ import { round, } from '../editor/editorUtils'; import { TextDocument, type TextEdit } from '../editor/textDocument'; +import { getHighlighterIfLoaded } from '../highlighter/shared_highlighter'; +import { areThemesAttached } from '../highlighter/themes/areThemesAttached'; import type { DiffsEditor, DiffsHighlighter, @@ -46,6 +43,7 @@ import type { LineAnnotation, RenderRange, } from '../types'; +import { getFiletypeFromFileName } from '../utils/getFiletypeFromFileName'; import { EDITOR_CSS, TOKENIZE_MAX_LINE_LENGTH, From c3a1fa80db1c8b4131b3f266591fc4de531d1767 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Thu, 7 May 2026 15:32:03 +0800 Subject: [PATCH 079/138] Fix line y/wrap cache --- packages/diffs/src/editor/index.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/diffs/src/editor/index.ts b/packages/diffs/src/editor/index.ts index 52b352bbe..fe41e04e2 100644 --- a/packages/diffs/src/editor/index.ts +++ b/packages/diffs/src/editor/index.ts @@ -516,8 +516,14 @@ export class Editor implements DiffsEditor { } } } else { - this.#wrapLineOffsetsCache.delete(lastChange.startLine); - this.#lineYCache.delete(lastChange.startLine); + for ( + let line = lastChange.startLine; + line <= lastChange.endLine; + line++ + ) { + this.#wrapLineOffsetsCache.delete(line); + this.#lineYCache.delete(line); + } } const t = performance.now(); From e567b0b6a09083a74972534509ca477152f21818 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Thu, 7 May 2026 21:41:53 +0800 Subject: [PATCH 080/138] Fix line cache --- packages/diffs/src/editor/index.ts | 44 ++++++++++++++---------------- 1 file changed, 21 insertions(+), 23 deletions(-) diff --git a/packages/diffs/src/editor/index.ts b/packages/diffs/src/editor/index.ts index fe41e04e2..1a61c2fcf 100644 --- a/packages/diffs/src/editor/index.ts +++ b/packages/diffs/src/editor/index.ts @@ -311,16 +311,21 @@ export class Editor implements DiffsEditor { // this.#focusTextarea(); } - console.log( - '[triggerEdit]', - 'renderRange:', - (renderRange?.startingLine ?? 0) + - '-' + - (renderRange?.totalLines ?? Infinity), - 'of', - this.#textDocument.lineCount, - 'lines' - ); + if (renderRange !== undefined) { + console.log( + '[diffs]', + 'RenderRange:', + renderRange.startingLine + + '-' + + Math.min( + renderRange.startingLine + renderRange.totalLines, + this.#textDocument.lineCount + ), + 'of', + this.#textDocument.lineCount, + 'lines' + ); + } } #initialize(): void { @@ -505,24 +510,17 @@ export class Editor implements DiffsEditor { // - line inserts/deletes shift line numbers, so clear from startLine onward // - wrapped edits can change visual height, which shifts downstream line Y if (lastChange.lineDelta !== 0) { - for (const line of this.#wrapLineOffsetsCache.keys()) { - if (line >= lastChange.startLine) { - this.#wrapLineOffsetsCache.delete(line); - } - } for (const line of this.#lineYCache.keys()) { if (line >= lastChange.startLine) { this.#lineYCache.delete(line); } } - } else { - for ( - let line = lastChange.startLine; - line <= lastChange.endLine; - line++ - ) { - this.#wrapLineOffsetsCache.delete(line); - this.#lineYCache.delete(line); + } + if (this.#wrap) { + for (const line of this.#wrapLineOffsetsCache.keys()) { + if (line >= lastChange.startLine) { + this.#wrapLineOffsetsCache.delete(line); + } } } From dd12a8a36627f34ff696693322eba1687e44fb2c Mon Sep 17 00:00:00 2001 From: Je Xia Date: Thu, 7 May 2026 22:08:15 +0800 Subject: [PATCH 081/138] Copies leading indentation onto the new line after Enter --- .../diffs/src/editor/editorMultiSelections.ts | 48 ++++++++++++++++--- .../diffs/test/editorMultiSelections.test.ts | 40 ++++++++++++++++ 2 files changed, 81 insertions(+), 7 deletions(-) diff --git a/packages/diffs/src/editor/editorMultiSelections.ts b/packages/diffs/src/editor/editorMultiSelections.ts index cdadee642..dd88d389d 100644 --- a/packages/diffs/src/editor/editorMultiSelections.ts +++ b/packages/diffs/src/editor/editorMultiSelections.ts @@ -133,21 +133,26 @@ export function applyTextChangeToSelections( if (mergedGroup === undefined) { return; } + const newText = expandSingleNewlineInsert( + textDocument, + change.text, + mergedGroup.start + ); edits.push({ range: { start: textDocument.positionAt(mergedGroup.start), end: textDocument.positionAt(mergedGroup.end), }, - newText: change.text, + newText, }); const nextOffsets: [number, number] = [ - mergedGroup.start + offsetDelta + change.text.length, - mergedGroup.start + offsetDelta + change.text.length, + mergedGroup.start + offsetDelta + newText.length, + mergedGroup.start + offsetDelta + newText.length, ]; for (const index of mergedGroup.indices) { nextSelectionOffsets[index] = nextOffsets; } - offsetDelta += change.text.length - (mergedGroup.end - mergedGroup.start); + offsetDelta += newText.length - (mergedGroup.end - mergedGroup.start); mergedGroup = undefined; }; for (const entry of ordered) { @@ -229,16 +234,21 @@ export function applyTextReplaceToSelections( throw new Error('Overlapping multi-selection edits are not supported'); } previousEditEnd = entry.end; + const newText = expandSingleNewlineInsert( + textDocument, + entry.text, + entry.start + ); edits.push({ range: { start: textDocument.positionAt(entry.start), end: textDocument.positionAt(entry.end), }, - newText: entry.text, + newText, }); nextSelectionOffsets[entry.index] = - entry.start + offsetDelta + entry.text.length; - offsetDelta += entry.text.length - (entry.end - entry.start); + entry.start + offsetDelta + newText.length; + offsetDelta += newText.length - (entry.end - entry.start); } textDocument.applyEdits(edits, true, selections, undefined, lineAnnotations); const nextSelections = nextSelectionOffsets.map((offset) => @@ -290,3 +300,27 @@ function getSelectionAnchorAndFocusOffsets( textDocument.offsetAt(isBackward ? selection.start : selection.end), ]; } + +/** When the user inserts a lone line break, copy the current line's indentation onto the new line. */ +function expandSingleNewlineInsert( + textDocument: TextDocument, + insertText: string, + insertStartOffset: number +): string { + if (insertText !== '\n' && insertText !== '\r\n') { + return insertText; + } + const line = textDocument.positionAt(insertStartOffset).line; + const lineText = textDocument.getLineText(line); + let indentLen = 0; + for (; indentLen < lineText.length; indentLen++) { + const ch = lineText[indentLen]; + if (ch !== ' ' && ch !== '\t') { + break; + } + } + if (indentLen === 0) { + return insertText; + } + return '\n' + lineText.slice(0, indentLen); +} diff --git a/packages/diffs/test/editorMultiSelections.test.ts b/packages/diffs/test/editorMultiSelections.test.ts index 109a7b0c3..dd6166e2f 100644 --- a/packages/diffs/test/editorMultiSelections.test.ts +++ b/packages/diffs/test/editorMultiSelections.test.ts @@ -146,6 +146,46 @@ describe('mapSelectionTextChange', () => { expect(nextSelections).toEqual([createSelection(1, 0, 1, 0)]); }); + test('copies leading indentation onto the new line after Enter', () => { + const textDocument = new TextDocument('inmemory://1', ' foo\nbar'); + const selections = [createSelection(0, 5, 0, 5)]; + const { nextSelections } = applyTextChangeToSelections( + textDocument, + selections, + { + start: 5, + end: 5, + text: '\n', + } + ); + + expect(textDocument.getText()).toBe(' foo\n \nbar'); + expect(nextSelections).toEqual([createSelection(1, 2, 1, 2)]); + }); + + test('uses each line’s indent when inserting a newline at multiple carets', () => { + const textDocument = new TextDocument('inmemory://1', ' a\n\tb'); + const selections = [ + createSelection(0, 3, 0, 3), + createSelection(1, 2, 1, 2), + ]; + const { nextSelections } = applyTextChangeToSelections( + textDocument, + selections, + { + start: 6, + end: 6, + text: '\n', + } + ); + + expect(textDocument.getText()).toBe(' a\n \n\tb\n\t'); + expect(nextSelections).toEqual([ + createSelection(1, 2, 1, 2), + createSelection(3, 1, 3, 1), + ]); + }); + test('moves the caret to the previous line end after deleting a line break', () => { const textDocument = new TextDocument('inmemory://1', 'foo\n\nbar'); const selections = [createSelection(1, 0, 1, 0)]; From 415b6dfffaf1a7b344a27097c1b4aaabec6c81e6 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Thu, 7 May 2026 22:09:46 +0800 Subject: [PATCH 082/138] Focus textare after undo/redo --- packages/diffs/src/editor/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/diffs/src/editor/index.ts b/packages/diffs/src/editor/index.ts index 1a61c2fcf..c9f40f9f0 100644 --- a/packages/diffs/src/editor/index.ts +++ b/packages/diffs/src/editor/index.ts @@ -1267,6 +1267,7 @@ export class Editor implements DiffsEditor { this.#emitChange(); if (undoResult?.selections !== undefined) { this.setSelections(undoResult.selections, false); + this.#focusTextarea(); } } break; @@ -1282,6 +1283,7 @@ export class Editor implements DiffsEditor { this.#emitChange(); if (redoResult?.selections !== undefined) { this.setSelections(redoResult.selections, false); + this.#focusTextarea(); } } break; From c5ad096bcf1758ffbde71deabda3f6b41adbcf63 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Fri, 8 May 2026 11:13:33 +0800 Subject: [PATCH 083/138] Move multi-selection functions to editorSelection module --- .../diffs/src/editor/editorMultiSelections.ts | 326 ----------------- packages/diffs/src/editor/editorSelection.ts | 322 ++++++++++++++++- packages/diffs/src/editor/editorTextarea.ts | 14 +- packages/diffs/src/editor/index.ts | 8 +- packages/diffs/src/editor/tokenzier.ts | 1 + .../diffs/test/editorMultiSelections.test.ts | 334 ------------------ packages/diffs/test/editorSelection.test.ts | 327 +++++++++++++++++ 7 files changed, 662 insertions(+), 670 deletions(-) delete mode 100644 packages/diffs/src/editor/editorMultiSelections.ts delete mode 100644 packages/diffs/test/editorMultiSelections.test.ts diff --git a/packages/diffs/src/editor/editorMultiSelections.ts b/packages/diffs/src/editor/editorMultiSelections.ts deleted file mode 100644 index dd88d389d..000000000 --- a/packages/diffs/src/editor/editorMultiSelections.ts +++ /dev/null @@ -1,326 +0,0 @@ -import type { LineAnnotation } from '../types'; -import { applyDocumentChangeToLineAnnotations } from './editorLineAnnotations'; -import { - DirectionBackward, - DirectionForward, - DirectionNone, - type EditorSelection, -} from './editorSelection'; -import { - type Position, - type ResolvedTextEdit, - TextDocument, - type TextEdit, -} from './textDocument'; - -export function mapSelectionMove( - textDocument: TextDocument, - selections: readonly EditorSelection[], - nextPosition: Position -): EditorSelection[] { - const primarySelection = selections[selections.length - 1]; - if (primarySelection === undefined) { - return []; - } - const deltaLine = nextPosition.line - primarySelection.start.line; - const deltaCharacter = - nextPosition.character - primarySelection.start.character; - const isMoveToLineStart = - deltaLine === 0 && nextPosition.character === 0 && deltaCharacter < -1; - const isMoveToLineEnd = - deltaLine === 0 && - nextPosition.character === - textDocument.getLineText(nextPosition.line)?.length && - deltaCharacter > 1; - return selections.map((selection) => { - let newLine = selection.start.line + deltaLine; - let newCharacter = selection.start.character + deltaCharacter; - if (selection !== primarySelection) { - if (isMoveToLineStart) { - newCharacter = 0; - } else if (isMoveToLineEnd) { - newCharacter = textDocument.getLineText(newLine)?.length ?? 0; - } - } - const newPosition: Position = { - line: newLine, - character: newCharacter, - }; - return { - start: newPosition, - end: newPosition, - direction: DirectionNone, - }; - }); -} - -export function mapSelectionRangeMove( - textDocument: TextDocument, - selections: readonly EditorSelection[], - nextAnchor: Position, - nextFocus: Position -): EditorSelection[] { - const primarySelection = selections[selections.length - 1]; - if (primarySelection === undefined) { - return []; - } - const [primaryAnchorOffset, primaryFocusOffset] = - getSelectionAnchorAndFocusOffsets(textDocument, primarySelection); - const anchorDelta = textDocument.offsetAt(nextAnchor) - primaryAnchorOffset; - const focusDelta = textDocument.offsetAt(nextFocus) - primaryFocusOffset; - return selections.map((selection) => { - const [anchorOffset, focusOffset] = getSelectionAnchorAndFocusOffsets( - textDocument, - selection - ); - return createSelectionFromAnchorAndFocusOffsets( - textDocument, - anchorOffset + anchorDelta, - focusOffset + focusDelta - ); - }); -} - -export function applyTextChangeToSelections( - textDocument: TextDocument, - selections: EditorSelection[], - change: ResolvedTextEdit, - lineAnnotations?: LineAnnotation[] -): { - nextSelections: EditorSelection[]; - newLineAnnotations: LineAnnotation[] | undefined; -} { - const primarySelection = selections[selections.length - 1]; - if (primarySelection === undefined) { - return { nextSelections: [], newLineAnnotations: undefined }; - } - const primaryStartOffset = textDocument.offsetAt(primarySelection.start); - const primaryEndOffset = textDocument.offsetAt(primarySelection.end); - const relativeStart = change.start - primaryStartOffset; - const relativeEnd = change.end - primaryEndOffset; - const ordered = selections - .map((selection, index) => ({ - selection, - index, - start: textDocument.offsetAt(selection.start), - end: textDocument.offsetAt(selection.end), - isPrimary: index === selections.length - 1, - })) - .sort((a, b) => { - const startOrder = a.start - b.start; - if (startOrder !== 0) { - return startOrder; - } - const endOrder = a.end - b.end; - if (endOrder !== 0) { - return endOrder; - } - return a.index - b.index; - }); - const edits: TextEdit[] = []; - const nextSelectionOffsets: Array<[number, number]> = Array.from({ - length: selections.length, - }); - let offsetDelta = 0; - let mergedGroup: - | { - start: number; - end: number; - indices: number[]; - } - | undefined; - const finalizeMergedGroup = () => { - if (mergedGroup === undefined) { - return; - } - const newText = expandSingleNewlineInsert( - textDocument, - change.text, - mergedGroup.start - ); - edits.push({ - range: { - start: textDocument.positionAt(mergedGroup.start), - end: textDocument.positionAt(mergedGroup.end), - }, - newText, - }); - const nextOffsets: [number, number] = [ - mergedGroup.start + offsetDelta + newText.length, - mergedGroup.start + offsetDelta + newText.length, - ]; - for (const index of mergedGroup.indices) { - nextSelectionOffsets[index] = nextOffsets; - } - offsetDelta += newText.length - (mergedGroup.end - mergedGroup.start); - mergedGroup = undefined; - }; - for (const entry of ordered) { - const startOffset = Math.max(0, entry.start + relativeStart); - const endOffset = Math.max(startOffset, entry.end + relativeEnd); - if (mergedGroup !== undefined && startOffset < mergedGroup.end) { - mergedGroup.end = Math.max(mergedGroup.end, endOffset); - mergedGroup.indices.push(entry.index); - continue; - } - finalizeMergedGroup(); - mergedGroup = { - start: startOffset, - end: endOffset, - indices: [entry.index], - }; - } - finalizeMergedGroup(); - textDocument.applyEdits(edits, true, selections, undefined, lineAnnotations); - const nextSelections = nextSelectionOffsets.map((offsets) => - createSelectionFromAnchorAndFocusOffsets(textDocument, ...offsets) - ); - textDocument.setLastUndoSelectionsAfter(nextSelections); - - let newLineAnnotations: LineAnnotation[] | undefined; - if (lineAnnotations !== undefined && textDocument.lastChange !== undefined) { - newLineAnnotations = applyDocumentChangeToLineAnnotations( - textDocument.lastChange, - lineAnnotations - ); - if (newLineAnnotations !== undefined) { - textDocument.setLastUndoLineAnnotationsAfter(newLineAnnotations); - } - } - - return { nextSelections, newLineAnnotations }; -} - -export function applyTextReplaceToSelections( - textDocument: TextDocument, - selections: EditorSelection[], - texts: readonly string[], - lineAnnotations?: LineAnnotation[] -): { - nextSelections: EditorSelection[]; - newLineAnnotations: LineAnnotation[] | undefined; -} { - if (selections.length !== texts.length) { - throw new Error( - 'Selection text replacements must match the selection count' - ); - } - const ordered = selections - .map((selection, index) => ({ - index, - start: textDocument.offsetAt(selection.start), - end: textDocument.offsetAt(selection.end), - text: texts[index], - })) - .sort((a, b) => { - const startOrder = a.start - b.start; - if (startOrder !== 0) { - return startOrder; - } - const endOrder = a.end - b.end; - if (endOrder !== 0) { - return endOrder; - } - return a.index - b.index; - }); - const edits: TextEdit[] = []; - const nextSelectionOffsets: number[] = Array.from({ - length: selections.length, - }); - let offsetDelta = 0; - let previousEditEnd = -1; - for (const entry of ordered) { - if (entry.start < previousEditEnd) { - throw new Error('Overlapping multi-selection edits are not supported'); - } - previousEditEnd = entry.end; - const newText = expandSingleNewlineInsert( - textDocument, - entry.text, - entry.start - ); - edits.push({ - range: { - start: textDocument.positionAt(entry.start), - end: textDocument.positionAt(entry.end), - }, - newText, - }); - nextSelectionOffsets[entry.index] = - entry.start + offsetDelta + newText.length; - offsetDelta += newText.length - (entry.end - entry.start); - } - textDocument.applyEdits(edits, true, selections, undefined, lineAnnotations); - const nextSelections = nextSelectionOffsets.map((offset) => - createSelectionFromAnchorAndFocusOffsets(textDocument, offset, offset) - ); - textDocument.setLastUndoSelectionsAfter(nextSelections); - - let newLineAnnotations: LineAnnotation[] | undefined; - if (lineAnnotations !== undefined && textDocument.lastChange !== undefined) { - newLineAnnotations = applyDocumentChangeToLineAnnotations( - textDocument.lastChange, - lineAnnotations - ); - if (newLineAnnotations !== undefined) { - textDocument.setLastUndoLineAnnotationsAfter(newLineAnnotations); - } - } - - return { nextSelections, newLineAnnotations }; -} - -export function createSelectionFromAnchorAndFocusOffsets( - textDocument: TextDocument, - anchorOffset: number, - focusOffset: number -): EditorSelection { - const direction = - anchorOffset === focusOffset - ? DirectionNone - : anchorOffset < focusOffset - ? DirectionForward - : DirectionBackward; - const start = Math.min(anchorOffset, focusOffset); - const end = Math.max(anchorOffset, focusOffset); - return { - start: textDocument.positionAt(start), - end: textDocument.positionAt(end), - direction, - }; -} - -function getSelectionAnchorAndFocusOffsets( - textDocument: TextDocument, - selection: EditorSelection -): [anchorOffset: number, focusOffset: number] { - const isBackward = selection.direction === DirectionBackward; - return [ - textDocument.offsetAt(isBackward ? selection.end : selection.start), - textDocument.offsetAt(isBackward ? selection.start : selection.end), - ]; -} - -/** When the user inserts a lone line break, copy the current line's indentation onto the new line. */ -function expandSingleNewlineInsert( - textDocument: TextDocument, - insertText: string, - insertStartOffset: number -): string { - if (insertText !== '\n' && insertText !== '\r\n') { - return insertText; - } - const line = textDocument.positionAt(insertStartOffset).line; - const lineText = textDocument.getLineText(line); - let indentLen = 0; - for (; indentLen < lineText.length; indentLen++) { - const ch = lineText[indentLen]; - if (ch !== ' ' && ch !== '\t') { - break; - } - } - if (indentLen === 0) { - return insertText; - } - return '\n' + lineText.slice(0, indentLen); -} diff --git a/packages/diffs/src/editor/editorSelection.ts b/packages/diffs/src/editor/editorSelection.ts index ca700ce54..06b71fb07 100644 --- a/packages/diffs/src/editor/editorSelection.ts +++ b/packages/diffs/src/editor/editorSelection.ts @@ -1,4 +1,12 @@ -import type { Position, Range, TextDocument, TextEdit } from './textDocument'; +import type { LineAnnotation } from '../types'; +import { applyDocumentChangeToLineAnnotations } from './editorLineAnnotations'; +import type { + Position, + Range, + ResolvedTextEdit, + TextDocument, + TextEdit, +} from './textDocument'; export const DirectionBackward = -1; export const DirectionNone = 0; @@ -103,6 +111,263 @@ export function resolveIndentEdits( return [edits, newSelection]; } +export function mapSelectionMove( + textDocument: TextDocument, + selections: readonly EditorSelection[], + nextPosition: Position +): EditorSelection[] { + const primarySelection = selections[selections.length - 1]; + if (primarySelection === undefined) { + return []; + } + const deltaLine = nextPosition.line - primarySelection.start.line; + const deltaCharacter = + nextPosition.character - primarySelection.start.character; + const isMoveToLineStart = + deltaLine === 0 && nextPosition.character === 0 && deltaCharacter < -1; + const isMoveToLineEnd = + deltaLine === 0 && + nextPosition.character === + textDocument.getLineText(nextPosition.line)?.length && + deltaCharacter > 1; + return selections.map((selection) => { + let newLine = selection.start.line + deltaLine; + let newCharacter = selection.start.character + deltaCharacter; + if (selection !== primarySelection) { + if (isMoveToLineStart) { + newCharacter = 0; + } else if (isMoveToLineEnd) { + newCharacter = textDocument.getLineText(newLine)?.length ?? 0; + } + } + const newPosition: Position = { + line: newLine, + character: newCharacter, + }; + return { + start: newPosition, + end: newPosition, + direction: DirectionNone, + }; + }); +} + +export function mapSelectionRangeMove( + textDocument: TextDocument, + selections: readonly EditorSelection[], + nextAnchor: Position, + nextFocus: Position +): EditorSelection[] { + const primarySelection = selections[selections.length - 1]; + if (primarySelection === undefined) { + return []; + } + const [primaryAnchorOffset, primaryFocusOffset] = + getSelectionAnchorAndFocusOffsets(textDocument, primarySelection); + const anchorDelta = textDocument.offsetAt(nextAnchor) - primaryAnchorOffset; + const focusDelta = textDocument.offsetAt(nextFocus) - primaryFocusOffset; + return selections.map((selection) => { + const [anchorOffset, focusOffset] = getSelectionAnchorAndFocusOffsets( + textDocument, + selection + ); + return createSelectionFromAnchorAndFocusOffsets( + textDocument, + anchorOffset + anchorDelta, + focusOffset + focusDelta + ); + }); +} + +export function applyTextChangeToSelections( + textDocument: TextDocument, + selections: EditorSelection[], + change: ResolvedTextEdit, + lineAnnotations?: LineAnnotation[] +): { + nextSelections: EditorSelection[]; + newLineAnnotations: LineAnnotation[] | undefined; +} { + const primarySelection = selections[selections.length - 1]; + if (primarySelection === undefined) { + return { nextSelections: [], newLineAnnotations: undefined }; + } + const primaryStartOffset = textDocument.offsetAt(primarySelection.start); + const primaryEndOffset = textDocument.offsetAt(primarySelection.end); + const relativeStart = change.start - primaryStartOffset; + const relativeEnd = change.end - primaryEndOffset; + const ordered = selections + .map((selection, index) => ({ + selection, + index, + start: textDocument.offsetAt(selection.start), + end: textDocument.offsetAt(selection.end), + isPrimary: index === selections.length - 1, + })) + .sort((a, b) => { + const startOrder = a.start - b.start; + if (startOrder !== 0) { + return startOrder; + } + const endOrder = a.end - b.end; + if (endOrder !== 0) { + return endOrder; + } + return a.index - b.index; + }); + const edits: TextEdit[] = []; + const nextSelectionOffsets: Array<[number, number]> = Array.from({ + length: selections.length, + }); + let offsetDelta = 0; + let mergedGroup: + | { + start: number; + end: number; + indices: number[]; + } + | undefined; + const finalizeMergedGroup = () => { + if (mergedGroup === undefined) { + return; + } + const newText = expandSingleNewlineInsert( + textDocument, + change.text, + mergedGroup.start + ); + edits.push({ + range: { + start: textDocument.positionAt(mergedGroup.start), + end: textDocument.positionAt(mergedGroup.end), + }, + newText, + }); + const nextOffsets: [number, number] = [ + mergedGroup.start + offsetDelta + newText.length, + mergedGroup.start + offsetDelta + newText.length, + ]; + for (const index of mergedGroup.indices) { + nextSelectionOffsets[index] = nextOffsets; + } + offsetDelta += newText.length - (mergedGroup.end - mergedGroup.start); + mergedGroup = undefined; + }; + for (const entry of ordered) { + const startOffset = Math.max(0, entry.start + relativeStart); + const endOffset = Math.max(startOffset, entry.end + relativeEnd); + if (mergedGroup !== undefined && startOffset < mergedGroup.end) { + mergedGroup.end = Math.max(mergedGroup.end, endOffset); + mergedGroup.indices.push(entry.index); + continue; + } + finalizeMergedGroup(); + mergedGroup = { + start: startOffset, + end: endOffset, + indices: [entry.index], + }; + } + finalizeMergedGroup(); + textDocument.applyEdits(edits, true, selections, undefined, lineAnnotations); + const nextSelections = nextSelectionOffsets.map((offsets) => + createSelectionFromAnchorAndFocusOffsets(textDocument, ...offsets) + ); + textDocument.setLastUndoSelectionsAfter(nextSelections); + + let newLineAnnotations: LineAnnotation[] | undefined; + if (lineAnnotations !== undefined && textDocument.lastChange !== undefined) { + newLineAnnotations = applyDocumentChangeToLineAnnotations( + textDocument.lastChange, + lineAnnotations + ); + if (newLineAnnotations !== undefined) { + textDocument.setLastUndoLineAnnotationsAfter(newLineAnnotations); + } + } + + return { nextSelections, newLineAnnotations }; +} + +export function applyTextReplaceToSelections( + textDocument: TextDocument, + selections: EditorSelection[], + texts: readonly string[], + lineAnnotations?: LineAnnotation[] +): { + nextSelections: EditorSelection[]; + newLineAnnotations: LineAnnotation[] | undefined; +} { + if (selections.length !== texts.length) { + throw new Error( + 'Selection text replacements must match the selection count' + ); + } + const ordered = selections + .map((selection, index) => ({ + index, + start: textDocument.offsetAt(selection.start), + end: textDocument.offsetAt(selection.end), + text: texts[index], + })) + .sort((a, b) => { + const startOrder = a.start - b.start; + if (startOrder !== 0) { + return startOrder; + } + const endOrder = a.end - b.end; + if (endOrder !== 0) { + return endOrder; + } + return a.index - b.index; + }); + const edits: TextEdit[] = []; + const nextSelectionOffsets: number[] = Array.from({ + length: selections.length, + }); + let offsetDelta = 0; + let previousEditEnd = -1; + for (const entry of ordered) { + if (entry.start < previousEditEnd) { + throw new Error('Overlapping multi-selection edits are not supported'); + } + previousEditEnd = entry.end; + const newText = expandSingleNewlineInsert( + textDocument, + entry.text, + entry.start + ); + edits.push({ + range: { + start: textDocument.positionAt(entry.start), + end: textDocument.positionAt(entry.end), + }, + newText, + }); + nextSelectionOffsets[entry.index] = + entry.start + offsetDelta + newText.length; + offsetDelta += newText.length - (entry.end - entry.start); + } + textDocument.applyEdits(edits, true, selections, undefined, lineAnnotations); + const nextSelections = nextSelectionOffsets.map((offset) => + createSelectionFromAnchorAndFocusOffsets(textDocument, offset, offset) + ); + textDocument.setLastUndoSelectionsAfter(nextSelections); + + let newLineAnnotations: LineAnnotation[] | undefined; + if (lineAnnotations !== undefined && textDocument.lastChange !== undefined) { + newLineAnnotations = applyDocumentChangeToLineAnnotations( + textDocument.lastChange, + lineAnnotations + ); + if (newLineAnnotations !== undefined) { + textDocument.setLastUndoLineAnnotationsAfter(newLineAnnotations); + } + } + + return { nextSelections, newLineAnnotations }; +} + export function isCollapsedSelection(selection: EditorSelection): boolean { return ( selection.start.line === selection.end.line && @@ -143,6 +408,61 @@ export function comparePosition(a: Position, b: Position): number { return a.character - b.character; } +export function createSelectionFromAnchorAndFocusOffsets( + textDocument: TextDocument, + anchorOffset: number, + focusOffset: number +): EditorSelection { + const direction = + anchorOffset === focusOffset + ? DirectionNone + : anchorOffset < focusOffset + ? DirectionForward + : DirectionBackward; + const start = Math.min(anchorOffset, focusOffset); + const end = Math.max(anchorOffset, focusOffset); + return { + start: textDocument.positionAt(start), + end: textDocument.positionAt(end), + direction, + }; +} + +function getSelectionAnchorAndFocusOffsets( + textDocument: TextDocument, + selection: EditorSelection +): [anchorOffset: number, focusOffset: number] { + const isBackward = selection.direction === DirectionBackward; + return [ + textDocument.offsetAt(isBackward ? selection.end : selection.start), + textDocument.offsetAt(isBackward ? selection.start : selection.end), + ]; +} + +/** When the user inserts a lone line break, copy the current line's indentation onto the new line. */ +function expandSingleNewlineInsert( + textDocument: TextDocument, + insertText: string, + insertStartOffset: number +): string { + if (insertText !== '\n' && insertText !== '\r\n') { + return insertText; + } + const line = textDocument.positionAt(insertStartOffset).line; + const lineText = textDocument.getLineText(line); + let indentLen = 0; + for (; indentLen < lineText.length; indentLen++) { + const ch = lineText[indentLen]; + if (ch !== ' ' && ch !== '\t') { + break; + } + } + if (indentLen === 0) { + return insertText; + } + return '\n' + lineText.slice(0, indentLen); +} + function boundaryToPosition(node: Node, offset: number): Position | null { if (node.nodeType === 3) { const parent = node.parentElement; diff --git a/packages/diffs/src/editor/editorTextarea.ts b/packages/diffs/src/editor/editorTextarea.ts index 053874ed6..5cb520dad 100644 --- a/packages/diffs/src/editor/editorTextarea.ts +++ b/packages/diffs/src/editor/editorTextarea.ts @@ -127,9 +127,14 @@ export function resolveTextareaChange( export function getSelectionDirectionFromTextarea( textareaEl: HTMLTextAreaElement ): SelectionDirection { - return textareaEl.selectionDirection === 'backward' - ? DirectionBackward - : DirectionForward; + switch (textareaEl.selectionDirection) { + case 'backward': + return DirectionBackward; + case 'forward': + return DirectionForward; + case 'none': + return DirectionNone; + } } export function toTextareaSelectionDirection( @@ -145,7 +150,8 @@ export function toTextareaSelectionDirection( } } -/** Aligns a column with `TextDocument.offsetAt` / `positionAt` so textarea indices match backing text (DOM may report past end for empty lines that render a placeholder space). */ +// Aligns a column with `TextDocument.offsetAt` / `positionAt` so textarea indices match backing text +// (DOM may report past end for empty lines that render a placeholder space). function normalizeCharacterForDocument( textDocument: TextDocument, position: Position diff --git a/packages/diffs/src/editor/index.ts b/packages/diffs/src/editor/index.ts index c9f40f9f0..e74edea75 100644 --- a/packages/diffs/src/editor/index.ts +++ b/packages/diffs/src/editor/index.ts @@ -7,20 +7,18 @@ import { isPrimaryModifier, resolveEditorCommandFromKeyboardEvent, } from '../editor/editorCommand'; +import type { EditorSelection } from '../editor/editorSelection'; import { applyTextChangeToSelections, applyTextReplaceToSelections, - mapSelectionMove, - mapSelectionRangeMove, -} from '../editor/editorMultiSelections'; -import type { EditorSelection } from '../editor/editorSelection'; -import { comparePosition, convertSelection, DirectionBackward, DirectionForward, DirectionNone, isCollapsedSelection, + mapSelectionMove, + mapSelectionRangeMove, resolveIndentEdits, type SelectionDirection, selectionIntersects, diff --git a/packages/diffs/src/editor/tokenzier.ts b/packages/diffs/src/editor/tokenzier.ts index 3f7939c48..a48daaf13 100644 --- a/packages/diffs/src/editor/tokenzier.ts +++ b/packages/diffs/src/editor/tokenzier.ts @@ -60,6 +60,7 @@ export class BackgroundTokenizer { this.#isStopped = false; this.#lastLine = startLine; this.#lastState = state; + // use `postMessage` instead of `setTimeout(fn, 0)` to avoid 4ms delay postMessage(this.#messageKey); } diff --git a/packages/diffs/test/editorMultiSelections.test.ts b/packages/diffs/test/editorMultiSelections.test.ts deleted file mode 100644 index dd6166e2f..000000000 --- a/packages/diffs/test/editorMultiSelections.test.ts +++ /dev/null @@ -1,334 +0,0 @@ -import { describe, expect, test } from 'bun:test'; - -import { - applyTextChangeToSelections, - applyTextReplaceToSelections, - mapSelectionMove, - mapSelectionRangeMove, -} from '../src/editor/editorMultiSelections'; -import type { EditorSelection } from '../src/editor/editorSelection'; -import { - DirectionBackward, - DirectionForward, - DirectionNone, - type SelectionDirection, -} from '../src/editor/editorSelection'; -import { TextDocument } from '../src/editor/textDocument'; -import type { LineAnnotation } from '../src/types'; - -function createSelection( - startLine: number, - startCharacter: number, - endLine: number, - endCharacter: number, - direction: SelectionDirection = DirectionNone -): EditorSelection { - return { - start: { line: startLine, character: startCharacter }, - end: { line: endLine, character: endCharacter }, - direction, - }; -} - -describe('mapSelectionTextChange', () => { - test('inserts the same text at multiple carets', () => { - const textDocument = new TextDocument('inmemory://1', 'a\nb\nc'); - const selections = [ - createSelection(0, 1, 0, 1), - createSelection(1, 1, 1, 1), - createSelection(2, 1, 2, 1), - ]; - const { nextSelections } = applyTextChangeToSelections( - textDocument, - selections, - { - start: 5, - end: 5, - text: '!', - } - ); - - expect(textDocument.getText()).toBe('a!\nb!\nc!'); - expect(nextSelections).toEqual([ - createSelection(0, 2, 0, 2), - createSelection(1, 2, 1, 2), - createSelection(2, 2, 2, 2), - ]); - }); - - test('replaces each selected range with the typed text', () => { - const textDocument = new TextDocument('inmemory://1', 'foo bar baz'); - const selections = [ - createSelection(0, 0, 0, 3, DirectionForward), - createSelection(0, 4, 0, 7, DirectionForward), - createSelection(0, 8, 0, 11, DirectionForward), - ]; - const { nextSelections } = applyTextChangeToSelections( - textDocument, - selections, - { - start: 8, - end: 11, - text: 'x', - } - ); - - expect(textDocument.getText()).toBe('x x x'); - expect(nextSelections).toEqual([ - createSelection(0, 1, 0, 1), - createSelection(0, 3, 0, 3), - createSelection(0, 5, 0, 5), - ]); - }); - - test('mirrors backspace for multiple carets', () => { - const textDocument = new TextDocument('inmemory://1', 'ax\nbx\ncx'); - const selections = [ - createSelection(0, 1, 0, 1), - createSelection(1, 1, 1, 1), - createSelection(2, 1, 2, 1), - ]; - const { nextSelections } = applyTextChangeToSelections( - textDocument, - selections, - { - start: 6, - end: 7, - text: '', - } - ); - - expect(textDocument.getText()).toBe('x\nx\nx'); - expect(nextSelections).toEqual([ - createSelection(0, 0, 0, 0), - createSelection(1, 0, 1, 0), - createSelection(2, 0, 2, 0), - ]); - }); - - test('coalesces transformed edits that would overlap', () => { - const textDocument = new TextDocument('inmemory://1', ' '); - const selections = [ - createSelection(0, 1, 0, 1), - createSelection(0, 2, 0, 2), - ]; - const { nextSelections } = applyTextChangeToSelections( - textDocument, - selections, - { - start: 0, - end: 2, - text: '', - } - ); - - expect(textDocument.getText()).toBe(' '); - expect(nextSelections).toEqual([ - createSelection(0, 0, 0, 0), - createSelection(0, 0, 0, 0), - ]); - }); - - test('places the caret on the inserted blank line after Enter', () => { - const textDocument = new TextDocument('inmemory://1', 'foo\nbar'); - const selections = [createSelection(0, 3, 0, 3)]; - const { nextSelections } = applyTextChangeToSelections( - textDocument, - selections, - { - start: 3, - end: 3, - text: '\n', - } - ); - - expect(textDocument.getText()).toBe('foo\n\nbar'); - expect(nextSelections).toEqual([createSelection(1, 0, 1, 0)]); - }); - - test('copies leading indentation onto the new line after Enter', () => { - const textDocument = new TextDocument('inmemory://1', ' foo\nbar'); - const selections = [createSelection(0, 5, 0, 5)]; - const { nextSelections } = applyTextChangeToSelections( - textDocument, - selections, - { - start: 5, - end: 5, - text: '\n', - } - ); - - expect(textDocument.getText()).toBe(' foo\n \nbar'); - expect(nextSelections).toEqual([createSelection(1, 2, 1, 2)]); - }); - - test('uses each line’s indent when inserting a newline at multiple carets', () => { - const textDocument = new TextDocument('inmemory://1', ' a\n\tb'); - const selections = [ - createSelection(0, 3, 0, 3), - createSelection(1, 2, 1, 2), - ]; - const { nextSelections } = applyTextChangeToSelections( - textDocument, - selections, - { - start: 6, - end: 6, - text: '\n', - } - ); - - expect(textDocument.getText()).toBe(' a\n \n\tb\n\t'); - expect(nextSelections).toEqual([ - createSelection(1, 2, 1, 2), - createSelection(3, 1, 3, 1), - ]); - }); - - test('moves the caret to the previous line end after deleting a line break', () => { - const textDocument = new TextDocument('inmemory://1', 'foo\n\nbar'); - const selections = [createSelection(1, 0, 1, 0)]; - const { nextSelections } = applyTextChangeToSelections( - textDocument, - selections, - { - start: 3, - end: 4, - text: '', - } - ); - - expect(textDocument.getText()).toBe('foo\nbar'); - expect(nextSelections).toEqual([createSelection(0, 3, 0, 3)]); - }); -}); - -describe('mapSelectionMove', () => { - test('moves all carets when the primary caret moves', () => { - const textDocument = new TextDocument('inmemory://1', 'ab\ncd\nef'); - const selections = [ - createSelection(0, 1, 0, 1), - createSelection(1, 1, 1, 1), - createSelection(2, 1, 2, 1), - ]; - - expect( - mapSelectionMove(textDocument, selections, { line: 2, character: 0 }) - ).toEqual([ - createSelection(0, 0, 0, 0), - createSelection(1, 0, 1, 0), - createSelection(2, 0, 2, 0), - ]); - }); - - test('extends all selections when the primary selection grows', () => { - const textDocument = new TextDocument('inmemory://1', 'abcd\nefgh'); - const selections = [ - createSelection(0, 1, 0, 2, DirectionForward), - createSelection(1, 1, 1, 2, DirectionForward), - ]; - - expect( - mapSelectionMove(textDocument, selections, { line: 1, character: 1 }) - ).toEqual([ - createSelection(0, 1, 0, 1, DirectionNone), - createSelection(1, 1, 1, 1, DirectionNone), - ]); - }); -}); - -describe('mapSelectionRangeMove', () => { - test('extends all carets when the primary textarea selection becomes a range', () => { - const textDocument = new TextDocument('inmemory://1', 'abcd\nefgh'); - const selections = [ - createSelection(0, 1, 0, 1), - createSelection(1, 1, 1, 1), - ]; - - expect( - mapSelectionRangeMove( - textDocument, - selections, - { line: 1, character: 1 }, - { line: 1, character: 3 } - ) - ).toEqual([ - createSelection(0, 1, 0, 3, DirectionForward), - createSelection(1, 1, 1, 3, DirectionForward), - ]); - }); - - test('preserves backward selection direction from the textarea focus', () => { - const textDocument = new TextDocument('inmemory://1', 'abcd\nefgh'); - const selections = [ - createSelection(0, 2, 0, 2), - createSelection(1, 2, 1, 2), - ]; - - expect( - mapSelectionRangeMove( - textDocument, - selections, - { line: 1, character: 2 }, - { line: 1, character: 0 } - ) - ).toEqual([ - createSelection(0, 0, 0, 2, DirectionBackward), - createSelection(1, 0, 1, 2, DirectionBackward), - ]); - }); -}); - -describe('mapSelectionTextReplace', () => { - test('replaces each selection with its own pasted text', () => { - const textDocument = new TextDocument('inmemory://1', 'x\ny\nz'); - const selections = [ - createSelection(0, 1, 0, 1), - createSelection(1, 1, 1, 1), - createSelection(2, 1, 2, 1), - ]; - const { nextSelections } = applyTextReplaceToSelections( - textDocument, - selections, - ['a', 'b', 'c'] - ); - - expect(textDocument.getText()).toBe('xa\nyb\nzc'); - expect(nextSelections).toEqual([ - createSelection(0, 2, 0, 2), - createSelection(1, 2, 1, 2), - createSelection(2, 2, 2, 2), - ]); - }); - - test('updates line annotations after replacements that insert lines', () => { - const textDocument = new TextDocument('inmemory://1', 'x\ny\nz'); - const selections = [createSelection(0, 1, 0, 1)]; - const annotations: LineAnnotation[] = [ - { lineNumber: 1, metadata: 'x' }, - { lineNumber: 2, metadata: 'y' }, - ]; - - const { newLineAnnotations } = applyTextReplaceToSelections( - textDocument, - selections, - ['\ninserted'], - annotations - ); - - expect(textDocument.getText()).toBe('x\ninserted\ny\nz'); - expect(newLineAnnotations).toEqual([ - { lineNumber: 1, metadata: 'x' }, - { lineNumber: 3, metadata: 'y' }, - ]); - expect(textDocument.undo()).toEqual({ - selections, - lineAnnotations: annotations, - }); - expect(textDocument.redo()).toEqual({ - selections: [createSelection(1, 8, 1, 8)], - lineAnnotations: newLineAnnotations, - }); - }); -}); diff --git a/packages/diffs/test/editorSelection.test.ts b/packages/diffs/test/editorSelection.test.ts index 3bc182051..608b1c7f9 100644 --- a/packages/diffs/test/editorSelection.test.ts +++ b/packages/diffs/test/editorSelection.test.ts @@ -1,12 +1,22 @@ import { describe, expect, test } from 'bun:test'; import { + applyTextChangeToSelections, + applyTextReplaceToSelections, convertSelection, DirectionForward, DirectionNone, type EditorSelection, + mapSelectionMove, + mapSelectionRangeMove, selectionIntersects, } from '../src/editor/editorSelection'; +import { + DirectionBackward, + type SelectionDirection, +} from '../src/editor/editorSelection'; +import { TextDocument } from '../src/editor/textDocument'; +import type { LineAnnotation } from '../src/types'; type MockNode = { nodeType: number; @@ -55,6 +65,20 @@ function editorSelection( }; } +function createSelection( + startLine: number, + startCharacter: number, + endLine: number, + endCharacter: number, + direction: SelectionDirection = DirectionNone +): EditorSelection { + return { + start: { line: startLine, character: startCharacter }, + end: { line: endLine, character: endCharacter }, + direction, + }; +} + function pre(line: number, children: MockElement[] = []): MockElement { const element: MockElement = { nodeType: 1, @@ -342,3 +366,306 @@ describe('selectionIntersects', () => { ).toBe(false); }); }); + +describe('applyTextChangeToSelections', () => { + test('inserts the same text at multiple carets', () => { + const textDocument = new TextDocument('inmemory://1', 'a\nb\nc'); + const selections = [ + createSelection(0, 1, 0, 1), + createSelection(1, 1, 1, 1), + createSelection(2, 1, 2, 1), + ]; + const { nextSelections } = applyTextChangeToSelections( + textDocument, + selections, + { + start: 5, + end: 5, + text: '!', + } + ); + + expect(textDocument.getText()).toBe('a!\nb!\nc!'); + expect(nextSelections).toEqual([ + createSelection(0, 2, 0, 2), + createSelection(1, 2, 1, 2), + createSelection(2, 2, 2, 2), + ]); + }); + + test('replaces each selected range with the typed text', () => { + const textDocument = new TextDocument('inmemory://1', 'foo bar baz'); + const selections = [ + createSelection(0, 0, 0, 3, DirectionForward), + createSelection(0, 4, 0, 7, DirectionForward), + createSelection(0, 8, 0, 11, DirectionForward), + ]; + const { nextSelections } = applyTextChangeToSelections( + textDocument, + selections, + { + start: 8, + end: 11, + text: 'x', + } + ); + + expect(textDocument.getText()).toBe('x x x'); + expect(nextSelections).toEqual([ + createSelection(0, 1, 0, 1), + createSelection(0, 3, 0, 3), + createSelection(0, 5, 0, 5), + ]); + }); + + test('mirrors backspace for multiple carets', () => { + const textDocument = new TextDocument('inmemory://1', 'ax\nbx\ncx'); + const selections = [ + createSelection(0, 1, 0, 1), + createSelection(1, 1, 1, 1), + createSelection(2, 1, 2, 1), + ]; + const { nextSelections } = applyTextChangeToSelections( + textDocument, + selections, + { + start: 6, + end: 7, + text: '', + } + ); + + expect(textDocument.getText()).toBe('x\nx\nx'); + expect(nextSelections).toEqual([ + createSelection(0, 0, 0, 0), + createSelection(1, 0, 1, 0), + createSelection(2, 0, 2, 0), + ]); + }); + + test('coalesces transformed edits that would overlap', () => { + const textDocument = new TextDocument('inmemory://1', ' '); + const selections = [ + createSelection(0, 1, 0, 1), + createSelection(0, 2, 0, 2), + ]; + const { nextSelections } = applyTextChangeToSelections( + textDocument, + selections, + { + start: 0, + end: 2, + text: '', + } + ); + + expect(textDocument.getText()).toBe(' '); + expect(nextSelections).toEqual([ + createSelection(0, 0, 0, 0), + createSelection(0, 0, 0, 0), + ]); + }); + + test('places the caret on the inserted blank line after Enter', () => { + const textDocument = new TextDocument('inmemory://1', 'foo\nbar'); + const selections = [createSelection(0, 3, 0, 3)]; + const { nextSelections } = applyTextChangeToSelections( + textDocument, + selections, + { + start: 3, + end: 3, + text: '\n', + } + ); + + expect(textDocument.getText()).toBe('foo\n\nbar'); + expect(nextSelections).toEqual([createSelection(1, 0, 1, 0)]); + }); + + test('copies leading indentation onto the new line after Enter', () => { + const textDocument = new TextDocument('inmemory://1', ' foo\nbar'); + const selections = [createSelection(0, 5, 0, 5)]; + const { nextSelections } = applyTextChangeToSelections( + textDocument, + selections, + { + start: 5, + end: 5, + text: '\n', + } + ); + + expect(textDocument.getText()).toBe(' foo\n \nbar'); + expect(nextSelections).toEqual([createSelection(1, 2, 1, 2)]); + }); + + test("uses each line's indent when inserting a newline at multiple carets", () => { + const textDocument = new TextDocument('inmemory://1', ' a\n\tb'); + const selections = [ + createSelection(0, 3, 0, 3), + createSelection(1, 2, 1, 2), + ]; + const { nextSelections } = applyTextChangeToSelections( + textDocument, + selections, + { + start: 6, + end: 6, + text: '\n', + } + ); + + expect(textDocument.getText()).toBe(' a\n \n\tb\n\t'); + expect(nextSelections).toEqual([ + createSelection(1, 2, 1, 2), + createSelection(3, 1, 3, 1), + ]); + }); + + test('moves the caret to the previous line end after deleting a line break', () => { + const textDocument = new TextDocument('inmemory://1', 'foo\n\nbar'); + const selections = [createSelection(1, 0, 1, 0)]; + const { nextSelections } = applyTextChangeToSelections( + textDocument, + selections, + { + start: 3, + end: 4, + text: '', + } + ); + + expect(textDocument.getText()).toBe('foo\nbar'); + expect(nextSelections).toEqual([createSelection(0, 3, 0, 3)]); + }); +}); + +describe('mapSelectionMove', () => { + test('moves all carets when the primary caret moves', () => { + const textDocument = new TextDocument('inmemory://1', 'ab\ncd\nef'); + const selections = [ + createSelection(0, 1, 0, 1), + createSelection(1, 1, 1, 1), + createSelection(2, 1, 2, 1), + ]; + + expect( + mapSelectionMove(textDocument, selections, { line: 2, character: 0 }) + ).toEqual([ + createSelection(0, 0, 0, 0), + createSelection(1, 0, 1, 0), + createSelection(2, 0, 2, 0), + ]); + }); + + test('extends all selections when the primary selection grows', () => { + const textDocument = new TextDocument('inmemory://1', 'abcd\nefgh'); + const selections = [ + createSelection(0, 1, 0, 2, DirectionForward), + createSelection(1, 1, 1, 2, DirectionForward), + ]; + + expect( + mapSelectionMove(textDocument, selections, { line: 1, character: 1 }) + ).toEqual([ + createSelection(0, 1, 0, 1, DirectionNone), + createSelection(1, 1, 1, 1, DirectionNone), + ]); + }); +}); + +describe('mapSelectionRangeMove', () => { + test('extends all carets when the primary textarea selection becomes a range', () => { + const textDocument = new TextDocument('inmemory://1', 'abcd\nefgh'); + const selections = [ + createSelection(0, 1, 0, 1), + createSelection(1, 1, 1, 1), + ]; + + expect( + mapSelectionRangeMove( + textDocument, + selections, + { line: 1, character: 1 }, + { line: 1, character: 3 } + ) + ).toEqual([ + createSelection(0, 1, 0, 3, DirectionForward), + createSelection(1, 1, 1, 3, DirectionForward), + ]); + }); + + test('preserves backward selection direction from the textarea focus', () => { + const textDocument = new TextDocument('inmemory://1', 'abcd\nefgh'); + const selections = [ + createSelection(0, 2, 0, 2), + createSelection(1, 2, 1, 2), + ]; + + expect( + mapSelectionRangeMove( + textDocument, + selections, + { line: 1, character: 2 }, + { line: 1, character: 0 } + ) + ).toEqual([ + createSelection(0, 0, 0, 2, DirectionBackward), + createSelection(1, 0, 1, 2, DirectionBackward), + ]); + }); +}); + +describe('applyTextReplaceToSelections', () => { + test('replaces each selection with its own pasted text', () => { + const textDocument = new TextDocument('inmemory://1', 'x\ny\nz'); + const selections = [ + createSelection(0, 1, 0, 1), + createSelection(1, 1, 1, 1), + createSelection(2, 1, 2, 1), + ]; + const { nextSelections } = applyTextReplaceToSelections( + textDocument, + selections, + ['a', 'b', 'c'] + ); + + expect(textDocument.getText()).toBe('xa\nyb\nzc'); + expect(nextSelections).toEqual([ + createSelection(0, 2, 0, 2), + createSelection(1, 2, 1, 2), + createSelection(2, 2, 2, 2), + ]); + }); + + test('updates line annotations after replacements that insert lines', () => { + const textDocument = new TextDocument('inmemory://1', 'x\ny\nz'); + const selections = [createSelection(0, 1, 0, 1)]; + const annotations: LineAnnotation[] = [ + { lineNumber: 1, metadata: 'x' }, + { lineNumber: 2, metadata: 'y' }, + ]; + + const { newLineAnnotations } = applyTextReplaceToSelections( + textDocument, + selections, + ['\ninserted'], + annotations + ); + + expect(textDocument.getText()).toBe('x\ninserted\ny\nz'); + expect(newLineAnnotations).toEqual([ + { lineNumber: 1, metadata: 'x' }, + { lineNumber: 3, metadata: 'y' }, + ]); + expect(textDocument.undo()).toEqual({ + selections, + lineAnnotations: annotations, + }); + expect(textDocument.redo()).toEqual({ + selections: [createSelection(1, 8, 1, 8)], + lineAnnotations: newLineAnnotations, + }); + }); +}); From 8a387b382f83d533bf9f30053a0c99137cb64689 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Fri, 8 May 2026 11:25:45 +0800 Subject: [PATCH 084/138] Add support for handling leading indentation deletion in applyTextChangeToSelections --- packages/diffs/src/editor/editorSelection.ts | 66 ++++++++++++++++++-- packages/diffs/src/editor/index.ts | 3 +- packages/diffs/test/editorSelection.test.ts | 57 +++++++++++++++++ 3 files changed, 119 insertions(+), 7 deletions(-) diff --git a/packages/diffs/src/editor/editorSelection.ts b/packages/diffs/src/editor/editorSelection.ts index 06b71fb07..a1ffd6569 100644 --- a/packages/diffs/src/editor/editorSelection.ts +++ b/packages/diffs/src/editor/editorSelection.ts @@ -183,7 +183,8 @@ export function applyTextChangeToSelections( textDocument: TextDocument, selections: EditorSelection[], change: ResolvedTextEdit, - lineAnnotations?: LineAnnotation[] + lineAnnotations?: LineAnnotation[], + tabSize = 2 ): { nextSelections: EditorSelection[]; newLineAnnotations: LineAnnotation[] | undefined; @@ -194,8 +195,6 @@ export function applyTextChangeToSelections( } const primaryStartOffset = textDocument.offsetAt(primarySelection.start); const primaryEndOffset = textDocument.offsetAt(primarySelection.end); - const relativeStart = change.start - primaryStartOffset; - const relativeEnd = change.end - primaryEndOffset; const ordered = selections .map((selection, index) => ({ selection, @@ -215,6 +214,12 @@ export function applyTextChangeToSelections( } return a.index - b.index; }); + const adjustedChange = normalizeLeadingIndentDeleteChange( + textDocument, + change, + primarySelection, + tabSize + ); const edits: TextEdit[] = []; const nextSelectionOffsets: Array<[number, number]> = Array.from({ length: selections.length, @@ -233,7 +238,7 @@ export function applyTextChangeToSelections( } const newText = expandSingleNewlineInsert( textDocument, - change.text, + adjustedChange.text, mergedGroup.start ); edits.push({ @@ -254,8 +259,14 @@ export function applyTextChangeToSelections( mergedGroup = undefined; }; for (const entry of ordered) { - const startOffset = Math.max(0, entry.start + relativeStart); - const endOffset = Math.max(startOffset, entry.end + relativeEnd); + const startOffset = Math.max( + 0, + entry.start + (adjustedChange.start - primaryStartOffset) + ); + const endOffset = Math.max( + startOffset, + entry.end + (adjustedChange.end - primaryEndOffset) + ); if (mergedGroup !== undefined && startOffset < mergedGroup.end) { mergedGroup.end = Math.max(mergedGroup.end, endOffset); mergedGroup.indices.push(entry.index); @@ -463,6 +474,49 @@ function expandSingleNewlineInsert( return '\n' + lineText.slice(0, indentLen); } +// Expands a backspace over leading spaces into one soft-tab width so mixed hard/soft indentation +// behaves like the explicit outdent command. +function normalizeLeadingIndentDeleteChange( + textDocument: TextDocument, + change: ResolvedTextEdit, + primarySelection: EditorSelection, + tabSize: number +): ResolvedTextEdit { + if ( + change.text !== '' || + change.start !== change.end - 1 || + primarySelection.start.line !== primarySelection.end.line || + primarySelection.start.character !== primarySelection.end.character + ) { + return change; + } + const caretPosition = textDocument.positionAt(change.end); + if (caretPosition.character === 0) { + return change; + } + const primaryOffset = textDocument.offsetAt(primarySelection.start); + if (change.end !== primaryOffset) { + return change; + } + const lineText = textDocument.getLineText(caretPosition.line); + const leadingText = lineText.slice(0, caretPosition.character); + if (/[^ \t]/.test(leadingText)) { + return change; + } + if (lineText[caretPosition.character - 1] === '\t') { + return change; + } + const softTabStart = Math.max(0, caretPosition.character - tabSize); + const softTabText = lineText.slice(softTabStart, caretPosition.character); + if (softTabText.length === tabSize && /^ +$/.test(softTabText)) { + return { + ...change, + start: change.end - softTabText.length, + }; + } + return change; +} + function boundaryToPosition(node: Node, offset: number): Position | null { if (node.nodeType === 3) { const parent = node.parentElement; diff --git a/packages/diffs/src/editor/index.ts b/packages/diffs/src/editor/index.ts index e74edea75..9ce77a9bc 100644 --- a/packages/diffs/src/editor/index.ts +++ b/packages/diffs/src/editor/index.ts @@ -783,7 +783,8 @@ export class Editor implements DiffsEditor { textDocument, selections, change, - lineAnnotations + lineAnnotations, + this.#tabSize ); this.#rerender(newLineAnnotations); this.#emitChange(); diff --git a/packages/diffs/test/editorSelection.test.ts b/packages/diffs/test/editorSelection.test.ts index 608b1c7f9..58e36bbe9 100644 --- a/packages/diffs/test/editorSelection.test.ts +++ b/packages/diffs/test/editorSelection.test.ts @@ -539,6 +539,63 @@ describe('applyTextChangeToSelections', () => { expect(textDocument.getText()).toBe('foo\nbar'); expect(nextSelections).toEqual([createSelection(0, 3, 0, 3)]); }); + + test('deletes one hard tab when backspacing in leading indentation', () => { + const textDocument = new TextDocument('inmemory://1', '\tfoo'); + const selections = [createSelection(0, 1, 0, 1)]; + const { nextSelections } = applyTextChangeToSelections( + textDocument, + selections, + { + start: 0, + end: 1, + text: '', + }, + undefined, + 2 + ); + + expect(textDocument.getText()).toBe('foo'); + expect(nextSelections).toEqual([createSelection(0, 0, 0, 0)]); + }); + + test('deletes one soft tab when backspacing in leading indentation', () => { + const textDocument = new TextDocument('inmemory://1', ' foo'); + const selections = [createSelection(0, 4, 0, 4)]; + const { nextSelections } = applyTextChangeToSelections( + textDocument, + selections, + { + start: 3, + end: 4, + text: '', + }, + undefined, + 4 + ); + + expect(textDocument.getText()).toBe('foo'); + expect(nextSelections).toEqual([createSelection(0, 0, 0, 0)]); + }); + + test('does not expand deletion outside leading indentation', () => { + const textDocument = new TextDocument('inmemory://1', ' foo'); + const selections = [createSelection(0, 3, 0, 3)]; + const { nextSelections } = applyTextChangeToSelections( + textDocument, + selections, + { + start: 2, + end: 3, + text: '', + }, + undefined, + 2 + ); + + expect(textDocument.getText()).toBe(' oo'); + expect(nextSelections).toEqual([createSelection(0, 2, 0, 2)]); + }); }); describe('mapSelectionMove', () => { From 47e57d47a8b1006b3cb1ba25d8a61fe09b991f53 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Fri, 8 May 2026 12:23:31 +0800 Subject: [PATCH 085/138] Fix selection glitch bug --- packages/diffs/src/editor/editorSelection.ts | 6 +- packages/diffs/src/editor/index.ts | 83 +++++++++++++------- packages/diffs/test/editorSelection.test.ts | 18 ++--- 3 files changed, 62 insertions(+), 45 deletions(-) diff --git a/packages/diffs/src/editor/editorSelection.ts b/packages/diffs/src/editor/editorSelection.ts index a1ffd6569..0c13918fe 100644 --- a/packages/diffs/src/editor/editorSelection.ts +++ b/packages/diffs/src/editor/editorSelection.ts @@ -25,13 +25,9 @@ export interface EditorSelection extends Range { * Converts a selection from a web selection to an editor selection. */ export function convertSelection( - composedRanges: StaticRange[], + range: StaticRange, direction: SelectionDirection = DirectionNone ): EditorSelection | null { - const range = composedRanges[composedRanges.length - 1]; - if (range === undefined) { - return null; - } const start = boundaryToPosition(range.startContainer, range.startOffset); const end = boundaryToPosition(range.endContainer, range.endOffset); if (start === null || end === null) { diff --git a/packages/diffs/src/editor/index.ts b/packages/diffs/src/editor/index.ts index 9ce77a9bc..0f1858793 100644 --- a/packages/diffs/src/editor/index.ts +++ b/packages/diffs/src/editor/index.ts @@ -306,7 +306,7 @@ export class Editor implements DiffsEditor { } if (this.#selections !== undefined) { this.setSelections(this.#selections); - // this.#focusTextarea(); + this.#focusTextarea(); } if (renderRange !== undefined) { @@ -327,7 +327,10 @@ export class Editor implements DiffsEditor { } #initialize(): void { - const isCodeLineTarget = (target?: EventTarget): target is HTMLElement => { + const mouseEventDisposes: (() => void)[] = []; + const targetBelongsCodeLine = ( + target?: EventTarget + ): target is HTMLElement => { if (target === undefined || !(target instanceof HTMLElement)) { return false; } @@ -337,10 +340,12 @@ export class Editor implements DiffsEditor { (tagName === 'SPAN' && dataset.char !== undefined) ); }; + this.#styleEl = createElement('style', { dataset: 'editorCss', textContent: EDITOR_CSS, }); + this.#textareaEl = extend( createElement('textarea', { dataset: 'textarea' }), { @@ -351,6 +356,7 @@ export class Editor implements DiffsEditor { wrap: 'off', } ); + this.#disposes = [ addEventListener(document, 'selectionchange', () => { const shadowRoot = this.#contentEl?.getRootNode(); @@ -371,19 +377,19 @@ export class Editor implements DiffsEditor { } const selectionRaw = document.getSelection(); - const composedRanges = selectionRaw?.getComposedRanges({ + const composedRange = selectionRaw?.getComposedRanges({ shadowRoots: [shadowRoot], - }); + })?.[0]; if ( - composedRanges === undefined || - !this.#selectionBelongsToEditor(composedRanges) + composedRange === undefined || + !this.#rangeBelongsToEditor(composedRange) ) { return; } const selection = convertSelection( - composedRanges, + composedRange, this.#computeMouseSelectionDirection() ); if (selection !== null) { @@ -403,7 +409,8 @@ export class Editor implements DiffsEditor { }), addEventListener(document, 'mousedown', (e) => { - if (!isCodeLineTarget(e.composedPath()[0])) { + const target = e.composedPath()[0]; + if (!targetBelongsCodeLine(target)) { return; } @@ -415,17 +422,45 @@ export class Editor implements DiffsEditor { this.#reservedSelections = undefined; } - if (!e.shiftKey) { - this.#selectionStartY = e.clientY; - this.#selectionStartX = e.clientX; - } + this.#selectionStartY = e.clientY; + this.#selectionStartX = e.clientX; this.#selectionEndX = e.clientX; this.#selectionEndY = e.clientY; + + mouseEventDisposes.push( + // `Selection.getComposedRanges` currently does not preserve the drag direction. + // The workaround is to check the mousemove event to determine the direction of the drag operation. + addEventListener(document, 'mousemove', (e) => { + if ((e.buttons & 1) !== 1) { + return; + } + this.#selectionEndX = e.clientX; + this.#selectionEndY = e.clientY; + }) + ); + + if (this.#contentEl !== undefined) { + mouseEventDisposes.push( + // `Selection.getComposedRanges` sets the `startContainer` to the first line element of + // the content element when the mouse leaves the content element. + // Set `shouldIgnoreSelectionChange` to true to avoid the glitch bug. + // TODO(@ije): update the seletion when mouse moving on the gutter. + addEventListener(this.#contentEl, 'mouseleave', () => { + this.#shouldIgnoreSelectionChange = true; + }), + addEventListener(this.#contentEl, 'mouseenter', () => { + this.#shouldIgnoreSelectionChange = false; + }) + ); + } }), addEventListener(document, 'mouseup', (e) => { + mouseEventDisposes.forEach((dispose) => dispose()); + mouseEventDisposes.length = 0; + const target = e.composedPath()[0]; - if (!isCodeLineTarget(target)) { + if (!targetBelongsCodeLine(target)) { return; } @@ -433,16 +468,6 @@ export class Editor implements DiffsEditor { this.#focusTextarea(); }), - // Selection.getComposedRanges currently does not preserve the drag direction. - // The workaround is to check the mousemove event to determine the direction of the drag operation. - addEventListener(document, 'mousemove', (e) => { - if ((e.buttons & 1) !== 1) { - return; - } - this.#selectionEndX = e.clientX; - this.#selectionEndY = e.clientY; - }), - addEventListener(this.#textareaEl, 'keydown', (e) => { const command = resolveEditorCommandFromKeyboardEvent(e); if (command !== undefined) { @@ -1617,17 +1642,15 @@ export class Editor implements DiffsEditor { } // check if the web selection belongs to editor - #selectionBelongsToEditor(composedRanges: StaticRange[]) { + #rangeBelongsToEditor(range: StaticRange) { const contentEl = this.#contentEl; if (contentEl === undefined) { return false; } - return composedRanges.every((range) => { - return ( - contentEl.contains(range.startContainer) && - contentEl.contains(range.endContainer) - ); - }); + return ( + contentEl.contains(range.startContainer) && + contentEl.contains(range.endContainer) + ); } } diff --git a/packages/diffs/test/editorSelection.test.ts b/packages/diffs/test/editorSelection.test.ts index 58e36bbe9..1a7c745c6 100644 --- a/packages/diffs/test/editorSelection.test.ts +++ b/packages/diffs/test/editorSelection.test.ts @@ -40,16 +40,14 @@ function composedRange( startOffset: number, endContainer = startContainer, endOffset = startOffset -): StaticRange[] { - return [ - { - startContainer, - startOffset, - endContainer, - endOffset, - collapsed: startContainer === endContainer && startOffset === endOffset, - } as StaticRange, - ]; +): StaticRange { + return { + startContainer, + startOffset, + endContainer, + endOffset, + collapsed: startContainer === endContainer && startOffset === endOffset, + } as StaticRange; } function editorSelection( From 2609f2274783b33f3026f4c102b58903b5bd022a Mon Sep 17 00:00:00 2001 From: Je Xia Date: Fri, 8 May 2026 13:45:25 +0800 Subject: [PATCH 086/138] Add extendSelection command --- packages/diffs/src/editor/editorCommand.ts | 4 +- packages/diffs/src/editor/editorSelection.ts | 183 +++++++++++++++++++ packages/diffs/src/editor/index.ts | 19 ++ packages/diffs/test/editorSelection.test.ts | 73 ++++++++ 4 files changed, 278 insertions(+), 1 deletion(-) diff --git a/packages/diffs/src/editor/editorCommand.ts b/packages/diffs/src/editor/editorCommand.ts index d9478ec96..8c81872aa 100644 --- a/packages/diffs/src/editor/editorCommand.ts +++ b/packages/diffs/src/editor/editorCommand.ts @@ -8,13 +8,15 @@ export type EditorCommand = | 'documentEnd' | 'undo' | 'redo' - | 'selectAll'; + | 'selectAll' + | 'extendSelection'; const SHORTCUTS: Partial> = { a: 'selectAll', c: 'copy', v: 'paste', x: 'cut', + d: 'extendSelection', }; export function resolveEditorCommandFromKeyboardEvent( diff --git a/packages/diffs/src/editor/editorSelection.ts b/packages/diffs/src/editor/editorSelection.ts index 0c13918fe..4f1148385 100644 --- a/packages/diffs/src/editor/editorSelection.ts +++ b/packages/diffs/src/editor/editorSelection.ts @@ -40,6 +40,9 @@ export function convertSelection( }; } +/** + * Resolves the indent edits for a selection. + */ export function resolveIndentEdits( textDocument: TextDocument, selection: EditorSelection, @@ -107,6 +110,9 @@ export function resolveIndentEdits( return [edits, newSelection]; } +/** + * Maps a selection move to a new selection. + */ export function mapSelectionMove( textDocument: TextDocument, selections: readonly EditorSelection[], @@ -148,6 +154,9 @@ export function mapSelectionMove( }); } +/** + * Maps a selection range move to a new selection. + */ export function mapSelectionRangeMove( textDocument: TextDocument, selections: readonly EditorSelection[], @@ -175,6 +184,9 @@ export function mapSelectionRangeMove( }); } +/** + * Applies a text change to a selection. + */ export function applyTextChangeToSelections( textDocument: TextDocument, selections: EditorSelection[], @@ -296,6 +308,9 @@ export function applyTextChangeToSelections( return { nextSelections, newLineAnnotations }; } +/** + * Applies a text replace to a selection. + */ export function applyTextReplaceToSelections( textDocument: TextDocument, selections: EditorSelection[], @@ -375,6 +390,9 @@ export function applyTextReplaceToSelections( return { nextSelections, newLineAnnotations }; } +/** + * Checks if a selection is collapsed. + */ export function isCollapsedSelection(selection: EditorSelection): boolean { return ( selection.start.line === selection.end.line && @@ -382,6 +400,9 @@ export function isCollapsedSelection(selection: EditorSelection): boolean { ); } +/** + * Checks if two selections intersect. + */ export function selectionIntersects( a: EditorSelection, b: EditorSelection @@ -408,6 +429,9 @@ export function selectionIntersects( ); } +/** + * Compares two positions. + */ export function comparePosition(a: Position, b: Position): number { if (a.line !== b.line) { return a.line - b.line; @@ -415,6 +439,9 @@ export function comparePosition(a: Position, b: Position): number { return a.character - b.character; } +/** + * Creates a selection from anchor and focus offsets. + */ export function createSelectionFromAnchorAndFocusOffsets( textDocument: TextDocument, anchorOffset: number, @@ -435,6 +462,162 @@ export function createSelectionFromAnchorAndFocusOffsets( }; } +/** + * Extends a selection. + */ +export function extendSelections( + textDocument: TextDocument, + selections: readonly EditorSelection[] +): EditorSelection[] | undefined { + if (selections.length === 0) { + return undefined; + } + + const allCollapsed = selections.every(isCollapsedSelection); + if (allCollapsed) { + const expanded: EditorSelection[] = []; + for (const sel of selections) { + const word = expandCollapsedSelectionToWord(textDocument, sel); + if (word === undefined) { + return undefined; + } + expanded.push(word); + } + return expanded; + } + + const texts = selections.map((s) => textDocument.getText(s)); + const needle = texts[0]; + if (needle.length === 0 || texts.some((t) => t !== needle)) { + return undefined; + } + + const occupied = selections.map( + (s) => + [textDocument.offsetAt(s.start), textDocument.offsetAt(s.end)] as [ + number, + number, + ] + ); + const nextOffset = findNextNonOverlappingSubstring( + textDocument.getText(), + needle, + occupied + ); + if (nextOffset === undefined) { + return undefined; + } + const added = createSelectionFromAnchorAndFocusOffsets( + textDocument, + nextOffset, + nextOffset + needle.length + ); + return [...selections, added]; +} + +// Expands a zero-width selection to the word-like segment that contains the caret. +function expandCollapsedSelectionToWord( + textDocument: TextDocument, + selection: EditorSelection +): EditorSelection | undefined { + const { line, character } = selection.start; + const lineText = textDocument.getLineText(line); + const ch = Math.max(0, Math.min(character, lineText.length)); + const span = expandCollapsedLineWord(lineText, ch); + if (span === undefined) { + return undefined; + } + return { + start: { line, character: span.start }, + end: { line, character: span.end }, + direction: DirectionForward, + }; +} + +function expandCollapsedLineWord( + lineText: string, + character: number +): { start: number; end: number } | undefined { + const segmenter = new Intl.Segmenter(undefined, { + granularity: 'word', + }); + for (const seg of segmenter.segment(lineText)) { + if (seg.isWordLike !== true) { + continue; + } + const lo = seg.index; + const hi = lo + seg.segment.length; + if (character >= lo && character < hi) { + return { start: lo, end: hi }; + } + } + for (const seg of segmenter.segment(lineText)) { + if (seg.isWordLike !== true) { + continue; + } + const lo = seg.index; + const hi = lo + seg.segment.length; + if (lo >= character) { + return { start: lo, end: hi }; + } + } + let best: { start: number; end: number } | undefined; + for (const seg of segmenter.segment(lineText)) { + if (seg.isWordLike !== true) { + continue; + } + const lo = seg.index; + const hi = lo + seg.segment.length; + if (hi <= character) { + best = { start: lo, end: hi }; + } + } + return best; +} + +function findNextNonOverlappingSubstring( + doc: string, + needle: string, + occupied: readonly [number, number][] +): number | undefined { + if (needle.length === 0) { + return undefined; + } + const pivot = Math.max(...occupied.map(([, end]) => end)); + const isFree = (start: number): boolean => { + const end = start + needle.length; + return !occupied.some(([s0, s1]) => start < s1 && s0 < end); + }; + + let pos = pivot; + while (pos <= doc.length - needle.length) { + const idx = doc.indexOf(needle, pos); + if (idx === -1) { + break; + } + if (isFree(idx)) { + return idx; + } + pos = idx + 1; + } + + pos = 0; + while (pos < pivot && pos <= doc.length - needle.length) { + const idx = doc.indexOf(needle, pos); + if (idx === -1) { + break; + } + if (idx >= pivot) { + break; + } + if (isFree(idx)) { + return idx; + } + pos = idx + 1; + } + return undefined; +} + function getSelectionAnchorAndFocusOffsets( textDocument: TextDocument, selection: EditorSelection diff --git a/packages/diffs/src/editor/index.ts b/packages/diffs/src/editor/index.ts index 0f1858793..8e7f49ea7 100644 --- a/packages/diffs/src/editor/index.ts +++ b/packages/diffs/src/editor/index.ts @@ -16,6 +16,7 @@ import { DirectionBackward, DirectionForward, DirectionNone, + extendSelections, isCollapsedSelection, mapSelectionMove, mapSelectionRangeMove, @@ -849,6 +850,10 @@ export class Editor implements DiffsEditor { #focusTextarea(): void { this.#shouldIgnoreSelectionChange = true; this.#textareaEl?.focus(); + this.#textareaEl?.scrollIntoView({ + block: 'nearest', + inline: 'nearest', + }); setTimeout(() => { this.#shouldIgnoreSelectionChange = false; }, 0); @@ -1229,6 +1234,20 @@ export class Editor implements DiffsEditor { break; } + case 'extendSelection': { + const selections = this.#selections; + const textDocument = this.#textDocument; + if (selections === undefined || textDocument === undefined) { + break; + } + const next = extendSelections(textDocument, selections); + if (next !== undefined) { + this.setSelections(next, false); + this.#focusTextarea(); + } + break; + } + case 'indent': case 'outdent': if ( diff --git a/packages/diffs/test/editorSelection.test.ts b/packages/diffs/test/editorSelection.test.ts index 1a7c745c6..606219e51 100644 --- a/packages/diffs/test/editorSelection.test.ts +++ b/packages/diffs/test/editorSelection.test.ts @@ -7,6 +7,7 @@ import { DirectionForward, DirectionNone, type EditorSelection, + extendSelections, mapSelectionMove, mapSelectionRangeMove, selectionIntersects, @@ -724,3 +725,75 @@ describe('applyTextReplaceToSelections', () => { }); }); }); + +describe('computeExtendSelection', () => { + test('returns undefined for empty selections', () => { + const doc = new TextDocument('inmemory://x', 'hello'); + expect(extendSelections(doc, [])).toBeUndefined(); + }); + + test('ignores non-collapsed selections with different text', () => { + const doc = new TextDocument('inmemory://x', 'aa bb'); + const selections: EditorSelection[] = [ + createSelection(0, 0, 0, 2), + createSelection(0, 3, 0, 5), + ]; + expect(extendSelections(doc, selections)).toBeUndefined(); + }); + + test('expands a collapsed caret to the surrounding word', () => { + const doc = new TextDocument('inmemory://x', "'foobar'"); + const caret = createSelection(0, 4, 0, 4); + const next = extendSelections(doc, [caret]); + expect(next).toEqual([ + { + start: { line: 0, character: 1 }, + end: { line: 0, character: 7 }, + direction: DirectionForward, + }, + ]); + }); + + test('adds the next matching range when one occurrence is selected', () => { + const doc = new TextDocument('inmemory://x', 'foo x foo'); + const first = createSelection(0, 0, 0, 3); + const afterFirst = extendSelections(doc, [first]); + expect(afterFirst).toEqual([ + first, + { + start: { line: 0, character: 6 }, + end: { line: 0, character: 9 }, + direction: DirectionForward, + }, + ]); + expect(extendSelections(doc, afterFirst!)).toBeUndefined(); + }); + + test('wraps to an earlier occurrence after the last match in the file', () => { + const doc = new TextDocument('inmemory://x', 'foo bar foo'); + const secondFoo = createSelection(0, 8, 0, 11); + const wrapped = extendSelections(doc, [secondFoo]); + expect(wrapped).toEqual([ + secondFoo, + { + start: { line: 0, character: 0 }, + end: { line: 0, character: 3 }, + direction: DirectionForward, + }, + ]); + }); + + test('allows multiple selections when every range has the same text', () => { + const doc = new TextDocument('inmemory://x', 'ab ab ab'); + const a = createSelection(0, 0, 0, 2); + const b = createSelection(0, 3, 0, 5); + const two = [a, b]; + const third = extendSelections(doc, two); + expect(third?.length).toBe(3); + expect(third?.[2]).toEqual({ + start: { line: 0, character: 6 }, + end: { line: 0, character: 8 }, + direction: DirectionForward, + }); + }); +}); From 23dc84234539cf2c55a99017c4665740d38709db Mon Sep 17 00:00:00 2001 From: Je Xia Date: Fri, 8 May 2026 14:06:24 +0800 Subject: [PATCH 087/138] Fix `focusTextare` function --- packages/diffs/src/editor/constants.ts | 3 ++- packages/diffs/src/editor/index.ts | 6 ++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/diffs/src/editor/constants.ts b/packages/diffs/src/editor/constants.ts index 0446b6573..6e224e123 100644 --- a/packages/diffs/src/editor/constants.ts +++ b/packages/diffs/src/editor/constants.ts @@ -17,7 +17,8 @@ export const EDITOR_CSS = /* CSS */ ` [data-content] { position: relative; } - [data-content]::selection { + [data-content]::selection, + [data-textarea]::selection { background-color: transparent; } [data-textarea], [data-caret], [data-selection-range] { diff --git a/packages/diffs/src/editor/index.ts b/packages/diffs/src/editor/index.ts index 8e7f49ea7..ac4f1c08e 100644 --- a/packages/diffs/src/editor/index.ts +++ b/packages/diffs/src/editor/index.ts @@ -849,10 +849,8 @@ export class Editor implements DiffsEditor { #focusTextarea(): void { this.#shouldIgnoreSelectionChange = true; - this.#textareaEl?.focus(); - this.#textareaEl?.scrollIntoView({ - block: 'nearest', - inline: 'nearest', + this.#textareaEl?.focus({ + preventScroll: true, }); setTimeout(() => { this.#shouldIgnoreSelectionChange = false; From 18487ed3539c846608fe180a920401b415272764 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Fri, 8 May 2026 14:32:27 +0800 Subject: [PATCH 088/138] Fix `resolveTextareaChange` function --- packages/diffs/src/editor/editorTextarea.ts | 28 +++++++++++++++++ .../diffs/test/editorTextareaSnapshot.test.ts | 31 +++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/packages/diffs/src/editor/editorTextarea.ts b/packages/diffs/src/editor/editorTextarea.ts index 5cb520dad..ebcca7ebd 100644 --- a/packages/diffs/src/editor/editorTextarea.ts +++ b/packages/diffs/src/editor/editorTextarea.ts @@ -74,6 +74,34 @@ export function resolveTextareaChange( const originalLength = original.length; const nextLength = newView.length; + // When the snapshot still has the pre-edit range, prefer it over prefix/suffix inference. + // Otherwise the diff can shift by one when the same character appears on both sides of + // the removed span (e.g. deleting between two `"` quotes in JSON). + const trustStart = textareaSnapshot.selectionStart; + const trustEnd = textareaSnapshot.selectionEnd; + if (trustStart !== trustEnd) { + const deleteLen = trustEnd - trustStart; + const insLen = nextLength - originalLength + deleteLen; + if ( + insLen >= 0 && + trustStart >= 0 && + trustEnd <= originalLength && + trustStart + insLen <= nextLength + ) { + const inserted = newView.slice(trustStart, trustStart + insLen); + if ( + original.slice(0, trustStart) + inserted + original.slice(trustEnd) === + newView + ) { + return { + start: textareaSnapshot.offset + trustStart, + end: textareaSnapshot.offset + trustEnd, + text: inserted, + }; + } + } + } + if ( selectionStart === selectionEnd && textareaSnapshot.selectionStart === textareaSnapshot.selectionEnd diff --git a/packages/diffs/test/editorTextareaSnapshot.test.ts b/packages/diffs/test/editorTextareaSnapshot.test.ts index 72dd96478..f4ffb54cd 100644 --- a/packages/diffs/test/editorTextareaSnapshot.test.ts +++ b/packages/diffs/test/editorTextareaSnapshot.test.ts @@ -54,6 +54,37 @@ describe('resolveTextChange', () => { }); }); + test('keeps the textarea selection range when neighbour characters match the diff', () => { + const line0 = ' "a": "catalog:",'; + const line1 = ' "b": "catalog:",'; + const line2 = ' "c": "catalog:",'; + const textDocument = new TextDocument( + 'inmemory://1', + [line0, line1, line2].join('\n') + ); + const snippet = createTextareaSnapshot( + textDocument, + createSelection(1, 4, 1, 38, DirectionNone) + ); + + const deleted = + snippet.text.slice(0, snippet.selectionStart) + + snippet.text.slice(snippet.selectionEnd); + + expect( + resolveTextareaChange( + snippet, + deleted, + snippet.selectionStart, + snippet.selectionStart + ) + ).toEqual({ + start: snippet.offset + snippet.selectionStart, + end: snippet.offset + snippet.selectionEnd, + text: '', + }); + }); + test('clamps caret column on empty lines so textarea slice matches the document', () => { const textDocument = new TextDocument('inmemory://1', 'a\n\nb'); const valid = createTextareaSnapshot( From 754958f98c841d2a1869cb00769a5e00942a26ce Mon Sep 17 00:00:00 2001 From: Je Xia Date: Fri, 8 May 2026 14:36:50 +0800 Subject: [PATCH 089/138] Remove unnecessary target check in mouseup event listener in Editor class --- packages/diffs/src/editor/index.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/diffs/src/editor/index.ts b/packages/diffs/src/editor/index.ts index ac4f1c08e..14e10f380 100644 --- a/packages/diffs/src/editor/index.ts +++ b/packages/diffs/src/editor/index.ts @@ -456,15 +456,9 @@ export class Editor implements DiffsEditor { } }), - addEventListener(document, 'mouseup', (e) => { + addEventListener(document, 'mouseup', () => { mouseEventDisposes.forEach((dispose) => dispose()); mouseEventDisposes.length = 0; - - const target = e.composedPath()[0]; - if (!targetBelongsCodeLine(target)) { - return; - } - this.#reservedSelections = undefined; this.#focusTextarea(); }), From fc26f733c52c78fefaf50286d817e95cc4dd3cb0 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Fri, 8 May 2026 14:54:05 +0800 Subject: [PATCH 090/138] Fix textarea selction direction --- packages/diffs/src/editor/editorTextarea.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/diffs/src/editor/editorTextarea.ts b/packages/diffs/src/editor/editorTextarea.ts index ebcca7ebd..7fbb00c6c 100644 --- a/packages/diffs/src/editor/editorTextarea.ts +++ b/packages/diffs/src/editor/editorTextarea.ts @@ -170,9 +170,9 @@ export function toTextareaSelectionDirection( ): HTMLTextAreaElement['selectionDirection'] { switch (selection.direction) { case DirectionBackward: - return 'forward'; - case DirectionForward: return 'backward'; + case DirectionForward: + return 'forward'; case DirectionNone: return 'none'; } From f16e618d761c553b8b6a728cf892e6f2a346460e Mon Sep 17 00:00:00 2001 From: Je Xia Date: Fri, 8 May 2026 15:34:01 +0800 Subject: [PATCH 091/138] Fix selection bg color for safair --- packages/diffs/src/editor/constants.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/diffs/src/editor/constants.ts b/packages/diffs/src/editor/constants.ts index 6e224e123..5c874cc2b 100644 --- a/packages/diffs/src/editor/constants.ts +++ b/packages/diffs/src/editor/constants.ts @@ -3,6 +3,9 @@ export const TOKENIZE_MAX_LINE_LENGTH = 10000; export const TOKENIZE_LINES_PRE_TOKENIZE = 50; export const EDITOR_CSS = /* CSS */ ` + ::selection { + background-color: transparent; + } @keyframes blinking { 0% { opacity: 1; } 50% { opacity: 0; } @@ -17,10 +20,6 @@ export const EDITOR_CSS = /* CSS */ ` [data-content] { position: relative; } - [data-content]::selection, - [data-textarea]::selection { - background-color: transparent; - } [data-textarea], [data-caret], [data-selection-range] { position: absolute; top: 0; @@ -32,6 +31,7 @@ export const EDITOR_CSS = /* CSS */ ` top: -1lh; padding: 0; padding-inline: 1ch; + tab-size: var(--diffs-tab-size, 2); font: inherit; color: transparent; background-color: transparent; From 779a50a96fd97f2ce9437943bbed405e3884b9d8 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Fri, 8 May 2026 15:58:28 +0800 Subject: [PATCH 092/138] Clean up --- packages/diffs/src/editor/editorCommand.ts | 23 +++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/packages/diffs/src/editor/editorCommand.ts b/packages/diffs/src/editor/editorCommand.ts index 8c81872aa..56fdafc5e 100644 --- a/packages/diffs/src/editor/editorCommand.ts +++ b/packages/diffs/src/editor/editorCommand.ts @@ -22,39 +22,40 @@ const SHORTCUTS: Partial> = { export function resolveEditorCommandFromKeyboardEvent( event: KeyboardEvent ): EditorCommand | undefined { - if (event.altKey) { + const hasPrimaryModifier = isPrimaryModifier(event); + const { shiftKey, altKey, key } = event; + if (altKey) { return undefined; } - const key = event.key.length === 1 ? event.key.toLowerCase() : event.key; - const hasPrimaryModifier = isPrimaryModifier(event); const isMac = isMacLike(); + const normalizedKey = key.length === 1 ? key.toLowerCase() : key; - if (!hasPrimaryModifier && key === 'Tab') { - return event.shiftKey ? 'outdent' : 'indent'; + if (!hasPrimaryModifier && normalizedKey === 'Tab') { + return shiftKey ? 'outdent' : 'indent'; } if (!hasPrimaryModifier) { return undefined; } - if (key === 'z') { - return event.shiftKey ? 'redo' : 'undo'; + if (normalizedKey === 'z') { + return shiftKey ? 'redo' : 'undo'; } - if (!isMac && key === 'y') { + if (!isMac && normalizedKey === 'y') { return 'redo'; } - if (key === 'Home' || (isMac && key === 'ArrowUp')) { + if (normalizedKey === 'Home' || (isMac && normalizedKey === 'ArrowUp')) { return 'documentStart'; } - if (key === 'End' || (isMac && key === 'ArrowDown')) { + if (normalizedKey === 'End' || (isMac && normalizedKey === 'ArrowDown')) { return 'documentEnd'; } - return SHORTCUTS[key]; + return SHORTCUTS[normalizedKey]; } export function isPrimaryModifier({ From d9651ff66cdb6914d9c21812cb2ef09da040bbf9 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Fri, 8 May 2026 17:10:32 +0800 Subject: [PATCH 093/138] Fix shift select --- packages/diffs/src/editor/editorSelection.ts | 21 ++++ packages/diffs/src/editor/index.ts | 122 ++++++++++++------- packages/diffs/test/editorSelection.test.ts | 42 +++++++ 3 files changed, 138 insertions(+), 47 deletions(-) diff --git a/packages/diffs/src/editor/editorSelection.ts b/packages/diffs/src/editor/editorSelection.ts index 4f1148385..ea8211a9c 100644 --- a/packages/diffs/src/editor/editorSelection.ts +++ b/packages/diffs/src/editor/editorSelection.ts @@ -400,6 +400,27 @@ export function isCollapsedSelection(selection: EditorSelection): boolean { ); } +/** + * Merges two selections into one range that covers both. + */ +export function mergeSelections( + a: EditorSelection, + b: EditorSelection +): EditorSelection { + const start = comparePosition(a.start, b.start) <= 0 ? a.start : b.start; + const end = comparePosition(a.end, b.end) >= 0 ? a.end : b.end; + const anchorA = a.direction === DirectionBackward ? a.end : a.start; + const focusB = b.direction === DirectionBackward ? b.start : b.end; + const anchorVsFocus = comparePosition(anchorA, focusB); + const direction: SelectionDirection = + anchorVsFocus === 0 + ? DirectionNone + : anchorVsFocus < 0 + ? DirectionForward + : DirectionBackward; + return { start, end, direction }; +} + /** * Checks if two selections intersect. */ diff --git a/packages/diffs/src/editor/index.ts b/packages/diffs/src/editor/index.ts index 14e10f380..c82141c6b 100644 --- a/packages/diffs/src/editor/index.ts +++ b/packages/diffs/src/editor/index.ts @@ -20,6 +20,7 @@ import { isCollapsedSelection, mapSelectionMove, mapSelectionRangeMove, + mergeSelections, resolveIndentEdits, type SelectionDirection, selectionIntersects, @@ -360,52 +361,8 @@ export class Editor implements DiffsEditor { this.#disposes = [ addEventListener(document, 'selectionchange', () => { - const shadowRoot = this.#contentEl?.getRootNode(); - if ( - this.#shouldIgnoreSelectionChange || - shadowRoot === undefined || - !(shadowRoot instanceof ShadowRoot) || - shadowRoot.activeElement === null - ) { - return; - } - - // Chrome-based browsers fire document selectionchange when the - // textarea caret moves inside the shadow root. - if (shadowRoot.activeElement === this.#textareaEl) { - this.#onTextareaSelectionChange(); - return; - } - - const selectionRaw = document.getSelection(); - const composedRange = selectionRaw?.getComposedRanges({ - shadowRoots: [shadowRoot], - })?.[0]; - - if ( - composedRange === undefined || - !this.#rangeBelongsToEditor(composedRange) - ) { - return; - } - - const selection = convertSelection( - composedRange, - this.#computeMouseSelectionDirection() - ); - if (selection !== null) { - this.#textareaSnapshot = undefined; - if (this.#reservedSelections !== undefined) { - this.setSelections([ - ...this.#reservedSelections.filter( - (reservedSelection) => - !selectionIntersects(reservedSelection, selection) - ), - selection, - ]); - } else { - this.setSelections([selection]); - } + if (!this.#shouldIgnoreSelectionChange) { + this.#onSelectionChange(); } }), @@ -428,6 +385,15 @@ export class Editor implements DiffsEditor { this.#selectionEndX = e.clientX; this.#selectionEndY = e.clientY; + // when the user is using the 'Shift' key to create a selection + // hide the textarea element or the selection will be created in the textarea + if (e.shiftKey) { + this.#shouldIgnoreSelectionChange = true; + if (this.#textareaEl !== undefined) { + this.#textareaEl.style.visibility = 'hidden'; + } + } + mouseEventDisposes.push( // `Selection.getComposedRanges` currently does not preserve the drag direction. // The workaround is to check the mousemove event to determine the direction of the drag operation. @@ -456,10 +422,17 @@ export class Editor implements DiffsEditor { } }), - addEventListener(document, 'mouseup', () => { + addEventListener(document, 'mouseup', (e) => { mouseEventDisposes.forEach((dispose) => dispose()); mouseEventDisposes.length = 0; this.#reservedSelections = undefined; + if (e.shiftKey) { + this.#onSelectionChange(true); + this.#shouldIgnoreSelectionChange = false; + if (this.#textareaEl !== undefined) { + this.#textareaEl.style.visibility = 'visible'; + } + } this.#focusTextarea(); }), @@ -851,6 +824,61 @@ export class Editor implements DiffsEditor { }, 0); } + #onSelectionChange(append = false) { + const shadowRoot = this.#contentEl?.getRootNode(); + if ( + shadowRoot === undefined || + !(shadowRoot instanceof ShadowRoot) || + shadowRoot.activeElement === null + ) { + return; + } + + // Chrome-based browsers fire document selectionchange when the + // textarea caret moves inside the shadow root. + if (shadowRoot.activeElement === this.#textareaEl) { + this.#onTextareaSelectionChange(); + return; + } + + const selectionRaw = document.getSelection(); + const composedRange = selectionRaw?.getComposedRanges({ + shadowRoots: [shadowRoot], + })?.[0]; + + if ( + composedRange === undefined || + !this.#rangeBelongsToEditor(composedRange) + ) { + return; + } + + const selection = convertSelection( + composedRange, + this.#computeMouseSelectionDirection() + ); + if (selection !== null) { + this.#textareaSnapshot = undefined; + if (append) { + const primarySelection = this.#selections?.at(-1); + if (primarySelection !== undefined) { + const newSelection = mergeSelections(primarySelection, selection); + this.setSelections([newSelection]); + } + } else if (this.#reservedSelections !== undefined) { + this.setSelections([ + ...this.#reservedSelections.filter( + (reservedSelection) => + !selectionIntersects(reservedSelection, selection) + ), + selection, + ]); + } else { + this.setSelections([selection]); + } + } + } + #onTextareaSelectionChange() { const textareaEl = this.#textareaEl; const textareaSnapshot = this.#textareaSnapshot; diff --git a/packages/diffs/test/editorSelection.test.ts b/packages/diffs/test/editorSelection.test.ts index 606219e51..db0dd21ea 100644 --- a/packages/diffs/test/editorSelection.test.ts +++ b/packages/diffs/test/editorSelection.test.ts @@ -10,6 +10,7 @@ import { extendSelections, mapSelectionMove, mapSelectionRangeMove, + mergeSelections, selectionIntersects, } from '../src/editor/editorSelection'; import { @@ -366,6 +367,47 @@ describe('selectionIntersects', () => { }); }); +describe('concatSelections', () => { + test('covers both ranges and uses forward direction when a anchor precedes b focus', () => { + const a = createSelection(0, 2, 0, 4, DirectionForward); + const b = createSelection(0, 6, 0, 8, DirectionForward); + expect(mergeSelections(a, b)).toEqual({ + start: { line: 0, character: 2 }, + end: { line: 0, character: 8 }, + direction: DirectionForward, + }); + }); + + test('uses backward direction when a anchor lies after b focus in the document', () => { + const a = createSelection(0, 0, 0, 10, DirectionBackward); + const b = createSelection(0, 4, 0, 6, DirectionForward); + expect(mergeSelections(a, b)).toEqual({ + start: { line: 0, character: 0 }, + end: { line: 0, character: 10 }, + direction: DirectionBackward, + }); + }); + + test('unions ranges when b lies before a in the file and picks backward when a anchor follows b focus', () => { + const a = createSelection(1, 0, 1, 5, DirectionForward); + const b = createSelection(0, 2, 0, 4, DirectionForward); + expect(mergeSelections(a, b)).toEqual({ + start: { line: 0, character: 2 }, + end: { line: 1, character: 5 }, + direction: DirectionBackward, + }); + }); + + test('returns a collapsed selection with no direction when both inputs are the same caret', () => { + const caret = createSelection(0, 3, 0, 3, DirectionNone); + expect(mergeSelections(caret, caret)).toEqual({ + start: { line: 0, character: 3 }, + end: { line: 0, character: 3 }, + direction: DirectionNone, + }); + }); +}); + describe('applyTextChangeToSelections', () => { test('inserts the same text at multiple carets', () => { const textDocument = new TextDocument('inmemory://1', 'a\nb\nc'); From 262f8f9510eb232f2e1ce623cd7463bb799594ca Mon Sep 17 00:00:00 2001 From: Je Xia Date: Fri, 8 May 2026 23:48:56 +0800 Subject: [PATCH 094/138] Refactor --- packages/diffs/src/editor/editStack.ts | 25 +-- .../diffs/src/editor/editorLineAnnotations.ts | 16 +- packages/diffs/src/editor/editorSelection.ts | 77 ++++----- packages/diffs/src/editor/editorTextarea.ts | 4 +- packages/diffs/src/editor/index.ts | 162 ++++++++++-------- packages/diffs/src/editor/textDocument.ts | 48 +++--- packages/diffs/src/editor/tokenzier.ts | 4 +- .../diffs/test/editorLineAnnotations.test.ts | 29 +--- packages/diffs/test/editorSelection.test.ts | 31 ---- packages/diffs/test/textDocument.test.ts | 35 ++-- 10 files changed, 204 insertions(+), 227 deletions(-) diff --git a/packages/diffs/src/editor/editStack.ts b/packages/diffs/src/editor/editStack.ts index 097d76a75..7e4ad2a25 100644 --- a/packages/diffs/src/editor/editStack.ts +++ b/packages/diffs/src/editor/editStack.ts @@ -1,3 +1,4 @@ +import type { LineAnnotation } from '../types'; import type { EditorSelection } from './editorSelection'; import type { ResolvedTextEdit } from './textDocument'; @@ -8,7 +9,7 @@ interface EditSource { getTextSlice(start: number, end: number): string; } -interface EditStackEntry { +interface EditStackEntry { /** Forward offset edits from the entry's base text to its final text. */ forwardEdits: ResolvedTextEdit[]; /** Inverse offset edits from the entry's final text back to its base text. */ @@ -22,18 +23,18 @@ interface EditStackEntry { /** Selection after the transaction (restored on redo). */ selectionsAfter?: EditorSelection[]; /** Line annotations before the transaction (restored on undo). */ - lineAnnotationsBefore?: unknown[]; + lineAnnotationsBefore?: LineAnnotation[]; /** Line annotations after the transaction (restored on redo). */ - lineAnnotationsAfter?: unknown[]; + lineAnnotationsAfter?: LineAnnotation[]; } export interface EditStackOptions { maxEntries?: number; } -export class EditStack { - #undoStack: EditStackEntry[] = []; - #redoStack: EditStackEntry[] = []; +export class EditStack { + #undoStack: EditStackEntry[] = []; + #redoStack: EditStackEntry[] = []; #maxEntries: number; constructor(options?: EditStackOptions) { @@ -63,8 +64,8 @@ export class EditStack { versionAfter: number, selectionsBefore: EditorSelection[], selectionsAfter?: EditorSelection[], - lineAnnotationsBefore?: unknown[], - lineAnnotationsAfter?: unknown[] + lineAnnotationsBefore?: LineAnnotation[], + lineAnnotationsAfter?: LineAnnotation[] ): void { const forwardEdits = [...resolvedEdits].sort((a, b) => a.start - b.start); const inverseEdits = buildInverseOffsetEdits(source, forwardEdits); @@ -95,7 +96,9 @@ export class EditStack { } } - setLastUndoLineAnnotationsAfter(lineAnnotations: unknown[]): void { + setLastUndoLineAnnotationsAfter( + lineAnnotations: LineAnnotation[] + ): void { const lastEntry = this.#undoStack[this.#undoStack.length - 1]; if (lastEntry !== undefined) { lastEntry.lineAnnotationsAfter = lineAnnotations.slice(); @@ -103,7 +106,7 @@ export class EditStack { } /** Moves the latest undo entry to the redo stack and returns it, or `undefined` if empty. */ - popUndoToRedo(): EditStackEntry | void { + popUndoToRedo(): EditStackEntry | void { const entry = this.#undoStack.pop(); if (entry !== undefined) { this.#redoStack.push(entry); @@ -112,7 +115,7 @@ export class EditStack { } /** Moves the latest redo entry back to the undo stack and returns it, or `undefined` if empty. */ - popRedoToUndo(): EditStackEntry | void { + popRedoToUndo(): EditStackEntry | void { const entry = this.#redoStack.pop(); if (entry !== undefined) { this.#undoStack.push(entry); diff --git a/packages/diffs/src/editor/editorLineAnnotations.ts b/packages/diffs/src/editor/editorLineAnnotations.ts index ba7422dd8..6946bc285 100644 --- a/packages/diffs/src/editor/editorLineAnnotations.ts +++ b/packages/diffs/src/editor/editorLineAnnotations.ts @@ -4,27 +4,27 @@ import type { TextDocumentChange } from './textDocument'; // Updates 1-based line annotations after the document has applied an edit, // returning undefined when no annotation moved or was deleted. export function applyDocumentChangeToLineAnnotations( - lastChange: TextDocumentChange, + change: TextDocumentChange, lineAnnotations: readonly LineAnnotation[] ): LineAnnotation[] | undefined { - if (lastChange.lineDelta === 0) { + if (change.lineDelta === 0) { return undefined; } - const startCharacter = lastChange.startCharacter ?? 0; - const removedLineCount = Math.max(0, -lastChange.lineDelta); + const startCharacter = change.startCharacter ?? 0; + const removedLineCount = Math.max(0, -change.lineDelta); const deletedStartLine = removedLineCount === 0 ? undefined - : lastChange.startLine + (startCharacter === 0 ? 0 : 1); + : change.startLine + (startCharacter === 0 ? 0 : 1); const deletedEndLine = deletedStartLine === undefined ? undefined : deletedStartLine + removedLineCount; const shiftFromLine = removedLineCount > 0 - ? lastChange.startLine + removedLineCount - : lastChange.startLine + (startCharacter === 0 ? 0 : 1); + ? change.startLine + removedLineCount + : change.startLine + (startCharacter === 0 ? 0 : 1); const nextLineAnnotations: LineAnnotation[] = []; let changed = false; @@ -43,7 +43,7 @@ export function applyDocumentChangeToLineAnnotations( if (line >= shiftFromLine) { nextLineAnnotations.push({ ...annotation, - lineNumber: line + lastChange.lineDelta + 1, + lineNumber: line + change.lineDelta + 1, }); changed = true; continue; diff --git a/packages/diffs/src/editor/editorSelection.ts b/packages/diffs/src/editor/editorSelection.ts index ea8211a9c..8c8f3b7df 100644 --- a/packages/diffs/src/editor/editorSelection.ts +++ b/packages/diffs/src/editor/editorSelection.ts @@ -1,10 +1,10 @@ import type { LineAnnotation } from '../types'; -import { applyDocumentChangeToLineAnnotations } from './editorLineAnnotations'; import type { Position, Range, ResolvedTextEdit, TextDocument, + TextDocumentChange, TextEdit, } from './textDocument'; @@ -44,7 +44,7 @@ export function convertSelection( * Resolves the indent edits for a selection. */ export function resolveIndentEdits( - textDocument: TextDocument, + textDocument: TextDocument, selection: EditorSelection, tabSize: number, outdent: boolean @@ -114,7 +114,7 @@ export function resolveIndentEdits( * Maps a selection move to a new selection. */ export function mapSelectionMove( - textDocument: TextDocument, + textDocument: TextDocument, selections: readonly EditorSelection[], nextPosition: Position ): EditorSelection[] { @@ -158,7 +158,7 @@ export function mapSelectionMove( * Maps a selection range move to a new selection. */ export function mapSelectionRangeMove( - textDocument: TextDocument, + textDocument: TextDocument, selections: readonly EditorSelection[], nextAnchor: Position, nextFocus: Position @@ -188,18 +188,18 @@ export function mapSelectionRangeMove( * Applies a text change to a selection. */ export function applyTextChangeToSelections( - textDocument: TextDocument, + textDocument: TextDocument, selections: EditorSelection[], - change: ResolvedTextEdit, + edit: ResolvedTextEdit, lineAnnotations?: LineAnnotation[], tabSize = 2 ): { nextSelections: EditorSelection[]; - newLineAnnotations: LineAnnotation[] | undefined; + change?: TextDocumentChange; } { const primarySelection = selections[selections.length - 1]; if (primarySelection === undefined) { - return { nextSelections: [], newLineAnnotations: undefined }; + return { nextSelections: [] }; } const primaryStartOffset = textDocument.offsetAt(primarySelection.start); const primaryEndOffset = textDocument.offsetAt(primarySelection.end); @@ -224,7 +224,7 @@ export function applyTextChangeToSelections( }); const adjustedChange = normalizeLeadingIndentDeleteChange( textDocument, - change, + edit, primarySelection, tabSize ); @@ -288,37 +288,32 @@ export function applyTextChangeToSelections( }; } finalizeMergedGroup(); - textDocument.applyEdits(edits, true, selections, undefined, lineAnnotations); + const change = textDocument.applyEdits( + edits, + true, + selections, + undefined, + lineAnnotations + ); const nextSelections = nextSelectionOffsets.map((offsets) => createSelectionFromAnchorAndFocusOffsets(textDocument, ...offsets) ); textDocument.setLastUndoSelectionsAfter(nextSelections); - let newLineAnnotations: LineAnnotation[] | undefined; - if (lineAnnotations !== undefined && textDocument.lastChange !== undefined) { - newLineAnnotations = applyDocumentChangeToLineAnnotations( - textDocument.lastChange, - lineAnnotations - ); - if (newLineAnnotations !== undefined) { - textDocument.setLastUndoLineAnnotationsAfter(newLineAnnotations); - } - } - - return { nextSelections, newLineAnnotations }; + return { nextSelections, change }; } /** * Applies a text replace to a selection. */ export function applyTextReplaceToSelections( - textDocument: TextDocument, + textDocument: TextDocument, selections: EditorSelection[], texts: readonly string[], lineAnnotations?: LineAnnotation[] ): { nextSelections: EditorSelection[]; - newLineAnnotations: LineAnnotation[] | undefined; + change?: TextDocumentChange; } { if (selections.length !== texts.length) { throw new Error( @@ -370,24 +365,18 @@ export function applyTextReplaceToSelections( entry.start + offsetDelta + newText.length; offsetDelta += newText.length - (entry.end - entry.start); } - textDocument.applyEdits(edits, true, selections, undefined, lineAnnotations); + const change = textDocument.applyEdits( + edits, + true, + selections, + undefined, + lineAnnotations + ); const nextSelections = nextSelectionOffsets.map((offset) => createSelectionFromAnchorAndFocusOffsets(textDocument, offset, offset) ); textDocument.setLastUndoSelectionsAfter(nextSelections); - - let newLineAnnotations: LineAnnotation[] | undefined; - if (lineAnnotations !== undefined && textDocument.lastChange !== undefined) { - newLineAnnotations = applyDocumentChangeToLineAnnotations( - textDocument.lastChange, - lineAnnotations - ); - if (newLineAnnotations !== undefined) { - textDocument.setLastUndoLineAnnotationsAfter(newLineAnnotations); - } - } - - return { nextSelections, newLineAnnotations }; + return { nextSelections, change }; } /** @@ -464,7 +453,7 @@ export function comparePosition(a: Position, b: Position): number { * Creates a selection from anchor and focus offsets. */ export function createSelectionFromAnchorAndFocusOffsets( - textDocument: TextDocument, + textDocument: TextDocument, anchorOffset: number, focusOffset: number ): EditorSelection { @@ -487,7 +476,7 @@ export function createSelectionFromAnchorAndFocusOffsets( * Extends a selection. */ export function extendSelections( - textDocument: TextDocument, + textDocument: TextDocument, selections: readonly EditorSelection[] ): EditorSelection[] | undefined { if (selections.length === 0) { @@ -538,7 +527,7 @@ export function extendSelections( // Expands a zero-width selection to the word-like segment that contains the caret. function expandCollapsedSelectionToWord( - textDocument: TextDocument, + textDocument: TextDocument, selection: EditorSelection ): EditorSelection | undefined { const { line, character } = selection.start; @@ -640,7 +629,7 @@ function findNextNonOverlappingSubstring( } function getSelectionAnchorAndFocusOffsets( - textDocument: TextDocument, + textDocument: TextDocument, selection: EditorSelection ): [anchorOffset: number, focusOffset: number] { const isBackward = selection.direction === DirectionBackward; @@ -652,7 +641,7 @@ function getSelectionAnchorAndFocusOffsets( /** When the user inserts a lone line break, copy the current line's indentation onto the new line. */ function expandSingleNewlineInsert( - textDocument: TextDocument, + textDocument: TextDocument, insertText: string, insertStartOffset: number ): string { @@ -677,7 +666,7 @@ function expandSingleNewlineInsert( // Expands a backspace over leading spaces into one soft-tab width so mixed hard/soft indentation // behaves like the explicit outdent command. function normalizeLeadingIndentDeleteChange( - textDocument: TextDocument, + textDocument: TextDocument, change: ResolvedTextEdit, primarySelection: EditorSelection, tabSize: number diff --git a/packages/diffs/src/editor/editorTextarea.ts b/packages/diffs/src/editor/editorTextarea.ts index 7fbb00c6c..8bd701b2e 100644 --- a/packages/diffs/src/editor/editorTextarea.ts +++ b/packages/diffs/src/editor/editorTextarea.ts @@ -17,7 +17,7 @@ export interface TextareaSnapshot { } export function createTextareaSnapshot( - textDocument: TextDocument, + textDocument: TextDocument, primarySelection: EditorSelection ): TextareaSnapshot { const startLine = Math.max(0, primarySelection.start.line - 1); @@ -181,7 +181,7 @@ export function toTextareaSelectionDirection( // Aligns a column with `TextDocument.offsetAt` / `positionAt` so textarea indices match backing text // (DOM may report past end for empty lines that render a placeholder space). function normalizeCharacterForDocument( - textDocument: TextDocument, + textDocument: TextDocument, position: Position ): number { return textDocument.positionAt(textDocument.offsetAt(position)).character; diff --git a/packages/diffs/src/editor/index.ts b/packages/diffs/src/editor/index.ts index c82141c6b..5721d06b0 100644 --- a/packages/diffs/src/editor/index.ts +++ b/packages/diffs/src/editor/index.ts @@ -32,7 +32,11 @@ import { extend, round, } from '../editor/editorUtils'; -import { TextDocument, type TextEdit } from '../editor/textDocument'; +import { + TextDocument, + type TextDocumentChange, + type TextEdit, +} from '../editor/textDocument'; import { getHighlighterIfLoaded } from '../highlighter/shared_highlighter'; import { areThemesAttached } from '../highlighter/themes/areThemesAttached'; import type { @@ -49,6 +53,7 @@ import { TOKENIZE_MAX_LINE_LENGTH, TOKENIZE_TIME_LIMIT, } from './constants'; +import { applyDocumentChangeToLineAnnotations } from './editorLineAnnotations'; import { createTextareaSnapshot, getSelectionDirectionFromTextarea, @@ -75,7 +80,7 @@ export class Editor implements DiffsEditor { #file?: File; #fileContents?: FileContents; #lineAnnotations?: LineAnnotation[]; - #textDocument?: TextDocument; + #textDocument?: TextDocument; // highlighter #highlighter?: DiffsHighlighter; @@ -476,45 +481,28 @@ export class Editor implements DiffsEditor { return DirectionNone; } - #rerender(newLineAnnotations?: LineAnnotation[] | undefined) { + #rerender( + lastChange: TextDocumentChange, + nextLineAnnotations?: LineAnnotation[] | undefined + ) { // cancel existing background tokenzier task this.#backgroundTokenizer?.stop(); - const contentEl = this.#contentEl; const highlighter = this.#highlighter; const file = this.#file; const fileContents = this.#fileContents; const textDocument = this.#textDocument; - const lastChange = textDocument?.lastChange; + const contentEl = this.#contentEl; if ( - contentEl === undefined || highlighter === undefined || file === undefined || fileContents === undefined || textDocument === undefined || - lastChange === undefined + contentEl === undefined ) { return; } - // Invalidate layout caches touched by the edit. - // - line inserts/deletes shift line numbers, so clear from startLine onward - // - wrapped edits can change visual height, which shifts downstream line Y - if (lastChange.lineDelta !== 0) { - for (const line of this.#lineYCache.keys()) { - if (line >= lastChange.startLine) { - this.#lineYCache.delete(line); - } - } - } - if (this.#wrap) { - for (const line of this.#wrapLineOffsetsCache.keys()) { - if (line >= lastChange.startLine) { - this.#wrapLineOffsetsCache.delete(line); - } - } - } - const t = performance.now(); const grammar = highlighter.getLanguage(textDocument.languageId); const colorMap = { @@ -577,6 +565,24 @@ export class Editor implements DiffsEditor { stateStackCache[line] = state; } + // Invalidate layout caches touched by the edit. + // - line inserts/deletes shift line numbers, so clear from startLine onward + // - wrapped edits can change visual height, which shifts downstream line Y + if (lastChange.lineDelta !== 0) { + for (const line of this.#lineYCache.keys()) { + if (line >= lastChange.startLine) { + this.#lineYCache.delete(line); + } + } + } + if (this.#wrap) { + for (const line of this.#wrapLineOffsetsCache.keys()) { + if (line >= lastChange.startLine) { + this.#wrapLineOffsetsCache.delete(line); + } + } + } + // update line elements that have been changed in the document // create new line elements for new lines if (dirtyLines.size > 0) { @@ -667,8 +673,8 @@ export class Editor implements DiffsEditor { if (lastChange.lineDelta !== 0) { file.emitLineCountChange(lastChange.lineCount); } - if (newLineAnnotations !== undefined) { - file.emitLineAnnotationsChange(newLineAnnotations); + if (nextLineAnnotations !== undefined) { + file.emitLineAnnotationsChange(nextLineAnnotations); } if (!settled && line < lineCount) { @@ -717,7 +723,7 @@ export class Editor implements DiffsEditor { } #buildStateStackCache( - textDocument: TextDocument, + textDocument: TextDocument, grammar: IGrammar, endLine: number ): StateStack[] { @@ -764,24 +770,25 @@ export class Editor implements DiffsEditor { // Text in the textarea has been changed. if (value !== textareaSnapshot.text) { - const change = resolveTextareaChange( + const edit = resolveTextareaChange( textareaSnapshot, value, selectionStart, selectionEnd ); const lineAnnotations = this.#lineAnnotations; - const { nextSelections, newLineAnnotations } = - applyTextChangeToSelections( - textDocument, - selections, - change, - lineAnnotations, - this.#tabSize - ); - this.#rerender(newLineAnnotations); - this.#emitChange(); - this.setSelections(nextSelections, false); + const { nextSelections, change } = applyTextChangeToSelections( + textDocument, + selections, + edit, + lineAnnotations, + this.#tabSize + ); + if (change !== undefined) { + this.#rerender(change, this.#applyChangeToLineAnnotations(change)); + this.#emitChange(); + this.setSelections(nextSelections, false); + } return; } @@ -934,16 +941,15 @@ export class Editor implements DiffsEditor { textDocument, primarySelection ); - const direction = toTextareaSelectionDirection(primarySelection); + this.#shouldIgnoreSelectionChange = true; + this.#textareaSnapshot = textareaSnapshot; textareaEl.value = textareaSnapshot.text; textareaEl.style.transform = `translateY(${this.#getLineY(primarySelection.start.line)}px)`; textareaEl.setSelectionRange( textareaSnapshot.selectionStart, textareaSnapshot.selectionEnd, - direction + toTextareaSelectionDirection(primarySelection) ); - this.#textareaSnapshot = textareaSnapshot; - this.#shouldIgnoreSelectionChange = true; setTimeout(() => { this.#shouldIgnoreSelectionChange = false; }, 0); @@ -1299,15 +1305,17 @@ export class Editor implements DiffsEditor { } } if (edits.length > 0) { - this.#textDocument.applyEdits( + const change = this.#textDocument.applyEdits( edits, true, this.#selections, nextSelections ); - this.#rerender(); - this.#emitChange(); - this.setSelections(nextSelections, false); + if (change !== undefined) { + this.#rerender(change); + this.#emitChange(); + this.setSelections(nextSelections, false); + } } } break; @@ -1322,13 +1330,9 @@ export class Editor implements DiffsEditor { case 'undo': if (this.#textDocument?.canUndo === true) { const undoResult = this.#textDocument.undo(); - this.#rerender( - undoResult?.lineAnnotations as - | LineAnnotation[] - | undefined - ); - this.#emitChange(); - if (undoResult?.selections !== undefined) { + if (undoResult?.change !== undefined) { + this.#rerender(undoResult.change, undoResult.lineAnnotations); + this.#emitChange(); this.setSelections(undoResult.selections, false); this.#focusTextarea(); } @@ -1338,15 +1342,13 @@ export class Editor implements DiffsEditor { case 'redo': if (this.#textDocument?.canRedo === true) { const redoResult = this.#textDocument.redo(); - this.#rerender( - redoResult?.lineAnnotations as - | LineAnnotation[] - | undefined - ); - this.#emitChange(); - if (redoResult?.selections !== undefined) { - this.setSelections(redoResult.selections, false); - this.#focusTextarea(); + if (redoResult?.change !== undefined) { + this.#rerender(redoResult.change, redoResult.lineAnnotations); + this.#emitChange(); + if (redoResult.selections !== undefined) { + this.setSelections(redoResult.selections, false); + this.#focusTextarea(); + } } } break; @@ -1411,9 +1413,9 @@ export class Editor implements DiffsEditor { if (textDocument == null || primarySelection == null) { return; } - // todo: normalize text with textDocument.EOF + // TODO(@ije): normalize text with textDocument.EOF const lineAnnotations = this.#lineAnnotations; - const { nextSelections, newLineAnnotations } = Array.isArray(text) + const { nextSelections, change } = Array.isArray(text) ? applyTextReplaceToSelections( textDocument, selections, @@ -1430,9 +1432,31 @@ export class Editor implements DiffsEditor { }, lineAnnotations ); - this.#rerender(newLineAnnotations); - this.#emitChange(); - this.setSelections(nextSelections, false); + + if (change !== undefined) { + this.#rerender(change, this.#applyChangeToLineAnnotations(change)); + this.#emitChange(); + this.setSelections(nextSelections, false); + } + } + + #applyChangeToLineAnnotations( + change: TextDocumentChange + ): LineAnnotation[] | undefined { + if (this.#lineAnnotations !== undefined && change !== undefined) { + const nextLineAnnotations = + applyDocumentChangeToLineAnnotations( + change, + this.#lineAnnotations + ); + if (nextLineAnnotations !== undefined) { + this.#textDocument?.setLastUndoLineAnnotationsAfter( + nextLineAnnotations + ); + } + return nextLineAnnotations; + } + return undefined; } #getLineElement(line: number): HTMLElement | undefined { diff --git a/packages/diffs/src/editor/textDocument.ts b/packages/diffs/src/editor/textDocument.ts index b6fb0b2b3..4c277b76d 100644 --- a/packages/diffs/src/editor/textDocument.ts +++ b/packages/diffs/src/editor/textDocument.ts @@ -1,3 +1,4 @@ +import type { LineAnnotation } from '../types'; import { type EditorSelection } from './editorSelection'; import { EditStack } from './editStack'; import { PieceTable } from './pieceTable'; @@ -106,20 +107,19 @@ export interface TextDocumentChange { /** * A vscode-languageserver-textdocument compatible text document. */ -export class TextDocument { +export class TextDocument { #uri: string; #languageId: string; #version: number; #pieceTable: PieceTable; - #editStack: EditStack; - #lastChange?: TextDocumentChange; + #editStack: EditStack; constructor( uri: string, text: string, languageId = 'plaintext', version = 0, - editStack: EditStack = new EditStack() + editStack: EditStack = new EditStack() ) { this.#uri = new URL(uri, 'file://').toString(); this.#languageId = languageId; @@ -144,10 +144,6 @@ export class TextDocument { return this.#pieceTable.lineCount; } - get lastChange(): TextDocumentChange | undefined { - return this.#lastChange; - } - get canUndo(): boolean { return this.#editStack.canUndo; } @@ -192,9 +188,9 @@ export class TextDocument { updateHistory = false, selectionsBefore?: EditorSelection[], selectionsAfter?: EditorSelection[], - lineAnnotationsBefore?: unknown[], - lineAnnotationsAfter?: unknown[] - ): void { + lineAnnotationsBefore?: LineAnnotation[], + lineAnnotationsAfter?: LineAnnotation[] + ): TextDocumentChange | undefined { if (edits.length === 0) { return; } @@ -213,45 +209,55 @@ export class TextDocument { lineAnnotationsAfter ); } - this.#lastChange = this.#applyResolvedEdits(resolvedEdits); this.#version++; + return this.#applyResolvedEdits(resolvedEdits); } setLastUndoSelectionsAfter(selections: EditorSelection[]): void { this.#editStack.setLastUndoSelectionsAfter(selections); } - setLastUndoLineAnnotationsAfter(lineAnnotations: unknown[]): void { + setLastUndoLineAnnotationsAfter( + lineAnnotations: LineAnnotation[] + ): void { this.#editStack.setLastUndoLineAnnotationsAfter(lineAnnotations); } undo(): - | { selections?: EditorSelection[]; lineAnnotations?: unknown[] } + | { + change?: TextDocumentChange; + selections: EditorSelection[]; + lineAnnotations?: LineAnnotation[]; + } | undefined { const entry = this.#editStack.popUndoToRedo(); if (entry === undefined) { - this.#lastChange = undefined; return undefined; } - this.#lastChange = this.#applyResolvedEdits(entry.inverseEdits); + const change = this.#applyResolvedEdits(entry.inverseEdits); this.#version = entry.versionBefore; return { + change, selections: cloneSelections(entry.selectionsBefore), lineAnnotations: entry.lineAnnotationsBefore?.slice(), }; } redo(): - | { selections?: EditorSelection[]; lineAnnotations?: unknown[] } + | { + change?: TextDocumentChange; + selections?: EditorSelection[]; + lineAnnotations?: LineAnnotation[]; + } | undefined { const entry = this.#editStack.popRedoToUndo(); if (entry === undefined) { - this.#lastChange = undefined; return undefined; } - this.#lastChange = this.#applyResolvedEdits(entry.forwardEdits); + const change = this.#applyResolvedEdits(entry.forwardEdits); this.#version = entry.versionAfter; return { + change, selections: entry.selectionsAfter !== undefined ? cloneSelections(entry.selectionsAfter) @@ -291,7 +297,7 @@ export class TextDocument { this.#pieceTable.insert(edit.text, edit.start); } const lineCount = this.#pieceTable.lineCount; - const change = { + const change: TextDocumentChange = { startLine: changedLineRange.startLine, endLine: Math.min(changedLineRange.endLine, Math.max(0, lineCount - 1)), previousLineCount, @@ -301,7 +307,7 @@ export class TextDocument { Object.defineProperty(change, 'startCharacter', { value: startPosition.character, }); - return change as TextDocumentChange; + return change; } #computeChangedLineRange(edits: ResolvedTextEdit[]): { diff --git a/packages/diffs/src/editor/tokenzier.ts b/packages/diffs/src/editor/tokenzier.ts index a48daaf13..2ae7a6e71 100644 --- a/packages/diffs/src/editor/tokenzier.ts +++ b/packages/diffs/src/editor/tokenzier.ts @@ -15,7 +15,7 @@ import type { TextDocument } from './textDocument'; export interface BackgroundTokenizerOptions { grammar: IGrammar; colorMap: { dark: string[]; light: string[] }; - textDocument: TextDocument; + textDocument: TextDocument; onTokenize: (result: { lines: Map> }) => void; linesPreTokenize?: number; // default to 50 } @@ -24,7 +24,7 @@ export interface BackgroundTokenizerOptions { export class BackgroundTokenizer { #grammar: IGrammar; #colorMap: { dark: string[]; light: string[] }; - #textDocument: TextDocument; + #textDocument: TextDocument; #messageKey: string; #onMessage: (event: MessageEvent) => void; #onTokenize: (result: { diff --git a/packages/diffs/test/editorLineAnnotations.test.ts b/packages/diffs/test/editorLineAnnotations.test.ts index 68134d707..3574755b8 100644 --- a/packages/diffs/test/editorLineAnnotations.test.ts +++ b/packages/diffs/test/editorLineAnnotations.test.ts @@ -13,7 +13,7 @@ describe('applyDocumentChangeToLineAnnotations', () => { { lineNumber: 3, metadata: 'three' }, ]; - textDocument.applyEdits([ + const change = textDocument.applyEdits([ { range: { start: { line: 1, character: 0 }, @@ -23,12 +23,7 @@ describe('applyDocumentChangeToLineAnnotations', () => { }, ]); - expect( - applyDocumentChangeToLineAnnotations( - textDocument.lastChange!, - annotations - ) - ).toEqual([ + expect(applyDocumentChangeToLineAnnotations(change!, annotations)).toEqual([ { lineNumber: 1, metadata: 'one' }, { lineNumber: 2, metadata: 'three' }, ]); @@ -42,7 +37,7 @@ describe('applyDocumentChangeToLineAnnotations', () => { { lineNumber: 3, metadata: 'three' }, ]; - textDocument.applyEdits([ + const change = textDocument.applyEdits([ { range: { start: { line: 1, character: 0 }, @@ -52,12 +47,7 @@ describe('applyDocumentChangeToLineAnnotations', () => { }, ]); - expect( - applyDocumentChangeToLineAnnotations( - textDocument.lastChange!, - annotations - ) - ).toEqual([ + expect(applyDocumentChangeToLineAnnotations(change!, annotations)).toEqual([ { lineNumber: 1, metadata: 'one' }, { lineNumber: 3, metadata: 'two' }, { lineNumber: 4, metadata: 'three' }, @@ -70,7 +60,7 @@ describe('applyDocumentChangeToLineAnnotations', () => { { lineNumber: 1, metadata: 'one' }, ]; - textDocument.applyEdits([ + const change = textDocument.applyEdits([ { range: { start: { line: 2, character: 0 }, @@ -80,11 +70,8 @@ describe('applyDocumentChangeToLineAnnotations', () => { }, ]); - expect( - applyDocumentChangeToLineAnnotations( - textDocument.lastChange!, - annotations - ) - ).toBe(undefined); + expect(applyDocumentChangeToLineAnnotations(change!, annotations)).toBe( + undefined + ); }); }); diff --git a/packages/diffs/test/editorSelection.test.ts b/packages/diffs/test/editorSelection.test.ts index db0dd21ea..f720510bc 100644 --- a/packages/diffs/test/editorSelection.test.ts +++ b/packages/diffs/test/editorSelection.test.ts @@ -18,7 +18,6 @@ import { type SelectionDirection, } from '../src/editor/editorSelection'; import { TextDocument } from '../src/editor/textDocument'; -import type { LineAnnotation } from '../src/types'; type MockNode = { nodeType: number; @@ -736,36 +735,6 @@ describe('applyTextReplaceToSelections', () => { createSelection(2, 2, 2, 2), ]); }); - - test('updates line annotations after replacements that insert lines', () => { - const textDocument = new TextDocument('inmemory://1', 'x\ny\nz'); - const selections = [createSelection(0, 1, 0, 1)]; - const annotations: LineAnnotation[] = [ - { lineNumber: 1, metadata: 'x' }, - { lineNumber: 2, metadata: 'y' }, - ]; - - const { newLineAnnotations } = applyTextReplaceToSelections( - textDocument, - selections, - ['\ninserted'], - annotations - ); - - expect(textDocument.getText()).toBe('x\ninserted\ny\nz'); - expect(newLineAnnotations).toEqual([ - { lineNumber: 1, metadata: 'x' }, - { lineNumber: 3, metadata: 'y' }, - ]); - expect(textDocument.undo()).toEqual({ - selections, - lineAnnotations: annotations, - }); - expect(textDocument.redo()).toEqual({ - selections: [createSelection(1, 8, 1, 8)], - lineAnnotations: newLineAnnotations, - }); - }); }); describe('computeExtendSelection', () => { diff --git a/packages/diffs/test/textDocument.test.ts b/packages/diffs/test/textDocument.test.ts index 5dcfb5223..2571abd55 100644 --- a/packages/diffs/test/textDocument.test.ts +++ b/packages/diffs/test/textDocument.test.ts @@ -104,7 +104,7 @@ describe('TextDocument', () => { test('applyEdits single replacement', () => { const d = doc('hello world'); - d.applyEdits([ + const change = d.applyEdits([ { range: { start: { line: 0, character: 6 }, @@ -113,7 +113,6 @@ describe('TextDocument', () => { newText: 'you', }, ]); - const change = d.lastChange; expect(d.getText()).toBe('hello you'); expect(change).toEqual({ startLine: 0, @@ -162,7 +161,7 @@ describe('TextDocument', () => { test('applyEdits preserves line breaks around edited line', () => { const d = doc('a\nb\nc'); - d.applyEdits([ + const change = d.applyEdits([ { range: { start: { line: 1, character: 0 }, @@ -173,7 +172,7 @@ describe('TextDocument', () => { ]); expect(d.getText()).toBe('a\nB\nc'); expect(d.lineCount).toBe(3); - expect(d.lastChange).toEqual({ + expect(change).toEqual({ startLine: 1, endLine: 1, previousLineCount: 3, @@ -182,9 +181,9 @@ describe('TextDocument', () => { }); }); - test('applyEdits reports inserted lines in lastChange', () => { + test('applyEdits reports inserted lines in returned change', () => { const d = doc('a'); - d.applyEdits([ + const change = d.applyEdits([ { range: { start: { line: 0, character: 1 }, @@ -194,7 +193,7 @@ describe('TextDocument', () => { }, ]); expect(d.getText()).toBe('a\nb'); - expect(d.lastChange).toEqual({ + expect(change).toEqual({ startLine: 0, endLine: 1, previousLineCount: 1, @@ -203,9 +202,9 @@ describe('TextDocument', () => { }); }); - test('applyEdits reports line deletions in lastChange', () => { + test('applyEdits reports line deletions in returned change', () => { const d = doc('a\nb\nc'); - d.applyEdits([ + const change = d.applyEdits([ { range: { start: { line: 0, character: 1 }, @@ -215,7 +214,7 @@ describe('TextDocument', () => { }, ]); expect(d.getText()).toBe('ac'); - expect(d.lastChange).toEqual({ + expect(change).toEqual({ startLine: 0, endLine: 0, previousLineCount: 3, @@ -414,9 +413,9 @@ describe('TextDocument', () => { expect(d.canUndo).toBe(true); expect(d.canRedo).toBe(false); - d.undo(); + const undoResult = d.undo(); expect(d.getText()).toBe('a'); - expect(d.lastChange).toEqual({ + expect(undoResult?.change).toEqual({ startLine: 0, endLine: 0, previousLineCount: 1, @@ -426,9 +425,9 @@ describe('TextDocument', () => { expect(d.canUndo).toBe(false); expect(d.canRedo).toBe(true); - d.redo(); + const redoResult = d.redo(); expect(d.getText()).toBe('ab'); - expect(d.lastChange).toEqual({ + expect(redoResult?.change).toEqual({ startLine: 0, endLine: 0, previousLineCount: 1, @@ -529,8 +528,8 @@ describe('TextDocument', () => { [selectionAfter] ); - expect(d.undo()).toEqual({ selections: [selectionBefore] }); - expect(d.redo()).toEqual({ selections: [selectionAfter] }); + expect(d.undo()?.selections).toEqual([selectionBefore]); + expect(d.redo()?.selections).toEqual([selectionAfter]); }); test('undo and redo preserve multiple selections', () => { @@ -559,7 +558,7 @@ describe('TextDocument', () => { selectionsAfter ); - expect(d.undo()).toEqual({ selections: selectionsBefore }); - expect(d.redo()).toEqual({ selections: selectionsAfter }); + expect(d.undo()?.selections).toEqual(selectionsBefore); + expect(d.redo()?.selections).toEqual(selectionsAfter); }); }); From 2bb2e4b3556057555b922fbaa9dfef6b3cb4c25c Mon Sep 17 00:00:00 2001 From: Je Xia Date: Sat, 9 May 2026 10:43:00 +0800 Subject: [PATCH 095/138] Refactor --- packages/diffs/src/editor/editStack.ts | 8 +- .../diffs/src/editor/editorLineAnnotations.ts | 8 +- packages/diffs/src/editor/index.ts | 289 +++++++++--------- packages/diffs/src/editor/pieceTable.ts | 4 +- packages/diffs/src/editor/textDocument.ts | 51 ++-- .../diffs/test/editorLineAnnotations.test.ts | 4 +- packages/diffs/test/textDocument.test.ts | 94 +++++- 7 files changed, 276 insertions(+), 182 deletions(-) diff --git a/packages/diffs/src/editor/editStack.ts b/packages/diffs/src/editor/editStack.ts index 7e4ad2a25..2fcb90759 100644 --- a/packages/diffs/src/editor/editStack.ts +++ b/packages/diffs/src/editor/editStack.ts @@ -18,13 +18,13 @@ interface EditStackEntry { versionBefore: number; /** Document version after the entry is applied. */ versionAfter: number; - /** Selection before the transaction (restored on undo). */ + /** Selection before the transaction. */ selectionsBefore: EditorSelection[]; - /** Selection after the transaction (restored on redo). */ + /** Selection after the transaction. */ selectionsAfter?: EditorSelection[]; - /** Line annotations before the transaction (restored on undo). */ + /** Line annotations before the transaction. */ lineAnnotationsBefore?: LineAnnotation[]; - /** Line annotations after the transaction (restored on redo). */ + /** Line annotations after the transaction. */ lineAnnotationsAfter?: LineAnnotation[]; } diff --git a/packages/diffs/src/editor/editorLineAnnotations.ts b/packages/diffs/src/editor/editorLineAnnotations.ts index 6946bc285..f2682bc55 100644 --- a/packages/diffs/src/editor/editorLineAnnotations.ts +++ b/packages/diffs/src/editor/editorLineAnnotations.ts @@ -5,10 +5,10 @@ import type { TextDocumentChange } from './textDocument'; // returning undefined when no annotation moved or was deleted. export function applyDocumentChangeToLineAnnotations( change: TextDocumentChange, - lineAnnotations: readonly LineAnnotation[] -): LineAnnotation[] | undefined { + lineAnnotations: LineAnnotation[] +): LineAnnotation[] { if (change.lineDelta === 0) { - return undefined; + return lineAnnotations; } const startCharacter = change.startCharacter ?? 0; @@ -52,5 +52,5 @@ export function applyDocumentChangeToLineAnnotations( nextLineAnnotations.push(annotation); } - return changed ? nextLineAnnotations : undefined; + return changed ? nextLineAnnotations : lineAnnotations; } diff --git a/packages/diffs/src/editor/index.ts b/packages/diffs/src/editor/index.ts index 5721d06b0..f6670270a 100644 --- a/packages/diffs/src/editor/index.ts +++ b/packages/diffs/src/editor/index.ts @@ -785,9 +785,11 @@ export class Editor implements DiffsEditor { this.#tabSize ); if (change !== undefined) { - this.#rerender(change, this.#applyChangeToLineAnnotations(change)); - this.#emitChange(); - this.setSelections(nextSelections, false); + this.#applyChange( + change, + nextSelections, + this.#applyChangeToLineAnnotations(change) + ); } return; } @@ -909,28 +911,6 @@ export class Editor implements DiffsEditor { } } - #emitChange() { - const fileContents = this.#fileContents; - const textDocument = this.#textDocument; - const onChange = this.#onChange; - if ( - fileContents !== undefined && - textDocument !== undefined && - onChange !== undefined - ) { - // TODO(@ije): use debounce - requestAnimationFrame(() => { - const { contents: _, ...file } = fileContents; - Object.defineProperty(file, 'contents', { - get() { - return textDocument.getText(); - }, - }); - onChange(file as FileContents, this.#lineAnnotations); - }); - } - } - #updateTextarea(primarySelection: EditorSelection) { const textDocument = this.#textDocument; const textareaEl = this.#textareaEl; @@ -1033,64 +1013,6 @@ export class Editor implements DiffsEditor { } } - // Render one selection range div for a single visual line. `applyEolSpacing` - // controls whether the trailing one-character "line continuation" marker is - // appended at the end. For wrapped logical lines this must be false on every - // visual segment except the last one, since an intra-line wrap is not a real - // newline and shouldn't visually extend past the wrapped content. - #renderSelectionRange( - selection: EditorSelection, - ln: number, - wrapLine: number, - startChar: number, - endChar: number, - width: number, - left: number, - fragment: DocumentFragment, - cacheMap: Map, - applyEolSpacing = true - ) { - const spacing = - !applyEolSpacing || - selection.end.line === ln || - (startChar === endChar && ln !== selection.start.line) - ? 0 - : this.#charWidth; - const css = `width:${width + spacing}px;transform:translateY(${this.#getLineY(ln) + wrapLine * this.#lineHeight}px) translateX(${left}px);`; - const cacheKey = 'selection-range-' + css; - const selectionEls = this.#selectionEls; - - let rangeEl: HTMLElement | undefined; - if (selectionEls?.has(cacheKey) === true) { - rangeEl = selectionEls.get(cacheKey)!; - selectionEls.delete(cacheKey); - } else { - for (const [key, el] of selectionEls?.entries() ?? []) { - if (key.startsWith(`selection-${ln}-`)) { - rangeEl = el; - selectionEls?.delete(key); - el.style.cssText = css; - break; - } - } - } - - if (rangeEl === undefined) { - rangeEl = createElement( - 'div', - { - dataset: 'selectionRange', - style: { cssText: css }, - }, - fragment - ); - } else if (rangeEl.parentElement !== this.#contentEl) { - fragment.appendChild(rangeEl); - } - - cacheMap.set(cacheKey, rangeEl); - } - // Render the selection on a wrapped logical line by splitting it into one // selection-range div per visual sub-line. For each wrap segment, we compute // the intersection with the line's selection range and render the slice in @@ -1180,6 +1102,64 @@ export class Editor implements DiffsEditor { } } + // Render one selection range div for a single visual line. `applyEolSpacing` + // controls whether the trailing one-character "line continuation" marker is + // appended at the end. For wrapped logical lines this must be false on every + // visual segment except the last one, since an intra-line wrap is not a real + // newline and shouldn't visually extend past the wrapped content. + #renderSelectionRange( + selection: EditorSelection, + ln: number, + wrapLine: number, + startChar: number, + endChar: number, + width: number, + left: number, + fragment: DocumentFragment, + cacheMap: Map, + applyEolSpacing = true + ) { + const spacing = + !applyEolSpacing || + selection.end.line === ln || + (startChar === endChar && ln !== selection.start.line) + ? 0 + : this.#charWidth; + const css = `width:${width + spacing}px;transform:translateY(${this.#getLineY(ln) + wrapLine * this.#lineHeight}px) translateX(${left}px);`; + const cacheKey = 'selection-range-' + css; + const selectionEls = this.#selectionEls; + + let rangeEl: HTMLElement | undefined; + if (selectionEls?.has(cacheKey) === true) { + rangeEl = selectionEls.get(cacheKey)!; + selectionEls.delete(cacheKey); + } else { + for (const [key, el] of selectionEls?.entries() ?? []) { + if (key.startsWith(`selection-${ln}-`)) { + rangeEl = el; + selectionEls?.delete(key); + el.style.cssText = css; + break; + } + } + } + + if (rangeEl === undefined) { + rangeEl = createElement( + 'div', + { + dataset: 'selectionRange', + style: { cssText: css }, + }, + fragment + ); + } else if (rangeEl.parentElement !== this.#contentEl) { + fragment.appendChild(rangeEl); + } + + cacheMap.set(cacheKey, rangeEl); + } + #renderCaret( selection: EditorSelection, fragment: DocumentFragment, @@ -1207,21 +1187,6 @@ export class Editor implements DiffsEditor { cacheMap.set('caret-' + line + '-' + character, caretEl); } - // Check whether a line is visible in the currently rendered line window. - #isLineVisible(line: number): boolean { - if (this.#renderRange === undefined) { - return true; - } - const { startingLine, totalLines } = this.#renderRange; - if (line < startingLine) { - return false; - } - if (totalLines === Infinity) { - return true; - } - return line < startingLine + totalLines; - } - async #runCommand(command: EditorCommand) { switch (command) { case 'selectAll': @@ -1230,10 +1195,7 @@ export class Editor implements DiffsEditor { case 'copy': case 'cut': - if ( - this.#selections !== undefined && - this.#textDocument !== undefined - ) { + if (this.#selections !== undefined) { try { // todo: use navigator.clipboard.write() for multiple selections copy await navigator.clipboard.writeText( @@ -1312,9 +1274,7 @@ export class Editor implements DiffsEditor { nextSelections ); if (change !== undefined) { - this.#rerender(change); - this.#emitChange(); - this.setSelections(nextSelections, false); + this.#applyChange(change, nextSelections); } } } @@ -1330,11 +1290,8 @@ export class Editor implements DiffsEditor { case 'undo': if (this.#textDocument?.canUndo === true) { const undoResult = this.#textDocument.undo(); - if (undoResult?.change !== undefined) { - this.#rerender(undoResult.change, undoResult.lineAnnotations); - this.#emitChange(); - this.setSelections(undoResult.selections, false); - this.#focusTextarea(); + if (undoResult !== undefined) { + this.#applyChange(...undoResult); } } break; @@ -1342,13 +1299,8 @@ export class Editor implements DiffsEditor { case 'redo': if (this.#textDocument?.canRedo === true) { const redoResult = this.#textDocument.redo(); - if (redoResult?.change !== undefined) { - this.#rerender(redoResult.change, redoResult.lineAnnotations); - this.#emitChange(); - if (redoResult.selections !== undefined) { - this.setSelections(redoResult.selections, false); - this.#focusTextarea(); - } + if (redoResult !== undefined) { + this.#applyChange(...redoResult); } } break; @@ -1387,7 +1339,8 @@ export class Editor implements DiffsEditor { } #getSelectionText(selections: readonly EditorSelection[]): string { - if (this.#textDocument === undefined) { + const textDocument = this.#textDocument; + if (textDocument === undefined) { return ''; } return [...selections] @@ -1398,7 +1351,12 @@ export class Editor implements DiffsEditor { } return comparePosition(a.end, b.end); }) - .map((selection) => this.#textDocument!.getText(selection)) + .map((selection) => { + if (isCollapsedSelection(selection)) { + return textDocument.getLineText(selection.start.line, false); + } + return textDocument.getText(selection); + }) .join('\n'); } @@ -1434,22 +1392,57 @@ export class Editor implements DiffsEditor { ); if (change !== undefined) { - this.#rerender(change, this.#applyChangeToLineAnnotations(change)); - this.#emitChange(); - this.setSelections(nextSelections, false); + this.#applyChange( + change, + nextSelections, + this.#applyChangeToLineAnnotations(change) + ); + } + } + + #applyChange( + change: TextDocumentChange, + selections?: EditorSelection[], + lineAnnotations?: LineAnnotation[] + ) { + const fileContents = this.#fileContents; + const textDocument = this.#textDocument; + const onChange = this.#onChange; + if ( + fileContents !== undefined && + textDocument !== undefined && + onChange !== undefined + ) { + // TODO(@ije): use debounce + setTimeout(() => { + const { contents: _, ...file } = fileContents; + Object.defineProperty(file, 'contents', { + get() { + return textDocument.getText(); + }, + }); + onChange( + file as FileContents, + lineAnnotations ?? this.#lineAnnotations + ); + }, 0); + } + this.#rerender(change, lineAnnotations); + if (selections !== undefined) { + this.setSelections(selections, false); } } #applyChangeToLineAnnotations( change: TextDocumentChange ): LineAnnotation[] | undefined { - if (this.#lineAnnotations !== undefined && change !== undefined) { + if (this.#lineAnnotations !== undefined) { const nextLineAnnotations = applyDocumentChangeToLineAnnotations( change, this.#lineAnnotations ); - if (nextLineAnnotations !== undefined) { + if (nextLineAnnotations !== this.#lineAnnotations) { this.#textDocument?.setLastUndoLineAnnotationsAfter( nextLineAnnotations ); @@ -1459,6 +1452,20 @@ export class Editor implements DiffsEditor { return undefined; } + #getContentWidth() { + const diffsColumnContentWidth = + this.#contentEl?.parentElement?.style.getPropertyValue( + '--diffs-column-content-width' + ) ?? ''; + if ( + diffsColumnContentWidth.length > 2 && + diffsColumnContentWidth.endsWith('px') + ) { + return Number(diffsColumnContentWidth.slice(0, -2)); + } + return this.#contentEl?.offsetWidth ?? 0; + } + #getLineElement(line: number): HTMLElement | undefined { const children = this.#contentEl?.children; if (children === undefined) { @@ -1581,20 +1588,6 @@ export class Editor implements DiffsEditor { return this.#measureCtx.measureText(textWithExpandedTabs).width; } - #getContentWidth() { - const diffsColumnContentWidth = - this.#contentEl?.parentElement?.style.getPropertyValue( - '--diffs-column-content-width' - ) ?? ''; - if ( - diffsColumnContentWidth.length > 2 && - diffsColumnContentWidth.endsWith('px') - ) { - return Number(diffsColumnContentWidth.slice(0, -2)); - } - return this.#contentEl?.offsetWidth ?? 0; - } - // Compute how a logical line of text is broken into visual lines when line // wrapping is enabled. #wrapLineText(line: number): Uint32Array { @@ -1705,16 +1698,30 @@ export class Editor implements DiffsEditor { } // check if the web selection belongs to editor - #rangeBelongsToEditor(range: StaticRange) { + #rangeBelongsToEditor({ startContainer, endContainer }: StaticRange) { const contentEl = this.#contentEl; if (contentEl === undefined) { return false; } return ( - contentEl.contains(range.startContainer) && - contentEl.contains(range.endContainer) + contentEl.contains(startContainer) && contentEl.contains(endContainer) ); } + + // Check whether a line is visible in the currently rendered line window. + #isLineVisible(line: number): boolean { + if (this.#renderRange === undefined) { + return true; + } + const { startingLine, totalLines } = this.#renderRange; + if (line < startingLine) { + return false; + } + if (totalLines === Infinity) { + return true; + } + return line < startingLine + totalLines; + } } export function edit(file: File): void { diff --git a/packages/diffs/src/editor/pieceTable.ts b/packages/diffs/src/editor/pieceTable.ts index 51df0d5f3..122d6b66f 100644 --- a/packages/diffs/src/editor/pieceTable.ts +++ b/packages/diffs/src/editor/pieceTable.ts @@ -88,7 +88,7 @@ export class PieceTable { return this.getTextSlice(start, end); } - getLineText(line: number): string { + getLineText(line: number, trimEOF = true): string { if (this.#lastVisitedLine !== null && this.#lastVisitedLine[0] === line) { return this.#lastVisitedLine[1]; } @@ -96,7 +96,7 @@ export class PieceTable { if (offset === undefined) { throw new Error(`Line index out of range: ${line}`); } - const text = this.getTextSlice(offset[0], offset[1], true); + const text = this.getTextSlice(offset[0], offset[1], trimEOF); this.#lastVisitedLine = [line, text]; return text; } diff --git a/packages/diffs/src/editor/textDocument.ts b/packages/diffs/src/editor/textDocument.ts index 4c277b76d..d5c47d408 100644 --- a/packages/diffs/src/editor/textDocument.ts +++ b/packages/diffs/src/editor/textDocument.ts @@ -166,8 +166,8 @@ export class TextDocument { return this.#pieceTable.getText(range); } - getLineText(line: number): string { - return this.#pieceTable.getLineText(line); + getLineText(line: number, trimEOF = true): string { + return this.#pieceTable.getLineText(line, trimEOF); } charAt(offset: number): string; @@ -224,46 +224,51 @@ export class TextDocument { } undo(): - | { - change?: TextDocumentChange; - selections: EditorSelection[]; - lineAnnotations?: LineAnnotation[]; - } + | [ + change: TextDocumentChange, + selections: EditorSelection[], + lineAnnotations?: LineAnnotation[], + ] | undefined { const entry = this.#editStack.popUndoToRedo(); if (entry === undefined) { return undefined; } const change = this.#applyResolvedEdits(entry.inverseEdits); + if (change === undefined) { + return undefined; + } this.#version = entry.versionBefore; - return { + return [ change, - selections: cloneSelections(entry.selectionsBefore), - lineAnnotations: entry.lineAnnotationsBefore?.slice(), - }; + cloneSelections(entry.selectionsBefore), + entry.lineAnnotationsBefore?.slice(), + ]; } redo(): - | { - change?: TextDocumentChange; - selections?: EditorSelection[]; - lineAnnotations?: LineAnnotation[]; - } + | [ + change: TextDocumentChange, + selections?: EditorSelection[], + lineAnnotations?: LineAnnotation[], + ] | undefined { const entry = this.#editStack.popRedoToUndo(); if (entry === undefined) { return undefined; } const change = this.#applyResolvedEdits(entry.forwardEdits); + if (change === undefined) { + return undefined; + } this.#version = entry.versionAfter; - return { + return [ change, - selections: - entry.selectionsAfter !== undefined - ? cloneSelections(entry.selectionsAfter) - : undefined, - lineAnnotations: entry.lineAnnotationsAfter?.slice(), - }; + entry.selectionsAfter !== undefined + ? cloneSelections(entry.selectionsAfter) + : undefined, + entry.lineAnnotationsAfter?.slice(), + ]; } #resolveEdit(edit: TextEdit): ResolvedTextEdit { diff --git a/packages/diffs/test/editorLineAnnotations.test.ts b/packages/diffs/test/editorLineAnnotations.test.ts index 3574755b8..f28585ae7 100644 --- a/packages/diffs/test/editorLineAnnotations.test.ts +++ b/packages/diffs/test/editorLineAnnotations.test.ts @@ -70,8 +70,8 @@ describe('applyDocumentChangeToLineAnnotations', () => { }, ]); - expect(applyDocumentChangeToLineAnnotations(change!, annotations)).toBe( - undefined + expect(applyDocumentChangeToLineAnnotations(change!, annotations)).toEqual( + annotations ); }); }); diff --git a/packages/diffs/test/textDocument.test.ts b/packages/diffs/test/textDocument.test.ts index 2571abd55..91cecb5c7 100644 --- a/packages/diffs/test/textDocument.test.ts +++ b/packages/diffs/test/textDocument.test.ts @@ -3,6 +3,7 @@ import { describe, expect, test } from 'bun:test'; import type { EditorSelection } from '../src/editor/editorSelection'; import { DirectionNone } from '../src/editor/editorSelection'; import { TextDocument, type TextEdit } from '../src/editor/textDocument'; +import type { LineAnnotation } from '../src/types'; function doc(text: string) { return new TextDocument('inmemory://1', text, 'plain'); @@ -415,7 +416,7 @@ describe('TextDocument', () => { const undoResult = d.undo(); expect(d.getText()).toBe('a'); - expect(undoResult?.change).toEqual({ + expect(undoResult?.[0]).toEqual({ startLine: 0, endLine: 0, previousLineCount: 1, @@ -427,7 +428,7 @@ describe('TextDocument', () => { const redoResult = d.redo(); expect(d.getText()).toBe('ab'); - expect(redoResult?.change).toEqual({ + expect(redoResult?.[0]).toEqual({ startLine: 0, endLine: 0, previousLineCount: 1, @@ -528,8 +529,8 @@ describe('TextDocument', () => { [selectionAfter] ); - expect(d.undo()?.selections).toEqual([selectionBefore]); - expect(d.redo()?.selections).toEqual([selectionAfter]); + expect(d.undo()?.[1]).toEqual([selectionBefore]); + expect(d.redo()?.[1]).toEqual([selectionAfter]); }); test('undo and redo preserve multiple selections', () => { @@ -558,7 +559,88 @@ describe('TextDocument', () => { selectionsAfter ); - expect(d.undo()?.selections).toEqual(selectionsBefore); - expect(d.redo()?.selections).toEqual(selectionsAfter); + expect(d.undo()?.[1]).toEqual(selectionsBefore); + expect(d.redo()?.[1]).toEqual(selectionsAfter); + }); + + test('undo and redo return stored line annotations', () => { + const d = doc('abc'); + const annotationsBefore: LineAnnotation[] = [ + { lineNumber: 1, metadata: 'bookmark-a' }, + ]; + const annotationsAfter: LineAnnotation[] = [ + { lineNumber: 1, metadata: 'bookmark-b' }, + ]; + d.applyEdits( + [ + { + range: { + start: { line: 0, character: 1 }, + end: { line: 0, character: 1 }, + }, + newText: 'x', + }, + ], + true, + [caret(0, 1)], + [caret(0, 2)], + annotationsBefore, + annotationsAfter + ); + + expect(d.undo()?.[2]).toEqual(annotationsBefore); + expect(d.redo()?.[2]).toEqual(annotationsAfter); + }); + + test('undo omits line annotations tuple entry when none were recorded', () => { + const d = doc('abc'); + d.applyEdits( + [ + { + range: { + start: { line: 0, character: 1 }, + end: { line: 0, character: 1 }, + }, + newText: 'x', + }, + ], + true, + [caret(0, 1)], + [caret(0, 2)] + ); + + expect(d.undo()?.[2]).toBeUndefined(); + expect(d.redo()?.[2]).toBeUndefined(); + }); + + test('setLastUndoLineAnnotationsAfter updates redo line annotations', () => { + const d = doc('a'); + const annotationsBefore: LineAnnotation[] = [ + { lineNumber: 1, metadata: 'initial' }, + ]; + d.applyEdits( + [ + { + range: { + start: { line: 0, character: 1 }, + end: { line: 0, character: 1 }, + }, + newText: 'b', + }, + ], + true, + [caret(0, 1)], + undefined, + annotationsBefore, + undefined + ); + + const patchedAfter: LineAnnotation[] = [ + { lineNumber: 1, metadata: 'patched-after-edit' }, + ]; + d.setLastUndoLineAnnotationsAfter(patchedAfter); + + d.undo(); + expect(d.redo()?.[2]).toEqual(patchedAfter); }); }); From f114396511ae4c9ca625b6efeffb7422b68f1524 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Sat, 9 May 2026 11:08:06 +0800 Subject: [PATCH 096/138] Fix shift select delay --- packages/diffs/src/editor/index.ts | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/packages/diffs/src/editor/index.ts b/packages/diffs/src/editor/index.ts index f6670270a..df811d821 100644 --- a/packages/diffs/src/editor/index.ts +++ b/packages/diffs/src/editor/index.ts @@ -392,11 +392,12 @@ export class Editor implements DiffsEditor { // when the user is using the 'Shift' key to create a selection // hide the textarea element or the selection will be created in the textarea - if (e.shiftKey) { + if (e.shiftKey && this.#textareaEl !== undefined) { this.#shouldIgnoreSelectionChange = true; - if (this.#textareaEl !== undefined) { - this.#textareaEl.style.visibility = 'hidden'; - } + this.#textareaEl.style.visibility = 'hidden'; + requestAnimationFrame(() => { + this.#onSelectionChange(true); + }); } mouseEventDisposes.push( @@ -431,12 +432,9 @@ export class Editor implements DiffsEditor { mouseEventDisposes.forEach((dispose) => dispose()); mouseEventDisposes.length = 0; this.#reservedSelections = undefined; - if (e.shiftKey) { - this.#onSelectionChange(true); + if (e.shiftKey && this.#textareaEl !== undefined) { this.#shouldIgnoreSelectionChange = false; - if (this.#textareaEl !== undefined) { - this.#textareaEl.style.visibility = 'visible'; - } + this.#textareaEl.style.visibility = 'visible'; } this.#focusTextarea(); }), From f847f637c0b5555d6cef3f43dcdc9bb2ccf0b180 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Sat, 9 May 2026 13:38:32 +0800 Subject: [PATCH 097/138] Coalesce edit stack entries for simple typing or backspace operations. --- packages/diffs/src/editor/editStack.ts | 91 ++--- .../diffs/src/editor/editorLineAnnotations.ts | 6 +- packages/diffs/src/editor/editorSelection.ts | 2 + packages/diffs/src/editor/editorTextarea.ts | 18 +- packages/diffs/src/editor/index.ts | 57 ++- packages/diffs/src/editor/textDocument.ts | 205 ++++++++++- packages/diffs/test/editStack.test.ts | 96 +++-- packages/diffs/test/textDocument.test.ts | 343 +++++++++++++++--- 8 files changed, 624 insertions(+), 194 deletions(-) diff --git a/packages/diffs/src/editor/editStack.ts b/packages/diffs/src/editor/editStack.ts index 2fcb90759..07a2dfed5 100644 --- a/packages/diffs/src/editor/editStack.ts +++ b/packages/diffs/src/editor/editStack.ts @@ -1,15 +1,11 @@ import type { LineAnnotation } from '../types'; import type { EditorSelection } from './editorSelection'; -import type { ResolvedTextEdit } from './textDocument'; +import type { ResolvedTextEdit, TextDocument } from './textDocument'; /** Largest number of undo or redo entries kept; oldest entries drop first once exceeded. */ const DEFAULT_EDIT_STACK_MAX_ENTRIES = 100; -interface EditSource { - getTextSlice(start: number, end: number): string; -} - -interface EditStackEntry { +export interface EditStackEntry { /** Forward offset edits from the entry's base text to its final text. */ forwardEdits: ResolvedTextEdit[]; /** Inverse offset edits from the entry's final text back to its base text. */ @@ -19,7 +15,7 @@ interface EditStackEntry { /** Document version after the entry is applied. */ versionAfter: number; /** Selection before the transaction. */ - selectionsBefore: EditorSelection[]; + selectionsBefore?: EditorSelection[]; /** Selection after the transaction. */ selectionsAfter?: EditorSelection[]; /** Line annotations before the transaction. */ @@ -57,31 +53,13 @@ export class EditStack { this.#redoStack.length = 0; } - push( - source: EditSource, - resolvedEdits: ResolvedTextEdit[], - versionBefore: number, - versionAfter: number, - selectionsBefore: EditorSelection[], - selectionsAfter?: EditorSelection[], - lineAnnotationsBefore?: LineAnnotation[], - lineAnnotationsAfter?: LineAnnotation[] - ): void { - const forwardEdits = [...resolvedEdits].sort((a, b) => a.start - b.start); - const inverseEdits = buildInverseOffsetEdits(source, forwardEdits); - this.#undoStack.push({ - forwardEdits: forwardEdits.map((edit) => ({ ...edit })), - inverseEdits: inverseEdits, - versionBefore, - versionAfter, - selectionsBefore: selectionsBefore?.map((selection) => ({ - ...selection, - })), - selectionsAfter: selectionsAfter?.map((selection) => ({ ...selection })), - lineAnnotationsBefore: lineAnnotationsBefore?.slice(), - lineAnnotationsAfter: lineAnnotationsAfter?.slice(), - }); + clearRedo(): void { this.#redoStack.length = 0; + } + + push(entry: EditStackEntry): void { + this.#undoStack.push(entry); + this.clearRedo(); if (this.#undoStack.length > this.#maxEntries) { this.#undoStack.shift(); } @@ -105,6 +83,19 @@ export class EditStack { } } + peekUndo(): EditStackEntry | undefined { + return this.#undoStack[this.#undoStack.length - 1]; + } + + replaceLastUndo(entry: EditStackEntry): void { + if (this.#undoStack.length === 0) { + this.push(entry); + return; + } + this.#undoStack[this.#undoStack.length - 1] = entry; + this.clearRedo(); + } + /** Moves the latest undo entry to the redo stack and returns it, or `undefined` if empty. */ popUndoToRedo(): EditStackEntry | void { const entry = this.#undoStack.pop(); @@ -124,21 +115,39 @@ export class EditStack { } } -function buildInverseOffsetEdits( - source: EditSource, - ascending: ResolvedTextEdit[] -): ResolvedTextEdit[] { - const inverse: ResolvedTextEdit[] = []; - for (let i = 0, offsetDelta = 0; i < ascending.length; i++) { - const edit = ascending[i]; - const replacedText = source.getTextSlice(edit.start, edit.end); +export function createEditStackEntry( + textDocument: TextDocument, + resolvedEdits: ResolvedTextEdit[], + versionBefore: number, + versionAfter: number, + selectionsBefore?: EditorSelection[], + selectionsAfter?: EditorSelection[], + lineAnnotationsBefore?: LineAnnotation[], + lineAnnotationsAfter?: LineAnnotation[] +): EditStackEntry { + const forwardEdits = [...resolvedEdits].sort((a, b) => a.start - b.start); + const inverseEdits: ResolvedTextEdit[] = []; + for (let i = 0, offsetDelta = 0; i < forwardEdits.length; i++) { + const edit = forwardEdits[i]; + const replacedText = textDocument.getTextSlice(edit.start, edit.end); const startAfterEdit = edit.start + offsetDelta; - inverse.push({ + inverseEdits.push({ start: startAfterEdit, end: startAfterEdit + edit.text.length, text: replacedText, }); offsetDelta += edit.text.length - (edit.end - edit.start); } - return inverse; + return { + forwardEdits: forwardEdits.map((edit) => ({ ...edit })), + inverseEdits: inverseEdits, + versionBefore, + versionAfter, + selectionsBefore: selectionsBefore?.map((selection) => ({ + ...selection, + })), + selectionsAfter: selectionsAfter?.map((selection) => ({ ...selection })), + lineAnnotationsBefore: lineAnnotationsBefore?.slice(), + lineAnnotationsAfter: lineAnnotationsAfter?.slice(), + }; } diff --git a/packages/diffs/src/editor/editorLineAnnotations.ts b/packages/diffs/src/editor/editorLineAnnotations.ts index f2682bc55..83890d9fa 100644 --- a/packages/diffs/src/editor/editorLineAnnotations.ts +++ b/packages/diffs/src/editor/editorLineAnnotations.ts @@ -1,8 +1,6 @@ import type { LineAnnotation } from '../types'; import type { TextDocumentChange } from './textDocument'; -// Updates 1-based line annotations after the document has applied an edit, -// returning undefined when no annotation moved or was deleted. export function applyDocumentChangeToLineAnnotations( change: TextDocumentChange, lineAnnotations: LineAnnotation[] @@ -11,7 +9,7 @@ export function applyDocumentChangeToLineAnnotations( return lineAnnotations; } - const startCharacter = change.startCharacter ?? 0; + const startCharacter = change.startCharacter; const removedLineCount = Math.max(0, -change.lineDelta); const deletedStartLine = removedLineCount === 0 @@ -26,8 +24,8 @@ export function applyDocumentChangeToLineAnnotations( ? change.startLine + removedLineCount : change.startLine + (startCharacter === 0 ? 0 : 1); const nextLineAnnotations: LineAnnotation[] = []; - let changed = false; + let changed = false; for (const annotation of lineAnnotations) { const line = annotation.lineNumber - 1; if ( diff --git a/packages/diffs/src/editor/editorSelection.ts b/packages/diffs/src/editor/editorSelection.ts index 8c8f3b7df..36cb8bab0 100644 --- a/packages/diffs/src/editor/editorSelection.ts +++ b/packages/diffs/src/editor/editorSelection.ts @@ -288,6 +288,7 @@ export function applyTextChangeToSelections( }; } finalizeMergedGroup(); + const change = textDocument.applyEdits( edits, true, @@ -365,6 +366,7 @@ export function applyTextReplaceToSelections( entry.start + offsetDelta + newText.length; offsetDelta += newText.length - (entry.end - entry.start); } + const change = textDocument.applyEdits( edits, true, diff --git a/packages/diffs/src/editor/editorTextarea.ts b/packages/diffs/src/editor/editorTextarea.ts index 8bd701b2e..9b8bd28cf 100644 --- a/packages/diffs/src/editor/editorTextarea.ts +++ b/packages/diffs/src/editor/editorTextarea.ts @@ -66,13 +66,13 @@ export function createTextareaSnapshot( export function resolveTextareaChange( textareaSnapshot: TextareaSnapshot, - newView: string, + value: string, selectionStart: number, selectionEnd: number ): ResolvedTextEdit { const original = textareaSnapshot.text; const originalLength = original.length; - const nextLength = newView.length; + const nextLength = value.length; // When the snapshot still has the pre-edit range, prefer it over prefix/suffix inference. // Otherwise the diff can shift by one when the same character appears on both sides of @@ -88,10 +88,10 @@ export function resolveTextareaChange( trustEnd <= originalLength && trustStart + insLen <= nextLength ) { - const inserted = newView.slice(trustStart, trustStart + insLen); + const inserted = value.slice(trustStart, trustStart + insLen); if ( original.slice(0, trustStart) + inserted + original.slice(trustEnd) === - newView + value ) { return { start: textareaSnapshot.offset + trustStart, @@ -109,12 +109,12 @@ export function resolveTextareaChange( const lengthDelta = nextLength - originalLength; const start = selectionStart - Math.max(lengthDelta, 0); const end = start + Math.max(-lengthDelta, 0); - const text = newView.slice(start, selectionStart); + const text = value.slice(start, selectionStart); if ( lengthDelta !== 0 && start >= 0 && end <= originalLength && - original.slice(0, start) + text + original.slice(end) === newView + original.slice(0, start) + text + original.slice(end) === value ) { return { start: textareaSnapshot.offset + start, @@ -128,7 +128,7 @@ export function resolveTextareaChange( while ( prefix < originalLength && prefix < nextLength && - original[prefix] === newView[prefix] + original[prefix] === value[prefix] ) { prefix++; } @@ -137,7 +137,7 @@ export function resolveTextareaChange( while ( suffix < originalLength - prefix && suffix < nextLength - prefix && - original[originalLength - 1 - suffix] === newView[nextLength - 1 - suffix] + original[originalLength - 1 - suffix] === value[nextLength - 1 - suffix] ) { suffix++; } @@ -148,7 +148,7 @@ export function resolveTextareaChange( return { start: textareaSnapshot.offset + originalStart, end: textareaSnapshot.offset + originalEnd, - text: newView.slice(prefix, nextLength - suffix), + text: value.slice(prefix, nextLength - suffix), }; } diff --git a/packages/diffs/src/editor/index.ts b/packages/diffs/src/editor/index.ts index df811d821..dac680c40 100644 --- a/packages/diffs/src/editor/index.ts +++ b/packages/diffs/src/editor/index.ts @@ -768,18 +768,16 @@ export class Editor implements DiffsEditor { // Text in the textarea has been changed. if (value !== textareaSnapshot.text) { - const edit = resolveTextareaChange( - textareaSnapshot, - value, - selectionStart, - selectionEnd - ); - const lineAnnotations = this.#lineAnnotations; const { nextSelections, change } = applyTextChangeToSelections( textDocument, selections, - edit, - lineAnnotations, + resolveTextareaChange( + textareaSnapshot, + value, + selectionStart, + selectionEnd + ), + this.#lineAnnotations, this.#tabSize ); if (change !== undefined) { @@ -1195,7 +1193,7 @@ export class Editor implements DiffsEditor { case 'cut': if (this.#selections !== undefined) { try { - // todo: use navigator.clipboard.write() for multiple selections copy + // TODO(@ije): use navigator.clipboard.write() for multiple selections copy await navigator.clipboard.writeText( this.#getSelectionText(this.#selections) ); @@ -1211,7 +1209,7 @@ export class Editor implements DiffsEditor { case 'paste': { let text: string | string[]; try { - // todo: use navigator.clipboard.read() for multiple segments paste + // TODO(@ije): use navigator.clipboard.read() for multiple segments paste text = await navigator.clipboard.readText(); } catch { return; @@ -1371,23 +1369,24 @@ export class Editor implements DiffsEditor { } // TODO(@ije): normalize text with textDocument.EOF const lineAnnotations = this.#lineAnnotations; - const { nextSelections, change } = Array.isArray(text) - ? applyTextReplaceToSelections( - textDocument, - selections, - text, - lineAnnotations - ) - : applyTextChangeToSelections( - textDocument, - selections, - { - start: textDocument.offsetAt(primarySelection.start), - end: textDocument.offsetAt(primarySelection.end), - text: text, - }, - lineAnnotations - ); + const { nextSelections, change } = + Array.isArray(text) && text.length === selections.length + ? applyTextReplaceToSelections( + textDocument, + selections, + text, + lineAnnotations + ) + : applyTextChangeToSelections( + textDocument, + selections, + { + start: textDocument.offsetAt(primarySelection.start), + end: textDocument.offsetAt(primarySelection.end), + text: Array.isArray(text) ? text.join('\n') : text, + }, + lineAnnotations + ); if (change !== undefined) { this.#applyChange( @@ -1444,8 +1443,8 @@ export class Editor implements DiffsEditor { this.#textDocument?.setLastUndoLineAnnotationsAfter( nextLineAnnotations ); + return nextLineAnnotations; } - return nextLineAnnotations; } return undefined; } diff --git a/packages/diffs/src/editor/textDocument.ts b/packages/diffs/src/editor/textDocument.ts index d5c47d408..a00fc4543 100644 --- a/packages/diffs/src/editor/textDocument.ts +++ b/packages/diffs/src/editor/textDocument.ts @@ -1,6 +1,10 @@ import type { LineAnnotation } from '../types'; import { type EditorSelection } from './editorSelection'; -import { EditStack } from './editStack'; +import { + createEditStackEntry, + EditStack, + type EditStackEntry, +} from './editStack'; import { PieceTable } from './pieceTable'; /** @@ -93,7 +97,7 @@ export interface TextDocumentChange { /** First line whose rendered content or tokenizer state may have changed. */ readonly startLine: number; /** Character on the first changed line where the edit began. */ - readonly startCharacter?: number; + readonly startCharacter: number; /** Last line whose rendered content may have changed after the edit. */ readonly endLine: number; /** Line count before the edit was applied. */ @@ -197,8 +201,8 @@ export class TextDocument { const resolvedEdits = this.#sortAndValidateResolvedEdits( edits.map((edit) => this.#resolveEdit(edit)) ); - if (updateHistory && selectionsBefore !== undefined) { - this.#editStack.push( + if (updateHistory) { + const entry = createEditStackEntry( this, resolvedEdits, this.#version, @@ -208,9 +212,21 @@ export class TextDocument { lineAnnotationsBefore, lineAnnotationsAfter ); + const previousEntry = this.#editStack.peekUndo(); + const change = this.#applyResolvedEdits(resolvedEdits); + this.#version++; + if (this.#shouldCoalesceEditStackEntry(previousEntry, entry, change)) { + this.#editStack.replaceLastUndo( + this.#coalesceEditStackEntries(previousEntry!, entry) + ); + } else { + this.#editStack.push(entry); + } + return change; } + const change = this.#applyResolvedEdits(resolvedEdits); this.#version++; - return this.#applyResolvedEdits(resolvedEdits); + return change; } setLastUndoSelectionsAfter(selections: EditorSelection[]): void { @@ -226,7 +242,7 @@ export class TextDocument { undo(): | [ change: TextDocumentChange, - selections: EditorSelection[], + selections?: EditorSelection[], lineAnnotations?: LineAnnotation[], ] | undefined { @@ -241,7 +257,7 @@ export class TextDocument { this.#version = entry.versionBefore; return [ change, - cloneSelections(entry.selectionsBefore), + entry.selectionsBefore?.slice(), entry.lineAnnotationsBefore?.slice(), ]; } @@ -264,9 +280,7 @@ export class TextDocument { this.#version = entry.versionAfter; return [ change, - entry.selectionsAfter !== undefined - ? cloneSelections(entry.selectionsAfter) - : undefined, + entry.selectionsAfter?.slice(), entry.lineAnnotationsAfter?.slice(), ]; } @@ -304,14 +318,12 @@ export class TextDocument { const lineCount = this.#pieceTable.lineCount; const change: TextDocumentChange = { startLine: changedLineRange.startLine, + startCharacter: startPosition.character, endLine: Math.min(changedLineRange.endLine, Math.max(0, lineCount - 1)), previousLineCount, lineCount, lineDelta: lineCount - previousLineCount, }; - Object.defineProperty(change, 'startCharacter', { - value: startPosition.character, - }); return change; } @@ -338,20 +350,177 @@ export class TextDocument { } return { startLine, endLine }; } + + #shouldCoalesceEditStackEntry( + previousEntry: EditStackEntry | undefined, + nextEntry: EditStackEntry, + change: TextDocumentChange + ): boolean { + if (previousEntry === undefined || change.lineDelta !== 0) { + return false; + } + if ( + previousEntry.forwardEdits.length === 0 || + previousEntry.forwardEdits.length !== previousEntry.inverseEdits.length || + previousEntry.forwardEdits.length !== nextEntry.forwardEdits.length || + nextEntry.forwardEdits.length !== nextEntry.inverseEdits.length + ) { + return false; + } + let mode: 'insert' | 'delete' | undefined; + for (let i = 0; i < previousEntry.forwardEdits.length; i++) { + const previousForward = previousEntry.forwardEdits[i]; + const previousInverse = previousEntry.inverseEdits[i]; + const nextForward = nextEntry.forwardEdits[i]; + const nextInverse = nextEntry.inverseEdits[i]; + const mappedNextStart = mapOffsetAfterForwardBatchToBefore( + nextForward.start, + previousEntry.forwardEdits + ); + const mappedNextEnd = mapOffsetAfterForwardBatchToBefore( + nextForward.end, + previousEntry.forwardEdits + ); + const previousWasInsert = + previousForward.start === previousForward.end && + previousForward.text.length > 0 && + previousInverse.text.length === 0; + const nextIsInsert = + nextForward.start === nextForward.end && + nextForward.text.length > 0 && + nextInverse.text.length === 0; + if (previousWasInsert && nextIsInsert) { + if ( + mappedNextStart !== previousForward.start || + mappedNextEnd !== previousForward.end + ) { + return false; + } + mode ??= 'insert'; + if (mode !== 'insert') { + return false; + } + continue; + } + const previousWasDelete = + previousForward.text.length === 0 && + previousForward.end > previousForward.start && + previousInverse.text.length > 0; + const nextIsDelete = + nextForward.text.length === 0 && + nextForward.end > nextForward.start && + nextInverse.text.length > 0; + if (previousWasDelete && nextIsDelete) { + if ( + mappedNextStart + (nextForward.end - nextForward.start) !== + previousForward.start + ) { + return false; + } + mode ??= 'delete'; + if (mode !== 'delete') { + return false; + } + continue; + } + return false; + } + return mode !== undefined; + } + + // Coalesce edit stack entries for simple typing or backspace operations. + #coalesceEditStackEntries( + previousEntry: EditStackEntry, + nextEntry: EditStackEntry + ): EditStackEntry { + const forwardEdits: ResolvedTextEdit[] = []; + const replacedTexts: string[] = []; + for (let i = 0; i < previousEntry.forwardEdits.length; i++) { + const previousForward = previousEntry.forwardEdits[i]; + const previousInverse = previousEntry.inverseEdits[i]; + const nextForward = nextEntry.forwardEdits[i]; + const nextInverse = nextEntry.inverseEdits[i]; + const mappedNextStart = mapOffsetAfterForwardBatchToBefore( + nextForward.start, + previousEntry.forwardEdits + ); + + if (previousForward.text.length > 0) { + forwardEdits.push({ + start: previousForward.start, + end: previousForward.end, + text: previousForward.text + nextForward.text, + }); + replacedTexts.push(previousInverse.text); + continue; + } + + forwardEdits.push({ + start: Math.min(previousForward.start, mappedNextStart), + end: previousForward.end, + text: '', + }); + replacedTexts.push(nextInverse.text + previousInverse.text); + } + + return { + forwardEdits, + inverseEdits: buildInverseEdits(forwardEdits, replacedTexts), + versionBefore: previousEntry.versionBefore, + versionAfter: nextEntry.versionAfter, + selectionsBefore: previousEntry.selectionsBefore?.slice(), + selectionsAfter: nextEntry.selectionsAfter?.slice(), + lineAnnotationsBefore: previousEntry.lineAnnotationsBefore?.slice(), + lineAnnotationsAfter: nextEntry.lineAnnotationsAfter?.slice(), + }; + } } function lineFeedCount(text: string): number { let count = 0; for (let i = 0; i < text.length; i++) { - if (text.charCodeAt(i) === 10) { + if (text.charCodeAt(i) === /* \n */ 10) { count++; } } return count; } -function cloneSelections( - selections: readonly EditorSelection[] -): EditorSelection[] { - return selections.map((selection) => ({ ...selection })); +function buildInverseEdits( + forwardEdits: readonly ResolvedTextEdit[], + replacedTexts: readonly string[] +): ResolvedTextEdit[] { + const inverseEdits: ResolvedTextEdit[] = []; + for (let i = 0, offsetDelta = 0; i < forwardEdits.length; i++) { + const edit = forwardEdits[i]; + const startAfterEdit = edit.start + offsetDelta; + inverseEdits.push({ + start: startAfterEdit, + end: startAfterEdit + edit.text.length, + text: replacedTexts[i], + }); + offsetDelta += edit.text.length - (edit.end - edit.start); + } + return inverseEdits; +} + +function mapOffsetAfterForwardBatchToBefore( + offsetAfter: number, + forwardEdits: readonly ResolvedTextEdit[] +): number { + let offset = offsetAfter; + for (const edit of forwardEdits) { + const oldLength = edit.end - edit.start; + const newLength = edit.text.length; + const delta = newLength - oldLength; + if (offset < edit.start) { + continue; + } + if (offset >= edit.start + newLength) { + offset -= delta; + continue; + } + offset = edit.start + Math.min(offset - edit.start, oldLength); + } + return offset; } diff --git a/packages/diffs/test/editStack.test.ts b/packages/diffs/test/editStack.test.ts index ee6ef1549..3f0215684 100644 --- a/packages/diffs/test/editStack.test.ts +++ b/packages/diffs/test/editStack.test.ts @@ -5,7 +5,8 @@ import { DirectionNone, type SelectionDirection, } from '../src/editor/editorSelection'; -import { EditStack } from '../src/editor/editStack'; +import { createEditStackEntry, EditStack } from '../src/editor/editStack'; +import { TextDocument } from '../src/editor/textDocument'; function createSelection( startLine: number, @@ -25,12 +26,28 @@ function caret(character: number) { return createSelection(0, character, 0, character, DirectionNone); } -function source(text: string) { - return { - getTextSlice(start: number, end: number): string { - return text.slice(start, end); - }, - }; +function stackEntry( + textBeforeEdit: string, + resolvedEdits: { start: number; end: number; text: string }[], + versionBefore: number, + versionAfter: number, + selectionsBefore?: EditorSelection[], + selectionsAfter?: EditorSelection[] +) { + const doc = new TextDocument( + 'inmemory://edit-stack-test', + textBeforeEdit, + 'plain', + versionBefore + ); + return createEditStackEntry( + doc, + resolvedEdits, + versionBefore, + versionAfter, + selectionsBefore, + selectionsAfter + ); } describe('EditHistory', () => { @@ -40,12 +57,14 @@ describe('EditHistory', () => { const selectionAfter = [caret(2), caret(3)]; editStack.push( - source('ab'), - [{ start: 1, end: 1, text: 'X' }], - 4, - 5, - selectionBefore, - selectionAfter + stackEntry( + 'ab', + [{ start: 1, end: 1, text: 'X' }], + 4, + 5, + selectionBefore, + selectionAfter + ) ); selectionBefore[0] = caret(99); @@ -77,12 +96,14 @@ describe('EditHistory', () => { let selectionAfter = caret(2); editStack.push( - source('a'), - [{ start: 1, end: 1, text: 'b' }], - 1, - 2, - [caret(1)], - [selectionAfter] + stackEntry( + 'a', + [{ start: 1, end: 1, text: 'b' }], + 1, + 2, + [caret(1)], + [selectionAfter] + ) ); selectionAfter = caret(99); @@ -95,20 +116,10 @@ describe('EditHistory', () => { const editStack = new EditStack(); editStack.push( - source(''), - [{ start: 0, end: 0, text: 'a' }], - 0, - 1, - [caret(0)], - undefined + stackEntry('', [{ start: 0, end: 0, text: 'a' }], 0, 1, [caret(0)]) ); editStack.push( - source('a'), - [{ start: 1, end: 1, text: 'b' }], - 1, - 2, - [caret(1)], - undefined + stackEntry('a', [{ start: 1, end: 1, text: 'b' }], 1, 2, [caret(1)]) ); expect(editStack.popUndoToRedo()).toMatchObject({ @@ -117,12 +128,7 @@ describe('EditHistory', () => { expect(editStack.canRedo).toBe(true); editStack.push( - source('a'), - [{ start: 1, end: 1, text: 'c' }], - 1, - 2, - [caret(1)], - undefined + stackEntry('a', [{ start: 1, end: 1, text: 'c' }], 1, 2, [caret(1)]) ); expect(editStack.canRedo).toBe(false); @@ -139,12 +145,9 @@ describe('EditHistory', () => { for (let i = 0; i < 4; i++) { editStack.push( - source(''), - [{ start: 0, end: 0, text: `${i}` }], - i, - i + 1, - [caret(0)], - undefined + stackEntry('', [{ start: 0, end: 0, text: `${i}` }], i, i + 1, [ + caret(0), + ]) ); } @@ -159,12 +162,7 @@ describe('EditHistory', () => { const editStack = new EditStack(); editStack.push( - source(''), - [{ start: 0, end: 0, text: 'a' }], - 0, - 1, - [caret(0)], - undefined + stackEntry('', [{ start: 0, end: 0, text: 'a' }], 0, 1, [caret(0)]) ); editStack.popUndoToRedo(); editStack.clear(); diff --git a/packages/diffs/test/textDocument.test.ts b/packages/diffs/test/textDocument.test.ts index 91cecb5c7..e1b218360 100644 --- a/packages/diffs/test/textDocument.test.ts +++ b/packages/diffs/test/textDocument.test.ts @@ -117,6 +117,7 @@ describe('TextDocument', () => { expect(d.getText()).toBe('hello you'); expect(change).toEqual({ startLine: 0, + startCharacter: 6, endLine: 0, previousLineCount: 1, lineCount: 1, @@ -175,6 +176,7 @@ describe('TextDocument', () => { expect(d.lineCount).toBe(3); expect(change).toEqual({ startLine: 1, + startCharacter: 0, endLine: 1, previousLineCount: 3, lineCount: 3, @@ -196,6 +198,7 @@ describe('TextDocument', () => { expect(d.getText()).toBe('a\nb'); expect(change).toEqual({ startLine: 0, + startCharacter: 1, endLine: 1, previousLineCount: 1, lineCount: 2, @@ -217,6 +220,7 @@ describe('TextDocument', () => { expect(d.getText()).toBe('ac'); expect(change).toEqual({ startLine: 0, + startCharacter: 1, endLine: 0, previousLineCount: 3, lineCount: 1, @@ -305,50 +309,299 @@ describe('TextDocument', () => { test('undo stack depth for sequential edits', () => { const d = doc('x'); - const originalNow = Date.now; - let now = 1000; - Object.defineProperty(Date, 'now', { - configurable: true, - value: () => now, - }); - try { - d.applyEdits( - [ - { - range: { - start: { line: 0, character: 0 }, - end: { line: 0, character: 0 }, - }, - newText: 'a', - }, - ], - true, - [caret(0, 0)] - ); - now += 600; - d.applyEdits( - [ - { - range: { - start: { line: 0, character: 1 }, - end: { line: 0, character: 1 }, - }, - newText: 'b', - }, - ], - true, - [caret(0, 1)] - ); - d.undo(); - expect(d.getText()).toBe('ax'); - d.undo(); - expect(d.getText()).toBe('x'); - } finally { - Object.defineProperty(Date, 'now', { - configurable: true, - value: originalNow, - }); - } + d.applyEdits( + [ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 0 }, + }, + newText: 'a', + }, + ], + true, + [caret(0, 0)] + ); + d.applyEdits( + [ + { + range: { + start: { line: 0, character: 1 }, + end: { line: 0, character: 1 }, + }, + newText: 'b', + }, + ], + true, + [caret(0, 1)] + ); + d.undo(); + expect(d.getText()).toBe('x'); + }); + + test('undo keeps later multiline edit separate from typing group', () => { + const d = doc('x'); + d.applyEdits( + [ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 0 }, + }, + newText: 'a', + }, + ], + true, + [caret(0, 0)] + ); + d.applyEdits( + [ + { + range: { + start: { line: 0, character: 1 }, + end: { line: 0, character: 1 }, + }, + newText: 'b', + }, + ], + true, + [caret(0, 1)] + ); + d.applyEdits( + [ + { + range: { + start: { line: 0, character: 2 }, + end: { line: 0, character: 2 }, + }, + newText: '\n', + }, + ], + true, + [caret(0, 2)] + ); + + expect(d.getText()).toBe('ab\nx'); + + d.undo(); + expect(d.getText()).toBe('abx'); + + d.undo(); + expect(d.getText()).toBe('x'); + }); + + test('contiguous backspaces coalesce into one undo step', () => { + const d = doc('abc'); + d.applyEdits( + [ + { + range: { + start: { line: 0, character: 2 }, + end: { line: 0, character: 3 }, + }, + newText: '', + }, + ], + true, + [caret(0, 3)] + ); + d.applyEdits( + [ + { + range: { + start: { line: 0, character: 1 }, + end: { line: 0, character: 2 }, + }, + newText: '', + }, + ], + true, + [caret(0, 2)] + ); + + expect(d.getText()).toBe('a'); + + d.undo(); + expect(d.getText()).toBe('abc'); + }); + + test('replacement edits do not coalesce', () => { + const d = doc('ab'); + d.applyEdits( + [ + { + range: { + start: { line: 0, character: 1 }, + end: { line: 0, character: 2 }, + }, + newText: 'X', + }, + ], + true, + [caret(0, 2)] + ); + d.applyEdits( + [ + { + range: { + start: { line: 0, character: 1 }, + end: { line: 0, character: 2 }, + }, + newText: 'Y', + }, + ], + true, + [caret(0, 2)] + ); + + expect(d.getText()).toBe('aY'); + + d.undo(); + expect(d.getText()).toBe('aX'); + + d.undo(); + expect(d.getText()).toBe('ab'); + }); + + test('multi-cursor contiguous inserts coalesce into one undo step', () => { + const d = doc('ab\ncd'); + d.applyEdits( + [ + { + range: { + start: { line: 0, character: 1 }, + end: { line: 0, character: 1 }, + }, + newText: 'X', + }, + { + range: { + start: { line: 1, character: 1 }, + end: { line: 1, character: 1 }, + }, + newText: 'X', + }, + ], + true, + [caret(0, 1), caret(1, 1)] + ); + d.applyEdits( + [ + { + range: { + start: { line: 0, character: 2 }, + end: { line: 0, character: 2 }, + }, + newText: 'Y', + }, + { + range: { + start: { line: 1, character: 2 }, + end: { line: 1, character: 2 }, + }, + newText: 'Y', + }, + ], + true, + [caret(0, 2), caret(1, 2)] + ); + + expect(d.getText()).toBe('aXYb\ncXYd'); + + d.undo(); + expect(d.getText()).toBe('ab\ncd'); + }); + + test('multi-cursor contiguous backspaces coalesce into one undo step', () => { + const d = doc('abc\ndef'); + d.applyEdits( + [ + { + range: { + start: { line: 0, character: 2 }, + end: { line: 0, character: 3 }, + }, + newText: '', + }, + { + range: { + start: { line: 1, character: 2 }, + end: { line: 1, character: 3 }, + }, + newText: '', + }, + ], + true, + [caret(0, 3), caret(1, 3)] + ); + d.applyEdits( + [ + { + range: { + start: { line: 0, character: 1 }, + end: { line: 0, character: 2 }, + }, + newText: '', + }, + { + range: { + start: { line: 1, character: 1 }, + end: { line: 1, character: 2 }, + }, + newText: '', + }, + ], + true, + [caret(0, 2), caret(1, 2)] + ); + + expect(d.getText()).toBe('a\nd'); + + d.undo(); + expect(d.getText()).toBe('abc\ndef'); + }); + + test('multi-cursor batches with different edit shapes do not coalesce', () => { + const d = doc('ab\ncd'); + d.applyEdits( + [ + { + range: { + start: { line: 0, character: 1 }, + end: { line: 0, character: 1 }, + }, + newText: 'X', + }, + { + range: { + start: { line: 1, character: 1 }, + end: { line: 1, character: 1 }, + }, + newText: 'X', + }, + ], + true, + [caret(0, 1), caret(1, 1)] + ); + d.applyEdits( + [ + { + range: { + start: { line: 0, character: 2 }, + end: { line: 0, character: 2 }, + }, + newText: 'Y', + }, + ], + true, + [caret(0, 2)] + ); + + d.undo(); + expect(d.getText()).toBe('aXb\ncXd'); + + d.undo(); + expect(d.getText()).toBe('ab\ncd'); }); test('applyEdits rejects overlapping ranges', () => { @@ -418,6 +671,7 @@ describe('TextDocument', () => { expect(d.getText()).toBe('a'); expect(undoResult?.[0]).toEqual({ startLine: 0, + startCharacter: 1, endLine: 0, previousLineCount: 1, lineCount: 1, @@ -430,6 +684,7 @@ describe('TextDocument', () => { expect(d.getText()).toBe('ab'); expect(redoResult?.[0]).toEqual({ startLine: 0, + startCharacter: 1, endLine: 0, previousLineCount: 1, lineCount: 1, From 2967161ab55889323cd1552463d5db5a21329912 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Sat, 9 May 2026 14:15:33 +0800 Subject: [PATCH 098/138] Add Support forward-delete coalescing for edit history --- packages/diffs/src/editor/editStack.ts | 194 +++++++++++++++++++++- packages/diffs/src/editor/textDocument.ts | 170 +------------------ packages/diffs/test/textDocument.test.ts | 84 ++++++++++ 3 files changed, 281 insertions(+), 167 deletions(-) diff --git a/packages/diffs/src/editor/editStack.ts b/packages/diffs/src/editor/editStack.ts index 07a2dfed5..a1e96aeb6 100644 --- a/packages/diffs/src/editor/editStack.ts +++ b/packages/diffs/src/editor/editStack.ts @@ -1,6 +1,10 @@ import type { LineAnnotation } from '../types'; import type { EditorSelection } from './editorSelection'; -import type { ResolvedTextEdit, TextDocument } from './textDocument'; +import type { + ResolvedTextEdit, + TextDocument, + TextDocumentChange, +} from './textDocument'; /** Largest number of undo or redo entries kept; oldest entries drop first once exceeded. */ const DEFAULT_EDIT_STACK_MAX_ENTRIES = 100; @@ -151,3 +155,191 @@ export function createEditStackEntry( lineAnnotationsAfter: lineAnnotationsAfter?.slice(), }; } + +/** Determines if the change matches following modes: + * - 'insert': simple typing + * - 'backspace': backward delete + * - 'delete': forward delete + */ +export function shouldCoalesceEditStackEntry( + previousEntry: EditStackEntry | undefined, + nextEntry: EditStackEntry, + change: TextDocumentChange +): boolean { + if (previousEntry === undefined || change.lineDelta !== 0) { + return false; + } + if ( + previousEntry.forwardEdits.length === 0 || + previousEntry.forwardEdits.length !== previousEntry.inverseEdits.length || + previousEntry.forwardEdits.length !== nextEntry.forwardEdits.length || + nextEntry.forwardEdits.length !== nextEntry.inverseEdits.length + ) { + return false; + } + let mode: 'insert' | 'backspace' | 'delete' | undefined; + for (let i = 0; i < previousEntry.forwardEdits.length; i++) { + const previousForward = previousEntry.forwardEdits[i]; + const previousInverse = previousEntry.inverseEdits[i]; + const nextForward = nextEntry.forwardEdits[i]; + const nextInverse = nextEntry.inverseEdits[i]; + const mappedNextStart = mapOffsetAfterForwardBatchToBefore( + nextForward.start, + previousEntry.forwardEdits + ); + const previousWasInsert = + previousForward.start === previousForward.end && + previousForward.text.length > 0 && + previousInverse.text.length === 0; + const nextIsInsert = + nextForward.start === nextForward.end && + nextForward.text.length > 0 && + nextInverse.text.length === 0; + if (previousWasInsert && nextIsInsert) { + const mappedNextEnd = mapOffsetAfterForwardBatchToBefore( + nextForward.end, + previousEntry.forwardEdits + ); + if ( + mappedNextStart !== previousForward.start || + mappedNextEnd !== previousForward.end + ) { + return false; + } + mode ??= 'insert'; + if (mode !== 'insert') { + return false; + } + continue; + } + const previousWasDelete = + previousForward.text.length === 0 && + previousForward.end > previousForward.start && + previousInverse.text.length > 0; + const nextIsDelete = + nextForward.text.length === 0 && + nextForward.end > nextForward.start && + nextInverse.text.length > 0; + if (previousWasDelete && nextIsDelete) { + if (mappedNextStart === previousForward.end) { + mode ??= 'delete'; + if (mode !== 'delete') { + return false; + } + continue; + } + if ( + mappedNextStart + (nextForward.end - nextForward.start) !== + previousForward.start + ) { + return false; + } + mode ??= 'backspace'; + if (mode !== 'backspace') { + return false; + } + continue; + } + return false; + } + return mode !== undefined; +} + +// Coalesce edit stack entries for simple typing and single-character deletes. +export function coalesceEditStackEntries( + previousEntry: EditStackEntry, + nextEntry: EditStackEntry +): EditStackEntry { + const forwardEdits: ResolvedTextEdit[] = []; + const replacedTexts: string[] = []; + for (let i = 0; i < previousEntry.forwardEdits.length; i++) { + const previousForward = previousEntry.forwardEdits[i]; + const previousInverse = previousEntry.inverseEdits[i]; + const nextForward = nextEntry.forwardEdits[i]; + const nextInverse = nextEntry.inverseEdits[i]; + const mappedNextStart = mapOffsetAfterForwardBatchToBefore( + nextForward.start, + previousEntry.forwardEdits + ); + + if (previousForward.text.length > 0) { + forwardEdits.push({ + start: previousForward.start, + end: previousForward.end, + text: previousForward.text + nextForward.text, + }); + replacedTexts.push(previousInverse.text); + continue; + } + + if (mappedNextStart === previousForward.end) { + forwardEdits.push({ + start: previousForward.start, + end: mappedNextStart + (nextForward.end - nextForward.start), + text: '', + }); + replacedTexts.push(previousInverse.text + nextInverse.text); + continue; + } + + forwardEdits.push({ + start: Math.min(previousForward.start, mappedNextStart), + end: previousForward.end, + text: '', + }); + replacedTexts.push(nextInverse.text + previousInverse.text); + } + + return { + forwardEdits, + inverseEdits: buildInverseEditsFromReplacedTexts( + forwardEdits, + replacedTexts + ), + versionBefore: previousEntry.versionBefore, + versionAfter: nextEntry.versionAfter, + selectionsBefore: previousEntry.selectionsBefore?.slice(), + selectionsAfter: nextEntry.selectionsAfter?.slice(), + lineAnnotationsBefore: previousEntry.lineAnnotationsBefore?.slice(), + lineAnnotationsAfter: nextEntry.lineAnnotationsAfter?.slice(), + }; +} + +function buildInverseEditsFromReplacedTexts( + forwardEdits: readonly ResolvedTextEdit[], + replacedTexts: readonly string[] +): ResolvedTextEdit[] { + const inverseEdits: ResolvedTextEdit[] = []; + for (let i = 0, offsetDelta = 0; i < forwardEdits.length; i++) { + const edit = forwardEdits[i]; + const startAfterEdit = edit.start + offsetDelta; + inverseEdits.push({ + start: startAfterEdit, + end: startAfterEdit + edit.text.length, + text: replacedTexts[i], + }); + offsetDelta += edit.text.length - (edit.end - edit.start); + } + return inverseEdits; +} + +function mapOffsetAfterForwardBatchToBefore( + offsetAfter: number, + forwardEdits: readonly ResolvedTextEdit[] +): number { + let offset = offsetAfter; + for (const edit of forwardEdits) { + const oldLength = edit.end - edit.start; + const newLength = edit.text.length; + const delta = newLength - oldLength; + if (offset < edit.start) { + continue; + } + if (offset >= edit.start + newLength) { + offset -= delta; + continue; + } + offset = edit.start + Math.min(offset - edit.start, oldLength); + } + return offset; +} diff --git a/packages/diffs/src/editor/textDocument.ts b/packages/diffs/src/editor/textDocument.ts index a00fc4543..ae23f4fa0 100644 --- a/packages/diffs/src/editor/textDocument.ts +++ b/packages/diffs/src/editor/textDocument.ts @@ -1,9 +1,10 @@ import type { LineAnnotation } from '../types'; import { type EditorSelection } from './editorSelection'; import { + coalesceEditStackEntries, createEditStackEntry, EditStack, - type EditStackEntry, + shouldCoalesceEditStackEntry, } from './editStack'; import { PieceTable } from './pieceTable'; @@ -215,9 +216,9 @@ export class TextDocument { const previousEntry = this.#editStack.peekUndo(); const change = this.#applyResolvedEdits(resolvedEdits); this.#version++; - if (this.#shouldCoalesceEditStackEntry(previousEntry, entry, change)) { + if (shouldCoalesceEditStackEntry(previousEntry, entry, change)) { this.#editStack.replaceLastUndo( - this.#coalesceEditStackEntries(previousEntry!, entry) + coalesceEditStackEntries(previousEntry!, entry) ); } else { this.#editStack.push(entry); @@ -350,130 +351,6 @@ export class TextDocument { } return { startLine, endLine }; } - - #shouldCoalesceEditStackEntry( - previousEntry: EditStackEntry | undefined, - nextEntry: EditStackEntry, - change: TextDocumentChange - ): boolean { - if (previousEntry === undefined || change.lineDelta !== 0) { - return false; - } - if ( - previousEntry.forwardEdits.length === 0 || - previousEntry.forwardEdits.length !== previousEntry.inverseEdits.length || - previousEntry.forwardEdits.length !== nextEntry.forwardEdits.length || - nextEntry.forwardEdits.length !== nextEntry.inverseEdits.length - ) { - return false; - } - let mode: 'insert' | 'delete' | undefined; - for (let i = 0; i < previousEntry.forwardEdits.length; i++) { - const previousForward = previousEntry.forwardEdits[i]; - const previousInverse = previousEntry.inverseEdits[i]; - const nextForward = nextEntry.forwardEdits[i]; - const nextInverse = nextEntry.inverseEdits[i]; - const mappedNextStart = mapOffsetAfterForwardBatchToBefore( - nextForward.start, - previousEntry.forwardEdits - ); - const mappedNextEnd = mapOffsetAfterForwardBatchToBefore( - nextForward.end, - previousEntry.forwardEdits - ); - const previousWasInsert = - previousForward.start === previousForward.end && - previousForward.text.length > 0 && - previousInverse.text.length === 0; - const nextIsInsert = - nextForward.start === nextForward.end && - nextForward.text.length > 0 && - nextInverse.text.length === 0; - if (previousWasInsert && nextIsInsert) { - if ( - mappedNextStart !== previousForward.start || - mappedNextEnd !== previousForward.end - ) { - return false; - } - mode ??= 'insert'; - if (mode !== 'insert') { - return false; - } - continue; - } - const previousWasDelete = - previousForward.text.length === 0 && - previousForward.end > previousForward.start && - previousInverse.text.length > 0; - const nextIsDelete = - nextForward.text.length === 0 && - nextForward.end > nextForward.start && - nextInverse.text.length > 0; - if (previousWasDelete && nextIsDelete) { - if ( - mappedNextStart + (nextForward.end - nextForward.start) !== - previousForward.start - ) { - return false; - } - mode ??= 'delete'; - if (mode !== 'delete') { - return false; - } - continue; - } - return false; - } - return mode !== undefined; - } - - // Coalesce edit stack entries for simple typing or backspace operations. - #coalesceEditStackEntries( - previousEntry: EditStackEntry, - nextEntry: EditStackEntry - ): EditStackEntry { - const forwardEdits: ResolvedTextEdit[] = []; - const replacedTexts: string[] = []; - for (let i = 0; i < previousEntry.forwardEdits.length; i++) { - const previousForward = previousEntry.forwardEdits[i]; - const previousInverse = previousEntry.inverseEdits[i]; - const nextForward = nextEntry.forwardEdits[i]; - const nextInverse = nextEntry.inverseEdits[i]; - const mappedNextStart = mapOffsetAfterForwardBatchToBefore( - nextForward.start, - previousEntry.forwardEdits - ); - - if (previousForward.text.length > 0) { - forwardEdits.push({ - start: previousForward.start, - end: previousForward.end, - text: previousForward.text + nextForward.text, - }); - replacedTexts.push(previousInverse.text); - continue; - } - - forwardEdits.push({ - start: Math.min(previousForward.start, mappedNextStart), - end: previousForward.end, - text: '', - }); - replacedTexts.push(nextInverse.text + previousInverse.text); - } - - return { - forwardEdits, - inverseEdits: buildInverseEdits(forwardEdits, replacedTexts), - versionBefore: previousEntry.versionBefore, - versionAfter: nextEntry.versionAfter, - selectionsBefore: previousEntry.selectionsBefore?.slice(), - selectionsAfter: nextEntry.selectionsAfter?.slice(), - lineAnnotationsBefore: previousEntry.lineAnnotationsBefore?.slice(), - lineAnnotationsAfter: nextEntry.lineAnnotationsAfter?.slice(), - }; - } } function lineFeedCount(text: string): number { @@ -485,42 +362,3 @@ function lineFeedCount(text: string): number { } return count; } - -function buildInverseEdits( - forwardEdits: readonly ResolvedTextEdit[], - replacedTexts: readonly string[] -): ResolvedTextEdit[] { - const inverseEdits: ResolvedTextEdit[] = []; - for (let i = 0, offsetDelta = 0; i < forwardEdits.length; i++) { - const edit = forwardEdits[i]; - const startAfterEdit = edit.start + offsetDelta; - inverseEdits.push({ - start: startAfterEdit, - end: startAfterEdit + edit.text.length, - text: replacedTexts[i], - }); - offsetDelta += edit.text.length - (edit.end - edit.start); - } - return inverseEdits; -} - -function mapOffsetAfterForwardBatchToBefore( - offsetAfter: number, - forwardEdits: readonly ResolvedTextEdit[] -): number { - let offset = offsetAfter; - for (const edit of forwardEdits) { - const oldLength = edit.end - edit.start; - const newLength = edit.text.length; - const delta = newLength - oldLength; - if (offset < edit.start) { - continue; - } - if (offset >= edit.start + newLength) { - offset -= delta; - continue; - } - offset = edit.start + Math.min(offset - edit.start, oldLength); - } - return offset; -} diff --git a/packages/diffs/test/textDocument.test.ts b/packages/diffs/test/textDocument.test.ts index e1b218360..7cb3bb0b2 100644 --- a/packages/diffs/test/textDocument.test.ts +++ b/packages/diffs/test/textDocument.test.ts @@ -463,6 +463,41 @@ describe('TextDocument', () => { expect(d.getText()).toBe('ab'); }); + test('contiguous forward deletes coalesce into one undo step', () => { + const d = doc('abc'); + d.applyEdits( + [ + { + range: { + start: { line: 0, character: 1 }, + end: { line: 0, character: 2 }, + }, + newText: '', + }, + ], + true, + [caret(0, 1)] + ); + d.applyEdits( + [ + { + range: { + start: { line: 0, character: 1 }, + end: { line: 0, character: 2 }, + }, + newText: '', + }, + ], + true, + [caret(0, 1)] + ); + + expect(d.getText()).toBe('a'); + + d.undo(); + expect(d.getText()).toBe('abc'); + }); + test('multi-cursor contiguous inserts coalesce into one undo step', () => { const d = doc('ab\ncd'); d.applyEdits( @@ -561,6 +596,55 @@ describe('TextDocument', () => { expect(d.getText()).toBe('abc\ndef'); }); + test('multi-cursor contiguous forward deletes coalesce into one undo step', () => { + const d = doc('abc\ndef'); + d.applyEdits( + [ + { + range: { + start: { line: 0, character: 1 }, + end: { line: 0, character: 2 }, + }, + newText: '', + }, + { + range: { + start: { line: 1, character: 1 }, + end: { line: 1, character: 2 }, + }, + newText: '', + }, + ], + true, + [caret(0, 1), caret(1, 1)] + ); + d.applyEdits( + [ + { + range: { + start: { line: 0, character: 1 }, + end: { line: 0, character: 2 }, + }, + newText: '', + }, + { + range: { + start: { line: 1, character: 1 }, + end: { line: 1, character: 2 }, + }, + newText: '', + }, + ], + true, + [caret(0, 1), caret(1, 1)] + ); + + expect(d.getText()).toBe('a\nd'); + + d.undo(); + expect(d.getText()).toBe('abc\ndef'); + }); + test('multi-cursor batches with different edit shapes do not coalesce', () => { const d = doc('ab\ncd'); d.applyEdits( From f63f2fd0cd8730151dcafd948b9918264e7cbd61 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Sat, 9 May 2026 14:17:09 +0800 Subject: [PATCH 099/138] docs: add docs for editStack module --- packages/diffs/src/editor/editStack.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/diffs/src/editor/editStack.ts b/packages/diffs/src/editor/editStack.ts index a1e96aeb6..ceb4710ab 100644 --- a/packages/diffs/src/editor/editStack.ts +++ b/packages/diffs/src/editor/editStack.ts @@ -9,6 +9,7 @@ import type { /** Largest number of undo or redo entries kept; oldest entries drop first once exceeded. */ const DEFAULT_EDIT_STACK_MAX_ENTRIES = 100; +/** An entry in the edit stack. */ export interface EditStackEntry { /** Forward offset edits from the entry's base text to its final text. */ forwardEdits: ResolvedTextEdit[]; @@ -28,10 +29,13 @@ export interface EditStackEntry { lineAnnotationsAfter?: LineAnnotation[]; } +/** Options for the edit stack. */ export interface EditStackOptions { + /** The maximum number of entries to keep in the undo stack. */ maxEntries?: number; } +/** A stack of edit entries. */ export class EditStack { #undoStack: EditStackEntry[] = []; #redoStack: EditStackEntry[] = []; @@ -52,15 +56,18 @@ export class EditStack { return this.#redoStack.length > 0; } + /** Clears both the undo and redo stacks. */ clear(): void { this.#undoStack.length = 0; this.#redoStack.length = 0; } + /** Clears the redo stack. */ clearRedo(): void { this.#redoStack.length = 0; } + /** Pushes a new entry onto the undo stack. */ push(entry: EditStackEntry): void { this.#undoStack.push(entry); this.clearRedo(); @@ -69,6 +76,7 @@ export class EditStack { } } + /** Sets the selections after the last undo entry. */ setLastUndoSelectionsAfter(selections: EditorSelection[]): void { const lastEntry = this.#undoStack[this.#undoStack.length - 1]; if (lastEntry !== undefined) { @@ -78,6 +86,7 @@ export class EditStack { } } + /** Sets the line annotations after the last undo entry. */ setLastUndoLineAnnotationsAfter( lineAnnotations: LineAnnotation[] ): void { @@ -87,10 +96,12 @@ export class EditStack { } } + /** Returns the last undo entry, or `undefined` if empty. */ peekUndo(): EditStackEntry | undefined { return this.#undoStack[this.#undoStack.length - 1]; } + /** Replaces the last undo entry with the given entry. */ replaceLastUndo(entry: EditStackEntry): void { if (this.#undoStack.length === 0) { this.push(entry); @@ -245,7 +256,7 @@ export function shouldCoalesceEditStackEntry( return mode !== undefined; } -// Coalesce edit stack entries for simple typing and single-character deletes. +/** Coalesce edit stack entries for simple typing and single-character deletes. */ export function coalesceEditStackEntries( previousEntry: EditStackEntry, nextEntry: EditStackEntry From d4d8dec50b0ad9303a1e523f7875c238f17ec662 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Sat, 9 May 2026 14:26:44 +0800 Subject: [PATCH 100/138] Refctor --- packages/diffs/src/components/File.ts | 48 +++++----- packages/diffs/src/editor/index.ts | 11 ++- packages/diffs/src/renderers/FileRenderer.ts | 94 ++++++++++---------- packages/diffs/src/types.ts | 4 +- 4 files changed, 81 insertions(+), 76 deletions(-) diff --git a/packages/diffs/src/components/File.ts b/packages/diffs/src/components/File.ts index a4446d5ce..d7560479f 100644 --- a/packages/diffs/src/components/File.ts +++ b/packages/diffs/src/components/File.ts @@ -177,7 +177,7 @@ export class File { public setEditor(editor: DiffsEditor): void { if (this.fileContainer != null && this.file != null) { - editor.triggerEdit( + editor.syncFile( this.fileContainer, this.file, this.lineAnnotations, @@ -391,6 +391,27 @@ export class File { return file != null ? this.fileRenderer.getLineCount(file) : 0; } + public emitDirtyLines(lines: Map>): void { + this.fileRenderer.emitDirtyLines(lines); + } + + public emitLineCountChange(lineCount: number): void { + const result = this.fileRenderer.emitLineCountChange( + lineCount, + this.renderRange + ); + if (result != null && this.code != null) { + const { gutterAST, rowCount } = result; + const columns = this.getColumns(this.code); + if (columns != null) { + columns.gutter.innerHTML = + this.fileRenderer.renderPartialHTML(gutterAST); + columns.content.style.gridRow = `span ${rowCount}`; + columns.gutter.style.gridRow = `span ${rowCount}`; + } + } + } + public emitLineAnnotationsChange( newLineAnnotations: LineAnnotation[] ): void { @@ -420,7 +441,7 @@ export class File { columns.gutter.style.gridRow = `span ${rowCount}`; this.renderAnnotations(); if (this.fileContainer != null && this.file != null) { - this.editor?.triggerEdit( + this.editor?.syncFile( this.fileContainer, this.file, this.lineAnnotations, @@ -431,27 +452,6 @@ export class File { } } - public emitLineCountChange(lineCount: number): void { - const result = this.fileRenderer.emitLineCountChange( - lineCount, - this.renderRange - ); - if (result != null && this.code != null) { - const { gutterAST, rowCount } = result; - const columns = this.getColumns(this.code); - if (columns != null) { - columns.gutter.innerHTML = - this.fileRenderer.renderPartialHTML(gutterAST); - columns.content.style.gridRow = `span ${rowCount}`; - columns.gutter.style.gridRow = `span ${rowCount}`; - } - } - } - - public emitTokenize(lines: Map>): void { - this.fileRenderer.emitTokenize(lines); - } - public render({ file, fileContainer, @@ -583,7 +583,7 @@ export class File { this.resizeManager.setup(pre, overflow === 'wrap'); this.renderAnnotations(); this.renderGutterUtility(); - this.editor?.triggerEdit( + this.editor?.syncFile( fileContainer, file, this.lineAnnotations, diff --git a/packages/diffs/src/editor/index.ts b/packages/diffs/src/editor/index.ts index dac680c40..123d663a8 100644 --- a/packages/diffs/src/editor/index.ts +++ b/packages/diffs/src/editor/index.ts @@ -238,7 +238,7 @@ export class Editor implements DiffsEditor { this.#reservedSelections = undefined; } - triggerEdit( + syncFile( fileContainer: HTMLElement, fileContents: FileContents, lineAnnotations: LineAnnotation[] | undefined, @@ -667,11 +667,14 @@ export class Editor implements DiffsEditor { } } - file.emitTokenize(dirtyLines); + file.emitDirtyLines(dirtyLines); if (lastChange.lineDelta !== 0) { file.emitLineCountChange(lastChange.lineCount); } - if (nextLineAnnotations !== undefined) { + if ( + nextLineAnnotations !== undefined && + nextLineAnnotations !== this.#lineAnnotations + ) { file.emitLineAnnotationsChange(nextLineAnnotations); } @@ -682,7 +685,7 @@ export class Editor implements DiffsEditor { colorMap, textDocument, onTokenize: (result) => { - file.emitTokenize(result.lines); + file.emitDirtyLines(result.lines); }, }); this.#backgroundTokenizer.scheduleTokenize(line, state); diff --git a/packages/diffs/src/renderers/FileRenderer.ts b/packages/diffs/src/renderers/FileRenderer.ts index 5904e2e72..d60f4e750 100644 --- a/packages/diffs/src/renderers/FileRenderer.ts +++ b/packages/diffs/src/renderers/FileRenderer.ts @@ -192,20 +192,46 @@ export class FileRenderer { ); } - public emitLineAnnotationsChange( - newLineAnnotations: LineAnnotation[], - renderRange: RenderRange = DEFAULT_RENDER_RANGE - ): FileRenderResult | undefined { + public emitDirtyLines( + dirtyLines: Map> + ): void { const renderCache = this.renderCache; if (renderCache == null || renderCache.result == null) { - return undefined; + return; + } + for (const [line, tokens] of dirtyLines) { + renderCache.result.code[line] = { + type: 'element', + tagName: 'div', + properties: { + 'data-line': line + 1, + 'data-line-type': 'context', + 'data-line-index': line, + }, + children: tokens.map(([char, style, text]) => { + if (char === 0 && style === '') { + return { + type: 'text', + value: text, + }; + } + return { + type: 'element', + tagName: 'span', + properties: { + 'data-char': char, + style, + }, + children: [ + { + type: 'text', + value: text, + }, + ], + }; + }), + }; } - this.setLineAnnotations(newLineAnnotations); - return this.processFileResult( - renderCache.file, - renderRange, - renderCache.result - ); } public emitLineCountChange( @@ -238,44 +264,20 @@ export class FileRenderer { ); } - public emitTokenize(lines: Map>): void { + public emitLineAnnotationsChange( + newLineAnnotations: LineAnnotation[], + renderRange: RenderRange = DEFAULT_RENDER_RANGE + ): FileRenderResult | undefined { const renderCache = this.renderCache; if (renderCache == null || renderCache.result == null) { - return; - } - for (const [line, tokens] of lines) { - renderCache.result.code[line] = { - type: 'element', - tagName: 'div', - properties: { - 'data-line': line + 1, - 'data-line-type': 'context', - 'data-line-index': line, - }, - children: tokens.map(([char, style, text]) => { - if (char === 0 && style === '') { - return { - type: 'text', - value: text, - }; - } - return { - type: 'element', - tagName: 'span', - properties: { - 'data-char': char, - style, - }, - children: [ - { - type: 'text', - value: text, - }, - ], - }; - }), - }; + return undefined; } + this.setLineAnnotations(newLineAnnotations); + return this.processFileResult( + renderCache.file, + renderRange, + renderCache.result + ); } public renderFile( diff --git a/packages/diffs/src/types.ts b/packages/diffs/src/types.ts index 082d57680..1fa3920dc 100644 --- a/packages/diffs/src/types.ts +++ b/packages/diffs/src/types.ts @@ -744,9 +744,9 @@ export interface AppliedThemeStyleCache { } export interface DiffsEditor { - triggerEdit( + syncFile( fileContainer: HTMLElement, - file: FileContents, + fileContents: FileContents, lineAnnotations: LineAnnotation[] | undefined, renderRange: RenderRange | undefined ): void; From fe5ab6d313932a91058015f6622f9584a3d286a8 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Sat, 9 May 2026 14:50:24 +0800 Subject: [PATCH 101/138] Fix 'documentStart' and 'documentEnd' commands --- .../diffs/src/components/VirtualizedFile.ts | 10 +++++----- packages/diffs/src/editor/index.ts | 20 ++++++++++++++++--- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/packages/diffs/src/components/VirtualizedFile.ts b/packages/diffs/src/components/VirtualizedFile.ts index 253bffc80..179796a45 100644 --- a/packages/diffs/src/components/VirtualizedFile.ts +++ b/packages/diffs/src/components/VirtualizedFile.ts @@ -236,6 +236,11 @@ export class VirtualizedFile< } } + override emitLineCountChange(lineCount: number): void { + super.emitLineCountChange(lineCount); + this.computeApproximateSize(); + } + override emitLineAnnotationsChange( lineAnnotations: LineAnnotation[] ): void { @@ -243,11 +248,6 @@ export class VirtualizedFile< this.computeApproximateSize(); } - override emitLineCountChange(lineCount: number): void { - super.emitLineCountChange(lineCount); - this.computeApproximateSize(); - } - override render({ fileContainer, file, diff --git a/packages/diffs/src/editor/index.ts b/packages/diffs/src/editor/index.ts index 123d663a8..fa8a6f51a 100644 --- a/packages/diffs/src/editor/index.ts +++ b/packages/diffs/src/editor/index.ts @@ -1281,9 +1281,23 @@ export class Editor implements DiffsEditor { case 'documentStart': case 'documentEnd': - this.setSelections([ - this.#getDocumentBoundarySelection(command === 'documentEnd'), - ]); + { + const atEnd = command === 'documentEnd'; + const anchor = createElement('span'); + const root = this.#contentEl?.getRootNode() as Element | undefined; + this.setSelections([this.#getDocumentBoundarySelection(atEnd)]); + if (root !== undefined) { + if (atEnd) { + root.appendChild(anchor); + } else { + root.prepend(anchor); + } + anchor.scrollIntoView({ block: atEnd ? 'end' : 'start' }); + requestAnimationFrame(() => { + anchor.remove(); + }); + } + } break; case 'undo': From 5ff2b423e9018abde19cc179035e98ad24e19443 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Sat, 9 May 2026 17:19:46 +0800 Subject: [PATCH 102/138] Rewrite selection handle logic --- packages/diffs/src/editor/editorSelection.ts | 86 +++++++++++++----- packages/diffs/src/editor/index.ts | 95 ++++++++------------ packages/diffs/test/editorSelection.test.ts | 27 ++++++ 3 files changed, 124 insertions(+), 84 deletions(-) diff --git a/packages/diffs/src/editor/editorSelection.ts b/packages/diffs/src/editor/editorSelection.ts index 36cb8bab0..f2176c0c3 100644 --- a/packages/diffs/src/editor/editorSelection.ts +++ b/packages/diffs/src/editor/editorSelection.ts @@ -27,11 +27,11 @@ export interface EditorSelection extends Range { export function convertSelection( range: StaticRange, direction: SelectionDirection = DirectionNone -): EditorSelection | null { +): EditorSelection | undefined { const start = boundaryToPosition(range.startContainer, range.startOffset); const end = boundaryToPosition(range.endContainer, range.endOffset); if (start === null || end === null) { - return null; + return undefined; } return { start, @@ -392,28 +392,7 @@ export function isCollapsedSelection(selection: EditorSelection): boolean { } /** - * Merges two selections into one range that covers both. - */ -export function mergeSelections( - a: EditorSelection, - b: EditorSelection -): EditorSelection { - const start = comparePosition(a.start, b.start) <= 0 ? a.start : b.start; - const end = comparePosition(a.end, b.end) >= 0 ? a.end : b.end; - const anchorA = a.direction === DirectionBackward ? a.end : a.start; - const focusB = b.direction === DirectionBackward ? b.start : b.end; - const anchorVsFocus = comparePosition(anchorA, focusB); - const direction: SelectionDirection = - anchorVsFocus === 0 - ? DirectionNone - : anchorVsFocus < 0 - ? DirectionForward - : DirectionBackward; - return { start, end, direction }; -} - -/** - * Checks if two selections intersect. + * Checks whether selections `a` and `b` intersect. */ export function selectionIntersects( a: EditorSelection, @@ -474,6 +453,65 @@ export function createSelectionFromAnchorAndFocusOffsets( }; } +/** + * Creates a selection from a start and current selection. + */ +export function createSelectionFrom( + start: EditorSelection, + current: EditorSelection +): EditorSelection { + const anchor = + start.direction === DirectionBackward ? start.end : start.start; + const currentStartOrder = comparePosition(anchor, current.start); + const currentEndOrder = comparePosition(anchor, current.end); + let focus = current.end; + if (currentStartOrder <= 0) { + focus = current.end; + } else if (currentEndOrder >= 0) { + focus = current.start; + } else { + // When the original anchor sits inside `current`, keep whichever edge + // stayed at the anchor so drag direction remains stable. + const anchorAtStart = currentStartOrder === 0; + focus = anchorAtStart ? current.end : current.start; + } + const anchorVsFocus = comparePosition(anchor, focus); + const direction: SelectionDirection = + anchorVsFocus === 0 + ? DirectionNone + : anchorVsFocus < 0 + ? DirectionForward + : DirectionBackward; + const selectionStart = anchorVsFocus <= 0 ? anchor : focus; + const selectionEnd = anchorVsFocus <= 0 ? focus : anchor; + return { + start: selectionStart, + end: selectionEnd, + direction, + }; +} + +/** + * Merges two selections into one range that covers both. + */ +export function mergeSelections( + a: EditorSelection, + b: EditorSelection +): EditorSelection { + const start = comparePosition(a.start, b.start) <= 0 ? a.start : b.start; + const end = comparePosition(a.end, b.end) >= 0 ? a.end : b.end; + const anchorA = a.direction === DirectionBackward ? a.end : a.start; + const focusB = b.direction === DirectionBackward ? b.start : b.end; + const anchorVsFocus = comparePosition(anchorA, focusB); + const direction: SelectionDirection = + anchorVsFocus === 0 + ? DirectionNone + : anchorVsFocus < 0 + ? DirectionForward + : DirectionBackward; + return { start, end, direction }; +} + /** * Extends a selection. */ diff --git a/packages/diffs/src/editor/index.ts b/packages/diffs/src/editor/index.ts index fa8a6f51a..cba1ac0a2 100644 --- a/packages/diffs/src/editor/index.ts +++ b/packages/diffs/src/editor/index.ts @@ -13,6 +13,7 @@ import { applyTextReplaceToSelections, comparePosition, convertSelection, + createSelectionFrom, DirectionBackward, DirectionForward, DirectionNone, @@ -22,7 +23,6 @@ import { mapSelectionRangeMove, mergeSelections, resolveIndentEdits, - type SelectionDirection, selectionIntersects, } from '../editor/editorSelection'; import { @@ -102,12 +102,9 @@ export class Editor implements DiffsEditor { #measureCtx?: CanvasRenderingContext2D; // state - #selectionStartX = 0; - #selectionStartY = 0; - #selectionEndX = 0; - #selectionEndY = 0; #shouldIgnoreSelectionChange = false; #textareaSnapshot?: TextareaSnapshot; + #selectionStart: EditorSelection | undefined; #reservedSelections?: EditorSelection[]; #selections?: EditorSelection[]; @@ -234,6 +231,7 @@ export class Editor implements DiffsEditor { this.#shouldIgnoreSelectionChange = false; this.#textareaSnapshot = undefined; + this.#selectionStart = undefined; this.#selections = undefined; this.#reservedSelections = undefined; } @@ -313,7 +311,9 @@ export class Editor implements DiffsEditor { } if (this.#selections !== undefined) { this.setSelections(this.#selections); - this.#focusTextarea(); + if (this.#selectionStart === undefined) { + this.#focusTextarea(); + } } if (renderRange !== undefined) { @@ -385,11 +385,6 @@ export class Editor implements DiffsEditor { this.#reservedSelections = undefined; } - this.#selectionStartY = e.clientY; - this.#selectionStartX = e.clientX; - this.#selectionEndX = e.clientX; - this.#selectionEndY = e.clientY; - // when the user is using the 'Shift' key to create a selection // hide the textarea element or the selection will be created in the textarea if (e.shiftKey && this.#textareaEl !== undefined) { @@ -400,18 +395,6 @@ export class Editor implements DiffsEditor { }); } - mouseEventDisposes.push( - // `Selection.getComposedRanges` currently does not preserve the drag direction. - // The workaround is to check the mousemove event to determine the direction of the drag operation. - addEventListener(document, 'mousemove', (e) => { - if ((e.buttons & 1) !== 1) { - return; - } - this.#selectionEndX = e.clientX; - this.#selectionEndY = e.clientY; - }) - ); - if (this.#contentEl !== undefined) { mouseEventDisposes.push( // `Selection.getComposedRanges` sets the `startContainer` to the first line element of @@ -431,6 +414,7 @@ export class Editor implements DiffsEditor { addEventListener(document, 'mouseup', (e) => { mouseEventDisposes.forEach((dispose) => dispose()); mouseEventDisposes.length = 0; + this.#selectionStart = undefined; this.#reservedSelections = undefined; if (e.shiftKey && this.#textareaEl !== undefined) { this.#shouldIgnoreSelectionChange = false; @@ -462,23 +446,6 @@ export class Editor implements DiffsEditor { ]; } - // Shadow DOM selection ranges do not expose direction, so track mouse - // movement as a workaround. - // See https://github.com/mfreed7/shadow-dom-selection#part-1-add-selectiongetcomposedrange-and-selectiondirection - #computeMouseSelectionDirection(): SelectionDirection { - const startLine = Math.ceil(this.#selectionStartY / this.#lineHeight); - const endLine = Math.ceil(this.#selectionEndY / this.#lineHeight); - if (endLine !== startLine) { - return endLine > startLine ? DirectionForward : DirectionBackward; - } - if (this.#selectionEndX !== this.#selectionStartX) { - return this.#selectionEndX > this.#selectionStartX - ? DirectionForward - : DirectionBackward; - } - return DirectionNone; - } - #rerender( lastChange: TextDocumentChange, nextLineAnnotations?: LineAnnotation[] | undefined @@ -861,29 +828,37 @@ export class Editor implements DiffsEditor { return; } - const selection = convertSelection( - composedRange, - this.#computeMouseSelectionDirection() - ); - if (selection !== null) { - this.#textareaSnapshot = undefined; - if (append) { - const primarySelection = this.#selections?.at(-1); - if (primarySelection !== undefined) { - const newSelection = mergeSelections(primarySelection, selection); - this.setSelections([newSelection]); - } - } else if (this.#reservedSelections !== undefined) { - this.setSelections([ - ...this.#reservedSelections.filter( - (reservedSelection) => - !selectionIntersects(reservedSelection, selection) - ), - selection, - ]); + let selection = convertSelection(composedRange, DirectionNone); + if (selection === undefined) { + return; + } + + if (this.#selectionStart === undefined) { + this.#selectionStart = selection; + } else { + selection = createSelectionFrom(this.#selectionStart, selection); + } + + this.#textareaSnapshot = undefined; + + if (append) { + const primarySelection = this.#selections?.at(-1); + if (primarySelection !== undefined) { + const newSelection = mergeSelections(primarySelection, selection); + this.setSelections([newSelection]); } else { this.setSelections([selection]); } + } else if (this.#reservedSelections !== undefined) { + this.setSelections([ + ...this.#reservedSelections.filter( + (reservedSelection) => + !selectionIntersects(reservedSelection, selection) + ), + selection, + ]); + } else { + this.setSelections([selection]); } } diff --git a/packages/diffs/test/editorSelection.test.ts b/packages/diffs/test/editorSelection.test.ts index f720510bc..e1ed05baf 100644 --- a/packages/diffs/test/editorSelection.test.ts +++ b/packages/diffs/test/editorSelection.test.ts @@ -4,6 +4,7 @@ import { applyTextChangeToSelections, applyTextReplaceToSelections, convertSelection, + createSelectionFrom, DirectionForward, DirectionNone, type EditorSelection, @@ -407,6 +408,32 @@ describe('concatSelections', () => { }); }); +describe('createSelectionFrom', () => { + test('keeps forward direction when drag focus moves after anchor', () => { + const start = createSelection(2, 3, 2, 3, DirectionNone); + const current = createSelection(2, 3, 2, 8, DirectionNone); + expect(createSelectionFrom(start, current)).toEqual( + createSelection(2, 3, 2, 8, DirectionForward) + ); + }); + + test('produces backward direction when drag focus moves before anchor', () => { + const start = createSelection(2, 8, 2, 8, DirectionNone); + const current = createSelection(2, 3, 2, 8, DirectionNone); + expect(createSelectionFrom(start, current)).toEqual( + createSelection(2, 3, 2, 8, DirectionBackward) + ); + }); + + test('uses backward start anchor when selection already has direction', () => { + const start = createSelection(1, 2, 1, 6, DirectionBackward); + const current = createSelection(1, 0, 1, 6, DirectionNone); + expect(createSelectionFrom(start, current)).toEqual( + createSelection(1, 0, 1, 6, DirectionBackward) + ); + }); +}); + describe('applyTextChangeToSelections', () => { test('inserts the same text at multiple carets', () => { const textDocument = new TextDocument('inmemory://1', 'a\nb\nc'); From 76a2fb4ad1fdc9eb04d1435072f66a3e9c386208 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Sat, 9 May 2026 17:39:49 +0800 Subject: [PATCH 103/138] Fix shouldCoalesceEditStackEntry function --- packages/diffs/src/editor/editStack.ts | 30 +++++++------------ packages/diffs/src/editor/textDocument.ts | 5 +++- packages/diffs/test/textDocument.test.ts | 35 +++++++++++++++++++++++ 3 files changed, 49 insertions(+), 21 deletions(-) diff --git a/packages/diffs/src/editor/editStack.ts b/packages/diffs/src/editor/editStack.ts index ceb4710ab..059bd4c5a 100644 --- a/packages/diffs/src/editor/editStack.ts +++ b/packages/diffs/src/editor/editStack.ts @@ -1,10 +1,6 @@ import type { LineAnnotation } from '../types'; import type { EditorSelection } from './editorSelection'; -import type { - ResolvedTextEdit, - TextDocument, - TextDocumentChange, -} from './textDocument'; +import type { ResolvedTextEdit, TextDocument } from './textDocument'; /** Largest number of undo or redo entries kept; oldest entries drop first once exceeded. */ const DEFAULT_EDIT_STACK_MAX_ENTRIES = 100; @@ -174,13 +170,10 @@ export function createEditStackEntry( */ export function shouldCoalesceEditStackEntry( previousEntry: EditStackEntry | undefined, - nextEntry: EditStackEntry, - change: TextDocumentChange + nextEntry: EditStackEntry ): boolean { - if (previousEntry === undefined || change.lineDelta !== 0) { - return false; - } if ( + previousEntry === undefined || previousEntry.forwardEdits.length === 0 || previousEntry.forwardEdits.length !== previousEntry.inverseEdits.length || previousEntry.forwardEdits.length !== nextEntry.forwardEdits.length || @@ -199,22 +192,19 @@ export function shouldCoalesceEditStackEntry( previousEntry.forwardEdits ); const previousWasInsert = - previousForward.start === previousForward.end && + previousForward.start <= previousForward.end && previousForward.text.length > 0 && - previousInverse.text.length === 0; + !previousForward.text.includes('\n') && + !previousInverse.text.includes('\n'); const nextIsInsert = nextForward.start === nextForward.end && nextForward.text.length > 0 && nextInverse.text.length === 0; if (previousWasInsert && nextIsInsert) { - const mappedNextEnd = mapOffsetAfterForwardBatchToBefore( - nextForward.end, - previousEntry.forwardEdits - ); - if ( - mappedNextStart !== previousForward.start || - mappedNextEnd !== previousForward.end - ) { + const expectedMappedNextStart = previousForward.end; + // Allow continuing typing after replacing a selection (e.g. "hello" -> "w") + // while still requiring that the cursor extension maps inside the same base range. + if (mappedNextStart !== expectedMappedNextStart) { return false; } mode ??= 'insert'; diff --git a/packages/diffs/src/editor/textDocument.ts b/packages/diffs/src/editor/textDocument.ts index ae23f4fa0..04f59b532 100644 --- a/packages/diffs/src/editor/textDocument.ts +++ b/packages/diffs/src/editor/textDocument.ts @@ -216,7 +216,10 @@ export class TextDocument { const previousEntry = this.#editStack.peekUndo(); const change = this.#applyResolvedEdits(resolvedEdits); this.#version++; - if (shouldCoalesceEditStackEntry(previousEntry, entry, change)) { + if ( + change.lineDelta === 0 && + shouldCoalesceEditStackEntry(previousEntry, entry) + ) { this.#editStack.replaceLastUndo( coalesceEditStackEntries(previousEntry!, entry) ); diff --git a/packages/diffs/test/textDocument.test.ts b/packages/diffs/test/textDocument.test.ts index 7cb3bb0b2..bb0aa007e 100644 --- a/packages/diffs/test/textDocument.test.ts +++ b/packages/diffs/test/textDocument.test.ts @@ -463,6 +463,41 @@ describe('TextDocument', () => { expect(d.getText()).toBe('ab'); }); + test('typing after replacing a selection coalesces into one undo step', () => { + const d = doc('hello'); + d.applyEdits( + [ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 5 }, + }, + newText: 'w', + }, + ], + true, + [caret(0, 5)] + ); + d.applyEdits( + [ + { + range: { + start: { line: 0, character: 1 }, + end: { line: 0, character: 1 }, + }, + newText: 'orld', + }, + ], + true, + [caret(0, 1)] + ); + + expect(d.getText()).toBe('world'); + + d.undo(); + expect(d.getText()).toBe('hello'); + }); + test('contiguous forward deletes coalesce into one undo step', () => { const d = doc('abc'); d.applyEdits( From b23ad56715a4611be76720e0e6cf5457f72b2858 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Sat, 9 May 2026 21:28:07 +0800 Subject: [PATCH 104/138] Update demo --- apps/demo/index.html | 1 - apps/demo/src/main.ts | 177 ++++++++---------------------------------- 2 files changed, 33 insertions(+), 145 deletions(-) diff --git a/apps/demo/index.html b/apps/demo/index.html index dfab4fc2d..29eb2454a 100644 --- a/apps/demo/index.html +++ b/apps/demo/index.html @@ -16,7 +16,6 @@ - diff --git a/apps/demo/src/main.ts b/apps/demo/src/main.ts index 023879515..c67ec005b 100644 --- a/apps/demo/src/main.ts +++ b/apps/demo/src/main.ts @@ -207,7 +207,8 @@ function renderDiff(parsedPatches: ParsedPatch[], manager?: WorkerPoolManager) { overflow: wrap ? 'wrap' : 'scroll', renderAnnotation: renderDiffAnnotation, renderHeaderMetadata() { - return createCollapsedToggle( + return createToggle( + 'Collapse', instance?.options.collapsed ?? false, (checked) => { instance?.setOptions({ @@ -674,6 +675,7 @@ if (renderFileButton != null) { virtualizer?.setup(globalThis.document); const wrap = getWrapped(); + const editor = new Editor(); const fileContainer = document.createElement(DIFFS_TAG_NAME); wrapper.appendChild(fileContainer); let instance: @@ -685,7 +687,8 @@ if (renderFileButton != null) { themeType: getThemeType(), renderAnnotation, renderCustomMetadata() { - return createCollapsedToggle( + const collapsedToggle = createToggle( + 'Collapse', instance?.options.collapsed ?? false, (checked) => { instance?.setOptions({ @@ -697,6 +700,31 @@ if (renderFileButton != null) { } } ); + const editableToggle = createToggle('Editable', false, (checked) => { + if (checked) { + editor.edit(instance); + editor.setSelections([ + { + start: { + line: 0, + character: 0, + }, + end: { + line: 0, + character: 0, + }, + direction: 0, + }, + ]); + } else { + editor.cleanUp(); + } + }); + const div = document.createElement('div'); + div.style.display = 'flex'; + div.style.gap = '8px'; + div.append(collapsedToggle, editableToggle); + return div; }, // Line selection stuff @@ -798,146 +826,6 @@ if (renderFileButton != null) { }); } -const renderEditorButton = document.getElementById('render-editor'); -if (renderEditorButton != null) { - // oxlint-disable-next-line @typescript-oxlint/no-misused-promises - renderEditorButton.addEventListener('click', async () => { - const file = await fileExample; - const wrapper = document.getElementById('wrapper'); - if (wrapper == null) return; - cleanupInstances(wrapper); - - virtualizer?.setup(globalThis.document); - const wrap = getWrapped(); - const fileContainer = document.createElement(DIFFS_TAG_NAME); - wrapper.appendChild(fileContainer); - let instance: - | File - | VirtualizedFile; - const options: FileOptions = { - overflow: wrap ? 'wrap' : 'scroll', - theme: DEMO_THEME, - themeType: getThemeType(), - renderAnnotation, - renderCustomMetadata() { - return createCollapsedToggle( - instance?.options.collapsed ?? false, - (checked) => { - instance?.setOptions({ - ...instance.options, - collapsed: checked, - }); - if (!VIRTUALIZE) { - void instance.rerender(); - } - } - ); - }, - - // Line selection stuff - // enableLineSelection: true, - // onLineClick(props) { - // console.log('onLineClick', props); - // }, - // onLineNumberClick(props) { - // console.info('onLineNumberClick', props); - // }, - // onLineSelected(props) { - // console.log('onLineSelected', props); - // }, - // onLineSelectionStart(props) { - // console.log('onLineSelectionStart', props); - // }, - // onLineSelectionChange(props) { - // console.log('onLineSelectionChange', props); - // }, - // onLineSelectionEnd(props) { - // console.log('onLineSelectionEnd', props); - // }, - // Super noisy, but for debuggin - // onLineEnter(props) { - // console.log('onLineEnter', props); - // }, - // onLineLeave(props) { - // console.log('onLineLeave', props); - // }, - - // Hover Decoration Snippets - // enableGutterUtility: true, - // onGutterUtilityClick(event) { - // console.log('onGutterUtilityClick', event); - // }, - // renderGutterUtility(getHoveredLine) { - // const el = document.createElement('div'); - // el.style.width = '20px'; - // el.style.height = '20px'; - // el.style.backgroundColor = 'blue'; - // el.style.borderRadius = '2px'; - // el.style.marginRight = '-10px'; - // el.style.textAlign = 'center'; - // el.style.color = 'white'; - // el.innerText = '+'; - // el.addEventListener('click', (event) => { - // event.stopPropagation(); - // console.log('ZZZZ - clicked', getHoveredLine()); - // }); - // el.addEventListener('mousedown', (event) => { - // event.stopPropagation(); - // }); - // return el; - // }, - - // Token Testing Helpers - // onTokenEnter(props) { - // console.log( - // 'enter', - // props.tokenText, - // props.lineNumber, - // props.lineCharStart - // ); - // props.tokenElement.style.backgroundColor = 'light-dark(black, white)'; - // props.tokenElement.style.color = 'light-dark(white, black)'; - // props.tokenElement.style.borderRadius = '2px'; - // }, - // onTokenLeave(props) { - // console.log( - // 'leave', - // props.tokenText, - // props.lineNumber, - // props.lineCharStart - // ); - // props.tokenElement.style.backgroundColor = ''; - // props.tokenElement.style.color = ''; - // props.tokenElement.style.borderRadius = ''; - // }, - }; - - instance = (() => { - if (virtualizer != null) { - return new VirtualizedFile( - options, - virtualizer, - undefined, - poolManager - ); - } else { - return new File(options, poolManager); - } - })(); - instance.render({ - file, - lineAnnotations: FAKE_LINE_ANNOTATIONS, - fileContainer, - }); - fileInstances.push(instance); - - const editor = new Editor(); - editor.edit(instance, (file) => { - console.log('onChange', file); - }); - }); -} - const renderFileConflictButton = document.getElementById('render-conflict'); if (renderFileConflictButton != null) { // oxlint-disable-next-line @typescript-oxlint/no-misused-promises @@ -1024,7 +912,8 @@ cleanButton?.addEventListener('click', () => { cleanupInstances(container); }); -function createCollapsedToggle( +function createToggle( + labelText: string, checked: boolean, onChange: (checked: boolean) => void ): HTMLElement { @@ -1037,7 +926,7 @@ function createCollapsedToggle( }); label.dataset.collapser = ''; label.appendChild(input); - label.append(' Collapse'); + label.append(labelText); return label; } From 1e76276ffe013582566b765191b6af0c8cee86c4 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Sat, 9 May 2026 21:29:21 +0800 Subject: [PATCH 105/138] Add `removeEditor` for File component --- packages/diffs/src/components/File.ts | 5 ++ packages/diffs/src/editor/index.ts | 114 ++++++++++++++------------ 2 files changed, 65 insertions(+), 54 deletions(-) diff --git a/packages/diffs/src/components/File.ts b/packages/diffs/src/components/File.ts index d7560479f..90d264c7d 100644 --- a/packages/diffs/src/components/File.ts +++ b/packages/diffs/src/components/File.ts @@ -176,6 +176,7 @@ export class File { } public setEditor(editor: DiffsEditor): void { + this.editor?.cleanUp(); if (this.fileContainer != null && this.file != null) { editor.syncFile( this.fileContainer, @@ -187,6 +188,10 @@ export class File { this.editor = editor; } + public removeEditor(): void { + this.editor = undefined; + } + private handleHighlightRender = (): void => { this.rerender(); }; diff --git a/packages/diffs/src/editor/index.ts b/packages/diffs/src/editor/index.ts index cba1ac0a2..ccc97df6c 100644 --- a/packages/diffs/src/editor/index.ts +++ b/packages/diffs/src/editor/index.ts @@ -153,49 +153,9 @@ export class Editor implements DiffsEditor { return () => this.cleanUp(); } - setSelections(selections: EditorSelection[], resetTextarea = true): void { - const primarySelection = selections.at(-1); - if (primarySelection === undefined) { - return; - } - if (resetTextarea) { - this.#textareaSnapshot = undefined; - } - this.#file?.setSelectedLines(null); - if (isCollapsedSelection(primarySelection)) { - const line = primarySelection.end.line + 1; - this.#file?.setSelectedLines({ - start: line, - end: line, - }); - } - const shouldUpdateTextarea = - Math.max(0, primarySelection.start.line - 1) !== - this.#textareaSnapshot?.startLine; - this.#selections = selections; - this.#renderSelections(selections); - if (shouldUpdateTextarea) { - this.#updateTextarea(primarySelection); - } else if ( - this.#textareaEl !== undefined && - this.#textDocument !== undefined - ) { - const nextTextareaSnapshot = createTextareaSnapshot( - this.#textDocument, - primarySelection - ); - const shouldSyncTextarea = - this.#textareaSnapshot === undefined || - nextTextareaSnapshot.text !== this.#textareaEl.value || - nextTextareaSnapshot.selectionStart !== - this.#textareaEl.selectionStart || - nextTextareaSnapshot.selectionEnd !== this.#textareaEl.selectionEnd; - if (shouldSyncTextarea) { - this.#updateTextarea(primarySelection); - } else { - this.#textareaSnapshot = nextTextareaSnapshot; - } - } + setSelections(selections: EditorSelection[]): void { + this.#setSelections(selections); + this.#focusTextarea(); } cleanUp(): void { @@ -203,6 +163,7 @@ export class Editor implements DiffsEditor { this.#disposes = undefined; this.#onChange = undefined; + this.#file?.removeEditor(); this.#file = undefined; this.#fileContents = undefined; this.#lineAnnotations = undefined; @@ -310,7 +271,7 @@ export class Editor implements DiffsEditor { this.#contentEl?.appendChild(this.#textareaEl); } if (this.#selections !== undefined) { - this.setSelections(this.#selections); + this.#setSelections(this.#selections); if (this.#selectionStart === undefined) { this.#focusTextarea(); } @@ -762,7 +723,7 @@ export class Editor implements DiffsEditor { // Selection in the textarea changed, but no text change was made. if (selectionStart === selectionEnd) { - this.setSelections( + this.#setSelections( mapSelectionMove( textDocument, selections, @@ -777,7 +738,7 @@ export class Editor implements DiffsEditor { textareaSnapshot.offset + (isBackward ? selectionEnd : selectionStart); const focusOffset = textareaSnapshot.offset + (isBackward ? selectionStart : selectionEnd); - this.setSelections( + this.#setSelections( mapSelectionRangeMove( textDocument, selections, @@ -845,12 +806,12 @@ export class Editor implements DiffsEditor { const primarySelection = this.#selections?.at(-1); if (primarySelection !== undefined) { const newSelection = mergeSelections(primarySelection, selection); - this.setSelections([newSelection]); + this.#setSelections([newSelection]); } else { - this.setSelections([selection]); + this.#setSelections([selection]); } } else if (this.#reservedSelections !== undefined) { - this.setSelections([ + this.#setSelections([ ...this.#reservedSelections.filter( (reservedSelection) => !selectionIntersects(reservedSelection, selection) @@ -858,7 +819,7 @@ export class Editor implements DiffsEditor { selection, ]); } else { - this.setSelections([selection]); + this.#setSelections([selection]); } } @@ -909,6 +870,51 @@ export class Editor implements DiffsEditor { }, 0); } + #setSelections(selections: EditorSelection[], resetTextarea = true): void { + const primarySelection = selections.at(-1); + if (primarySelection === undefined) { + return; + } + if (resetTextarea) { + this.#textareaSnapshot = undefined; + } + this.#file?.setSelectedLines(null); + if (isCollapsedSelection(primarySelection)) { + const line = primarySelection.end.line + 1; + this.#file?.setSelectedLines({ + start: line, + end: line, + }); + } + const shouldUpdateTextarea = + Math.max(0, primarySelection.start.line - 1) !== + this.#textareaSnapshot?.startLine; + this.#selections = selections; + this.#renderSelections(selections); + if (shouldUpdateTextarea) { + this.#updateTextarea(primarySelection); + } else if ( + this.#textareaEl !== undefined && + this.#textDocument !== undefined + ) { + const nextTextareaSnapshot = createTextareaSnapshot( + this.#textDocument, + primarySelection + ); + const shouldSyncTextarea = + this.#textareaSnapshot === undefined || + nextTextareaSnapshot.text !== this.#textareaEl.value || + nextTextareaSnapshot.selectionStart !== + this.#textareaEl.selectionStart || + nextTextareaSnapshot.selectionEnd !== this.#textareaEl.selectionEnd; + if (shouldSyncTextarea) { + this.#updateTextarea(primarySelection); + } else { + this.#textareaSnapshot = nextTextareaSnapshot; + } + } + } + #renderSelections(selections: EditorSelection[]) { const fragment = document.createDocumentFragment(); const cacheMap = new Map(); @@ -1164,7 +1170,7 @@ export class Editor implements DiffsEditor { async #runCommand(command: EditorCommand) { switch (command) { case 'selectAll': - this.setSelections([this.#getFullSelection()]); + this.#setSelections([this.#getFullSelection()]); break; case 'copy': @@ -1204,7 +1210,7 @@ export class Editor implements DiffsEditor { } const next = extendSelections(textDocument, selections); if (next !== undefined) { - this.setSelections(next, false); + this.#setSelections(next, false); this.#focusTextarea(); } break; @@ -1260,7 +1266,7 @@ export class Editor implements DiffsEditor { const atEnd = command === 'documentEnd'; const anchor = createElement('span'); const root = this.#contentEl?.getRootNode() as Element | undefined; - this.setSelections([this.#getDocumentBoundarySelection(atEnd)]); + this.#setSelections([this.#getDocumentBoundarySelection(atEnd)]); if (root !== undefined) { if (atEnd) { root.appendChild(anchor); @@ -1418,7 +1424,7 @@ export class Editor implements DiffsEditor { } this.#rerender(change, lineAnnotations); if (selections !== undefined) { - this.setSelections(selections, false); + this.#setSelections(selections, false); } } From 3bb44ee370b982522c318a9c5ea0182aff1383a6 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Sat, 9 May 2026 22:57:01 +0800 Subject: [PATCH 106/138] Add react api --- packages/diffs/src/editor/index.ts | 4 ++- packages/diffs/src/react/EditorContext.tsx | 29 +++++++++++++++++++ packages/diffs/src/react/File.tsx | 4 +++ packages/diffs/src/react/index.ts | 1 + packages/diffs/src/react/types.ts | 5 ++++ .../diffs/src/react/utils/useFileInstance.ts | 25 ++++++++++++++++ 6 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 packages/diffs/src/react/EditorContext.tsx diff --git a/packages/diffs/src/editor/index.ts b/packages/diffs/src/editor/index.ts index ccc97df6c..87b61be96 100644 --- a/packages/diffs/src/editor/index.ts +++ b/packages/diffs/src/editor/index.ts @@ -63,7 +63,9 @@ import { } from './editorTextarea'; import { BackgroundTokenizer, tokenizeLine } from './tokenzier'; -export class Editor implements DiffsEditor { +export class Editor< + LAnnotation = undefined, +> implements DiffsEditor { #disposes?: (() => void)[]; #onChange?: ( file: FileContents, diff --git a/packages/diffs/src/react/EditorContext.tsx b/packages/diffs/src/react/EditorContext.tsx new file mode 100644 index 000000000..52d6a3f1f --- /dev/null +++ b/packages/diffs/src/react/EditorContext.tsx @@ -0,0 +1,29 @@ +'use client'; + +import type { Context, PropsWithChildren } from 'react'; +import { createContext, useContext, useEffect } from 'react'; + +import { Editor as VanillaEditor } from '../editor'; + +export const EditorContext: Context | undefined> = + createContext | undefined>(undefined); + +export function EditorProvider({ + children, + editor, +}: PropsWithChildren<{ editor: VanillaEditor }>): React.JSX.Element { + useEffect(() => { + return () => { + editor.cleanUp(); + }; + }, [editor]); + return ( + {children} + ); +} + +export function useEditor(): + | VanillaEditor + | undefined { + return useContext(EditorContext) as VanillaEditor | undefined; +} diff --git a/packages/diffs/src/react/File.tsx b/packages/diffs/src/react/File.tsx index 11727a8d3..b32b8042a 100644 --- a/packages/diffs/src/react/File.tsx +++ b/packages/diffs/src/react/File.tsx @@ -25,6 +25,8 @@ export function File({ renderGutterUtility, renderHoverUtility, disableWorkerPool = false, + editable = false, + onChange, }: FileProps): React.JSX.Element { const { ref, getHoveredLine } = useFileInstance({ file, @@ -37,6 +39,8 @@ export function File({ renderGutterUtility != null || renderHoverUtility != null, hasCustomHeader: renderCustomHeader != null, disableWorkerPool, + editable, + onChange, }); const children = renderFileChildren({ file, diff --git a/packages/diffs/src/react/index.ts b/packages/diffs/src/react/index.ts index 92efc882c..af275c460 100644 --- a/packages/diffs/src/react/index.ts +++ b/packages/diffs/src/react/index.ts @@ -9,6 +9,7 @@ export * from './MultiFileDiff'; export * from './PatchDiff'; export * from './Virtualizer'; export * from './WorkerPoolContext'; +export * from './EditorContext'; export * from './constants'; export * from './types'; export * from './utils/renderDiffChildren'; diff --git a/packages/diffs/src/react/types.ts b/packages/diffs/src/react/types.ts index 47ee19078..3469dc5e6 100644 --- a/packages/diffs/src/react/types.ts +++ b/packages/diffs/src/react/types.ts @@ -60,4 +60,9 @@ export interface FileProps { style?: CSSProperties; prerenderedHTML?: string; disableWorkerPool?: boolean; + editable?: boolean; + onChange?: ( + file: FileContents, + lineAnnotations?: LineAnnotation[] + ) => void; } diff --git a/packages/diffs/src/react/utils/useFileInstance.ts b/packages/diffs/src/react/utils/useFileInstance.ts index 9b0971594..70b3b9abe 100644 --- a/packages/diffs/src/react/utils/useFileInstance.ts +++ b/packages/diffs/src/react/utils/useFileInstance.ts @@ -19,6 +19,7 @@ import type { } from '../../types'; import { areOptionsEqual } from '../../utils/areOptionsEqual'; import { noopRender } from '../constants'; +import { useEditor } from '../EditorContext'; import { useVirtualizer } from '../Virtualizer'; import { WorkerPoolContext } from '../WorkerPoolContext'; import { useStableCallback } from './useStableCallback'; @@ -36,6 +37,11 @@ interface UseFileInstanceProps { hasGutterRenderUtility: boolean; hasCustomHeader: boolean; disableWorkerPool: boolean; + editable: boolean; + onChange?: ( + file: FileContents, + lineAnnotations?: LineAnnotation[] + ) => void; } interface UseFileInstanceReturn { @@ -53,9 +59,12 @@ export function useFileInstance({ hasGutterRenderUtility, hasCustomHeader, disableWorkerPool, + editable, + onChange, }: UseFileInstanceProps): UseFileInstanceReturn { const simpleVirtualizer = useVirtualizer(); const poolManager = useContext(WorkerPoolContext); + const editor = useEditor(); const instanceRef = useRef< File | VirtualizedFile | null >(null); @@ -95,10 +104,16 @@ export function useFileInstance({ lineAnnotations, prerenderedHTML, }); + if (editable && editor != null) { + editor.edit(instanceRef.current, onChange); + } } else { if (instanceRef.current == null) { throw new Error('File: A File instance should exist when unmounting'); } + if (editable && editor != null) { + editor.cleanUp(); + } instanceRef.current.cleanUp(); instanceRef.current = null; } @@ -122,6 +137,16 @@ export function useFileInstance({ } }); + useIsometricEffect(() => { + if (editable && editor != null && instanceRef.current != null) { + editor.edit(instanceRef.current, onChange); + return () => { + editor.cleanUp(); + }; + } + return undefined; + }, [editable, editor, onChange]); + const getHoveredLine = useCallback((): | GetHoveredLineResult<'file'> | undefined => { From ff1d4525779f92843226825d09037387888fec1f Mon Sep 17 00:00:00 2001 From: Je Xia Date: Sat, 9 May 2026 22:58:26 +0800 Subject: [PATCH 107/138] Clean up --- packages/diffs/src/react/utils/useFileInstance.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/diffs/src/react/utils/useFileInstance.ts b/packages/diffs/src/react/utils/useFileInstance.ts index 70b3b9abe..768daa966 100644 --- a/packages/diffs/src/react/utils/useFileInstance.ts +++ b/packages/diffs/src/react/utils/useFileInstance.ts @@ -139,10 +139,7 @@ export function useFileInstance({ useIsometricEffect(() => { if (editable && editor != null && instanceRef.current != null) { - editor.edit(instanceRef.current, onChange); - return () => { - editor.cleanUp(); - }; + return editor.edit(instanceRef.current, onChange); } return undefined; }, [editable, editor, onChange]); From 0ff08c90a4fcfdaf25540fc192446765ac0d3f63 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Sat, 9 May 2026 23:21:00 +0800 Subject: [PATCH 108/138] Update demo app --- apps/demo/src/main.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/demo/src/main.ts b/apps/demo/src/main.ts index c67ec005b..c201a463c 100644 --- a/apps/demo/src/main.ts +++ b/apps/demo/src/main.ts @@ -48,7 +48,7 @@ import { const DEMO_THEME: DiffsThemeNames | ThemesType = DEFAULT_THEMES; const WORKER_POOL = true; const VIRTUALIZE = true; -const CRAZY_FILE = true; +const CRAZY_FILE = false; const LARGE_CONFLICT_FILE = false; const FileStreamCodeConfigs: FileStreamCodeConfigsItem[] = [ @@ -675,7 +675,7 @@ if (renderFileButton != null) { virtualizer?.setup(globalThis.document); const wrap = getWrapped(); - const editor = new Editor(); + const editor = new Editor(); const fileContainer = document.createElement(DIFFS_TAG_NAME); wrapper.appendChild(fileContainer); let instance: @@ -702,7 +702,9 @@ if (renderFileButton != null) { ); const editableToggle = createToggle('Editable', false, (checked) => { if (checked) { - editor.edit(instance); + editor.edit(instance, (file, lineAnnotations) => { + console.log('change', file, lineAnnotations); + }); editor.setSelections([ { start: { @@ -926,7 +928,7 @@ function createToggle( }); label.dataset.collapser = ''; label.appendChild(input); - label.append(labelText); + label.appendChild(document.createTextNode(` ${labelText}`)); return label; } From 3b40cf899f3b2e55099934c412e671b307eb6a4b Mon Sep 17 00:00:00 2001 From: Je Xia Date: Sat, 9 May 2026 23:21:41 +0800 Subject: [PATCH 109/138] Refactor useFileInstance to remove redundant editor cleanup logic --- packages/diffs/src/react/utils/useFileInstance.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/packages/diffs/src/react/utils/useFileInstance.ts b/packages/diffs/src/react/utils/useFileInstance.ts index 768daa966..6c747642b 100644 --- a/packages/diffs/src/react/utils/useFileInstance.ts +++ b/packages/diffs/src/react/utils/useFileInstance.ts @@ -104,16 +104,10 @@ export function useFileInstance({ lineAnnotations, prerenderedHTML, }); - if (editable && editor != null) { - editor.edit(instanceRef.current, onChange); - } } else { if (instanceRef.current == null) { throw new Error('File: A File instance should exist when unmounting'); } - if (editable && editor != null) { - editor.cleanUp(); - } instanceRef.current.cleanUp(); instanceRef.current = null; } From d3289c7f057c14193c9d177fce3d1553e5012f20 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Sun, 10 May 2026 14:57:42 +0800 Subject: [PATCH 110/138] Fix `computeLineOffsets` function --- packages/diffs/src/editor/index.ts | 4 +-- packages/diffs/src/editor/pieceTable.ts | 15 ++-------- .../diffs/src/utils/computeFileOffsets.ts | 3 -- packages/diffs/test/fileLineUtils.test.ts | 28 +++++-------------- packages/diffs/test/pieceTable.test.ts | 19 ------------- 5 files changed, 11 insertions(+), 58 deletions(-) diff --git a/packages/diffs/src/editor/index.ts b/packages/diffs/src/editor/index.ts index 87b61be96..ccc97df6c 100644 --- a/packages/diffs/src/editor/index.ts +++ b/packages/diffs/src/editor/index.ts @@ -63,9 +63,7 @@ import { } from './editorTextarea'; import { BackgroundTokenizer, tokenizeLine } from './tokenzier'; -export class Editor< - LAnnotation = undefined, -> implements DiffsEditor { +export class Editor implements DiffsEditor { #disposes?: (() => void)[]; #onChange?: ( file: FileContents, diff --git a/packages/diffs/src/editor/pieceTable.ts b/packages/diffs/src/editor/pieceTable.ts index 122d6b66f..d2d7b4534 100644 --- a/packages/diffs/src/editor/pieceTable.ts +++ b/packages/diffs/src/editor/pieceTable.ts @@ -1,3 +1,4 @@ +import { computeLineOffsets } from '../utils/computeFileOffsets'; import type { Position, Range } from './textDocument'; // A piece is a segment of text that is either original or added. @@ -17,14 +18,14 @@ class TextBuffer { lineOffsets: number[]; constructor(public text: string) { - this.lineOffsets = createLineOffsets(text); + this.lineOffsets = computeLineOffsets(text); } // the append operation is efficient because it only appends // elements to the lineOffsets array in the end append(text: string): number { const offset = this.text.length; - const appendedLineOffsets = createLineOffsets(text); + const appendedLineOffsets = computeLineOffsets(text); for (let i = 1; i < appendedLineOffsets.length; i++) { this.lineOffsets.push(offset + appendedLineOffsets[i]); } @@ -620,16 +621,6 @@ function clamp(value: number, min: number, max: number): number { return Math.min(Math.max(value, min), max); } -function createLineOffsets(text: string): number[] { - const offsets = [0]; - for (let i = 0; i < text.length; i++) { - if (text.charCodeAt(i) === 10) { - offsets.push(i + 1); - } - } - return offsets; -} - function createPrefixTable(text: string): number[] { const table = Array.from({ length: text.length }).fill(0); let matched = 0; diff --git a/packages/diffs/src/utils/computeFileOffsets.ts b/packages/diffs/src/utils/computeFileOffsets.ts index 6d6a21f63..609090edf 100644 --- a/packages/diffs/src/utils/computeFileOffsets.ts +++ b/packages/diffs/src/utils/computeFileOffsets.ts @@ -21,8 +21,5 @@ export function computeLineOffsets(contents: string): number[] { offsets.push(i + 1); } } - if (offsets.length > 0 && offsets[offsets.length - 1] !== contents.length) { - offsets.push(contents.length); - } return offsets; } diff --git a/packages/diffs/test/fileLineUtils.test.ts b/packages/diffs/test/fileLineUtils.test.ts index 9bb26fd0b..28ed2412e 100644 --- a/packages/diffs/test/fileLineUtils.test.ts +++ b/packages/diffs/test/fileLineUtils.test.ts @@ -10,21 +10,21 @@ describe('computeLineOffsets', () => { expect(result.length).toBe(1); }); - test('computes offsets for single line without trailing newline', () => { + test('computes offsets for single line', () => { const result = computeLineOffsets('hello'); - expect([...result]).toEqual([0, 5]); - expect(result.length).toBe(2); + expect([...result]).toEqual([0]); + expect(result.length).toBe(1); }); - test('computes offsets for LF files with and without terminal newline', () => { + test('computes offsets for LF files', () => { const withTerminalNewline = computeLineOffsets('a\nb\n'); const withoutTerminalNewline = computeLineOffsets('a\nb'); expect([...withTerminalNewline]).toEqual([0, 2, 4]); expect(withTerminalNewline.length).toBe(3); - expect([...withoutTerminalNewline]).toEqual([0, 2, 3]); - expect(withoutTerminalNewline.length).toBe(3); + expect([...withoutTerminalNewline]).toEqual([0, 2]); + expect(withoutTerminalNewline.length).toBe(2); }); test('computes offsets for CRLF and lone CR line endings', () => { @@ -36,25 +36,11 @@ describe('computeLineOffsets', () => { expect([...mixed]).toEqual([0, 2, 5, 7]); expect(mixed.length).toBe(4); }); -}); - -describe('renderable line count', () => { - test('counts row slots including end offset for two lines without terminal newline', () => { - const lines = computeLineOffsets('first\nsecond'); - - expect(lines.length).toBe(3); - }); - - test('includes trailing blank line segment in offset array length', () => { - const lines = computeLineOffsets('first\nsecond\n\n'); - - expect([...lines]).toEqual([0, 6, 13, 14]); - expect(lines.length).toBe(4); - }); test('treats newline-only contents as two offset boundaries', () => { const lines = computeLineOffsets('\n'); + expect([...lines]).toEqual([0, 1]); expect(lines.length).toBe(2); }); }); diff --git a/packages/diffs/test/pieceTable.test.ts b/packages/diffs/test/pieceTable.test.ts index 4719d818f..963abb63c 100644 --- a/packages/diffs/test/pieceTable.test.ts +++ b/packages/diffs/test/pieceTable.test.ts @@ -196,25 +196,6 @@ describe('PieceTable', () => { expect(table.positionAt(3)).toEqual({ line: 1, character: 0 }); }); - test('keeps repeated line ending characters in line offsets', () => { - const table = new PieceTable('a\r\r\nb\r'); - - expectTableToMatchText(table, 'a\r\r\nb\r'); - expect(table.getLineText(0)).toBe('a'); - expect(table.getLineText(1)).toBe('b'); - expect(table.positionAt(2)).toEqual({ line: 0, character: 2 }); - expect(table.offsetAt({ line: 1, character: 10 })).toBe(6); - }); - - test('keeps line endings split across pieces', () => { - const table = new PieceTable('a\nb'); - - table.insert('\r\r', 1); - table.insert('\r', table.getText().length); - - expectTableToMatchText(table, 'a\r\r\nb\r'); - }); - test('handles an empty document', () => { const table = new PieceTable(''); From 2346eef1fa5619bf85a9851d07745f0c5d903a0f Mon Sep 17 00:00:00 2001 From: Je Xia Date: Sun, 10 May 2026 15:01:26 +0800 Subject: [PATCH 111/138] typo --- packages/diffs/src/editor/editorSelection.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/diffs/src/editor/editorSelection.ts b/packages/diffs/src/editor/editorSelection.ts index f2176c0c3..5614cda93 100644 --- a/packages/diffs/src/editor/editorSelection.ts +++ b/packages/diffs/src/editor/editorSelection.ts @@ -87,13 +87,13 @@ export function resolveIndentEdits( }, newText, }); - const delte = newText.length - deleteLength; + const delta = newText.length - deleteLength; if (line === start.line) { newSelection = { ...newSelection, start: { ...start, - character: Math.max(0, start.character + delte), + character: Math.max(0, start.character + delta), }, }; } @@ -102,7 +102,7 @@ export function resolveIndentEdits( ...newSelection, end: { ...end, - character: Math.max(0, end.character + delte), + character: Math.max(0, end.character + delta), }, }; } From 1a7797cb00377168ca100b9aba6733e4ca344f11 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Sun, 10 May 2026 15:19:37 +0800 Subject: [PATCH 112/138] Update editor style --- packages/diffs/src/editor/constants.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/diffs/src/editor/constants.ts b/packages/diffs/src/editor/constants.ts index 5c874cc2b..14306543c 100644 --- a/packages/diffs/src/editor/constants.ts +++ b/packages/diffs/src/editor/constants.ts @@ -58,13 +58,18 @@ export const EDITOR_CSS = /* CSS */ ` animation-delay: 0.6s; visibility: hidden; } - [data-file]:focus [data-caret], - [data-textarea]:focus ~ [data-caret] { - visibility: visible; - } [data-selection-range] { height: 1lh; z-index: -10; background-color: var(--diffs-bg-selection); + opacity: 0.75; + } + [data-file]:focus [data-caret], + [data-textarea]:focus ~ [data-caret] { + visibility: visible; + } + [data-file]:focus [data-selection-range], + [data-textarea]:focus ~ [data-selection-range] { + opacity: 1; } `; From 99720aeea8b4b44eb60d16ff4346c82cefec2d04 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Sun, 10 May 2026 15:29:26 +0800 Subject: [PATCH 113/138] Fix `getOrCreateLineOffSets` method --- packages/diffs/src/components/File.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/diffs/src/components/File.ts b/packages/diffs/src/components/File.ts index 90d264c7d..23d87d0a6 100644 --- a/packages/diffs/src/components/File.ts +++ b/packages/diffs/src/components/File.ts @@ -389,7 +389,10 @@ export class File { public getOrCreateLineOffSets( file: FileContents | undefined = this.file ): number[] { - return file != null ? this.fileRenderer.getOrCreateLineOffsets(file) : []; + return file != null + ? this.fileRenderer.getOrCreateLineOffsets(file) + : // empty string + [0]; } public getLineCount(file: FileContents | undefined = this.file): number { From 1abf83a4a45c44fe4e51898f0a2c6ef6195ecb4f Mon Sep 17 00:00:00 2001 From: Je Xia Date: Sun, 10 May 2026 16:21:33 +0800 Subject: [PATCH 114/138] Refactor line count and annotation handling in File component; remove hasVisibleLineAnnotation utility --- packages/diffs/src/components/File.ts | 64 +++++++------------ .../diffs/src/components/VirtualizedFile.ts | 8 --- packages/diffs/src/editor/index.ts | 8 +-- packages/diffs/src/index.ts | 1 - .../diffs/src/managers/InteractionManager.ts | 17 +++-- packages/diffs/src/renderers/FileRenderer.ts | 18 +----- .../src/utils/hasVisibleLineAnnotation.ts | 26 -------- .../test/hasVisibleLineAnnotation.test.ts | 50 --------------- 8 files changed, 39 insertions(+), 153 deletions(-) delete mode 100644 packages/diffs/src/utils/hasVisibleLineAnnotation.ts delete mode 100644 packages/diffs/test/hasVisibleLineAnnotation.test.ts diff --git a/packages/diffs/src/components/File.ts b/packages/diffs/src/components/File.ts index 23d87d0a6..910490dc4 100644 --- a/packages/diffs/src/components/File.ts +++ b/packages/diffs/src/components/File.ts @@ -43,7 +43,6 @@ import { createUnsafeCSSStyleNode } from '../utils/createUnsafeCSSStyleNode'; import { wrapThemeCSS, wrapUnsafeCSS } from '../utils/cssWrappers'; import { getLineAnnotationName } from '../utils/getLineAnnotationName'; import { getOrCreateCodeNode } from '../utils/getOrCreateCodeNode'; -import { hasVisibleLineAnnotation } from '../utils/hasVisibleLineAnnotation'; import { upsertHostThemeStyle } from '../utils/hostTheme'; import { prerenderHTMLIfNecessary } from '../utils/prerenderHTMLIfNecessary'; import { setPreNodeProperties } from '../utils/setWrapperNodeProps'; @@ -403,53 +402,38 @@ export class File { this.fileRenderer.emitDirtyLines(lines); } - public emitLineCountChange(lineCount: number): void { + public emitLineCountChange( + lineCount: number, + newLineAnnotations?: LineAnnotation[] + ): void { const result = this.fileRenderer.emitLineCountChange( lineCount, - this.renderRange - ); - if (result != null && this.code != null) { - const { gutterAST, rowCount } = result; - const columns = this.getColumns(this.code); - if (columns != null) { - columns.gutter.innerHTML = - this.fileRenderer.renderPartialHTML(gutterAST); - columns.content.style.gridRow = `span ${rowCount}`; - columns.gutter.style.gridRow = `span ${rowCount}`; - } - } - } - - public emitLineAnnotationsChange( - newLineAnnotations: LineAnnotation[] - ): void { - const previousLineAnnotations = this.lineAnnotations; - const renderRange = this.renderRange; - const result = this.fileRenderer.emitLineAnnotationsChange( newLineAnnotations, this.renderRange ); - for (const { element } of this.annotationCache.values()) { - element.remove(); - } - this.annotationCache.clear(); - this.lineAnnotations = newLineAnnotations; - const hasVisibleAnnotationChange = - hasVisibleLineAnnotation(previousLineAnnotations, renderRange) || - hasVisibleLineAnnotation(newLineAnnotations, renderRange); - if (result != null && this.code != null && hasVisibleAnnotationChange) { + if (result != null && this.code != null) { const { gutterAST, contentAST, rowCount } = result; const columns = this.getColumns(this.code); if (columns != null) { - columns.content.innerHTML = - this.fileRenderer.renderPartialHTML(contentAST); - columns.gutter.innerHTML = - this.fileRenderer.renderPartialHTML(gutterAST); - columns.content.style.gridRow = `span ${rowCount}`; - columns.gutter.style.gridRow = `span ${rowCount}`; - this.renderAnnotations(); - if (this.fileContainer != null && this.file != null) { - this.editor?.syncFile( + const { gutter, content } = columns; + gutter.innerHTML = toHtml(gutterAST); + content.innerHTML = toHtml(contentAST); + gutter.style.gridRow = `span ${rowCount}`; + content.style.gridRow = `span ${rowCount}`; + if (newLineAnnotations != null) { + for (const { element } of this.annotationCache.values()) { + element.remove(); + } + this.annotationCache.clear(); + this.lineAnnotations = newLineAnnotations; + this.renderAnnotations(); + } + if ( + this.fileContainer != null && + this.file != null && + this.editor != null + ) { + this.editor.syncFile( this.fileContainer, this.file, this.lineAnnotations, diff --git a/packages/diffs/src/components/VirtualizedFile.ts b/packages/diffs/src/components/VirtualizedFile.ts index 179796a45..fd3f7a9ef 100644 --- a/packages/diffs/src/components/VirtualizedFile.ts +++ b/packages/diffs/src/components/VirtualizedFile.ts @@ -1,7 +1,6 @@ import { DEFAULT_VIRTUAL_FILE_METRICS } from '../constants'; import type { FileContents, - LineAnnotation, RenderRange, RenderWindow, VirtualFileMetrics, @@ -241,13 +240,6 @@ export class VirtualizedFile< this.computeApproximateSize(); } - override emitLineAnnotationsChange( - lineAnnotations: LineAnnotation[] - ): void { - super.emitLineAnnotationsChange(lineAnnotations); - this.computeApproximateSize(); - } - override render({ fileContainer, file, diff --git a/packages/diffs/src/editor/index.ts b/packages/diffs/src/editor/index.ts index ccc97df6c..b6775eef5 100644 --- a/packages/diffs/src/editor/index.ts +++ b/packages/diffs/src/editor/index.ts @@ -597,13 +597,7 @@ export class Editor implements DiffsEditor { file.emitDirtyLines(dirtyLines); if (lastChange.lineDelta !== 0) { - file.emitLineCountChange(lastChange.lineCount); - } - if ( - nextLineAnnotations !== undefined && - nextLineAnnotations !== this.#lineAnnotations - ) { - file.emitLineAnnotationsChange(nextLineAnnotations); + file.emitLineCountChange(lastChange.lineCount, nextLineAnnotations); } if (!settled && line < lineCount) { diff --git a/packages/diffs/src/index.ts b/packages/diffs/src/index.ts index 20197f8c3..fc1db46b0 100644 --- a/packages/diffs/src/index.ts +++ b/packages/diffs/src/index.ts @@ -87,7 +87,6 @@ export * from './utils/getSingularPatch'; export * from './utils/getThemes'; export * from './utils/getTotalLineCountFromHunks'; export * from './utils/hast_utils'; -export * from './utils/hasVisibleLineAnnotation'; export * from './utils/isDefaultRenderRange'; export * from './utils/isWorkerContext'; export * from './utils/parseDiffDecorations'; diff --git a/packages/diffs/src/managers/InteractionManager.ts b/packages/diffs/src/managers/InteractionManager.ts index d42410b73..5c6b8580c 100644 --- a/packages/diffs/src/managers/InteractionManager.ts +++ b/packages/diffs/src/managers/InteractionManager.ts @@ -1208,12 +1208,17 @@ export class InteractionManager { const last = Math.max(rowRange.start, rowRange.end); for (const code of codeElements) { const [gutter, content] = code.children; - const len = content.children.length; - // if (len !== gutter.children.length) { - // throw new Error( - // 'InteractionManager.renderSelection: gutter and content children dont match, something is wrong' - // ); - // } + const len = [...content.children].filter((child) => { + const dataset = (child as HTMLElement).dataset; + return ( + dataset.line !== undefined || dataset.lineAnnotation !== undefined + ); + }).length; + if (len !== gutter.children.length) { + throw new Error( + 'InteractionManager.renderSelection: gutter and content children dont match, something is wrong' + ); + } for (let i = 0; i < len; i++) { const contentElement = content.children[i]; const gutterElement = gutter.children[i]; diff --git a/packages/diffs/src/renderers/FileRenderer.ts b/packages/diffs/src/renderers/FileRenderer.ts index d60f4e750..df84e4f0f 100644 --- a/packages/diffs/src/renderers/FileRenderer.ts +++ b/packages/diffs/src/renderers/FileRenderer.ts @@ -236,6 +236,7 @@ export class FileRenderer { public emitLineCountChange( lineCount: number, + newLineAnnotations?: LineAnnotation[], renderRange: RenderRange = DEFAULT_RENDER_RANGE ): FileRenderResult | undefined { const renderCache = this.renderCache; @@ -257,22 +258,9 @@ export class FileRenderer { }; } this.alternateLineCount.set(renderCache.file, lineCount); - return this.processFileResult( - renderCache.file, - renderRange, - renderCache.result - ); - } - - public emitLineAnnotationsChange( - newLineAnnotations: LineAnnotation[], - renderRange: RenderRange = DEFAULT_RENDER_RANGE - ): FileRenderResult | undefined { - const renderCache = this.renderCache; - if (renderCache == null || renderCache.result == null) { - return undefined; + if (newLineAnnotations != null) { + this.setLineAnnotations(newLineAnnotations); } - this.setLineAnnotations(newLineAnnotations); return this.processFileResult( renderCache.file, renderRange, diff --git a/packages/diffs/src/utils/hasVisibleLineAnnotation.ts b/packages/diffs/src/utils/hasVisibleLineAnnotation.ts deleted file mode 100644 index e5385f8b4..000000000 --- a/packages/diffs/src/utils/hasVisibleLineAnnotation.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { LineAnnotation, RenderRange } from '../types'; - -/** - * Checks if any line annotations are visible within the given render range. - * @param lineAnnotations - The array of line annotations to check. - * @param renderRange - The render range to check against. - * @returns True if any line annotations are visible, false otherwise. - */ -export function hasVisibleLineAnnotation( - lineAnnotations: readonly LineAnnotation[], - renderRange: RenderRange | undefined -): boolean { - if (lineAnnotations.length === 0) { - return false; - } - if (renderRange == null) { - return true; - } - const { startingLine, totalLines } = renderRange; - const endLine = - totalLines === Infinity ? Infinity : startingLine + totalLines; - return lineAnnotations.some((annotation) => { - const lineIndex = annotation.lineNumber - 1; - return lineIndex >= startingLine && lineIndex < endLine; - }); -} diff --git a/packages/diffs/test/hasVisibleLineAnnotation.test.ts b/packages/diffs/test/hasVisibleLineAnnotation.test.ts deleted file mode 100644 index a6816a610..000000000 --- a/packages/diffs/test/hasVisibleLineAnnotation.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { describe, expect, test } from 'bun:test'; - -import type { LineAnnotation, RenderRange } from '../src/types'; -import { hasVisibleLineAnnotation } from '../src/utils/hasVisibleLineAnnotation'; - -const annotations: LineAnnotation[] = [ - { lineNumber: 1, metadata: 'first' }, - { lineNumber: 3, metadata: 'third' }, - { lineNumber: 5, metadata: 'fifth' }, -]; - -function createRenderRange( - startingLine: number, - totalLines: number -): RenderRange { - return { - startingLine, - totalLines, - bufferBefore: 0, - bufferAfter: 0, - }; -} - -describe('hasVisibleLineAnnotation', () => { - test('returns false when there are no annotations', () => { - expect(hasVisibleLineAnnotation([], undefined)).toBe(false); - }); - - test('returns true for any annotation without a render range', () => { - expect(hasVisibleLineAnnotation(annotations, undefined)).toBe(true); - }); - - test('matches annotations inside a zero-based render range', () => { - expect(hasVisibleLineAnnotation(annotations, createRenderRange(2, 2))).toBe( - true - ); - }); - - test('treats the render range end as exclusive', () => { - expect(hasVisibleLineAnnotation(annotations, createRenderRange(1, 1))).toBe( - false - ); - }); - - test('supports infinite render ranges', () => { - expect( - hasVisibleLineAnnotation(annotations, createRenderRange(3, Infinity)) - ).toBe(true); - }); -}); From e17458515889792842acbf5fbd1a2ad9638121cf Mon Sep 17 00:00:00 2001 From: Je Xia Date: Sun, 10 May 2026 17:57:44 +0800 Subject: [PATCH 115/138] Fix lines deletion crocss virtul viewport --- packages/diffs/src/components/File.ts | 41 +----- .../diffs/src/components/VirtualizedFile.ts | 8 +- packages/diffs/src/editor/editorSelection.ts | 50 +++++++ packages/diffs/src/editor/index.ts | 139 ++++++++++++------ packages/diffs/src/renderers/FileRenderer.ts | 10 +- 5 files changed, 156 insertions(+), 92 deletions(-) diff --git a/packages/diffs/src/components/File.ts b/packages/diffs/src/components/File.ts index 910490dc4..150334816 100644 --- a/packages/diffs/src/components/File.ts +++ b/packages/diffs/src/components/File.ts @@ -406,41 +406,12 @@ export class File { lineCount: number, newLineAnnotations?: LineAnnotation[] ): void { - const result = this.fileRenderer.emitLineCountChange( - lineCount, - newLineAnnotations, - this.renderRange - ); - if (result != null && this.code != null) { - const { gutterAST, contentAST, rowCount } = result; - const columns = this.getColumns(this.code); - if (columns != null) { - const { gutter, content } = columns; - gutter.innerHTML = toHtml(gutterAST); - content.innerHTML = toHtml(contentAST); - gutter.style.gridRow = `span ${rowCount}`; - content.style.gridRow = `span ${rowCount}`; - if (newLineAnnotations != null) { - for (const { element } of this.annotationCache.values()) { - element.remove(); - } - this.annotationCache.clear(); - this.lineAnnotations = newLineAnnotations; - this.renderAnnotations(); - } - if ( - this.fileContainer != null && - this.file != null && - this.editor != null - ) { - this.editor.syncFile( - this.fileContainer, - this.file, - this.lineAnnotations, - this.renderRange - ); - } - } + this.fileRenderer.emitLineCountChange(lineCount, newLineAnnotations); + if (newLineAnnotations != null) { + this.annotationCache.forEach(({ element }) => element.remove()); + this.annotationCache.clear(); + this.lineAnnotations = newLineAnnotations; + this.rerender(); } } diff --git a/packages/diffs/src/components/VirtualizedFile.ts b/packages/diffs/src/components/VirtualizedFile.ts index fd3f7a9ef..61b073a23 100644 --- a/packages/diffs/src/components/VirtualizedFile.ts +++ b/packages/diffs/src/components/VirtualizedFile.ts @@ -1,6 +1,7 @@ import { DEFAULT_VIRTUAL_FILE_METRICS } from '../constants'; import type { FileContents, + LineAnnotation, RenderRange, RenderWindow, VirtualFileMetrics, @@ -235,8 +236,11 @@ export class VirtualizedFile< } } - override emitLineCountChange(lineCount: number): void { - super.emitLineCountChange(lineCount); + override emitLineCountChange( + lineCount: number, + newLineAnnotations?: LineAnnotation[] + ): void { + super.emitLineCountChange(lineCount, newLineAnnotations); this.computeApproximateSize(); } diff --git a/packages/diffs/src/editor/editorSelection.ts b/packages/diffs/src/editor/editorSelection.ts index 5614cda93..bc400929a 100644 --- a/packages/diffs/src/editor/editorSelection.ts +++ b/packages/diffs/src/editor/editorSelection.ts @@ -565,6 +565,56 @@ export function extendSelections( return [...selections, added]; } +/** Clamp saved selections to the current document before rendering or editing. + * Virtualized renders can reuse a selection after offscreen lines were deleted. + * @param selections - The selections to normalize. + * @param textDocument - The text document to normalize the selections to. + * @returns The normalized selections. + */ +export function normalizeSelectionsForDocument( + selections: readonly EditorSelection[], + textDocument: TextDocument +): EditorSelection[] { + return selections.map((selection) => { + const start = normalizePositionForDocument(selection.start, textDocument); + const end = normalizePositionForDocument(selection.end, textDocument); + if (comparePosition(start, end) <= 0) { + return { ...selection, start, end }; + } + return { + ...selection, + start: end, + end: start, + direction: reverseSelectionDirection(selection.direction), + }; + }); +} + +function normalizePositionForDocument( + position: Position, + textDocument: TextDocument +): Position { + const lastLine = Math.max(0, textDocument.lineCount - 1); + const line = Math.max(0, Math.min(position.line, lastLine)); + const character = Math.max( + 0, + Math.min(position.character, textDocument.getLineText(line).length) + ); + return { line, character }; +} + +function reverseSelectionDirection( + direction: EditorSelection['direction'] +): EditorSelection['direction'] { + if (direction === DirectionForward) { + return DirectionBackward; + } + if (direction === DirectionBackward) { + return DirectionForward; + } + return direction; +} + // Expands a zero-width selection to the word-like segment that contains the caret. function expandCollapsedSelectionToWord( textDocument: TextDocument, diff --git a/packages/diffs/src/editor/index.ts b/packages/diffs/src/editor/index.ts index b6775eef5..f47dfc5c4 100644 --- a/packages/diffs/src/editor/index.ts +++ b/packages/diffs/src/editor/index.ts @@ -22,6 +22,7 @@ import { mapSelectionMove, mapSelectionRangeMove, mergeSelections, + normalizeSelectionsForDocument, resolveIndentEdits, selectionIntersects, } from '../editor/editorSelection'; @@ -408,7 +409,7 @@ export class Editor implements DiffsEditor { } #rerender( - lastChange: TextDocumentChange, + change: TextDocumentChange, nextLineAnnotations?: LineAnnotation[] | undefined ) { // cancel existing background tokenzier task @@ -419,12 +420,16 @@ export class Editor implements DiffsEditor { const fileContents = this.#fileContents; const textDocument = this.#textDocument; const contentEl = this.#contentEl; + const gutterEl = this.#contentEl?.previousElementSibling ?? undefined; if ( highlighter === undefined || file === undefined || fileContents === undefined || textDocument === undefined || - contentEl === undefined + contentEl === undefined || + gutterEl === undefined || + !(gutterEl instanceof HTMLElement) || + gutterEl.dataset.gutter === undefined ) { return; } @@ -438,7 +443,7 @@ export class Editor implements DiffsEditor { const stateStackCache = this.#buildStateStackCache( textDocument, grammar, - lastChange.startLine + change.startLine ); const { lineCount } = textDocument; @@ -448,7 +453,7 @@ export class Editor implements DiffsEditor { ? lineCount : Math.min(startingLine + totalLines, lineCount); - let line = lastChange.startLine; + let line = change.startLine; let state = stateStackCache[line]; let settled = false; let dirtyLines: Map> = new Map(); @@ -477,8 +482,8 @@ export class Editor implements DiffsEditor { } settled = - line >= lastChange.endLine && - lastChange.lineDelta === 0 && + line >= change.endLine && + change.lineDelta === 0 && stateStackCache[line + 1] !== undefined && state.equals(stateStackCache[line + 1]); if (settled) { @@ -494,31 +499,27 @@ export class Editor implements DiffsEditor { // Invalidate layout caches touched by the edit. // - line inserts/deletes shift line numbers, so clear from startLine onward // - wrapped edits can change visual height, which shifts downstream line Y - if (lastChange.lineDelta !== 0) { + if (change.lineDelta !== 0) { for (const line of this.#lineYCache.keys()) { - if (line >= lastChange.startLine) { + if (line >= change.startLine) { this.#lineYCache.delete(line); } } } if (this.#wrap) { for (const line of this.#wrapLineOffsetsCache.keys()) { - if (line >= lastChange.startLine) { + if (line >= change.startLine) { this.#wrapLineOffsetsCache.delete(line); } } } - // update line elements that have been changed in the document - // create new line elements for new lines if (dirtyLines.size > 0) { const children = contentEl.children; const dirtyLineIndexes = new Set(dirtyLines.keys()); - for ( - let i = lastChange.startLine - startingLine; - i < children.length; - i++ - ) { + + // update line elements that have been changed in the document + for (let i = change.startLine - startingLine; i < children.length; i++) { if (dirtyLineIndexes.size === 0) { break; } @@ -530,7 +531,7 @@ export class Editor implements DiffsEditor { child.replaceChildren( ...tokens.map(([char, style, textContent]) => { if (char === 0 && style === '') { - return textContent; + return document.createTextNode(textContent); } return createElement('span', { dataset: { @@ -545,20 +546,23 @@ export class Editor implements DiffsEditor { } } } + + // create new line elements for new lines if (dirtyLineIndexes.size > 0) { for (const lineIndex of dirtyLineIndexes) { const tokens = dirtyLines.get(lineIndex)!; - createElement( + const lineNumber = String(lineIndex + 1); + const contentLineEl = createElement( 'div', { dataset: { - line: (lineIndex + 1).toString(), + line: lineNumber, lineType: 'context', lineIndex: lineIndex.toString(), }, children: tokens.map(([char, style, textContent]) => { if (char === 0 && style === '') { - return textContent; + return document.createTextNode(textContent); } return createElement('span', { dataset: { @@ -571,33 +575,57 @@ export class Editor implements DiffsEditor { }, contentEl ); + contentEl.insertBefore(contentLineEl, this.#textareaEl ?? null); + createElement( + 'div', + { + dataset: { + lineType: 'context', + columnNumber: lineNumber, + lineIndex: lineIndex.toString(), + }, + children: [ + createElement('span', { + dataset: { + lineNumberContent: '', + }, + textContent: lineNumber, + }), + ], + }, + gutterEl + ); } } } // remove line elements that have been deleted in the document - if (lastChange.lineDelta < 0) { - const children = contentEl.children; - for (let i = children.length - 1; i >= 0; i--) { - const child = children[i] as HTMLElement; - const { lineIndex, lineAnnotation } = child.dataset; - if (lineIndex !== undefined || lineAnnotation !== undefined) { - const lineIndexNum = Number( - lineAnnotation !== undefined - ? lineAnnotation.split(',')[1] - : lineIndex - ); - if (lineIndexNum < lastChange.lineCount) { - break; + if (change.lineDelta < 0) { + for (const element of [contentEl, gutterEl]) { + const children = element.children; + for (let i = children.length - 1; i >= 0; i--) { + const child = children[i] as HTMLElement; + const { lineIndex, lineAnnotation } = child.dataset; + if (lineIndex !== undefined || lineAnnotation !== undefined) { + const lineIndexNum = Number( + lineAnnotation !== undefined + ? lineAnnotation.split(',')[1] + : lineIndex + ); + if (lineIndexNum < change.lineCount) { + break; + } + child.remove(); } - child.remove(); } } } file.emitDirtyLines(dirtyLines); - if (lastChange.lineDelta !== 0) { - file.emitLineCountChange(lastChange.lineCount, nextLineAnnotations); + if (change.lineDelta !== 0) { + gutterEl.style.gridRow = 'span ' + gutterEl.children.length; + contentEl.style.gridRow = 'span ' + gutterEl.children.length; + file.emitLineCountChange(change.lineCount, nextLineAnnotations); } if (!settled && line < lineCount) { @@ -617,7 +645,7 @@ export class Editor implements DiffsEditor { console.log( `[diffs] re-render time: ${Math.round((performance.now() - t) * 1000) / 1000}ms`, 'lastChange:', - lastChange, + change, 'dirtyLines:', dirtyLines.size, settled ? '(settled)' : '' @@ -865,6 +893,12 @@ export class Editor implements DiffsEditor { } #setSelections(selections: EditorSelection[], resetTextarea = true): void { + if (this.#textDocument !== undefined) { + selections = normalizeSelectionsForDocument( + selections, + this.#textDocument + ); + } const primarySelection = selections.at(-1); if (primarySelection === undefined) { return; @@ -1139,14 +1173,13 @@ export class Editor implements DiffsEditor { fragment: DocumentFragment, cacheMap: Map ) { - if (!this.#isLineVisible(selection.start.line)) { - return; - } - const { start, end, direction } = selection; const isBackward = direction === DirectionBackward; const line = isBackward ? start.line : end.line; const character = isBackward ? start.character : end.character; + if (!this.#isLineVisible(line)) { + return; + } const [left, wrapLine] = this.#getCharX(line, character); const caretEl = createElement( 'div', @@ -1331,7 +1364,7 @@ export class Editor implements DiffsEditor { if (textDocument === undefined) { return ''; } - return [...selections] + return normalizeSelectionsForDocument([...selections], textDocument) .sort((a, b) => { const startOrder = comparePosition(a.start, b.start); if (startOrder !== 0) { @@ -1359,22 +1392,30 @@ export class Editor implements DiffsEditor { if (textDocument == null || primarySelection == null) { return; } + const normalizedSelections = normalizeSelectionsForDocument( + selections, + textDocument + ); + const normalizedPrimarySelection = normalizedSelections.at(-1); + if (normalizedPrimarySelection == null) { + return; + } // TODO(@ije): normalize text with textDocument.EOF const lineAnnotations = this.#lineAnnotations; const { nextSelections, change } = - Array.isArray(text) && text.length === selections.length + Array.isArray(text) && text.length === normalizedSelections.length ? applyTextReplaceToSelections( textDocument, - selections, + normalizedSelections, text, lineAnnotations ) : applyTextChangeToSelections( textDocument, - selections, + normalizedSelections, { - start: textDocument.offsetAt(primarySelection.start), - end: textDocument.offsetAt(primarySelection.end), + start: textDocument.offsetAt(normalizedPrimarySelection.start), + end: textDocument.offsetAt(normalizedPrimarySelection.end), text: Array.isArray(text) ? text.join('\n') : text, }, lineAnnotations @@ -1699,6 +1740,10 @@ export class Editor implements DiffsEditor { // Check whether a line is visible in the currently rendered line window. #isLineVisible(line: number): boolean { + const lineCount = this.#textDocument?.lineCount; + if (line < 0 || (lineCount !== undefined && line >= lineCount)) { + return false; + } if (this.#renderRange === undefined) { return true; } diff --git a/packages/diffs/src/renderers/FileRenderer.ts b/packages/diffs/src/renderers/FileRenderer.ts index df84e4f0f..d48fe29b6 100644 --- a/packages/diffs/src/renderers/FileRenderer.ts +++ b/packages/diffs/src/renderers/FileRenderer.ts @@ -236,9 +236,8 @@ export class FileRenderer { public emitLineCountChange( lineCount: number, - newLineAnnotations?: LineAnnotation[], - renderRange: RenderRange = DEFAULT_RENDER_RANGE - ): FileRenderResult | undefined { + newLineAnnotations?: LineAnnotation[] + ): void { const renderCache = this.renderCache; if (renderCache == null || renderCache.result == null) { return undefined; @@ -261,11 +260,6 @@ export class FileRenderer { if (newLineAnnotations != null) { this.setLineAnnotations(newLineAnnotations); } - return this.processFileResult( - renderCache.file, - renderRange, - renderCache.result - ); } public renderFile( From 9083774b338c9f0cfe5282953a8a1d4580f4a4d2 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Sun, 10 May 2026 21:23:58 +0800 Subject: [PATCH 116/138] Remove `normalizeSelectionsForDocument` function --- packages/diffs/src/editor/editorSelection.ts | 50 ----- packages/diffs/src/editor/editorTextarea.ts | 21 +- packages/diffs/src/editor/index.ts | 190 ++++++++++--------- packages/diffs/src/editor/textDocument.ts | 11 ++ 4 files changed, 116 insertions(+), 156 deletions(-) diff --git a/packages/diffs/src/editor/editorSelection.ts b/packages/diffs/src/editor/editorSelection.ts index bc400929a..5614cda93 100644 --- a/packages/diffs/src/editor/editorSelection.ts +++ b/packages/diffs/src/editor/editorSelection.ts @@ -565,56 +565,6 @@ export function extendSelections( return [...selections, added]; } -/** Clamp saved selections to the current document before rendering or editing. - * Virtualized renders can reuse a selection after offscreen lines were deleted. - * @param selections - The selections to normalize. - * @param textDocument - The text document to normalize the selections to. - * @returns The normalized selections. - */ -export function normalizeSelectionsForDocument( - selections: readonly EditorSelection[], - textDocument: TextDocument -): EditorSelection[] { - return selections.map((selection) => { - const start = normalizePositionForDocument(selection.start, textDocument); - const end = normalizePositionForDocument(selection.end, textDocument); - if (comparePosition(start, end) <= 0) { - return { ...selection, start, end }; - } - return { - ...selection, - start: end, - end: start, - direction: reverseSelectionDirection(selection.direction), - }; - }); -} - -function normalizePositionForDocument( - position: Position, - textDocument: TextDocument -): Position { - const lastLine = Math.max(0, textDocument.lineCount - 1); - const line = Math.max(0, Math.min(position.line, lastLine)); - const character = Math.max( - 0, - Math.min(position.character, textDocument.getLineText(line).length) - ); - return { line, character }; -} - -function reverseSelectionDirection( - direction: EditorSelection['direction'] -): EditorSelection['direction'] { - if (direction === DirectionForward) { - return DirectionBackward; - } - if (direction === DirectionBackward) { - return DirectionForward; - } - return direction; -} - // Expands a zero-width selection to the word-like segment that contains the caret. function expandCollapsedSelectionToWord( textDocument: TextDocument, diff --git a/packages/diffs/src/editor/editorTextarea.ts b/packages/diffs/src/editor/editorTextarea.ts index 9b8bd28cf..7542a0745 100644 --- a/packages/diffs/src/editor/editorTextarea.ts +++ b/packages/diffs/src/editor/editorTextarea.ts @@ -25,20 +25,17 @@ export function createTextareaSnapshot( textDocument.lineCount - 1, primarySelection.end.line + 1 ); + const startCharacter = normalizeCharacter( + primarySelection.start, + textDocument + ); + const endCharacter = normalizeCharacter(primarySelection.end, textDocument); const lines: string[] = []; + let offset = 0; let selectionStart = 0; let selectionEnd = 0; - const startCharacter = normalizeCharacterForDocument( - textDocument, - primarySelection.start - ); - const endCharacter = normalizeCharacterForDocument( - textDocument, - primarySelection.end - ); - for (let line = startLine; line <= endLine; line++) { const lineText = textDocument.getLineText(line); if (line === primarySelection.start.line) { @@ -180,9 +177,9 @@ export function toTextareaSelectionDirection( // Aligns a column with `TextDocument.offsetAt` / `positionAt` so textarea indices match backing text // (DOM may report past end for empty lines that render a placeholder space). -function normalizeCharacterForDocument( - textDocument: TextDocument, - position: Position +function normalizeCharacter( + position: Position, + textDocument: TextDocument ): number { return textDocument.positionAt(textDocument.offsetAt(position)).character; } diff --git a/packages/diffs/src/editor/index.ts b/packages/diffs/src/editor/index.ts index f47dfc5c4..adcab93da 100644 --- a/packages/diffs/src/editor/index.ts +++ b/packages/diffs/src/editor/index.ts @@ -22,7 +22,6 @@ import { mapSelectionMove, mapSelectionRangeMove, mergeSelections, - normalizeSelectionsForDocument, resolveIndentEdits, selectionIntersects, } from '../editor/editorSelection'; @@ -134,6 +133,16 @@ export class Editor implements DiffsEditor { this.#buildStateStackCache(textDocument, grammar, endLine); }, 500); + #emitChange = debounce( + ( + fileContents: FileContents, + lineAnnotations?: LineAnnotation[] + ) => { + this.#onChange?.(fileContents, lineAnnotations); + }, + 500 + ); + edit( file: File, onChange?: ( @@ -155,7 +164,16 @@ export class Editor implements DiffsEditor { } setSelections(selections: EditorSelection[]): void { - this.#setSelections(selections); + const textDocument = this.#textDocument; + // normalize the selections + if (textDocument !== undefined) { + selections = selections.map((selection) => { + const start = textDocument.normalizePosition(selection.start); + const end = textDocument.normalizePosition(selection.end); + return { ...selection, start, end }; + }); + } + this.#appleSelections(selections); this.#focusTextarea(); } @@ -272,7 +290,7 @@ export class Editor implements DiffsEditor { this.#contentEl?.appendChild(this.#textareaEl); } if (this.#selections !== undefined) { - this.#setSelections(this.#selections); + this.#appleSelections(this.#selections); if (this.#selectionStart === undefined) { this.#focusTextarea(); } @@ -601,8 +619,8 @@ export class Editor implements DiffsEditor { // remove line elements that have been deleted in the document if (change.lineDelta < 0) { - for (const element of [contentEl, gutterEl]) { - const children = element.children; + for (const parent of [contentEl, gutterEl]) { + const children = parent.children; for (let i = children.length - 1; i >= 0; i--) { const child = children[i] as HTMLElement; const { lineIndex, lineAnnotation } = child.dataset; @@ -745,7 +763,7 @@ export class Editor implements DiffsEditor { // Selection in the textarea changed, but no text change was made. if (selectionStart === selectionEnd) { - this.#setSelections( + this.#appleSelections( mapSelectionMove( textDocument, selections, @@ -760,7 +778,7 @@ export class Editor implements DiffsEditor { textareaSnapshot.offset + (isBackward ? selectionEnd : selectionStart); const focusOffset = textareaSnapshot.offset + (isBackward ? selectionStart : selectionEnd); - this.#setSelections( + this.#appleSelections( mapSelectionRangeMove( textDocument, selections, @@ -828,12 +846,12 @@ export class Editor implements DiffsEditor { const primarySelection = this.#selections?.at(-1); if (primarySelection !== undefined) { const newSelection = mergeSelections(primarySelection, selection); - this.#setSelections([newSelection]); + this.#appleSelections([newSelection]); } else { - this.#setSelections([selection]); + this.#appleSelections([selection]); } } else if (this.#reservedSelections !== undefined) { - this.#setSelections([ + this.#appleSelections([ ...this.#reservedSelections.filter( (reservedSelection) => !selectionIntersects(reservedSelection, selection) @@ -841,7 +859,7 @@ export class Editor implements DiffsEditor { selection, ]); } else { - this.#setSelections([selection]); + this.#appleSelections([selection]); } } @@ -892,57 +910,6 @@ export class Editor implements DiffsEditor { }, 0); } - #setSelections(selections: EditorSelection[], resetTextarea = true): void { - if (this.#textDocument !== undefined) { - selections = normalizeSelectionsForDocument( - selections, - this.#textDocument - ); - } - const primarySelection = selections.at(-1); - if (primarySelection === undefined) { - return; - } - if (resetTextarea) { - this.#textareaSnapshot = undefined; - } - this.#file?.setSelectedLines(null); - if (isCollapsedSelection(primarySelection)) { - const line = primarySelection.end.line + 1; - this.#file?.setSelectedLines({ - start: line, - end: line, - }); - } - const shouldUpdateTextarea = - Math.max(0, primarySelection.start.line - 1) !== - this.#textareaSnapshot?.startLine; - this.#selections = selections; - this.#renderSelections(selections); - if (shouldUpdateTextarea) { - this.#updateTextarea(primarySelection); - } else if ( - this.#textareaEl !== undefined && - this.#textDocument !== undefined - ) { - const nextTextareaSnapshot = createTextareaSnapshot( - this.#textDocument, - primarySelection - ); - const shouldSyncTextarea = - this.#textareaSnapshot === undefined || - nextTextareaSnapshot.text !== this.#textareaEl.value || - nextTextareaSnapshot.selectionStart !== - this.#textareaEl.selectionStart || - nextTextareaSnapshot.selectionEnd !== this.#textareaEl.selectionEnd; - if (shouldSyncTextarea) { - this.#updateTextarea(primarySelection); - } else { - this.#textareaSnapshot = nextTextareaSnapshot; - } - } - } - #renderSelections(selections: EditorSelection[]) { const fragment = document.createDocumentFragment(); const cacheMap = new Map(); @@ -1197,7 +1164,7 @@ export class Editor implements DiffsEditor { async #runCommand(command: EditorCommand) { switch (command) { case 'selectAll': - this.#setSelections([this.#getFullSelection()]); + this.#appleSelections([this.#getFullSelection()]); break; case 'copy': @@ -1237,7 +1204,7 @@ export class Editor implements DiffsEditor { } const next = extendSelections(textDocument, selections); if (next !== undefined) { - this.#setSelections(next, false); + this.#appleSelections(next, false); this.#focusTextarea(); } break; @@ -1293,7 +1260,7 @@ export class Editor implements DiffsEditor { const atEnd = command === 'documentEnd'; const anchor = createElement('span'); const root = this.#contentEl?.getRootNode() as Element | undefined; - this.#setSelections([this.#getDocumentBoundarySelection(atEnd)]); + this.#appleSelections([this.#getDocumentBoundarySelection(atEnd)]); if (root !== undefined) { if (atEnd) { root.appendChild(anchor); @@ -1364,7 +1331,7 @@ export class Editor implements DiffsEditor { if (textDocument === undefined) { return ''; } - return normalizeSelectionsForDocument([...selections], textDocument) + return [...selections] .sort((a, b) => { const startOrder = comparePosition(a.start, b.start); if (startOrder !== 0) { @@ -1392,30 +1359,22 @@ export class Editor implements DiffsEditor { if (textDocument == null || primarySelection == null) { return; } - const normalizedSelections = normalizeSelectionsForDocument( - selections, - textDocument - ); - const normalizedPrimarySelection = normalizedSelections.at(-1); - if (normalizedPrimarySelection == null) { - return; - } // TODO(@ije): normalize text with textDocument.EOF const lineAnnotations = this.#lineAnnotations; const { nextSelections, change } = - Array.isArray(text) && text.length === normalizedSelections.length + Array.isArray(text) && text.length === selections.length ? applyTextReplaceToSelections( textDocument, - normalizedSelections, + selections, text, lineAnnotations ) : applyTextChangeToSelections( textDocument, - normalizedSelections, + selections, { - start: textDocument.offsetAt(normalizedPrimarySelection.start), - end: textDocument.offsetAt(normalizedPrimarySelection.end), + start: textDocument.offsetAt(primarySelection.start), + end: textDocument.offsetAt(primarySelection.end), text: Array.isArray(text) ? text.join('\n') : text, }, lineAnnotations @@ -1443,23 +1402,21 @@ export class Editor implements DiffsEditor { textDocument !== undefined && onChange !== undefined ) { - // TODO(@ije): use debounce - setTimeout(() => { - const { contents: _, ...file } = fileContents; - Object.defineProperty(file, 'contents', { - get() { - return textDocument.getText(); - }, - }); - onChange( - file as FileContents, - lineAnnotations ?? this.#lineAnnotations - ); - }, 0); + const { contents: _, ...file } = fileContents; + Object.defineProperty(file, 'contents', { + get() { + return textDocument.getText(); + }, + }); + this.#emitChange( + file as FileContents, + lineAnnotations ?? this.#lineAnnotations + ); } + this.#selections = selections; this.#rerender(change, lineAnnotations); - if (selections !== undefined) { - this.#setSelections(selections, false); + if (this.#selections !== undefined) { + this.#appleSelections(this.#selections, false); } } @@ -1482,6 +1439,51 @@ export class Editor implements DiffsEditor { return undefined; } + #appleSelections(selections: EditorSelection[], resetTextarea = true): void { + const primarySelection = selections.at(-1); + if (primarySelection === undefined) { + return; + } + if (resetTextarea) { + this.#textareaSnapshot = undefined; + } + this.#file?.setSelectedLines(null); + if (isCollapsedSelection(primarySelection)) { + const line = primarySelection.end.line + 1; + this.#file?.setSelectedLines({ + start: line, + end: line, + }); + } + const shouldUpdateTextarea = + Math.max(0, primarySelection.start.line - 1) !== + this.#textareaSnapshot?.startLine; + this.#selections = selections; + this.#renderSelections(selections); + if (shouldUpdateTextarea) { + this.#updateTextarea(primarySelection); + } else if ( + this.#textareaEl !== undefined && + this.#textDocument !== undefined + ) { + const nextTextareaSnapshot = createTextareaSnapshot( + this.#textDocument, + primarySelection + ); + const shouldSyncTextarea = + this.#textareaSnapshot === undefined || + nextTextareaSnapshot.text !== this.#textareaEl.value || + nextTextareaSnapshot.selectionStart !== + this.#textareaEl.selectionStart || + nextTextareaSnapshot.selectionEnd !== this.#textareaEl.selectionEnd; + if (shouldSyncTextarea) { + this.#updateTextarea(primarySelection); + } else { + this.#textareaSnapshot = nextTextareaSnapshot; + } + } + } + #getContentWidth() { const diffsColumnContentWidth = this.#contentEl?.parentElement?.style.getPropertyValue( diff --git a/packages/diffs/src/editor/textDocument.ts b/packages/diffs/src/editor/textDocument.ts index 04f59b532..d9d80dd1a 100644 --- a/packages/diffs/src/editor/textDocument.ts +++ b/packages/diffs/src/editor/textDocument.ts @@ -289,6 +289,17 @@ export class TextDocument { ]; } + normalizePosition(position: Position): Position { + const line = Math.max(0, Math.min(position.line, this.lineCount - 1)); + return { + line, + character: Math.max( + 0, + Math.min(position.character, this.getLineText(line).length) + ), + }; + } + #resolveEdit(edit: TextEdit): ResolvedTextEdit { let start = this.offsetAt(edit.range.start); let end = this.offsetAt(edit.range.end); From 2e6580e6e9fe2b529742e96e7c90b0950f533b61 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Sun, 10 May 2026 21:39:29 +0800 Subject: [PATCH 117/138] Fix `edit` function --- packages/diffs/src/editor/index.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/diffs/src/editor/index.ts b/packages/diffs/src/editor/index.ts index adcab93da..d13980583 100644 --- a/packages/diffs/src/editor/index.ts +++ b/packages/diffs/src/editor/index.ts @@ -1760,7 +1760,13 @@ export class Editor implements DiffsEditor { } } -export function edit(file: File): void { - const editor = new Editor(); - editor.edit(file); +export function edit( + file: File, + onChange?: ( + file: FileContents, + lineAnnotations?: LineAnnotation[] + ) => void +): void { + const editor = new Editor(); + editor.edit(file, onChange); } From 3951793f0bf0cfe63e5772b7428c16674ef57d65 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Sun, 10 May 2026 21:39:47 +0800 Subject: [PATCH 118/138] Add editor sub-module --- packages/diffs/package.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/diffs/package.json b/packages/diffs/package.json index 4c34fd5a1..3334c0779 100644 --- a/packages/diffs/package.json +++ b/packages/diffs/package.json @@ -32,6 +32,10 @@ "types": "./dist/index.d.ts", "import": "./dist/index.js" }, + "./editor": { + "types": "./dist/editor/index.d.ts", + "import": "./dist/editor/index.js" + }, "./react": { "types": "./dist/react/index.d.ts", "import": "./dist/react/index.js" From 32197374d851f5c9921ab84aeef2b97604d8f739 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Mon, 11 May 2026 19:55:08 +0800 Subject: [PATCH 119/138] Use `contenteditable` model --- apps/demo/src/main.ts | 6 +- packages/diffs/src/components/File.ts | 7 +- packages/diffs/src/editor/constants.ts | 43 +- packages/diffs/src/editor/editorCommand.ts | 6 - packages/diffs/src/editor/editorSelection.ts | 123 ++- packages/diffs/src/editor/editorTextarea.ts | 185 ---- packages/diffs/src/editor/index.ts | 887 +++++++++--------- packages/diffs/src/editor/tokenzier.ts | 20 +- .../diffs/src/managers/InteractionManager.ts | 7 +- packages/diffs/src/renderers/FileRenderer.ts | 11 +- packages/diffs/src/types.ts | 14 +- packages/diffs/test/editorCommand.test.ts | 6 - packages/diffs/test/editorSelection.test.ts | 182 +++- .../diffs/test/editorTextareaSnapshot.test.ts | 108 --- 14 files changed, 700 insertions(+), 905 deletions(-) delete mode 100644 packages/diffs/src/editor/editorTextarea.ts delete mode 100644 packages/diffs/test/editorTextareaSnapshot.test.ts diff --git a/apps/demo/src/main.ts b/apps/demo/src/main.ts index c201a463c..17a3ab006 100644 --- a/apps/demo/src/main.ts +++ b/apps/demo/src/main.ts @@ -709,13 +709,13 @@ if (renderFileButton != null) { { start: { line: 0, - character: 0, + character: 1000, // will be normalized to the end of the line(< 1000 chars) }, end: { line: 0, - character: 0, + character: 1000, // will be normalized to the end of the line(< 1000 chars) }, - direction: 0, + direction: 'none', }, ]); } else { diff --git a/packages/diffs/src/components/File.ts b/packages/diffs/src/components/File.ts index 150334816..2eb836a67 100644 --- a/packages/diffs/src/components/File.ts +++ b/packages/diffs/src/components/File.ts @@ -398,8 +398,11 @@ export class File { return file != null ? this.fileRenderer.getLineCount(file) : 0; } - public emitDirtyLines(lines: Map>): void { - this.fileRenderer.emitDirtyLines(lines); + public emitDirtyLines( + themeType: 'dark' | 'light', + lines: Map> + ): void { + this.fileRenderer.emitDirtyLines(themeType, lines); } public emitLineCountChange( diff --git a/packages/diffs/src/editor/constants.ts b/packages/diffs/src/editor/constants.ts index 14306543c..c04393f38 100644 --- a/packages/diffs/src/editor/constants.ts +++ b/packages/diffs/src/editor/constants.ts @@ -11,45 +11,26 @@ export const EDITOR_CSS = /* CSS */ ` 50% { opacity: 0; } 100% { opacity: 1; } } + [data-code] { + position: relative; + } + [data-content] { + caret-color: transparent; + outline: none; + } [data-line] { cursor: text; } [data-line]:not([data-selected-line]) { background-color: transparent; } - [data-content] { - position: relative; - } - [data-textarea], [data-caret], [data-selection-range] { + [data-caret], [data-selection-range] { position: absolute; top: 0; left: 0; line-height: var(--diffs-line-height); pointer-events: none; } - [data-textarea] { - top: -1lh; - padding: 0; - padding-inline: 1ch; - tab-size: var(--diffs-tab-size, 2); - font: inherit; - color: transparent; - background-color: transparent; - border: none; - outline: none; - resize: none; - overflow: hidden; - field-sizing: content; - } - [data-overflow='scroll'] [data-textarea] { - white-space: pre; - min-height: 1lh; - } - [data-overflow='wrap'] [data-textarea] { - width: 100%; - white-space: pre-wrap; - word-break: break-word; - } [data-caret] { width: 2px; height: 1lh; @@ -62,14 +43,12 @@ export const EDITOR_CSS = /* CSS */ ` height: 1lh; z-index: -10; background-color: var(--diffs-bg-selection); - opacity: 0.75; + opacity: 0.5; } - [data-file]:focus [data-caret], - [data-textarea]:focus ~ [data-caret] { + [data-content]:focus ~ [data-caret] { visibility: visible; } - [data-file]:focus [data-selection-range], - [data-textarea]:focus ~ [data-selection-range] { + [data-content]:focus ~ [data-selection-range] { opacity: 1; } `; diff --git a/packages/diffs/src/editor/editorCommand.ts b/packages/diffs/src/editor/editorCommand.ts index 56fdafc5e..37b3cb76c 100644 --- a/packages/diffs/src/editor/editorCommand.ts +++ b/packages/diffs/src/editor/editorCommand.ts @@ -1,7 +1,4 @@ export type EditorCommand = - | 'copy' - | 'cut' - | 'paste' | 'indent' | 'outdent' | 'documentStart' @@ -13,9 +10,6 @@ export type EditorCommand = const SHORTCUTS: Partial> = { a: 'selectAll', - c: 'copy', - v: 'paste', - x: 'cut', d: 'extendSelection', }; diff --git a/packages/diffs/src/editor/editorSelection.ts b/packages/diffs/src/editor/editorSelection.ts index 5614cda93..5a80691e3 100644 --- a/packages/diffs/src/editor/editorSelection.ts +++ b/packages/diffs/src/editor/editorSelection.ts @@ -160,17 +160,17 @@ export function mapSelectionMove( export function mapSelectionRangeMove( textDocument: TextDocument, selections: readonly EditorSelection[], - nextAnchor: Position, - nextFocus: Position + selection: EditorSelection ): EditorSelection[] { + const { start, end } = selection; const primarySelection = selections[selections.length - 1]; if (primarySelection === undefined) { return []; } const [primaryAnchorOffset, primaryFocusOffset] = getSelectionAnchorAndFocusOffsets(textDocument, primarySelection); - const anchorDelta = textDocument.offsetAt(nextAnchor) - primaryAnchorOffset; - const focusDelta = textDocument.offsetAt(nextFocus) - primaryFocusOffset; + const anchorDelta = textDocument.offsetAt(start) - primaryAnchorOffset; + const focusDelta = textDocument.offsetAt(end) - primaryFocusOffset; return selections.map((selection) => { const [anchorOffset, focusOffset] = getSelectionAnchorAndFocusOffsets( textDocument, @@ -454,26 +454,28 @@ export function createSelectionFromAnchorAndFocusOffsets( } /** - * Creates a selection from a start and current selection. + * Creates a selection from a anchor and focus selection. */ export function createSelectionFrom( - start: EditorSelection, - current: EditorSelection + anchorSelection: EditorSelection, + focusSelection: EditorSelection ): EditorSelection { const anchor = - start.direction === DirectionBackward ? start.end : start.start; - const currentStartOrder = comparePosition(anchor, current.start); - const currentEndOrder = comparePosition(anchor, current.end); - let focus = current.end; + anchorSelection.direction === DirectionBackward + ? anchorSelection.end + : anchorSelection.start; + const currentStartOrder = comparePosition(anchor, focusSelection.start); + const currentEndOrder = comparePosition(anchor, focusSelection.end); + let focus = focusSelection.end; if (currentStartOrder <= 0) { - focus = current.end; + focus = focusSelection.end; } else if (currentEndOrder >= 0) { - focus = current.start; + focus = focusSelection.start; } else { // When the original anchor sits inside `current`, keep whichever edge // stayed at the anchor so drag direction remains stable. const anchorAtStart = currentStartOrder === 0; - focus = anchorAtStart ? current.end : current.start; + focus = anchorAtStart ? focusSelection.end : focusSelection.start; } const anchorVsFocus = comparePosition(anchor, focus); const direction: SelectionDirection = @@ -492,30 +494,57 @@ export function createSelectionFrom( } /** - * Merges two selections into one range that covers both. + * Extends or shrinks the selection `original` using the endpoints of `target`, \ + * matching contenteditable shift + click extend behavior. */ -export function mergeSelections( - a: EditorSelection, - b: EditorSelection +export function extendSelection( + original: EditorSelection, + target: EditorSelection ): EditorSelection { - const start = comparePosition(a.start, b.start) <= 0 ? a.start : b.start; - const end = comparePosition(a.end, b.end) >= 0 ? a.end : b.end; - const anchorA = a.direction === DirectionBackward ? a.end : a.start; - const focusB = b.direction === DirectionBackward ? b.start : b.end; - const anchorVsFocus = comparePosition(anchorA, focusB); - const direction: SelectionDirection = - anchorVsFocus === 0 - ? DirectionNone - : anchorVsFocus < 0 - ? DirectionForward - : DirectionBackward; - return { start, end, direction }; + const leftExtended = comparePosition(target.start, original.start) < 0; + const rightExtended = comparePosition(target.end, original.end) > 0; + + if (leftExtended && !rightExtended) { + return { + start: target.start, + end: original.end, + direction: DirectionBackward, + }; + } + + if (rightExtended && !leftExtended) { + return { + start: original.start, + end: target.end, + direction: DirectionForward, + }; + } + + if (original.direction === DirectionBackward) { + return { + start: target.start, + end: original.end, + direction: + comparePosition(target.start, original.end) === 0 + ? DirectionNone + : DirectionBackward, + }; + } + + return { + start: original.start, + end: target.end, + direction: + comparePosition(original.start, target.end) === 0 + ? DirectionNone + : DirectionForward, + }; } /** - * Extends a selection. + * Finds the next matching word and updates the selections. */ -export function extendSelections( +export function findNexMatch( textDocument: TextDocument, selections: readonly EditorSelection[] ): EditorSelection[] | undefined { @@ -565,6 +594,36 @@ export function extendSelections( return [...selections, added]; } +/** + * Gets the text node and offset for a selection. + */ +export function getSelectionTextNode( + lineElement: HTMLElement, + character: number +): [Node, number] { + if (lineElement.childElementCount > 0) { + for (const child of lineElement.children) { + if (child.hasAttribute('data-char')) { + const char = Number(child.getAttribute('data-char')); + const textNode = child.firstChild; + if ( + textNode !== null && + textNode.nodeType === /* Node.TEXT_NODE */ 3 && + character >= char && + character <= char + (textNode as Text).textContent.length + ) { + return [textNode, character - char]; + } + } + } + } + const textNode = lineElement.firstChild; + if (textNode !== null && textNode.nodeType === /* Node.TEXT_NODE */ 3) { + return [textNode, character]; + } + throw new Error('No text node found'); +} + // Expands a zero-width selection to the word-like segment that contains the caret. function expandCollapsedSelectionToWord( textDocument: TextDocument, diff --git a/packages/diffs/src/editor/editorTextarea.ts b/packages/diffs/src/editor/editorTextarea.ts deleted file mode 100644 index 7542a0745..000000000 --- a/packages/diffs/src/editor/editorTextarea.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { - DirectionBackward, - DirectionForward, - DirectionNone, - type EditorSelection, - type SelectionDirection, -} from './editorSelection'; -import type { Position, ResolvedTextEdit, TextDocument } from './textDocument'; - -export interface TextareaSnapshot { - startLine: number; - offset: number; - selectionStart: number; - selectionEnd: number; - text: string; - lineCount: number; -} - -export function createTextareaSnapshot( - textDocument: TextDocument, - primarySelection: EditorSelection -): TextareaSnapshot { - const startLine = Math.max(0, primarySelection.start.line - 1); - const endLine = Math.min( - textDocument.lineCount - 1, - primarySelection.end.line + 1 - ); - const startCharacter = normalizeCharacter( - primarySelection.start, - textDocument - ); - const endCharacter = normalizeCharacter(primarySelection.end, textDocument); - const lines: string[] = []; - - let offset = 0; - let selectionStart = 0; - let selectionEnd = 0; - - for (let line = startLine; line <= endLine; line++) { - const lineText = textDocument.getLineText(line); - if (line === primarySelection.start.line) { - selectionStart = offset + startCharacter; - } - if (line === primarySelection.end.line) { - selectionEnd = offset + endCharacter; - } - lines.push(lineText); - offset += lineText.length; - if (line < endLine) { - offset++; - } - } - - return { - startLine, - offset: textDocument.offsetAt({ line: startLine, character: 0 }), - selectionStart, - selectionEnd, - text: lines.join('\n'), - lineCount: lines.length, - }; -} - -export function resolveTextareaChange( - textareaSnapshot: TextareaSnapshot, - value: string, - selectionStart: number, - selectionEnd: number -): ResolvedTextEdit { - const original = textareaSnapshot.text; - const originalLength = original.length; - const nextLength = value.length; - - // When the snapshot still has the pre-edit range, prefer it over prefix/suffix inference. - // Otherwise the diff can shift by one when the same character appears on both sides of - // the removed span (e.g. deleting between two `"` quotes in JSON). - const trustStart = textareaSnapshot.selectionStart; - const trustEnd = textareaSnapshot.selectionEnd; - if (trustStart !== trustEnd) { - const deleteLen = trustEnd - trustStart; - const insLen = nextLength - originalLength + deleteLen; - if ( - insLen >= 0 && - trustStart >= 0 && - trustEnd <= originalLength && - trustStart + insLen <= nextLength - ) { - const inserted = value.slice(trustStart, trustStart + insLen); - if ( - original.slice(0, trustStart) + inserted + original.slice(trustEnd) === - value - ) { - return { - start: textareaSnapshot.offset + trustStart, - end: textareaSnapshot.offset + trustEnd, - text: inserted, - }; - } - } - } - - if ( - selectionStart === selectionEnd && - textareaSnapshot.selectionStart === textareaSnapshot.selectionEnd - ) { - const lengthDelta = nextLength - originalLength; - const start = selectionStart - Math.max(lengthDelta, 0); - const end = start + Math.max(-lengthDelta, 0); - const text = value.slice(start, selectionStart); - if ( - lengthDelta !== 0 && - start >= 0 && - end <= originalLength && - original.slice(0, start) + text + original.slice(end) === value - ) { - return { - start: textareaSnapshot.offset + start, - end: textareaSnapshot.offset + end, - text, - }; - } - } - - let prefix = 0; - while ( - prefix < originalLength && - prefix < nextLength && - original[prefix] === value[prefix] - ) { - prefix++; - } - - let suffix = 0; - while ( - suffix < originalLength - prefix && - suffix < nextLength - prefix && - original[originalLength - 1 - suffix] === value[nextLength - 1 - suffix] - ) { - suffix++; - } - - const originalStart = prefix; - const originalEnd = originalLength - suffix; - - return { - start: textareaSnapshot.offset + originalStart, - end: textareaSnapshot.offset + originalEnd, - text: value.slice(prefix, nextLength - suffix), - }; -} - -export function getSelectionDirectionFromTextarea( - textareaEl: HTMLTextAreaElement -): SelectionDirection { - switch (textareaEl.selectionDirection) { - case 'backward': - return DirectionBackward; - case 'forward': - return DirectionForward; - case 'none': - return DirectionNone; - } -} - -export function toTextareaSelectionDirection( - selection: EditorSelection -): HTMLTextAreaElement['selectionDirection'] { - switch (selection.direction) { - case DirectionBackward: - return 'backward'; - case DirectionForward: - return 'forward'; - case DirectionNone: - return 'none'; - } -} - -// Aligns a column with `TextDocument.offsetAt` / `positionAt` so textarea indices match backing text -// (DOM may report past end for empty lines that render a placeholder space). -function normalizeCharacter( - position: Position, - textDocument: TextDocument -): number { - return textDocument.positionAt(textDocument.offsetAt(position)).character; -} diff --git a/packages/diffs/src/editor/index.ts b/packages/diffs/src/editor/index.ts index d13980583..332c3f703 100644 --- a/packages/diffs/src/editor/index.ts +++ b/packages/diffs/src/editor/index.ts @@ -17,11 +17,12 @@ import { DirectionBackward, DirectionForward, DirectionNone, - extendSelections, + extendSelection, + findNexMatch, + getSelectionTextNode, isCollapsedSelection, mapSelectionMove, mapSelectionRangeMove, - mergeSelections, resolveIndentEdits, selectionIntersects, } from '../editor/editorSelection'; @@ -41,6 +42,7 @@ import { getHighlighterIfLoaded } from '../highlighter/shared_highlighter'; import { areThemesAttached } from '../highlighter/themes/areThemesAttached'; import type { DiffsEditor, + DiffsEditorSelection, DiffsHighlighter, FileContents, HighlightedToken, @@ -54,13 +56,6 @@ import { TOKENIZE_TIME_LIMIT, } from './constants'; import { applyDocumentChangeToLineAnnotations } from './editorLineAnnotations'; -import { - createTextareaSnapshot, - getSelectionDirectionFromTextarea, - resolveTextareaChange, - type TextareaSnapshot, - toTextareaSelectionDirection, -} from './editorTextarea'; import { BackgroundTokenizer, tokenizeLine } from './tokenzier'; export class Editor implements DiffsEditor { @@ -95,15 +90,16 @@ export class Editor implements DiffsEditor { #lastCharX?: [line: number, character: number, x: number, wrapLine: number]; // dom elements - #contentEl?: HTMLElement; - #styleEl?: HTMLStyleElement; - #textareaEl?: HTMLTextAreaElement; - #selectionEls?: Map; + #contentElement?: HTMLElement; + #contentElementDisposes?: (() => void)[]; + #styleElement?: HTMLStyleElement; + #selectionElements?: Map; #measureCtx?: CanvasRenderingContext2D; // state #shouldIgnoreSelectionChange = false; - #textareaSnapshot?: TextareaSnapshot; + #isMouseDown = false; + #shiftKeyPressed = false; #selectionStart: EditorSelection | undefined; #reservedSelections?: EditorSelection[]; #selections?: EditorSelection[]; @@ -163,18 +159,29 @@ export class Editor implements DiffsEditor { return () => this.cleanUp(); } - setSelections(selections: EditorSelection[]): void { + setSelections(selections: DiffsEditorSelection[]): void { const textDocument = this.#textDocument; - // normalize the selections if (textDocument !== undefined) { - selections = selections.map((selection) => { - const start = textDocument.normalizePosition(selection.start); - const end = textDocument.normalizePosition(selection.end); - return { ...selection, start, end }; - }); + const resolvedSelections = selections.map( + (selection) => { + const start = textDocument.normalizePosition(selection.start); + const end = textDocument.normalizePosition(selection.end); + const direction = + selection.direction === 'none' + ? DirectionNone + : selection.direction === 'backward' + ? DirectionBackward + : DirectionForward; + return { direction, start, end }; + } + ); + const primarySelection = resolvedSelections.at(-1); + if (primarySelection === undefined) { + return; + } + this.#updateSelections(resolvedSelections); + this.#focusContentElement(primarySelection); } - this.#appleSelections(selections); - this.#focusTextarea(); } cleanUp(): void { @@ -182,6 +189,7 @@ export class Editor implements DiffsEditor { this.#disposes = undefined; this.#onChange = undefined; + this.#file?.setSelectedLines(null); this.#file?.removeEditor(); this.#file = undefined; this.#fileContents = undefined; @@ -199,18 +207,22 @@ export class Editor implements DiffsEditor { this.#wrapLineOffsetsCache.clear(); this.#lastCharX = undefined; - this.#contentEl = undefined; - this.#styleEl?.remove(); - this.#styleEl = undefined; - this.#textareaEl?.remove(); - this.#textareaEl = undefined; - this.#selectionEls?.forEach((el) => el.remove()); - this.#selectionEls?.clear(); - this.#selectionEls = undefined; + if (this.#contentElement !== undefined) { + this.#contentElement.contentEditable = 'false'; + this.#contentElement.role = null; + this.#contentElement.ariaMultiLine = null; + } + this.#contentElement = undefined; + this.#contentElementDisposes?.forEach((dispose) => dispose()); + this.#contentElementDisposes = undefined; + this.#styleElement?.remove(); + this.#styleElement = undefined; + this.#selectionElements?.forEach((el) => el.remove()); + this.#selectionElements?.clear(); + this.#selectionElements = undefined; this.#measureCtx = undefined; this.#shouldIgnoreSelectionChange = false; - this.#textareaSnapshot = undefined; this.#selectionStart = undefined; this.#selections = undefined; this.#reservedSelections = undefined; @@ -224,14 +236,76 @@ export class Editor implements DiffsEditor { ): void { const shadowRoot = fileContainer.shadowRoot ?? fileContainer.attachShadow({ mode: 'open' }); - this.#contentEl = shadowRoot.querySelector('[data-content]') ?? undefined; - if (this.#contentEl === undefined) { + const contentEl = + shadowRoot.querySelector('div[data-content]') ?? undefined; + if (contentEl === undefined) { throw new Error('Could not edit the file.'); } + if (this.#contentElement !== contentEl) { + this.#contentElement = extend(contentEl, { + contentEditable: 'true', + role: 'textbox', + ariaMultiLine: 'true', + autocapitalize: 'off', + writingSuggestions: 'off', + autocorrect: false, + spellcheck: false, + translate: false, + }); + this.#contentElementDisposes?.forEach((dispose) => dispose()); + this.#contentElementDisposes = [ + addEventListener(contentEl, 'keydown', (e) => { + if (!e.shiftKey) { + this.#selectionStart = undefined; + } + const command = resolveEditorCommandFromKeyboardEvent(e); + if (command !== undefined) { + e.preventDefault(); + this.#runCommand(command); + } + }), + + addEventListener(contentEl, 'copy', (e) => { + e.preventDefault(); + e.clipboardData?.setData('text', this.#getSelectionText()); + }), + + addEventListener(contentEl, 'cut', (e) => { + e.preventDefault(); + e.clipboardData?.setData('text', this.#getSelectionText()); + this.#replaceSelectionText(''); + }), + + addEventListener(contentEl, 'paste', (e) => { + e.preventDefault(); + const text = e.clipboardData?.getData('text'); + if (text !== undefined) { + // TODO(@ije): Add support of multiple selections paste + // TODO(@ije): normalize the pasted text with textDocument.EOF + this.#replaceSelectionText(text); + } + }), + + addEventListener(contentEl, 'beforeinput', (e) => { + e.preventDefault(); + this.#handleInput(e.inputType, e.data); + }), + + addEventListener(contentEl, 'compositionstart', () => { + this.#shouldIgnoreSelectionChange = true; + }), + + addEventListener(contentEl, 'compositionend', (e) => { + this.#shouldIgnoreSelectionChange = false; + this.#handleInput('insertText', e.data); + }), + ]; + } + // measure the font width, line height, and tab size // purge the lineY cache if the line height or line annotations change - const style = getComputedStyle(this.#contentEl); + const style = getComputedStyle(contentEl); const { fontSize, fontFamily, tabSize, lineHeight } = style; let lineHeighPx = 20; if (lineHeight.endsWith('px')) { @@ -270,7 +344,6 @@ export class Editor implements DiffsEditor { ); this.#stateStackCache = undefined; this.#shouldIgnoreSelectionChange = false; - this.#textareaSnapshot = undefined; this.#selections = undefined; this.#reservedSelections = undefined; } @@ -283,17 +356,11 @@ export class Editor implements DiffsEditor { this.#renderRange = renderRange; this.#prebuildStateStackCache(); - if (this.#styleEl !== undefined) { - shadowRoot.appendChild(this.#styleEl); - } - if (this.#textareaEl !== undefined) { - this.#contentEl?.appendChild(this.#textareaEl); + if (this.#styleElement !== undefined) { + shadowRoot.appendChild(this.#styleElement); } - if (this.#selections !== undefined) { - this.#appleSelections(this.#selections); - if (this.#selectionStart === undefined) { - this.#focusTextarea(); - } + if (this.#selections !== undefined && this.#selections.length > 0) { + this.#updateSelections(this.#selections); } if (renderRange !== undefined) { @@ -314,114 +381,132 @@ export class Editor implements DiffsEditor { } #initialize(): void { - const mouseEventDisposes: (() => void)[] = []; - const targetBelongsCodeLine = ( - target?: EventTarget - ): target is HTMLElement => { - if (target === undefined || !(target instanceof HTMLElement)) { - return false; - } - const { tagName, dataset } = target; - return ( - (tagName === 'DIV' && dataset.line !== undefined) || - (tagName === 'SPAN' && dataset.char !== undefined) - ); - }; - - this.#styleEl = createElement('style', { + this.#styleElement = createElement('style', { dataset: 'editorCss', textContent: EDITOR_CSS, }); - this.#textareaEl = extend( - createElement('textarea', { dataset: 'textarea' }), - { - autocapitalize: 'off', - autocomplete: 'off', - autocorrect: false, - spellcheck: false, - wrap: 'off', - } - ); - this.#disposes = [ addEventListener(document, 'selectionchange', () => { - if (!this.#shouldIgnoreSelectionChange) { - this.#onSelectionChange(); + if (this.#shouldIgnoreSelectionChange) { + return; } - }), - addEventListener(document, 'mousedown', (e) => { - const target = e.composedPath()[0]; - if (!targetBelongsCodeLine(target)) { + const shadowRoot = this.#contentElement?.getRootNode(); + if (shadowRoot === undefined || !(shadowRoot instanceof ShadowRoot)) { return; } - if (e.button === 0 && isPrimaryModifier(e)) { - this.#reservedSelections = this.#selections?.map((selection) => ({ - ...selection, - })); - } else { - this.#reservedSelections = undefined; - } + const selectionRaw = document.getSelection(); + const composedRange = selectionRaw?.getComposedRanges({ + shadowRoots: [shadowRoot], + })?.[0]; - // when the user is using the 'Shift' key to create a selection - // hide the textarea element or the selection will be created in the textarea - if (e.shiftKey && this.#textareaEl !== undefined) { - this.#shouldIgnoreSelectionChange = true; - this.#textareaEl.style.visibility = 'hidden'; - requestAnimationFrame(() => { - this.#onSelectionChange(true); - }); + if ( + composedRange === undefined || + !this.#rangeBelongsToEditor(composedRange) + ) { + return; } - if (this.#contentEl !== undefined) { - mouseEventDisposes.push( - // `Selection.getComposedRanges` sets the `startContainer` to the first line element of - // the content element when the mouse leaves the content element. - // Set `shouldIgnoreSelectionChange` to true to avoid the glitch bug. - // TODO(@ije): update the seletion when mouse moving on the gutter. - addEventListener(this.#contentEl, 'mouseleave', () => { - this.#shouldIgnoreSelectionChange = true; - }), - addEventListener(this.#contentEl, 'mouseenter', () => { - this.#shouldIgnoreSelectionChange = false; - }) - ); + let selection = convertSelection(composedRange, DirectionNone); + if (selection === undefined) { + return; } - }), - addEventListener(document, 'mouseup', (e) => { - mouseEventDisposes.forEach((dispose) => dispose()); - mouseEventDisposes.length = 0; - this.#selectionStart = undefined; - this.#reservedSelections = undefined; - if (e.shiftKey && this.#textareaEl !== undefined) { - this.#shouldIgnoreSelectionChange = false; - this.#textareaEl.style.visibility = 'visible'; + if ( + this.#shiftKeyPressed && + this.#selections !== undefined && + this.#selections.length > 0 + ) { + const primarySelection = this.#selections.at(-1)!; + this.#updateSelections([ + extendSelection(primarySelection, selection), + ]); + return; } - this.#focusTextarea(); - }), - addEventListener(this.#textareaEl, 'keydown', (e) => { - const command = resolveEditorCommandFromKeyboardEvent(e); - if (command !== undefined) { - e.preventDefault(); - void this.#runCommand(command); + if (this.#selectionStart === undefined) { + this.#selectionStart = selection; + } else { + selection = createSelectionFrom(this.#selectionStart, selection); + } + if (this.#reservedSelections !== undefined) { + this.#updateSelections([ + ...this.#reservedSelections.filter( + (reservedSelection) => + !selectionIntersects(reservedSelection, selection) + ), + selection, + ]); + } else { + if ( + this.#isMouseDown || + this.#selections === undefined || + this.#selections.length === 0 || + this.#textDocument === undefined + ) { + this.#updateSelections([selection]); + } else { + // The selection change is triggered by the keyboard + // For example, when the user presses the arrow keys, the selection changes. + if (isCollapsedSelection(selection)) { + this.#updateSelections( + mapSelectionMove( + this.#textDocument, + this.#selections, + selection.start + ) + ); + } else { + // shift key is pressed when moving the cursor by + this.#updateSelections( + mapSelectionRangeMove( + this.#textDocument, + this.#selections, + selection + ) + ); + } + } } }), - addEventListener(this.#textareaEl, 'input', () => { - if (this.#shouldIgnoreSelectionChange) { + addEventListener(document, 'mousedown', (e) => { + const target = e.composedPath()[0]; + if (target === undefined || !(target instanceof HTMLElement)) { + return; + } + const { tagName, dataset } = target; + if ( + !( + (tagName === 'DIV' && dataset.line !== undefined) || + (tagName === 'SPAN' && dataset.char !== undefined) + ) + ) { return; } - this.#syncTextareaState(); + this.#isMouseDown = true; + this.#selectionStart = undefined; + if (e.button === 0 && isPrimaryModifier(e)) { + this.#reservedSelections = this.#selections?.map((selection) => ({ + ...selection, + })); + } + if (e.shiftKey) { + window.getSelection()?.empty(); + this.#shiftKeyPressed = true; + } else { + this.#selections = undefined; + } }), - // Chrome-based browsers ignore selectionchange on textarea elements. - addEventListener(this.#textareaEl, 'selectionchange', () => { - this.#onTextareaSelectionChange(); + addEventListener(document, 'mouseup', () => { + this.#isMouseDown = false; + this.#shiftKeyPressed = false; + this.#selectionStart = undefined; + this.#reservedSelections = undefined; }), ]; } @@ -437,8 +522,8 @@ export class Editor implements DiffsEditor { const file = this.#file; const fileContents = this.#fileContents; const textDocument = this.#textDocument; - const contentEl = this.#contentEl; - const gutterEl = this.#contentEl?.previousElementSibling ?? undefined; + const contentEl = this.#contentElement; + const gutterEl = this.#contentElement?.previousElementSibling ?? undefined; if ( highlighter === undefined || file === undefined || @@ -454,10 +539,8 @@ export class Editor implements DiffsEditor { const t = performance.now(); const grammar = highlighter.getLanguage(textDocument.languageId); - const colorMap = { - dark: this.#getThemeColorMap('dark'), - light: this.#getThemeColorMap('light'), - }; + const themeType = this.#getThemeType(); + const colorMap = this.#getThemeColorMap(themeType); const stateStackCache = this.#buildStateStackCache( textDocument, grammar, @@ -547,15 +630,15 @@ export class Editor implements DiffsEditor { if (dirtyLines.has(lineIndex)) { const tokens = dirtyLines.get(lineIndex)!; child.replaceChildren( - ...tokens.map(([char, style, textContent]) => { - if (char === 0 && style === '') { + ...tokens.map(([char, fg, textContent]) => { + if (char === 0 && fg === '') { return document.createTextNode(textContent); } return createElement('span', { dataset: { char: char.toString(), }, - style, + style: `--diffs-token-${themeType}:${fg};`, textContent: textContent, }); }) @@ -570,7 +653,7 @@ export class Editor implements DiffsEditor { for (const lineIndex of dirtyLineIndexes) { const tokens = dirtyLines.get(lineIndex)!; const lineNumber = String(lineIndex + 1); - const contentLineEl = createElement( + createElement( 'div', { dataset: { @@ -578,22 +661,21 @@ export class Editor implements DiffsEditor { lineType: 'context', lineIndex: lineIndex.toString(), }, - children: tokens.map(([char, style, textContent]) => { - if (char === 0 && style === '') { + children: tokens.map(([char, fg, textContent]) => { + if (char === 0 && fg === '') { return document.createTextNode(textContent); } return createElement('span', { dataset: { char: char.toString(), }, - style, + style: `--diffs-token-${themeType}:${fg};`, textContent, }); }), }, contentEl ); - contentEl.insertBefore(contentLineEl, this.#textareaEl ?? null); createElement( 'div', { @@ -639,7 +721,7 @@ export class Editor implements DiffsEditor { } } - file.emitDirtyLines(dirtyLines); + file.emitDirtyLines(themeType, dirtyLines); if (change.lineDelta !== 0) { gutterEl.style.gridRow = 'span ' + gutterEl.children.length; contentEl.style.gridRow = 'span ' + gutterEl.children.length; @@ -652,12 +734,13 @@ export class Editor implements DiffsEditor { grammar, colorMap, textDocument, - onTokenize: (result) => { - file.emitDirtyLines(result.lines); + onTokenize: (lines) => { + file.emitDirtyLines(themeType, lines); }, }); this.#backgroundTokenizer.scheduleTokenize(line, state); }); + // TODO(@ije): should add another background tokenzier for the other theme? } console.log( @@ -670,6 +753,16 @@ export class Editor implements DiffsEditor { ); } + #getThemeType(): 'dark' | 'light' { + const { themeType } = this.#file?.options ?? {}; + if (themeType !== undefined && themeType !== 'system') { + return themeType; + } + return window.matchMedia('(prefers-color-scheme: dark)').matches + ? 'dark' + : 'light'; + } + #getThemeColorMap(themeType: 'dark' | 'light'): string[] { if (this.#highlighter === undefined || this.#file === undefined) { throw new Error('editor not initialized'); @@ -722,213 +815,98 @@ export class Editor implements DiffsEditor { return stateStackCache; } - #syncTextareaState() { - const textDocument = this.#textDocument; - const textareaEl = this.#textareaEl; - const textareaSnapshot = this.#textareaSnapshot; - const selections = this.#selections; - if ( - textDocument === undefined || - textareaEl === undefined || - textareaSnapshot === undefined || - selections === undefined - ) { - return; - } - const { selectionStart, selectionEnd, value } = textareaEl; - - // Text in the textarea has been changed. - if (value !== textareaSnapshot.text) { - const { nextSelections, change } = applyTextChangeToSelections( - textDocument, - selections, - resolveTextareaChange( - textareaSnapshot, - value, - selectionStart, - selectionEnd - ), - this.#lineAnnotations, - this.#tabSize - ); - if (change !== undefined) { - this.#applyChange( - change, - nextSelections, - this.#applyChangeToLineAnnotations(change) - ); - } - return; - } - - // Selection in the textarea changed, but no text change was made. - if (selectionStart === selectionEnd) { - this.#appleSelections( - mapSelectionMove( - textDocument, - selections, - textDocument.positionAt(textareaSnapshot.offset + selectionStart) - ), - false - ); - } else { - const isBackward = - getSelectionDirectionFromTextarea(textareaEl) === DirectionBackward; - const anchorOffset = - textareaSnapshot.offset + (isBackward ? selectionEnd : selectionStart); - const focusOffset = - textareaSnapshot.offset + (isBackward ? selectionStart : selectionEnd); - this.#appleSelections( - mapSelectionRangeMove( - textDocument, - selections, - textDocument.positionAt(anchorOffset), - textDocument.positionAt(focusOffset) - ), - false - ); + #handleInput(inputType: string, data: string | null) { + switch (inputType) { + case 'insertText': + this.#replaceSelectionText(data ?? ''); + break; + case 'deleteContentBackward': + this.#deleteSelectionText(); + break; + case 'deleteContentForward': + this.#deleteSelectionText(true); + break; + case 'insertParagraph': + // TODO(@ije): use document.EOF instead of '\n' + this.#replaceSelectionText('\n'); + break; + default: + console.warn(`[diffs] Unknown input type: ${inputType}`); + break; } } - #focusTextarea(): void { - this.#shouldIgnoreSelectionChange = true; - this.#textareaEl?.focus({ - preventScroll: true, - }); - setTimeout(() => { - this.#shouldIgnoreSelectionChange = false; - }, 0); - } - - #onSelectionChange(append = false) { - const shadowRoot = this.#contentEl?.getRootNode(); - if ( - shadowRoot === undefined || - !(shadowRoot instanceof ShadowRoot) || - shadowRoot.activeElement === null - ) { - return; - } - - // Chrome-based browsers fire document selectionchange when the - // textarea caret moves inside the shadow root. - if (shadowRoot.activeElement === this.#textareaEl) { - this.#onTextareaSelectionChange(); - return; - } - - const selectionRaw = document.getSelection(); - const composedRange = selectionRaw?.getComposedRanges({ - shadowRoots: [shadowRoot], - })?.[0]; - - if ( - composedRange === undefined || - !this.#rangeBelongsToEditor(composedRange) - ) { - return; - } - - let selection = convertSelection(composedRange, DirectionNone); - if (selection === undefined) { + #focusContentElement(selection: EditorSelection) { + if (this.#contentElement === undefined) { return; } - - if (this.#selectionStart === undefined) { - this.#selectionStart = selection; - } else { - selection = createSelectionFrom(this.#selectionStart, selection); - } - - this.#textareaSnapshot = undefined; - - if (append) { - const primarySelection = this.#selections?.at(-1); - if (primarySelection !== undefined) { - const newSelection = mergeSelections(primarySelection, selection); - this.#appleSelections([newSelection]); - } else { - this.#appleSelections([selection]); - } - } else if (this.#reservedSelections !== undefined) { - this.#appleSelections([ - ...this.#reservedSelections.filter( - (reservedSelection) => - !selectionIntersects(reservedSelection, selection) - ), - selection, - ]); - } else { - this.#appleSelections([selection]); - } - } - - #onTextareaSelectionChange() { - const textareaEl = this.#textareaEl; - const textareaSnapshot = this.#textareaSnapshot; - if ( - textareaEl === undefined || - textareaSnapshot === undefined || - this.#shouldIgnoreSelectionChange - ) { + const winSelection = window.getSelection(); + if (winSelection === null) { return; } - - const { selectionStart, selectionEnd } = textareaEl; - if ( - (textareaSnapshot.selectionStart !== selectionStart || - textareaSnapshot.selectionEnd !== selectionEnd) && - textareaSnapshot.text === textareaEl.value - ) { - textareaSnapshot.selectionStart = selectionStart; - textareaSnapshot.selectionEnd = selectionEnd; - this.#syncTextareaState(); + let { start, end } = selection; + if (comparePosition(start, end) > 0) { + [start, end] = [end, start]; } - } - - #updateTextarea(primarySelection: EditorSelection) { - const textDocument = this.#textDocument; - const textareaEl = this.#textareaEl; - if (textDocument === undefined || textareaEl === undefined) { + const startLineElement = this.#getLineElement(start.line); + const endLineElement = this.#getLineElement(end.line); + if (startLineElement === undefined || endLineElement === undefined) { return; } - const textareaSnapshot = createTextareaSnapshot( - textDocument, - primarySelection + const [anchorNode, anchorOffset] = getSelectionTextNode( + startLineElement, + start.character + ); + const [focusNode, focusOffset] = getSelectionTextNode( + endLineElement, + end.character ); this.#shouldIgnoreSelectionChange = true; - this.#textareaSnapshot = textareaSnapshot; - textareaEl.value = textareaSnapshot.text; - textareaEl.style.transform = `translateY(${this.#getLineY(primarySelection.start.line)}px)`; - textareaEl.setSelectionRange( - textareaSnapshot.selectionStart, - textareaSnapshot.selectionEnd, - toTextareaSelectionDirection(primarySelection) + winSelection.setBaseAndExtent( + anchorNode, + anchorOffset, + focusNode, + focusOffset ); + this.#contentElement.focus(); setTimeout(() => { this.#shouldIgnoreSelectionChange = false; }, 0); } - #renderSelections(selections: EditorSelection[]) { - const fragment = document.createDocumentFragment(); - const cacheMap = new Map(); + #updateSelections(selections: EditorSelection[]) { + const primarySelection = selections.at(-1); + if (primarySelection === undefined) { + return; + } + this.#selections = selections; + this.#file?.setSelectedLines(null); + if (isCollapsedSelection(primarySelection)) { + const line = primarySelection.end.line + 1; + this.#file?.setSelectedLines({ + start: line, + end: line, + }); + } + const renderCtx = new Map(); selections.forEach((selection) => { if (selections.length > 1 || !isCollapsedSelection(selection)) { - this.#renderSelection(selection, fragment, cacheMap); + this.#renderSelection(renderCtx, selection); } - this.#renderCaret(selection, fragment, cacheMap); + this.#renderCaret(renderCtx, selection); }); - this.#contentEl?.append(fragment); - this.#selectionEls?.forEach((el) => el.remove()); - this.#selectionEls?.clear(); - this.#selectionEls = cacheMap; + + const fragment = document.createDocumentFragment(); + fragment.append(...renderCtx.values()); + this.#contentElement?.parentElement?.appendChild(fragment); + this.#selectionElements?.forEach((el) => el.remove()); + this.#selectionElements?.clear(); + this.#selectionElements = renderCtx; } #renderSelection( - selection: EditorSelection, - fragment: DocumentFragment, - cacheMap: Map + renderCtx: Map, + selection: EditorSelection ) { if (this.#textDocument === undefined) { return; @@ -951,14 +929,13 @@ export class Editor implements DiffsEditor { const textWidth = 2 * paddingInline + this.#measureTextWidth(lineText); if (textWidth > contentWidth) { this.#renderWrappedSelection( + renderCtx, selection, ln, lineText, startChar, endChar, - paddingInline, - fragment, - cacheMap + paddingInline ); continue; } @@ -967,7 +944,7 @@ export class Editor implements DiffsEditor { let left = 0; let width = 0; if (startChar === endChar && startChar === 0) { - left = this.#charWidth; + left = this.#getGutterLeft() + this.#charWidth; // gutter width + inline padding (1ch) width = ln === end.line ? 0 : this.#charWidth; } else { left = this.#getCharX(ln, startChar)[0]; @@ -975,15 +952,14 @@ export class Editor implements DiffsEditor { endChar === startChar ? 0 : this.#getCharX(ln, endChar)[0] - left; } this.#renderSelectionRange( + renderCtx, selection, ln, 0, startChar, endChar, width, - left, - fragment, - cacheMap + left ); } } @@ -995,14 +971,13 @@ export class Editor implements DiffsEditor { // text. Zero-width slices that fall on intermediate segment boundaries are // skipped to avoid duplicate markers across consecutive visual lines. #renderWrappedSelection( + renderCtx: Map, selection: EditorSelection, line: number, lineText: string, startChar: number, endChar: number, - paddingInline: number, - fragment: DocumentFragment, - cacheMap: Map + paddingInline: number ) { const wrapOffsets = this.#wrapLineText(line); const segmentCount = wrapOffsets.length - 1; @@ -1063,6 +1038,7 @@ export class Editor implements DiffsEditor { } this.#renderSelectionRange( + renderCtx, selection, line, w, @@ -1070,8 +1046,6 @@ export class Editor implements DiffsEditor { wrapEndChar, segmentWidth, segmentLeft, - fragment, - cacheMap, w === lastSegmentIndex ); } @@ -1083,6 +1057,7 @@ export class Editor implements DiffsEditor { // visual segment except the last one, since an intra-line wrap is not a real // newline and shouldn't visually extend past the wrapped content. #renderSelectionRange( + renderCtx: Map, selection: EditorSelection, ln: number, wrapLine: number, @@ -1090,8 +1065,6 @@ export class Editor implements DiffsEditor { endChar: number, width: number, left: number, - fragment: DocumentFragment, - cacheMap: Map, applyEolSpacing = true ) { const spacing = @@ -1102,7 +1075,7 @@ export class Editor implements DiffsEditor { : this.#charWidth; const css = `width:${width + spacing}px;transform:translateY(${this.#getLineY(ln) + wrapLine * this.#lineHeight}px) translateX(${left}px);`; const cacheKey = 'selection-range-' + css; - const selectionEls = this.#selectionEls; + const selectionEls = this.#selectionElements; let rangeEl: HTMLElement | undefined; if (selectionEls?.has(cacheKey) === true) { @@ -1119,26 +1092,17 @@ export class Editor implements DiffsEditor { } } - if (rangeEl === undefined) { - rangeEl = createElement( - 'div', - { - dataset: 'selectionRange', - style: { cssText: css }, - }, - fragment - ); - } else if (rangeEl.parentElement !== this.#contentEl) { - fragment.appendChild(rangeEl); - } + rangeEl ??= createElement('div', { + dataset: 'selectionRange', + style: { cssText: css }, + }); - cacheMap.set(cacheKey, rangeEl); + renderCtx.set(cacheKey, rangeEl); } #renderCaret( - selection: EditorSelection, - fragment: DocumentFragment, - cacheMap: Map + renderCtx: Map, + selection: EditorSelection ) { const { start, end, direction } = selection; const isBackward = direction === DirectionBackward; @@ -1148,53 +1112,20 @@ export class Editor implements DiffsEditor { return; } const [left, wrapLine] = this.#getCharX(line, character); - const caretEl = createElement( - 'div', - { - dataset: 'caret', - style: { - transform: `translateY(${this.#getLineY(line) + wrapLine * this.#lineHeight}px) translateX(${left - 1}px)`, - }, + const caretEl = createElement('div', { + dataset: 'caret', + style: { + transform: `translateY(${this.#getLineY(line) + wrapLine * this.#lineHeight}px) translateX(${left - 1}px)`, }, - fragment - ); - cacheMap.set('caret-' + line + '-' + character, caretEl); + }); + renderCtx.set('caret-' + line + '-' + character, caretEl); } - async #runCommand(command: EditorCommand) { + #runCommand(command: EditorCommand) { switch (command) { case 'selectAll': - this.#appleSelections([this.#getFullSelection()]); - break; - - case 'copy': - case 'cut': - if (this.#selections !== undefined) { - try { - // TODO(@ije): use navigator.clipboard.write() for multiple selections copy - await navigator.clipboard.writeText( - this.#getSelectionText(this.#selections) - ); - } catch { - return; - } - if (command === 'cut') { - this.#replaceSelectionText(''); - } - } - break; - - case 'paste': { - let text: string | string[]; - try { - // TODO(@ije): use navigator.clipboard.read() for multiple segments paste - text = await navigator.clipboard.readText(); - } catch { - return; - } - this.#replaceSelectionText(text); + this.#updateSelections([this.#getFullSelection()]); break; - } case 'extendSelection': { const selections = this.#selections; @@ -1202,10 +1133,9 @@ export class Editor implements DiffsEditor { if (selections === undefined || textDocument === undefined) { break; } - const next = extendSelections(textDocument, selections); + const next = findNexMatch(textDocument, selections); if (next !== undefined) { - this.#appleSelections(next, false); - this.#focusTextarea(); + this.#updateSelections(next); } break; } @@ -1259,8 +1189,10 @@ export class Editor implements DiffsEditor { { const atEnd = command === 'documentEnd'; const anchor = createElement('span'); - const root = this.#contentEl?.getRootNode() as Element | undefined; - this.#appleSelections([this.#getDocumentBoundarySelection(atEnd)]); + const root = this.#contentElement?.getRootNode() as + | Element + | undefined; + this.#updateSelections([this.#getDocumentBoundarySelection(atEnd)]); if (root !== undefined) { if (atEnd) { root.appendChild(anchor); @@ -1326,12 +1258,16 @@ export class Editor implements DiffsEditor { }; } - #getSelectionText(selections: readonly EditorSelection[]): string { + #getSelectionText(): string { const textDocument = this.#textDocument; - if (textDocument === undefined) { + if ( + textDocument === undefined || + this.#selections === undefined || + this.#selections.length === 0 + ) { return ''; } - return [...selections] + return [...this.#selections] .sort((a, b) => { const startOrder = comparePosition(a.start, b.start); if (startOrder !== 0) { @@ -1359,7 +1295,6 @@ export class Editor implements DiffsEditor { if (textDocument == null || primarySelection == null) { return; } - // TODO(@ije): normalize text with textDocument.EOF const lineAnnotations = this.#lineAnnotations; const { nextSelections, change } = Array.isArray(text) && text.length === selections.length @@ -1389,6 +1324,53 @@ export class Editor implements DiffsEditor { } } + #deleteSelectionText(forward: boolean = false) { + const selections = this.#selections; + const textDocument = this.#textDocument; + if (selections === undefined || textDocument === undefined) { + return; + } + + const primarySelection = selections.at(-1); + if (primarySelection === undefined) { + return; + } + + const edit = isCollapsedSelection(primarySelection) + ? (() => { + const offset = textDocument.offsetAt(primarySelection.start); + const nextOffset = forward + ? Math.min(textDocument.getText().length, offset + 1) + : Math.max(0, offset - 1); + return { + start: Math.min(offset, nextOffset), + end: Math.max(offset, nextOffset), + text: '', + }; + })() + : { + start: textDocument.offsetAt(primarySelection.start), + end: textDocument.offsetAt(primarySelection.end), + text: '', + }; + + const { nextSelections, change } = applyTextChangeToSelections( + textDocument, + selections, + edit, + this.#lineAnnotations, + this.#tabSize + ); + + if (change !== undefined) { + this.#applyChange( + change, + nextSelections, + this.#applyChangeToLineAnnotations(change) + ); + } + } + #applyChange( change: TextDocumentChange, selections?: EditorSelection[], @@ -1416,7 +1398,13 @@ export class Editor implements DiffsEditor { this.#selections = selections; this.#rerender(change, lineAnnotations); if (this.#selections !== undefined) { - this.#appleSelections(this.#selections, false); + this.#updateSelections(this.#selections); + // since we prevent the default input event, + // we need to focus the content element manually + const primarySelection = this.#selections.at(-1); + if (primarySelection !== undefined) { + this.#focusContentElement(primarySelection); + } } } @@ -1439,54 +1427,31 @@ export class Editor implements DiffsEditor { return undefined; } - #appleSelections(selections: EditorSelection[], resetTextarea = true): void { - const primarySelection = selections.at(-1); - if (primarySelection === undefined) { - return; - } - if (resetTextarea) { - this.#textareaSnapshot = undefined; - } - this.#file?.setSelectedLines(null); - if (isCollapsedSelection(primarySelection)) { - const line = primarySelection.end.line + 1; - this.#file?.setSelectedLines({ - start: line, - end: line, - }); + #getGutterLeft() { + const diffsColumnNumbertWidth = + this.#contentElement?.parentElement?.style.getPropertyValue( + '--diffs-column-number-width' + ) ?? ''; + if ( + diffsColumnNumbertWidth.length > 2 && + diffsColumnNumbertWidth.endsWith('px') + ) { + return Number(diffsColumnNumbertWidth.slice(0, -2)); } - const shouldUpdateTextarea = - Math.max(0, primarySelection.start.line - 1) !== - this.#textareaSnapshot?.startLine; - this.#selections = selections; - this.#renderSelections(selections); - if (shouldUpdateTextarea) { - this.#updateTextarea(primarySelection); - } else if ( - this.#textareaEl !== undefined && - this.#textDocument !== undefined + const gutterElement = + this.#contentElement?.previousElementSibling ?? undefined; + if ( + gutterElement === undefined || + !gutterElement.hasAttribute('data-gutter') ) { - const nextTextareaSnapshot = createTextareaSnapshot( - this.#textDocument, - primarySelection - ); - const shouldSyncTextarea = - this.#textareaSnapshot === undefined || - nextTextareaSnapshot.text !== this.#textareaEl.value || - nextTextareaSnapshot.selectionStart !== - this.#textareaEl.selectionStart || - nextTextareaSnapshot.selectionEnd !== this.#textareaEl.selectionEnd; - if (shouldSyncTextarea) { - this.#updateTextarea(primarySelection); - } else { - this.#textareaSnapshot = nextTextareaSnapshot; - } + return 0; } + return (gutterElement as HTMLElement).offsetWidth ?? 0; } #getContentWidth() { const diffsColumnContentWidth = - this.#contentEl?.parentElement?.style.getPropertyValue( + this.#contentElement?.parentElement?.style.getPropertyValue( '--diffs-column-content-width' ) ?? ''; if ( @@ -1495,11 +1460,11 @@ export class Editor implements DiffsEditor { ) { return Number(diffsColumnContentWidth.slice(0, -2)); } - return this.#contentEl?.offsetWidth ?? 0; + return this.#contentElement?.offsetWidth ?? 0; } #getLineElement(line: number): HTMLElement | undefined { - const children = this.#contentEl?.children; + const children = this.#contentElement?.children; if (children === undefined) { return undefined; } @@ -1542,9 +1507,9 @@ export class Editor implements DiffsEditor { } const lineText = this.#textDocument?.getLineText(line); - const paddingInline = this.#charWidth; + const offsetLeft = this.#getGutterLeft() + this.#charWidth; // gutter width + inline padding (1ch) if (lineText === undefined || lineText.length === 0 || char <= 0) { - return [paddingInline, 0]; + return [offsetLeft, 0]; } const boundedCharacter = Math.min(char, lineText.length); @@ -1554,14 +1519,14 @@ export class Editor implements DiffsEditor { let left = 0; let wrapLine = 0; if (asciiWidth !== -1) { - left = paddingInline + asciiWidth; + left = offsetLeft + asciiWidth; } else { - left = paddingInline + this.#measureTextWidth(textBeforeCharacter); + left = offsetLeft + this.#measureTextWidth(textBeforeCharacter); } if (this.#wrap) { const contentWidth = this.#getContentWidth(); - const width = 2 * paddingInline + this.#measureTextWidth(lineText); + const width = 2 * offsetLeft + this.#measureTextWidth(lineText); if (width > contentWidth) { const wrapOffsets = this.#wrapLineText(line); for (let w = 0; w + 1 < wrapOffsets.length; w++) { @@ -1576,9 +1541,9 @@ export class Editor implements DiffsEditor { const segmentAsciiWidth = this.#getExpandedAsciiTextWidth(prefixInSegment); if (segmentAsciiWidth !== -1) { - left = paddingInline + segmentAsciiWidth; + left = offsetLeft + segmentAsciiWidth; } else { - left = paddingInline + this.#measureTextWidth(prefixInSegment); + left = offsetLeft + this.#measureTextWidth(prefixInSegment); } break; } @@ -1652,7 +1617,7 @@ export class Editor implements DiffsEditor { }, textContent: lineText, }, - this.#contentEl + this.#contentElement ); const textNode = div.firstChild as Text; const range = document.createRange(); @@ -1731,7 +1696,7 @@ export class Editor implements DiffsEditor { // check if the web selection belongs to editor #rangeBelongsToEditor({ startContainer, endContainer }: StaticRange) { - const contentEl = this.#contentEl; + const contentEl = this.#contentElement; if (contentEl === undefined) { return false; } diff --git a/packages/diffs/src/editor/tokenzier.ts b/packages/diffs/src/editor/tokenzier.ts index 2ae7a6e71..a2233c01b 100644 --- a/packages/diffs/src/editor/tokenzier.ts +++ b/packages/diffs/src/editor/tokenzier.ts @@ -14,22 +14,20 @@ import type { TextDocument } from './textDocument'; export interface BackgroundTokenizerOptions { grammar: IGrammar; - colorMap: { dark: string[]; light: string[] }; + colorMap: string[]; textDocument: TextDocument; - onTokenize: (result: { lines: Map> }) => void; + onTokenize: (lines: Map>) => void; linesPreTokenize?: number; // default to 50 } /** Stoppable background tokenizer */ export class BackgroundTokenizer { #grammar: IGrammar; - #colorMap: { dark: string[]; light: string[] }; + #colorMap: string[]; #textDocument: TextDocument; #messageKey: string; #onMessage: (event: MessageEvent) => void; - #onTokenize: (result: { - lines: Map>; - }) => void; + #onTokenize: (lines: Map>) => void; // state #isStopped: boolean = true; @@ -108,7 +106,7 @@ export class BackgroundTokenizer { state = ret.ruleStack; } - this.#onTokenize({ lines }); + this.#onTokenize(lines); if (line >= totalLines) { this.stop(); return; @@ -122,7 +120,7 @@ export class BackgroundTokenizer { export function tokenizeLine( grammar: IGrammar, - colorMap: { dark: string[]; light: string[] }, + colorMap: string[], lineText: string, stateStack: StateStack, timeLimit?: number @@ -149,11 +147,9 @@ export function tokenizeLine( } const metadata = rawTokens[2 * j + 1]; const bg = EncodedTokenMetadata.getForeground(metadata); - const darkFG = colorMap.dark[bg]; - const lightFG = colorMap.light[bg]; - const cssText = `--diffs-token-dark:${darkFG};--diffs-token-light:${lightFG}`; + const fg = colorMap[bg]; const tokenText = lineText.slice(offset, nextOffset); - resolvedTokens.push([offset, cssText, tokenText]); + resolvedTokens.push([offset, fg, tokenText]); } return { ruleStack: result.ruleStack, diff --git a/packages/diffs/src/managers/InteractionManager.ts b/packages/diffs/src/managers/InteractionManager.ts index 5c6b8580c..e8bcd3e76 100644 --- a/packages/diffs/src/managers/InteractionManager.ts +++ b/packages/diffs/src/managers/InteractionManager.ts @@ -1208,12 +1208,7 @@ export class InteractionManager { const last = Math.max(rowRange.start, rowRange.end); for (const code of codeElements) { const [gutter, content] = code.children; - const len = [...content.children].filter((child) => { - const dataset = (child as HTMLElement).dataset; - return ( - dataset.line !== undefined || dataset.lineAnnotation !== undefined - ); - }).length; + const len = content.children.length; if (len !== gutter.children.length) { throw new Error( 'InteractionManager.renderSelection: gutter and content children dont match, something is wrong' diff --git a/packages/diffs/src/renderers/FileRenderer.ts b/packages/diffs/src/renderers/FileRenderer.ts index d48fe29b6..bd2740aff 100644 --- a/packages/diffs/src/renderers/FileRenderer.ts +++ b/packages/diffs/src/renderers/FileRenderer.ts @@ -193,13 +193,14 @@ export class FileRenderer { } public emitDirtyLines( - dirtyLines: Map> + themeType: 'dark' | 'light', + lines: Map> ): void { const renderCache = this.renderCache; if (renderCache == null || renderCache.result == null) { return; } - for (const [line, tokens] of dirtyLines) { + for (const [line, tokens] of lines) { renderCache.result.code[line] = { type: 'element', tagName: 'div', @@ -208,8 +209,8 @@ export class FileRenderer { 'data-line-type': 'context', 'data-line-index': line, }, - children: tokens.map(([char, style, text]) => { - if (char === 0 && style === '') { + children: tokens.map(([char, fg, text]) => { + if (char === 0 && fg === '') { return { type: 'text', value: text, @@ -220,7 +221,7 @@ export class FileRenderer { tagName: 'span', properties: { 'data-char': char, - style, + style: `--diffs-token-${themeType}:${fg};`, }, children: [ { diff --git a/packages/diffs/src/types.ts b/packages/diffs/src/types.ts index 1fa3920dc..4c2084255 100644 --- a/packages/diffs/src/types.ts +++ b/packages/diffs/src/types.ts @@ -37,7 +37,7 @@ export interface FileContents { export type HighlighterTypes = 'shiki-js' | 'shiki-wasm'; -export type HighlightedToken = [char: number, style: string, text: string]; +export type HighlightedToken = [char: number, fg: string, text: string]; export type { BundledLanguage, @@ -752,3 +752,15 @@ export interface DiffsEditor { ): void; cleanUp(): void; } + +export interface DiffsEditorSelection { + start: { + line: number; + character: number; + }; + end: { + line: number; + character: number; + }; + direction: 'none' | 'backward' | 'forward'; +} diff --git a/packages/diffs/test/editorCommand.test.ts b/packages/diffs/test/editorCommand.test.ts index 15df22c81..7e3e7dc28 100644 --- a/packages/diffs/test/editorCommand.test.ts +++ b/packages/diffs/test/editorCommand.test.ts @@ -60,9 +60,6 @@ function expectShortcuts(platform: string, cases: ShortcutCase[]): void { describe('resolveEditorShortcutCommand', () => { test('uses command shortcuts on macOS', () => { expectShortcuts('MacIntel', [ - { event: { key: 'c', metaKey: true }, expected: 'copy' }, - { event: { key: 'x', metaKey: true }, expected: 'cut' }, - { event: { key: 'v', metaKey: true }, expected: 'paste' }, { event: { key: 'z', metaKey: true }, expected: 'undo' }, { event: { key: 'z', metaKey: true, shiftKey: true }, expected: 'redo' }, { event: { key: 'a', metaKey: true }, expected: 'selectAll' }, @@ -73,9 +70,6 @@ describe('resolveEditorShortcutCommand', () => { test('uses control shortcuts on windows and linux', () => { expectShortcuts('Linux x86_64', [ - { event: { key: 'c', ctrlKey: true }, expected: 'copy' }, - { event: { key: 'x', ctrlKey: true }, expected: 'cut' }, - { event: { key: 'v', ctrlKey: true }, expected: 'paste' }, { event: { key: 'z', ctrlKey: true }, expected: 'undo' }, { event: { key: 'z', ctrlKey: true, shiftKey: true }, expected: 'redo' }, { event: { key: 'y', ctrlKey: true }, expected: 'redo' }, diff --git a/packages/diffs/test/editorSelection.test.ts b/packages/diffs/test/editorSelection.test.ts index e1ed05baf..3ac878e20 100644 --- a/packages/diffs/test/editorSelection.test.ts +++ b/packages/diffs/test/editorSelection.test.ts @@ -8,10 +8,10 @@ import { DirectionForward, DirectionNone, type EditorSelection, - extendSelections, + extendSelection, + findNexMatch, mapSelectionMove, mapSelectionRangeMove, - mergeSelections, selectionIntersects, } from '../src/editor/editorSelection'; import { @@ -367,44 +367,86 @@ describe('selectionIntersects', () => { }); }); -describe('concatSelections', () => { - test('covers both ranges and uses forward direction when a anchor precedes b focus', () => { - const a = createSelection(0, 2, 0, 4, DirectionForward); - const b = createSelection(0, 6, 0, 8, DirectionForward); - expect(mergeSelections(a, b)).toEqual({ - start: { line: 0, character: 2 }, - end: { line: 0, character: 8 }, - direction: DirectionForward, - }); +describe('extendSelection', () => { + test('extends a collapsed selection forward', () => { + expect( + extendSelection( + createSelection(2, 3, 2, 3, DirectionNone), + createSelection(2, 10, 2, 10, DirectionNone) + ) + ).toEqual(createSelection(2, 3, 2, 10, DirectionForward)); }); - test('uses backward direction when a anchor lies after b focus in the document', () => { - const a = createSelection(0, 0, 0, 10, DirectionBackward); - const b = createSelection(0, 4, 0, 6, DirectionForward); - expect(mergeSelections(a, b)).toEqual({ - start: { line: 0, character: 0 }, - end: { line: 0, character: 10 }, - direction: DirectionBackward, - }); + test('extends a collapsed selection backward', () => { + expect( + extendSelection( + createSelection(2, 3, 2, 3, DirectionNone), + createSelection(2, 1, 2, 1, DirectionNone) + ) + ).toEqual(createSelection(2, 1, 2, 3, DirectionBackward)); }); - test('unions ranges when b lies before a in the file and picks backward when a anchor follows b focus', () => { - const a = createSelection(1, 0, 1, 5, DirectionForward); - const b = createSelection(0, 2, 0, 4, DirectionForward); - expect(mergeSelections(a, b)).toEqual({ - start: { line: 0, character: 2 }, - end: { line: 1, character: 5 }, - direction: DirectionBackward, - }); + test('extends forward when shift-click lands after the original anchor', () => { + expect( + extendSelection( + createSelection(2, 3, 2, 8, DirectionForward), + createSelection(2, 10, 2, 10, DirectionNone) + ) + ).toEqual(createSelection(2, 3, 2, 10, DirectionForward)); }); - test('returns a collapsed selection with no direction when both inputs are the same caret', () => { - const caret = createSelection(0, 3, 0, 3, DirectionNone); - expect(mergeSelections(caret, caret)).toEqual({ - start: { line: 0, character: 3 }, - end: { line: 0, character: 3 }, - direction: DirectionNone, - }); + test('left extend spans from target through original end (forward original)', () => { + expect( + extendSelection( + createSelection(2, 3, 2, 8, DirectionForward), + createSelection(2, 1, 2, 1, DirectionNone) + ) + ).toEqual(createSelection(2, 1, 2, 8, DirectionBackward)); + }); + + test('right extend spans from original start through target (backward original)', () => { + expect( + extendSelection( + createSelection(2, 3, 2, 8, DirectionBackward), + createSelection(2, 10, 2, 10, DirectionNone) + ) + ).toEqual(createSelection(2, 3, 2, 10, DirectionForward)); + }); + + test('keeps the original anchored edge when shift-click lands inside the range', () => { + expect( + extendSelection( + createSelection(2, 3, 2, 8, DirectionForward), + createSelection(2, 5, 2, 5, DirectionNone) + ) + ).toEqual(createSelection(2, 3, 2, 5, DirectionForward)); + }); + + test('keeps the backward anchor stable when shift-click lands inside the range', () => { + expect( + extendSelection( + createSelection(2, 3, 2, 8, DirectionBackward), + createSelection(2, 5, 2, 5, DirectionNone) + ) + ).toEqual(createSelection(2, 5, 2, 8, DirectionBackward)); + }); + + test('collapses a forward selection when shift-click lands on its anchor', () => { + expect( + extendSelection( + createSelection(2, 3, 2, 8, DirectionForward), + createSelection(2, 3, 2, 3, DirectionNone) + ) + ).toEqual(createSelection(2, 3, 2, 3, DirectionNone)); + }); + + test('collapses a backward selection when shift-click lands on its anchor', () => { + expect( + extendSelection( + createSelection(2, 3, 2, 8, DirectionBackward), + createSelection(2, 8, 2, 8, DirectionNone) + ) + ).toEqual(createSelection(2, 8, 2, 8, DirectionNone)); }); }); @@ -510,6 +552,56 @@ describe('applyTextChangeToSelections', () => { ]); }); + test('mirrors delete for multiple carets', () => { + const textDocument = new TextDocument('inmemory://1', 'xa\nxb\nxc'); + const selections = [ + createSelection(0, 1, 0, 1), + createSelection(1, 1, 1, 1), + createSelection(2, 1, 2, 1), + ]; + const { nextSelections } = applyTextChangeToSelections( + textDocument, + selections, + { + start: 7, + end: 8, + text: '', + } + ); + + expect(textDocument.getText()).toBe('x\nx\nx'); + expect(nextSelections).toEqual([ + createSelection(0, 1, 0, 1), + createSelection(1, 1, 1, 1), + createSelection(2, 1, 2, 1), + ]); + }); + + test('deletes explicit ranges across multiple selections', () => { + const textDocument = new TextDocument('inmemory://1', 'abc def ghi'); + const selections = [ + createSelection(0, 1, 0, 3), + createSelection(0, 5, 0, 7), + createSelection(0, 9, 0, 11), + ]; + const { nextSelections } = applyTextChangeToSelections( + textDocument, + selections, + { + start: 9, + end: 11, + text: '', + } + ); + + expect(textDocument.getText()).toBe('a d g'); + expect(nextSelections).toEqual([ + createSelection(0, 1, 0, 1), + createSelection(0, 3, 0, 3), + createSelection(0, 5, 0, 5), + ]); + }); + test('coalesces transformed edits that would overlap', () => { const textDocument = new TextDocument('inmemory://1', ' '); const selections = [ @@ -711,8 +803,7 @@ describe('mapSelectionRangeMove', () => { mapSelectionRangeMove( textDocument, selections, - { line: 1, character: 1 }, - { line: 1, character: 3 } + createSelection(1, 1, 1, 3) ) ).toEqual([ createSelection(0, 1, 0, 3, DirectionForward), @@ -731,8 +822,7 @@ describe('mapSelectionRangeMove', () => { mapSelectionRangeMove( textDocument, selections, - { line: 1, character: 2 }, - { line: 1, character: 0 } + createSelection(1, 2, 1, 0) ) ).toEqual([ createSelection(0, 0, 0, 2, DirectionBackward), @@ -764,10 +854,10 @@ describe('applyTextReplaceToSelections', () => { }); }); -describe('computeExtendSelection', () => { +describe('findNextMatch', () => { test('returns undefined for empty selections', () => { const doc = new TextDocument('inmemory://x', 'hello'); - expect(extendSelections(doc, [])).toBeUndefined(); + expect(findNexMatch(doc, [])).toBeUndefined(); }); test('ignores non-collapsed selections with different text', () => { @@ -776,13 +866,13 @@ describe('computeExtendSelection', () => { createSelection(0, 0, 0, 2), createSelection(0, 3, 0, 5), ]; - expect(extendSelections(doc, selections)).toBeUndefined(); + expect(findNexMatch(doc, selections)).toBeUndefined(); }); test('expands a collapsed caret to the surrounding word', () => { const doc = new TextDocument('inmemory://x', "'foobar'"); const caret = createSelection(0, 4, 0, 4); - const next = extendSelections(doc, [caret]); + const next = findNexMatch(doc, [caret]); expect(next).toEqual([ { start: { line: 0, character: 1 }, @@ -795,7 +885,7 @@ describe('computeExtendSelection', () => { test('adds the next matching range when one occurrence is selected', () => { const doc = new TextDocument('inmemory://x', 'foo x foo'); const first = createSelection(0, 0, 0, 3); - const afterFirst = extendSelections(doc, [first]); + const afterFirst = findNexMatch(doc, [first]); expect(afterFirst).toEqual([ first, { @@ -804,13 +894,13 @@ describe('computeExtendSelection', () => { direction: DirectionForward, }, ]); - expect(extendSelections(doc, afterFirst!)).toBeUndefined(); + expect(findNexMatch(doc, afterFirst!)).toBeUndefined(); }); test('wraps to an earlier occurrence after the last match in the file', () => { const doc = new TextDocument('inmemory://x', 'foo bar foo'); const secondFoo = createSelection(0, 8, 0, 11); - const wrapped = extendSelections(doc, [secondFoo]); + const wrapped = findNexMatch(doc, [secondFoo]); expect(wrapped).toEqual([ secondFoo, { @@ -826,7 +916,7 @@ describe('computeExtendSelection', () => { const a = createSelection(0, 0, 0, 2); const b = createSelection(0, 3, 0, 5); const two = [a, b]; - const third = extendSelections(doc, two); + const third = findNexMatch(doc, two); expect(third?.length).toBe(3); expect(third?.[2]).toEqual({ start: { line: 0, character: 6 }, diff --git a/packages/diffs/test/editorTextareaSnapshot.test.ts b/packages/diffs/test/editorTextareaSnapshot.test.ts deleted file mode 100644 index f4ffb54cd..000000000 --- a/packages/diffs/test/editorTextareaSnapshot.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { describe, expect, test } from 'bun:test'; - -import { - DirectionNone, - type EditorSelection, - type SelectionDirection, -} from '../src/editor/editorSelection'; -import { - createTextareaSnapshot, - resolveTextareaChange, -} from '../src/editor/editorTextarea'; -import { TextDocument } from '../src/editor/textDocument'; - -function createSelection( - startLine: number, - startCharacter: number, - endLine: number, - endCharacter: number, - direction: SelectionDirection = DirectionNone -): EditorSelection { - return { - start: { line: startLine, character: startCharacter }, - end: { line: endLine, character: endCharacter }, - direction, - }; -} - -describe('resolveTextChange', () => { - test('uses the caret to resolve Enter before an existing line break', () => { - const textDocument = new TextDocument('inmemory://1', 'foo\nbar'); - const snippet = createTextareaSnapshot( - textDocument, - createSelection(0, 3, 0, 3) - ); - - expect(resolveTextareaChange(snippet, 'foo\n\nbar', 4, 4)).toEqual({ - start: 3, - end: 3, - text: '\n', - }); - }); - - test('uses the caret to resolve Backspace at an empty line start', () => { - const textDocument = new TextDocument('inmemory://1', 'foo\n\nbar'); - const snippet = createTextareaSnapshot( - textDocument, - createSelection(1, 0, 1, 0) - ); - - expect(resolveTextareaChange(snippet, 'foo\nbar', 3, 3)).toEqual({ - start: 3, - end: 4, - text: '', - }); - }); - - test('keeps the textarea selection range when neighbour characters match the diff', () => { - const line0 = ' "a": "catalog:",'; - const line1 = ' "b": "catalog:",'; - const line2 = ' "c": "catalog:",'; - const textDocument = new TextDocument( - 'inmemory://1', - [line0, line1, line2].join('\n') - ); - const snippet = createTextareaSnapshot( - textDocument, - createSelection(1, 4, 1, 38, DirectionNone) - ); - - const deleted = - snippet.text.slice(0, snippet.selectionStart) + - snippet.text.slice(snippet.selectionEnd); - - expect( - resolveTextareaChange( - snippet, - deleted, - snippet.selectionStart, - snippet.selectionStart - ) - ).toEqual({ - start: snippet.offset + snippet.selectionStart, - end: snippet.offset + snippet.selectionEnd, - text: '', - }); - }); - - test('clamps caret column on empty lines so textarea slice matches the document', () => { - const textDocument = new TextDocument('inmemory://1', 'a\n\nb'); - const valid = createTextareaSnapshot( - textDocument, - createSelection(1, 0, 1, 0) - ); - const oversizedColumnFromDomPlaceholder = createTextareaSnapshot( - textDocument, - createSelection(1, 1, 1, 1) - ); - - expect(oversizedColumnFromDomPlaceholder.selectionStart).toBe( - valid.selectionStart - ); - expect(oversizedColumnFromDomPlaceholder.selectionEnd).toBe( - valid.selectionEnd - ); - expect(oversizedColumnFromDomPlaceholder.text).toBe(valid.text); - expect(oversizedColumnFromDomPlaceholder.offset).toBe(valid.offset); - }); -}); From 3fbcba618a729db01b36d985a6f9443dcc6c2be2 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Mon, 11 May 2026 20:15:50 +0800 Subject: [PATCH 120/138] Fix line wrap --- packages/diffs/src/editor/constants.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/diffs/src/editor/constants.ts b/packages/diffs/src/editor/constants.ts index c04393f38..562c18507 100644 --- a/packages/diffs/src/editor/constants.ts +++ b/packages/diffs/src/editor/constants.ts @@ -11,7 +11,8 @@ export const EDITOR_CSS = /* CSS */ ` 50% { opacity: 0; } 100% { opacity: 1; } } - [data-code] { + [data-code], + [data-content] { position: relative; } [data-content] { From 7c979b18e2061fed037aedcfc76e582ca7b0e27f Mon Sep 17 00:00:00 2001 From: Je Xia Date: Mon, 11 May 2026 21:54:51 +0800 Subject: [PATCH 121/138] Fix wrap line --- packages/diffs/src/editor/index.ts | 68 +++++++++--------------------- 1 file changed, 19 insertions(+), 49 deletions(-) diff --git a/packages/diffs/src/editor/index.ts b/packages/diffs/src/editor/index.ts index 332c3f703..2f1e24aae 100644 --- a/packages/diffs/src/editor/index.ts +++ b/packages/diffs/src/editor/index.ts @@ -508,6 +508,17 @@ export class Editor implements DiffsEditor { this.#selectionStart = undefined; this.#reservedSelections = undefined; }), + + addEventListener(document, 'resize', () => { + if (this.#wrap) { + this.#wrapLineOffsetsCache.clear(); + this.#lineYCache.clear(); + this.#lastCharX = undefined; + if (this.#selections !== undefined) { + this.#updateSelections(this.#selections); + } + } + }), ]; } @@ -982,6 +993,7 @@ export class Editor implements DiffsEditor { const wrapOffsets = this.#wrapLineText(line); const segmentCount = wrapOffsets.length - 1; const lastSegmentIndex = segmentCount - 1; + const offsetLeft = this.#getGutterLeft() + paddingInline; for (let w = 0; w < segmentCount; w++) { const segmentStart = wrapOffsets[w]; @@ -1012,14 +1024,14 @@ export class Editor implements DiffsEditor { if (wrapStartChar === 0 && wrapEndChar === 0) { // Empty range pinned to line start (e.g. multi-line selection ending // with end.character === 0). Mirrors the non-wrap path. - segmentLeft = paddingInline; + segmentLeft = offsetLeft; segmentWidth = line === selection.end.line ? 0 : paddingInline; } else { const prefixInSegment = lineText.slice(segmentStart, wrapStartChar); const prefixAsciiWidth = this.#getExpandedAsciiTextWidth(prefixInSegment); segmentLeft = - paddingInline + + offsetLeft + (prefixAsciiWidth !== -1 ? prefixAsciiWidth : this.#measureTextWidth(prefixInSegment)); @@ -1612,8 +1624,9 @@ export class Editor implements DiffsEditor { pointerEvents: 'none', whiteSpace: 'pre-wrap', wordBreak: 'break-word', - paddingInline: '1ch', font: 'inherit', + paddingInline: '1ch', + tabSize: this.#tabSize.toString(), }, textContent: lineText, }, @@ -1622,11 +1635,8 @@ export class Editor implements DiffsEditor { const textNode = div.firstChild as Text; const range = document.createRange(); const starts: number[] = []; - const ends: number[] = []; - const hasNonWhitespace: boolean[] = []; try { - let currentHasNonWhitespace = false; let lastTop = Number.NEGATIVE_INFINITY; for (let i = 0; i < lineText.length; i++) { @@ -1637,56 +1647,16 @@ export class Editor implements DiffsEditor { // below the previous character's top edge. const { top } = range.getBoundingClientRect(); if (top > lastTop) { - if (starts.length > 0) { - ends.push(i); - hasNonWhitespace.push(currentHasNonWhitespace); - } starts.push(i); - currentHasNonWhitespace = false; lastTop = top; } - - const ch = lineText.charAt(i); - if (ch !== ' ' && ch !== '\t') { - currentHasNonWhitespace = true; - } } - ends.push(lineText.length); - hasNonWhitespace.push(currentHasNonWhitespace); - - // The browser treats leading indentation before an unbreakable token as - // its own visual line (the indentation sits on line N, the broken word - // begins on line N+1). For wrap-line accounting we want the indentation - // to stay attached to the content it precedes, so merge any - // whitespace-only line into the line that follows it. - const mergedStarts: number[] = []; - const mergedEnds: number[] = []; - const mergedWhitespaceOnly: boolean[] = []; + const offsets = new Uint32Array(starts.length + 1); for (let i = 0; i < starts.length; i++) { - const start = starts[i]; - const end = ends[i]; - const isWhitespaceOnly = !hasNonWhitespace[i] && end > start; - - const prevIndex = mergedStarts.length - 1; - if (prevIndex >= 0) { - if (mergedWhitespaceOnly[prevIndex] === true) { - mergedEnds[prevIndex] = end; - mergedWhitespaceOnly[prevIndex] = isWhitespaceOnly; - continue; - } - } - - mergedStarts.push(start); - mergedEnds.push(end); - mergedWhitespaceOnly.push(isWhitespaceOnly); - } - - const offsets = new Uint32Array(mergedStarts.length + 1); - for (let i = 0; i < mergedStarts.length; i++) { - offsets[i] = mergedStarts[i]!; + offsets[i] = starts[i]!; } - offsets[mergedStarts.length] = lineText.length; + offsets[starts.length] = lineText.length; this.#wrapLineOffsetsCache.set(line, offsets); return offsets; } finally { From 60a98b602c900f6e1fef326692733a51cd6b6fd2 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Mon, 11 May 2026 22:15:12 +0800 Subject: [PATCH 122/138] Fix selection on mobile --- packages/diffs/src/editor/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/diffs/src/editor/index.ts b/packages/diffs/src/editor/index.ts index 2f1e24aae..c4b069ad9 100644 --- a/packages/diffs/src/editor/index.ts +++ b/packages/diffs/src/editor/index.ts @@ -426,10 +426,10 @@ export class Editor implements DiffsEditor { return; } - if (this.#selectionStart === undefined) { - this.#selectionStart = selection; - } else { + if (this.#selectionStart !== undefined) { selection = createSelectionFrom(this.#selectionStart, selection); + } else if (this.#isMouseDown) { + this.#selectionStart = selection; } if (this.#reservedSelections !== undefined) { this.#updateSelections([ From 583adb38b3df0bdc1924d06511e386d048b1dec4 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Mon, 11 May 2026 22:55:05 +0800 Subject: [PATCH 123/138] Update editor style --- packages/diffs/src/editor/constants.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/diffs/src/editor/constants.ts b/packages/diffs/src/editor/constants.ts index 562c18507..0626a88d9 100644 --- a/packages/diffs/src/editor/constants.ts +++ b/packages/diffs/src/editor/constants.ts @@ -16,9 +16,14 @@ export const EDITOR_CSS = /* CSS */ ` position: relative; } [data-content] { - caret-color: transparent; + caret-color: var(--diffs-bg-caret); outline: none; } + @media (min-width: 480px) { + [data-content] { + caret-color: transparent; + } + } [data-line] { cursor: text; } @@ -46,8 +51,10 @@ export const EDITOR_CSS = /* CSS */ ` background-color: var(--diffs-bg-selection); opacity: 0.5; } - [data-content]:focus ~ [data-caret] { - visibility: visible; + @media (min-width: 480px) { + [data-content]:focus ~ [data-caret] { + visibility: visible; + } } [data-content]:focus ~ [data-selection-range] { opacity: 1; From 942cba9ac058ffa83986c1d97bd2f08ad718ed01 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Mon, 11 May 2026 22:55:35 +0800 Subject: [PATCH 124/138] Fix resize handling --- packages/diffs/src/editor/index.ts | 42 +++++++++++++++++++++++------- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/packages/diffs/src/editor/index.ts b/packages/diffs/src/editor/index.ts index c4b069ad9..d4f1bb74e 100644 --- a/packages/diffs/src/editor/index.ts +++ b/packages/diffs/src/editor/index.ts @@ -95,6 +95,8 @@ export class Editor implements DiffsEditor { #styleElement?: HTMLStyleElement; #selectionElements?: Map; #measureCtx?: CanvasRenderingContext2D; + #contentResizeObserver?: ResizeObserver; + #lastContentWidth = -1; // state #shouldIgnoreSelectionChange = false; @@ -221,6 +223,9 @@ export class Editor implements DiffsEditor { this.#selectionElements?.clear(); this.#selectionElements = undefined; this.#measureCtx = undefined; + this.#contentResizeObserver?.disconnect(); + this.#contentResizeObserver = undefined; + this.#lastContentWidth = -1; this.#shouldIgnoreSelectionChange = false; this.#selectionStart = undefined; @@ -301,6 +306,15 @@ export class Editor implements DiffsEditor { this.#handleInput('insertText', e.data); }), ]; + + this.#contentResizeObserver?.disconnect(); + this.#contentResizeObserver = new ResizeObserver(() => { + this.#handleLayoutResize(); + }); + this.#contentResizeObserver.observe(contentEl); + if (contentEl.parentElement !== null) { + this.#contentResizeObserver.observe(contentEl.parentElement); + } } // measure the font width, line height, and tab size @@ -319,6 +333,7 @@ export class Editor implements DiffsEditor { this.#lineHeight = lineHeighPx; this.#tabSize = Number(tabSize); this.#wrap = this.#file?.options.overflow === 'wrap'; + this.#lastContentWidth = this.#getContentWidth(); this.#measureCtx ??= document.createElement('canvas').getContext('2d') ?? undefined; const font = fontSize + ' ' + fontFamily; @@ -509,19 +524,28 @@ export class Editor implements DiffsEditor { this.#reservedSelections = undefined; }), - addEventListener(document, 'resize', () => { - if (this.#wrap) { - this.#wrapLineOffsetsCache.clear(); - this.#lineYCache.clear(); - this.#lastCharX = undefined; - if (this.#selections !== undefined) { - this.#updateSelections(this.#selections); - } - } + addEventListener(window, 'resize', () => { + this.#handleLayoutResize(); }), ]; } + #handleLayoutResize() { + const contentWidth = this.#getContentWidth(); + const widthChanged = contentWidth !== this.#lastContentWidth; + this.#lastContentWidth = contentWidth; + + this.#lineYCache.clear(); + this.#lastCharX = undefined; + if (this.#wrap && widthChanged) { + this.#wrapLineOffsetsCache.clear(); + } + + if (this.#selections !== undefined) { + this.#updateSelections(this.#selections); + } + } + #rerender( change: TextDocumentChange, nextLineAnnotations?: LineAnnotation[] | undefined From 2095c1d38c2debb02f2ef712a45744d8c0e62559 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Tue, 12 May 2026 14:39:44 +0800 Subject: [PATCH 125/138] Add editor overlay layer --- packages/diffs/src/editor/constants.ts | 7 +- packages/diffs/src/editor/index.ts | 112 ++++++++++++++++--------- 2 files changed, 79 insertions(+), 40 deletions(-) diff --git a/packages/diffs/src/editor/constants.ts b/packages/diffs/src/editor/constants.ts index 0626a88d9..25a59ccc5 100644 --- a/packages/diffs/src/editor/constants.ts +++ b/packages/diffs/src/editor/constants.ts @@ -51,12 +51,15 @@ export const EDITOR_CSS = /* CSS */ ` background-color: var(--diffs-bg-selection); opacity: 0.5; } + [data-editor-overlay] { + display: contents; + } @media (min-width: 480px) { - [data-content]:focus ~ [data-caret] { + [data-content]:focus ~ [data-editor-overlay] [data-caret] { visibility: visible; } } - [data-content]:focus ~ [data-selection-range] { + [data-content]:focus ~ [data-editor-overlay] [data-selection-range] { opacity: 1; } `; diff --git a/packages/diffs/src/editor/index.ts b/packages/diffs/src/editor/index.ts index d4f1bb74e..f95be2e01 100644 --- a/packages/diffs/src/editor/index.ts +++ b/packages/diffs/src/editor/index.ts @@ -79,7 +79,8 @@ export class Editor implements DiffsEditor { // highlighter #highlighter?: DiffsHighlighter; - #colorMap?: Map; + #currentTheme?: string; + #colorMap?: string[]; #renderRange?: RenderRange; #backgroundTokenizer?: BackgroundTokenizer; @@ -90,9 +91,11 @@ export class Editor implements DiffsEditor { #lastCharX?: [line: number, character: number, x: number, wrapLine: number]; // dom elements + #fileContainer?: HTMLElement; #contentElement?: HTMLElement; #contentElementDisposes?: (() => void)[]; #styleElement?: HTMLStyleElement; + #overlayElement?: HTMLElement; #selectionElements?: Map; #measureCtx?: CanvasRenderingContext2D; #contentResizeObserver?: ResizeObserver; @@ -199,6 +202,7 @@ export class Editor implements DiffsEditor { this.#textDocument = undefined; this.#highlighter = undefined; + this.#currentTheme = undefined; this.#colorMap = undefined; this.#renderRange = undefined; this.#backgroundTokenizer?.stop(); @@ -219,6 +223,8 @@ export class Editor implements DiffsEditor { this.#contentElementDisposes = undefined; this.#styleElement?.remove(); this.#styleElement = undefined; + this.#overlayElement?.remove(); + this.#overlayElement = undefined; this.#selectionElements?.forEach((el) => el.remove()); this.#selectionElements?.clear(); this.#selectionElements = undefined; @@ -247,6 +253,13 @@ export class Editor implements DiffsEditor { throw new Error('Could not edit the file.'); } + if (this.#fileContainer !== fileContainer) { + this.#fileContainer = fileContainer; + if (this.#styleElement !== undefined) { + shadowRoot.appendChild(this.#styleElement); + } + } + if (this.#contentElement !== contentEl) { this.#contentElement = extend(contentEl, { contentEditable: 'true', @@ -258,6 +271,9 @@ export class Editor implements DiffsEditor { spellcheck: false, translate: false, }); + if (this.#overlayElement !== undefined) { + contentEl.after(this.#overlayElement); + } this.#contentElementDisposes?.forEach((dispose) => dispose()); this.#contentElementDisposes = [ addEventListener(contentEl, 'keydown', (e) => { @@ -371,9 +387,6 @@ export class Editor implements DiffsEditor { this.#renderRange = renderRange; this.#prebuildStateStackCache(); - if (this.#styleElement !== undefined) { - shadowRoot.appendChild(this.#styleElement); - } if (this.#selections !== undefined && this.#selections.length > 0) { this.#updateSelections(this.#selections); } @@ -401,6 +414,10 @@ export class Editor implements DiffsEditor { textContent: EDITOR_CSS, }); + this.#overlayElement = createElement('div', { + dataset: 'editorOverlay', + }); + this.#disposes = [ addEventListener(document, 'selectionchange', () => { if (this.#shouldIgnoreSelectionChange) { @@ -534,15 +551,13 @@ export class Editor implements DiffsEditor { const contentWidth = this.#getContentWidth(); const widthChanged = contentWidth !== this.#lastContentWidth; this.#lastContentWidth = contentWidth; - - this.#lineYCache.clear(); - this.#lastCharX = undefined; if (this.#wrap && widthChanged) { + this.#lineYCache.clear(); + this.#lastCharX = undefined; this.#wrapLineOffsetsCache.clear(); - } - - if (this.#selections !== undefined) { - this.#updateSelections(this.#selections); + if (this.#selections !== undefined) { + this.#updateSelections(this.#selections); + } } } @@ -696,6 +711,7 @@ export class Editor implements DiffsEditor { lineType: 'context', lineIndex: lineIndex.toString(), }, + // oxlint-disable-next-line react/no-children-prop children: tokens.map(([char, fg, textContent]) => { if (char === 0 && fg === '') { return document.createTextNode(textContent); @@ -719,6 +735,7 @@ export class Editor implements DiffsEditor { columnNumber: lineNumber, lineIndex: lineIndex.toString(), }, + // oxlint-disable-next-line react/no-children-prop children: [ createElement('span', { dataset: { @@ -809,14 +826,12 @@ export class Editor implements DiffsEditor { } else { themeName = theme[themeType]; } - this.#colorMap ??= new Map(); - let colors = this.#colorMap.get(themeName); - if (colors === undefined) { + if (this.#currentTheme !== themeName || this.#colorMap === undefined) { const ret = this.#highlighter.setTheme(themeName); - colors = ret.colorMap; - this.#colorMap.set(themeName, ret.colorMap ?? []); + this.#colorMap = ret.colorMap; + this.#currentTheme = themeName; } - return colors; + return this.#colorMap; } #buildStateStackCache( @@ -923,24 +938,28 @@ export class Editor implements DiffsEditor { end: line, }); } - const renderCtx = new Map(); + const fragment = document.createDocumentFragment(); + const renderCtx = { + fragment, + elements: new Map(), + }; selections.forEach((selection) => { if (selections.length > 1 || !isCollapsedSelection(selection)) { this.#renderSelection(renderCtx, selection); } this.#renderCaret(renderCtx, selection); }); - - const fragment = document.createDocumentFragment(); - fragment.append(...renderCtx.values()); - this.#contentElement?.parentElement?.appendChild(fragment); + this.#overlayElement?.appendChild(fragment); this.#selectionElements?.forEach((el) => el.remove()); this.#selectionElements?.clear(); - this.#selectionElements = renderCtx; + this.#selectionElements = renderCtx.elements; } #renderSelection( - renderCtx: Map, + renderCtx: { + fragment: DocumentFragment; + elements: Map; + }, selection: EditorSelection ) { if (this.#textDocument === undefined) { @@ -1006,7 +1025,10 @@ export class Editor implements DiffsEditor { // text. Zero-width slices that fall on intermediate segment boundaries are // skipped to avoid duplicate markers across consecutive visual lines. #renderWrappedSelection( - renderCtx: Map, + renderCtx: { + fragment: DocumentFragment; + elements: Map; + }, selection: EditorSelection, line: number, lineText: string, @@ -1093,7 +1115,10 @@ export class Editor implements DiffsEditor { // visual segment except the last one, since an intra-line wrap is not a real // newline and shouldn't visually extend past the wrapped content. #renderSelectionRange( - renderCtx: Map, + renderCtx: { + fragment: DocumentFragment; + elements: Map; + }, selection: EditorSelection, ln: number, wrapLine: number, @@ -1128,16 +1153,23 @@ export class Editor implements DiffsEditor { } } - rangeEl ??= createElement('div', { - dataset: 'selectionRange', - style: { cssText: css }, - }); + rangeEl ??= createElement( + 'div', + { + dataset: 'selectionRange', + style: { cssText: css }, + }, + renderCtx.fragment + ); - renderCtx.set(cacheKey, rangeEl); + renderCtx.elements.set(cacheKey, rangeEl); } #renderCaret( - renderCtx: Map, + renderCtx: { + fragment: DocumentFragment; + elements: Map; + }, selection: EditorSelection ) { const { start, end, direction } = selection; @@ -1148,13 +1180,17 @@ export class Editor implements DiffsEditor { return; } const [left, wrapLine] = this.#getCharX(line, character); - const caretEl = createElement('div', { - dataset: 'caret', - style: { - transform: `translateY(${this.#getLineY(line) + wrapLine * this.#lineHeight}px) translateX(${left - 1}px)`, + const caretEl = createElement( + 'div', + { + dataset: 'caret', + style: { + transform: `translateY(${this.#getLineY(line) + wrapLine * this.#lineHeight}px) translateX(${left - 1}px)`, + }, }, - }); - renderCtx.set('caret-' + line + '-' + character, caretEl); + renderCtx.fragment + ); + renderCtx.elements.set('caret-' + line + '-' + character, caretEl); } #runCommand(command: EditorCommand) { From 3ce394adcd45c852b81038d2178c1244731ef88f Mon Sep 17 00:00:00 2001 From: Je Xia Date: Tue, 12 May 2026 14:43:36 +0800 Subject: [PATCH 126/138] Cleanup --- packages/diffs/src/editor/constants.ts | 4 ++-- packages/diffs/src/editor/tokenzier.ts | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/diffs/src/editor/constants.ts b/packages/diffs/src/editor/constants.ts index 25a59ccc5..7b6077955 100644 --- a/packages/diffs/src/editor/constants.ts +++ b/packages/diffs/src/editor/constants.ts @@ -1,6 +1,6 @@ -export const TOKENIZE_TIME_LIMIT = 500; +export const TOKENIZE_TIME_LIMIT = 100; export const TOKENIZE_MAX_LINE_LENGTH = 10000; -export const TOKENIZE_LINES_PRE_TOKENIZE = 50; +export const BGTOKENIZER_LINES_PRE_TOKENIZE = 50; export const EDITOR_CSS = /* CSS */ ` ::selection { diff --git a/packages/diffs/src/editor/tokenzier.ts b/packages/diffs/src/editor/tokenzier.ts index a2233c01b..0b71d854d 100644 --- a/packages/diffs/src/editor/tokenzier.ts +++ b/packages/diffs/src/editor/tokenzier.ts @@ -6,7 +6,7 @@ import { import type { HighlightedToken } from '../types'; import { - TOKENIZE_LINES_PRE_TOKENIZE, + BGTOKENIZER_LINES_PRE_TOKENIZE, TOKENIZE_MAX_LINE_LENGTH, TOKENIZE_TIME_LIMIT, } from './constants'; @@ -16,8 +16,8 @@ export interface BackgroundTokenizerOptions { grammar: IGrammar; colorMap: string[]; textDocument: TextDocument; + linesPreTokenize?: number; onTokenize: (lines: Map>) => void; - linesPreTokenize?: number; // default to 50 } /** Stoppable background tokenizer */ @@ -69,7 +69,7 @@ export class BackgroundTokenizer { this.#lastState = null; } - #doTokenize(linesPreTokenize: number = TOKENIZE_LINES_PRE_TOKENIZE): void { + #doTokenize(linesPreTokenize: number = BGTOKENIZER_LINES_PRE_TOKENIZE): void { if (this.#isStopped || this.#lastState === null) { return; } From a8b674dce5e7dbc1c3f7928b5b45827a3d8aeff1 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Tue, 12 May 2026 16:33:59 +0800 Subject: [PATCH 127/138] Add `DiffsEditableComponent` types --- packages/diffs/src/components/File.ts | 9 ++++++--- packages/diffs/src/editor/index.ts | 27 ++++++++++++++------------- packages/diffs/src/types.ts | 17 ++++++++++++++++- 3 files changed, 36 insertions(+), 17 deletions(-) diff --git a/packages/diffs/src/components/File.ts b/packages/diffs/src/components/File.ts index 2eb836a67..f3c9d3db6 100644 --- a/packages/diffs/src/components/File.ts +++ b/packages/diffs/src/components/File.ts @@ -24,6 +24,7 @@ import { SVGSpriteSheet } from '../sprite'; import type { AppliedThemeStyleCache, BaseCodeOptions, + DiffsEditableComponent, DiffsEditor, FileContents, HighlightedToken, @@ -116,7 +117,9 @@ interface HydrationSetup { let instanceId = -1; -export class File { +export class File< + LAnnotation = undefined, +> implements DiffsEditableComponent { static LoadedCustomComponent: boolean = DiffsContainerLoaded; readonly __id: string = `file:${++instanceId}`; @@ -177,7 +180,7 @@ export class File { public setEditor(editor: DiffsEditor): void { this.editor?.cleanUp(); if (this.fileContainer != null && this.file != null) { - editor.syncFile( + editor.emitRender( this.fileContainer, this.file, this.lineAnnotations, @@ -549,7 +552,7 @@ export class File { this.resizeManager.setup(pre, overflow === 'wrap'); this.renderAnnotations(); this.renderGutterUtility(); - this.editor?.syncFile( + this.editor?.emitRender( fileContainer, file, this.lineAnnotations, diff --git a/packages/diffs/src/editor/index.ts b/packages/diffs/src/editor/index.ts index f95be2e01..adeaec62f 100644 --- a/packages/diffs/src/editor/index.ts +++ b/packages/diffs/src/editor/index.ts @@ -41,6 +41,7 @@ import { import { getHighlighterIfLoaded } from '../highlighter/shared_highlighter'; import { areThemesAttached } from '../highlighter/themes/areThemesAttached'; import type { + DiffsEditableComponent, DiffsEditor, DiffsEditorSelection, DiffsHighlighter, @@ -72,7 +73,7 @@ export class Editor implements DiffsEditor { #wrap = false; // file - #file?: File; + #component?: DiffsEditableComponent; #fileContents?: FileContents; #lineAnnotations?: LineAnnotation[]; #textDocument?: TextDocument; @@ -151,7 +152,7 @@ export class Editor implements DiffsEditor { lineAnnotations?: LineAnnotation[] ) => void ): () => void { - this.#file = file; + this.#component = file; this.#wrap = file.options.overflow === 'wrap'; this.#highlighter ??= areThemesAttached( file.options.theme ?? DEFAULT_THEMES @@ -194,9 +195,9 @@ export class Editor implements DiffsEditor { this.#disposes = undefined; this.#onChange = undefined; - this.#file?.setSelectedLines(null); - this.#file?.removeEditor(); - this.#file = undefined; + this.#component?.setSelectedLines(null); + this.#component?.removeEditor(); + this.#component = undefined; this.#fileContents = undefined; this.#lineAnnotations = undefined; this.#textDocument = undefined; @@ -239,7 +240,7 @@ export class Editor implements DiffsEditor { this.#reservedSelections = undefined; } - syncFile( + emitRender( fileContainer: HTMLElement, fileContents: FileContents, lineAnnotations: LineAnnotation[] | undefined, @@ -348,7 +349,7 @@ export class Editor implements DiffsEditor { this.#lastCharX = undefined; this.#lineHeight = lineHeighPx; this.#tabSize = Number(tabSize); - this.#wrap = this.#file?.options.overflow === 'wrap'; + this.#wrap = this.#component?.options.overflow === 'wrap'; this.#lastContentWidth = this.#getContentWidth(); this.#measureCtx ??= document.createElement('canvas').getContext('2d') ?? undefined; @@ -569,7 +570,7 @@ export class Editor implements DiffsEditor { this.#backgroundTokenizer?.stop(); const highlighter = this.#highlighter; - const file = this.#file; + const file = this.#component; const fileContents = this.#fileContents; const textDocument = this.#textDocument; const contentEl = this.#contentElement; @@ -806,7 +807,7 @@ export class Editor implements DiffsEditor { } #getThemeType(): 'dark' | 'light' { - const { themeType } = this.#file?.options ?? {}; + const { themeType } = this.#component?.options ?? {}; if (themeType !== undefined && themeType !== 'system') { return themeType; } @@ -816,11 +817,11 @@ export class Editor implements DiffsEditor { } #getThemeColorMap(themeType: 'dark' | 'light'): string[] { - if (this.#highlighter === undefined || this.#file === undefined) { + if (this.#highlighter === undefined || this.#component === undefined) { throw new Error('editor not initialized'); } let themeName: string; - const { theme = DEFAULT_THEMES } = this.#file.options; + const { theme = DEFAULT_THEMES } = this.#component.options; if (typeof theme === 'string') { themeName = theme; } else { @@ -930,10 +931,10 @@ export class Editor implements DiffsEditor { return; } this.#selections = selections; - this.#file?.setSelectedLines(null); + this.#component?.setSelectedLines(null); if (isCollapsedSelection(primarySelection)) { const line = primarySelection.end.line + 1; - this.#file?.setSelectedLines({ + this.#component?.setSelectedLines({ start: line, end: line, }); diff --git a/packages/diffs/src/types.ts b/packages/diffs/src/types.ts index 4c2084255..461e4cde9 100644 --- a/packages/diffs/src/types.ts +++ b/packages/diffs/src/types.ts @@ -744,7 +744,7 @@ export interface AppliedThemeStyleCache { } export interface DiffsEditor { - syncFile( + emitRender( fileContainer: HTMLElement, fileContents: FileContents, lineAnnotations: LineAnnotation[] | undefined, @@ -753,6 +753,21 @@ export interface DiffsEditor { cleanUp(): void; } +export interface DiffsEditableComponent { + readonly options: BaseCodeOptions; + setSelectedLines: (range: { start: number; end: number } | null) => void; + emitDirtyLines: ( + themeType: 'dark' | 'light', + lines: Map> + ) => void; + emitLineCountChange: ( + lineCount: number, + newLineAnnotations?: LineAnnotation[] + ) => void; + removeEditor(): void; + cleanUp(): void; +} + export interface DiffsEditorSelection { start: { line: number; From 1f3a448d489ff093d4b009d30f96ce31e7890141 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Tue, 12 May 2026 16:55:03 +0800 Subject: [PATCH 128/138] Fix `VirtualizedFile` component --- packages/diffs/src/components/VirtualizedFile.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/diffs/src/components/VirtualizedFile.ts b/packages/diffs/src/components/VirtualizedFile.ts index 61b073a23..7971cf9e0 100644 --- a/packages/diffs/src/components/VirtualizedFile.ts +++ b/packages/diffs/src/components/VirtualizedFile.ts @@ -285,7 +285,7 @@ export class VirtualizedFile< windowSpecs ); return super.render({ - file: this.file, + file, fileContainer, renderRange, ...props, From c18a37344a8fda7181be76320f9d6ac859e9c723 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Tue, 12 May 2026 17:44:34 +0800 Subject: [PATCH 129/138] Update `DiffsEditableComponent` type --- packages/diffs/src/editor/index.ts | 39 +++++++++++++++++++++--------- packages/diffs/src/types.ts | 3 +++ 2 files changed, 30 insertions(+), 12 deletions(-) diff --git a/packages/diffs/src/editor/index.ts b/packages/diffs/src/editor/index.ts index adeaec62f..5e9e249d9 100644 --- a/packages/diffs/src/editor/index.ts +++ b/packages/diffs/src/editor/index.ts @@ -146,22 +146,23 @@ export class Editor implements DiffsEditor { ); edit( - file: File, + component: DiffsEditableComponent, onChange?: ( file: FileContents, lineAnnotations?: LineAnnotation[] ) => void ): () => void { - this.#component = file; - this.#wrap = file.options.overflow === 'wrap'; - this.#highlighter ??= areThemesAttached( - file.options.theme ?? DEFAULT_THEMES - ) - ? getHighlighterIfLoaded() - : undefined; + this.#component = component; this.#onChange = onChange; this.#initialize(); - file.setEditor(this); + if (component.options.useTokenTransformer !== true) { + // Tell the component to use token transformer that adds + // `data-char` attribute to the tokens + component.options.useTokenTransformer = true; + component.setOptions(component.options); + component.rerender(); + } + component.setEditor(this); return () => this.cleanUp(); } @@ -254,6 +255,13 @@ export class Editor implements DiffsEditor { throw new Error('Could not edit the file.'); } + this.#wrap = this.#component?.options.overflow === 'wrap'; + this.#highlighter ??= areThemesAttached( + this.#component?.options.theme ?? DEFAULT_THEMES + ) + ? getHighlighterIfLoaded() + : undefined; + if (this.#fileContainer !== fileContainer) { this.#fileContainer = fileContainer; if (this.#styleElement !== undefined) { @@ -365,8 +373,9 @@ export class Editor implements DiffsEditor { if ( this.#textDocument === undefined || this.#fileContents === undefined || - this.#fileContents.contents !== fileContents.contents || - this.#fileContents.lang !== fileContents.lang + this.#fileContents.name !== fileContents.name || + this.#fileContents.lang !== fileContents.lang || + this.#fileContents.contents !== fileContents.contents ) { this.#fileContents = fileContents; this.#textDocument = new TextDocument( @@ -376,6 +385,9 @@ export class Editor implements DiffsEditor { ); this.#stateStackCache = undefined; this.#shouldIgnoreSelectionChange = false; + this.#selectionElements?.forEach((el) => el.remove()); + this.#selectionElements?.clear(); + this.#selectionElements = undefined; this.#selections = undefined; this.#reservedSelections = undefined; } @@ -394,7 +406,8 @@ export class Editor implements DiffsEditor { if (renderRange !== undefined) { console.log( - '[diffs]', + '[diffs] render file:', + fileContents.name, 'RenderRange:', renderRange.startingLine + '-' + @@ -575,6 +588,8 @@ export class Editor implements DiffsEditor { const textDocument = this.#textDocument; const contentEl = this.#contentElement; const gutterEl = this.#contentElement?.previousElementSibling ?? undefined; + + console.log('rerender', highlighter); if ( highlighter === undefined || file === undefined || diff --git a/packages/diffs/src/types.ts b/packages/diffs/src/types.ts index 461e4cde9..d2dfa23e3 100644 --- a/packages/diffs/src/types.ts +++ b/packages/diffs/src/types.ts @@ -755,6 +755,7 @@ export interface DiffsEditor { export interface DiffsEditableComponent { readonly options: BaseCodeOptions; + setOptions: (options: Partial) => void; setSelectedLines: (range: { start: number; end: number } | null) => void; emitDirtyLines: ( themeType: 'dark' | 'light', @@ -764,7 +765,9 @@ export interface DiffsEditableComponent { lineCount: number, newLineAnnotations?: LineAnnotation[] ) => void; + setEditor: (editor: DiffsEditor) => void; removeEditor(): void; + rerender(): void; cleanUp(): void; } From 0d9ea911175cff16325da07ac1716ddb3e10e331 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Tue, 12 May 2026 17:45:33 +0800 Subject: [PATCH 130/138] Add editor demo --- apps/demo/editor.html | 25 ++++++++ apps/demo/package.json | 4 +- apps/demo/src/editor.ts | 61 ++++++++++++++++++ apps/demo/src/style.css | 23 +++++++ apps/demo/vite.config.ts | 129 ++++++++++++++++++++++++++++++++++++++- bun.lock | 5 +- 6 files changed, 243 insertions(+), 4 deletions(-) create mode 100644 apps/demo/editor.html create mode 100644 apps/demo/src/editor.ts diff --git a/apps/demo/editor.html b/apps/demo/editor.html new file mode 100644 index 000000000..e4dc9b4ec --- /dev/null +++ b/apps/demo/editor.html @@ -0,0 +1,25 @@ + + + + + + + PierreJS R&D + + +
+
+
+
+

@pierre/diffs

+
+
+
+ +
+ + + diff --git a/apps/demo/package.json b/apps/demo/package.json index 163412f4e..3fbb9292e 100644 --- a/apps/demo/package.json +++ b/apps/demo/package.json @@ -8,8 +8,9 @@ "build:deps": "bun run build:deps:diffs", "build:deps:diffs": "output=$(cd ../../packages/diffs && bun run build 2>&1) && echo '[diffs] Successfully cleaned and built.' || (echo \"$output\" >&2 && exit 1)", "build-types": "bun run build:deps && tsgo --build", - "dev": "bun run build:deps && concurrently \"bun run dev:deps:diffs\" \"bun run dev:vite\" --names \"diffs,vite\" --prefix-colors \"blue,green\"", + "dev": "bun run build:deps && concurrently \"bun run dev:deps:diffs\" \"bun run dev:deps:trees\" \"bun run dev:vite\" --names \"diffs,trees,vite\" --prefix-colors \"blue,green,yellow\"", "dev:deps:diffs": "(cd ../../packages/diffs && bun run dev)", + "dev:deps:trees": "(cd ../../packages/trees && bun run dev)", "dev:vite": "vite --host --clearScreen=false", "preview": "vite preview", "start": "vite preview", @@ -18,6 +19,7 @@ }, "dependencies": { "@pierre/diffs": "workspace:*", + "@pierre/trees": "workspace:*", "react": "catalog:", "react-dom": "catalog:", "shiki": "catalog:" diff --git a/apps/demo/src/editor.ts b/apps/demo/src/editor.ts new file mode 100644 index 000000000..1ec29a23b --- /dev/null +++ b/apps/demo/src/editor.ts @@ -0,0 +1,61 @@ +import { + Editor, + type FileContents, + VirtualizedFile, + Virtualizer, +} from '@pierre/diffs'; +import { FileTree } from '@pierre/trees'; + +import './style.css'; + +const API = { + paths: () => + fetch('/fs/packages/diffs').then( + (res) => res.json() as unknown as string[] + ), + renderFile: (path: string) => + fetch(`/fs/packages/diffs/${path}`).then((res) => res.text()), + writeFile: (path: string, contents: string) => + fetch(`/fs/packages/diffs/${path}`, { method: 'POST', body: contents }), +}; + +const fileTreeContainer = document.getElementById('file-tree-container')!; +const editorContainer = document.getElementById('editor-container')!; +const editor = new Editor(); +const virtualizer = new Virtualizer(); +const fileInstance = new VirtualizedFile({}, virtualizer); +const fileTree = new FileTree({ + paths: await API.paths(), + search: true, + onSelectionChange: (selectedPaths) => { + if (selectedPaths.length === 1) { + const filename = selectedPaths[0]; + if (!filename.endsWith('/')) { + void openDocument(filename); + } + } + }, + unsafeCSS: /* CSS */ ` + :host { + --trees-bg-override: transparent; + } + `, +}); + +async function openDocument(filename: string) { + const file: FileContents = { + name: filename, + contents: await API.renderFile(filename), + }; + editorContainer.scrollTo({ left: 0, top: 0 }); + fileInstance.render({ + file, + containerWrapper: editorContainer, + }); +} + +virtualizer.setup(editorContainer); +editor.edit(fileInstance, (file) => { + console.log('edit', file); +}); +fileTree.render({ fileTreeContainer }); diff --git a/apps/demo/src/style.css b/apps/demo/src/style.css index fdf80dc2b..d32e80d56 100644 --- a/apps/demo/src/style.css +++ b/apps/demo/src/style.css @@ -240,3 +240,26 @@ diffs-container { align-items: center; gap: 4px; } + +#editor { + display: grid; + grid-template-columns: 1fr 280px ; + grid-template-rows: 1fr; + gap: 10px; + background-color: light-dark(white, black); + height: 100vh; + width: 100vw; + overflow: hidden; +} + +#editor[data-show-sidebar] { + grid-template-columns: 280px 1fr 280px; +} + +#file-tree { + background-color: light-dark(#f6f6f6, #1a1a1a); +} + +#editor-container { + overflow-y: auto; +} diff --git a/apps/demo/vite.config.ts b/apps/demo/vite.config.ts index c4526a4ec..0f7e92e83 100644 --- a/apps/demo/vite.config.ts +++ b/apps/demo/vite.config.ts @@ -2,9 +2,13 @@ import react from '@vitejs/plugin-react'; import fs from 'fs'; import type { IncomingMessage, ServerResponse } from 'http'; import path, { resolve } from 'path'; +import { Readable } from 'stream'; +import { ReadableStream } from 'stream/web'; import type { Plugin, PreviewServer, ViteDevServer } from 'vite'; import { createLogger, defineConfig, type Logger } from 'vite'; +const projectDir = resolve(__dirname, '../../'); + function escapeRegExp(s: string) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } @@ -48,6 +52,27 @@ function makeFilteredLogger(folder: string): Logger { }; } +function readProjectDirSync(dir: string, basePath: string = dir): string[] { + const fullPath = path.join(projectDir, dir); + const entries = fs.readdirSync(fullPath, { withFileTypes: true }); + return entries + .map((entry) => { + if ( + entry.name.startsWith('.') || + entry.name === 'dist' || + entry.name === 'node_modules' + ) { + return []; + } + if (entry.isDirectory()) { + return readProjectDirSync(path.join(dir, entry.name), basePath); + } + const relPath = path.join(dir, entry.name); + return path.relative(basePath, relPath); + }) + .flat(Infinity) as string[]; +} + export default defineConfig(() => { const htmlPlugin = (): Plugin => ({ name: 'html-fallback', @@ -105,8 +130,110 @@ export default defineConfig(() => { }, }); + const editorDevPlugin = (): Plugin => ({ + name: 'dev-fs', + configureServer(server: ViteDevServer) { + const handleRoutes = async ( + req: IncomingMessage, + res: ServerResponse, + next: () => void + ) => { + if (req.url === '/editor') { + const htmlPath = resolve(__dirname, 'editor.html'); + try { + const htmlContent = fs.readFileSync(htmlPath, 'utf-8'); + const html = await server.transformIndexHtml( + '/editor', + htmlContent + ); + res.setHeader('Content-Type', 'text/html'); + res.end(html); + return; + } catch (e) { + res.writeHead(500, { 'Content-Type': 'text/plain' }); + res.end( + +'Error transforming HTML:' + + (e instanceof Error ? e.message : String(e)) + ); + } + } + + // mock fs API + if (req.url?.startsWith('/fs/') === true) { + const reqPath = req.url.slice(4); + try { + switch (req.method) { + case 'GET': + { + const stat = fs.lstatSync(path.join(projectDir, reqPath)); + if (stat.isDirectory()) { + const enties = readProjectDirSync(reqPath); + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify(enties)); + } else { + const stream = fs.createReadStream( + path.join(projectDir, reqPath) + ); + res.setHeader('Content-Type', 'text/plain'); + for await (const chunk of stream) { + res.write(chunk); + } + res.end(); + } + } + break; + + case 'POST': + { + const stream = new ReadableStream({ + start(controller) { + req.on('data', (chunk) => { + controller.enqueue(chunk); + }); + req.on('end', () => { + controller.close(); + }); + }, + }); + const writer = fs.createWriteStream( + path.join(projectDir, reqPath) + ); + Readable.fromWeb(stream).pipe(writer); + res.setHeader('Content-Type', 'text/plain'); + res.end('File created'); + } + break; + + case 'DELETE': + { + fs.unlinkSync(path.join(projectDir, reqPath)); + res.setHeader('Content-Type', 'text/plain'); + res.end('File deleted'); + } + break; + + default: { + res.writeHead(405, { 'Content-Type': 'text/plain' }); + res.end('Method not allowed'); + } + } + } catch (e) { + res.writeHead(500, { 'Content-Type': 'text/plain' }); + res.end(e instanceof Error ? e.message : String(e)); + } + return; + } + + next(); + }; + + // oxlint-disable-next-line typescript/no-misused-promises + server.middlewares.use('/', handleRoutes); + }, + }); + return { - plugins: [react(), htmlPlugin()], + plugins: [react(), htmlPlugin(), editorDevPlugin()], customLogger: makeFilteredLogger('packages/diffs'), build: { rollupOptions: { diff --git a/bun.lock b/bun.lock index 750096bc1..5dfa85e7b 100644 --- a/bun.lock +++ b/bun.lock @@ -28,6 +28,7 @@ "version": "0.0.0", "dependencies": { "@pierre/diffs": "workspace:*", + "@pierre/trees": "workspace:*", "react": "catalog:", "react-dom": "catalog:", "shiki": "catalog:", @@ -104,7 +105,7 @@ }, "packages/diffs": { "name": "@pierre/diffs", - "version": "1.1.16", + "version": "1.1.17", "dependencies": { "@pierre/theme": "catalog:", "@shikijs/transformers": "^3.0.0", @@ -191,7 +192,7 @@ }, "packages/trees": { "name": "@pierre/trees", - "version": "1.0.0-beta.1", + "version": "1.0.0-beta.3", "dependencies": { "@pierre/path-store": "workspace:*", "preact": "catalog:", From b1c50ef48bd37c14682ef02f0274bf2bf517483d Mon Sep 17 00:00:00 2001 From: Je Xia Date: Tue, 12 May 2026 18:39:48 +0800 Subject: [PATCH 131/138] Fix slection rendering --- packages/diffs/src/editor/constants.ts | 3 ++- packages/diffs/src/style.css | 8 ++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/diffs/src/editor/constants.ts b/packages/diffs/src/editor/constants.ts index 7b6077955..9eb20fae0 100644 --- a/packages/diffs/src/editor/constants.ts +++ b/packages/diffs/src/editor/constants.ts @@ -16,6 +16,7 @@ export const EDITOR_CSS = /* CSS */ ` position: relative; } [data-content] { + background-color: transparent; caret-color: var(--diffs-bg-caret); outline: none; } @@ -48,7 +49,7 @@ export const EDITOR_CSS = /* CSS */ ` [data-selection-range] { height: 1lh; z-index: -10; - background-color: var(--diffs-bg-selection); + background-color: var(--diffs-line-bg); opacity: 0.5; } [data-editor-overlay] { diff --git a/packages/diffs/src/style.css b/packages/diffs/src/style.css index ef97a758f..a6d59b20e 100644 --- a/packages/diffs/src/style.css +++ b/packages/diffs/src/style.css @@ -328,7 +328,8 @@ [data-line-annotation], [data-no-newline], [data-merge-conflict], - [data-merge-conflict-actions] { + [data-merge-conflict-actions], + [data-selection-range] { /* Pre-fill css variables for appropriate up-mixing */ --diffs-computed-decoration-bg: var(--diffs-bg); --diffs-computed-diff-line-bg: var(--diffs-bg); @@ -678,12 +679,14 @@ [data-line-annotation], [data-merge-conflict], [data-merge-conflict-actions], - [data-no-newline] { + [data-no-newline], + [data-selection-range] { --diffs-selection-mix-target: var( --diffs-bg-selection-override, var(--diffs-selection-base) ); + &:where([data-selection-range]), &:where( [data-line], [data-line-annotation], @@ -726,6 +729,7 @@ } } + &:where([data-selection-range]), &[data-selected-line] { --diffs-computed-selected-line-bg: light-dark( color-mix( From c9ef7e880c455d8f4b883b9893abfafa16310e33 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Tue, 12 May 2026 20:06:22 +0800 Subject: [PATCH 132/138] Update editor demo app --- apps/demo/editor.html | 13 +++- apps/demo/src/editor.ts | 46 ++++++++++---- apps/demo/src/style.css | 23 ++++++- apps/demo/vite.config.ts | 131 +++++++++++++++++++++++++++++++++++++-- 4 files changed, 193 insertions(+), 20 deletions(-) diff --git a/apps/demo/editor.html b/apps/demo/editor.html index e4dc9b4ec..7da86e128 100644 --- a/apps/demo/editor.html +++ b/apps/demo/editor.html @@ -11,13 +11,22 @@
-
-

@pierre/diffs

+

+ + + + + + + + @pierre/diffs +

+
diff --git a/apps/demo/src/editor.ts b/apps/demo/src/editor.ts index 1ec29a23b..74550cd4b 100644 --- a/apps/demo/src/editor.ts +++ b/apps/demo/src/editor.ts @@ -4,17 +4,28 @@ import { VirtualizedFile, Virtualizer, } from '@pierre/diffs'; -import { FileTree } from '@pierre/trees'; +import { FileTree, type GitStatusEntry } from '@pierre/trees'; import './style.css'; const API = { - paths: () => + // get git status + getGitStatus: () => + fetch(`/git-status/packages/diffs`).then( + (res) => res.json() as unknown as GitStatusEntry[] + ), + + // get paths + getPaths: () => fetch('/fs/packages/diffs').then( (res) => res.json() as unknown as string[] ), - renderFile: (path: string) => + + // read file from disk + readFile: (path: string) => fetch(`/fs/packages/diffs/${path}`).then((res) => res.text()), + + // write file to disk writeFile: (path: string, contents: string) => fetch(`/fs/packages/diffs/${path}`, { method: 'POST', body: contents }), }; @@ -23,9 +34,25 @@ const fileTreeContainer = document.getElementById('file-tree-container')!; const editorContainer = document.getElementById('editor-container')!; const editor = new Editor(); const virtualizer = new Virtualizer(); -const fileInstance = new VirtualizedFile({}, virtualizer); +const fileInstance = new VirtualizedFile( + { + unsafeCSS: /* CSS */ ` + [data-diffs-header] { + position: sticky; + top: 0; + z-index: 100; + } + `, + }, + virtualizer +); +const [paths, gitStatus] = await Promise.all([ + API.getPaths(), + API.getGitStatus(), +]); const fileTree = new FileTree({ - paths: await API.paths(), + paths, + gitStatus, search: true, onSelectionChange: (selectedPaths) => { if (selectedPaths.length === 1) { @@ -35,23 +62,18 @@ const fileTree = new FileTree({ } } }, - unsafeCSS: /* CSS */ ` - :host { - --trees-bg-override: transparent; - } - `, }); async function openDocument(filename: string) { const file: FileContents = { name: filename, - contents: await API.renderFile(filename), + contents: await API.readFile(filename), }; - editorContainer.scrollTo({ left: 0, top: 0 }); fileInstance.render({ file, containerWrapper: editorContainer, }); + editorContainer.scrollTo({ left: 0, top: 0 }); } virtualizer.setup(editorContainer); diff --git a/apps/demo/src/style.css b/apps/demo/src/style.css index d32e80d56..984db9070 100644 --- a/apps/demo/src/style.css +++ b/apps/demo/src/style.css @@ -243,7 +243,7 @@ diffs-container { #editor { display: grid; - grid-template-columns: 1fr 280px ; + grid-template-columns: 280px 1fr; grid-template-rows: 1fr; gap: 10px; background-color: light-dark(white, black); @@ -257,9 +257,28 @@ diffs-container { } #file-tree { - background-color: light-dark(#f6f6f6, #1a1a1a); + background-color: light-dark(#f8f8f8, #141415); } +#file-tree h1 { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + padding: 16px; +} + +#file-tree h1 svg { + width: 20px; + height: 20px; +} + + #editor-container { overflow-y: auto; + overscroll-behavior: none; +} + +#editor-container diffs-container { + margin-top: 0; } diff --git a/apps/demo/vite.config.ts b/apps/demo/vite.config.ts index 0f7e92e83..48d5c4024 100644 --- a/apps/demo/vite.config.ts +++ b/apps/demo/vite.config.ts @@ -1,6 +1,8 @@ +import type { GitStatus, GitStatusEntry } from '@pierre/trees'; import react from '@vitejs/plugin-react'; import fs from 'fs'; import type { IncomingMessage, ServerResponse } from 'http'; +import { execFileSync } from 'node:child_process'; import path, { resolve } from 'path'; import { Readable } from 'stream'; import { ReadableStream } from 'stream/web'; @@ -73,6 +75,91 @@ function readProjectDirSync(dir: string, basePath: string = dir): string[] { .flat(Infinity) as string[]; } +function unquoteGitPath(segment: string): string { + if (segment.length >= 2 && segment.startsWith('"') && segment.endsWith('"')) { + return segment.slice(1, -1).replace(/\\(.)/g, '$1'); + } + return segment; +} + +function getGitStatus(repoRoot: string, pathspec: string): GitStatusEntry[] { + const args = ['-C', repoRoot, 'status', '--porcelain=v1', '-uall']; + if (pathspec.length > 0) { + args.push('--', pathspec); + } + const out = execFileSync('git', args, { + encoding: 'utf8', + maxBuffer: 50 * 1024 * 1024, + }); + const entries: GitStatusEntry[] = []; + for (const rawLine of out.split('\n')) { + const line = rawLine.replace(/\r$/, ''); + if (line.length === 0) { + continue; + } + if (line.startsWith('??')) { + const p = unquoteGitPath(line.slice(3).trimStart()); + if (p.length > 0) { + entries.push({ path: p, status: 'untracked' }); + } + continue; + } + if (line.length < 4 || line[2] !== ' ') { + continue; + } + const x = line[0]; + const y = line[1]; + let rest = line.slice(3); + const renameSep = ' -> '; + const renameIdx = rest.includes(renameSep) + ? rest.lastIndexOf(renameSep) + : -1; + if (renameIdx >= 0 && (x === 'R' || y === 'R' || x === 'C' || y === 'C')) { + const newPath = unquoteGitPath( + rest.slice(renameIdx + renameSep.length).trim() + ); + if (newPath.length > 0) { + entries.push({ path: newPath, status: 'renamed' }); + } + continue; + } + rest = rest.trimEnd(); + let filePath = unquoteGitPath(rest); + if (filePath.length === 0) { + continue; + } + if (filePath.startsWith(pathspec + '/')) { + filePath = filePath.slice(pathspec.length + 1); + } + const letter = + y !== ' ' && y !== '.' ? y : x !== ' ' && x !== '.' ? x : null; + let status: GitStatus | null = null; + switch (letter) { + case 'M': + status = 'modified'; + break; + case 'A': + status = 'added'; + break; + case 'D': + status = 'deleted'; + break; + case 'R': + case 'C': + status = 'renamed'; + break; + case 'U': + case 'T': + status = 'modified'; + break; + } + if (status != null) { + entries.push({ path: filePath, status }); + } + } + return entries; +} + export default defineConfig(() => { const htmlPlugin = (): Plugin => ({ name: 'html-fallback', @@ -131,7 +218,7 @@ export default defineConfig(() => { }); const editorDevPlugin = (): Plugin => ({ - name: 'dev-fs', + name: 'editor-dev', configureServer(server: ViteDevServer) { const handleRoutes = async ( req: IncomingMessage, @@ -158,9 +245,45 @@ export default defineConfig(() => { } } - // mock fs API - if (req.url?.startsWith('/fs/') === true) { - const reqPath = req.url.slice(4); + const pathname = req.url?.split('?')[0] ?? ''; + if (pathname === '/git-status' || pathname.startsWith('/git-status/')) { + if (req.method !== 'GET') { + res.writeHead(405, { 'Content-Type': 'text/plain' }); + res.end('Method not allowed'); + return; + } + try { + const encoded = + pathname === '/git-status' + ? '' + : pathname.slice('/git-status/'.length); + const rel = decodeURIComponent(encoded); + const absTarget = path.resolve(projectDir, rel); + const rootResolved = path.resolve(projectDir); + const isUnderRoot = + absTarget === rootResolved || + absTarget.startsWith(rootResolved + path.sep); + if (isUnderRoot !== true) { + res.writeHead(403, { 'Content-Type': 'text/plain' }); + res.end('Path outside repository root'); + return; + } + const pathspec = rel.split(path.sep).join('/'); + const entries: GitStatusEntry[] = getGitStatus( + projectDir, + pathspec + ); + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify(entries)); + } catch (e) { + res.writeHead(500, { 'Content-Type': 'text/plain' }); + res.end(e instanceof Error ? e.message : String(e)); + } + return; + } + + if (pathname.startsWith('/fs/')) { + const reqPath = pathname.slice(4); try { switch (req.method) { case 'GET': From 6dea24aa7fbddfaa3207ec7cada2e8f00000f3fa Mon Sep 17 00:00:00 2001 From: Je Xia Date: Tue, 12 May 2026 20:06:48 +0800 Subject: [PATCH 133/138] Fix VirualizedFile component --- packages/diffs/src/components/VirtualizedFile.ts | 14 +++++++++++++- packages/diffs/src/editor/index.ts | 2 -- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/diffs/src/components/VirtualizedFile.ts b/packages/diffs/src/components/VirtualizedFile.ts index 82767dc73..678c201d0 100644 --- a/packages/diffs/src/components/VirtualizedFile.ts +++ b/packages/diffs/src/components/VirtualizedFile.ts @@ -6,6 +6,7 @@ import type { RenderWindow, VirtualFileMetrics, } from '../types'; +import { areFilesEqual } from '../utils/areFilesEqual'; import type { WorkerPoolManager } from '../worker'; import { File, type FileOptions, type FileRenderProps } from './File'; import type { Virtualizer } from './Virtualizer'; @@ -252,8 +253,9 @@ export class VirtualizedFile< ...props }: FileRenderProps): boolean { const { isSetup } = this; + const fileChanged = this.file == null || !areFilesEqual(this.file, file); - this.file ??= file; + this.file = file; fileContainer = this.getOrCreateFileContainerNode(fileContainer); @@ -275,6 +277,16 @@ export class VirtualizedFile< this.isSetup = true; } else { this.top ??= this.virtualizer.getOffsetInScrollContainer(fileContainer); + if (fileChanged) { + this.heightCache.clear(); + this.computeApproximateSize(); + this.renderRange = undefined; + this.virtualizer.instanceChanged(this); + this.isVisible = this.virtualizer.isInstanceVisible( + this.top, + this.height + ); + } } if (!this.isVisible) { diff --git a/packages/diffs/src/editor/index.ts b/packages/diffs/src/editor/index.ts index 5e9e249d9..76424b0b0 100644 --- a/packages/diffs/src/editor/index.ts +++ b/packages/diffs/src/editor/index.ts @@ -588,8 +588,6 @@ export class Editor implements DiffsEditor { const textDocument = this.#textDocument; const contentEl = this.#contentElement; const gutterEl = this.#contentElement?.previousElementSibling ?? undefined; - - console.log('rerender', highlighter); if ( highlighter === undefined || file === undefined || From 8140e1553505baec7f9c8419208c6156ba2e9fec Mon Sep 17 00:00:00 2001 From: Je Xia Date: Tue, 12 May 2026 22:02:13 +0800 Subject: [PATCH 134/138] Update editor demo app --- apps/demo/editor.html | 21 +++++++++++++++++---- apps/demo/package.json | 2 +- apps/demo/src/editor.ts | 21 ++++++++++++--------- apps/demo/src/style.css | 1 - apps/demo/vite.config.ts | 3 +++ 5 files changed, 33 insertions(+), 15 deletions(-) diff --git a/apps/demo/editor.html b/apps/demo/editor.html index 7da86e128..9bcc39df2 100644 --- a/apps/demo/editor.html +++ b/apps/demo/editor.html @@ -14,11 +14,24 @@

- - + + - - + + @pierre/diffs diff --git a/apps/demo/package.json b/apps/demo/package.json index 3fbb9292e..90c03ebf5 100644 --- a/apps/demo/package.json +++ b/apps/demo/package.json @@ -8,7 +8,7 @@ "build:deps": "bun run build:deps:diffs", "build:deps:diffs": "output=$(cd ../../packages/diffs && bun run build 2>&1) && echo '[diffs] Successfully cleaned and built.' || (echo \"$output\" >&2 && exit 1)", "build-types": "bun run build:deps && tsgo --build", - "dev": "bun run build:deps && concurrently \"bun run dev:deps:diffs\" \"bun run dev:deps:trees\" \"bun run dev:vite\" --names \"diffs,trees,vite\" --prefix-colors \"blue,green,yellow\"", + "dev": "bun run build:deps && concurrently \"bun run dev:deps:diffs\" \"bun run dev:deps:trees\" \"bun run dev:vite\" --names \"diffs,trees,vite\" --prefix-colors \"blue,green,gray\"", "dev:deps:diffs": "(cd ../../packages/diffs && bun run dev)", "dev:deps:trees": "(cd ../../packages/trees && bun run dev)", "dev:vite": "vite --host --clearScreen=false", diff --git a/apps/demo/src/editor.ts b/apps/demo/src/editor.ts index 74550cd4b..8b910dee2 100644 --- a/apps/demo/src/editor.ts +++ b/apps/demo/src/editor.ts @@ -37,12 +37,12 @@ const virtualizer = new Virtualizer(); const fileInstance = new VirtualizedFile( { unsafeCSS: /* CSS */ ` - [data-diffs-header] { - position: sticky; - top: 0; - z-index: 100; - } - `, + [data-diffs-header] { + position: sticky; + top: 0; + z-index: 100; + } + `, }, virtualizer ); @@ -76,8 +76,11 @@ async function openDocument(filename: string) { editorContainer.scrollTo({ left: 0, top: 0 }); } +async function onFileChange(file: FileContents) { + await API.writeFile(file.name, file.contents); + fileTree.setGitStatus(await API.getGitStatus()); +} + virtualizer.setup(editorContainer); -editor.edit(fileInstance, (file) => { - console.log('edit', file); -}); fileTree.render({ fileTreeContainer }); +editor.edit(fileInstance, () => void onFileChange); diff --git a/apps/demo/src/style.css b/apps/demo/src/style.css index 984db9070..3ab681857 100644 --- a/apps/demo/src/style.css +++ b/apps/demo/src/style.css @@ -273,7 +273,6 @@ diffs-container { height: 20px; } - #editor-container { overflow-y: auto; overscroll-behavior: none; diff --git a/apps/demo/vite.config.ts b/apps/demo/vite.config.ts index 48d5c4024..2b7143805 100644 --- a/apps/demo/vite.config.ts +++ b/apps/demo/vite.config.ts @@ -365,5 +365,8 @@ export default defineConfig(() => { }, }, }, + server: { + hmr: !process.env.NO_HMR, + }, }; }); From b273533a928c1006e5fd069f69bc68847b088e5c Mon Sep 17 00:00:00 2001 From: Je Xia Date: Wed, 13 May 2026 19:53:31 +0800 Subject: [PATCH 135/138] Fix some selection bugs --- .../diffs/src/components/VirtualizedFile.ts | 2 + packages/diffs/src/editor/constants.ts | 8 +- packages/diffs/src/editor/editorCommand.ts | 38 +- packages/diffs/src/editor/editorSelection.ts | 152 +++- packages/diffs/src/editor/editorUtils.ts | 14 +- packages/diffs/src/editor/index.ts | 854 +++++++++--------- packages/diffs/src/editor/platform.ts | 27 + packages/diffs/src/editor/textDocument.ts | 2 - packages/diffs/src/types.ts | 2 +- packages/diffs/test/editorCommand.test.ts | 24 +- packages/diffs/test/editorSelection.test.ts | 38 +- 11 files changed, 664 insertions(+), 497 deletions(-) create mode 100644 packages/diffs/src/editor/platform.ts diff --git a/packages/diffs/src/components/VirtualizedFile.ts b/packages/diffs/src/components/VirtualizedFile.ts index 678c201d0..c4d20cb9a 100644 --- a/packages/diffs/src/components/VirtualizedFile.ts +++ b/packages/diffs/src/components/VirtualizedFile.ts @@ -244,7 +244,9 @@ export class VirtualizedFile< newLineAnnotations?: LineAnnotation[] ): void { super.emitLineCountChange(lineCount, newLineAnnotations); + this.heightCache.clear(); this.computeApproximateSize(); + this.renderRange = undefined; } override render({ diff --git a/packages/diffs/src/editor/constants.ts b/packages/diffs/src/editor/constants.ts index 9eb20fae0..0e14542ff 100644 --- a/packages/diffs/src/editor/constants.ts +++ b/packages/diffs/src/editor/constants.ts @@ -2,9 +2,11 @@ export const TOKENIZE_TIME_LIMIT = 100; export const TOKENIZE_MAX_LINE_LENGTH = 10000; export const BGTOKENIZER_LINES_PRE_TOKENIZE = 50; -export const EDITOR_CSS = /* CSS */ ` +const DEBUG_SELECTION = true; + +export const EDITOR_CSS: string = /* CSS */ ` ::selection { - background-color: transparent; + background-color: ${DEBUG_SELECTION ? 'rgba(255, 0, 0, 0.1)' : 'transparent'}; } @keyframes blinking { 0% { opacity: 1; } @@ -22,7 +24,7 @@ export const EDITOR_CSS = /* CSS */ ` } @media (min-width: 480px) { [data-content] { - caret-color: transparent; + caret-color: ${DEBUG_SELECTION ? 'red' : 'transparent'}; } } [data-line] { diff --git a/packages/diffs/src/editor/editorCommand.ts b/packages/diffs/src/editor/editorCommand.ts index 37b3cb76c..9eb671e0d 100644 --- a/packages/diffs/src/editor/editorCommand.ts +++ b/packages/diffs/src/editor/editorCommand.ts @@ -1,28 +1,30 @@ +import { isMacLike, isPrimaryModifier } from './platform'; + export type EditorCommand = | 'indent' | 'outdent' - | 'documentStart' - | 'documentEnd' | 'undo' | 'redo' | 'selectAll' - | 'extendSelection'; + | 'findNextMatch' + | 'moveCursorToDocStart' + | 'moveCursorToDocEnd'; const SHORTCUTS: Partial> = { a: 'selectAll', - d: 'extendSelection', + d: 'findNextMatch', }; export function resolveEditorCommandFromKeyboardEvent( - event: KeyboardEvent + event: KeyboardEvent, + isMac: boolean = isMacLike() ): EditorCommand | undefined { - const hasPrimaryModifier = isPrimaryModifier(event); + const hasPrimaryModifier = isPrimaryModifier(event, isMac); const { shiftKey, altKey, key } = event; if (altKey) { return undefined; } - const isMac = isMacLike(); const normalizedKey = key.length === 1 ? key.toLowerCase() : key; if (!hasPrimaryModifier && normalizedKey === 'Tab') { @@ -42,30 +44,12 @@ export function resolveEditorCommandFromKeyboardEvent( } if (normalizedKey === 'Home' || (isMac && normalizedKey === 'ArrowUp')) { - return 'documentStart'; + return 'moveCursorToDocStart'; } if (normalizedKey === 'End' || (isMac && normalizedKey === 'ArrowDown')) { - return 'documentEnd'; + return 'moveCursorToDocEnd'; } return SHORTCUTS[normalizedKey]; } - -export function isPrimaryModifier({ - metaKey, - ctrlKey, -}: MouseEvent | KeyboardEvent): boolean { - return isMacLike() ? metaKey && !ctrlKey : ctrlKey && !metaKey; -} - -function isMacLike(): boolean { - return /macOS|MacIntel|iPhone|iPad|iPod/i.test(getPlatform()); -} - -function getPlatform(): string { - const navigator = globalThis.navigator as Navigator & { - userAgentData?: { platform?: string }; - }; - return navigator?.platform ?? navigator?.userAgentData?.platform ?? 'unknown'; -} diff --git a/packages/diffs/src/editor/editorSelection.ts b/packages/diffs/src/editor/editorSelection.ts index 5a80691e3..4617fe5c1 100644 --- a/packages/diffs/src/editor/editorSelection.ts +++ b/packages/diffs/src/editor/editorSelection.ts @@ -111,9 +111,10 @@ export function resolveIndentEdits( } /** - * Maps a selection move to a new selection. + * Maps the cursor move to all selections. + * TODO(@ije): use move cursor commands */ -export function mapSelectionMove( +export function mapCursorMove( textDocument: TextDocument, selections: readonly EditorSelection[], nextPosition: Position @@ -122,66 +123,94 @@ export function mapSelectionMove( if (primarySelection === undefined) { return []; } + const deltaOffset = + textDocument.offsetAt(nextPosition) - + textDocument.offsetAt(primarySelection.start); const deltaLine = nextPosition.line - primarySelection.start.line; - const deltaCharacter = - nextPosition.character - primarySelection.start.character; - const isMoveToLineStart = - deltaLine === 0 && nextPosition.character === 0 && deltaCharacter < -1; - const isMoveToLineEnd = - deltaLine === 0 && - nextPosition.character === - textDocument.getLineText(nextPosition.line)?.length && - deltaCharacter > 1; - return selections.map((selection) => { - let newLine = selection.start.line + deltaLine; - let newCharacter = selection.start.character + deltaCharacter; + const movedOneChar = deltaOffset === 1 || deltaOffset === -1; + const newSelections: EditorSelection[] = []; + for (const selection of selections) { + let newPosition = nextPosition; if (selection !== primarySelection) { - if (isMoveToLineStart) { - newCharacter = 0; - } else if (isMoveToLineEnd) { - newCharacter = textDocument.getLineText(newLine)?.length ?? 0; + if (deltaLine === 0 || movedOneChar) { + newPosition = textDocument.positionAt( + textDocument.offsetAt(selection.start) + deltaOffset + ); + } else { + newPosition = { + line: clamp( + selection.start.line + deltaLine, + 0, + textDocument.lineCount - 1 + ), + character: selection.start.character, + }; } } - const newPosition: Position = { - line: newLine, - character: newCharacter, - }; - return { + const newSelection: EditorSelection = { start: newPosition, end: newPosition, direction: DirectionNone, }; - }); + const previousSelection = newSelections.at(-1); + if ( + previousSelection === undefined || + comparePosition(previousSelection.start, newSelection.start) !== 0 + ) { + newSelections.push(newSelection); + } + } + return newSelections; } /** - * Maps a selection range move to a new selection. + * Maps the selection shift to all selections. */ -export function mapSelectionRangeMove( +export function mapSelectionShift( textDocument: TextDocument, selections: readonly EditorSelection[], - selection: EditorSelection + selectionShift: EditorSelection ): EditorSelection[] { - const { start, end } = selection; const primarySelection = selections[selections.length - 1]; if (primarySelection === undefined) { return []; } const [primaryAnchorOffset, primaryFocusOffset] = getSelectionAnchorAndFocusOffsets(textDocument, primarySelection); - const anchorDelta = textDocument.offsetAt(start) - primaryAnchorOffset; - const focusDelta = textDocument.offsetAt(end) - primaryFocusOffset; - return selections.map((selection) => { + const [shiftAnchorOffset, shiftFocusOffset] = + getSelectionAnchorAndFocusOffsets(textDocument, selectionShift); + const anchorDelta = shiftAnchorOffset - primaryAnchorOffset; + const focusDelta = shiftFocusOffset - primaryFocusOffset; + const mappedSelections: EditorSelection[] = []; + for (const selection of selections) { const [anchorOffset, focusOffset] = getSelectionAnchorAndFocusOffsets( textDocument, selection ); - return createSelectionFromAnchorAndFocusOffsets( + const mappedOffsets = createSelectionFromAnchorAndFocusOffsets( textDocument, anchorOffset + anchorDelta, focusOffset + focusDelta ); - }); + const newSelection = + !isCollapsedSelection(mappedOffsets) && + selectionShift.direction !== DirectionNone + ? { ...mappedOffsets, direction: selectionShift.direction } + : mappedOffsets; + const previousSelection = mappedSelections.at(-1); + if ( + previousSelection !== undefined && + selectionIntersects(previousSelection, newSelection) + ) { + Object.assign( + previousSelection, + createSelectionFrom(previousSelection, newSelection) + ); + } else { + mappedSelections.push(newSelection); + } + } + return mappedSelections; } /** @@ -222,7 +251,7 @@ export function applyTextChangeToSelections( } return a.index - b.index; }); - const adjustedChange = normalizeLeadingIndentDeleteChange( + const adjustedChange = normalizeLeadingIndentForChange( textDocument, edit, primarySelection, @@ -594,6 +623,53 @@ export function findNexMatch( return [...selections, added]; } +export function getDocumentFullSelection( + textDocument: TextDocument +): EditorSelection { + const lastLine = textDocument.lineCount - 1; + const lastCharacter = textDocument.getLineText(lastLine)?.length ?? 0; + return { + start: { line: 0, character: 0 }, + end: { line: lastLine, character: lastCharacter }, + direction: DirectionForward, + }; +} + +export function getDocumentBoundarySelection( + textDocument: TextDocument, + atEnd: boolean +): EditorSelection { + const line = atEnd ? textDocument.lineCount - 1 : 0; + const character = atEnd ? (textDocument.getLineText(line)?.length ?? 0) : 0; + const start = { line, character }; + return { + start: start, + end: start, + direction: DirectionForward, + }; +} + +export function getSelectionText( + textDocument: TextDocument, + selections: readonly EditorSelection[] +): string { + return [...selections] + .sort((a, b) => { + const startOrder = comparePosition(a.start, b.start); + if (startOrder !== 0) { + return startOrder; + } + return comparePosition(a.end, b.end); + }) + .map((selection) => { + if (isCollapsedSelection(selection)) { + return textDocument.getLineText(selection.start.line, false); + } + return textDocument.getText(selection); + }) + .join('\n'); +} + /** * Gets the text node and offset for a selection. */ @@ -738,7 +814,7 @@ function getSelectionAnchorAndFocusOffsets( ]; } -/** When the user inserts a lone line break, copy the current line's indentation onto the new line. */ +// When the user inserts a lone line break, copy the current line's indentation onto the new line. function expandSingleNewlineInsert( textDocument: TextDocument, insertText: string, @@ -764,7 +840,7 @@ function expandSingleNewlineInsert( // Expands a backspace over leading spaces into one soft-tab width so mixed hard/soft indentation // behaves like the explicit outdent command. -function normalizeLeadingIndentDeleteChange( +function normalizeLeadingIndentForChange( textDocument: TextDocument, change: ResolvedTextEdit, primarySelection: EditorSelection, @@ -946,3 +1022,7 @@ function getTextOffset( lineBreakIndex === -1 ? value.length : lineBreakIndex ); } + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(value, max)); +} diff --git a/packages/diffs/src/editor/editorUtils.ts b/packages/diffs/src/editor/editorUtils.ts index 777775e9c..a6b9b0055 100644 --- a/packages/diffs/src/editor/editorUtils.ts +++ b/packages/diffs/src/editor/editorUtils.ts @@ -63,24 +63,28 @@ export function createElement( export function addEventListener( el: HTMLElement, event: K, - listener: (this: HTMLElement, evt: HTMLElementEventMap[K]) => void + listener: (this: HTMLElement, evt: HTMLElementEventMap[K]) => void, + options?: AddEventListenerOptions ): () => void; export function addEventListener( el: Document, event: K, - listener: (this: Document, evt: DocumentEventMap[K]) => void + listener: (this: Document, evt: DocumentEventMap[K]) => void, + options?: AddEventListenerOptions ): () => void; export function addEventListener( el: Window, event: K, - listener: (this: Window, evt: WindowEventMap[K]) => void + listener: (this: Window, evt: WindowEventMap[K]) => void, + options?: AddEventListenerOptions ): () => void; export function addEventListener( el: HTMLElement | Document | ShadowRoot | Window, event: string, - listener: EventListener + listener: EventListener, + options?: AddEventListenerOptions ) { - el.addEventListener(event, listener); + el.addEventListener(event, listener, options); return () => el.removeEventListener(event, listener); } diff --git a/packages/diffs/src/editor/index.ts b/packages/diffs/src/editor/index.ts index 76424b0b0..e76e233c2 100644 --- a/packages/diffs/src/editor/index.ts +++ b/packages/diffs/src/editor/index.ts @@ -4,7 +4,6 @@ import type { File } from '../components/File'; import { DEFAULT_THEMES } from '../constants'; import { type EditorCommand, - isPrimaryModifier, resolveEditorCommandFromKeyboardEvent, } from '../editor/editorCommand'; import type { EditorSelection } from '../editor/editorSelection'; @@ -19,10 +18,13 @@ import { DirectionNone, extendSelection, findNexMatch, + getDocumentBoundarySelection, + getDocumentFullSelection, + getSelectionText, getSelectionTextNode, isCollapsedSelection, - mapSelectionMove, - mapSelectionRangeMove, + mapCursorMove, + mapSelectionShift, resolveIndentEdits, selectionIntersects, } from '../editor/editorSelection'; @@ -57,6 +59,7 @@ import { TOKENIZE_TIME_LIMIT, } from './constants'; import { applyDocumentChangeToLineAnnotations } from './editorLineAnnotations'; +import { isPrimaryModifier } from './platform'; import { BackgroundTokenizer, tokenizeLine } from './tokenzier'; export class Editor implements DiffsEditor { @@ -182,12 +185,8 @@ export class Editor implements DiffsEditor { return { direction, start, end }; } ); - const primarySelection = resolvedSelections.at(-1); - if (primarySelection === undefined) { - return; - } - this.#updateSelections(resolvedSelections); - this.#focusContentElement(primarySelection); + this.#updateSelections(resolvedSelections, true); + this.#contentElement?.focus(); } } @@ -215,11 +214,8 @@ export class Editor implements DiffsEditor { this.#wrapLineOffsetsCache.clear(); this.#lastCharX = undefined; - if (this.#contentElement !== undefined) { - this.#contentElement.contentEditable = 'false'; - this.#contentElement.role = null; - this.#contentElement.ariaMultiLine = null; - } + this.#fileContainer = undefined; + this.#contentElement?.removeAttribute('contentEditable'); this.#contentElement = undefined; this.#contentElementDisposes?.forEach((dispose) => dispose()); this.#contentElementDisposes = undefined; @@ -285,51 +281,83 @@ export class Editor implements DiffsEditor { } this.#contentElementDisposes?.forEach((dispose) => dispose()); this.#contentElementDisposes = [ - addEventListener(contentEl, 'keydown', (e) => { - if (!e.shiftKey) { - this.#selectionStart = undefined; - } - const command = resolveEditorCommandFromKeyboardEvent(e); - if (command !== undefined) { + addEventListener( + contentEl, + 'keydown', + (e) => { + const command = resolveEditorCommandFromKeyboardEvent(e); + if (command !== undefined) { + e.preventDefault(); + this.#runCommand(command); + } + }, + { passive: false } + ), + + addEventListener( + contentEl, + 'copy', + (e) => { e.preventDefault(); - this.#runCommand(command); - } - }), - - addEventListener(contentEl, 'copy', (e) => { - e.preventDefault(); - e.clipboardData?.setData('text', this.#getSelectionText()); - }), - - addEventListener(contentEl, 'cut', (e) => { - e.preventDefault(); - e.clipboardData?.setData('text', this.#getSelectionText()); - this.#replaceSelectionText(''); - }), - - addEventListener(contentEl, 'paste', (e) => { - e.preventDefault(); - const text = e.clipboardData?.getData('text'); - if (text !== undefined) { - // TODO(@ije): Add support of multiple selections paste - // TODO(@ije): normalize the pasted text with textDocument.EOF - this.#replaceSelectionText(text); - } - }), + e.clipboardData?.setData('text', this.#getSelectionText()); + }, + { passive: false } + ), - addEventListener(contentEl, 'beforeinput', (e) => { - e.preventDefault(); - this.#handleInput(e.inputType, e.data); - }), + addEventListener( + contentEl, + 'cut', + (e) => { + e.preventDefault(); + e.clipboardData?.setData('text', this.#getSelectionText()); + this.#replaceSelectionText(''); + }, + { passive: false } + ), - addEventListener(contentEl, 'compositionstart', () => { - this.#shouldIgnoreSelectionChange = true; - }), + addEventListener( + contentEl, + 'paste', + (e) => { + e.preventDefault(); + const text = e.clipboardData?.getData('text'); + if (text !== undefined) { + // TODO(@ije): Add support of multiple selections paste + // TODO(@ije): normalize the pasted text with textDocument.EOF + this.#replaceSelectionText(text); + } + }, + { passive: false } + ), - addEventListener(contentEl, 'compositionend', (e) => { - this.#shouldIgnoreSelectionChange = false; - this.#handleInput('insertText', e.data); - }), + addEventListener( + contentEl, + 'beforeinput', + (e) => { + e.preventDefault(); + this.#handleInput(e.inputType, e.data); + }, + { passive: false } + ), + + addEventListener( + contentEl, + 'compositionstart', + () => { + this.#shouldIgnoreSelectionChange = true; + }, + { passive: true } + ), + + addEventListener( + contentEl, + 'compositionend', + (e) => { + this.#shouldIgnoreSelectionChange = false; + this.#handleInput('insertText', e.data); + }, + { passive: true } + ), ]; this.#contentResizeObserver?.disconnect(); @@ -387,6 +415,7 @@ export class Editor implements DiffsEditor { this.#shouldIgnoreSelectionChange = false; this.#selectionElements?.forEach((el) => el.remove()); this.#selectionElements?.clear(); + this.#component?.setSelectedLines(null); this.#selectionElements = undefined; this.#selections = undefined; this.#reservedSelections = undefined; @@ -401,7 +430,7 @@ export class Editor implements DiffsEditor { this.#prebuildStateStackCache(); if (this.#selections !== undefined && this.#selections.length > 0) { - this.#updateSelections(this.#selections); + this.#updateSelections(this.#selections, true); } if (renderRange !== undefined) { @@ -433,132 +462,302 @@ export class Editor implements DiffsEditor { }); this.#disposes = [ - addEventListener(document, 'selectionchange', () => { - if (this.#shouldIgnoreSelectionChange) { - return; - } - - const shadowRoot = this.#contentElement?.getRootNode(); - if (shadowRoot === undefined || !(shadowRoot instanceof ShadowRoot)) { - return; - } + addEventListener( + document, + 'selectionchange', + () => { + if (this.#shouldIgnoreSelectionChange) { + return; + } - const selectionRaw = document.getSelection(); - const composedRange = selectionRaw?.getComposedRanges({ - shadowRoots: [shadowRoot], - })?.[0]; + const shadowRoot = this.#contentElement?.getRootNode(); + if (shadowRoot === undefined || !(shadowRoot instanceof ShadowRoot)) { + return; + } - if ( - composedRange === undefined || - !this.#rangeBelongsToEditor(composedRange) - ) { - return; - } + const selectionRaw = document.getSelection(); + const composedRange = selectionRaw?.getComposedRanges({ + shadowRoots: [shadowRoot], + })?.[0]; - let selection = convertSelection(composedRange, DirectionNone); - if (selection === undefined) { - return; - } + if ( + composedRange === undefined || + !this.#rangeBelongsToEditor(composedRange) + ) { + return; + } - if ( - this.#shiftKeyPressed && - this.#selections !== undefined && - this.#selections.length > 0 - ) { - const primarySelection = this.#selections.at(-1)!; - this.#updateSelections([ - extendSelection(primarySelection, selection), - ]); - return; - } + let selection = convertSelection(composedRange, DirectionNone); + if (selection === undefined) { + return; + } - if (this.#selectionStart !== undefined) { - selection = createSelectionFrom(this.#selectionStart, selection); - } else if (this.#isMouseDown) { - this.#selectionStart = selection; - } - if (this.#reservedSelections !== undefined) { - this.#updateSelections([ - ...this.#reservedSelections.filter( - (reservedSelection) => - !selectionIntersects(reservedSelection, selection) - ), - selection, - ]); - } else { if ( - this.#isMouseDown || - this.#selections === undefined || - this.#selections.length === 0 || - this.#textDocument === undefined + this.#isMouseDown && + this.#shiftKeyPressed && + this.#selections !== undefined && + this.#selections.length > 0 ) { - this.#updateSelections([selection]); + const primarySelection = this.#selections.at(-1)!; + // before shift + click, the window selection has been cleared, + // so we need to set the window selection manually with the new + // selection + this.#updateSelections( + [extendSelection(primarySelection, selection)], + true + ); + return; + } + + if (this.#isMouseDown) { + if (this.#selectionStart !== undefined) { + selection = createSelectionFrom(this.#selectionStart, selection); + } else { + this.#selectionStart = selection; + } + } else if (this.#selectionStart !== undefined) { + selection.direction = createSelectionFrom( + this.#selectionStart, + selection + ).direction; + } + + if (this.#reservedSelections !== undefined) { + this.#updateSelections([ + ...this.#reservedSelections.filter( + (reservedSelection) => + !selectionIntersects(reservedSelection, selection) + ), + selection, + ]); } else { - // The selection change is triggered by the keyboard - // For example, when the user presses the arrow keys, the selection changes. - if (isCollapsedSelection(selection)) { - this.#updateSelections( - mapSelectionMove( - this.#textDocument, - this.#selections, - selection.start - ) - ); + if ( + this.#isMouseDown || + this.#selections === undefined || + this.#selections.length === 0 || + this.#textDocument === undefined + ) { + this.#updateSelections([selection]); } else { - // shift key is pressed when moving the cursor by - this.#updateSelections( - mapSelectionRangeMove( + // The selection change is triggered by the keyboard + // For example, moving the cursor by arrow keys. + if (isCollapsedSelection(selection)) { + this.#updateSelections( + mapCursorMove( + this.#textDocument, + this.#selections, + selection.start + ) + ); + } else { + // shift key is pressed when moving the cursor by + const newSelections = mapSelectionShift( this.#textDocument, this.#selections, selection - ) - ); + ); + const hasMergedSelections = + newSelections.length !== this.#selections.length; + this.#updateSelections(newSelections, false); + if (hasMergedSelections) { + this.#updateWindowSelection(newSelections.at(-1)!); + } + } } } - } - }), + }, + { passive: true } + ), + + addEventListener( + document, + 'mousedown', + (e) => { + const target = e.composedPath()[0]; + if (target === undefined || !(target instanceof HTMLElement)) { + return; + } + const { tagName, dataset } = target; + if ( + !( + (tagName === 'DIV' && dataset.line !== undefined) || + (tagName === 'SPAN' && dataset.char !== undefined) + ) + ) { + return; + } + + this.#isMouseDown = true; + this.#selectionStart = undefined; + if (e.button === 0 && isPrimaryModifier(e)) { + this.#reservedSelections = this.#selections?.map((selection) => ({ + ...selection, + })); + } + if (e.shiftKey) { + window.getSelection()?.empty(); + this.#shiftKeyPressed = true; + } else { + this.#selections = undefined; + } + }, + { passive: true } + ), + + addEventListener( + document, + 'mouseup', + () => { + this.#isMouseDown = false; + this.#shiftKeyPressed = false; + this.#selectionStart = undefined; + this.#reservedSelections = undefined; + }, + { passive: true } + ), + + addEventListener( + document, + 'keydown', + (e) => { + if (e.key === 'Shift') { + this.#selectionStart = this.#selections?.at(-1); + } + }, + { passive: true } + ), + + addEventListener( + document, + 'keyup', + (e) => { + if (e.key === 'Shift') { + this.#selectionStart = undefined; + } + }, + { passive: true } + ), + + addEventListener( + window, + 'resize', + () => { + this.#handleLayoutResize(); + }, + { passive: true } + ), + ]; + } + + #runCommand(command: EditorCommand) { + const textDocument = this.#textDocument; + if (textDocument === undefined) { + return; + } + + switch (command) { + case 'selectAll': + this.#updateSelections([getDocumentFullSelection(textDocument)]); + break; - addEventListener(document, 'mousedown', (e) => { - const target = e.composedPath()[0]; - if (target === undefined || !(target instanceof HTMLElement)) { - return; + case 'findNextMatch': { + const selections = this.#selections; + const textDocument = this.#textDocument; + if (selections === undefined || textDocument === undefined) { + break; } - const { tagName, dataset } = target; - if ( - !( - (tagName === 'DIV' && dataset.line !== undefined) || - (tagName === 'SPAN' && dataset.char !== undefined) - ) - ) { - return; + const next = findNexMatch(textDocument, selections); + if (next !== undefined) { + this.#updateSelections(next); } + break; + } - this.#isMouseDown = true; - this.#selectionStart = undefined; - if (e.button === 0 && isPrimaryModifier(e)) { - this.#reservedSelections = this.#selections?.map((selection) => ({ - ...selection, - })); + case 'indent': + case 'outdent': + if (this.#selections !== undefined) { + const edits: TextEdit[] = []; + const nextSelections: EditorSelection[] = []; + for (const selection of this.#selections) { + const startLine = selection.start.line; + const outdent = command === 'outdent'; + if (startLine !== selection.end.line || outdent) { + const ret = resolveIndentEdits( + textDocument, + selection, + this.#tabSize, + outdent + ); + edits.push(...ret[0]); + nextSelections.push(ret[1]); + } else { + const lineChar0 = textDocument.charAt({ + line: startLine, + character: 0, + }); + this.#replaceSelectionText( + lineChar0 === '\t' ? '\t' : ' '.repeat(this.#tabSize) + ); + } + } + if (edits.length > 0) { + const change = textDocument.applyEdits( + edits, + true, + this.#selections, + nextSelections + ); + if (change !== undefined) { + this.#applyChange(change, nextSelections); + } + } } - if (e.shiftKey) { - window.getSelection()?.empty(); - this.#shiftKeyPressed = true; - } else { - this.#selections = undefined; + break; + + case 'moveCursorToDocStart': + case 'moveCursorToDocEnd': + { + const atEnd = command === 'moveCursorToDocEnd'; + const anchor = createElement('span'); + const root = this.#contentElement?.getRootNode() as + | Element + | undefined; + this.#updateSelections( + [getDocumentBoundarySelection(textDocument, atEnd)], + true + ); + if (root !== undefined) { + if (atEnd) { + root.appendChild(anchor); + } else { + root.prepend(anchor); + } + anchor.scrollIntoView({ block: atEnd ? 'end' : 'start' }); + requestAnimationFrame(() => { + anchor.remove(); + }); + } } - }), + break; - addEventListener(document, 'mouseup', () => { - this.#isMouseDown = false; - this.#shiftKeyPressed = false; - this.#selectionStart = undefined; - this.#reservedSelections = undefined; - }), + case 'undo': + if (this.#textDocument?.canUndo === true) { + const undoResult = this.#textDocument.undo(); + if (undoResult !== undefined) { + this.#applyChange(...undoResult); + } + } + break; - addEventListener(window, 'resize', () => { - this.#handleLayoutResize(); - }), - ]; + case 'redo': + if (this.#textDocument?.canRedo === true) { + const redoResult = this.#textDocument.redo(); + if (redoResult !== undefined) { + this.#applyChange(...redoResult); + } + } + break; + } } #handleLayoutResize() { @@ -900,15 +1099,49 @@ export class Editor implements DiffsEditor { } } - #focusContentElement(selection: EditorSelection) { - if (this.#contentElement === undefined) { + #updateSelections( + selections: EditorSelection[], + updateWindowSelection: boolean = false + ) { + const primarySelection = selections.at(-1); + if (primarySelection === undefined) { return; } + this.#selections = selections; + this.#component?.setSelectedLines(null); + if (isCollapsedSelection(primarySelection)) { + const line = primarySelection.end.line + 1; + this.#component?.setSelectedLines({ + start: line, + end: line, + }); + } + const fragment = document.createDocumentFragment(); + const renderCtx = { + fragment, + elements: new Map(), + }; + selections.forEach((selection) => { + if (selections.length > 1 || !isCollapsedSelection(selection)) { + this.#renderSelection(renderCtx, selection); + } + this.#renderCaret(renderCtx, selection); + }); + this.#overlayElement?.appendChild(fragment); + this.#selectionElements?.forEach((el) => el.remove()); + this.#selectionElements?.clear(); + this.#selectionElements = renderCtx.elements; + if (updateWindowSelection) { + this.#updateWindowSelection(primarySelection); + } + } + + #updateWindowSelection(primarySelection: EditorSelection) { const winSelection = window.getSelection(); if (winSelection === null) { return; } - let { start, end } = selection; + let { start, end, direction } = primarySelection; if (comparePosition(start, end) > 0) { [start, end] = [end, start]; } @@ -917,14 +1150,22 @@ export class Editor implements DiffsEditor { if (startLineElement === undefined || endLineElement === undefined) { return; } - const [anchorNode, anchorOffset] = getSelectionTextNode( + let [anchorNode, anchorOffset] = getSelectionTextNode( startLineElement, start.character ); - const [focusNode, focusOffset] = getSelectionTextNode( + let [focusNode, focusOffset] = getSelectionTextNode( endLineElement, end.character ); + if (direction === DirectionBackward) { + [anchorNode, anchorOffset, focusNode, focusOffset] = [ + focusNode, + focusOffset, + anchorNode, + anchorOffset, + ]; + } this.#shouldIgnoreSelectionChange = true; winSelection.setBaseAndExtent( anchorNode, @@ -932,43 +1173,11 @@ export class Editor implements DiffsEditor { focusNode, focusOffset ); - this.#contentElement.focus(); setTimeout(() => { this.#shouldIgnoreSelectionChange = false; }, 0); } - #updateSelections(selections: EditorSelection[]) { - const primarySelection = selections.at(-1); - if (primarySelection === undefined) { - return; - } - this.#selections = selections; - this.#component?.setSelectedLines(null); - if (isCollapsedSelection(primarySelection)) { - const line = primarySelection.end.line + 1; - this.#component?.setSelectedLines({ - start: line, - end: line, - }); - } - const fragment = document.createDocumentFragment(); - const renderCtx = { - fragment, - elements: new Map(), - }; - selections.forEach((selection) => { - if (selections.length > 1 || !isCollapsedSelection(selection)) { - this.#renderSelection(renderCtx, selection); - } - this.#renderCaret(renderCtx, selection); - }); - this.#overlayElement?.appendChild(fragment); - this.#selectionElements?.forEach((el) => el.remove()); - this.#selectionElements?.clear(); - this.#selectionElements = renderCtx.elements; - } - #renderSelection( renderCtx: { fragment: DocumentFragment; @@ -1012,7 +1221,7 @@ export class Editor implements DiffsEditor { let left = 0; let width = 0; if (startChar === endChar && startChar === 0) { - left = this.#getGutterLeft() + this.#charWidth; // gutter width + inline padding (1ch) + left = this.#getGutterWidth() + this.#charWidth; // gutter width + inline padding (1ch) width = ln === end.line ? 0 : this.#charWidth; } else { left = this.#getCharX(ln, startChar)[0]; @@ -1053,7 +1262,7 @@ export class Editor implements DiffsEditor { const wrapOffsets = this.#wrapLineText(line); const segmentCount = wrapOffsets.length - 1; const lastSegmentIndex = segmentCount - 1; - const offsetLeft = this.#getGutterLeft() + paddingInline; + const offsetLeft = this.#getGutterWidth() + paddingInline; for (let w = 0; w < segmentCount; w++) { const segmentStart = wrapOffsets[w]; @@ -1152,30 +1361,25 @@ export class Editor implements DiffsEditor { const cacheKey = 'selection-range-' + css; const selectionEls = this.#selectionElements; + if (renderCtx.elements.has(cacheKey)) { + return; + } + let rangeEl: HTMLElement | undefined; if (selectionEls?.has(cacheKey) === true) { rangeEl = selectionEls.get(cacheKey)!; selectionEls.delete(cacheKey); } else { - for (const [key, el] of selectionEls?.entries() ?? []) { - if (key.startsWith(`selection-${ln}-`)) { - rangeEl = el; - selectionEls?.delete(key); - el.style.cssText = css; - break; - } - } + rangeEl = createElement( + 'div', + { + dataset: 'selectionRange', + style: { cssText: css }, + }, + renderCtx.fragment + ); } - rangeEl ??= createElement( - 'div', - { - dataset: 'selectionRange', - style: { cssText: css }, - }, - renderCtx.fragment - ); - renderCtx.elements.set(cacheKey, rangeEl); } @@ -1194,6 +1398,10 @@ export class Editor implements DiffsEditor { return; } const [left, wrapLine] = this.#getCharX(line, character); + const cacheKey = 'caret-' + line + '-' + character; + if (renderCtx.elements.has(cacheKey)) { + return; + } const caretEl = createElement( 'div', { @@ -1204,170 +1412,16 @@ export class Editor implements DiffsEditor { }, renderCtx.fragment ); - renderCtx.elements.set('caret-' + line + '-' + character, caretEl); + renderCtx.elements.set(cacheKey, caretEl); } - #runCommand(command: EditorCommand) { - switch (command) { - case 'selectAll': - this.#updateSelections([this.#getFullSelection()]); - break; - - case 'extendSelection': { - const selections = this.#selections; - const textDocument = this.#textDocument; - if (selections === undefined || textDocument === undefined) { - break; - } - const next = findNexMatch(textDocument, selections); - if (next !== undefined) { - this.#updateSelections(next); - } - break; - } - - case 'indent': - case 'outdent': - if ( - this.#selections !== undefined && - this.#textDocument !== undefined - ) { - const edits: TextEdit[] = []; - const nextSelections: EditorSelection[] = []; - for (const selection of this.#selections) { - const startLine = selection.start.line; - const outdent = command === 'outdent'; - if (startLine !== selection.end.line || outdent) { - const ret = resolveIndentEdits( - this.#textDocument, - selection, - this.#tabSize, - outdent - ); - edits.push(...ret[0]); - nextSelections.push(ret[1]); - } else { - const lineChar0 = this.#textDocument.charAt({ - line: startLine, - character: 0, - }); - this.#replaceSelectionText( - lineChar0 === '\t' ? '\t' : ' '.repeat(this.#tabSize) - ); - } - } - if (edits.length > 0) { - const change = this.#textDocument.applyEdits( - edits, - true, - this.#selections, - nextSelections - ); - if (change !== undefined) { - this.#applyChange(change, nextSelections); - } - } - } - break; - - case 'documentStart': - case 'documentEnd': - { - const atEnd = command === 'documentEnd'; - const anchor = createElement('span'); - const root = this.#contentElement?.getRootNode() as - | Element - | undefined; - this.#updateSelections([this.#getDocumentBoundarySelection(atEnd)]); - if (root !== undefined) { - if (atEnd) { - root.appendChild(anchor); - } else { - root.prepend(anchor); - } - anchor.scrollIntoView({ block: atEnd ? 'end' : 'start' }); - requestAnimationFrame(() => { - anchor.remove(); - }); - } - } - break; - - case 'undo': - if (this.#textDocument?.canUndo === true) { - const undoResult = this.#textDocument.undo(); - if (undoResult !== undefined) { - this.#applyChange(...undoResult); - } - } - break; - - case 'redo': - if (this.#textDocument?.canRedo === true) { - const redoResult = this.#textDocument.redo(); - if (redoResult !== undefined) { - this.#applyChange(...redoResult); - } - } - break; - } - } - - // for select all command - #getFullSelection(): EditorSelection { - const textDocument = this.#textDocument; - if (textDocument === undefined) { - throw new Error('Editor has no text document'); - } - const lastLine = textDocument.lineCount - 1; - const lastCharacter = textDocument.getLineText(lastLine)?.length ?? 0; - return { - start: { line: 0, character: 0 }, - end: { line: lastLine, character: lastCharacter }, - direction: DirectionForward, - }; - } - - // for documentStart/documentEnd commands - #getDocumentBoundarySelection(atEnd: boolean): EditorSelection { - const textDocument = this.#textDocument; - if (textDocument === undefined) { - throw new Error('Editor has no text document'); - } - const line = atEnd ? textDocument.lineCount - 1 : 0; - const character = atEnd ? (textDocument.getLineText(line)?.length ?? 0) : 0; - const start = { line, character }; - return { - start: start, - end: start, - direction: DirectionForward, - }; - } - - #getSelectionText(): string { + #getSelectionText() { const textDocument = this.#textDocument; - if ( - textDocument === undefined || - this.#selections === undefined || - this.#selections.length === 0 - ) { + const selections = this.#selections; + if (textDocument === undefined || selections === undefined) { return ''; } - return [...this.#selections] - .sort((a, b) => { - const startOrder = comparePosition(a.start, b.start); - if (startOrder !== 0) { - return startOrder; - } - return comparePosition(a.end, b.end); - }) - .map((selection) => { - if (isCollapsedSelection(selection)) { - return textDocument.getLineText(selection.start.line, false); - } - return textDocument.getText(selection); - }) - .join('\n'); + return getSelectionText(textDocument, selections); } // replace the selection text @@ -1484,13 +1538,9 @@ export class Editor implements DiffsEditor { this.#selections = selections; this.#rerender(change, lineAnnotations); if (this.#selections !== undefined) { - this.#updateSelections(this.#selections); // since we prevent the default input event, - // we need to focus the content element manually - const primarySelection = this.#selections.at(-1); - if (primarySelection !== undefined) { - this.#focusContentElement(primarySelection); - } + // we need to update the window selection manually + this.#updateSelections(this.#selections, true); } } @@ -1513,7 +1563,26 @@ export class Editor implements DiffsEditor { return undefined; } - #getGutterLeft() { + #getLineElement(line: number): HTMLElement | undefined { + const children = this.#contentElement?.children; + if (children === undefined) { + return undefined; + } + const { startingLine = 0 } = this.#renderRange ?? {}; + for (let i = line - startingLine; i <= children.length; i++) { + const child = children[i] as HTMLElement | undefined; + if ( + child !== undefined && + child.dataset.lineIndex !== undefined && + Number(child.dataset.lineIndex) === line + ) { + return child; + } + } + return undefined; + } + + #getGutterWidth() { const diffsColumnNumbertWidth = this.#contentElement?.parentElement?.style.getPropertyValue( '--diffs-column-number-width' @@ -1549,25 +1618,6 @@ export class Editor implements DiffsEditor { return this.#contentElement?.offsetWidth ?? 0; } - #getLineElement(line: number): HTMLElement | undefined { - const children = this.#contentElement?.children; - if (children === undefined) { - return undefined; - } - const { startingLine = 0 } = this.#renderRange ?? {}; - for (let i = line - startingLine; i <= children.length; i++) { - const child = children[i] as HTMLElement | undefined; - if ( - child !== undefined && - child.dataset.lineIndex !== undefined && - Number(child.dataset.lineIndex) === line - ) { - return child; - } - } - return undefined; - } - // get line top position #getLineY(line: number) { const cachedY = this.#lineYCache.get(line); @@ -1593,7 +1643,7 @@ export class Editor implements DiffsEditor { } const lineText = this.#textDocument?.getLineText(line); - const offsetLeft = this.#getGutterLeft() + this.#charWidth; // gutter width + inline padding (1ch) + const offsetLeft = this.#getGutterWidth() + this.#charWidth; // gutter width + inline padding (1ch) if (lineText === undefined || lineText.length === 0 || char <= 0) { return [offsetLeft, 0]; } diff --git a/packages/diffs/src/editor/platform.ts b/packages/diffs/src/editor/platform.ts new file mode 100644 index 000000000..e2cd6a313 --- /dev/null +++ b/packages/diffs/src/editor/platform.ts @@ -0,0 +1,27 @@ +let _isMacLike: boolean | undefined = undefined; +let _isLinux: boolean | undefined = undefined; + +export function isMacLike(): boolean { + return ( + _isMacLike ?? + (_isMacLike = /macOS|MacIntel|iPhone|iPad|iPod/i.test(getPlatform())) + ); +} + +export function isLinux(): boolean { + return _isLinux ?? (_isLinux = /Linux/i.test(getPlatform())); +} + +export function isPrimaryModifier( + { metaKey, ctrlKey }: MouseEvent | KeyboardEvent, + isMac: boolean = isMacLike() +): boolean { + return isMac ? metaKey && !ctrlKey : ctrlKey && !metaKey; +} + +function getPlatform(): string { + const navigator = globalThis.navigator as Navigator & { + userAgentData?: { platform?: string }; + }; + return navigator?.platform ?? navigator?.userAgentData?.platform ?? 'unknown'; +} diff --git a/packages/diffs/src/editor/textDocument.ts b/packages/diffs/src/editor/textDocument.ts index d9d80dd1a..493e32135 100644 --- a/packages/diffs/src/editor/textDocument.ts +++ b/packages/diffs/src/editor/textDocument.ts @@ -162,12 +162,10 @@ export class TextDocument { } offsetAt(position: Position): number { - // todo: clamp return this.#pieceTable.offsetAt(position); } getText(range?: Range): string { - // todo: clamp return this.#pieceTable.getText(range); } diff --git a/packages/diffs/src/types.ts b/packages/diffs/src/types.ts index 74a7c5adc..d230daae6 100644 --- a/packages/diffs/src/types.ts +++ b/packages/diffs/src/types.ts @@ -756,6 +756,7 @@ export interface DiffsEditor { export interface DiffsEditableComponent { readonly options: BaseCodeOptions; + setEditor: (editor: DiffsEditor) => void; setOptions: (options: Partial) => void; setSelectedLines: (range: { start: number; end: number } | null) => void; emitDirtyLines: ( @@ -766,7 +767,6 @@ export interface DiffsEditableComponent { lineCount: number, newLineAnnotations?: LineAnnotation[] ) => void; - setEditor: (editor: DiffsEditor) => void; removeEditor(): void; rerender(): void; cleanUp(): void; diff --git a/packages/diffs/test/editorCommand.test.ts b/packages/diffs/test/editorCommand.test.ts index 7e3e7dc28..8dc5fcf97 100644 --- a/packages/diffs/test/editorCommand.test.ts +++ b/packages/diffs/test/editorCommand.test.ts @@ -48,11 +48,12 @@ function withPlatform(platform: string, run: () => void): void { } function expectShortcuts(platform: string, cases: ShortcutCase[]): void { + const isMac = /macOS|MacIntel|iPhone|iPad|iPod/i.test(platform); withPlatform(platform, () => { for (const { event: shortcutEvent, expected } of cases) { - expect(resolveEditorCommandFromKeyboardEvent(event(shortcutEvent))).toBe( - expected - ); + expect( + resolveEditorCommandFromKeyboardEvent(event(shortcutEvent), isMac) + ).toBe(expected); } }); } @@ -63,8 +64,14 @@ describe('resolveEditorShortcutCommand', () => { { event: { key: 'z', metaKey: true }, expected: 'undo' }, { event: { key: 'z', metaKey: true, shiftKey: true }, expected: 'redo' }, { event: { key: 'a', metaKey: true }, expected: 'selectAll' }, - { event: { key: 'ArrowUp', metaKey: true }, expected: 'documentStart' }, - { event: { key: 'ArrowDown', metaKey: true }, expected: 'documentEnd' }, + { + event: { key: 'ArrowUp', metaKey: true }, + expected: 'moveCursorToDocStart', + }, + { + event: { key: 'ArrowDown', metaKey: true }, + expected: 'moveCursorToDocEnd', + }, ]); }); @@ -74,8 +81,11 @@ describe('resolveEditorShortcutCommand', () => { { event: { key: 'z', ctrlKey: true, shiftKey: true }, expected: 'redo' }, { event: { key: 'y', ctrlKey: true }, expected: 'redo' }, { event: { key: 'a', ctrlKey: true }, expected: 'selectAll' }, - { event: { key: 'Home', ctrlKey: true }, expected: 'documentStart' }, - { event: { key: 'End', ctrlKey: true }, expected: 'documentEnd' }, + { + event: { key: 'Home', ctrlKey: true }, + expected: 'moveCursorToDocStart', + }, + { event: { key: 'End', ctrlKey: true }, expected: 'moveCursorToDocEnd' }, ]); }); diff --git a/packages/diffs/test/editorSelection.test.ts b/packages/diffs/test/editorSelection.test.ts index 3ac878e20..de7a9034a 100644 --- a/packages/diffs/test/editorSelection.test.ts +++ b/packages/diffs/test/editorSelection.test.ts @@ -10,8 +10,8 @@ import { type EditorSelection, extendSelection, findNexMatch, - mapSelectionMove, - mapSelectionRangeMove, + mapCursorMove, + mapSelectionShift, selectionIntersects, } from '../src/editor/editorSelection'; import { @@ -767,7 +767,7 @@ describe('mapSelectionMove', () => { ]; expect( - mapSelectionMove(textDocument, selections, { line: 2, character: 0 }) + mapCursorMove(textDocument, selections, { line: 2, character: 0 }) ).toEqual([ createSelection(0, 0, 0, 0), createSelection(1, 0, 1, 0), @@ -783,7 +783,7 @@ describe('mapSelectionMove', () => { ]; expect( - mapSelectionMove(textDocument, selections, { line: 1, character: 1 }) + mapCursorMove(textDocument, selections, { line: 1, character: 1 }) ).toEqual([ createSelection(0, 1, 0, 1, DirectionNone), createSelection(1, 1, 1, 1, DirectionNone), @@ -800,11 +800,7 @@ describe('mapSelectionRangeMove', () => { ]; expect( - mapSelectionRangeMove( - textDocument, - selections, - createSelection(1, 1, 1, 3) - ) + mapSelectionShift(textDocument, selections, createSelection(1, 1, 1, 3)) ).toEqual([ createSelection(0, 1, 0, 3, DirectionForward), createSelection(1, 1, 1, 3, DirectionForward), @@ -819,16 +815,30 @@ describe('mapSelectionRangeMove', () => { ]; expect( - mapSelectionRangeMove( - textDocument, - selections, - createSelection(1, 2, 1, 0) - ) + mapSelectionShift(textDocument, selections, createSelection(1, 2, 1, 0)) ).toEqual([ createSelection(0, 0, 0, 2, DirectionBackward), createSelection(1, 0, 1, 2, DirectionBackward), ]); }); + + test('maps a normalized backward range using selection direction', () => { + const textDocument = new TextDocument('inmemory://1', 'abcd\nefgh'); + const selections = [ + createSelection(0, 2, 0, 2), + createSelection(1, 2, 1, 2), + ]; + const shift: EditorSelection = { + start: { line: 1, character: 0 }, + end: { line: 1, character: 2 }, + direction: DirectionBackward, + }; + + expect(mapSelectionShift(textDocument, selections, shift)).toEqual([ + createSelection(0, 0, 0, 2, DirectionBackward), + createSelection(1, 0, 1, 2, DirectionBackward), + ]); + }); }); describe('applyTextReplaceToSelections', () => { From 6892b6cdf3786a452e6d2a505d27e0ed084726b9 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Wed, 13 May 2026 19:53:39 +0800 Subject: [PATCH 136/138] Update demo app --- apps/demo/src/editor.ts | 66 +++++++++++++++++++++++++++++++---------- apps/demo/src/main.ts | 47 ++++++++++++++++------------- apps/demo/src/style.css | 6 +++- 3 files changed, 82 insertions(+), 37 deletions(-) diff --git a/apps/demo/src/editor.ts b/apps/demo/src/editor.ts index 8b910dee2..b17ce45e0 100644 --- a/apps/demo/src/editor.ts +++ b/apps/demo/src/editor.ts @@ -1,4 +1,5 @@ import { + DEFAULT_THEMES, Editor, type FileContents, VirtualizedFile, @@ -6,34 +7,58 @@ import { } from '@pierre/diffs'; import { FileTree, type GitStatusEntry } from '@pierre/trees'; +import { createWorkerAPI } from './utils/createWorkerAPI'; import './style.css'; const API = { // get git status - getGitStatus: () => - fetch(`/git-status/packages/diffs`).then( + getGitStatus: () => { + return fetch(`/git-status/packages/diffs`).then( (res) => res.json() as unknown as GitStatusEntry[] - ), + ); + }, // get paths - getPaths: () => - fetch('/fs/packages/diffs').then( + getPaths: () => { + return fetch('/fs/packages/diffs').then( (res) => res.json() as unknown as string[] - ), + ); + }, // read file from disk - readFile: (path: string) => - fetch(`/fs/packages/diffs/${path}`).then((res) => res.text()), + readFile: (path: string) => { + return fetch(`/fs/packages/diffs/${path}`).then((res) => res.text()); + }, // write file to disk - writeFile: (path: string, contents: string) => - fetch(`/fs/packages/diffs/${path}`, { method: 'POST', body: contents }), + writeFile: (path: string, contents: string) => { + return fetch(`/fs/packages/diffs/${path}`, { + method: 'POST', + body: contents, + }); + }, }; +const recentFile = localStorage.getItem('diffs-editor:recentFile'); const fileTreeContainer = document.getElementById('file-tree-container')!; const editorContainer = document.getElementById('editor-container')!; const editor = new Editor(); const virtualizer = new Virtualizer(); +const poolManager = (() => { + const manager = createWorkerAPI({ + theme: DEFAULT_THEMES, + langs: ['typescript', 'tsx'], + preferredHighlighter: 'shiki-wasm', + useTokenTransformer: true, + }); + void manager.initialize().then(() => { + console.log('WorkerPoolManager initialized, with:', manager.getStats()); + }); + + // @ts-expect-error bcuz + window.__POOL = manager; + return manager; +})(); const fileInstance = new VirtualizedFile( { unsafeCSS: /* CSS */ ` @@ -44,7 +69,9 @@ const fileInstance = new VirtualizedFile( } `, }, - virtualizer + virtualizer, + undefined, + poolManager ); const [paths, gitStatus] = await Promise.all([ API.getPaths(), @@ -74,13 +101,20 @@ async function openDocument(filename: string) { containerWrapper: editorContainer, }); editorContainer.scrollTo({ left: 0, top: 0 }); + localStorage.setItem('diffs-editor:recentFile', filename); } -async function onFileChange(file: FileContents) { - await API.writeFile(file.name, file.contents); - fileTree.setGitStatus(await API.getGitStatus()); +function onFileChange(file: FileContents) { + console.log('writeFile', file.name); + // await API.writeFile(file.name, file.contents); + // fileTree.setGitStatus(await API.getGitStatus()); } -virtualizer.setup(editorContainer); +virtualizer.setup(editorContainer, editorContainer); fileTree.render({ fileTreeContainer }); -editor.edit(fileInstance, () => void onFileChange); +editor.edit(fileInstance, (file) => void onFileChange(file)); + +if (recentFile !== null && paths.includes(recentFile)) { + fileTree.focusPath(recentFile); + void openDocument(recentFile); +} diff --git a/apps/demo/src/main.ts b/apps/demo/src/main.ts index 17a3ab006..f799e6899 100644 --- a/apps/demo/src/main.ts +++ b/apps/demo/src/main.ts @@ -681,6 +681,7 @@ if (renderFileButton != null) { let instance: | File | VirtualizedFile; + let isEditing = false; const options: FileOptions = { overflow: wrap ? 'wrap' : 'scroll', theme: DEMO_THEME, @@ -700,28 +701,34 @@ if (renderFileButton != null) { } } ); - const editableToggle = createToggle('Editable', false, (checked) => { - if (checked) { - editor.edit(instance, (file, lineAnnotations) => { - console.log('change', file, lineAnnotations); - }); - editor.setSelections([ - { - start: { - line: 0, - character: 1000, // will be normalized to the end of the line(< 1000 chars) - }, - end: { - line: 0, - character: 1000, // will be normalized to the end of the line(< 1000 chars) + const editableToggle = createToggle( + 'Editable', + isEditing, + (checked) => { + if (checked) { + isEditing = true; + editor.edit(instance, (file, lineAnnotations) => { + console.log('change', file, lineAnnotations); + }); + editor.setSelections([ + { + start: { + line: 0, + character: 1000, // will be normalized to the end of the line(< 1000 chars) + }, + end: { + line: 0, + character: 1000, // will be normalized to the end of the line(< 1000 chars) + }, + direction: 'none', }, - direction: 'none', - }, - ]); - } else { - editor.cleanUp(); + ]); + } else { + isEditing = false; + editor.cleanUp(); + } } - }); + ); const div = document.createElement('div'); div.style.display = 'flex'; div.style.gap = '8px'; diff --git a/apps/demo/src/style.css b/apps/demo/src/style.css index 3ab681857..a26126987 100644 --- a/apps/demo/src/style.css +++ b/apps/demo/src/style.css @@ -241,6 +241,10 @@ diffs-container { gap: 4px; } +[data-icon-sprite] { + display: none; +} + #editor { display: grid; grid-template-columns: 280px 1fr; @@ -265,7 +269,7 @@ diffs-container { align-items: center; gap: 8px; font-size: 14px; - padding: 16px; + padding: 4px 16px 12px; } #file-tree h1 svg { From 50fe7284367825495cddbcfafe070cd9a2b2942c Mon Sep 17 00:00:00 2001 From: Je Xia Date: Wed, 13 May 2026 23:01:22 +0800 Subject: [PATCH 137/138] Refactor findNextNonOverlappingSubstring method into PieceTable and TextDocument --- packages/diffs/src/editor/editorSelection.ts | 46 +--------- packages/diffs/src/editor/pieceTable.ts | 90 ++++++++++++++++++++ packages/diffs/src/editor/textDocument.ts | 7 ++ packages/diffs/test/pieceTable.test.ts | 27 ++++++ 4 files changed, 125 insertions(+), 45 deletions(-) diff --git a/packages/diffs/src/editor/editorSelection.ts b/packages/diffs/src/editor/editorSelection.ts index 4617fe5c1..5aaf8648b 100644 --- a/packages/diffs/src/editor/editorSelection.ts +++ b/packages/diffs/src/editor/editorSelection.ts @@ -607,8 +607,7 @@ export function findNexMatch( number, ] ); - const nextOffset = findNextNonOverlappingSubstring( - textDocument.getText(), + const nextOffset = textDocument.findNextNonOverlappingSubstring( needle, occupied ); @@ -760,49 +759,6 @@ function expandCollapsedLineWord( return best; } -function findNextNonOverlappingSubstring( - doc: string, - needle: string, - occupied: readonly [number, number][] -): number | undefined { - if (needle.length === 0) { - return undefined; - } - const pivot = Math.max(...occupied.map(([, end]) => end)); - const isFree = (start: number): boolean => { - const end = start + needle.length; - return !occupied.some(([s0, s1]) => start < s1 && s0 < end); - }; - - let pos = pivot; - while (pos <= doc.length - needle.length) { - const idx = doc.indexOf(needle, pos); - if (idx === -1) { - break; - } - if (isFree(idx)) { - return idx; - } - pos = idx + 1; - } - - pos = 0; - while (pos < pivot && pos <= doc.length - needle.length) { - const idx = doc.indexOf(needle, pos); - if (idx === -1) { - break; - } - if (idx >= pivot) { - break; - } - if (isFree(idx)) { - return idx; - } - pos = idx + 1; - } - return undefined; -} - function getSelectionAnchorAndFocusOffsets( textDocument: TextDocument, selection: EditorSelection diff --git a/packages/diffs/src/editor/pieceTable.ts b/packages/diffs/src/editor/pieceTable.ts index d2d7b4534..df5fbcedd 100644 --- a/packages/diffs/src/editor/pieceTable.ts +++ b/packages/diffs/src/editor/pieceTable.ts @@ -178,6 +178,50 @@ export class PieceTable { return found; } + findNextNonOverlappingSubstring( + needle: string, + occupied: readonly [start: number, end: number][] + ): number | undefined { + if (needle.length === 0 || needle.length > this.#length) { + return undefined; + } + + const ranges = normalizeRanges(occupied, this.#length); + const pivot = ranges.reduce((max, [, end]) => Math.max(max, end), 0); + const prefixTable = createPrefixTable(needle); + let matched = 0; + let documentOffset = 0; + let wrappedOffset: number | undefined; + let foundOffset: number | undefined; + + this.#forEachPieceSegment((segment) => { + for (let offset = segment.start; offset < segment.end; offset++) { + const charCode = segment.text.charCodeAt(offset); + while (matched > 0 && charCode !== needle.charCodeAt(matched)) { + matched = prefixTable[matched - 1]; + } + if (charCode === needle.charCodeAt(matched)) { + matched++; + } + if (matched === needle.length) { + const start = documentOffset - needle.length + 1; + if (!rangeOverlaps(ranges, start, start + needle.length)) { + if (start >= pivot) { + foundOffset = start; + return false; + } + wrappedOffset ??= start; + } + matched = prefixTable[matched - 1]; + } + documentOffset++; + } + return true; + }); + + return foundOffset ?? wrappedOffset; + } + insert(text: string, offset: number): void { if (text.length === 0) { return; @@ -637,6 +681,52 @@ function createPrefixTable(text: string): number[] { return table; } +function normalizeRanges( + ranges: readonly [start: number, end: number][], + length: number +): [start: number, end: number][] { + const normalized: [start: number, end: number][] = []; + for (const [rawStart, rawEnd] of ranges) { + const start = clamp(rawStart, 0, length); + const end = clamp(rawEnd, start, length); + if (start < end) { + normalized.push([start, end]); + } + } + normalized.sort((a, b) => a[0] - b[0]); + + const merged: [start: number, end: number][] = []; + for (const range of normalized) { + const previous = merged[merged.length - 1]; + if (previous !== undefined && range[0] <= previous[1]) { + previous[1] = Math.max(previous[1], range[1]); + continue; + } + merged.push(range); + } + return merged; +} + +function rangeOverlaps( + ranges: readonly [start: number, end: number][], + start: number, + end: number +): boolean { + let low = 0; + let high = ranges.length; + while (low < high) { + const mid = low + Math.floor((high - low) / 2); + if (ranges[mid][1] <= start) { + low = mid + 1; + } else { + high = mid; + } + } + + const range = ranges[low]; + return range !== undefined && range[0] < end; +} + // Keeps the table compact after repeated edits by joining neighboring pieces // that already point at contiguous text in the same backing buffer. function coalescePieces(pieces: Piece[]): Piece[] { diff --git a/packages/diffs/src/editor/textDocument.ts b/packages/diffs/src/editor/textDocument.ts index 493e32135..f91b4330c 100644 --- a/packages/diffs/src/editor/textDocument.ts +++ b/packages/diffs/src/editor/textDocument.ts @@ -186,6 +186,13 @@ export class TextDocument { return this.#pieceTable.getTextSlice(start, end); } + findNextNonOverlappingSubstring( + needle: string, + occupied: readonly [start: number, end: number][] + ): number | undefined { + return this.#pieceTable.findNextNonOverlappingSubstring(needle, occupied); + } + applyEdits( edits: TextEdit[], updateHistory = false, diff --git a/packages/diffs/test/pieceTable.test.ts b/packages/diffs/test/pieceTable.test.ts index 963abb63c..79b80674a 100644 --- a/packages/diffs/test/pieceTable.test.ts +++ b/packages/diffs/test/pieceTable.test.ts @@ -253,6 +253,33 @@ describe('PieceTable', () => { expect(table.includes('')).toBe(true); }); + test('finds the next non-overlapping match across piece boundaries', () => { + const table = new PieceTable('foo x fo'); + + table.insert('o foo', table.getText().length); + + expect(table.findNextNonOverlappingSubstring('foo', [[0, 3]])).toBe(6); + expect( + table.findNextNonOverlappingSubstring('foo', [ + [0, 3], + [6, 9], + ]) + ).toBe(10); + expect( + table.findNextNonOverlappingSubstring('foo', [ + [6, 9], + [10, 13], + ]) + ).toBe(0); + expect( + table.findNextNonOverlappingSubstring('foo', [ + [0, 3], + [6, 9], + [10, 13], + ]) + ).toBeUndefined(); + }); + test('tracks trailing newline as an empty final line', () => { const table = new PieceTable('a\n'); From 672dae3b0462f7a4398de0468c6fbccd21f333d2 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Wed, 13 May 2026 23:33:52 +0800 Subject: [PATCH 138/138] Refactor --- packages/diffs/src/editor/editorSelection.ts | 37 ++++++-------------- packages/diffs/src/editor/index.ts | 18 ++++++++-- 2 files changed, 26 insertions(+), 29 deletions(-) diff --git a/packages/diffs/src/editor/editorSelection.ts b/packages/diffs/src/editor/editorSelection.ts index 5aaf8648b..82bf64983 100644 --- a/packages/diffs/src/editor/editorSelection.ts +++ b/packages/diffs/src/editor/editorSelection.ts @@ -116,7 +116,7 @@ export function resolveIndentEdits( */ export function mapCursorMove( textDocument: TextDocument, - selections: readonly EditorSelection[], + selections: EditorSelection[], nextPosition: Position ): EditorSelection[] { const primarySelection = selections[selections.length - 1]; @@ -168,7 +168,7 @@ export function mapCursorMove( */ export function mapSelectionShift( textDocument: TextDocument, - selections: readonly EditorSelection[], + selections: EditorSelection[], selectionShift: EditorSelection ): EditorSelection[] { const primarySelection = selections[selections.length - 1]; @@ -339,7 +339,7 @@ export function applyTextChangeToSelections( export function applyTextReplaceToSelections( textDocument: TextDocument, selections: EditorSelection[], - texts: readonly string[], + texts: string[], lineAnnotations?: LineAnnotation[] ): { nextSelections: EditorSelection[]; @@ -575,25 +575,8 @@ export function extendSelection( */ export function findNexMatch( textDocument: TextDocument, - selections: readonly EditorSelection[] + selections: EditorSelection[] ): EditorSelection[] | undefined { - if (selections.length === 0) { - return undefined; - } - - const allCollapsed = selections.every(isCollapsedSelection); - if (allCollapsed) { - const expanded: EditorSelection[] = []; - for (const sel of selections) { - const word = expandCollapsedSelectionToWord(textDocument, sel); - if (word === undefined) { - return undefined; - } - expanded.push(word); - } - return expanded; - } - const texts = selections.map((s) => textDocument.getText(s)); const needle = texts[0]; if (needle.length === 0 || texts.some((t) => t !== needle)) { @@ -650,7 +633,7 @@ export function getDocumentBoundarySelection( export function getSelectionText( textDocument: TextDocument, - selections: readonly EditorSelection[] + selections: EditorSelection[] ): string { return [...selections] .sort((a, b) => { @@ -699,17 +682,19 @@ export function getSelectionTextNode( throw new Error('No text node found'); } -// Expands a zero-width selection to the word-like segment that contains the caret. -function expandCollapsedSelectionToWord( +/** + * Expands a zero-width selection to the word-like segment that contains the caret. + */ +export function expandCollapsedSelectionToWord( textDocument: TextDocument, selection: EditorSelection -): EditorSelection | undefined { +): EditorSelection { const { line, character } = selection.start; const lineText = textDocument.getLineText(line); const ch = Math.max(0, Math.min(character, lineText.length)); const span = expandCollapsedLineWord(lineText, ch); if (span === undefined) { - return undefined; + return selection; } return { start: { line, character: span.start }, diff --git a/packages/diffs/src/editor/index.ts b/packages/diffs/src/editor/index.ts index e76e233c2..2cf427d9b 100644 --- a/packages/diffs/src/editor/index.ts +++ b/packages/diffs/src/editor/index.ts @@ -16,6 +16,7 @@ import { DirectionBackward, DirectionForward, DirectionNone, + expandCollapsedSelectionToWord, extendSelection, findNexMatch, getDocumentBoundarySelection, @@ -666,9 +667,20 @@ export class Editor implements DiffsEditor { if (selections === undefined || textDocument === undefined) { break; } - const next = findNexMatch(textDocument, selections); - if (next !== undefined) { - this.#updateSelections(next); + const hasCollapsed = selections.some(isCollapsedSelection); + if (hasCollapsed) { + const expanded: EditorSelection[] = selections.map((sel) => { + if (isCollapsedSelection(sel)) { + return expandCollapsedSelectionToWord(textDocument, sel); + } + return sel; + }); + this.#updateSelections(expanded, true); + } else { + const nextMatch = findNexMatch(textDocument, selections); + if (nextMatch !== undefined) { + this.#updateSelections(nextMatch, true); + } } break; }