UNPKG

@pierre/diffs

Version:

322 lines (321 loc) 12.3 kB
//#region src/editor/editStack.ts /** Largest number of undo or redo entries kept; oldest entries drop first once exceeded. */ const DEFAULT_EDIT_STACK_MAX_ENTRIES = 100; const COALESCING_MODE_INSERT = "insert"; const COALESCING_MODE_BACKSPACE = "backspace"; const COALESCING_MODE_DELETE = "delete"; /** A stack of edit entries. */ var EditStack = class EditStack { #undoStack = []; #redoStack = []; #maxEntries; #canCoalesce = false; constructor(options) { this.#maxEntries = Math.max(1, options?.maxEntries ?? DEFAULT_EDIT_STACK_MAX_ENTRIES); } get canUndo() { return this.#undoStack.length > 0; } get canRedo() { return this.#redoStack.length > 0; } /** Return an independent, developer-editable history snapshot. */ getState() { return { undoStack: this.#undoStack.map(cloneEditStackEntryForState), redoStack: this.#redoStack.map(cloneEditStackEntryForState), maxEntries: this.#maxEntries, canCoalesce: this.#canCoalesce }; } /** Return a live view over this stack without copying its entries. */ getLiveState() { return Object.defineProperties({}, { undoStack: { enumerable: true, get: () => this.#undoStack }, redoStack: { enumerable: true, get: () => this.#redoStack }, maxEntries: { enumerable: true, get: () => this.#maxEntries }, canCoalesce: { enumerable: true, get: () => this.#canCoalesce } }); } /** Transfer developer-owned history into an edit stack. */ static fromState(state) { const stack = new EditStack({ maxEntries: state.maxEntries }); stack.#undoStack = state.undoStack; stack.#redoStack = state.redoStack; stack.#canCoalesce = state.canCoalesce; return stack; } /** Clears both the undo and redo stacks. */ clear() { this.#undoStack.length = 0; this.#redoStack.length = 0; this.#canCoalesce = false; } /** Clears the redo stack. */ clearRedo() { this.#redoStack.length = 0; } /** Pushes a new entry onto the undo stack. */ push(entry) { this.#undoStack.push(entry); this.clearRedo(); this.#canCoalesce = true; if (this.#undoStack.length > this.#maxEntries) this.#undoStack.shift(); } /** Sets the selections after the last undo entry. */ setLastUndoSelectionsAfter(selections) { const lastEntry = this.#undoStack[this.#undoStack.length - 1]; if (lastEntry !== void 0) lastEntry.selectionsAfter = selections.map((selection) => ({ ...selection })); } /** Sets the line annotations after the last undo entry. */ setLastUndoLineAnnotations(lineAnnotationsBefore, lineAnnotationsAfter) { const lastEntry = this.#undoStack[this.#undoStack.length - 1]; if (lastEntry !== void 0) { lastEntry.lineAnnotationsBefore = lineAnnotationsBefore.slice(); lastEntry.lineAnnotationsAfter = lineAnnotationsAfter.slice(); } } /** Returns the last undo entry, or `undefined` if empty. */ peekUndo() { return this.#undoStack[this.#undoStack.length - 1]; } /** Returns the last undo entry only while its coalescing group is open. */ peekUndoForCoalescing() { return this.#canCoalesce ? this.peekUndo() : void 0; } /** Replaces the last undo entry with the given entry. */ replaceLastUndo(entry) { if (this.#undoStack.length === 0) { this.push(entry); return; } this.#undoStack[this.#undoStack.length - 1] = entry; this.clearRedo(); this.#canCoalesce = true; } /** Moves the latest undo entry to the redo stack and returns it, or `undefined` if empty. */ popUndoToRedo() { const entry = this.#undoStack.pop(); if (entry !== void 0) { this.#redoStack.push(entry); this.#canCoalesce = false; return entry; } } /** Moves the latest redo entry back to the undo stack and returns it, or `undefined` if empty. */ popRedoToUndo() { const entry = this.#redoStack.pop(); if (entry !== void 0) { this.#undoStack.push(entry); this.#canCoalesce = false; return entry; } } }; function cloneEditStackEntry(entry) { return { ...entry, forwardEdits: entry.forwardEdits.map((edit) => ({ ...edit })), inverseEdits: entry.inverseEdits.map((edit) => ({ ...edit })), selectionsBefore: entry.selectionsBefore?.map(cloneSelection), selectionsAfter: entry.selectionsAfter?.map(cloneSelection), lineAnnotationsBefore: entry.lineAnnotationsBefore?.slice(), lineAnnotationsAfter: entry.lineAnnotationsAfter?.slice() }; } function cloneEditStackEntryForState(entry) { return { ...cloneEditStackEntry(entry), lineAnnotationsBefore: entry.lineAnnotationsBefore?.map((annotation) => ({ ...annotation })), lineAnnotationsAfter: entry.lineAnnotationsAfter?.map((annotation) => ({ ...annotation })) }; } function cloneSelection(selection) { return { ...selection, start: { ...selection.start }, end: { ...selection.end } }; } function createEditStackEntry(textDocument, resolvedEdits, versionBefore, versionAfter, selectionsBefore, selectionsAfter, lineAnnotationsBefore, lineAnnotationsAfter) { const forwardEdits = [...resolvedEdits].sort((a, b) => a.start - b.start); const inverseEdits = []; let coalescingMode; if (selectionsBefore?.length === forwardEdits.length) { const caretOffsets = []; for (const selection of selectionsBefore) { if (selection.start.line !== selection.end.line || selection.start.character !== selection.end.character) break; caretOffsets.push(textDocument.offsetAt(selection.start)); } if (caretOffsets.length === forwardEdits.length) { caretOffsets.sort((a, b) => a - b); let isBackspace = true; let isDelete = true; for (let i = 0; i < forwardEdits.length; i++) { const edit = forwardEdits[i]; if (edit.text.length > 0 || edit.start === edit.end) { isBackspace = false; isDelete = false; break; } isBackspace &&= caretOffsets[i] === edit.end; isDelete &&= caretOffsets[i] === edit.start; } coalescingMode = isBackspace ? COALESCING_MODE_BACKSPACE : isDelete ? COALESCING_MODE_DELETE : void 0; } } 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; inverseEdits.push({ start: startAfterEdit, end: startAfterEdit + edit.text.length, text: replacedText }); offsetDelta += edit.text.length - (edit.end - edit.start); } return { forwardEdits: forwardEdits.map((edit) => ({ ...edit })), inverseEdits, versionBefore, versionAfter, selectionsBefore: selectionsBefore?.map((selection) => ({ ...selection })), selectionsAfter: selectionsAfter?.map((selection) => ({ ...selection })), lineAnnotationsBefore: lineAnnotationsBefore?.slice(), lineAnnotationsAfter: lineAnnotationsAfter?.slice(), coalescingMode }; } /** Determines if the change matches following modes: * - 'insert': simple typing * - 'backspace': backward delete * - 'delete': forward delete */ function shouldCoalesceEditStackEntry(previousEntry, nextEntry) { if (previousEntry === void 0 || previousEntry.undoBoundary === true || nextEntry.undoBoundary === true || 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; 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 && !previousForward.text.includes("\n") && !previousInverse.text.includes("\n"); const nextIsInsert = nextForward.start === nextForward.end && nextForward.text.length > 0 && !nextForward.text.includes("\n") && nextInverse.text.length === 0; if (previousWasInsert && nextIsInsert) { if (nextForward.start !== previousInverse.end) return false; mode ??= COALESCING_MODE_INSERT; if (mode !== COALESCING_MODE_INSERT || previousEntry.coalescingMode !== void 0 && previousEntry.coalescingMode !== COALESCING_MODE_INSERT || nextEntry.coalescingMode !== void 0 && nextEntry.coalescingMode !== COALESCING_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) { let nextMode; if (mappedNextStart === previousForward.end) nextMode = COALESCING_MODE_DELETE; else if (mappedNextStart + (nextForward.end - nextForward.start) !== previousForward.start) return false; else nextMode = COALESCING_MODE_BACKSPACE; mode ??= nextMode; if (mode !== nextMode || previousEntry.coalescingMode !== void 0 && previousEntry.coalescingMode !== nextMode || nextEntry.coalescingMode !== void 0 && nextEntry.coalescingMode !== nextMode) return false; continue; } return false; } return mode !== void 0; } /** Coalesce edit stack entries for simple typing and single-character deletes. */ function coalesceEditStackEntries(previousEntry, nextEntry) { const forwardEdits = []; const replacedTexts = []; let coalescingMode; 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) { coalescingMode ??= COALESCING_MODE_INSERT; forwardEdits.push({ start: previousForward.start, end: previousForward.end, text: previousForward.text + nextForward.text }); replacedTexts.push(previousInverse.text); continue; } if (mappedNextStart === previousForward.end) { coalescingMode ??= COALESCING_MODE_DELETE; forwardEdits.push({ start: previousForward.start, end: mappedNextStart + (nextForward.end - nextForward.start), text: "" }); replacedTexts.push(previousInverse.text + nextInverse.text); continue; } coalescingMode ??= COALESCING_MODE_BACKSPACE; 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(), coalescingMode }; } function buildInverseEditsFromReplacedTexts(forwardEdits, replacedTexts) { const inverseEdits = []; 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, forwardEdits) { 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; } //#endregion export { EditStack, coalesceEditStackEntries, createEditStackEntry, shouldCoalesceEditStackEntry }; //# sourceMappingURL=editStack.js.map