/** * 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 window, document, alert, navigator, require, dojo, runtime, core, gui, ops, odf, WodoFromSource*/ /** * Namespace of the Wodo.TextEditor * @namespace * @name Wodo */ window.Wodo = window.Wodo || (function () { "use strict"; function getInstallationPath() { /** * Sees to get the url of this script on top of the stack trace. * @param {!string|undefined} stack * @return {!string|undefined} */ function getScriptUrlFromStack(stack) { var url, matches; if (typeof stack === "string" && stack) { /*jslint regexp: true*/ matches = stack.match(/((?:http[s]?|file):\/\/[\/]?.+?\/[^:\)]*?)(?::\d+)(?::\d+)?/); /*jslint regexp: false*/ url = matches && matches[1]; } if (typeof url === "string" && url) { return url; } return undefined; } /** * Tries by various tricks to get the url of this script. * To be called if document.currentScript is not supported * @return {!string|undefined} */ function getCurrentScriptElementSrcByTricks() { var scriptElements = document.getElementsByTagName("script"); // if there is only one script, it must be this if (scriptElements.length === 1) { return scriptElements[0].src; } // otherwise get it from the stacktrace try { throw new Error(); } catch (err) { return getScriptUrlFromStack(err.stack); } } var path = ".", scriptElementSrc, a, pathname, pos; if (document.currentScript && document.currentScript.src) { scriptElementSrc = document.currentScript.src; } else { scriptElementSrc = getCurrentScriptElementSrcByTricks(); } if (scriptElementSrc) { a = document.createElement('a'); a.href = scriptElementSrc; pathname = a.pathname; if (pathname.charAt(0) !== "/") { // Various versions of Internet Explorer seems to neglect the leading slash under some conditions // (not when watching it with the dev tools of course!). This was confirmed in IE10 + IE11 pathname = "/" + pathname; } pos = pathname.lastIndexOf("/"); if (pos !== -1) { path = pathname.substr(0, pos); } } else { alert("Could not estimate installation path of the Wodo.TextEditor."); } return path; } var /** @inner @const @type{!string} */ installationPath = getInstallationPath(), /** @inner @type{!boolean} */ isInitalized = false, /** @inner @type{!Array.} */ pendingInstanceCreationCalls = [], /** @inner @type{!number} */ instanceCounter = 0, // TODO: avatar image url needs base-url setting. // so far Wodo itself does not have a setup call, // but then the avatar is also not used yet here defaultUserData = { fullName: "", color: "black", imageUrl: "avatar-joe.png" }, /** @inner @const @type{!Array.} */ userDataFieldNames = ["fullName", "color", "imageUrl"], /** @inner @const @type{!string} */ memberId = "localuser", // constructors BorderContainer, ContentPane, FullWindowZoomHelper, EditorSession, Tools, /** @inner @const @type{!string} */ MODUS_FULLEDITING = "fullediting", /** @inner @const @type{!string} */ MODUS_REVIEW = "review", /** @inner @const @type{!string} */ EVENT_UNKNOWNERROR = "unknownError", /** @inner @const @type {!string} */ EVENT_DOCUMENTMODIFIEDCHANGED = "documentModifiedChanged", /** @inner @const @type {!string} */ EVENT_METADATACHANGED = "metadataChanged"; window.dojoConfig = (function () { var WebODFEditorDojoLocale = "C"; if (navigator && navigator.language && navigator.language.match(/^(de)/)) { WebODFEditorDojoLocale = navigator.language.substr(0, 2); } return { locale: WebODFEditorDojoLocale, paths: { "webodf/editor": installationPath, "dijit": installationPath + "/dijit", "dojox": installationPath + "/dojox", "dojo": installationPath + "/dojo", "resources": installationPath + "/resources" } }; }()); /** * @return {undefined} */ function initTextEditor() { require([ "dijit/layout/BorderContainer", "dijit/layout/ContentPane", "webodf/editor/FullWindowZoomHelper", "webodf/editor/EditorSession", "webodf/editor/Tools", "webodf/editor/Translator"], function (BC, CP, FWZH, ES, T, 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; // 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); } } }; defaultUserData.fullName = runtime.tr("Unknown Author"); isInitalized = true; pendingInstanceCreationCalls.forEach(function (create) { create(); }); }); // only done to make jslint see the var used return t; } ); } /** * Creates a new record with userdata, and for all official fields * copies over the value from the original or, if not present there, * sets it to the default value. * @param {?Object.|undefined} original, defaults to {} * @return {!Object.} */ function cloneUserData(original) { var result = {}; if (!original) { original = {}; } userDataFieldNames.forEach(function (fieldName) { result[fieldName] = original[fieldName] || defaultUserData[fieldName]; }); return result; } /** * @name TextEditor * @constructor * @param {!string} mainContainerElementId * @param {!Object.} editorOptions */ function TextEditor(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 * @return {!boolean} */ function isEnabled(isFeatureEnabled) { return editorOptions.allFeaturesEnabled ? (isFeatureEnabled !== false) : isFeatureEnabled; } var userData, // mainContainerElement = document.getElementById(mainContainerElementId), canvasElement, canvasContainerElement, toolbarElement, toolbarContainerElement, // needed because dijit toolbar overwrites direct classList editorElement, /** @inner @const @type{!string} */ canvasElementId = "webodfeditor-canvas" + instanceCounter, /** @inner @const @type{!string} */ canvasContainerElementId = "webodfeditor-canvascontainer" + instanceCounter, /** @inner @const @type{!string} */ toolbarElementId = "webodfeditor-toolbar" + instanceCounter, /** @inner @const @type{!string} */ editorElementId = "webodfeditor-editor" + instanceCounter, // fullWindowZoomHelper, // mainContainer, tools, odfCanvas, // editorSession, session, // loadOdtFile = editorOptions.loadCallback, saveOdtFile = editorOptions.saveCallback, saveAsOdtFile = editorOptions.saveAsCallback, downloadOdtFile = editorOptions.downloadCallback, close = editorOptions.closeCallback, // reviewModeEnabled = (editorOptions.modus === MODUS_REVIEW), directTextStylingEnabled = isEnabled(editorOptions.directTextStylingEnabled), directParagraphStylingEnabled = isEnabled(editorOptions.directParagraphStylingEnabled), paragraphStyleSelectingEnabled = (!reviewModeEnabled) && isEnabled(editorOptions.paragraphStyleSelectingEnabled), paragraphStyleEditingEnabled = (!reviewModeEnabled) && isEnabled(editorOptions.paragraphStyleEditingEnabled), imageEditingEnabled = (!reviewModeEnabled) && isEnabled(editorOptions.imageEditingEnabled), hyperlinkEditingEnabled = isEnabled(editorOptions.hyperlinkEditingEnabled), annotationsEnabled = reviewModeEnabled || isEnabled(editorOptions.annotationsEnabled), undoRedoEnabled = isEnabled(editorOptions.undoRedoEnabled), zoomingEnabled = isEnabled(editorOptions.zoomingEnabled), // pendingMemberId, pendingEditorReadyCallback, // eventNotifier = new core.EventNotifier([ EVENT_UNKNOWNERROR, EVENT_DOCUMENTMODIFIEDCHANGED, EVENT_METADATACHANGED ]); runtime.assert(Boolean(mainContainerElement), "No id of an existing element passed to Wodo.createTextEditor(): " + mainContainerElementId); /** * @param {!Object} changes * @return {undefined} */ function relayMetadataSignal(changes) { eventNotifier.emit(EVENT_METADATACHANGED, changes); } /** * @param {!Object} changes * @return {undefined} */ function relayModifiedSignal(modified) { eventNotifier.emit(EVENT_DOCUMENTMODIFIEDCHANGED, modified); } /** * @return {undefined} */ function createSession() { var viewOptions = { editInfoMarkersInitiallyVisible: false, caretAvatarsInitiallyVisible: false, caretBlinksOnRangeSelect: true }; // create session around loaded document session = new ops.Session(odfCanvas); editorSession = new EditorSession(session, pendingMemberId, { viewOptions: 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()); editorSession.sessionController.getUndoManager().subscribe(gui.UndoManager.signalDocumentModifiedChanged, relayModifiedSignal); } // 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(); } /** * Loads an ODT document into the editor. * @name TextEditor#openDocumentFromUrl * @function * @param {!string} docUrl url from which the ODT document can be loaded * @param {!function(!Error=):undefined} callback Called once the document has been opened, passes an error object in case of error * @return {undefined} */ this.openDocumentFromUrl = function (docUrl, editorReadyCallback) { runtime.assert(docUrl, "document should be defined 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 = memberId; pendingEditorReadyCallback = function () { var op = new ops.OpAddMember(); op.init({ memberid: memberId, setProperties: userData }); session.enqueue([op]); startEditing(); if (editorReadyCallback) { editorReadyCallback(); } }; odfCanvas.load(docUrl); }; /** * Closes the document, and does cleanup. * @name TextEditor#closeDocument * @function * @param {!function(!Error=):undefined} callback Called once the document has been closed, passes an error object in case of error * @return {undefined} */ this.closeDocument = function (callback) { runtime.assert(session, "session should exist here."); endEditing(); var op = new ops.OpRemoveMember(); op.init({ memberid: memberId }); session.enqueue([op]); session.close(function (err) { if (err) { callback(err); } else { 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(); } }); } }); } }); }; /** * @name TextEditor#getDocumentAsByteArray * @function * @param {!function(err:?Error, file:!Uint8Array=):undefined} callback Called with the current document as ODT file as bytearray, passes an error object in case of error * @return {undefined} */ this.getDocumentAsByteArray = function (callback) { var odfContainer = odfCanvas.odfContainer(); if (odfContainer) { odfContainer.createByteArray(function (ba) { callback(null, ba); }, function (errorString) { callback(new Error(errorString || "Could not create bytearray from OdfContainer.")); }); } else { callback(new Error("No odfContainer set!")); } }; /** * 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 * * 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. * * @name TextEditor#setMetadata * @function * @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} */ this.setMetadata = function (setProperties, removedProperties) { runtime.assert(editorSession, "editorSession should exist here."); editorSession.sessionController.getMetadataController().setMetadata(setProperties, removedProperties); }; /** * Returns the value of the requested document metadata field. * @name TextEditor#getMetadata * @function * @param {!string} property A namespace-prefixed field name, for example * dc:creator * @return {?string} */ this.getMetadata = function (property) { runtime.assert(editorSession, "editorSession should exist here."); return editorSession.sessionController.getMetadataController().getMetadata(property); }; /** * Sets the data for the person that is editing the document. * The supported fields are: * "fullName": the full name of the editing person * "color": color to use for the user specific UI elements * @name TextEditor#setUserData * @function * @param {?Object.|undefined} data * @return {undefined} */ function setUserData(data) { userData = cloneUserData(data); } this.setUserData = setUserData; /** * Returns the data set for the person that is editing the document. * @name TextEditor#getUserData * @function * @return {!Object.} */ this.getUserData = function () { return cloneUserData(userData); }; /** * 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 passes an error object in case of error * @return {undefined} */ function destroyInternal(callback) { mainContainerElement.removeChild(editorElement); callback(); } /** * Destructs the editor object completely. * @name TextEditor#destroy * @function * @param {!function(!Error=):undefined} callback Called once the destruction has been completed, passes an error object in case of error * @return {undefined} */ this.destroy = function (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, tools.destroy, odfCanvas.destroy, destroyInternal ]); core.Async.destroyAll(destroyCallbacks, callback); }; // TODO: // this.openDocumentFromByteArray = openDocumentFromByteArray; see also https://github.com/kogmbh/WebODF/issues/375 // setReadOnly: setReadOnly, /** * Registers a callback which should be called if the given event happens. * @name TextEditor#addEventListener * @function * @param {!string} eventId * @param {!Function} callback * @return {undefined} */ this.addEventListener = eventNotifier.subscribe; /** * Unregisters a callback for the given event. * @name TextEditor#removeEventListener * @function * @param {!string} eventId * @param {!Function} callback * @return {undefined} */ this.removeEventListener = eventNotifier.unsubscribe; /** * @return {undefined} */ function init() { var editorPane, /** @inner @const @type{!string} */ documentns = document.documentElement.namespaceURI; /** * @param {!string} tagLocalName * @param {!string|undefined} id * @param {!string} className * @return {!Element} */ 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); // 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 // TODO: this should be done more centrally, but where exactly? canvasElement.setAttribute("translate", "no"); canvasElement.classList.add("notranslate"); // create widgets mainContainer = new BorderContainer({}, mainContainerElementId); editorPane = new ContentPane({ region: 'center' }, editorElementId); mainContainer.addChild(editorPane); mainContainer.startup(); tools = new Tools(toolbarElementId, { onToolDone: setFocusToOdfCanvas, loadOdtFile: loadOdtFile, saveOdtFile: saveOdtFile, saveAsOdtFile: saveAsOdtFile, downloadOdtFile: downloadOdtFile, close: close, directTextStylingEnabled: directTextStylingEnabled, directParagraphStylingEnabled: directParagraphStylingEnabled, paragraphStyleSelectingEnabled: paragraphStyleSelectingEnabled, paragraphStyleEditingEnabled: paragraphStyleEditingEnabled, imageInsertingEnabled: imageEditingEnabled, hyperlinkEditingEnabled: hyperlinkEditingEnabled, annotationsEnabled: annotationsEnabled, undoRedoEnabled: undoRedoEnabled, zoomingEnabled: zoomingEnabled, aboutEnabled: true }); odfCanvas = new odf.OdfCanvas(canvasElement); odfCanvas.enableAnnotations(annotationsEnabled, true); odfCanvas.addListener("statereadychange", createSession); fullWindowZoomHelper = new FullWindowZoomHelper(toolbarContainerElement, canvasContainerElement); setUserData(editorOptions.userData); } init(); } function loadDojoAndStuff(callback) { var head = document.getElementsByTagName("head")[0], frag = document.createDocumentFragment(), link, script; // append two link and two script elements to the header link = document.createElement("link"); link.rel = "stylesheet"; link.href = installationPath + "/app/resources/app.css"; link.type = "text/css"; link.async = false; frag.appendChild(link); link = document.createElement("link"); link.rel = "stylesheet"; link.href = installationPath + "/wodotexteditor.css"; link.type = "text/css"; link.async = false; frag.appendChild(link); script = document.createElement("script"); script.src = installationPath + "/dojo-amalgamation.js"; script["data-dojo-config"] = "async: true"; script.charset = "utf-8"; script.type = "text/javascript"; script.async = false; frag.appendChild(script); script = document.createElement("script"); script.src = installationPath + "/webodf.js"; script.charset = "utf-8"; script.type = "text/javascript"; script.async = false; script.onload = callback; frag.appendChild(script); head.appendChild(frag); } /** * Creates a text editor object and returns it on success in the passed callback. * @name Wodo#createTextEditor * @function * @param {!string} editorContainerElementId id of the existing div element which will contain the editor (should be empty before) * @param editorOptions options to configure the features of the editor. All entries are optional * @param [editorOptions.modus=Wodo.MODUS_FULLEDITING] set the editing modus. Current options: Wodo.MODUS_FULLEDITING, Wodo.MODUS_REVIEW * @param [editorOptions.loadCallback] parameter-less callback method, adds a "Load" button to the toolbar which triggers this method * @param [editorOptions.saveCallback] parameter-less callback method, adds a "Save" button to the toolbar which triggers this method * @param [editorOptions.saveAsCallback] parameter-less callback method, adds a "Save as" button to the toolbar which triggers this method * @param [editorOptions.downloadCallback] parameter-less callback method, adds a "Download" button to the right of the toolbar which triggers this method * @param [editorOptions.closeCallback] parameter-less callback method, adds a "Save" button to the toolbar which triggers this method * @param [editorOptions.allFeaturesEnabled=false] if set to 'true', switches the default for all features from 'false' to 'true' * @param [editorOptions.directTextStylingEnabled=false] if set to 'true', enables the direct styling of text (e.g. bold/italic or font) * @param [editorOptions.directParagraphStylingEnabled=false] if set to 'true', enables the direct styling of paragraphs (e.g. indentation or alignement) * @param [editorOptions.paragraphStyleSelectingEnabled=false] if set to 'true', enables setting of defined paragraph styles to paragraphs * @param [editorOptions.paragraphStyleEditingEnabled=false] if set to 'true', enables the editing of defined paragraph styles * @param [editorOptions.imageEditingEnabled=false] if set to 'true', enables the insertion of images * @param [editorOptions.hyperlinkEditingEnabled=false] if set to 'true', enables the editing of hyperlinks * @param [editorOptions.annotationsEnabled=false] if set to 'true', enables the display and the editing of annotations * @param [editorOptions.undoRedoEnabled=false] if set to 'true', enables the Undo and Redo of editing actions * @param [editorOptions.zoomingEnabled=false] if set to 'true', enables the zooming tool * @param [editorOptions.userData] data about the user editing the document * @param [editorOptions.userData.fullName] full name of the user, used for annotations and in the metadata of the document * @param [editorOptions.userData.color="black"] color to use for any user related indicators like cursor or annotations * @param {!function(err:?Error, editor:!TextEditor=):undefined} onEditorCreated * @return {undefined} */ function createTextEditor(editorContainerElementId, editorOptions, onEditorCreated) { /** * @return {undefined} */ function create() { var editor = new TextEditor(editorContainerElementId, editorOptions); onEditorCreated(null, editor); } if (!isInitalized) { pendingInstanceCreationCalls.push(create); // first request? if (pendingInstanceCreationCalls.length === 1) { if (String(typeof WodoFromSource) === "undefined") { loadDojoAndStuff(initTextEditor); } else { initTextEditor(); } } } else { create(); } } /** * @lends Wodo# */ return { createTextEditor: createTextEditor, // flags /** Id of full editing modus */ MODUS_FULLEDITING: MODUS_FULLEDITING, /** Id of review modus */ MODUS_REVIEW: MODUS_REVIEW, /** Id of event for an unkown error */ EVENT_UNKNOWNERROR: EVENT_UNKNOWNERROR, /** Id of event if documentModified state changes */ EVENT_DOCUMENTMODIFIEDCHANGED: EVENT_DOCUMENTMODIFIEDCHANGED, /** Id of event if metadata changes */ EVENT_METADATACHANGED: EVENT_METADATACHANGED }; }());