From 1a1adadfcc815d559f68661ac289841cc138406f Mon Sep 17 00:00:00 2001 From: Je Xia Date: Fri, 12 Jun 2026 01:15:18 +0800 Subject: [PATCH] [diffs/editor] refactor editor API --- packages/diffs/src/components/File.ts | 6 +- packages/diffs/src/components/FileDiff.ts | 6 +- packages/diffs/src/editor/editor.ts | 452 ++++++++++++++-------- packages/diffs/src/editor/tokenzier.ts | 2 +- packages/diffs/src/types.ts | 10 +- 5 files changed, 308 insertions(+), 168 deletions(-) diff --git a/packages/diffs/src/components/File.ts b/packages/diffs/src/components/File.ts index 0abc49872..8f3265dca 100644 --- a/packages/diffs/src/components/File.ts +++ b/packages/diffs/src/components/File.ts @@ -471,7 +471,7 @@ export class File< const file = this.file; if (fileContainer != null && file != null) { void this.fileRenderer.initializeHighlighter().then((highlighter) => { - editor.syncToRenderedView( + editor.__resetEditState( highlighter, fileContainer, file, @@ -529,7 +529,7 @@ export class File< // postpone background tokenizing to next frame for avoiding UI freeze // during render - this.editor?.postponeBackgroundTokenizeToNextFrame(); + this.editor?.__postponeBackgroundTokenizeToNextFrame(); const { collapsed = false, themeType = 'system' } = this.options; const nextRenderRange = collapsed ? undefined : renderRange; @@ -659,7 +659,7 @@ export class File< const editor = this.editor; if (editor != null) { void this.fileRenderer.initializeHighlighter().then((highlighter) => { - editor.syncToRenderedView( + editor.__resetEditState( highlighter, fileContainer, file, diff --git a/packages/diffs/src/components/FileDiff.ts b/packages/diffs/src/components/FileDiff.ts index 585d57fb0..a5905e117 100644 --- a/packages/diffs/src/components/FileDiff.ts +++ b/packages/diffs/src/components/FileDiff.ts @@ -761,7 +761,7 @@ export class FileDiff< // postpone background tokenizing to next frame for avoiding UI freeze // during render - this.editor?.postponeBackgroundTokenizeToNextFrame(); + this.editor?.__postponeBackgroundTokenizeToNextFrame(); const { collapsed = false, themeType = 'system' } = this.options; const nextRenderRange = collapsed ? undefined : renderRange; @@ -950,7 +950,7 @@ export class FileDiff< const file = this.getAdditionFile(); if (editor != null && file != null) { void this.hunksRenderer.initializeHighlighter().then((highlighter) => { - editor.syncToRenderedView( + editor.__resetEditState( highlighter, fileContainer, file, @@ -1008,7 +1008,7 @@ export class FileDiff< const file = this.getAdditionFile(); if (fileContainer != null && file != null) { void this.hunksRenderer.initializeHighlighter().then((highlighter) => { - editor.syncToRenderedView( + editor.__resetEditState( highlighter, fileContainer, file, diff --git a/packages/diffs/src/editor/editor.ts b/packages/diffs/src/editor/editor.ts index 3f7568b8a..b9c2226c0 100644 --- a/packages/diffs/src/editor/editor.ts +++ b/packages/diffs/src/editor/editor.ts @@ -1,20 +1,28 @@ +import { + getFiletypeFromFileName, + VirtualizedFile, + VirtualizedFileDiff, + Virtualizer, +} from '..'; import type { + BaseCodeOptions, DiffLineAnnotation, DiffsEditableComponent, DiffsEditor, DiffsEditorSelection, DiffsHighlighter, FileContents, + FileDiffMetadata, HighlightedToken, LineAnnotation, RenderRange, } from '../types'; -import { getFiletypeFromFileName } from '../utils/getFiletypeFromFileName'; import { type EditorCommand, resolveEditorCommandFromKeyboardEvent, } from './command'; import editorCSS from './editor.css'; +import { EditStack } from './editStack'; import { applyDocumentChangeToLineAnnotations, renderLineAnnotations, @@ -84,6 +92,8 @@ import { } from './utils'; export interface EditorOptions { + /** The maximum number of entries to keep in the undo stack. */ + historyMaxEntries?: number; /** Render rounded corners for selection ranges, default is true. */ roundedSelection?: boolean; /** Show the clickable selection action icon, default is disabled. */ @@ -100,6 +110,12 @@ export interface EditorOptions { __debug?: boolean; } +export interface EditorState { + file?: FileContents; + diff?: FileDiffMetadata; + selections?: EditorSelection[]; +} + export class Editor implements DiffsEditor { #options: EditorOptions; #wrap = false; @@ -231,7 +247,259 @@ export class Editor implements DiffsEditor { return () => this.cleanUp(); } - syncToRenderedView( + render( + container: HTMLElement, + options: BaseCodeOptions & { + file?: FileContents; + fileDiff?: FileDiffMetadata; + } + ): void { + const { file, fileDiff } = options; + const virtualizer = new Virtualizer(); + let fileInstance: DiffsEditableComponent | undefined; + if (file !== undefined) { + fileInstance = new VirtualizedFile( + { + ...options, + useTokenTransformer: true, + }, + virtualizer + ); + } else if (fileDiff !== undefined) { + fileInstance = new VirtualizedFileDiff( + { + ...options, + useTokenTransformer: true, + expandUnchanged: true, + }, + virtualizer + ); + } else { + throw new Error('Either file or fileDiff must be provided'); + } + fileInstance.render({ + containerWrapper: container, + file, + fileDiff, + }); + virtualizer.setup(container); + this.edit(fileInstance); + } + + openFile(file: FileContents): void { + if (this.#fileInstance === undefined) { + throw new Error('Editor is not attached to a file instance'); + } + this.#fileInstance.render({ + file, + }); + } + + openDiff(diff: FileDiffMetadata): void { + if (this.#fileInstance === undefined) { + throw new Error('Editor is not attached to a file instance'); + } + this.#fileInstance.render({ + fileDiff: diff, + }); + } + + /** + * Apply edits to the file. + */ + applyEdits(filename: string, edits: TextEdit[], updateHistory = false): void { + const fileContents = this.#fileContents; + const textDocument = this.#textDocument; + if ( + fileContents === undefined || + textDocument === undefined || + fileContents.name !== filename + ) { + return; + } + const change = textDocument.applyEdits( + edits, + updateHistory, + this.#selections + ); + if (change !== undefined) { + this.#applyChangeToView( + change, + undefined, + this.#applyChangeToLineAnnotations(change) + ); + } + } + + getState(): EditorState { + const fileContents = this.#fileContents; + const textDocument = this.#textDocument; + if (fileContents === undefined || textDocument === undefined) { + throw new Error('Editor is not attached to a file instance'); + } + const { contents: _, ...file } = fileContents; + Object.defineProperty(file, 'contents', { + enumerable: true, + get: () => textDocument.getText(), + }); + return { + file: file as FileContents, + selections: this.#selections, + }; + } + + setState({ file, diff: fileDiff, selections = [] }: EditorState): void { + if (file !== undefined) { + this.openFile(file); + } else if (fileDiff !== undefined) { + this.openDiff(fileDiff); + } else { + throw new Error('Invalid editor state'); + } + requestAnimationFrame(() => { + this.#updateSelections(selections); + if (selections.length > 0) { + this.#scrollToPrimaryCaret(); + } + }); + } + + setSelections(selections: DiffsEditorSelection[]): void { + const textDocument = this.#textDocument; + if (textDocument !== undefined) { + 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 }; + } + ); + this.#updateSelections(resolvedSelections); + this.#scrollToPrimaryCaret(); + } else { + this.#initSelections = selections; + } + } + + setMarkers(markers: Marker[]): void { + const textDocument = this.#textDocument; + if (textDocument === undefined) { + throw new Error('Text document is not initialized'); + } + + if (markers.length === 0) { + this.#markerManager?.cleanup(); + this.#markerManager = undefined; + this.#updateSelections(this.#selections ?? []); + return; + } + + this.#markerManager ??= new MarkerManager({ + getLineHeight: () => this.#metrics.lineHeight, + getFileContainer: () => this.#fileContainer, + getCharX: (line, character) => this.#getCharX(line, character), + getLineY: (line) => this.#getLineY(line), + isMouseDown: () => this.#isContentMouseDown || this.#isGutterMouseDown, + }); + this.#markerManager.setMarkers(markers, textDocument); + if (this.#contentElement !== undefined) { + this.#markerManager.listenHover(this.#contentElement); + } + this.#updateSelections(this.#selections ?? []); + } + + focus(options?: FocusOptions): void { + const preventScroll = options?.preventScroll ?? false; + const primarySelection = this.#selections?.at(-1); + if (primarySelection !== undefined) { + const pos = + primarySelection.direction === DirectionBackward + ? primarySelection.end + : primarySelection.start; + this.#focus(pos, preventScroll); + } else { + this.#focus(undefined, preventScroll); + } + } + + cleanUp(): void { + this.#tokenizer?.cleanUp(); + this.#tokenizer = undefined; + + this.#globalEventDisposes?.forEach((dispose) => dispose()); + this.#globalEventDisposes = undefined; + this.#editorEventDisposes?.forEach((dispose) => dispose()); + this.#editorEventDisposes = undefined; + this.#selectEventDisposes?.forEach((dispose) => dispose()); + this.#selectEventDisposes = undefined; + this.#markerManager?.cleanup(); + this.#markerManager = undefined; + + this.#detach?.(); + this.#detach = undefined; + this.#fileInstance?.setSelectedLines(null); + this.#fileInstance = undefined; + this.#fileContents = undefined; + this.#lineAnnotations = undefined; + this.#textDocument = undefined; + this.#renderRange = undefined; + + this.#gutterWidthCache = undefined; + this.#contentWidthCache = undefined; + this.#lineYCache.clear(); + this.#wrapLineOffsetsCache.clear(); + this.#lastAccessedLineElement = undefined; + this.#lastAccessedCharX = undefined; + + this.#globalStyleElement?.remove(); + this.#globalStyleElement = undefined; + this.#editorStyleElement?.remove(); + this.#editorStyleElement = undefined; + this.#themeStyleElement?.remove(); + this.#themeStyleElement = undefined; + this.#spriteElement?.remove(); + this.#spriteElement = undefined; + this.#fileContainer = undefined; + this.#gutterElement = undefined; + this.#contentElement?.removeAttribute('contentEditable'); + this.#contentElement = undefined; + this.#overlayElement?.remove(); + this.#overlayElement = undefined; + this.#overlayElements?.forEach((el) => el.remove()); + this.#overlayElements = undefined; + this.#primaryCaretElement = undefined; + this.#searchPanel?.cleanup(); + this.#searchPanel = undefined; + this.#selectionAction?.cleanup(); + this.#selectionAction = undefined; + this.#resizeObserver?.disconnect(); + this.#resizeObserver = undefined; + + this.#shouldIgnoreSelectionChange = false; + this.#selectionStart = undefined; + this.#selections = undefined; + this.#reservedSelections = undefined; + } + + /** @internal */ + __postponeBackgroundTokenizeToNextFrame(): void { + const tokenizer = this.#tokenizer; + if (tokenizer !== undefined) { + tokenizer.pauseBackgroundTokenize(); + requestAnimationFrame(() => { + tokenizer.resumeBackgroundTokenize(); + }); + } + } + + /** @internal */ + __resetEditState( highlighter: DiffsHighlighter, fileContainer: HTMLElement, fileContents: FileContents, @@ -289,10 +557,15 @@ export class Editor implements DiffsEditor { this.#fileContents === undefined || didFileChange ) { + const editStack = new EditStack({ + maxEntries: this.#options.historyMaxEntries, + }); const textDocument = new TextDocument( fileContents.name, fileContents.contents, - fileContents.lang ?? getFiletypeFromFileName(fileContents.name) + fileContents.lang ?? getFiletypeFromFileName(fileContents.name), + 0, + editStack ); this.#fileContents = fileContents; this.#textDocument = textDocument; @@ -417,139 +690,6 @@ export class Editor implements DiffsEditor { } } - postponeBackgroundTokenizeToNextFrame(): void { - const tokenizer = this.#tokenizer; - if (tokenizer !== undefined) { - tokenizer.pauseBackgroundTokenize(); - requestAnimationFrame(() => { - tokenizer.resumeBackgroundTokenize(); - }); - } - } - - setSelections(selections: DiffsEditorSelection[]): void { - const textDocument = this.#textDocument; - if (textDocument !== undefined) { - 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 }; - } - ); - this.#updateSelections(resolvedSelections); - this.#scrollToPrimaryCaret(); - } else { - this.#initSelections = selections; - } - } - - setMarkers(markers: Marker[]): void { - const textDocument = this.#textDocument; - if (textDocument === undefined) { - throw new Error('Text document is not initialized'); - } - - if (markers.length === 0) { - this.#markerManager?.cleanup(); - this.#markerManager = undefined; - this.#updateSelections(this.#selections ?? []); - return; - } - - this.#markerManager ??= new MarkerManager({ - getLineHeight: () => this.#metrics.lineHeight, - getFileContainer: () => this.#fileContainer, - getCharX: (line, character) => this.#getCharX(line, character), - getLineY: (line) => this.#getLineY(line), - isMouseDown: () => this.#isContentMouseDown || this.#isGutterMouseDown, - }); - this.#markerManager.setMarkers(markers, textDocument); - if (this.#contentElement !== undefined) { - this.#markerManager.listenHover(this.#contentElement); - } - this.#updateSelections(this.#selections ?? []); - } - - focus(options?: FocusOptions): void { - const preventScroll = options?.preventScroll ?? false; - const primarySelection = this.#selections?.at(-1); - if (primarySelection !== undefined) { - const pos = - primarySelection.direction === DirectionBackward - ? primarySelection.end - : primarySelection.start; - this.#focus(pos, preventScroll); - } else { - this.#focus(undefined, preventScroll); - } - } - - cleanUp(): void { - this.#tokenizer?.cleanUp(); - this.#tokenizer = undefined; - - this.#globalEventDisposes?.forEach((dispose) => dispose()); - this.#globalEventDisposes = undefined; - this.#editorEventDisposes?.forEach((dispose) => dispose()); - this.#editorEventDisposes = undefined; - this.#selectEventDisposes?.forEach((dispose) => dispose()); - this.#selectEventDisposes = undefined; - this.#markerManager?.cleanup(); - this.#markerManager = undefined; - - this.#detach?.(); - this.#detach = undefined; - this.#fileInstance?.setSelectedLines(null); - this.#fileInstance = undefined; - this.#fileContents = undefined; - this.#lineAnnotations = undefined; - this.#textDocument = undefined; - this.#renderRange = undefined; - - this.#gutterWidthCache = undefined; - this.#contentWidthCache = undefined; - this.#lineYCache.clear(); - this.#wrapLineOffsetsCache.clear(); - this.#lastAccessedLineElement = undefined; - this.#lastAccessedCharX = undefined; - - this.#globalStyleElement?.remove(); - this.#globalStyleElement = undefined; - this.#editorStyleElement?.remove(); - this.#editorStyleElement = undefined; - this.#themeStyleElement?.remove(); - this.#themeStyleElement = undefined; - this.#spriteElement?.remove(); - this.#spriteElement = undefined; - this.#fileContainer = undefined; - this.#gutterElement = undefined; - this.#contentElement?.removeAttribute('contentEditable'); - this.#contentElement = undefined; - this.#overlayElement?.remove(); - this.#overlayElement = undefined; - this.#overlayElements?.forEach((el) => el.remove()); - this.#overlayElements = undefined; - this.#primaryCaretElement = undefined; - this.#searchPanel?.cleanup(); - this.#searchPanel = undefined; - this.#selectionAction?.cleanup(); - this.#selectionAction = undefined; - this.#resizeObserver?.disconnect(); - this.#resizeObserver = undefined; - - this.#shouldIgnoreSelectionChange = false; - this.#selectionStart = undefined; - this.#selections = undefined; - this.#reservedSelections = undefined; - } - #initialize(): void { // Safari doesn't support `::selection` for slot elements in ShadowDOM, // Add a global style to disable selection for slot elements @@ -1101,7 +1241,7 @@ export class Editor implements DiffsEditor { nextSelections ); if (change !== undefined) { - this.#applyChange(change, nextSelections); + this.#applyChangeToView(change, nextSelections); } } break; @@ -1143,7 +1283,7 @@ export class Editor implements DiffsEditor { if (this.#textDocument?.canUndo === true) { const undoResult = this.#textDocument.undo(); if (undoResult !== undefined) { - this.#applyChange(...undoResult); + this.#applyChangeToView(...undoResult); } } break; @@ -1152,7 +1292,7 @@ export class Editor implements DiffsEditor { if (this.#textDocument?.canRedo === true) { const redoResult = this.#textDocument.redo(); if (redoResult !== undefined) { - this.#applyChange(...redoResult); + this.#applyChangeToView(...redoResult); } } break; @@ -1455,11 +1595,11 @@ export class Editor implements DiffsEditor { } #scrollToPrimaryCaret(noFocus = false) { - const primaryCaretElement = this.#primaryCaretElement; const primarySelection = this.#selections?.at(-1); if (primarySelection === undefined) { return; } + const primaryCaretElement = this.#primaryCaretElement; if (primaryCaretElement !== undefined) { primaryCaretElement.scrollIntoView({ block: 'nearest', @@ -1489,7 +1629,7 @@ export class Editor implements DiffsEditor { } #scrollToLine(line: number, char = 0, noFocus = false) { - this.postponeBackgroundTokenizeToNextFrame(); + this.__postponeBackgroundTokenizeToNextFrame(); const virtualCaret = h('div', { style: { @@ -1559,7 +1699,7 @@ export class Editor implements DiffsEditor { } #updateSelections(selections: EditorSelection[]) { - this.postponeBackgroundTokenizeToNextFrame(); + this.__postponeBackgroundTokenizeToNextFrame(); this.#primaryCaretElement = undefined; this.#fileInstance?.setSelectedLines(null); @@ -2057,10 +2197,12 @@ export class Editor implements DiffsEditor { // remove the existing selection action element cleanUpSelectionAction(); + const filename = this.#fileContents?.name; const textDocument = this.#textDocument; const renderSelectionAction = this.#options.renderSelectionAction; const fileContainer = this.#fileContainer; if ( + filename === undefined || textDocument === undefined || renderSelectionAction === undefined || fileContainer == null @@ -2073,16 +2215,8 @@ export class Editor implements DiffsEditor { const selectionActionElement = renderSelectionAction({ textDocument, selection, - applyEdits: (edits: TextEdit[]) => { - const change = textDocument.applyEdits( - edits, - true, - this.#selections - ); - if (change !== undefined) { - this.#applyChange(change); - } - }, + applyEdits: (edits: TextEdit[]) => + this.applyEdits(filename, edits, true), getSelectionText: () => { return this.#textDocument?.getText(selection) ?? ''; }, @@ -2256,7 +2390,7 @@ export class Editor implements DiffsEditor { ); if (change !== undefined) { - this.#applyChange( + this.#applyChangeToView( change, nextSelections, this.#applyChangeToLineAnnotations(change) @@ -2325,7 +2459,7 @@ export class Editor implements DiffsEditor { this.#lineAnnotations ); if (change !== undefined) { - this.#applyChange( + this.#applyChangeToView( change, nextSelections, this.#applyChangeToLineAnnotations(change) @@ -2346,7 +2480,7 @@ export class Editor implements DiffsEditor { this.#lineAnnotations ); if (change !== undefined) { - this.#applyChange( + this.#applyChangeToView( change, nextSelections, this.#applyChangeToLineAnnotations(change) @@ -2367,7 +2501,7 @@ export class Editor implements DiffsEditor { this.#lineAnnotations ); if (change !== undefined) { - this.#applyChange( + this.#applyChangeToView( change, nextSelections, this.#applyChangeToLineAnnotations(change) @@ -2387,7 +2521,7 @@ export class Editor implements DiffsEditor { this.#lineAnnotations ); if (change !== undefined) { - this.#applyChange( + this.#applyChangeToView( change, nextSelections, this.#applyChangeToLineAnnotations(change) @@ -2407,7 +2541,7 @@ export class Editor implements DiffsEditor { this.#metrics.tabSize ); if (change !== undefined) { - this.#applyChange( + this.#applyChangeToView( change, nextSelections, this.#applyChangeToLineAnnotations(change) @@ -2415,7 +2549,7 @@ export class Editor implements DiffsEditor { } } - #applyChange( + #applyChangeToView( change: TextDocumentChange, selections?: EditorSelection[], newLineAnnotations?: DiffLineAnnotation[] diff --git a/packages/diffs/src/editor/tokenzier.ts b/packages/diffs/src/editor/tokenzier.ts index ca9baa5ea..f824804e5 100644 --- a/packages/diffs/src/editor/tokenzier.ts +++ b/packages/diffs/src/editor/tokenzier.ts @@ -192,8 +192,8 @@ export class EditorTokenizer { } cleanUp(): void { - this.#detachMessageListener(); this.stopBackgroundTokenize(); + this.#detachMessageListener(); this.#disposes?.forEach((dispose) => dispose()); this.#disposes = undefined; } diff --git a/packages/diffs/src/types.ts b/packages/diffs/src/types.ts index 3f01a589c..9d4cdc26d 100644 --- a/packages/diffs/src/types.ts +++ b/packages/diffs/src/types.ts @@ -900,6 +900,12 @@ export interface DiffsBaseComponent { readonly options: DiffsComponentOptions; setOptions: (options: Partial) => void; setSelectedLines: (range: { start: number; end: number } | null) => void; + render(options: { + containerWrapper?: HTMLElement; + file?: FileContents; + fileDiff?: FileDiffMetadata; + renderRange?: RenderRange; + }): void; rerender(): void; cleanUp(): void; } @@ -921,7 +927,8 @@ export interface DiffsEditableComponent< } export interface DiffsEditor { - syncToRenderedView( + __postponeBackgroundTokenizeToNextFrame(): void; + __resetEditState( highlighter: DiffsHighlighter, fileContainer: HTMLElement, fileContents: FileContents, @@ -932,7 +939,6 @@ export interface DiffsEditor { | undefined, renderRange: RenderRange | undefined ): void; - postponeBackgroundTokenizeToNextFrame(): void; cleanUp(): void; }