diff --git a/ace-internal.d.ts b/ace-internal.d.ts index 12165e80b0b..7a345ea0828 100644 --- a/ace-internal.d.ts +++ b/ace-internal.d.ts @@ -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 @@ -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 { diff --git a/ace.d.ts b/ace.d.ts index 6fbcbbd1366..88560068eb7 100644 --- a/ace.d.ts +++ b/ace.d.ts @@ -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 @@ -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(name: T, value: EditSessionOptions[T]): void; getOption(name: T): EditSessionOptions[T]; @@ -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; } diff --git a/src/edit_session.js b/src/edit_session.js index 9cb28439f8b..0546545c505 100644 --- a/src/edit_session.js +++ b/src/edit_session.js @@ -45,6 +45,7 @@ class EditSession { this.$backMarkers = {}; this.$markerId = 1; this.$undoSelect = true; + this.prevOp = {}; /** @type {FoldLine[]} */ this.$foldData = []; @@ -69,7 +70,12 @@ 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); @@ -77,6 +83,83 @@ class EditSession { 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); + } } /** @@ -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 @@ -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(); } } diff --git a/src/edit_session_test.js b/src/edit_session_test.js index 915912d2a1a..ca65b12bde2 100644 --- a/src/edit_session_test.js +++ b/src/edit_session_test.js @@ -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); } }; diff --git a/src/editor.js b/src/editor.js index 01ef6461c68..62e1d613910 100644 --- a/src/editor.js +++ b/src/editor.js @@ -48,7 +48,6 @@ class Editor { **/ constructor(renderer, session, options) { /**@type{EditSession}*/this.session; - this.$toDestroy = []; var container = renderer.getContainerElement(); @@ -99,61 +98,42 @@ class Editor { $initOperationListeners() { this.commands.on("exec", this.startOperation.bind(this), true); this.commands.on("afterExec", this.endOperation.bind(this), true); - - this.$opResetTimer = lang.delayedCall(this.endOperation.bind(this, true)); - - // todo: add before change events? - this.on("change", function() { - if (!this.curOp) { - this.startOperation(); - this.curOp.selectionBefore = this.$lastSel; - } - this.curOp.docChanged = true; - }.bind(this), true); - - this.on("changeSelection", function() { - if (!this.curOp) { - this.startOperation(); - this.curOp.selectionBefore = this.$lastSel; - } - this.curOp.selectionChanged = true; - }.bind(this), true); } startOperation(commandEvent) { - if (this.curOp) { - if (!commandEvent || this.curOp.command) - return; - this.prevOp = this.curOp; - } + this.session.startOperation(commandEvent); + } + + /** + * @arg e + */ + endOperation(e) { + this.session.endOperation(e); + } + + onStartOperation(commandEvent) { + this.curOp = this.session.curOp; + this.curOp.scrollTop = this.renderer.scrollTop; + this.prevOp = this.session.prevOp; + if (!commandEvent) { this.previousCommand = null; - commandEvent = {}; } - - this.$opResetTimer.schedule(); - /** - * @type {{[key: string]: any;}} - */ - this.curOp = this.session.curOp = { - command: commandEvent.command || {}, - args: commandEvent.args, - scrollTop: this.renderer.scrollTop - }; - this.curOp.selectionBefore = this.selection.toJSON(); } /** * @arg e */ - endOperation(e) { + onEndOperation(e) { if (this.curOp && this.session) { - if (e && e.returnValue === false || !this.session) - return (this.curOp = null); - if (e == true && this.curOp.command && this.curOp.command.name == "mouse") + if (e && e.returnValue === false) { + this.curOp = null; return; + } + this._signal("beforeEndOperation"); if (!this.curOp) return; + var command = this.curOp.command; var scrollIntoView = command && command.scrollIntoView; if (scrollIntoView) { @@ -181,12 +161,8 @@ class Editor { if (scrollIntoView == "animate") this.renderer.animateScrolling(this.curOp.scrollTop); } - var sel = this.selection.toJSON(); - this.curOp.selectionAfter = sel; - this.$lastSel = this.selection.toJSON(); - - // console.log(this.$lastSel+" endOP") - this.session.getUndoManager().addSelection(sel); + + this.$lastSel = this.session.selection.toJSON(); this.prevOp = this.curOp; this.curOp = null; } @@ -291,6 +267,8 @@ class Editor { this.session.off("changeOverwrite", this.$onCursorChange); this.session.off("changeScrollTop", this.$onScrollTopChange); this.session.off("changeScrollLeft", this.$onScrollLeftChange); + this.session.off("startOperation", this.$onStartOperation); + this.session.off("endOperation", this.$onEndOperation); var selection = this.session.getSelection(); selection.off("changeCursor", this.$onCursorChange); @@ -347,6 +325,11 @@ class Editor { this.$onSelectionChange = this.onSelectionChange.bind(this); this.selection.on("changeSelection", this.$onSelectionChange); + + this.$onStartOperation = this.onStartOperation.bind(this); + this.session.on("startOperation", this.$onStartOperation); + this.$onEndOperation = this.onEndOperation.bind(this); + this.session.on("endOperation", this.$onEndOperation); this.onChangeMode(); @@ -2679,6 +2662,7 @@ class Editor { if (this._$emitInputEvent) this._$emitInputEvent.cancel(); this.removeAllListeners(); + } /**