/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.waveprotocol.wave.client.editor.extract;
import com.google.gwt.dom.client.Element;
import com.google.gwt.dom.client.Node;
import com.google.gwt.dom.client.Text;
import org.waveprotocol.wave.client.common.util.DomHelper;
import org.waveprotocol.wave.client.editor.EditorStaticDeps;
import org.waveprotocol.wave.client.editor.RestrictedRange;
import org.waveprotocol.wave.client.editor.content.ContentNode;
import org.waveprotocol.wave.client.editor.content.ContentTextNode;
import org.waveprotocol.wave.client.editor.content.ContentView;
import org.waveprotocol.wave.client.editor.extract.InconsistencyException.HtmlInserted;
import org.waveprotocol.wave.client.editor.extract.InconsistencyException.HtmlMissing;
import org.waveprotocol.wave.client.editor.impl.HtmlView;
import org.waveprotocol.wave.client.editor.impl.NodeManager;
import org.waveprotocol.wave.client.scheduler.Scheduler;
import org.waveprotocol.wave.client.scheduler.SchedulerInstance;
import org.waveprotocol.wave.client.scheduler.SchedulerTimerService;
import org.waveprotocol.wave.client.scheduler.TimerService;
import org.waveprotocol.wave.model.document.util.Point;
import org.waveprotocol.wave.model.util.Preconditions;
import java.util.ArrayList;
import java.util.List;
/**
* This class extracts operations from one or more typing changes made by the
* native editor. It relies on the various EditorImpl implementations to call
* its {@link #somethingHappened(Point)} method appropriately.
*
* The extractor may at any time have outstanding changes queued up for several
* ranges of text, each comprising multiple text nodelets and up to 2
* ContentTextNodes one text node. Call {@link #flush()} to ensure all such
* outstanding changes have been extracted
*/
public class TypingExtractor {
/**
* The interface used by the typing extractor to express what typing changes
* have occurred.
*/
public interface TypingSink {
/**
* Called just before the flush code executes
*/
void aboutToFlush();
/**
* Replacement of text occurred at the given location.
*
* The deleted area must only encompass text.
*
* @param start Place where replacement begins
* @param length Length of replaced text
* @param text Replacement text
* @param range Range bounding the typing sequence
*/
void typingReplace(Point<ContentNode> start, int length, String text,
RestrictedRange<ContentNode> range);
}
/**
* An interface that provides information about the current user selection
* in the raw html.
*
* Useful to provide a fake implementation for testing.
*/
public interface SelectionSource {
/**
* @return The start point of the selection in the raw html
*/
Point<Node> getSelectionStart();
/**
* @return The end point. It could be the same as the start point.
*/
Point<Node> getSelectionEnd();
}
/** Our "output" */
private final TypingSink sink;
/** Needed to get the wrapper for a given html node */
private final NodeManager manager;
/** How we know what the current selection is */
private final SelectionSource selectionSource;
/** Areas of changing text we are tracking */
private final List<TypingState> statePool = new ArrayList<TypingState>();
/** Number of states being tracked */
private int numStates = 0;
/** html */
private final HtmlView filteredHtmlView;
/** corresponding content */
private final ContentView renderedContentView;
/** when things go wrong */
private final Repairer repairer;
/**
* To prevent infinite recursion when searching for nearby areas of text.
* We only need to search once, so a boolean suffices.
*/
private boolean searchingForAdjacentArea = false;
/**
* Mutating State
* Tracks text changing under a single ContentTextNode (possibly
* multiple text nodelets). Sometimes we need to track more than
* one of these at a time.
*/
private class TypingState {
/**
* The wrapper nodes whose html text node(s) are being changed. If null,
* we don't have a wrapper associated with the current action (meaning we are
* inserting new text in an empty element, or between two elements).
* Currently, firstWrapper will either be equal to or adjacent to lastWrapper.
* We need two when the typing occurs at a boundary between content text nodes.
*/
private ContentTextNode firstWrapper = null, lastWrapper = null;
/** The range being changed from the content view */
private RestrictedRange<ContentNode> contentRange = null;
/** The range being changed from the html view */
private RestrictedRange<Node> htmlRange = null;
/**
* The smallest length before the selection across
* all versions of the text under the restricted range
*/
private int minpre = 0;
/**
* The last text node we saw in a previous call to
* {@link #somethingHappened(Point)}. Usually it will be the same node,
* so we can speed things up by avoiding more expensive checks to see if we
* are typing in the same place
*/
private Text lastTextNode = null;
/**
* clears all state
*/
private void clear() {
contentRange = null;
firstWrapper = null;
lastWrapper = null;
htmlRange = null;
minpre = 0;
lastTextNode = null;
}
/**
* @return true if this state can be reused
*/
private boolean isClear() {
return contentRange == null;
}
private boolean isPartOfThisState(Point<Node> point) {
checkRangeIsValid();
Text node = point.isInTextNode()
? point.getContainer().<Text>cast()
: null;
if (node == null) {
// If we're not in a text node - i.e. we just started typing
// either in an empty element, or between elements.
if (htmlRange.getNodeAfter() == point.getNodeAfter()
&& htmlRange.getContainer() == point.getContainer()) {
return true;
} else if (point.getNodeAfter() == null) {
return false;
} else {
return partOfMutatingRange(point.getNodeAfter());
}
}
// The first check is redundant but speeds up the general case
return node == lastTextNode || partOfMutatingRange(node);
}
private boolean partOfMutatingRange(Node node) {
return htmlRange.contains(filteredHtmlView, node);
}
/**
* Specify that a typing sequence might be starting between two elements,
* or in an empty element. If there is a text node at the cursor,
* {@link #startTypingSequence(Point.Tx)} should be called instead
* @param point A point between nodes
*/
private void startTypingSequence(Point.El<Node> point) {
htmlRange = RestrictedRange.collapsedAt(filteredHtmlView, point);
assert htmlRange.getContainer() != null;
assert
(htmlRange.getNodeBefore() == null || !DomHelper.isTextNode(htmlRange.getNodeBefore())) &&
(htmlRange.getNodeAfter() == null || !DomHelper.isTextNode(htmlRange.getNodeAfter()));
contentRange = RestrictedRange.<ContentNode>boundedBy(
NodeManager.getBackReference(htmlRange.getContainer().<Element>cast()),
NodeManager.getBackReference(htmlRange.getNodeBefore().<Element>cast()),
NodeManager.getBackReference(htmlRange.getNodeAfter().<Element>cast()));
}
/**
* Start a typing sequence in a text node, even if the text node has no
* ContentTextNode wrapper.
* @param previousSelectionStart The selection just before the text changes. However
* it's not the end of the world if it's the selection after the text changed,
* as often happens with some international input methods.
* @throws HtmlMissing When something is abnormal
* @throws HtmlInserted When an element got inserted. We won't throw this
* for text nodes, instead we'll assume they're new and part of this
* typing sequence.
*/
private void startTypingSequence(Point.Tx<Node> previousSelectionStart)
throws HtmlMissing, HtmlInserted {
Text node = previousSelectionStart.getContainer().cast();
ContentView renderedContent = renderedContentView;
HtmlView filteredHtml = filteredHtmlView;
try {
// This might throw an exception
ContentTextNode wrapper = manager.findTextWrapper(node, true);
// No exception -> already a wrapper for this node (we're editing some existing text)
firstWrapper = wrapper;
lastWrapper = wrapper;
checkNeighbouringTextNodes(previousSelectionStart);
contentRange = RestrictedRange.around(renderedContent, firstWrapper, lastWrapper);
// Ensure methods we call on the text node operate on the same view as us
assert wrapper.getFilteredHtmlView() == filteredHtml;
Node htmlNodeBefore = filteredHtml.getPreviousSibling(firstWrapper.getImplNodelet());
Element htmlParent = filteredHtml.getParentElement(node);
ContentNode cnodeAfter = contentRange.getNodeAfter();
Node htmlNodeAfter = cnodeAfter == null ? null : cnodeAfter.getImplNodelet();
htmlRange = RestrictedRange.between(
htmlNodeBefore, Point.inElement(htmlParent, htmlNodeAfter));
if (partOfMutatingRange(filteredHtml.asText(previousSelectionStart.getContainer()))) {
// This must be true if getWrapper worked correctly. Program error
// otherwise (not browser error)
assert firstWrapper.getImplNodelet() == htmlRange.getStartNode(filteredHtml);
// NOTE(danilatos): We are asking the firstWrapper to give us the offset of
// a nodelet that might not actually belong to it, but to its next sibling.
// This is ok, because we tell it what node to stop the search at, and it
// doesn't know any better.
minpre = previousSelectionStart.getTextOffset() +
firstWrapper.getOffset(node, htmlNodeAfter);
}
} catch (HtmlInserted e) {
// Exception caught -> no wrapper for this node (we're starting a new chunk of text)
Node nodeAfter = e.getHtmlPoint().getNodeAfter();
if (!DomHelper.isTextNode(nodeAfter)) {
throw e;
}
node = nodeAfter.cast();
contentRange = RestrictedRange.collapsedAt(renderedContent, e.getContentPoint());
Node before = contentRange.getNodeBefore() == null
? null : contentRange.getNodeBefore().getImplNodelet();
Node after = contentRange.getNodeAfter() == null
? null : contentRange.getNodeAfter().getImplNodelet();
htmlRange = RestrictedRange.between(before,
Point.inElement(contentRange.getContainer().getImplNodelet(), after));
}
}
/**
* Continue tracking an existing typing sequence, after we have determined
* that this selection is indeed part of an existing one
* @param previousSelectionStart
* @throws HtmlMissing
* @throws HtmlInserted
*/
private void continueTypingSequence(Point<Node> previousSelectionStart)
throws HtmlMissing, HtmlInserted {
if (firstWrapper != null) {
// minpost is only needed if we allow non-typing actions (such as moving
// with arrow keys) to stay as part of the same typing sequence. otherwise,
// minpost should always correspond to the last cursor position.
// TODO(danilatos): Ensure this is the case
updateMinPre(previousSelectionStart);
// TODO(danilatos): Is it possible to ever need to check neighbouring
// nodes if we're not in a text node now? If we're not, we are almost
// certainly somewhere were there are no valid neighbouring text nodes,
// otherwise the selection should have been reported as in one of
// those nodes.....
checkNeighbouringTextNodes(previousSelectionStart);
}
}
/**
* Checks to see if we need to expand the current state to cover a larger
* area, or add a new state to track inside a neighbouring element.
* @param previousSelectionStart
* @throws HtmlMissing
* @throws HtmlInserted
*/
private void checkNeighbouringTextNodes(Point<Node> previousSelectionStart)
throws HtmlMissing, HtmlInserted {
// Note that this method is called before most of the member variables are
// initialised, so therefore don't use them! However we assert that we
// have first & last wrapper, which is what we care about here.
assert firstWrapper != null && lastWrapper != null;
if (searchingForAdjacentArea) {
return;
}
try {
searchingForAdjacentArea = true;
HtmlView filteredHtml = filteredHtmlView;
ContentView renderedContent = renderedContentView;
// Is this method slow? we need it often enough, but not in 95% of scenarios,
// so there is room to optimise.
// See if there are other text nodes we should check
Text selNode = previousSelectionStart.getContainer().cast();
int selOffset = previousSelectionStart.getTextOffset();
// TODO(patcoleman): see if being zero here is actually a problem.
// assert selNode.getLength() > 0;
if (selOffset == 0 && firstWrapper.getImplNodelet() == selNode) {
// if we are at beginning of mutating node
ContentNode prev = renderedContent.getPreviousSibling(firstWrapper);
if (prev != null && prev.isTextNode()) {
firstWrapper = (ContentTextNode)prev;
}
} else {
ContentNode nextNode = renderedContent.getNextSibling(lastWrapper);
Node nextNodelet = nextNode != null ? nextNode.getImplNodelet() : null;
if (selOffset == selNode.getLength() &&
filteredHtml.getNextSibling(selNode) == nextNodelet) {
// if we are at end of mutating node
if (nextNode != null && nextNode.isTextNode()) {
lastWrapper = (ContentTextNode)nextNode;
}
}
}
} finally {
searchingForAdjacentArea = false;
}
}
/**
* @return The current value of the text in the html, within our tracked range
*/
private String calculateNewValue() {
HtmlView filteredHtml = filteredHtmlView;
Text fromIncl = htmlRange.getStartNode(filteredHtml).cast();
Node toExcl = htmlRange.getPointAfter().getNodeAfter();
return ContentTextNode.sumTextNodes(fromIncl, toExcl, filteredHtml);
}
/**
* Set the last text node we looked at, to optimise the general case of
* checking if a text node is part of the given typing sequence.
*/
private void setLastTextNode(Text node) {
lastTextNode = node;
}
/**
* Outputs any pending operations and reset state
*/
public void flush() {
try {
// Return if no operations are pending
if (isClear()) {
return;
}
checkRangeIsValid();
String newValue = calculateNewValue();
if (firstWrapper == null) {
if (newValue.length() > 0) {
// point after and point before should be identical, point after is
// a simple getter though, rather than involving a calculation.
sink.typingReplace(contentRange.getPointAfter(), 0, newValue, contentRange);
}
} else {
// TODO(danilatos): Avoid calculating all of impl data
// NOTE(danilatos): Assume that our range contains at most 2 wrappers
String originalValue = firstWrapper.getData() +
(firstWrapper != lastWrapper ? lastWrapper.getData() : "");
Point<Node> selectionStart = selectionSource.getSelectionStart();
Point<Node> selectionEnd = selectionSource.getSelectionEnd();
// TODO(danilatos): Use some old selection value rather than forcing
// checking the whole node?
if (selectionStart != null) {
updateMinPre(selectionStart);
} else {
minpre = 0;
}
int minpost;
if (selectionEnd != null) {
int endOffset = getAbsoluteOffset(selectionEnd);
minpost = (endOffset == -1) ? 0 : newValue.length() - endOffset;
// // XXX(danilatos): Figure out why this might ever happen, instead of just
// // stupidly guarding the condition
// if (minpost > originalValue.length() || minpost > newValue.length()) {
// minpost = 0;
// }
} else {
minpost = 0;
}
assert minpre >= 0 && minpost >= 0 : "minpre/minpost outside valid range, minpre: " +
minpre + " minpost: " + minpost;
if (minpre < 0) minpre = 0;
if (minpost < 0) minpost = 0;
// Compute what has been deleted and what been inserted
// based solely on minpre and minpost
int deleteEndIndex = originalValue.length() - minpost;
int insertEndIndex = newValue.length() - minpost;
int startIndex = Math.min(minpre, deleteEndIndex);
// Try to expand/contract the region of change
// Expanding sometimes happens with multilanguage input.
// E.g. when typing with pinyin, you can type a whole bunch of roman
// characters, then trigger them all to be converted at once by
// pressing space
while (startIndex > 0
&& originalValue.charAt(startIndex - 1) != newValue.charAt(startIndex - 1)) {
startIndex--;
}
while (startIndex < deleteEndIndex && startIndex < insertEndIndex
&& originalValue.charAt(startIndex) == newValue.charAt(startIndex)) {
startIndex++;
}
int minpostLeft = minpost;
while (minpostLeft > 0
&& originalValue.charAt(deleteEndIndex) != newValue.charAt(insertEndIndex)) {
deleteEndIndex++;
insertEndIndex++;
minpostLeft--;
}
while (startIndex < deleteEndIndex && startIndex < insertEndIndex
&& originalValue.charAt(deleteEndIndex - 1) ==
newValue.charAt(insertEndIndex - 1)) {
deleteEndIndex--;
insertEndIndex--;
}
assert startIndex <= deleteEndIndex : "startIndex larger than deleteEndIndex, " +
"startIndex: " + startIndex + " deleteEndIndex: " + deleteEndIndex;
assert startIndex <= insertEndIndex : "startIndex larger than insertEndIndex, " +
"startIndex: " + startIndex + " insertEndIndex: " + insertEndIndex;
// Check whether we need to do a delete or an insert now, as
// we might be modifying these variables shortly.
boolean deleting = startIndex < deleteEndIndex;
boolean inserting = startIndex < insertEndIndex;
int deleteSize = deleteEndIndex - startIndex;
// Figure out what node the start point lies in
ContentTextNode startNode = firstWrapper, deleteEndNode = firstWrapper;
if (firstWrapper.getLength() < startIndex) {
assert firstWrapper != lastWrapper : "first wrapper != lastWrapper";
startIndex -= firstWrapper.getLength();
startNode = lastWrapper;
}
Point<ContentNode> start = Point.<ContentNode>inText(startNode, startIndex);
if (deleting || inserting) {
sink.typingReplace(start, deleteSize, newValue.substring(startIndex, insertEndIndex),
contentRange);
}
}
} catch (RuntimeException e) { // TODO(danilatos): Is this the best type & place to catch?
EditorStaticDeps.logger.error().log(e);
EditorStaticDeps.logger.trace();
tryRepair();
} finally {
// Clear all state
clear();
}
}
private void tryRepair() {
if (contentRange == null) {
// no range, so revert everything
repairer.revert(renderedContentView, renderedContentView.getDocumentElement());
} else {
repairer.revert(
contentRange.getPointBefore(renderedContentView),
contentRange.getPointAfter());
}
clear();
}
/**
* Updates minpre
*
* @param selectionStart
*/
private void updateMinPre(Point<Node> selectionStart) {
if (selectionStart == null) {
minpre = 0;
return;
}
int newVal = getAbsoluteOffset(selectionStart);
if (newVal == -1) {
newVal = 0;
}
minpre = Math.min(minpre, newVal);
}
/**
* @param point
* @return The offset of the point with respect to the start of our typing
* sequence, not with respect to its container text node. Returns -1
* if the point's container node isn't one of the impl nodes of our
* mutatingNode.
*/
private int getAbsoluteOffset(Point<Node> point) {
// This method is used to calculate minpre and minpost.
// We shouldn't need this method if mutatingNode is null, because
// in such cases minpre and minpost are both trivially zero.
assert firstWrapper != null;
if (partOfMutatingRange(point.getContainer())) {
// TODO(danilatos): check for mutatingNodeOwns duplicates a loop which
// is done in getOffset
Text toFind = point.getContainer().<Text>cast();
HtmlView filteredHtml = filteredHtmlView;
return ContentTextNode.getOffset(
toFind,
htmlRange.getStartNode(filteredHtml).<Text>cast(),
htmlRange.getNodeAfter(),
filteredHtml) + point.getTextOffset();
}
return -1;
}
}
/**
* Use this to schedule a future flush unless one is already pending
*/
private final Scheduler.Task flushCmd;
private final TimerService timerService;
/**
* @param sink
* @param manager
* @param selectionSource
*/
public TypingExtractor(TypingSink sink, NodeManager manager,
HtmlView filteredHtmlView, ContentView renderedContentView,
Repairer repairer, SelectionSource selectionSource) {
// TYPING EXTRACTOR MUST ALWAYS BE CRITICAL PRIORITY
// NOTHING ELSE CAN BE CRITICAL
this(sink, manager,
new SchedulerTimerService(SchedulerInstance.get(), Scheduler.Priority.CRITICAL),
filteredHtmlView, renderedContentView, repairer, selectionSource);
}
TypingExtractor(TypingSink sink, NodeManager manager, TimerService service,
HtmlView filteredHtmlView, ContentView renderedContentView, Repairer repairer,
SelectionSource selectionSource) {
this.sink = sink;
this.manager = manager;
this.selectionSource = selectionSource;
this.timerService = service;
this.filteredHtmlView = filteredHtmlView;
this.renderedContentView = renderedContentView;
this.repairer = repairer;
this.flushCmd = new Scheduler.Task() {
@Override
public void execute() {
flush();
}
};
}
/**
*
* TODO: use isSameRange. currently, it'll just start a new typing sequence
* @param previousSelectionStart Where the selection start is,
* before the result of typing
* @throws HtmlMissing
* @throws HtmlInserted
*/
public void somethingHappened(Point<Node> previousSelectionStart)
throws HtmlMissing, HtmlInserted {
Preconditions.checkNotNull(previousSelectionStart,
"Typing extractor notified with null selection");
Text node = previousSelectionStart.isInTextNode()
? previousSelectionStart.getContainer().<Text>cast()
: null;
// Attempt to associate our location with a node
// This should be a last resort, ideally we should be given selections
// in the correct text node, when the selection is at a text node boundary
if (node == null) {
HtmlView filteredHtml = filteredHtmlView;
Node nodeBefore = Point.nodeBefore(filteredHtml, previousSelectionStart.asElementPoint());
Node nodeAfter = previousSelectionStart.getNodeAfter();
//TODO(danilatos): Usually we would want nodeBefore as a preference, but
// not always...
if (nodeBefore != null && DomHelper.isTextNode(nodeBefore)) {
node = nodeBefore.cast();
previousSelectionStart = Point.<Node>inText(node, 0);
} else if (nodeAfter != null && DomHelper.isTextNode(nodeAfter)) {
node = nodeAfter.cast();
previousSelectionStart = Point.<Node>inText(node, node.getLength());
}
}
TypingState t = findTypingState(previousSelectionStart);
if (t == null) {
t = getFreshTypingState();
if (node != null) {
// check the selection is in a text point, and start the sequence
Preconditions.checkNotNull(previousSelectionStart.asTextPoint(),
"previousSelectionStart must be a text point");
t.startTypingSequence(previousSelectionStart.asTextPoint());
} else {
// otherwise make sure we're not in a text point, and start the sequence
Preconditions.checkState(!previousSelectionStart.isInTextNode(),
"previousSelectionStart must not be a text point");
t.startTypingSequence(previousSelectionStart.asElementPoint());
}
} else {
t.continueTypingSequence(previousSelectionStart);
}
t.setLastTextNode(node);
timerService.schedule(flushCmd);
}
/**
* Outputs any pending operations and reset all states
*/
public void flush() {
sink.aboutToFlush();
for (int i = 0; i < numStates; i++) {
TypingState t = statePool.get(i);
if (t.isClear()) {
continue;
}
t.flush();
}
numStates = 0;
}
/**
* @return true if we are in the middle of extracting
*/
public boolean isBusy() {
for (int i = 0; i < numStates; i++) {
TypingState t = statePool.get(i);
if (t.isClear()) {
continue;
}
return true;
}
return false;
}
/**
* @param selection
* @return The typing state that the given text node is a part of, or null if none
*/
private TypingState findTypingState(Point<Node> selection) {
for (int i = 0; i < numStates; i++) {
TypingState t = statePool.get(i);
if (t.isClear()) {
continue;
}
if (t.isPartOfThisState(selection)) {
return t;
}
}
return null;
}
/**
* @return A clear typing state from our pool of states
*/
private TypingState getFreshTypingState() {
//TODO(danilatos): In the background, reduce numStates
numStates++;
if (numStates > statePool.size()) {
TypingState t = new TypingState();
statePool.add(t);
return t;
} else {
assert statePool.get(numStates - 1).isClear();
return statePool.get(numStates - 1);
}
}
/**
* Ensures the html range is valid. If it isn't, it can repair it in some
* circumstances. In others, it will throw an exception.
*/
private void checkRangeIsValid() {
//TODO(danilatos) I haven't encountered this issue in practice yet, but it's a
//potential hole that needs to be plugged.
//if (htmlRange.getNodeBefore())
}
}