@pierre/diffs
Version:
322 lines (321 loc) • 12.3 kB
JavaScript
//#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