/** * 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 org.waveprotocol.wave.client.common.util.VolatileComparable; import org.waveprotocol.wave.client.debug.logger.DomLogger; import org.waveprotocol.wave.client.debug.logger.LogLevel; import org.waveprotocol.wave.client.editor.extract.Repairer; import org.waveprotocol.wave.client.editor.extract.TypingExtractor; import org.waveprotocol.wave.client.editor.impl.HtmlView; import org.waveprotocol.wave.client.editor.selection.content.SelectionHelper; import org.waveprotocol.wave.client.editor.sugg.SuggestionsManager; import org.waveprotocol.wave.client.scheduler.ScheduleCommand; import org.waveprotocol.wave.client.scheduler.Scheduler.Task; import org.waveprotocol.wave.common.logging.LoggerBundle; import org.waveprotocol.wave.model.document.Doc; import org.waveprotocol.wave.model.document.ReadableDocument; import org.waveprotocol.wave.model.document.indexed.LocationMapper; import org.waveprotocol.wave.model.document.raw.RawDocument; import org.waveprotocol.wave.model.document.util.Point; import org.waveprotocol.wave.model.document.util.Pretty; import org.waveprotocol.wave.model.util.OffsetList; import org.waveprotocol.wave.model.util.Preconditions; import java.util.HashMap; import java.util.Map; /** * Content node. Base class for ContentTextNode and ContentElement. * * TODO(danilatos): Thoroughly update the javadoc for this class * * See {@link ContentDocument} for more... * * In this context, the word "nodelet" is used to refer to the JSO dom nodes, to * avoid confusion with the word "node", which when unqualified, refers to * subclasses of ContentNode. * * The handleXXX methods with boolean return values are called to see if this * node will handle a given event. If the method handles the event, it will * return true, and no further processing of the event is needed. Otherwise, it * will return false. The methods may trigger an operation event if they handle * the browser event. (They might not, for example to simply cause the browser * event to be ignored, and prevent the default handling by returning true) * * TODO(danilatos): Extract out an interface for these methods. * * NOTE(danilatos): By default, here and in subclasses, if a method's name is * ambiguous as to whether it refers to the content or the html, it refers to * the html. * */ public abstract class ContentNode implements Doc.N, VolatileComparable<ContentNode>, MutatingNode<ContentNode, ContentElement> { /** * Debug logger */ protected static LoggerBundle logger = new DomLogger("editor-node"); private final ExtendedClientDocumentContext context; private Node implNodelet; private ContentElement parent = null; private ContentNode next = null; private ContentNode prev = null; protected static final int MAX_REPAIR_ATTEMPTS = 50; private OffsetList.Container<ContentNode> indexingContainer; /** * @param implNodelet The wrapped nodelet * @param context */ public ContentNode(Node implNodelet, ExtendedClientDocumentContext context) { this.context = context; this.implNodelet = implNodelet; } /** * @return The top-level wrapped implementation html nodelet. It might be null * (either because we are halfway through repairing, or because this * ContentNode is a meta-node that has no corresponding HTML * implementation) */ public Node getImplNodelet() { return this.implNodelet; } /** * Same as {@link #getImplNodelet()}, but traverses the filtered view righwards * until it finds a wrapper that actually has an impl nodelet, if the first * doesn't. */ public Node getImplNodeletRightwards() { return getImplNodeletRightwards(null); } /** * Same as {@link #getImplNodeletRightwards()} but with early exit * @param toExcl Stop here if reached */ public Node getImplNodeletRightwards(ContentNode toExcl) { // TODO(danilatos): This implementation will skip over an html-only node // in the midst of other impl nodelets. This might not be desirable in // some contexts... assert isContentAttached(); ContentNode node = this; Node nodelet = null; ContentView renderedContent = getRenderedContentView(); while (node != toExcl) { nodelet = node.getImplNodelet(); if (nodelet != null) { break; } node = renderedContent.getNextSibling(node); } return nodelet; } /** * Same as {@link #getImplNodeletRightwards()}, but starts from the next * sibling of this ContentNode */ public Node getNextImplNodeletRightwards() { return getNextImplNodeletRightwards(null); } /** * Same as {@link #getNextImplNodeletRightwards()} but with early exit * @param toExcl Stop here if reached */ public Node getNextImplNodeletRightwards(ContentNode toExcl) { ContentNode next = getNextSibling(); return next == null ? null : next.getImplNodeletRightwards(toExcl); } public Node normaliseImpl() { return getImplNodelet(); } void setImplNodelet(Node nodelet) { implNodelet = nodelet; } void breakBackRef(boolean recurse) { } /** * TODO(danilatos): Use something other than this method to determine this * @return whether a node is persistent. */ public boolean isPersistent() { return getIndexingContainer() != null; } /** * @see ReadableDocument#getParentElement(Object) */ public ContentElement getParentElement() { return parent; } /** * @see ReadableDocument#getNextSibling(Object) */ public ContentNode getNextSibling() { return next; } /** * @see ReadableDocument#getPreviousSibling(Object) */ public ContentNode getPreviousSibling() { return prev; } /** * @see ReadableDocument#getFirstChild(Object) */ public ContentNode getFirstChild() { return null; } /** * @see ReadableDocument#getLastChild(Object) */ public ContentNode getLastChild() { return null; } /** package private setter, used by ContentElement */ void setNext(ContentNode next) { this.next = next; } /** package private setter, used by ContentElement */ void setPrev(ContentNode prev) { this.prev = prev; } /** package private setter, used by ContentElement */ void setParent(ContentElement parent) { this.parent = parent; } /** package private getter, used by ContentRawDocument */ OffsetList.Container<ContentNode> getIndexingContainer() { return indexingContainer; } /** package private setter, used by ContentRawDocument */ void setIndexingContainer(OffsetList.Container<ContentNode> container) { indexingContainer = container; } /** * Package private, used by ContentElement * Removes from the wrapper structure * Does not affect the underlying dom node * Does not affect its own pointers * Does not affect its relationship with its children, if any */ final void removeFromShadowTree() { if (prev == null) { if (parent != null) { parent.setFirstChild(next); } } else { prev.next = next; } if (next == null) { if (parent != null) { parent.setLastChild(prev); } } else { next.prev = prev; } } /** * Sets its prev, next and parent pointers to null * Does not affect underlying dom node * Does not affect neighbours * Does not affect relationship with children, if any */ final void clearNodeLinks() { prev = next = parent = null; } /** * @see ReadableDocument#getNodeType(Object) */ public abstract short getNodeType(); /** * @return true if this node is an element */ public abstract boolean isElement(); /** * @return true if this node is a text node */ public abstract boolean isTextNode(); /** * @return the node as a text node if it is one, null otherwise */ public abstract ContentTextNode asText(); /** * @return the node as an element if it is one, null otherwise */ public abstract ContentElement asElement(); /** * @return true if this node is in the rendered view */ public boolean isRendered() { // This logic might need updating at some point. // Currently, if a node is lacking an impl nodelet, then we treat it // as unrendered. Text nodes are an exception, because they often // lack an impl nodelet because of typing extraction & zipping. return getImplNodelet() != null || (isTextNode() && getParentElement().isRendered()); } /** Package private low level functionality */ final ExtendedClientDocumentContext getExtendedContext() { return context; } /** * @return the document context this node is a part of */ public final ClientDocumentContext getContext() { return context; } /** * Exposed for subclasses. */ public final HtmlView getFilteredHtmlView() { return context.rendering().getFilteredHtmlView(); } /** * Exposed for subclasses. */ public ContentView getRenderedContentView() { return context.rendering().getRenderedContentView(); } /** * Exposed for subclasses. */ public final CMutableDocument getMutableDoc() { return context.document(); } /** * Exposed for subclasses * * DO NOT expose getAggressiveSelectionHelper() ! * It could cause all kinds of problems with interleaved application of ops. */ public final SelectionHelper getSelectionHelper() { return context.editing().getSelectionHelper(); } // Package private final Repairer getRepairer() { return context.rendering().getRepairer(); } // Package private final TypingExtractor getTypingExtractor() { return context.editing().getTypingExtractor(); } /** * Exposed for subclasses */ public final LocationMapper<ContentNode> getLocationMapper() { return context.locationMapper(); } /** * Exposed for subclasses */ public final boolean inEditMode() { return context.isEditing(); } /** * Exposed for subclasses */ public final String getEditorUniqueString() { return context.getDocumentId(); } /** * Exposed for subclasses */ public final SuggestionsManager getSuggestionsManager() { return context.editing().getSuggestionsManager(); } /** * Exposed for subclasses */ public final ContentElement getElementByName(String name) { return context.getElementByName(name); } /** * Check if the HTML for this element is "OK" (where OK loosely means * "doesn't need fixing"). * @return true if html impl is correct & attached */ public abstract boolean isConsistent(); /** * Throw away and redo the html implementation (drastic repair mechanism) */ public abstract void revertImplementation(); /** * {@inheritDoc} */ @Override public String toString() { String name = getClass().getName(); name = name.substring(name.lastIndexOf('.') + 1); String nodeletString = "destroyed"; try { // Note(user): this toString can fail for text nodes that IE's editor has deleted nodeletString = getImplNodelet() == null ? "null" : new Pretty<Node>().print(context.rendering().getFullHtmlView(), getImplNodelet()); } catch (Throwable t) { } String contentString = new Pretty<ContentNode>().print( FullContentView.INSTANCE, this); return name + ": " + contentString + " / " + nodeletString; } //////// MUTATING NODE /** * {@inheritDoc} */ public void onAddedToParent(ContentElement previousParent) {} /** * {@inheritDoc} */ public void onRemovedFromParent(ContentElement newParent) {} /** * {@inheritDoc} */ public void onChildAdded(ContentNode child) {} /** * {@inheritDoc} */ public void onChildRemoved(ContentNode child) {} /** * {@inheritDoc} */ public void onAttributeModified(String name, String oldValue, String newValue) {} /** * {@inheritDoc} */ public void onDescendantsMutated() {} /** * {@inheritDoc} */ public void onEmptied() {} void rethrowOrNoteErrorOnMutation(RuntimeException e) { try { assert false; } catch (AssertionError ae) { // assertions turned on - re-throw unconditionally throw e; } if (LogLevel.showErrors()) { throw e; } else { noteErrorOnMutationEvent(e); } } /** * This must be called after this node is added to a parent. * Calls onAddedToParent, onChildAdded on all appropriate nodes. */ protected final void notifyAddedToParent(ContentElement oldParent, boolean notifyMutatedUpwards) { try { // TODO(danilatos, lars): Order of these? does it matter? onAddedToParent(oldParent); ContentElement parent = getParentElement(); parent.onChildAdded(this); } catch (RuntimeException e) { rethrowOrNoteErrorOnMutation(e); } if (notifyMutatedUpwards) { parent.notifyChildrenMutated(); } } /** * This must be called after this node is removed from a parent. Calls * onRemovingFromParent, onRemovingChild * * Does NOT call onDescendantsMutated * * @param oldParent the parent this node is being removed from * @param newParent the parent this node is being moved to, if any. null if it * is being removed from the DOM altogether */ protected final void notifyRemovedFromParent(ContentNode oldParent, ContentElement newParent) { try { onRemovedFromParent(newParent); oldParent.onChildRemoved(this); } catch (RuntimeException e) { rethrowOrNoteErrorOnMutation(e); } } /** * Gracefully handle any errors when changing the underlying HTML dom. * This should always be used and exception guards should be placed around * code that mutates the HTML, wherever exceptions could cause document * corruption. * @param e The exception thrown */ void noteErrorWithImplMutation(Exception e) { // TODO(danilatos, mtsui): Better handling, see why we are throwing // exceptions in the first place and what sorts of exceptions. logger.error().log(e + " Scheduling revert."); ScheduleCommand.addCommand(new Task() { public void execute() { getRepairer().revert(Point.inElement(getParentElement(), ContentNode.this), null); } }); } /** * Gracefully handle any errors thrown by external code in a mutation handler. * This should always be used and exception guards should be placed around * code that calls the notifyXXX methods, wherever exceptions could cause document * corruption. * @param e The exception thrown */ void noteErrorOnMutationEvent(Exception e) { // TODO(danilatos): Better handling logger.error().log( "noteErrorOnMutationEvent: " + e); // For debug builds, fail here rather than trying to recover. assert false : "noteErrorOnMutationEvent: " + e; } ///// IMPL helpers /** * Non static versions. Separation exists purely because the exception guarding * requires the "this" context, but we'd like to enforce a static * context for the meat of the implementation. */ void implInsertBefore(ContentElement parent, ContentNode from, ContentNode to, ContentNode refChild, Element oldContainerNodelet) { try { staticImplInsertBefore(parent, from, to, refChild, oldContainerNodelet); } catch (RuntimeException e) { e.printStackTrace(); // Safe to swallow the exception, the impl mutation code does not // transitively affect external state. noteErrorWithImplMutation(e); } } /** * Do not use these directly, they are used by the non-static equivalents * * Parameters correspond to parameters of * @see RawDocument#insertBefore(Object, Object, Object, Object) */ private static void staticImplInsertBefore(ContentElement parent, ContentNode from, ContentNode toExcl, ContentNode refChild, Element oldContainerNodelet) { Preconditions.checkArgument(toExcl == null || toExcl.getParentElement() == from.getParentElement(), "invalid toExcl"); Element containerNodelet = parent.getContainerNodelet(); if (containerNodelet != null) { Node implRef = null; // Don't use getImplNodeletRightwards(), it's too clever for (ContentNode node = refChild; node != null; node = node.getNextSibling()) { if (node.getImplNodelet() != null) { Preconditions.checkState(node.getImplNodelet().hasParentElement(), "implNodelet not attached"); implRef = node.getImplNodelet(); break; } } if (implRef != null) { assert implRef.getParentElement() == containerNodelet; // Be robust if assertions are off containerNodelet = implRef.getParentElement(); if (containerNodelet == null) { return; } } for (ContentNode node = from; node != toExcl; node = node.getNextSibling()) { if (node.isTextNode()) { ((ContentTextNode) node).normaliseImpl(); } Node nodelet = node.getImplNodelet(); if (nodelet != null) { containerNodelet.insertBefore(nodelet, implRef); } } } else { if (oldContainerNodelet != null) { for (ContentNode node = from; node != toExcl; node = node.getNextSibling()) { Node nodelet = node.getImplNodelet(); if (nodelet != null) { nodelet.removeFromParent(); } } } } } /** {@inheritDoc} */ public boolean isComparable() { // TODO(danilatos): Is there a more robust measure, whilst remaining efficient? return isContentAttached(); } /** * Comparison is based on position in the tree. * * TODO(danilatos): Use our new indexing scheme to compare instead?? * * WARNING(danilatos): This is a dynamic property! Be careful when you use it. * TODO(danilatos): Investigate if it's better to not implement the comparator * interface to prevent accidental inappropriate use, but just have this * method implemented for when needed directly. * {@link #isComparable()} will return false when this node cannot be compared * to other nodes. * * {@inheritDoc} */ public int compareTo(ContentNode other) { // TODO(danilatos): Room for some optimisation in this method. // Could probably do most (not all) cases with some kind of text range // comparison. // http://developer.mozilla.org/en/docs/DOM:range.compareBoundaryPoints if (!isComparable() || !other.isComparable()) { throw new IllegalArgumentException("Cannot compare unattached nodes"); } // Map of elements in the ancestor path -> child of said parent in ancestor path Map<ContentNode, ContentNode> ancestors = new HashMap<ContentNode, ContentNode>(); ContentNode minePrev = null, theirsPrev = null; ContentNode mine = this, theirs = other; // Check if the same if (mine == theirs || mine.equals(theirs)) { return 0; } // Go up one level if text nodes, to avoid placing them as keys in the map if (mine instanceof ContentTextNode) { minePrev = mine; mine = mine.getParentElement(); } if (theirs instanceof ContentTextNode) { theirsPrev = theirs; theirs = theirs.getParentElement(); } // Populate my ancestor chain while (mine != null) { ancestors.put(mine, minePrev); minePrev = mine; mine = mine.getParentElement(); } // Find nearest common ancestor ContentNode nca = theirs; while (!ancestors.containsKey(nca)) { theirsPrev = nca; assert nca != null : "Incomparable nodes!"; nca = nca.getParentElement(); } minePrev = ancestors.get(nca); if (minePrev == null) { return -1; } if (theirsPrev == null) { return 1; } // We assume that they are not equal, if we're up to here. for (ContentNode search = minePrev; search != null; search = search.getPreviousSibling()) { if (search.equals(theirsPrev)) { return 1; } } return -1; } /** * TODO(danilatos): A more robust way to see if the node still "exists". * w.r.t. the content representation. * We also need to clean these up when they are removed... * * @return true if the node is attached to our content tree */ public boolean isContentAttached() { ContentElement e = isTextNode() ? getParentElement() : (ContentElement) this; ContentElement root = getMutableDoc().getDocumentElement(); while (e != root) { if (e == null) { return false; } e = e.getParentElement(); } return true; } /** * @return true if the node is still attached w.r.t. the html implementation */ public boolean isImplAttached() { // TODO(danilatos): Implement this as doing a filtered search up the filtered html tree Node nodelet = getImplNodelet(); return nodelet != null && nodelet.hasParentElement(); } /** * Assert that node is healthy */ public void debugAssertHealthy() { // Assert that implNodelet points back to this // Assert.assertEquals("Backref should be to wrapping ContentNode", // this, ContentNode.getContentNode(implNodelet)); } /** * @param other * @return true if this node is equal to or is an ancestor of other */ boolean isOrIsAncestorOf(ContentNode other) { while (other != null) { if (this == other) { return true; } other = other.getParentElement(); } return false; } }