/**
* 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.content;
import com.google.gwt.dom.client.Document;
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.EditorRuntimeException;
import org.waveprotocol.wave.client.editor.content.SelectionMaintainer.TextNodeChangeType;
import org.waveprotocol.wave.client.editor.extract.InconsistencyException;
import org.waveprotocol.wave.client.editor.extract.InconsistencyException.HtmlMissing;
import org.waveprotocol.wave.client.editor.extract.Repairer;
import org.waveprotocol.wave.client.editor.impl.HtmlView;
import org.waveprotocol.wave.model.document.Doc;
import org.waveprotocol.wave.model.document.indexed.NodeType;
import org.waveprotocol.wave.model.document.raw.RawDocument;
import org.waveprotocol.wave.model.document.util.Point;
/**
* Content text node
*
* Wrapper that tracks multiple implementation text nodelets.
* This is because we can't trust the browser not to unpredictably split
* our text nodelets, so we simply allow & expect it.
* TODO(danilatos): Thorough details of how this stuff works. For now,
* NodeManager has some more information.
*
* See {@link ContentDocument} for more...
*
* @author danilatos@google.com (Daniel Danilatos)
* @author lars@google.com (Lars Rasmussen)
*/
public class ContentTextNode extends ContentNode implements Doc.T {
private String data = "";
public ContentTextNode(String data, ExtendedClientDocumentContext bundle) {
this(data, null, bundle);
}
/**
* Constructor should only be used for testing
*
* @param wrapped
* @param bundle
*/
public ContentTextNode(Text wrapped, ExtendedClientDocumentContext bundle) {
this(wrapped.getData(), wrapped, bundle);
}
protected ContentTextNode(String data, Text wrapped, ExtendedClientDocumentContext bundle) {
super(wrapped, bundle);
this.data = data;
}
@Override
public ContentElement asElement() {
return null;
}
@Override
public ContentTextNode asText() {
return this;
}
/** {@inheritDoc} */
@Override
public Text getImplNodelet() {
return super.getImplNodelet().cast();
}
/**
* Prefer this type safety (only allow Text nodelet)
*/
public void setTextNodelet(Text nodelet) {
setImplNodelet(nodelet);
}
void setRendering(boolean isRendering) {
if ((getImplNodelet() != null) == isRendering) {
return;
}
if (isRendering) {
setImplNodelet(Document.get().createTextNode(data));
} else {
setImplNodelet(null);
}
}
/**
* @return The wrapper text node's character data
*/
public String getData() {
return data;
}
private void setContentData(String newData) {
if (!newData.equals(this.data)) {
this.data = newData;
ContentElement parent = getParentElement();
if (parent != null) {
parent.notifyChildrenMutated();
}
}
}
/**
* @see RawDocument#insertData(Object, int, String)
*/
void insertData(int offset, String arg, boolean affectImpl) {
String data = getData();
setContentData(
data.substring(0, offset) +
arg +
data.substring(offset, data.length()));
if (affectImpl) {
// NOTE(user): There is an issue here. When findNodeletWihOffset causes a
// repair, the data may get inserted twice. The repairer may set the DOM
// node to reflect the updated content data (which already has the data
// inseretd). Then, when insertData is called, the data is inserted again.
findNodeletWithOffset(offset, nodeletOffsetOutput, getRepairer());
Text nodelet = nodeletOffsetOutput.getNode().<Text>cast();
int nodeletOffset = nodeletOffsetOutput.getOffset();
nodelet.insertData(nodeletOffset, arg);
getExtendedContext().editing().textNodeletAffected(
nodelet, nodeletOffset, arg.length(), TextNodeChangeType.DATA);
}
}
/**
* @see RawDocument#deleteData(Object, int, int)
*/
void deleteData(int offset, int count, boolean affectImpl) {
String data = getData();
setContentData(
data.substring(0, offset) +
data.substring(offset + count, data.length()));
if (affectImpl) {
if (isImplAttached()) {
findNodeletWithOffset(offset, nodeletOffsetOutput, getRepairer());
Text nodelet = nodeletOffsetOutput.getNode().cast();
int subOffset = nodeletOffsetOutput.getOffset();
if (nodelet.getLength() - subOffset >= count) {
// Handle the special case where the delete is in a single text nodelet
// carefully, to avoid splitting it
nodelet.deleteData(subOffset, count);
getExtendedContext().editing().textNodeletAffected(
nodelet, subOffset, -count, TextNodeChangeType.DATA);
} else {
// General case
Node toExcl = implSplitText(offset + count);
Node fromIncl = implSplitText(offset);
HtmlView filteredHtml = getFilteredHtmlView();
for (Node node = fromIncl; node != toExcl && node != null;) {
Node next = filteredHtml.getNextSibling(node);
node.removeFromParent();
node = next;
}
}
} else {
// TODO(user): have these assertion failure fixed (b/2129931)
// assert getImplNodelet().getLength() == getLength() :
// "text node's html impl not normalised while not attached to html dom";
getImplNodelet().deleteData(offset, count);
}
}
}
/**
* Splits this text node at the given offset.
* If the offset is zero, no split occurs, and the current node is returned.
* If the offset is equal to or greater than the length of the text node, no split
* occurs, and null is returned.
*
* @see RawDocument#splitText(Object, int)
*/
ContentTextNode splitText(int offset, boolean affectImpl) {
if (offset == 0) {
return this;
} else if (offset >= getLength()) {
return null;
}
Text nodelet = null;
if (affectImpl) {
nodelet = implSplitText(offset);
} else {
nodelet = Document.get().createTextNode("");
}
String first = getData().substring(0, offset);
String second = getData().substring(offset);
ContentTextNode sibling = new ContentTextNode(second, nodelet, getExtendedContext());
setContentData(first);
// Always false for affecting the impl, as it's already been done
getParentElement().insertBefore(sibling, getNextSibling(), false);
return sibling;
}
/**
* Splits and returns the second.
* If split point at a node boundary, doesn't split, but returns the next nodelet.
*/
private Text implSplitText(int offset) {
findNodeletWithOffset(offset, nodeletOffsetOutput, getRepairer());
Text text = nodeletOffsetOutput.getNode().<Text>cast();
if (text.getLength() == nodeletOffsetOutput.getOffset()) {
return text.getNextSibling().cast();
} else if (nodeletOffsetOutput.getOffset() == 0) {
return text;
} else {
int nodeletOffset = nodeletOffsetOutput.getOffset();
Text ret = text.splitText(nodeletOffset);
// -10000 because the number should be ignored in the splitText case,
// so some large number to trigger an error if it is not ignored.
getExtendedContext().editing().textNodeletAffected(
text, nodeletOffset, -10000, TextNodeChangeType.SPLIT);
return ret;
}
}
/**
* @return Length of the character data
*/
public int getLength() {
return getData().length();
}
/**
* @return the calculated character data in the html
* @throws HtmlMissing
*/
public String getImplData() throws HtmlMissing {
Node next = checkNodeAndNeighbourReturnImpl(this);
HtmlView filteredHtml = getFilteredHtmlView();
return sumTextNodes(getImplNodelet(), next, filteredHtml);
}
@Override
public void onAddedToParent(ContentElement previousParent) {
if (!isImplAttached()) {
simpleNormaliseImpl();
}
}
/**
* @return length of character data in the html
* @throws HtmlMissing
*/
public int getImplDataLength() throws HtmlMissing {
Node next = checkNodeAndNeighbourReturnImpl(this);
HtmlView filteredHtml = getFilteredHtmlView();
return sumTextNodesLength(getImplNodelet(), next, filteredHtml);
}
/** {@inheritDoc} */
@Override
public void revertImplementation() {
setImplNodelet(Document.get().createTextNode(getData()));
}
// TODO(danilatos): A lot of these methods have roughly the same 4-5 lines,
// iterating over the nodelets and doing something with them. There is a
// lot of repeated logic, but I can't see an easy way to factor it out
// without resorting to callbacks. Try using callbacks and see if GWT
// optimises it out.
/**
* Compacts the multiple impl text nodelets into one
* @throws HtmlMissing
*/
public void normaliseImplThrow() throws HtmlMissing {
// TODO(danilatos): Some code in line container depends on the isImplAttached() check,
// but sometimes it might not be attached but should, and so should throw an exception.
if (!isContentAttached() || !isImplAttached()) {
simpleNormaliseImpl();
}
Text first = getImplNodelet();
if (first.getLength() == getLength()) {
return;
}
ContentNode next = checkNodeAndNeighbour(this);
HtmlView filteredHtml = getFilteredHtmlView();
//String sum = "";
Node nextImpl = (next == null) ? null : next.getImplNodelet();
for (Text nodelet = first; nodelet != nextImpl && nodelet != null;
nodelet = filteredHtml.getNextSibling(first).cast()) {
//sum += nodelet.getData();
if (nodelet != first) {
getExtendedContext().editing().textNodeletAffected(
nodelet, -1000, -1000, TextNodeChangeType.REMOVE);
nodelet.removeFromParent();
}
}
getExtendedContext().editing().textNodeletAffected(
first, -1000, -1000, TextNodeChangeType.REPLACE_DATA);
first.setData(getData());
}
void simpleNormaliseImpl() {
if (getImplNodelet() == null || getImplNodelet().getLength() != getLength()) {
setImplNodelet(Document.get().createTextNode(getData()));
}
return;
}
/**
* Same as {@link #normaliseImplThrow()}, but uses a repairer to fix problems
* rather than throw an exception
*/
@Override
public Text normaliseImpl() {
Repairer repairer = getRepairer();
for (int i = 0; i < MAX_REPAIR_ATTEMPTS; i++) {
try {
normaliseImplThrow();
return getImplNodelet();
} catch (HtmlMissing e) {
repairer.handle(e);
} catch (RuntimeException e) {
// Safe to catch runtime exception - no stateful code should be affected,
// just browser DOM has been munged which we repair
repairer.revert(Point.before(getRenderedContentView(), this), null);
}
}
Text nodelet = getImplNodelet();
getExtendedContext().editing().textNodeletAffected(
nodelet, -1000, -1000, TextNodeChangeType.REPLACE_DATA);
nodelet.setData(getData());
return nodelet;
}
/**
* Helper function to concatenate the character data of a group of
* adjacent text nodes.
* Important: Does not do any consistency checking. It assumes this has
* already been done.
*
* @param fromIncl Start from this node, inclusive
* @param toExcl Go until this node, exclusive
* @param filteredHtml Html view to use
* @return the summed impl data
*/
public static String sumTextNodes(Text fromIncl, Node toExcl, HtmlView filteredHtml) {
// TODO(danilatos): This could potentially be slow if there are many nodelets. In
// practice this shouldn't be an issue, as we should be normalising them when
// they get too many.
String data = "";
// TODO(danilatos): Some assumptions about validity here. Could they fail?
for (Text n = fromIncl; n != toExcl && n != null;
n = filteredHtml.getNextSibling(n).cast()) {
data += n.getData();
}
return getNodeValueFromHtmlString(data);
}
private static int sumTextNodesLength(Text fromIncl, Node toExcl, HtmlView filteredHtml) {
int length = 0;
for (Text n = fromIncl; n != toExcl && n != null;
n = filteredHtml.getNextSibling(n).cast()) {
length += n.getLength();
}
return length;
}
/**
* Check whether the given text nodelet is one of the nodelets owned by this
* wrapper.
* @param textNodelet
* @return true if textNodelet is owned by this wrapper
* @throws HtmlMissing
*/
public boolean owns(Text textNodelet) throws HtmlMissing {
ContentNode next = checkNodeAndNeighbour(this);
HtmlView filteredHtml = getFilteredHtmlView();
Node nextImpl = (next == null) ? null : next.getImplNodelet();
for (Text nodelet = getImplNodelet(); nodelet != nextImpl;
nodelet = filteredHtml.getNextSibling(nodelet).cast()) {
if (nodelet == textNodelet) {
return true;
}
}
return false;
}
/**
* Finds the character offset of the given text node. For example, if this
* wrapper were tracking 3 nodelets with data "abc", "de", "fghi", and we
* asked for the offset of the third, we'd get back 5.
*
* @param textNodelet
* @return The character offset of the given node
* @throws HtmlMissing If the nodelet isn't owned by this wrapper
*/
public int getOffset(Text textNodelet) throws HtmlMissing {
try {
return getOffset(textNodelet, checkNodeAndNeighbourReturnImpl(this));
} catch (Exception t) {
// is this a missing or inserted error?
throw new HtmlMissing(this, getRenderedContentView().getParentElement(this).getImplNodelet());
}
}
/**
* Same as {@link #getOffset(Text)}, but we also provide the nodelet where
* we should stop looking. This method assumes the correctness of its input
* and the document state, and doesn't do any inconsistency checking.
*
* NOTE(danilatos): It is possible to provide an "incorrect" nextImpl
* that is much further down. This is OK as long as you know what you are doing.
*
* @param nextImpl The first impl nodelet of the next wrapper.
*/
public int getOffset(Text textNodelet, Node nextImpl) {
return getOffset(textNodelet, getImplNodelet(), nextImpl, getFilteredHtmlView());
}
/**
* Implementation of {@link #getOffset(Text, Node)} that is not bound to any
* specific ContentTextNode
*/
public static int getOffset(Text textNodelet, Text startNode, Node nextImpl, HtmlView view) {
int offset = 0;
for (Text nodelet = startNode; nodelet != nextImpl;
nodelet = view.getNextSibling(nodelet).cast()) {
if (nodelet == textNodelet) {
return offset;
}
offset += nodelet.getLength();
}
// Programming error, this method assumes the input state was valid,
// unlike other methods which don't
throw new EditorRuntimeException("Didn't find text nodelet to get offset for");
}
/**
* Useful to use as the output from {@link #findNodeletWithOffset(int, HtmlPoint)}
* and friends. Instead of everyone creating their own singleton, this is a handy
* common one to use.
*/
public static final HtmlPoint nodeletOffsetOutput = new HtmlPoint(null, 0);
/**
* Given an offset, finds the nodelet that corresponds to that offset, and
* the remaining offset within that nodelet.
* @param offset
* @param output The nodelet and offset result are placed in this parameter
* @throws HtmlMissing
*/
public void findNodeletWithOffset(int offset, HtmlPoint output) throws HtmlMissing {
findNodeletWithOffset(offset, output, checkNodeAndNeighbourReturnImpl(this));
}
/**
* Same as {@link #findNodeletWithOffset(int, HtmlPoint)}, but provide a repairer
* to handle the checked exception, instead of this method throwing it.
*/
public void findNodeletWithOffset(int offset, HtmlPoint output, Repairer repairer) {
// HACK(danilatos): Nicer way?
for (int tries = 0; tries < 3; tries++) {
try {
findNodeletWithOffset(offset, output);
return;
} catch (HtmlMissing e) {
getRepairer().handle(e);
}
}
throw new EditorRuntimeException("Tried to repair and it just wouldn't work");
}
/**
* Same as {@link #findNodeletWithOffset(int, HtmlPoint)}, but does not do
* a consistency check. Instead it accepts the impl nodelet of the next
* wrapper (or null) and assumes everthing is consistent.
*/
public void findNodeletWithOffset(int offset, HtmlPoint output, Node nextImpl) {
HtmlView filteredHtml = getFilteredHtmlView();
int sum = 0;
int prevSum;
for (Text nodelet = getImplNodelet(); nodelet != nextImpl;
nodelet = filteredHtml.getNextSibling(nodelet).cast()) {
prevSum = sum;
sum += nodelet.getLength();
if (sum >= offset) {
output.setNode(nodelet);
output.setOffset(offset - prevSum);
return;
}
}
output.setNode(null);
}
/**
* Same as {@link #checkNodeAndNeighbour(ContentTextNode)}, but returns the
* impl nodelet of the wrapper found.
*/
private static Node checkNodeAndNeighbourReturnImpl(ContentTextNode node) throws HtmlMissing {
ContentNode next = checkNodeAndNeighbour(node);
return next == null ? null : next.getImplNodelet();
}
/**
* Return the given node's next sibling, after doing consistency checks to ensure both are
* consistent.
*/
private static ContentNode checkNodeAndNeighbour(ContentTextNode node) throws HtmlMissing {
// TODO(danilatos): How do we fare without these checks?
// Worth the performance hit or not?
ContentView renderedContent = node.getRenderedContentView();
if (!node.isImplAttached()) {
Element parentElement = renderedContent.getParentElement(node).getImplNodelet();
throw new InconsistencyException.HtmlMissing(node, parentElement);
}
ContentNode next = renderedContent.getNextSibling(node);
if (next != null && !next.isImplAttached()) {
Element parentElement = renderedContent.getParentElement(node).getImplNodelet();
throw new InconsistencyException.HtmlMissing(next, parentElement);
}
return next;
}
/** {@inheritDoc} */
@Override
public short getNodeType() {
return NodeType.TEXT_NODE;
}
/** {@inheritDoc} */
@Override
public boolean isElement() {
return false;
}
/** {@inheritDoc} */
@Override
public boolean isTextNode() {
return true;
}
/** {@inheritDoc} */
@Override
public boolean isConsistent() {
try {
return isImplAttached() && getImplData().equals(getData());
} catch (HtmlMissing e) {
return false;
} catch (Throwable t) {
return false;
}
}
/**
* TODO(user): for now translate all chars to regular spaces
* such that only regular spaces go on the wire. Consider doing
* the translation closer to the wire to avoid the danger of accidentally
* using a translated string internally, e.g., in split or join.
*
* @param value a node value string from an html text node
* @return the content text node's interpretations of value
*/
public static String getNodeValueFromHtmlString(String value) {
return value.replace('\u00a0', ' ');
}
/**
* {@inheritDoc}
*/
@Override
public void debugAssertHealthy() {
// Assert ContentTextnode has a text nodelet
assert DomHelper.isTextNode(getImplNodelet()) :
"ContentTextNode's implNodelet should be a text node";
// Assert it has no children
assert null == getFirstChild() : "ContentTextNode should be childless";
super.debugAssertHealthy();
}
}