node-webodf
Version:
WebODF - JavaScript Document Engine http://webodf.org/
305 lines (285 loc) • 12.8 kB
JavaScript
/**
* Copyright (C) 2013 KO GmbH <copyright@kogmbh.com>
*
* @licstart
* This file is part of WebODF.
*
* WebODF is free software: you can redistribute it and/or modify it
* under the terms of the GNU Affero General Public License (GNU AGPL)
* as published by the Free Software Foundation, either version 3 of
* the License, or (at your option) any later version.
*
* WebODF is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with WebODF. If not, see <http://www.gnu.org/licenses/>.
* @licend
*
* @source: http://www.webodf.org/
* @source: https://github.com/kogmbh/WebODF/
*/
/*global gui, runtime*/
/**
* This class attempts to implement undo/redo behaviour identical
* to how LibreOffice behaves.
*
* State iteration rules are:
* - Multiple text inserts in the same direction are treated as one state
* - Multiple text removes in the same direction are treated as one state
* - An Undo state always consumes all trailing cursor move operations
* - An edit operation cannot follow non-edit operations. A state can
* start with non-edit operations if it contains no further edit ops.
* @constructor
*/
gui.UndoStateRules = function UndoStateRules() {
"use strict";
/**
* Return the first element from the end of the array that matches the supplied predicate.
* Each subsequent call to previous will return the next element from the end of the array
* that matches the predicate.
* @constructor
* @param {!Array.<!ops.Operation>} array
* @param {!function(!ops.Operation):!boolean} predicate
*/
function ReverseIterator(array, predicate) {
var index = array.length;
/**
* Return the previous element in the array that matches the predicate
* @return {?ops.Operation} Returns null when no more elements in the array match the predicate
*/
this.previous = function () {
for (index = index - 1; index >= 0; index -= 1) {
if (predicate(array[index])) {
return array[index];
}
}
return null;
};
}
/**
* @param {!ops.Operation} op
* @return {string}
*/
function getOpType(op) {
return op.spec().optype;
}
/**
* @param {!ops.Operation} op
* @return {number|undefined}
*/
function getOpPosition(op) {
var key = "position",
spec = op.spec(),
value;
if (spec.hasOwnProperty(key)) {
value = /**@type{number}*/(spec[key]);
}
return value;
}
/**
* Returns true if the supplied operation
* is considered an editing operation.
* @param {!ops.Operation} op
* @return {!boolean} Returns true if the supplied op is an edit operation
*/
function isEditOperation(op) {
return op.isEdit;
}
this.isEditOperation = isEditOperation;
/**
* Returns true if the supplied optype is allowed to
* aggregate multiple operations in a single undo or redo state
* @param {!ops.Operation} op
* @return {!boolean}
*/
function canAggregateOperation(op) {
switch (getOpType(op)) {
case "RemoveText":
case "InsertText":
return true;
default:
return false;
}
}
/**
* Returns true if the newly supplied operation is continuing
* in the same direction of travel as the recent edit operations
* @param {!ops.Operation} thisOp
* @param {!ops.Operation} lastEditOp
* @param {!ops.Operation} secondLastEditOp
* @return {!boolean}
*/
function isSameDirectionOfTravel(thisOp, lastEditOp, secondLastEditOp) {
// Note, the operations in the recent queue are most
// recent to least recent. As such, the direction order
// should be thisPos => existing2 => existing1
var thisPosition = getOpPosition(thisOp),
lastPosition = getOpPosition(lastEditOp),
secondLastPosition = getOpPosition(secondLastEditOp),
diffLastToSecondLast = lastPosition - secondLastPosition,
diffThisToLast = thisPosition - lastPosition;
// Next, the tricky part... determining the direction of travel
// Each aggregate operation can have two directions of travel:
// RemoveText:
// - delete via backspace - direction will be -1 as cursor moves back
// - delete via delete key - direction will be 0 as cursor doesn't move
// InsertText:
// - prepend text - direction will be 0 as cursor doesn't move
// - append text - direction will be 1 as cursor moves forward
return diffThisToLast === diffLastToSecondLast;
}
/**
* Returns true if the two operations are considered adjacent.
* @param {!ops.Operation} thisOp
* @param {!ops.Operation} lastEditOp
* @return {!boolean}
*/
function isAdjacentOperation(thisOp, lastEditOp) {
var positionDifference = getOpPosition(thisOp) - getOpPosition(lastEditOp);
// RemoveText:
// - delete via backspace - direction will be -1 as cursor moves back
// - delete via delete key - direction will be 0 as cursor doesn't move
// InsertText:
// - prepend text - direction will be 0 as cursor doesn't move
// - append text - direction will be 1 as cursor moves forward
return positionDifference === 0 || Math.abs(positionDifference) === 1;
}
/**
*
* @param {!ops.Operation} thisOp
* @param {!ops.Operation} lastEditOp
* @param {?ops.Operation} secondLastEditOp
* @return {!boolean}
*/
function continuesOperations(thisOp, lastEditOp, secondLastEditOp) {
if (!secondLastEditOp) {
// No previous edit operations, so can't calculate a direction of travel.
// Just check new op is adjacent to existing op
return isAdjacentOperation(thisOp, lastEditOp);
}
return isSameDirectionOfTravel(thisOp, lastEditOp, secondLastEditOp);
}
/**
* Returns true if thisOp can be grouped together with the most recent edit operations.
*
* For an operation to be considered continuous it must:
* - Be of a type that supports aggregation (e.g., insert text or remove text)
* - Be of the same type as the most recent edit operation
* - Be considered adjacent (and in the same direction as) the most recent edit operation
*
* @param {!ops.Operation} thisOp
* @param {!Array.<!ops.Operation>} recentOperations
* @return {!boolean}
*/
function continuesMostRecentEditOperation(thisOp, recentOperations) {
var thisOpType = getOpType(thisOp),
editOpsFinder = new ReverseIterator(recentOperations, isEditOperation),
lastEditOp = editOpsFinder.previous();
runtime.assert(Boolean(lastEditOp), "No edit operations found in state");
if (thisOpType === getOpType(/**@type{!ops.Operation}*/(lastEditOp))) {
// Operation type is identical, so check if these operations are continuous
return continuesOperations(thisOp, /**@type{!ops.Operation}*/(lastEditOp), editOpsFinder.previous());
}
return false;
}
/**
* Returns true if thisOp can be grouped together with the most recent edit operations group.
*
* For an operation to be considered continuous it must:
* - Be of a type that supports aggregation (e.g., insert text or remove text)
* - Be continuous with the most recent edit operation of the same type in the most recent operations group
* (see isContinuousWithExistingOperation for the definition of "continuous")
*
* @param {!ops.Operation} thisOp
* @param {!Array.<!ops.Operation>} recentOperations
* @return {!boolean}
*/
function continuesMostRecentEditGroup(thisOp, recentOperations) {
var thisOpType = getOpType(thisOp),
editOpsFinder = new ReverseIterator(recentOperations, isEditOperation),
candidateOp = editOpsFinder.previous(),
lastEditOp,
secondLastEditOp = null,
inspectedGroupsCount,
groupId;
runtime.assert(Boolean(candidateOp), "No edit operations found in state");
groupId = candidateOp.group;
runtime.assert(groupId !== undefined, "Operation has no group");
inspectedGroupsCount = 1; // Need to keep track of how many edit groups have been inspected
// Check if the current operation continues any operation in the latest group
while (candidateOp && candidateOp.group === groupId) {
if (thisOpType === getOpType(candidateOp)) {
// A matching edit operation was found in the most recent edit group
lastEditOp = candidateOp;
break;
}
candidateOp = editOpsFinder.previous();
}
if (lastEditOp) {
// Now try and find a second operation of the same type in either of the most recent two edit groups
candidateOp = editOpsFinder.previous();
while (candidateOp) {
if (candidateOp.group !== groupId) {
if (inspectedGroupsCount === 2) {
// No second compatible op was found within two edit groups, so abandon searching for more
// and check continuity against lastEditOp only
break;
}
groupId = candidateOp.group;
inspectedGroupsCount += 1;
}
if (thisOpType === getOpType(candidateOp)) {
// Found an operation of the same type within the last two edit groups on the stack
secondLastEditOp = candidateOp;
break;
}
candidateOp = editOpsFinder.previous();
}
return continuesOperations(thisOp, /**@type{!ops.Operation}*/(lastEditOp), secondLastEditOp);
}
return false;
}
/**
* Returns true if the provided operation is part of the existing
* set of operations according to the undo rules
* @param {!ops.Operation} operation
* @param {!Array.<!ops.Operation>} recentOperations
* @return {!boolean}
*/
function isPartOfOperationSet(operation, recentOperations) {
var areOperationsGrouped = operation.group !== undefined, // Expect groups to be consistently used (if in use at all)
lastOperation;
if (!isEditOperation(operation)) {
// Non-edit operations always get appended to the existing undo state
// this covers things such as move cursor ops
return true;
}
if (recentOperations.length === 0) {
// This is the first operation of a pristine state
return true;
}
lastOperation = recentOperations[recentOperations.length - 1];
if (areOperationsGrouped && operation.group === lastOperation.group) {
// Operation groups match, so these were queued as a group
return true;
}
if (canAggregateOperation(operation) && recentOperations.some(isEditOperation)) {
// The are existing edit operations. Check if the current op can be combined with existing operations
// E.g., multiple insert text or remove text ops
if (areOperationsGrouped) {
return continuesMostRecentEditGroup(operation, recentOperations);
}
return continuesMostRecentEditOperation(operation, recentOperations);
}
// The following are all true at this point:
// - new operation is an edit operation (check 1)
// - existing undo state has at least one existing operation (check 2)
// - new operation is not part of most recent operation group (check 3)
// - new operation is not continuous with the existing edit operations (check 4 + 5)
return false;
}
this.isPartOfOperationSet = isPartOfOperationSet;
};