/**
* Copyright 2008 Google Inc.
*
* Licensed 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.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.common.util.JsoView;
import org.waveprotocol.wave.client.editor.EditorStaticDeps;
import org.waveprotocol.wave.client.editor.ElementHandlerRegistry.HasHandlers;
import org.waveprotocol.wave.client.editor.impl.HtmlView;
import org.waveprotocol.wave.client.editor.impl.NodeManager;
import org.waveprotocol.wave.model.document.Doc;
import org.waveprotocol.wave.model.document.indexed.NodeType;
import org.waveprotocol.wave.model.document.util.ElementManager;
import org.waveprotocol.wave.model.document.util.Property;
import org.waveprotocol.wave.model.util.CollectionUtils;
import org.waveprotocol.wave.model.util.IntMap;
import org.waveprotocol.wave.model.util.Preconditions;
import org.waveprotocol.wave.model.util.StringMap;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
/**
* Content Element.
*
* See {@link ContentDocument} for more...
*
* @author danilatos@google.com (Daniel Danilatos)
* @author lars@google.com (Lars Rasmussen)
*/
public class ContentElement extends ContentNode implements Doc.E, HasHandlers, HasImplNodelets {
/**
* We mark nodelets at the top of a complex implementation tree with
* this, so we can optimise traversal in the filtered view.
*/
public static final String COMPLEX_IMPLEMENTATION_MARKER =
NodeManager.getNextMarkerName("cim");
/**
* ContentElement's manager for non-persistent properties
*/
public static final ElementManager<ContentElement> ELEMENT_MANAGER =
new ElementManager<ContentElement>() {
public <T> void setProperty(Property<T> property, ContentElement element, T value) {
element.setProperty(property, value);
}
public <T> T getProperty(Property<T> property, ContentElement element) {
return element.getProperty(property);
}
public boolean isDestroyed(ContentElement element) {
return !element.isContentAttached();
}
};
private final String tagName;
private ContentNode firstChild = null;
private ContentNode lastChild = null;
private final StringMap<String> attributes = CollectionUtils.createStringMap();
private final IntMap<Object> transientData = CollectionUtils.createIntMap();
private Element containerNodelet = null;
/**
* Some common attribute names. By default, 'name' serves to identify in particular
* form elements, and 'submit' to identify (by name) an element that should receive
* a click event if the element containing the submit attribute is the target of
* a submit event. See {@code Button} and {@code Input} for examples.
*/
public static final String NAME = "name";
public static final String SUBMIT = "submit";
public ContentElement(String tagName, Element nodelet, ExtendedClientDocumentContext bundle) {
this(tagName, bundle, true);
setImplNodelets(nodelet, nodelet);
init(Collections.<String, String>emptyMap());
}
/**
* Constructor that does not initialize the impl nodelet.
* @param bundle
* @param initLater
*/
public ContentElement(String tagName,
ExtendedClientDocumentContext bundle, boolean initLater) {
super(null, bundle);
assert initLater == true;
this.tagName = tagName;
}
@Override
public void setImplNodelets(Element domImplNodelet, Element containerNodelet) {
//TODO(danilatos): redundant setImplNodelet?
setImplNodeletInner(domImplNodelet);
setContainerNodelet(containerNodelet);
if (domImplNodelet != null) {
walkImpl(domImplNodelet);
// In a non-empty region between two text-editable regions (i.e., the region between an
// element's implNodelet and its declared container nodelet for child implNodelets), we turn
// white-space back to normal.
if (containerNodelet != domImplNodelet) {
// TODO(danilatos): allow renderers to have non-normal whitespace through CSS
// HACK(user): can't use setProperty due to assertCamelCase:
JsoView.as(domImplNodelet.getStyle()).setString("white-space", "normal");
if (containerNodelet != null) {
// HACK(user): can't use setProperty due to assertCamelCase:
JsoView.as(domImplNodelet.getStyle()).setString("white-space", "prewrap");
containerNodelet.setInnerHTML("");
}
}
}
assert getImplNodelet() == null || NodeManager.getBackReference(getImplNodelet()) == this;
}
@Override
public void setBothNodelets(Element implAndContainerNodelet) {
setImplNodelets(implAndContainerNodelet, implAndContainerNodelet);
}
void init(Map<String, String> attributes) {
for (Map.Entry<String, String> entry : attributes.entrySet()) {
// Set the attributes directly without triggering events during init()n
this.attributes.put(entry.getKey(), entry.getValue());
}
}
/**
* Mark implementation elements that aren't transparent as part of a
* a complex implementation structure.
*
* @param element
*/
public static void walkImpl(Element element) {
for (Node n = element.getFirstChild(); n != null;) {
if (DomHelper.isTextNode(n)) {
n = n.getNextSibling();
} else {
Element e = n.cast();
if (!NodeManager.isTransparent(e)) {
e.setPropertyBoolean(COMPLEX_IMPLEMENTATION_MARKER, true);
}
walkImpl(e);
n = n.getNextSibling();
}
}
}
/**
* Get the handler of the given type for this node
*
* @param <T>
* @param handlerType
* @return The handler, or null if none exists for this node
*/
public <T> T getHandler(Class<T> handlerType) {
throw new UnsupportedOperationException("getHandler only implemented for AgentAdapter for now");
}
/**
* Gets a transient property on the element.
* @param <T>
* @param property
*/
@SuppressWarnings("unchecked")
public final <T> T getProperty(Property<T> property) {
return (T) transientData.get(property.getId());
}
/**
* Sets a transient property on the element.
* @param <T>
* @param property
* @param value
*/
public final <T> void setProperty(Property<T> property, T value) {
transientData.put(property.getId(), value);
}
/** {@inheritDoc} */
@Override
public Element getImplNodelet() {
return (Element) super.getImplNodelet();
}
/**
* Also affects the container nodelet. If the current container nodelet is the
* same as the current impl nodelet, the new container will be the same as the new
* impl nodelet. If it is null, it will stay null. Other scenarios are not supported
*
* @deprecated Use {@link #setImplNodelets(Element, Element)} instead of this method.
*/
@Override
@Deprecated // Use #setImplNodelets(impl, container) instead
public void setImplNodelet(Node nodelet) {
Preconditions.checkNotNull(nodelet,
"Null nodelet not supported with this deprecated method, use setImplNodelets instead");
Preconditions.checkState(containerNodelet == null || containerNodelet == getImplNodelet(),
"Cannot set only the impl nodelet if the container nodelet is different");
Preconditions.checkArgument(!DomHelper.isTextNode(nodelet),
"element cannot have text implnodelet");
Element element = nodelet.cast();
if (this.containerNodelet != null) {
setContainerNodelet(element);
}
setImplNodeletInner(element);
}
private void setImplNodeletInner(Element newNodelet) {
swapNodelet(getImplNodelet(), newNodelet);
super.setImplNodelet(newNodelet);
}
@Override
public Element setAutoAppendContainer(Element containerNodelet) {
setContainerNodelet(containerNodelet);
return containerNodelet;
}
void setContainerNodelet(Element newNodelet) {
if (newNodelet == containerNodelet) {
return;
}
// We want the new container nodelet to get everything that was in the old
// container nodelet, and nothing more
if (newNodelet != null) {
// Remove any existing junk
DomHelper.emptyElement(newNodelet);
// Copy children from old container
for (ContentNode node = getFirstChild(); node != null; node = node.getNextSibling()) {
if (node.isTextNode()) {
node.normaliseImpl();
}
Node implNodelet = node.getImplNodelet();
if (implNodelet != null) {
newNodelet.appendChild(implNodelet);
}
}
}
// If the container nodelet is the same as the impl nodelet, don't specify it as an
// old nodelet, because that would clear the backreference on a node still in use.
swapNodelet(containerNodelet == getImplNodelet() ? null : containerNodelet, newNodelet);
this.containerNodelet = newNodelet;
}
void swapNodelet(Element oldNodelet, Element newNodelet) {
if (oldNodelet != null) {
NodeManager.setBackReference(oldNodelet, null);
}
if (newNodelet != null) {
NodeManager.setBackReference(newNodelet, this);
}
}
@Override
void breakBackRef(boolean recurse) {
swapNodelet(getImplNodelet(), null);
swapNodelet(getContainerNodelet(), null);
if (recurse) {
for (ContentNode n = getFirstChild(); n != null; n = n.getNextSibling()) {
n.breakBackRef(true);
}
}
}
@Override
public Element getContainerNodelet() {
return containerNodelet;
}
/** Return the element's tag name */
@Override
public final String getTagName() {
return tagName;
}
@Override
public ContentElement asElement() {
return this;
}
@Override
public ContentTextNode asText() {
return null;
}
/** {@inheritDoc} */
@Override
public final ContentNode getFirstChild() {
return firstChild;
}
/** {@inheritDoc} */
@Override
public final ContentNode getLastChild() {
return lastChild;
}
/**
* TODO(danilatos): This is a mutability leak, make it return a readonly StringMap
*
* @return The attributes of this element in their optimised map form
*/
public final StringMap<String> getAttributes() {
return attributes;
}
void setFirstChild(ContentNode child) {
firstChild = child;
}
void setLastChild(ContentNode child) {
lastChild = child;
}
/**
* Get the value of the given attribute name.
* @return the value, or null if not present
*/
public final String getAttribute(String name) {
return attributes.get(name);
}
/**
* @param name
* @return true if the element has the given attribute
*/
public final boolean hasAttribute(String name) {
return attributes.containsKey(name);
}
///// CONTENT
/**
* Set an attribute. Does not affect the html implementation.
* @param name
* @param value
*/
void setAttribute(String name, String value) {
assert value != null : "Do not set an attribute to null, use removeAttribute instead";
String old = attributes.get(name);
attributes.put(name, value);
notifyAttributeModified(name, old, value);
}
/**
* Remove the given attribute if present. Does not affect the html
* @param name
*/
void removeAttribute(String name) {
String old = attributes.get(name);
attributes.remove(name);
notifyAttributeModified(name, old, null);
}
/**
* Same semantics as the corresponding DOM method
* @param newChild
* @param refChild
* @param affectImpl Don't touch the html if this is false
* @return The new child for convenience
*/
ContentNode insertBefore(ContentNode newChild, ContentNode refChild, boolean affectImpl) {
return insertBefore(newChild, newChild.getNextSibling(), refChild, affectImpl, null);
}
ContentNode insertBefore(ContentNode fromIncl, ContentNode toExcl,
ContentNode refChild, boolean affectImpl, ContentRawDocument.Factory factory) {
if (fromIncl == toExcl) {
// Early exit if nothing to do
return fromIncl;
}
if (refChild != null && refChild.getParentElement() != this) {
throw new IllegalArgumentException("insertBefore: refChild is not child of parent");
}
if (fromIncl.isOrIsAncestorOf(this)) {
throw new IllegalArgumentException("insertBefore: fromIncl is or is an ancestor of parent!");
}
if (toExcl != null && toExcl.getParentElement() != fromIncl.getParentElement()) {
throw new IllegalArgumentException("insertBefore: toExcl does not have the same " +
"parent as fromIncl!");
}
//TODO(danilatos): Ensure this works when from == toExcl
//TODO(danilatos): Test cases for appending a MetaElement with a null implNodelet
assert (refChild == null || refChild.getParentElement() == this);
ContentElement oldParent = fromIncl.getParentElement();
Element oldContainerNodelet = oldParent != null ? oldParent.getContainerNodelet() : null;
ContentNode newChild = fromIncl;
ContentNode prev;
if (refChild == null) {
prev = getLastChild();
setLastChild(newChild);
} else {
prev = refChild.getPreviousSibling();
refChild.setPrev(newChild);
}
List<ContentNode> movedNodes = new ArrayList<ContentNode>();
while (true) {
movedNodes.add(newChild);
ContentNode next = newChild.getNextSibling();
newChild.removeFromShadowTree();
if (prev == null) {
setFirstChild(newChild);
} else {
prev.setNext(newChild);
}
newChild.setNext(refChild);
newChild.setPrev(prev);
newChild.setParent(this);
prev = newChild;
if (next == toExcl) {
if (refChild == null) {
setLastChild(newChild);
} else {
refChild.setPrev(newChild);
}
break;
}
newChild = next;
}
// activation of new nodes before notifications & html impl business
if (factory != null) {
assert toExcl == null && fromIncl.isElement() && movedNodes.get(0) == fromIncl;
factory.setupBehaviour(fromIncl.asElement());
}
// html updates
if (affectImpl) {
implInsertBefore(this, fromIncl, prev.getNextSibling(), refChild, oldContainerNodelet);
}
// notifications
if (oldParent != null) {
oldParent.notifyChildrenMutated();
for (ContentNode node : movedNodes) {
node.notifyRemovedFromParent(oldParent, this);
}
}
for (ContentNode node : movedNodes) {
node.notifyAddedToParent(oldParent, false);
}
notifyChildrenMutated();
return fromIncl;
}
void reInsertImpl() {
implInsertBefore(this, getFirstChild(), null, null, getContainerNodelet());
}
/**
* Same semantics as the corresponding DOM method
* @param oldChild Child node to remove
* @param affectImpl Don't touch the html if this is false
*/
void removeChild(ContentNode oldChild, boolean affectImpl) {
removeChildren(oldChild, oldChild.getNextSibling(), affectImpl);
}
/**
* Remove a contiguous range of adjacent siblings, rather than just one
*
* Is to removeChild as the ranged version of insertBefore is to the regular version
*
* @param fromIncl
* @param toExcl
* @param affectImpl
*/
void removeChildren(ContentNode fromIncl, ContentNode toExcl, boolean affectImpl) {
if (fromIncl.getParentElement() != this) {
throw new IllegalArgumentException("removeChild: fromIncl is not child of parent");
}
if (toExcl != null && toExcl.getParentElement() != this) {
throw new IllegalArgumentException("removeChild: toExcl is not child of parent");
}
removeChildrenInner(fromIncl, toExcl, affectImpl);
// Now propagate the post-event info - only do this for the outermost removed node.
notifyChildrenMutated();
if (getFirstChild() == null) {
notifyEmptied();
}
}
void removeChildrenInner(ContentNode fromIncl, ContentNode toExcl, boolean affectImpl) {
List<ContentNode> removedNodes = new ArrayList<ContentNode>();
for (ContentNode node = fromIncl; node != toExcl; ) {
// Recurse
ContentNode nodeFirstChild = node.getFirstChild();
if (nodeFirstChild != null) {
// NOTE(danilatos): possible optimisation is to unconditionally pass false
// for affectImpl - need to consider all scenarios to ensure no strange behaviour.
node.asElement().removeChildrenInner(nodeFirstChild, null, affectImpl);
}
ContentNode oldChild = node;
node = node.getNextSibling();
if (affectImpl) {
Node nodelet = oldChild.normaliseImpl();
if (nodelet != null) {
// removeFromParent() checks if parent is null
nodelet.removeFromParent();
}
}
removedNodes.add(oldChild);
oldChild.removeFromShadowTree();
oldChild.clearNodeLinks();
oldChild.breakBackRef(true);
}
for (ContentNode node : removedNodes) {
node.notifyRemovedFromParent(this, null);
}
}
///////
/** {@inheritDoc} */
@Override
public final short getNodeType() {
return NodeType.ELEMENT_NODE;
}
/** {@inheritDoc} */
@Override
public final boolean isElement() {
return true;
}
/** {@inheritDoc} */
@Override
public final boolean isTextNode() {
return false;
}
/**
* To "zip" is to take two filtered-equivalent trees, one content, one html,
* and setup the back references between each. This often happens when the
* html changes for some reason, we change the content independently to match,
* then we go through and "zip" the two subtrees together again. The assumption
* is, of course, that they match in their filtered views.
*
* @param from first node that might need zipping
* @param to last node that might need zipping.
* @param notifyIfSplit we will return true if we split this node
* @return true if notifyIfSplit is affected
*/
public boolean zipChildren(ContentNode from, ContentNode to, Node notifyIfSplit) {
ContentView renderedContent = getRenderedContentView();
if (from != null) {
from = renderedContent.getPreviousSibling(from);
}
return zipChildrenExcludingFrom(from, to, notifyIfSplit);
}
/**
* Same as {@link #zipChildren(ContentNode, ContentNode, Node)}, except that the
* "from" parameter is exclusive.
*
* @param from
* @param to
* @param notifyIfSplit we will return true if we split this node
* @return true if notifyIfSplit is affected
*/
public boolean zipChildrenExcludingFrom(ContentNode from, ContentNode to, Node notifyIfSplit) {
EditorStaticDeps.startIgnoreMutations();
try {
boolean ret = false;
ContentView renderedContent = getRenderedContentView();
HtmlView filteredHtml = getFilteredHtmlView();
ContentNode node = from;
Node nodelet;
if (node == null) {
node = renderedContent.getFirstChild(this);
nodelet = filteredHtml.getFirstChild(getImplNodelet());
} else {
nodelet = node.getImplNodelet();
}
while (node != null) {
if (node.getImplNodelet() != nodelet) {
node.setImplNodelet(nodelet);
}
if (DomHelper.isTextNode(nodelet)) {
String target = ((ContentTextNode) node).getData();
String txt = nodelet.<Text>cast().getData();
String nodeletData = txt;
int left = target.length() - txt.length();
while (left > 0) {
nodelet = filteredHtml.getNextSibling(nodelet);
assert DomHelper.isTextNode(nodelet) : "Some random element!";
nodeletData = nodelet.<Text>cast().getData();
// TODO(danilatos): Is a StringBuilder more efficient here? On average, how many
// string concatenations are expected?
txt += nodeletData;
left -= nodeletData.length();
}
assert target.equals(ContentTextNode.getNodeValueFromHtmlString(
txt.substring(0, target.length()))) : "Content & html text don't match!";
if (left < 0) {
if (nodelet.equals(notifyIfSplit)) {
ret = true;
}
nodelet.<Text>cast().splitText(nodeletData.length() + left);
}
}
nodelet = filteredHtml.getNextSibling(nodelet);
node = renderedContent.getNextSibling(node);
if (node == to) {
break;
}
// must both be null
assert (node == null) == (nodelet == null) : "Content & Html don't match!";
}
// set the next one as well
// TODO(danilatos): Talk to alex about behaviour or operations applying in between text nodes,
// how they often don't modify the node we'd prefer.
if (node != null && node.isTextNode()) {
node.setImplNodelet(nodelet);
}
return ret;
} finally {
EditorStaticDeps.endIgnoreMutations();
}
}
/**
* {@inheritDoc}
*/
@Override
public boolean isConsistent() {
// TODO(danilatos): More rigorous?
return getImplNodelet() == null || isImplAttached();
}
/**
* Reverts the HTML implementation to match the content. Recurses.
*/
@Override
public void revertImplementation() {
// TODO(danilatos): Detect nodelets that don't need reverting, to avoid
// unecessary discarding and re-creation of element objects
EditorStaticDeps.startIgnoreMutations();
try {
// reset children
ContentView renderedContent = getRenderedContentView();
for (ContentNode node = renderedContent.getFirstChild(this); node != null;
node = renderedContent.getNextSibling(node)) {
node.revertImplementation();
}
reattachImplChildren();
onRepair();
} finally {
EditorStaticDeps.endIgnoreMutations();
}
}
/**
* Called after the node is reverted, in case any custom handling is needed.
*/
protected void onRepair() {
}
/**
* Add back in the impl nodelets of the children.
*
* Override this to provide specific functionality as needed. For example,
* ensuring certain children live in certain specific locations of the
* doodad's dom.
*/
protected void reattachImplChildren() {
ContentView renderedContent = getRenderedContentView();
Element container = getContainerNodelet();
if (container != null) {
while (container.getFirstChild() != null) {
container.getFirstChild().removeFromParent();
}
for (ContentNode node = renderedContent.getFirstChild(this); node != null;
node = renderedContent.getNextSibling(node)) {
container.appendChild(node.getImplNodelet());
}
} else {
EditorStaticDeps.logger.error().log(
"You need to override this method for your doodad: " + tagName);
}
}
/**
* Override this method to provide additional checks/repairs to the
* implementation. Can even return a new implNodelet if desired.
* By default, just assumes the current nodelet is fine and returns it.
* NOTE: Do not recurse, just do a shallow fix.
* @return repaired nodelet
*/
protected Element revertImplNodelet() {
return getImplNodelet();
}
/**
* Action to perform on an element
*/
public interface Action {
/** Run action on paragraph */
void execute(ContentElement e);
}
/**
* This must be called whenever this element's children have mutated.
* Calls onDescendantsMutated() on this node and all ancestors.
*/
public final void notifyChildrenMutated() {
ContentElement element = this;
while (element != null) {
try {
element.onDescendantsMutated();
} catch (RuntimeException e) {
rethrowOrNoteErrorOnMutation(e);
}
element = element.getParentElement();
}
}
/**
* This must be called whenever an attribute is modified on this element
* Calls onAttributeModified() on this node, then onDescendantsMutated()
* on this node and all ancestors.
*/
protected final void notifyAttributeModified(String name,
String oldValue, String newValue) {
try {
onAttributeModified(name, oldValue, newValue);
} catch (RuntimeException e) {
rethrowOrNoteErrorOnMutation(e);
}
if (getParentElement() != null) {
getParentElement().notifyChildrenMutated();
}
}
protected final void notifyEmptied() {
try {
onEmptied();
} catch (RuntimeException e) {
rethrowOrNoteErrorOnMutation(e);
}
}
/**
* @return true if element has name attribute
*/
public boolean hasName() {
return attributes.containsKey(NAME);
}
/**
* @return value of name attribute
*/
public String getName() {
return getAttribute(NAME);
}
/**
* {@inheritDoc}
*/
@Override
public void debugAssertHealthy() {
// Assert we have an element impl nodelet
assert getImplNodelet().getNodeType() == 1 :
"ContentElement's implNodelet should be an element";
// Assert all children are healthy, and appropriately attached
Element container = getContainerNodelet();
for (ContentNode child = getFirstChild(); child != null; child = child.getNextSibling()) {
child.debugAssertHealthy();
assert container.equals(child.getImplNodelet().getParentElement()) :
"Child's attach nodelet should have correct parent nodelet";
}
super.debugAssertHealthy();
}
}