node-webodf
Version:
WebODF - JavaScript Document Engine http://webodf.org/
438 lines (396 loc) • 17.5 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 core, ops, gui, odf, runtime*/
/**
* @constructor
* @implements {core.Destroyable}
* @param {!ops.Session} session
* @param {!gui.SessionConstraints} sessionConstraints
* @param {!gui.SessionContext} sessionContext
* @param {!string} inputMemberId
* @param {function(!number, !number, !boolean):ops.Operation} directStyleOp
* @param {function(!number):!Array.<!ops.Operation>} paragraphStyleOps
*/
gui.TextController = function TextController(
session,
sessionConstraints,
sessionContext,
inputMemberId,
directStyleOp,
paragraphStyleOps
) {
"use strict";
var odtDocument = session.getOdtDocument(),
odfUtils = odf.OdfUtils,
domUtils = core.DomUtils,
/**
* @const
* @type {!boolean}
*/
BACKWARD = false,
/**
* @const
* @type {!boolean}
*/
FORWARD = true,
isEnabled = false,
/** @const */
textns = odf.Namespaces.textns,
/**@const*/
NEXT = core.StepDirection.NEXT;
/**
* @return {undefined}
*/
function updateEnabledState() {
if (sessionConstraints.getState(gui.CommonConstraints.EDIT.REVIEW_MODE) === true) {
isEnabled = /**@type{!boolean}*/(sessionContext.isLocalCursorWithinOwnAnnotation());
} else {
isEnabled = true;
}
}
/**
* @param {!ops.OdtCursor} cursor
* @return {undefined}
*/
function onCursorEvent(cursor) {
if (cursor.getMemberId() === inputMemberId) {
updateEnabledState();
}
}
/**
* @return {!boolean}
*/
this.isEnabled = function () {
return isEnabled;
};
/**
* Return the equivalent cursor range of the specified DOM range.
* This is found by rounding the range's start and end DOM points to the closest step as defined by the document's
* position filter (and optionally the root filter as well).
*
* @param {!Range} range Range to convert to an equivalent cursor selection
* @param {!Element} subTree Subtree to limit step searches within. E.g., limit to steps within a certain paragraph.
* @param {!boolean} withRootFilter Specify true to restrict steps to be within the same root as the range's
* start container.
* @return {!{position: !number, length: !number}}
*/
function domToCursorRange(range, subTree, withRootFilter) {
var filters = [odtDocument.getPositionFilter()],
startStep,
endStep,
stepIterator;
if (withRootFilter) {
filters.push(odtDocument.createRootFilter(/**@type{!Node}*/(range.startContainer)));
}
stepIterator = odtDocument.createStepIterator(/**@type{!Node}*/(range.startContainer), range.startOffset,
filters, subTree);
if (!stepIterator.roundToClosestStep()) {
runtime.assert(false, "No walkable step found in paragraph element at range start");
}
startStep = odtDocument.convertDomPointToCursorStep(stepIterator.container(), stepIterator.offset());
if (range.collapsed) {
endStep = startStep;
} else {
stepIterator.setPosition(/**@type{!Node}*/(range.endContainer), range.endOffset);
if (!stepIterator.roundToClosestStep()) {
runtime.assert(false, "No walkable step found in paragraph element at range end");
}
endStep = odtDocument.convertDomPointToCursorStep(stepIterator.container(), stepIterator.offset());
}
return {
position: /**@type{!number}*/(startStep),
length: /**@type{!number}*/(endStep - startStep)
};
}
/**
* Creates operations to remove the provided selection and update the destination
* paragraph's style if necessary.
* @param {!Range} range
* @return {!Array.<!ops.Operation>}
*/
function createRemoveSelectionOps(range) {
var firstParagraph,
lastParagraph,
mergedParagraphStyleName,
previousParagraphStart,
paragraphs = odfUtils.getParagraphElements(range),
paragraphRange = /**@type{!Range}*/(range.cloneRange()),
operations = [];
// If the removal range spans several paragraphs, decide the final paragraph's style name.
firstParagraph = paragraphs[0];
if (paragraphs.length > 1) {
if (odfUtils.hasNoODFContent(firstParagraph)) {
// If the first paragraph is empty, the last paragraph's style wins, otherwise the first wins.
lastParagraph = paragraphs[paragraphs.length - 1];
mergedParagraphStyleName = lastParagraph.getAttributeNS(odf.Namespaces.textns, 'style-name') || "";
// Side note:
// According to https://developer.mozilla.org/en-US/docs/Web/API/element.getAttributeNS, if there is no
// explicitly defined style, getAttributeNS might return either "" or null or undefined depending on the
// implementation. Simplify the operation by combining all these cases to be ""
} else {
mergedParagraphStyleName = firstParagraph.getAttributeNS(odf.Namespaces.textns, 'style-name') || "";
}
}
// Note, the operations are built up in reverse order to the paragraph DOM order. This prevents the need for
// any translation of paragraph start limits as the last paragraph will be removed and merged first
paragraphs.forEach(function(paragraph, index) {
var paragraphStart,
removeLimits,
intersectionRange,
removeOp,
mergeOp;
paragraphRange.setStart(paragraph, 0);
paragraphRange.collapse(true);
paragraphStart = domToCursorRange(paragraphRange, paragraph, false).position;
if (index > 0) {
mergeOp = new ops.OpMergeParagraph();
mergeOp.init({
memberid: inputMemberId,
paragraphStyleName: mergedParagraphStyleName,
destinationStartPosition: previousParagraphStart,
sourceStartPosition: paragraphStart,
// For perf reasons, only the very last merge paragraph op should move the cursor
moveCursor: index === 1
});
operations.unshift(mergeOp);
}
previousParagraphStart = paragraphStart;
paragraphRange.selectNodeContents(paragraph);
// The paragraph limits will differ from the text remove limits if either
// 1. the remove range starts within an different inline root such as within an annotation
// 2. the remove range doesn't cover the entire paragraph (i.e., it starts or ends within the paragraph)
intersectionRange = domUtils.rangeIntersection(paragraphRange, range);
if (intersectionRange) {
removeLimits = domToCursorRange(intersectionRange, paragraph, true);
if (removeLimits.length > 0) {
removeOp = new ops.OpRemoveText();
removeOp.init({
memberid: inputMemberId,
position: removeLimits.position,
length: removeLimits.length
});
operations.unshift(removeOp);
}
}
});
return operations;
}
/**
* Ensures the provided selection is a "forward" selection (i.e., length is positive)
* @param {!{position: number, length: number}} selection
* @return {!{position: number, length: number}}
*/
function toForwardSelection(selection) {
if (selection.length < 0) {
selection.position += selection.length;
selection.length = -selection.length;
}
return selection;
}
/**
* Insert a paragraph break at the current cursor location. Will remove any currently selected text first
* @return {!boolean}
*/
this.enqueueParagraphSplittingOps = function() {
if (!isEnabled) {
return false;
}
var cursor = odtDocument.getCursor(inputMemberId),
range = cursor.getSelectedRange(),
selection = toForwardSelection(odtDocument.getCursorSelection(inputMemberId)),
op,
operations = [],
styleOps,
originalParagraph = /**@type{!Element}*/(odfUtils.getParagraphElement(cursor.getNode())),
paragraphStyle = originalParagraph.getAttributeNS(textns, "style-name") || "";
if (selection.length > 0) {
operations = operations.concat(createRemoveSelectionOps(range));
}
op = new ops.OpSplitParagraph();
op.init({
memberid: inputMemberId,
position: selection.position,
paragraphStyleName: paragraphStyle,
sourceParagraphPosition: odtDocument.convertDomPointToCursorStep(originalParagraph, 0, NEXT),
moveCursor: true
});
operations.push(op);
// disabled for now, because nowjs seems to revert the order of the ops, which does not work here TODO: grouping of ops
/*
if (isAtEndOfParagraph) {
paragraphNode = odfUtils.getParagraphElement(odtDocument.getCursor(inputMemberId).getNode());
nextStyleName = odtDocument.getFormatting().getParagraphStyleAttribute(styleName, odf.Namespaces.stylens, 'next-style-name');
if (nextStyleName && nextStyleName !== styleName) {
op = new ops.OpSetParagraphStyle();
op.init({
memberid: inputMemberId,
position: position + 1, // +1 should be at the start of the new paragraph
styleName: nextStyleName
});
operations.push(op);
}
}
*/
if (paragraphStyleOps) {
styleOps = paragraphStyleOps(selection.position + 1);
operations = operations.concat(styleOps);
}
session.enqueue(operations);
return true;
};
/**
* Checks if there are any walkable positions in the specified direction within
* the current root, starting at the specified node.
* The iterator is constrained within the root element for the current cursor position so
* iteration will stop once the root is entirely walked in the requested direction
* @param {!Element} cursorNode
* @return {!core.StepIterator}
*/
function createStepIterator(cursorNode) {
var cursorRoot = odtDocument.getRootElement(cursorNode),
filters = [odtDocument.getPositionFilter(), odtDocument.createRootFilter(cursorRoot)];
return odtDocument.createStepIterator(cursorNode, 0, filters, cursorRoot);
}
/**
* Remove the current selection, or if the cursor is collapsed, remove the next step
* in the specified direction.
*
* @param {!boolean} isForward True indicates delete the next step. False indicates delete the previous step
* @return {!boolean}
*/
function removeTextInDirection(isForward) {
if (!isEnabled) {
return false;
}
var cursorNode,
// Take a clone of the range as it will be modified if the selection length is 0
range = /**@type{!Range}*/(odtDocument.getCursor(inputMemberId).getSelectedRange().cloneRange()),
selection = toForwardSelection(odtDocument.getCursorSelection(inputMemberId)),
stepIterator;
if (selection.length === 0) {
selection = undefined;
cursorNode = odtDocument.getCursor(inputMemberId).getNode();
stepIterator = createStepIterator(cursorNode);
// There must be at least one more step in the root same root as the cursor node
// in order to do something if there is no selected text
// TODO Superstition alert - Step rounding is probably not necessary as cursor should always be at a step
if (stepIterator.roundToClosestStep()
&& (isForward ? stepIterator.nextStep() : stepIterator.previousStep())) {
selection = toForwardSelection(odtDocument.convertDomToCursorRange({
anchorNode: cursorNode,
anchorOffset: 0,
focusNode: stepIterator.container(),
focusOffset: stepIterator.offset()
}));
if (isForward) {
range.setStart(cursorNode, 0);
range.setEnd(stepIterator.container(), stepIterator.offset());
} else {
range.setStart(stepIterator.container(), stepIterator.offset());
range.setEnd(cursorNode, 0);
}
}
}
if (selection) {
session.enqueue(createRemoveSelectionOps(range));
}
return selection !== undefined;
}
/**
* Removes the currently selected content. If no content is selected and there is at least
* one character to the left of the current selection, that character will be removed instead.
* @return {!boolean}
*/
this.removeTextByBackspaceKey = function () {
return removeTextInDirection(BACKWARD);
};
/**
* Removes the currently selected content. If no content is selected and there is at least
* one character to the right of the current selection, that character will be removed instead.
* @return {!boolean}
*/
this.removeTextByDeleteKey = function () {
return removeTextInDirection(FORWARD);
};
/**
* Removes the currently selected content
* @return {!boolean}
*/
this.removeCurrentSelection = function () {
if (!isEnabled) {
return false;
}
var range = odtDocument.getCursor(inputMemberId).getSelectedRange();
session.enqueue(createRemoveSelectionOps(range));
return true; // The function is always considered handled, even if nothing is removed
};
/**
* Removes currently selected text (if any) before inserting the supplied text.
* @param {!string} text
* @return {undefined}
*/
function insertText(text) {
if (!isEnabled) {
return;
}
var range = odtDocument.getCursor(inputMemberId).getSelectedRange(),
selection = toForwardSelection(odtDocument.getCursorSelection(inputMemberId)),
op, stylingOp, operations = [], useCachedStyle = false;
if (selection.length > 0) {
operations = operations.concat(createRemoveSelectionOps(range));
useCachedStyle = true;
}
op = new ops.OpInsertText();
op.init({
memberid: inputMemberId,
position: selection.position,
text: text,
moveCursor: true
});
operations.push(op);
if (directStyleOp) {
stylingOp = directStyleOp(selection.position, text.length, useCachedStyle);
if (stylingOp) {
operations.push(stylingOp);
}
}
session.enqueue(operations);
}
this.insertText = insertText;
/**
* @param {!function(!Error=)} callback, passing an error object in case of error
* @return {undefined}
*/
this.destroy = function (callback) {
odtDocument.unsubscribe(ops.Document.signalCursorMoved, onCursorEvent);
sessionConstraints.unsubscribe(gui.CommonConstraints.EDIT.REVIEW_MODE, updateEnabledState);
callback();
};
function init() {
odtDocument.subscribe(ops.Document.signalCursorMoved, onCursorEvent);
sessionConstraints.subscribe(gui.CommonConstraints.EDIT.REVIEW_MODE, updateEnabledState);
updateEnabledState();
}
init();
};