UndoRedoController.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545
  1. 'use strict';
  2. function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
  3. /**
  4. * Licensed Materials - Property of IBM
  5. * IBM Cognos Products: BI Cloud (C) Copyright IBM Corp. 2014, 2021
  6. * US Government Users Restricted Rights - Use, duplication or disclosure restricted by GSA ADP Schedule Contract with IBM Corp.
  7. */
  8. define(['underscore', 'chronology', './util/EventConstants', '../lib/@waca/dashboard-common/dist/core/APIContext'], function (_, Chronology, EventConstants, APIContext) {
  9. var STACK_SIZE_LIMIT = 150;
  10. var UndoRedoController = function () {
  11. function UndoRedoController(canvasModel) {
  12. var options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
  13. _classCallCheck(this, UndoRedoController);
  14. this.canvas = options.canvas;
  15. this._registerCanvasEvents();
  16. //Events to exclude from the undo/redo stack
  17. this.undoRedoExclusionList = { 'change:name': 1, 'change:id': 1, 'change:_meta': 1 };
  18. this.canvasModel = canvasModel;
  19. this.transactionApi = options.transaction;
  20. this.listeners = {};
  21. this.canvasModel.on('all', this.addToUndoRedoStack.bind(this), this.canvasModel);
  22. this.logger = options.logger;
  23. this.currentMode = options.mode || 'canvasMode';
  24. this.isStateDirty = options.isStateDirty || false;
  25. this.undoRedoStacks = {};
  26. this.changeModeName = EventConstants.changeModeAction;
  27. //Ensure the stack is created first
  28. this._getUndoRedoStack();
  29. return this.getPublicProperties();
  30. }
  31. UndoRedoController.prototype._registerCanvasEvents = function _registerCanvasEvents() {
  32. var _this = this;
  33. if (this.canvas) {
  34. this.canvas.on('all', function (event) {
  35. // For now, we only support the operations that explicitly indicate the undo/redo support
  36. if (event.info && event.info.supportsUndoRedo) {
  37. _this._addCanvasActionToUndoRedo(event);
  38. }
  39. });
  40. }
  41. };
  42. UndoRedoController.prototype._addCanvasActionToUndoRedo = function _addCanvasActionToUndoRedo(event) {
  43. // the event must have a tracking and a reversAction to support undo/redo
  44. if (event.tracking && event.tracking.undoActions) {
  45. this.addToUndoRedoStack(this._convertCanvasEventToUndoAction(event));
  46. }
  47. };
  48. // Create an action object that is compatible with the existing undo/redo
  49. // This object will have a "applyFn" that will do the actual undo/redo based on the info in the event
  50. UndoRedoController.prototype._convertCanvasEventToUndoAction = function _convertCanvasEventToUndoAction(event) {
  51. var undoRedoTransactionId = event && event.transactionToken && event.transactionToken.transactionId;
  52. return {
  53. value: 'redo', // dummy value to identify that we doing a redo
  54. prevValue: 'undo', // dummy value to identify that we doing an undo
  55. data: {
  56. undoRedoTransactionId: undoRedoTransactionId,
  57. transactionToken: event.transactionToken,
  58. transactionParamIndex: event.info.transactionParamIndex
  59. },
  60. sender: event.context && event.context.undoRedo ? 'UndoRedoController' : 'canvas',
  61. senderContext: {
  62. applyFn: this._canvasEventApplyFn.bind(this, event)
  63. }
  64. };
  65. };
  66. UndoRedoController.prototype._canvasEventApplyFn = function _canvasEventApplyFn(event, value, sender, name, data) {
  67. var _this2 = this;
  68. var response = void 0;
  69. // if the operartion is part of a transaction,
  70. // the undoRedoController will create a transaction and pass it here
  71. var transactionToken = data && data.transactionToken;
  72. // Create an api context that is used to identify that the context of the operation
  73. // is undo/redo so that we don't add it again
  74. var apiContext = new APIContext({
  75. undoRedo: true,
  76. transactionToken: transactionToken
  77. });
  78. // Start with the canvas as a root API since we are listening on the canvas object
  79. var apiStack = this._executeTrace(event.tracking.callStack, [this.canvas]);
  80. if (value === 'undo') {
  81. //undo
  82. var undoActions = event.tracking.undoActions;
  83. var actionsToExecute = [];
  84. undoActions.forEach(function (undoAction) {
  85. actionsToExecute.push({
  86. fn: _this2._executeAction.bind(_this2),
  87. args: [undoAction, apiStack, apiContext]
  88. });
  89. });
  90. response = this._executeActions(actionsToExecute);
  91. } else {
  92. // redo
  93. var action = event.tracking.action;
  94. response = this._executeAction(action, apiStack, apiContext);
  95. }
  96. if (response instanceof Promise) {
  97. response = response.catch(function (err) {
  98. _this2.logger.error('An error occured while executing an API undo trace', err);
  99. throw err;
  100. });
  101. }
  102. return response;
  103. };
  104. UndoRedoController.prototype._executeTrace = function _executeTrace(callStack) {
  105. var apiStack = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : [];
  106. var localCopyApiStack = [].concat(apiStack);
  107. if (callStack) {
  108. callStack.forEach(function (step) {
  109. var currentAPI = localCopyApiStack[localCopyApiStack.length - 1];
  110. if (step && step.name === '..') {
  111. localCopyApiStack.pop();
  112. } else if (step && currentAPI && typeof currentAPI[step.name] === 'function') {
  113. // execute the step in the current context
  114. localCopyApiStack.push(currentAPI[step.name].apply(currentAPI, step.params));
  115. }
  116. });
  117. }
  118. return localCopyApiStack;
  119. };
  120. UndoRedoController.prototype._executeAction = function _executeAction(action, apiStack, apiContext) {
  121. var _this3 = this;
  122. var response = void 0;
  123. // If the action has a stack callStack, then execute to get the api context
  124. var newApiStack = this._executeTrace(action.callStack, apiStack);
  125. var currentAPI = newApiStack[newApiStack.length - 1];
  126. if (action && currentAPI && typeof currentAPI[action.name] === 'function') {
  127. // event objects are frozen.. so take a copy of the parameters bebore passing to the API.
  128. var params = JSON.parse(JSON.stringify(action.params || []));
  129. params.unshift(apiContext);
  130. response = currentAPI[action.name].apply(currentAPI, params);
  131. // if we have result actions, go over the response and execute the actions
  132. if (action.resultActions) {
  133. var responseList = Array.isArray(response) ? response : [response];
  134. responseList.forEach(function (resultObject, index) {
  135. var actions = action.resultActions[index];
  136. if (actions) {
  137. actions.forEach(function (resultAction) {
  138. _this3._executeAction(resultAction, resultObject, apiContext);
  139. });
  140. }
  141. });
  142. }
  143. } else {
  144. this.logger.warn('undo/redo: missing action in event or action does not exist in API ', action, currentAPI);
  145. }
  146. return response;
  147. };
  148. UndoRedoController.prototype.getPublicProperties = function getPublicProperties() {
  149. return {
  150. addToUndoRedoStack: this.addToUndoRedoStack.bind(this),
  151. clearStack: this.clearStack.bind(this),
  152. addListener: this.addListener.bind(this),
  153. removeListener: this.removeListener.bind(this),
  154. canUndo: this.canUndo.bind(this),
  155. canRedo: this.canRedo.bind(this),
  156. undo: this.undo.bind(this),
  157. redo: this.redo.bind(this),
  158. setMode: this.setMode.bind(this),
  159. setDirty: this.setDirty.bind(this),
  160. registerSaveAction: this.registerSaveAction.bind(this),
  161. isDirty: this.isDirty.bind(this)
  162. };
  163. };
  164. UndoRedoController.prototype._validateUndoRedoAction = function _validateUndoRedoAction(action) {
  165. if (!action || !action.senderContext || !action.senderContext.applyFn) {
  166. return false;
  167. }
  168. if (action.sender === 'UndoRedoController') {
  169. return false;
  170. }
  171. if (action.data && action.data.undoRedoTransactionId && action.data.undoRedoTransactionId.indexOf('undoRedoTransaction') === 0) {
  172. return false;
  173. }
  174. if (!action.data || !action.data.transactionToken) {
  175. // TODO: no data means it's a valid transaction? confusing!!! re-evaluate this condition
  176. return true;
  177. }
  178. var _ref = action.data.transactionToken || {},
  179. eventGenerated = _ref.eventGenerated;
  180. if (!eventGenerated) {
  181. return true;
  182. }
  183. return false;
  184. };
  185. /**
  186. * Add the action to the undo/redo stack.
  187. *
  188. * If the action has a transaction id 'action.data.undoRedoTransactionId', then it will be combined with other actions that have the same id.
  189. * Transactions retriggered by a secondary event will not be kept since the initial transaction is already part of the undo stack.
  190. *
  191. * Note that the undo/redo assumes that all actions within a transaction happen in the same thread (no setTimeout).
  192. * If one action is using setTimeout and takes longer to execute and the user selects undo before it finishes,
  193. * then the undo for the transaction will not include this action. The action will be added separately when it is completed.
  194. *
  195. * @param action
  196. */
  197. UndoRedoController.prototype.addToUndoRedoStack = function addToUndoRedoStack(action) {
  198. if (this._validateUndoRedoAction(action)) {
  199. var upDownActions = this._checkTransaction(action);
  200. if (upDownActions) {
  201. var undoRedoObj = {
  202. up: function up() {
  203. if (!this.call) {
  204. upDownActions.up(action.upValue ? action.upValue : action.value, 'UndoRedoController', action.name);
  205. }
  206. },
  207. down: function down() {
  208. upDownActions.down(action.prevValue, 'UndoRedoController', action.name);
  209. },
  210. call: false,
  211. name: action.name,
  212. prevDirtyState: this.isStateDirty
  213. };
  214. this.logger.debug('UndoRedoController:addToUndoRedoStack', action.data ? action.data.undoRedoTransactionId : action.data, action.name, action.value, action.prevValue);
  215. var stack = this._getUndoRedoStack();
  216. stack.add(undoRedoObj);
  217. }
  218. }
  219. };
  220. UndoRedoController.prototype.setDirty = function setDirty(bool) {
  221. this.isStateDirty = bool;
  222. };
  223. UndoRedoController.prototype.clearStack = function clearStack() {
  224. var _this4 = this;
  225. var stack = this._getUndoRedoStack();
  226. if (stack) {
  227. stack.clear();
  228. }
  229. _.each(this.listeners, function (listener) {
  230. listener.onStateChange(_this4);
  231. });
  232. };
  233. UndoRedoController.prototype._checkTransaction = function _checkTransaction(action) {
  234. if (action.data && (action.data.saveOnly || action.data.skipUndoRedo || action.data.runtimeOnly)) {
  235. return null;
  236. }
  237. var upDownAction = {
  238. up: action.senderContext.applyFn,
  239. down: action.senderContext.applyFn
  240. };
  241. var transactionId = action.data ? action.data.undoRedoTransactionId : null;
  242. if (transactionId) {
  243. var transactionsMap = this._getTransactionsMap();
  244. var actions = transactionsMap[transactionId];
  245. if (actions) {
  246. actions.push(action);
  247. return null;
  248. }
  249. actions = [action];
  250. transactionsMap[transactionId] = actions;
  251. upDownAction.up = function () {
  252. this._redoTransaction(actions);
  253. }.bind(this);
  254. upDownAction.down = function () {
  255. // cleanup the transactions list
  256. delete transactionsMap[transactionId];
  257. this._undoTransaction(actions);
  258. }.bind(this);
  259. }
  260. return upDownAction;
  261. };
  262. UndoRedoController.prototype._createTransation = function _createTransation() {
  263. var id = _.uniqueId('undoRedoTransaction');
  264. var transactionToken = this.transactionApi.startTransactionById(id);
  265. return transactionToken;
  266. };
  267. UndoRedoController.prototype._redoTransaction = function _redoTransaction(actions) {
  268. var _this5 = this;
  269. var transactionToken = this._createTransation();
  270. var action = void 0,
  271. applyAction = void 0;
  272. var actionsToExecute = [];
  273. for (var i = 0; i < actions.length; i++) {
  274. action = actions[i];
  275. applyAction = action.senderContext.applyFn;
  276. if (applyAction) {
  277. action.data.transactionToken = transactionToken;
  278. action.data.undoRedoTransactionId = transactionToken.transactionId;
  279. action.data.undoRedoTransactionType = 'redo';
  280. actionsToExecute.push({
  281. fn: applyAction,
  282. args: [action.upValue ? action.upValue : action.value, 'UndoRedoController', action.name, action.data]
  283. });
  284. }
  285. }
  286. var response = this._executeActions(actionsToExecute);
  287. if (response instanceof Promise) {
  288. this.lastUndoRedoOpPromise = response.catch(function (err) {
  289. _this5.logger.error('An error occured while executing the redo stack', err);
  290. throw err;
  291. }).finally(function () {
  292. _this5.transactionApi.endTransaction(transactionToken);
  293. });
  294. } else {
  295. this.transactionApi.endTransaction(transactionToken);
  296. }
  297. };
  298. UndoRedoController.prototype._undoTransaction = function _undoTransaction(actions) {
  299. var _this6 = this;
  300. var transactionToken = this._createTransation();
  301. var action = void 0,
  302. applyAction = void 0;
  303. var actionsToExecute = [];
  304. for (var i = actions.length - 1; i >= 0; i--) {
  305. action = actions[i];
  306. applyAction = action.senderContext.applyFn;
  307. if (applyAction) {
  308. action.data.transactionToken = transactionToken;
  309. action.data.undoRedoTransactionId = transactionToken.transactionId;
  310. action.data.undoRedoTransactionType = 'undo';
  311. actionsToExecute.push({
  312. fn: applyAction,
  313. args: [action.prevValue, 'UndoRedoController', action.name, action.data]
  314. });
  315. }
  316. }
  317. var response = this._executeActions(actionsToExecute);
  318. if (response instanceof Promise) {
  319. this.lastUndoRedoOpPromise = response.catch(function (err) {
  320. _this6.logger.error('An error occured while executing the undo stack', err);
  321. throw err;
  322. }).finally(function () {
  323. _this6.transactionApi.endTransaction(transactionToken);
  324. });
  325. } else {
  326. this.transactionApi.endTransaction(transactionToken);
  327. }
  328. };
  329. // Execute a series of async actions sequentially ..
  330. UndoRedoController.prototype._executeActions = function _executeActions(actions) {
  331. var _this7 = this;
  332. var startIndex = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 0;
  333. var response = void 0;
  334. while (startIndex < actions.length) {
  335. var _actions$startIndex;
  336. response = (_actions$startIndex = actions[startIndex]).fn.apply(_actions$startIndex, actions[startIndex].args);
  337. startIndex++;
  338. if (response instanceof Promise) {
  339. // one of the action returned a promise.
  340. // break the loop and continue it after the promise is resolved
  341. response = response.then(function () {
  342. return _this7._executeActions(actions, startIndex);
  343. });
  344. break;
  345. }
  346. }
  347. return response;
  348. };
  349. UndoRedoController.prototype.addListener = function addListener(id, listener) {
  350. if (typeof listener.onStateChange === 'function') {
  351. this.listeners[id] = listener;
  352. }
  353. };
  354. UndoRedoController.prototype.removeListener = function removeListener(id) {
  355. delete this.listeners[id];
  356. };
  357. UndoRedoController.prototype.canUndo = function canUndo() {
  358. var stack = this._getUndoRedoStack();
  359. return stack.undos.length > 0;
  360. };
  361. UndoRedoController.prototype.canRedo = function canRedo() {
  362. var stack = this._getUndoRedoStack();
  363. return stack.redos.length > 0;
  364. };
  365. UndoRedoController.prototype._trackDirtyState = function _trackDirtyState(stack) {
  366. if (!stack || !stack[0]) {
  367. return;
  368. }
  369. var peek = stack[0].prevDirtyState;
  370. // We flip the state for moving undo items to the redo stack and vice versa
  371. stack[0].prevDirtyState = this.isStateDirty;
  372. if (stack[0].name !== this.changeModeName) {
  373. this.setDirty(peek);
  374. }
  375. };
  376. UndoRedoController.prototype.undo = function undo() {
  377. var stack = this._getUndoRedoStack();
  378. this._trackDirtyState(stack.undos);
  379. stack.undo();
  380. var promise = this.lastUndoRedoOpPromise || Promise.resolve();
  381. if (this.lastUndoRedoOpPromise) {
  382. this.lastUndoRedoOpPromise = null;
  383. }
  384. return promise;
  385. };
  386. UndoRedoController.prototype.redo = function redo() {
  387. var stack = this._getUndoRedoStack();
  388. this._trackDirtyState(stack.redos);
  389. stack.redo();
  390. var promise = this.lastUndoRedoOpPromise || Promise.resolve();
  391. if (this.lastUndoRedoOpPromise) {
  392. this.lastUndoRedoOpPromise = null;
  393. }
  394. return promise;
  395. };
  396. UndoRedoController.prototype.onAdd = function onAdd() {
  397. this.enableState('undo', true);
  398. this.enableState('redo', false);
  399. };
  400. UndoRedoController.prototype.enableState = function enableState(state, enabled) {
  401. this.canvasModel.trigger('undo:state', { senderContext: this, 'stack': state, 'enabled': enabled });
  402. _.each(this.listeners, function (listener) {
  403. listener.onStateChange(this);
  404. }.bind(this));
  405. };
  406. UndoRedoController.prototype.setMode = function setMode(mode) {
  407. var transactionsToMergeFrom = void 0;
  408. if (this.currentMode !== 'canvasMode') {
  409. transactionsToMergeFrom = this.undoRedoStacks[this.currentMode].transactions;
  410. delete this.undoRedoStacks[this.currentMode];
  411. }
  412. this.currentMode = mode || 'canvasMode';
  413. this._mergeUndoStacks(transactionsToMergeFrom);
  414. var stack = this._getUndoRedoStack();
  415. this.enableState('undo', stack.undos.length > 0);
  416. this.enableState('redo', stack.redos.length > 0);
  417. };
  418. UndoRedoController.prototype.registerSaveAction = function registerSaveAction() {
  419. // When we save we need to cleanup the items in the undo stack and mark them all as dirty
  420. var stack = this._getUndoRedoStack();
  421. _.forEach(stack.undos, function (ele) {
  422. ele.prevDirtyState = true;
  423. });
  424. };
  425. UndoRedoController.prototype.isDirty = function isDirty() {
  426. /**
  427. * Board Logic
  428. * - We want to track the dirty/clean state of the DB from here
  429. * - changeMode actions are added to the stack but dont count as 'dirty'
  430. * all other actions count as dirty
  431. * - If we save, the save action will clear the dirty flag and we need to track that boundary
  432. * - We can have an initially dirty db if created from reloadFromJSONSpec
  433. */
  434. return this.isStateDirty;
  435. };
  436. UndoRedoController.prototype._getUndoRedoStack = function _getUndoRedoStack() {
  437. if (!this.undoRedoStacks[this.currentMode]) {
  438. this.undoRedoStacks[this.currentMode] = new Chronology({
  439. limit: STACK_SIZE_LIMIT,
  440. onAdd: this.onAdd.bind(this),
  441. onRedo: this.enableState.bind(this, 'undo', true),
  442. onUndo: this.enableState.bind(this, 'redo', true),
  443. onBegin: this.enableState.bind(this, 'undo', false),
  444. onEnd: this.enableState.bind(this, 'redo', false)
  445. });
  446. this.undoRedoStacks[this.currentMode].transactions = {};
  447. }
  448. return this.undoRedoStacks[this.currentMode];
  449. };
  450. UndoRedoController.prototype._getTransactionsMap = function _getTransactionsMap() {
  451. return this.undoRedoStacks[this.currentMode].transactions;
  452. };
  453. UndoRedoController.prototype._mergeUndoStacks = function _mergeUndoStacks() {
  454. var _this8 = this;
  455. var transactionsToMergeFrom = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
  456. _.each(transactionsToMergeFrom, function (actions) {
  457. actions.forEach(function (action) {
  458. _this8.addToUndoRedoStack(action);
  459. });
  460. });
  461. };
  462. return UndoRedoController;
  463. }();
  464. return UndoRedoController;
  465. });
  466. //# sourceMappingURL=UndoRedoController.js.map