Skip to content

Commit

Permalink
Edit session operation (#5657)
Browse files Browse the repository at this point in the history
* feat(edit-session): adding operation handling
---------

Co-authored-by: Marin Sokol <[email protected]>
  • Loading branch information
marinsokol5 and sokomari authored Oct 21, 2024
1 parent 37c5f76 commit aba0575
Show file tree
Hide file tree
Showing 5 changed files with 260 additions and 48 deletions.
14 changes: 14 additions & 0 deletions ace-internal.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,10 @@ export namespace Ace {
* @param delta
*/
"change": (delta: Delta) => void;
/**
* Emitted when the selection changes.
*/
"changeSelection": () => void;
/**
* Emitted when the tab size changes, via [[EditSession.setTabSize]].
* @param tabSize
Expand Down Expand Up @@ -492,6 +496,16 @@ export namespace Ace {
**/
"changeScrollLeft": (scrollLeft: number) => void;
"changeEditor": (e: { editor: Editor }) => void;
/**
* Emitted after operation starts.
* @param commandEvent event causing the operation
*/
"startOperation": (commandEvent) => void;
/**
* Emitted after operation finishes.
* @param e event causing the finish
*/
"endOperation": (e) => void;
}

interface EditorEvents {
Expand Down
11 changes: 10 additions & 1 deletion ace.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,13 @@ export namespace Ace {

export interface EditSession extends EventEmitter, OptionsProvider, Folding {
selection: Selection;
curOp?: {
docChanged?: boolean;
selectionChanged?: boolean;
command?: {
name?: string;
};
};

// TODO: define BackgroundTokenizer

Expand All @@ -517,7 +524,7 @@ export namespace Ace {
callback: (obj: { data: { first: number, last: number } }) => void): Function;
on(name: 'change', callback: () => void): Function;
on(name: 'changeTabSize', callback: () => void): Function;

on(name: "beforeEndOperation", callback: () => void): Function;

setOption<T extends keyof EditSessionOptions>(name: T, value: EditSessionOptions[T]): void;
getOption<T extends keyof EditSessionOptions>(name: T): EditSessionOptions[T];
Expand Down Expand Up @@ -625,6 +632,8 @@ export namespace Ace {
documentToScreenRow(docRow: number, docColumn: number): number;
getScreenLength(): number;
getPrecedingCharacter(): string;
startOperation(commandEvent: {command: {name: string}}): void;
endOperation(): void;
toJSON(): Object;
destroy(): void;
}
Expand Down
92 changes: 92 additions & 0 deletions src/edit_session.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ class EditSession {
this.$backMarkers = {};
this.$markerId = 1;
this.$undoSelect = true;
this.prevOp = {};

/** @type {FoldLine[]} */
this.$foldData = [];
Expand All @@ -69,14 +70,96 @@ class EditSession {
text = new Document(/**@type{string}*/(text));

this.setDocument(text);

this.selection = new Selection(this);
this.$onSelectionChange = this.onSelectionChange.bind(this);
this.selection.on("changeSelection", this.$onSelectionChange);
this.selection.on("changeCursor", this.$onSelectionChange);

this.$bidiHandler = new BidiHandler(this);

config.resetOptions(this);
this.setMode(mode);
config._signal("session", this);

this.destroyed = false;
this.$initOperationListeners();
}

$initOperationListeners() {
this.curOp = null;
this.on("change", () => {
if (!this.curOp) {
this.startOperation();
this.curOp.selectionBefore = this.$lastSel;
}
this.curOp.docChanged = true;
}, true);
this.on("changeSelection", () => {
if (!this.curOp) {
this.startOperation();
this.curOp.selectionBefore = this.$lastSel;
}
this.curOp.selectionChanged = true;
}, true);

// Fallback mechanism in case current operation doesn't finish more explicitly.
// Triggered, for example, when a consumer makes programmatic changes without invoking endOperation afterwards.
this.$operationResetTimer = lang.delayedCall(this.endOperation.bind(this, true));
}

/**
* Start an Ace operation, which will then batch all the subsequent changes (to either content or selection) under a single atomic operation.
* @param {{command?: {name?: string}, args?: any}|undefined} [commandEvent] Optional name for the operation
*/
startOperation(commandEvent) {
if (this.curOp) {
if (!commandEvent || this.curOp.command) {
return;
}
this.prevOp = this.curOp;
}
if (!commandEvent) {
commandEvent = {};
}

this.$operationResetTimer.schedule();
this.curOp = {
command: commandEvent.command || {},
args: commandEvent.args
};
this.curOp.selectionBefore = this.selection.toJSON();
this._signal("startOperation", commandEvent);
}

/**
* End current Ace operation.
* Emits "beforeEndOperation" event just before clearing everything, where the current operation can be accessed through `curOp` property.
* @param {any} e
*/
endOperation(e) {
if (this.curOp) {
if (e && e.returnValue === false) {
this.curOp = null;
this._signal("endOperation", e);
return;
}
if (e == true && this.curOp.command && this.curOp.command.name == "mouse") {
// When current operation is mousedown, we wait for the mouseup to end the operation.
// So during a user selection, we would only end the operation when the final selection is known.
return;
}

const currentSelection = this.selection.toJSON();
this.curOp.selectionAfter = currentSelection;
this.$lastSel = this.selection.toJSON();
this.getUndoManager().addSelection(currentSelection);

this._signal("beforeEndOperation");
this.prevOp = this.curOp;
this.curOp = null;
this._signal("endOperation", e);
}
}

/**
Expand Down Expand Up @@ -186,6 +269,10 @@ class EditSession {
this._signal("change", delta);
}

onSelectionChange() {
this._signal("changeSelection");
}

/**
* Sets the session text.
* @param {String} text The new text to place
Expand Down Expand Up @@ -2386,11 +2473,16 @@ class EditSession {
this.bgTokenizer.cleanup();
this.destroyed = true;
}
this.endOperation();
this.$stopWorker();
this.removeAllListeners();
if (this.doc) {
this.doc.off("change", this.$onChange);
}
if (this.selection) {
this.selection.off("changeCursor", this.$onSelectionChange);
this.selection.off("changeSelection", this.$onSelectionChange);
}
this.selection.detach();
}
}
Expand Down
113 changes: 113 additions & 0 deletions src/edit_session_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1150,6 +1150,119 @@ module.exports = {
assert.equal(session.getScrollLeft(), 0);
assert.equal(session.getScrollTop(), 0);
assert.equal(session.getValue(), "Hello world!");
},

"test: operation handling : when session it not attached to an editor": function(done) {
const session = new EditSession("Hello world!");
const beforeEndOperationSpy = [];
session.on("beforeEndOperation", () => {
beforeEndOperationSpy.push(session.curOp);
});

// When both start and end operation are invoked by the consumer
session.startOperation({command: {name: "inserting-both"}});
session.insert({row: 0, column : 0}, "both");
session.endOperation();
assert.equal(beforeEndOperationSpy.length, 1);
assert.equal(beforeEndOperationSpy[0].command.name, "inserting-both");
assert.equal(beforeEndOperationSpy[0].docChanged, true);
assert.equal(beforeEndOperationSpy[0].selectionChanged, true);

// When only start operation is invoked
session.startOperation({command: {name: "inserting-start"}});
session.insert({row: 0, column : 0}, "start");
setTimeout(() => {
assert.equal(beforeEndOperationSpy.length, 2);
assert.equal(beforeEndOperationSpy[1].command.name, "inserting-start");
assert.equal(beforeEndOperationSpy[1].docChanged, true);
assert.equal(beforeEndOperationSpy[1].selectionChanged, true);

// When only end operation is invoked
session.insert({row: 0, column : 0}, "end");
session.endOperation();
assert.equal(beforeEndOperationSpy.length, 3);
assert.deepEqual(beforeEndOperationSpy[2].command, {});
assert.equal(beforeEndOperationSpy[2].docChanged, true);
assert.equal(beforeEndOperationSpy[2].selectionChanged, true);

// When nothing is invoked
session.insert({row: 0, column : 0}, "none");
setTimeout(() => {
assert.equal(beforeEndOperationSpy.length, 4);
assert.deepEqual(beforeEndOperationSpy[3].command, {});
assert.equal(beforeEndOperationSpy[3].docChanged, true);
assert.equal(beforeEndOperationSpy[3].selectionChanged, true);

done();
}, 10);
}, 10);
},

"test: operation handling : when session is attached to an editor": function(done) {
const session = new EditSession("Hello world!");
const editor = new Editor(new MockRenderer(), session);
const beforeEndOperationSpySession = [];
session.on("beforeEndOperation", () => {
beforeEndOperationSpySession.push(session.curOp);
});
const beforeEndOperationSpyEditor = [];
editor.on("beforeEndOperation", () => {
beforeEndOperationSpyEditor.push(editor.curOp);
});

// Imperative update from editor
editor.startOperation({command: {name: "imperative-update"}});
editor.insert("update");
editor.endOperation();
for (const beforeEndOperationSpy of [beforeEndOperationSpySession, beforeEndOperationSpyEditor ]) {
assert.equal(beforeEndOperationSpy.length, 1);
assert.equal(beforeEndOperationSpy[0].command.name, "imperative-update");
assert.equal(beforeEndOperationSpy[0].docChanged, true);
assert.equal(beforeEndOperationSpy[0].selectionChanged, true);
assert.equal(!!beforeEndOperationSpy[0].selectionBefore, true);
}

// Imperative update from session
session.startOperation({command: {name: "session-update"}});
session.insert({row: 0, column : 0},"update");
session.endOperation();
for (const beforeEndOperationSpy of [beforeEndOperationSpySession, beforeEndOperationSpyEditor ]) {
assert.equal(beforeEndOperationSpy.length, 2);
assert.equal(beforeEndOperationSpy[1].command.name, "session-update");
assert.equal(beforeEndOperationSpy[1].docChanged, true);
assert.equal(beforeEndOperationSpy[1].selectionChanged, true);
assert.equal(!!beforeEndOperationSpy[1].selectionBefore, true);
}

// Command update
editor.execCommand(editor.commands.byName.indent);
for (const beforeEndOperationSpy of [beforeEndOperationSpySession, beforeEndOperationSpyEditor ]) {
assert.equal(beforeEndOperationSpy.length, 3);
assert.equal(beforeEndOperationSpy[2].command.name, "indent");
assert.equal(beforeEndOperationSpy[2].docChanged, true);
assert.equal(beforeEndOperationSpy[2].selectionChanged, true);
assert.equal(!!beforeEndOperationSpy[2].selectionBefore, true);
}

// Session cleanup logic
const newSession = new EditSession("Hello again!");
editor.setSession(newSession);
const beforeEndOperationSpyNewSession = [];
newSession.on("beforeEndOperation", () => {
beforeEndOperationSpyNewSession.push(newSession.curOp);
});
editor.execCommand(editor.commands.byName.indent);
assert.equal(beforeEndOperationSpyEditor.length, 4);
assert.equal(beforeEndOperationSpyNewSession.length, 1);
assert.equal(beforeEndOperationSpySession.length, 3);

// Imperative implicit update from editor
editor.insert("update");
setTimeout(() => {
assert.equal(beforeEndOperationSpyEditor.length, 5);
assert.equal(beforeEndOperationSpyNewSession.length, 2);
done();
}, 10);
}
};

Expand Down
Loading

0 comments on commit aba0575

Please sign in to comment.