Sync with webodf 82510ae020f8ee8a1f7b65a27b6af107b6023e90

* exposes some state-change/error events in the Editor API
* catches more possible errors and handles them
  (e.g. staying cool on temporary disconnection to server, but warning about it)
* improves selection by mouse
* fixes selected paragraph style not being set to all selected paragraphs
* fixes leaking of some helper attributes into saved ODT files
pull/1/head
Friedrich W. H. Kossebau 11 years ago
parent daf553d06c
commit e3adf6bd19

@ -76,9 +76,25 @@ define("webodf/editor/Editor", [
saveOdtFile = args.saveCallback,
close = args.closeCallback,
odfCanvas,
eventNotifier = new core.EventNotifier([
Editor.EVENT_ERROR,
Editor.EVENT_BEFORESAVETOFILE,
Editor.EVENT_SAVEDTOFILE,
Editor.EVENT_HASLOCALUNSYNCEDOPERATIONSCHANGED,
Editor.EVENT_HASSESSIONHOSTCONNECTIONCHANGED
]),
pendingMemberId,
pendingEditorReadyCallback;
/**
* @param {!string} eventid
* @param {*} args
* @return {undefined}
*/
function fireEvent(eventid, args) {
eventNotifier.emit(eventid, args);
}
function getFileBlob(cbSuccess, cbError) {
var odfContainer = odfCanvas.odfContainer();
@ -196,14 +212,27 @@ define("webodf/editor/Editor", [
}
blob = new Blob([data.buffer], {type: mimetype});
saveAs(blob, filename);
//TODO: add callback as event handler to saveAs
fireEvent(Editor.EVENT_SAVEDTOFILE, null);
}
function onerror(error) {
// TODO: use callback for that
alert(error);
}
fireEvent(Editor.EVENT_BEFORESAVETOFILE, null);
getFileBlob(onsuccess, onerror);
};
/**
* @param {!Object} error
* @return {undefined}
*/
function handleOperationRouterErrors(error) {
// TODO: translate error into Editor ids or at least document the possible values
fireEvent(Editor.EVENT_ERROR, error);
}
/**
* open the initial document of an editing-session,
* request a replay of previous operations, call
@ -219,9 +248,24 @@ define("webodf/editor/Editor", [
// overwrite router
// TODO: serverFactory should be a backendFactory,
// and there should be a backendFactory for local editing
var opRouter = serverFactory.createOperationRouter(sessionId, memberId, server, odfCanvas.odfContainer());
var opRouter = serverFactory.createOperationRouter(sessionId, memberId, server, odfCanvas.odfContainer(), handleOperationRouterErrors);
session.setOperationRouter(opRouter);
// forward events
// TODO: relying here on that opRouter uses the same id strings ATM, those should be defined at OperationRouter interface
opRouter.subscribe(Editor.EVENT_HASLOCALUNSYNCEDOPERATIONSCHANGED, function (hasUnsyncedOps) {
fireEvent(Editor.EVENT_HASLOCALUNSYNCEDOPERATIONSCHANGED, hasUnsyncedOps);
});
opRouter.subscribe(Editor.EVENT_HASSESSIONHOSTCONNECTIONCHANGED, function (hasSessionHostConnection) {
fireEvent(Editor.EVENT_HASSESSIONHOSTCONNECTIONCHANGED, hasSessionHostConnection);
});
opRouter.subscribe(Editor.EVENT_BEFORESAVETOFILE, function () {
fireEvent(Editor.EVENT_BEFORESAVETOFILE, null);
});
opRouter.subscribe(Editor.EVENT_SAVEDTOFILE, function () {
fireEvent(Editor.EVENT_SAVEDTOFILE, null);
});
// now get existing ops and after that let the user edit
opRouter.requestReplay(function done() {
editorReadyCallback();
});
@ -288,6 +332,38 @@ define("webodf/editor/Editor", [
editorSession.sessionController.endEditing();
};
/**
* Allows to register listeners for certain events. Currently
* available events are, with the type of the argument passed to the callback:
* Editor.EVENT_BEFORESAVETOFILE - no argument
* Editor.EVENT_SAVEDTOFILE - no argument
* Editor.EVENT_HASLOCALUNSYNCEDOPERATIONSCHANGED - boolean, reflecting new hasLocalUnsyncedOperations state
* Editor.EVENT_HASSESSIONHOSTCONNECTIONCHANGED - boolean, reflecting new hasSessionhostConnection state
* Editor.EVENT_ERROR - string, one of these errorcodes:
* "notMemberOfSession"
* "opExecutionFailure"
* "sessionDoesNotExist"
* "unknownOpReceived"
* "unknownServerReply"
* "unresolvableConflictingOps"
*
* @param {!string} eventid
* @param {!Function} listener
* @return {undefined}
*/
this.addEventListener = function (eventid, listener) {
eventNotifier.subscribe(eventid, listener);
};
/**
* @param {!string} eventid
* @param {!Function} listener
* @return {undefined}
*/
this.removeEventListener = function (eventid, listener) {
eventNotifier.unsubscribe(eventid, listener);
};
/**
* @param {!function(!Object=)} callback, passing an error object in case of error
* @return {undefined}
@ -440,6 +516,18 @@ define("webodf/editor/Editor", [
init();
}
/**@const @type {!string}*/
Editor.EVENT_ERROR = "error";
/**@const @type {!string}*/
Editor.EVENT_BEFORESAVETOFILE = "beforeSaveToFile";
/**@const @type {!string}*/
Editor.EVENT_SAVEDTOFILE = "savedToFile";
/**@const @type {!string}*/
Editor.EVENT_HASLOCALUNSYNCEDOPERATIONSCHANGED = "hasLocalUnsyncedOperationsChanged";
/**@const @type {!string}*/
Editor.EVENT_HASSESSIONHOSTCONNECTIONCHANGED = "hasSessionHostConnectionChanged";
return Editor;
});

@ -48,7 +48,9 @@ define("webodf/editor/EditorSession", [
};
runtime.loadClass("core.DomUtils");
runtime.loadClass("odf.OdfUtils");
runtime.loadClass("ops.OdtDocument");
runtime.loadClass("ops.StepsTranslator");
runtime.loadClass("ops.Session");
runtime.loadClass("odf.Namespaces");
runtime.loadClass("odf.OdfCanvas");
@ -80,6 +82,7 @@ define("webodf/editor/EditorSession", [
fontStyles = document.createElement('style'),
formatting = odtDocument.getFormatting(),
domUtils = new core.DomUtils(),
odfUtils = new odf.OdfUtils(),
eventNotifier = new core.EventNotifier([
EditorSession.signalMemberAdded,
EditorSession.signalMemberUpdated,
@ -306,16 +309,45 @@ define("webodf/editor/EditorSession", [
return currentCommonStyleName;
};
this.setCurrentParagraphStyle = function (value) {
var op;
if (currentCommonStyleName !== value) {
op = new ops.OpSetParagraphStyle();
op.init({
memberid: localMemberId,
position: self.getCursorPosition(),
styleName: value
});
session.enqueue([op]);
/**
* Round the step up to the next step
* @param {!number} step
* @returns {!boolean}
*/
function roundUp(step) {
return step === ops.StepsTranslator.NEXT_STEP;
}
/**
* Applies the paragraph style with the given
* style name to all the paragraphs within
* the cursor selection.
* @param {!string} styleName
* @return {undefined}
*/
this.setCurrentParagraphStyle = function (styleName) {
var range = odtDocument.getCursor(localMemberId).getSelectedRange(),
paragraphs = odfUtils.getParagraphElements(range),
opQueue = [];
paragraphs.forEach(function (paragraph) {
var paragraphStartPoint = odtDocument.convertDomPointToCursorStep(paragraph, 0, roundUp),
paragraphStyleName = paragraph.getAttributeNS(odf.Namespaces.textns, "style-name"),
opSetParagraphStyle;
if (paragraphStyleName !== styleName) {
opSetParagraphStyle = new ops.OpSetParagraphStyle();
opSetParagraphStyle.init({
memberid: localMemberId,
styleName: styleName,
position: paragraphStartPoint
});
opQueue.push(opSetParagraphStyle);
}
});
if (opQueue.length > 0) {
session.enqueue(opQueue);
}
};

@ -53,9 +53,10 @@ ServerFactory.prototype.createServer = function () {"use strict"; };
* @param {!string} memberId
* @param {!ops.Server} server
* @param {!odf.OdfContainer} odfContainer TODO: needed for pullbox writing to server at end, find better solution
* @param {!function(!Object)} errorCallback
* @return {!ops.OperationRouter}
*/
ServerFactory.prototype.createOperationRouter = function (sessionId, memberId, server, odfContainer) {"use strict"; };
ServerFactory.prototype.createOperationRouter = function (sessionId, memberId, server, odfContainer, errorCallback) {"use strict"; };
/**
* @param {!ops.Server} server

@ -50,8 +50,8 @@ define("webodf/editor/server/owncloud/ServerFactory", [
};
return server;
};
this.createOperationRouter = function (sid, mid, server, odfContainer) {
return new PullBoxOperationRouter(sid, mid, server, odfContainer);
this.createOperationRouter = function (sid, mid, server, odfContainer, errorCallback) {
return new PullBoxOperationRouter(sid, mid, server, odfContainer, errorCallback);
};
this.createSessionList = function (server) {
return new PullBoxSessionList(server);

@ -28,7 +28,18 @@
define("webodf/editor/server/pullbox/OperationRouter", [], function () {
"use strict";
// TODO: these eventid strings should be defined at OperationRouter interface
var /**@const @type {!string}*/
EVENT_BEFORESAVETOFILE = "beforeSaveToFile",
/**@const @type {!string}*/
EVENT_SAVEDTOFILE = "savedToFile",
/**@const @type {!string}*/
EVENT_HASLOCALUNSYNCEDOPERATIONSCHANGED = "hasLocalUnsyncedOperationsChanged",
/**@const @type {!string}*/
EVENT_HASSESSIONHOSTCONNECTIONCHANGED = "hasSessionHostConnectionChanged";
runtime.loadClass("ops.OperationTransformer");
runtime.loadClass("core.EventNotifier");
/**
* route operations in a networked collaborative manner.
@ -43,11 +54,11 @@ define("webodf/editor/server/pullbox/OperationRouter", [], function () {
* @constructor
* @implements ops.OperationRouter
*/
return function PullBoxOperationRouter(sessionId, memberId, server, odfContainer) {
return function PullBoxOperationRouter(sessionId, memberId, server, odfContainer, errorCallback) {
"use strict";
var operationFactory,
/**@type{function(!ops.Operation)}*/
/**@type{function(!ops.Operation):boolean}*/
playbackFunction,
idleTimeout = null,
syncOpsTimeout = null,
@ -58,7 +69,7 @@ define("webodf/editor/server/pullbox/OperationRouter", [], function () {
/**@type{!boolean}*/
isSyncCallRunning = false,
/**@type{!boolean}*/
hasUnresolvableConflict = false,
hasError = false,
/**@type{!boolean}*/
syncingBlocked = false,
/** @type {!string} id of latest op stack state known on the server */
@ -71,8 +82,20 @@ define("webodf/editor/server/pullbox/OperationRouter", [], function () {
unplayedServerOpspecQueue = [],
/** @type {!Array.<!Function>} sync request callbacks which should be called after the received ops have been applied server */
uncalledSyncRequestCallbacksQueue = [],
/** @type {!Array.<!function(!boolean):undefined>} ops created since the last sync call to the server */
hasLocalUnsyncedOpsStateSubscribers = [],
/**@type{!boolean}*/
hasLocalUnsyncedOps = false,
/** @type {!Array.<!function(!boolean):undefined>} */
hasSessionHostConnectionStateSubscribers = [],
/**@type{!boolean}*/
hasSessionHostConnection = true,
eventNotifier = new core.EventNotifier([
EVENT_BEFORESAVETOFILE,
EVENT_SAVEDTOFILE,
EVENT_HASLOCALUNSYNCEDOPERATIONSCHANGED,
EVENT_HASSESSIONHOSTCONNECTIONCHANGED
]),
/**@type{!boolean} tells if any local ops have been modifying ops */
hasPushedModificationOps = false,
operationTransformer = new ops.OperationTransformer(),
@ -84,7 +107,8 @@ define("webodf/editor/server/pullbox/OperationRouter", [], function () {
* @return {undefined}
*/
function updateHasLocalUnsyncedOpsState() {
var hasLocalUnsyncedOpsNow = (unsyncedClientOpspecQueue.length > 0);
var i,
hasLocalUnsyncedOpsNow = (unsyncedClientOpspecQueue.length > 0);
// no change?
if (hasLocalUnsyncedOps === hasLocalUnsyncedOpsNow) {
@ -92,6 +116,23 @@ define("webodf/editor/server/pullbox/OperationRouter", [], function () {
}
hasLocalUnsyncedOps = hasLocalUnsyncedOpsNow;
eventNotifier.emit(EVENT_HASLOCALUNSYNCEDOPERATIONSCHANGED, hasLocalUnsyncedOps);
}
/**
* @param {!boolean} hasConnection
* @return {undefined}
*/
function updateHasSessionHostConnectionState(hasConnection) {
var i;
// no change?
if (hasSessionHostConnection === hasConnection) {
return;
}
hasSessionHostConnection = hasConnection;
eventNotifier.emit(EVENT_HASSESSIONHOSTCONNECTIONCHANGED, hasSessionHostConnection);
}
/**
@ -122,9 +163,16 @@ define("webodf/editor/server/pullbox/OperationRouter", [], function () {
op = operationFactory.create(opspec);
runtime.log(" op in: "+runtime.toJson(opspec));
if (op !== null) {
playbackFunction(op);
if (!playbackFunction(op)) {
hasError = true;
errorCallback("opExecutionFailure");
return;
}
} else {
hasError = true;
runtime.log("ignoring invalid incoming opspec: " + opspec);
errorCallback("unknownOpReceived");
return;
}
}
@ -214,7 +262,7 @@ define("webodf/editor/server/pullbox/OperationRouter", [], function () {
}, syncOpsDelay);
}
if (isSyncCallRunning || hasUnresolvableConflict) {
if (isSyncCallRunning || hasError) {
return;
}
// TODO: hack, remove
@ -243,13 +291,25 @@ runtime.log("OperationRouter: sending sync_ops call");
client_ops: syncedClientOpspecs
}
}, function(responseData) {
var response = /** @type{{result:string, head_seq:string, ops:Array.<!Object>}} */(runtime.fromJson(responseData));
var response,
/**@type{!boolean}*/
hasUnresolvableConflict = false;
updateHasSessionHostConnectionState(true);
// TODO: hack, remove
if (syncingBlocked) {
return;
}
try {
response = /** @type{{result:string, head_seq:string, ops:Array.<!Object>}} */(runtime.fromJson(responseData));
} catch (e) {
hasError = true;
runtime.log("Could not parse reply: "+responseData);
errorCallback("unknownServerReply");
return;
}
// TODO: hack, remove
runtime.log("sync_ops reply: " + responseData);
// just new ops?
@ -290,20 +350,28 @@ runtime.log("OperationRouter: sending sync_ops call");
if (!hasUnresolvableConflict) {
isInstantSyncRequested = true;
}
} else if (response.result === "error") {
runtime.log("server reports an error: "+response.error);
hasError = true;
errorCallback(
response.error === "ENOSESSION" ? "sessionDoesNotExist":
response.error === "ENOMEMBER" ? "notMemberOfSession":
"unknownServerReply"
);
return;
} else {
runtime.assert(false, "Unexpected result on sync-ops call: "+response.result);
hasError = true;
runtime.log("Unexpected result on sync-ops call: "+response.result);
errorCallback("unknownServerReply");
return;
}
// unlock
isSyncCallRunning = false;
if (hasUnresolvableConflict) {
// TODO: offer option to reload session automatically?
runtime.assert(false,
"Sorry to tell:\n" +
"we hit a pair of operations in a state which yet need to be supported for transformation against each other.\n" +
"Client disconnected from session, no further editing accepted.\n\n" +
"Please reconnect manually for now.");
hasError = true;
errorCallback("unresolvableConflictingOps");
} else {
// prepare next sync
if (isInstantSyncRequested) {
@ -318,6 +386,22 @@ runtime.log("OperationRouter: sending sync_ops call");
}
playUnplayedServerOpSpecs();
}
}, function() {
runtime.log("meh, server cannot be reached ATM.");
// signal connection problem, but do not give up for now
updateHasSessionHostConnectionState(false);
// put the (not) send ops back into the outgoing queue
unsyncedClientOpspecQueue = syncedClientOpspecs.concat(unsyncedClientOpspecQueue);
syncRequestCallbacksQueue = syncRequestCallbacksArray.concat(syncRequestCallbacksQueue);
// unlock
isSyncCallRunning = false;
// nothing on client to sync?
if (unsyncedClientOpspecQueue.length === 0) {
idleTimeout = runtime.getWindow().setTimeout(startSyncOpsTimeout, idleDelay);
} else {
startSyncOpsTimeout();
}
playUnplayedServerOpSpecs();
});
}
@ -381,7 +465,7 @@ runtime.log("OperationRouter: instant opsSync requested");
/**
* Sets the method which should be called to apply operations.
*
* @param {!function(!ops.Operation)} playback_func
* @param {!function(!ops.Operation):boolean} playback_func
* @return {undefined}
*/
this.setPlaybackFunction = function (playback_func) {
@ -395,7 +479,10 @@ runtime.log("OperationRouter: instant opsSync requested");
* @return {undefined}
*/
this.push = function (operations) {
if (hasUnresolvableConflict) {
var i, op, opspec,
timestamp = (new Date()).getTime();
if (hasError) {
return;
}
// TODO: should be an assert in the future
@ -406,22 +493,27 @@ runtime.log("OperationRouter: instant opsSync requested");
return;
}
operations.forEach(function(op) {
var timedOp,
opspec = op.spec();
for (i = 0; i < operations.length; i += 1) {
op = operations[i];
opspec = op.spec();
// note if any local ops modified
hasPushedModificationOps = hasPushedModificationOps || op.isEdit;
// apply locally
opspec.timestamp = (new Date()).getTime();
timedOp = operationFactory.create(opspec);
// add timestamp TODO: improve the useless recreation of the op
opspec.timestamp = timestamp;
op = operationFactory.create(opspec);
playbackFunction(timedOp);
// apply locally
if (!playbackFunction(op)) {
hasError = true;
errorCallback("opExecutionFailure");
return;
}
// send to server
unsyncedClientOpspecQueue.push(opspec);
});
}
triggerPushingOps();
@ -434,25 +526,65 @@ runtime.log("OperationRouter: instant opsSync requested");
* A callback is called on success.
*/
this.close = function (cb) {
function cbDoneSaving(err) {
eventNotifier.emit(EVENT_SAVEDTOFILE, null);
cb(err);
}
function cbSuccess(fileData) {
server.writeSessionStateToFile(sessionId, memberId, lastServerSeq, fileData, cb);
server.writeSessionStateToFile(sessionId, memberId, lastServerSeq, fileData, cbDoneSaving);
}
function doClose() {
syncingBlocked = true;
if (hasPushedModificationOps) {
odfContainer.createByteArray(cbSuccess, cb);
eventNotifier.emit(EVENT_BEFORESAVETOFILE, null);
odfContainer.createByteArray(cbSuccess, cbDoneSaving);
} else {
cb();
}
}
if (hasLocalUnsyncedOps) {
if (hasError) {
cb();
} else if (hasLocalUnsyncedOps) {
requestInstantOpsSync(doClose);
} else {
doClose();
}
};
/**
* @param {!string} eventId
* @param {!Function} cb
* @return {undefined}
*/
this.subscribe = function (eventId, cb) {
eventNotifier.subscribe(eventId, cb);
};
/**
* @param {!string} eventId
* @param {!Function} cb
* @return {undefined}
*/
this.unsubscribe = function (eventId, cb) {
eventNotifier.unsubscribe(eventId, cb);
};
/**
* @return {!boolean}
*/
this.hasLocalUnsyncedOps = function () {
return hasLocalUnsyncedOps;
};
/**
* @return {!boolean}
*/
this.hasSessionHostConnection = function () {
return hasSessionHostConnection;
};
};
});

@ -40,6 +40,7 @@ define("webodf/editor/server/pullbox/Server", [], function () {
var self = this,
token,
/**@const*/serverCallTimeout = 10000,
base64 = new core.Base64();
args = args || {};
@ -53,22 +54,33 @@ define("webodf/editor/server/pullbox/Server", [], function () {
/**
* @param {!Object} message
* @param {!function(!string)} cb
* @param {!function(!number,!string)} cbError passes the status number
* and the statustext, or -1 if there was an exception on sending
* @return {undefined}
*/
function call(message, cb) {
function call(message, cb, cbError) {
var xhr = new XMLHttpRequest(),
messageString = JSON.stringify(message);
function handleResult() {
if (xhr.readyState === 4) {
if ((xhr.status < 200 || xhr.status >= 300) && xhr.status === 0) {
if (xhr.status < 200 || xhr.status >= 300) {
// report error
runtime.log("Status " + String(xhr.status) + ": " +
xhr.responseText || xhr.statusText);
cbError(xhr.status, xhr.statusText);
} else {
runtime.log("Status " + String(xhr.status) + ": " +
xhr.responseText || xhr.statusText);
cb(xhr.responseText);
}
cb(xhr.responseText);
}
}
function handleTimeout() {
runtime.log("Timeout on call to server.");
cbError(0, xhr.statusText);
}
runtime.log("Sending message to server: "+messageString);
// create body data for request from metadata and payload
@ -78,11 +90,14 @@ runtime.log("Sending message to server: "+messageString);
xhr.setRequestHeader("requesttoken", token);
}
xhr.onreadystatechange = handleResult;
xhr.timeout = serverCallTimeout;
// TODO: seems handleResult is called on timeout as well, with xhr.status === 0
// xhr.ontimeout = handleTimeout;
try {
xhr.send(messageString);
} catch (e) {
runtime.log("Problem with calling server: " + e + " " + data);
cb(e.message);
cbError(-1, e.message);
}
}
@ -151,14 +166,14 @@ runtime.log("Sending message to server: "+messageString);
} else {
failCb(responseData);
}
});
}, failCb);
};
/**
* @param {!string} userId
* @param {!string} sessionId
* @param {!function(!string)} successCb
* @param {function()=} failCb
* @param {!function()} failCb
* @return {undefined}
*/
this.joinSession = function (userId, sessionId, successCb, failCb) {
@ -175,18 +190,16 @@ runtime.log("Sending message to server: "+messageString);
if (response.hasOwnProperty("success") && response.success) {
successCb(response.member_id);
} else {
if (failCb) {
failCb();
}
failCb();
}
});
}, failCb);
};
/**
* @param {!string} sessionId
* @param {!string} memberId
* @param {!function()} successCb
* @param {function()=} failCb
* @param {!function()} failCb
* @return {undefined}
*/
this.leaveSession = function (sessionId, memberId, successCb, failCb) {
@ -203,18 +216,16 @@ runtime.log("Sending message to server: "+messageString);
if (response.hasOwnProperty("success") && response.success) {
successCb();
} else {
if (failCb) {
failCb();
}
failCb();
}
});
}, failCb);
};
/**
* @param {!string} sessionId
* @param {!string} memberId
* @param {!string} seqHead
* @param {function()=} callback
* @param {!function(!Object=)} callback
* @return {undefined}
*/
this.writeSessionStateToFile = function(sessionId, memberId, seqHead, fileData, callback) {

@ -40,8 +40,8 @@ define("webodf/editor/server/pullbox/ServerFactory", [
this.createServer = function (args) {
return new PullBoxServer(args);
};
this.createOperationRouter = function (sid, mid, server, odfContainer) {
return new PullBoxOperationRouter(sid, mid, server, odfContainer);
this.createOperationRouter = function (sid, mid, server, odfContainer, errorCallback) {
return new PullBoxOperationRouter(sid, mid, server, odfContainer, errorCallback);
};
this.createSessionList = function (server) {
return new PullBoxSessionList(server);

@ -111,6 +111,8 @@ define("webodf/editor/server/pullbox/SessionList", [], function () {
} else {
runtime.log("Meh, sessionlist data broken: " + responseData);
}
}, function() {
// ignore error for now
});
}

@ -75,7 +75,7 @@ define("webodf/editor/widgets/paragraphStyles",
if (value === "") {
value = defaultStyleUIId;
}
select.set('value', value);
select.set('value', value, false);
};
// events

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long
Loading…
Cancel
Save