/** * Copyright (C) 2014 KO GmbH * * @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 . * @licend * * @source: http://www.webodf.org/ * @source: https://github.com/kogmbh/WebODF/ */ /*global Wodo, require, navigator, dojo, runtime, document, window, core, ops, gui, odf*/ window.Wodo = window.Wodo || (function () { "use strict"; var /** @type{!boolean} */ isInitalized = false, /** @type{!Array.} */ pendingInstanceCreationCalls = [], /** @type{!number} */ instanceCounter = 0, // constructors BorderContainer, ContentPane, FullWindowZoomHelper, EditorSession, Tools, MemberListView, // const strings /** @const @type {!string} */ EVENT_UNKNOWNERROR = "unkownError", /** @const @type {!string} */ EVENT_METADATACHANGED = "metadataChanged", /** @const @type {!string} */ EVENT_BEFORESAVETOFILE = "beforeSaveToFile", /** @const @type {!string} */ EVENT_SAVEDTOFILE = "savedToFile", /** @const @type {!string} */ EVENT_HASLOCALUNSYNCEDOPERATIONSCHANGED = "hasLocalUnsyncedOperationsChanged", /** @const @type {!string} */ EVENT_HASSESSIONHOSTCONNECTIONCHANGED = "hasSessionHostConnectionChanged"; /** * @return {undefined} */ function initCollabTextEditor() { webodfModule.require([ "dijit/layout/BorderContainer", "dijit/layout/ContentPane", "webodf/editor/FullWindowZoomHelper", "webodf/editor/EditorSession", "webodf/editor/Tools", "webodf/editor/MemberListView", "webodf/editor/Translator"], function (BC, CP, FWZH, ES, T, MLV, Translator) { var locale = navigator.language || "en-US", editorBase = dojo.config && dojo.config.paths && dojo.config.paths["webodf/editor"], translationsDir = editorBase + '/translations', t; BorderContainer = BC; ContentPane = CP; FullWindowZoomHelper = FWZH; EditorSession = ES; Tools = T; MemberListView = MLV; // TODO: locale cannot be set by the user, also different for different editors t = new Translator(translationsDir, locale, function (editorTranslator) { runtime.setTranslator(editorTranslator.translate); // Extend runtime with a convenient translation function runtime.translateContent = function (node) { var i, element, tag, placeholder, translatable = node.querySelectorAll("*[text-i18n]"); for (i = 0; i < translatable.length; i += 1) { element = translatable[i]; tag = element.localName; placeholder = element.getAttribute('text-i18n'); if (tag === "label" || tag === "span" || /h\d/i.test(tag)) { element.textContent = runtime.tr(placeholder); } } }; isInitalized = true; pendingInstanceCreationCalls.forEach(function (create) { create(); }); return t; // return it so 't' is not unused }); }); } /** * @constructor * @param {!string} mainContainerElementId * @param {!Object.} editorOptions */ function CollabTextEditor(mainContainerElementId, editorOptions) { instanceCounter = instanceCounter + 1; /** * Returns true if either all features are wanted and this one is not explicitely disabled * or if not all features are wanted by default and it is explicitely enabled * @param {?boolean|undefined} isFeatureEnabled explicit flag which enables a feature * @param {!boolean=} isUnstable set to true if the feature is not stable (in collab mode) * @return {!boolean} */ function isEnabled(isFeatureEnabled, isUnstable) { if (isUnstable && !editorOptions.unstableFeaturesEnabled) { return false; } return editorOptions.allFeaturesEnabled ? (isFeatureEnabled !== false) : isFeatureEnabled; } var // mainContainerElement = document.getElementById(mainContainerElementId), canvasElement, canvasContainerElement, toolbarElement, toolbarContainerElement, // needed because dijit toolbar overwrites direct classList editorElement, /** @const @type{!string} */ canvasElementId = "webodfeditor-canvas" + instanceCounter, /** @const @type{!string} */ canvasContainerElementId = "webodfeditor-canvascontainer" + instanceCounter, /** @const @type{!string} */ toolbarElementId = "webodfeditor-toolbar" + instanceCounter, /** @const @type{!string} */ editorElementId = "webodfeditor-editor" + instanceCounter, // fullWindowZoomHelper, // memberListElement, membersElement, /** @const @type{!string} */ membersElementId = "webodfeditor-members" + instanceCounter, // mainContainer, memberListView, tools, odfCanvas, // editorSession, session, // saveOdtFile = editorOptions.saveCallback, close = editorOptions.closeCallback, // directTextStylingEnabled = isEnabled(editorOptions.directTextStylingEnabled), directParagraphStylingEnabled = isEnabled(editorOptions.directParagraphStylingEnabled), paragraphStyleSelectingEnabled = isEnabled(editorOptions.paragraphStyleSelectingEnabled), paragraphStyleEditingEnabled = isEnabled(editorOptions.paragraphStyleEditingEnabled), imageEditingEnabled = isEnabled(editorOptions.imageEditingEnabled, true), hyperlinkEditingEnabled = isEnabled(editorOptions.hyperlinkEditingEnabled, true), reviewModeEnabled = isEnabled(editorOptions.reviewModeEnabled, true), annotationsEnabled = reviewModeEnabled || isEnabled(editorOptions.annotationsEnabled), undoRedoEnabled = false, // no proper mechanism yet for collab zoomingEnabled = isEnabled(editorOptions.zoomingEnabled), // pendingMemberId, pendingEditorReadyCallback, // eventNotifier = new core.EventNotifier([ EVENT_UNKNOWNERROR, EVENT_METADATACHANGED, EVENT_BEFORESAVETOFILE, EVENT_SAVEDTOFILE, EVENT_HASLOCALUNSYNCEDOPERATIONSCHANGED, EVENT_HASSESSIONHOSTCONNECTIONCHANGED ]); runtime.assert(Boolean(mainContainerElement), "No id of an existing element passed to Wodo.createCollabTextEditor(): "+mainContainerElementId); /** * @param {!Object} changes * @return {undefined} */ function relayMetadataSignal(changes) { eventNotifier.emit(EVENT_METADATACHANGED, changes); } /** * @return {undefined} */ function createSession() { var viewOptions = { editInfoMarkersInitiallyVisible: true, caretAvatarsInitiallyVisible: true, caretBlinksOnRangeSelect: true }; // create session around loaded document session = new ops.Session(odfCanvas); editorSession = new EditorSession(session, pendingMemberId, { viewOptions: jQuery.extend(viewOptions,editorOptions.viewOptions), directTextStylingEnabled: directTextStylingEnabled, directParagraphStylingEnabled: directParagraphStylingEnabled, paragraphStyleSelectingEnabled: paragraphStyleSelectingEnabled, paragraphStyleEditingEnabled: paragraphStyleEditingEnabled, imageEditingEnabled: imageEditingEnabled, hyperlinkEditingEnabled: hyperlinkEditingEnabled, annotationsEnabled: annotationsEnabled, zoomingEnabled: zoomingEnabled, reviewModeEnabled: reviewModeEnabled }); if (undoRedoEnabled) { editorSession.sessionController.setUndoManager(new gui.TrivialUndoManager()); } memberListView.setEditorSession(editorSession); // Relay any metadata changes to the Editor's consumer as an event editorSession.sessionController.getMetadataController().subscribe(gui.MetadataController.signalMetadataChanged, relayMetadataSignal); // and report back to caller pendingEditorReadyCallback(); // reset pendingEditorReadyCallback = null; pendingMemberId = null; } /** * @return {undefined} */ function startEditing() { runtime.assert(editorSession, "editorSession should exist here."); tools.setEditorSession(editorSession); editorSession.sessionController.insertLocalCursor(); editorSession.sessionController.startEditing(); } /** * @return {undefined} */ function endEditing() { runtime.assert(editorSession, "editorSession should exist here."); tools.setEditorSession(undefined); editorSession.sessionController.endEditing(); editorSession.sessionController.removeLocalCursor(); } /** * @param {!string} eventid * @param {*} args * @return {undefined} */ function fireEvent(eventid, args) { eventNotifier.emit(eventid, args); } /** * @param {!Object} error * @return {undefined} */ function handleOperationRouterErrors(error) { // TODO: translate error into Editor ids or at least document the possible values fireEvent(EVENT_UNKNOWNERROR, error); } /** * @param {!SessionBackend} sessionBackend * @param {!function(!Error=):undefined} callback, passing an error object in case of error * @return {undefined} */ function joinSession(sessionBackend, editorReadyCallback) { runtime.assert(sessionBackend, "No sessionBackend passed."); runtime.assert(sessionBackend.getMemberId(), "sessionBackend should deliver a memberId here."); runtime.assert(!pendingEditorReadyCallback, "pendingEditorReadyCallback should not exist here."); runtime.assert(!editorSession, "editorSession should not exist here."); runtime.assert(!session, "session should not exist here."); pendingMemberId = sessionBackend.getMemberId(); pendingEditorReadyCallback = function () { // overwrite router var opRouter = sessionBackend.createOperationRouter(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(EVENT_HASLOCALUNSYNCEDOPERATIONSCHANGED, function (hasUnsyncedOps) { fireEvent(EVENT_HASLOCALUNSYNCEDOPERATIONSCHANGED, hasUnsyncedOps); }); opRouter.subscribe(EVENT_HASSESSIONHOSTCONNECTIONCHANGED, function (hasSessionHostConnection) { fireEvent(EVENT_HASSESSIONHOSTCONNECTIONCHANGED, hasSessionHostConnection); }); opRouter.subscribe(EVENT_BEFORESAVETOFILE, function () { fireEvent(EVENT_BEFORESAVETOFILE, null); }); opRouter.subscribe(EVENT_SAVEDTOFILE, function () { fireEvent(EVENT_SAVEDTOFILE, null); }); // now get existing ops and after that let the user edit opRouter.requestReplay(function done() { startEditing(); if (editorReadyCallback) { editorReadyCallback(); } }); }; odfCanvas.load(sessionBackend.getGenesisUrl()); } /** * Leave the currently joined edit session, and do cleanup. * @param {!function(!Error=)} callback, passing an error object in case of error * @return {undefined} */ function leaveSession(callback) { runtime.assert(session, "session should exist here."); endEditing(); session.close(function(err) { if (err) { callback(err); } else { // now also destroy session, will not be reused for new document memberListView.setEditorSession(undefined); editorSession.sessionController.getMetadataController().unsubscribe(gui.MetadataController.signalMetadataChanged, relayMetadataSignal); editorSession.destroy(function(err) { if (err) { callback(err); } else { editorSession = undefined; session.destroy(function(err) { if (err) { callback(err); } else { session = undefined; callback(); } }); } }); } }); } /** * @return {!Element} */ function getCanvasContainerElement() { return canvasContainerElement; } /** * @param {!function(err:?Error, !Uint8Array=):undefined} cb receiving zip as bytearray * @return {undefined} */ function getDocumentAsByteArray(cb) { var odfContainer = odfCanvas.odfContainer(); if (odfContainer) { odfContainer.createByteArray(function (ba) { cb(null, ba); }, function (err) { cb(err || "Could not create bytearray."); }); } else { cb("No odfContainer!"); } } /** * Sets the metadata fields from the given properties map. * Avoid setting certain fields since they are automatically set: * dc:creator * dc:date * meta:editing-cycles * If you do wish to externally set these fields, try getting * the server backend (if any) to inject operations into the timeline * with the relevant properties. * * The following properties are never used and will be removed for semantic * consistency from the document: * meta:editing-duration * meta:document-statistic * * Setting any of the above mentioned fields using this method will have no effect. * * @param {?Object.} setProperties A flat object that is a string->string map of field name -> value. * @param {?Array.} removedProperties An array of metadata field names (prefixed). * @return {undefined} */ function setMetadata(setProperties, removedProperties) { runtime.assert(editorSession, "editorSession should exist here."); editorSession.sessionController.getMetadataController().setMetadata(setProperties, removedProperties); } /** * Returns the value of the requested document metadata field * @param {!string} property A namespace-prefixed field name, for example * dc:creator * @return {?string} */ function getMetadata(property) { runtime.assert(editorSession, "editorSession should exist here."); return editorSession.sessionController.getMetadataController().getMetadata(property); } /** * Sets the current state of the document to be either the unmodified state * or a modified state. * If @p modified is @true and the current state was already a modified state, * this call has no effect and also does not remove the unmodified flag * from the state which has it set. * * @name TextEditor#setDocumentModified * @function * @param {!boolean} modified * @return {undefined} */ this.setDocumentModified = function (modified) { runtime.assert(editorSession, "editorSession should exist here."); if (undoRedoEnabled) { editorSession.sessionController.getUndoManager().setDocumentModified(modified); } }; /** * Returns if the current state of the document matches the unmodified state. * @name TextEditor#isDocumentModified * @function * @return {!boolean} */ this.isDocumentModified = function () { runtime.assert(editorSession, "editorSession should exist here."); if (undoRedoEnabled) { return editorSession.sessionController.getUndoManager().isDocumentModified(); } return false; }; /** * @return {undefined} */ function setFocusToOdfCanvas() { editorSession.sessionController.getEventManager().focus(); } /** * @param {!function(!Error=):undefined} callback, passing an error object in case of error * @return {undefined} */ function destroyInternal(callback) { mainContainerElement.removeChild(editorElement); mainContainerElement.removeChild(membersElement); callback(); } /** * @param {!function(!Error=):undefined} callback, passing an error object in case of error * @return {undefined} */ function destroy(callback) { var destroyCallbacks = []; // TODO: decide if some forced close should be done here instead of enforcing proper API usage runtime.assert(!session, "session should not exist here."); // TODO: investigate what else needs to be done mainContainer.destroyRecursive(true); destroyCallbacks = destroyCallbacks.concat([ fullWindowZoomHelper.destroy, memberListView.destroy, tools.destroy, odfCanvas.destroy, destroyInternal ]); core.Async.destroyAll(destroyCallbacks, callback); } ///////////////////////////////////////////////////////////////////// // Exposed API // make sure no data structures are shared, copy as needed // this.joinSession = joinSession; this.leaveSession = leaveSession; this.getDocumentAsByteArray = getDocumentAsByteArray; // setReadOnly: setReadOnly, this.setMetadata = setMetadata; this.getMetadata = getMetadata; // temporary hack: this.getCanvasContainerElement = getCanvasContainerElement; this.addEventListener = eventNotifier.subscribe; this.removeEventListener = eventNotifier.unsubscribe; this.destroy = destroy; /** * @return {undefined} */ function init() { var editorPane, memberListPane, documentns = document.documentElement.namespaceURI; function createElement(tagLocalName, id, className) { var element; element = document.createElementNS(documentns, tagLocalName); if (id) { element.id = id; } element.classList.add(className); return element; } // create needed tree structure canvasElement = createElement('div', canvasElementId, "webodfeditor-canvas"); canvasContainerElement = createElement('div', canvasContainerElementId, "webodfeditor-canvascontainer"); toolbarElement = createElement('span', toolbarElementId, "webodfeditor-toolbar"); toolbarContainerElement = createElement('span', undefined, "webodfeditor-toolbarcontainer"); editorElement = createElement('div', editorElementId, "webodfeditor-editor"); // put into tree canvasContainerElement.appendChild(canvasElement); toolbarContainerElement.appendChild(toolbarElement); editorElement.appendChild(toolbarContainerElement); editorElement.appendChild(canvasContainerElement); mainContainerElement.appendChild(editorElement); // memberlist plugin memberListElement = createElement('div', undefined, "webodfeditor-memberList"); membersElement = createElement('div', membersElementId, "webodfeditor-members"); // put into tree membersElement.appendChild(memberListElement); mainContainerElement.appendChild(membersElement); // style all elements with Dojo's claro. // Not nice to do this on body, but then there is no other way known // to style also all dialogs, which are attached directly to body document.body.classList.add("claro"); // prevent browser translation service messing up internal address system canvasElement.setAttribute("translate", "no"); canvasElement.classList.add("notranslate"); // create widgets mainContainer = new BorderContainer({}, mainContainerElementId); editorPane = new ContentPane({ region: 'center' }, editorElementId); mainContainer.addChild(editorPane); memberListPane = new ContentPane({ region: 'right', title: runtime.tr("Members") }, membersElementId); mainContainer.addChild(memberListPane); memberListView = new MemberListView(memberListElement); mainContainer.startup(); tools = new Tools(toolbarElementId, { onToolDone: setFocusToOdfCanvas, saveOdtFile: saveOdtFile, close: close, directTextStylingEnabled: directTextStylingEnabled, directParagraphStylingEnabled: directParagraphStylingEnabled, paragraphStyleSelectingEnabled: paragraphStyleSelectingEnabled, paragraphStyleEditingEnabled: paragraphStyleEditingEnabled, imageInsertingEnabled: imageEditingEnabled, hyperlinkEditingEnabled: hyperlinkEditingEnabled, annotationsEnabled: annotationsEnabled, undoRedoEnabled: undoRedoEnabled, zoomingEnabled: zoomingEnabled }); odfCanvas = new odf.OdfCanvas(canvasElement); odfCanvas.enableAnnotations(annotationsEnabled, true); odfCanvas.addListener("statereadychange", createSession); fullWindowZoomHelper = new FullWindowZoomHelper(toolbarContainerElement, canvasContainerElement); } init(); } /** * @param {!string} mainContainerElementId * @param {!Object.} editorOptions * @param {!function(err:?Error, editor:!CollabTextEditor=):undefined} onEditorCreated * @return {undefined} */ function createInstance(mainContainerElementId, editorOptions, onEditorCreated) { /** * @return {undefined} */ function create() { var editor = new CollabTextEditor(mainContainerElementId, editorOptions); onEditorCreated(null, editor); } if (!isInitalized) { pendingInstanceCreationCalls.push(create); // first request? if (pendingInstanceCreationCalls.length === 1) { initCollabTextEditor(); } } else { create(); } } // exposed API return { createCollabTextEditor: createInstance, // flags EVENT_UNKNOWNERROR: EVENT_UNKNOWNERROR, EVENT_METADATACHANGED: EVENT_METADATACHANGED, EVENT_BEFORESAVETOFILE: EVENT_BEFORESAVETOFILE, EVENT_SAVEDTOFILE: EVENT_SAVEDTOFILE, EVENT_HASLOCALUNSYNCEDOPERATIONSCHANGED: EVENT_HASLOCALUNSYNCEDOPERATIONSCHANGED, EVENT_HASSESSIONHOSTCONNECTIONCHANGED: EVENT_HASSESSIONHOSTCONNECTIONCHANGED }; }());