/** * 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.impl; 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.debug.logger.LogLevel; import org.waveprotocol.wave.client.editor.EditorRuntimeException; import org.waveprotocol.wave.client.editor.content.ContentElement; 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.content.HtmlPoint; import org.waveprotocol.wave.client.editor.content.TransparentManager; import org.waveprotocol.wave.client.editor.extract.InconsistencyException.HtmlInserted; import org.waveprotocol.wave.client.editor.extract.InconsistencyException.HtmlMissing; import org.waveprotocol.wave.client.editor.extract.Repairer; import org.waveprotocol.wave.model.document.util.FilteredView.Skip; import org.waveprotocol.wave.model.document.util.Point; /** * A class that manages the connection from html nodes to wrapper nodes. * Contains various utility methods to convert html-node-things (nodes, points) * to wrapper equivalents. * * There are some methods that deal with old-style node/offset inputs, and * convert them to new-style points. TODO(danilatos): Eradicate all use of * the node/offset idiom, and replace it with node/node idiom. * * @author danilatos@google.com (Daniel Danilatos) */ public class NodeManager { private static final String BACKREF_NAME = getNextMarkerName("cn"); // TODO(danilatos): Consider having the transparent manager say whether // a node is shallow/deep etc. private static final String TRANSPARENCY = getNextMarkerName("tl"); private static final String TRANSPARENT_BACKREF = getNextMarkerName("tb"); /** * @see #setMayContainSelectionEvenWhenDeep(Element, boolean) */ private static final String MAY_CONTAIN_SELECTION = getNextMarkerName("mcs"); /** * NOTE(danilatos): Initialising this to zero causes it to be re-initialised to * zero a few times for no apparent reason! Why??? Should not static initialising * code be only called once!? (This appears to happen with eclipse hosted mode..) */ private static int markerIndex; /** * Because of the proliferation of markers, backreference properties etc, * and the need to keep them short, there is a danger of collision. Therefore, * use this method to statically initialise any property names. * @param info For debugging purposes. A couple of characters should suffice. * This is ignored when debug is off. * @return A globally unique marker property name. */ public static String getNextMarkerName(String info) { markerIndex++; if (LogLevel.showDebug()) { return "_x_" + markerIndex + "_" + info; } else { // TODO(danilatos): Ensure this is unique enough to be safe? return "_x" + markerIndex; } } /** * NOTE(danilatos): This is preliminary * @param element Element * @return True if it is an intentional transparent node */ public static boolean isTransparent(Element element) { return element.getPropertyObject(TRANSPARENCY) != null; } /** * NOTE(danilatos): This is preliminary * Mark the element as a transparent node with the given skip level * @param element * @param skipType */ public static void setTransparency(Element element, Skip skipType) { element.setPropertyObject(TRANSPARENCY, skipType); } /** * @param element * @return the transparency level of the given element, or null if it is not * a transparent node */ public static Skip getTransparency(Element element) { return (Skip) element.getPropertyObject(TRANSPARENCY); } /** * @param element * @return The transparent node manager for the given element, or null if * none. */ @SuppressWarnings("unchecked") public static TransparentManager<Element> getTransparentManager(Element element) { return (TransparentManager<Element>)element.getPropertyObject(TRANSPARENT_BACKREF); } /** * * @param element * @param manager */ public static void setTransparentBackref(Element element, TransparentManager<?> manager) { element.setPropertyObject(TRANSPARENT_BACKREF, manager); } private final HtmlView filteredHtmlView; private final ContentView renderedContentView; private final Repairer repairer; /** */ public NodeManager(HtmlView filteredHtmlView, ContentView renderedContentView, Repairer repairer) { this.filteredHtmlView = filteredHtmlView; this.renderedContentView = renderedContentView; this.repairer = repairer; } /** * General convenience method. Given any html node, attempts to find its wrapper node * @param node * @return wrapper node * @throws HtmlInserted * @throws HtmlMissing */ public ContentNode findNodeWrapper(Node node) throws HtmlInserted, HtmlMissing { return findNodeWrapper(node, null); } /** TODO(danilatos,user) Bring back early exit & clean up this interface */ public ContentNode findNodeWrapper(Node node, Element earlyExit) throws HtmlInserted, HtmlMissing { if (node == null) { return null; } if (DomHelper.isTextNode(node)) { return findTextWrapper(node.<Text>cast(), false); } else { return findElementWrapper(node.<Element>cast(), earlyExit); } } private <T extends ContentNode> T nullifyIfWrongDocument(T node) { if (node != null && node.getRenderedContentView() == renderedContentView) { return node; } else { return null; } } /** * Given an html element, finds the wrapper (may traverse upwards). * @param element An html element * @return The wrapper, if found, null otherwise. */ public ContentElement findElementWrapper(Element element) { if (element == null) { return null; } HtmlView filteredHtml = filteredHtmlView; Element visibleElement = filteredHtml.getVisibleNode(element).cast(); return visibleElement != null ? nullifyIfWrongDocument(NodeManager.getBackReference(visibleElement)) : null; } /** TODO(danilatos,user) Bring back early exit & clean up this interface */ public ContentElement findElementWrapper(Element element, Element earlyExit) { // TODO(danilatos): Bring back the optimisation for IE where it stops at the // editor decorator element, rather than traversing up. return findElementWrapper(element); } /** * Given a text nodelet, finds the ContentTextNode wrapper if possible. * It also optionally can attempt to repair any trivial problems it finds * (such as a nodelet being replaced by an equivalent nodelet). * * The task of finding the wrapper for a text node is a fairly complex one, * given that: * 1) There may be more than one text nodelet per wrapper content text node * (Reason: We cannot rely on text nodelets not to randomly be split in * various scenarios, and it also makes decorating with transparent nodes * easier) * 2) We do not save backreferences from text nodelets * (Reason: IE doesn't let you, and text nodelets are unreliable anyway * TODO(danilatos): Try to use backreferences in Safari/FF as an * optimisation) * * There are three general types of scenarios: * 1) Consistent: Everything is consistent and we find the wrapper without * problem. * 2) Normal Inconsistent: Expected inconsistencies which arise * from basic typing. Some we can deal with here, others we throw an * exception which the typing extractor can deal with. * 3) Abnormal Inconsistent: Something weird has happened and we probably * need to invoke a repairing mechanism. These scenarios are also * treated by throwing an inconsistency exception. * I have a more detailed list of letter-indexed scenarios in my notebook, * which I refer to in the code. TODO(danilatos): Put diagrams in google * docs or something * * Warning: Assumes the text node is attached. TODO(danilatos): Remove this * assumption? * * @param target The nodelet we are trying to find a wrapper for. * @param attemptRepair * If true, the method will attempt trivial repairs to * inconsistencies where possible. Trivial probably means no new * nodes being created or destroyed, just re-assignments of * starting impl nodeletes in ContentTextNodes. This may still * leave the content and html out of sync, but the dom mapping will * be valid. Set to false to just throw an exception instead. * @return The wrapping text node, or null if there is no corresponding one * in this document and it's not an inconsistency problem. * @throws HtmlInserted * @throws HtmlMissing * * Note that we do not actually check text value inconsistencies in this * method, only tree structure inconsistencies. */ // IMPORTANT: All return values must be wrapped in a call to nullifyIfWrongDocument() public ContentTextNode findTextWrapper(Text target, boolean attemptRepair) throws HtmlInserted, HtmlMissing { if (target == null) { return null; } // TODO(danilatos): Optimise this for the general case /* * Basic strategy: We go backwards to the start of our run of text nodes, * from there up into the wrapper world, then scan rightwards across both * doms to find the matching pair, using current as our cursor into the html * dom, and possibleOwnerNode as our cursor into the wrapper dom. */ ContentView renderedContent = renderedContentView; HtmlView filteredHtml = filteredHtmlView; // The element before all previous text node siblings of target, // or null if no such element Node nodeletBeforeTextNodes; ContentNode wrapperBeforeTextNodes; // Our cursors Text current = target; ContentNode possibleOwnerNode; // Go leftwards to find the start of the text nodelet sequence for (nodeletBeforeTextNodes = filteredHtml.getPreviousSibling(target); nodeletBeforeTextNodes != null; nodeletBeforeTextNodes = filteredHtml.getPreviousSibling(nodeletBeforeTextNodes)) { Text maybeText = filteredHtml.asText(nodeletBeforeTextNodes); if (maybeText == null) { break; } current = maybeText; } Element parentNodelet = filteredHtml.getParentElement(target); if (parentNodelet == null) { throw new RuntimeException( "Somehow we are asking for the wrapper of something not in the editor??"); } ContentElement parentElement = NodeManager.getBackReference(parentNodelet); // Find our foothold in wrapper land if (nodeletBeforeTextNodes == null) { // reached the beginning wrapperBeforeTextNodes = null; possibleOwnerNode = renderedContent.getFirstChild(parentElement); } else { // reached an element wrapperBeforeTextNodes = NodeManager.getBackReference( nodeletBeforeTextNodes.<Element>cast()); possibleOwnerNode = renderedContent.getNextSibling(wrapperBeforeTextNodes); } // Scan to find a matching pair while (true) { // TODO(danilatos): Clarify and possibly reorganise this loop // TODO(danilatos): Write more unit tests to thoroughly cover all scenarios if (possibleOwnerNode == null) { // Scenario (D) throw new HtmlInserted( Point.inElement(parentElement, (ContentNode) null), Point.start(filteredHtml, parentNodelet) ); } ContentTextNode possibleOwner; try { possibleOwner = (ContentTextNode) possibleOwnerNode; } catch (ClassCastException e) { if (possibleOwnerNode.isImplAttached()) { // Scenario (C) throw new HtmlInserted( Point.inElement(parentElement, possibleOwnerNode), Point.inElementReverse(filteredHtml, parentNodelet, nodeletBeforeTextNodes) ); } else { // Scenario (A) // Not minor, an element has gone missing throw new HtmlMissing(possibleOwnerNode, parentNodelet); } } ContentNode nextNode = renderedContent.getNextSibling(possibleOwner); if (nextNode != null && !nextNode.isImplAttached()) { // Scenario (E) throw new HtmlMissing(nextNode, parentNodelet); } if (current != possibleOwner.getImplNodelet()) { // Scenario (B) if (attemptRepair) { possibleOwner.setTextNodelet(current); return nullifyIfWrongDocument(possibleOwner); } else { // TODO(danilatos): Ensure repairs handle nodes on either // side, as this is kind of a "replace" error throw new HtmlInserted( Point.inElement(parentElement, possibleOwner), Point.inElement(parentNodelet, current)); } } Node nextNodelet = nextNode == null ? null : nextNode.getImplNodelet(); while (current != nextNodelet && current != null) { // TODO(danilatos): Fix up every where in the code to use .equals // for GWT DOM. if (current == target) { return nullifyIfWrongDocument(possibleOwner); } current = filteredHtml.getNextSibling(current).cast(); } possibleOwnerNode = nextNode; } } /** * Converts a nodelet/offset pair to a Point of ContentNode. * Does its best in the face of adversity to yield reasonably accurate * results, but may throw inconsistency exceptions. * * @param node * @param offset * @return content node point * @throws HtmlInserted * @throws HtmlMissing */ public Point<ContentNode> nodeOffsetToWrapperPoint(Node node, int offset) throws HtmlInserted, HtmlMissing { if (DomHelper.isTextNode(node)) { return textNodeToWrapperPoint(node.<Text> cast(), offset); } else { Element container = node.<Element> cast(); return elementNodeToWrapperPoint(container, DomHelper.nodeAfterFromOffset(container, offset)); } } private Point<ContentNode> textNodeToWrapperPoint(Text text, int offset) throws HtmlInserted, HtmlMissing { if (getTransparency(text.getParentElement()) == Skip.DEEP) { Element e = text.getParentElement(); return elementNodeToWrapperPoint(e.getParentElement(), e); } else { ContentTextNode textNode = findTextWrapper(text, true); return Point.<ContentNode> inText(textNode, offset + textNode.getOffset(text)); } } private Point<ContentNode> elementNodeToWrapperPoint(Element container, Node nodeAfter) throws HtmlInserted, HtmlMissing { HtmlView filteredHtml = filteredHtmlView; // TODO(danilatos): Generalise/abstract this idiom if (filteredHtml.getVisibleNode(nodeAfter) != nodeAfter) { nodeAfter = filteredHtml.getNextSibling(nodeAfter); if (nodeAfter != null) { container = nodeAfter.getParentElement(); } } return Point.inElement(findElementWrapper(container), findNodeWrapper(nodeAfter)); } /** * Converts a nodelet to a Point of contentNode * * @param nodelet * @return content node point * @throws HtmlInserted * @throws HtmlMissing */ public Point<ContentNode> nodeletPointToWrapperPoint(Point<Node> nodelet) throws HtmlInserted, HtmlMissing { if (nodelet.isInTextNode()) { return textNodeToWrapperPoint(nodelet.getContainer().<Text> cast(), nodelet.getTextOffset()); } else { return elementNodeToWrapperPoint(nodelet.getContainer().<Element> cast(), nodelet .getNodeAfter()); } } /** * Convert a wrapper point to an HtmlPoint * @param point * @return the converted point. */ public HtmlPoint wrapperPointToHtmlPoint(Point<ContentNode> point) { if (point.isInTextNode()) { return wrapperTextPointToHtmlPoint(point); } else { return nodeletPointToHtmlPoint(wrapperElementPointToNodeletPoint(point)); } } /** * @param point * @return point of nodelet */ public Point<Node> wrapperPointToNodeletPoint(Point<ContentNode> point) { if (point.isInTextNode()) { HtmlPoint output = wrapperTextPointToHtmlPoint(point); return Point.inText(output.getNode(), output.getOffset()); } else { return wrapperElementPointToNodeletPoint(point); } } private HtmlPoint wrapperTextPointToHtmlPoint(Point<ContentNode> point) { HtmlPoint output = new HtmlPoint(null, 0); for (int attempt = 1; attempt <= 2; attempt++) { try { ((ContentTextNode) point.getContainer()).findNodeletWithOffset( point.getTextOffset(), output); break; } catch (HtmlMissing e) { repairer.handle(e); } } if (output.getNode() == null) { // TODO(danilatos): Something sensible throw new EditorRuntimeException("Don't know what to do with this - offset too big? point: " + point); } return output; } Point<Node> wrapperElementPointToNodeletPoint(Point<ContentNode> point) { Node nodeletAfter; // TODO(danilatos): return null for points that don't correspond to a location // in the html. if (point.getNodeAfter() == null) { // Scan backwards over trailing, non-implemented html (such as the spacer BR // for paragraphs) until we reach the last nodelet that has a wrapper - // then go one forward again and that's the nodeAfter we want to use. // E.g. if the content point is at the end of a paragraph, we want the nodelet // point to be the paragraph nodelet and the nodeAfter to be the spacer br, // if it is present. // TODO(danilatos): Clean this up & make more efficient. Element container = ((ContentElement) point.getContainer()).getContainerNodelet(); // NOTE(danilatos): This could be null because the doodad is handling its own // rendering explicitly, so there is no clear mapping to the corresponding html // point. if (container == null) { return null; } Node lastWrappedNodelet = container.getLastChild(); while (lastWrappedNodelet != null && !DomHelper.isTextNode(lastWrappedNodelet) && NodeManager.getBackReference(lastWrappedNodelet.<Element>cast()) == null) { lastWrappedNodelet = lastWrappedNodelet.getPreviousSibling(); } nodeletAfter = lastWrappedNodelet == null ? container.getFirstChild() : lastWrappedNodelet.getNextSibling(); } else { nodeletAfter = point.getNodeAfter().getImplNodeletRightwards(); } if (nodeletAfter == null) { ContentElement visibleNode = renderedContentView.getVisibleNode( point.getContainer()).asElement(); assert visibleNode != null; Element el = visibleNode.getContainerNodelet(); return el != null ? Point.<Node>inElement(el, null) : null; } else { Node parentNode = nodeletAfter.getParentNode(); // NOTE(danilatos): Instead, getImplNodeletRightwards(), (or the use of // some other utility method) should probably instead be guaranteeing nodeletAfter // being attached (visibly rendered), otherwise it should be null. // For now, we instead check that it has a parent (it might not in both // normal and abnormal circumstances). return parentNode != null ? Point.inElement(parentNode, nodeletAfter) : null; } } /** * @param point point of node * @return htmlpoint */ public static HtmlPoint nodeletPointToHtmlPoint(Point<Node> point) { if (point.isInTextNode()) { return new HtmlPoint(point.getContainer(), point.getTextOffset()); } else { return point.getNodeAfter() == null ? new HtmlPoint(point.getContainer(), point.getContainer().getChildCount()) : new HtmlPoint(point.getContainer(), DomHelper.findChildIndex(point.getNodeAfter())); } } /** * Puts a back reference to the element into the nodelet. * @param nodelet The element to store the back reference. Must not be null. * @param element The element to reference. */ public static void setBackReference(Element nodelet, ContentElement element) { nodelet.setPropertyObject(BACKREF_NAME, element); } /** * @param nodelet Gets the ContentElement reference stored in this node. * @return null if no back reference. */ public static ContentElement getBackReference(Element nodelet) { return nodelet == null ? null : (ContentElement) nodelet.getPropertyObject(BACKREF_NAME); } /** * Check if an element has a back reference. No smartness or searching upwards. * @param nodelet * @return true if the given element has a back reference */ public static boolean hasBackReference(Element nodelet) { return nodelet == null ? false : nodelet.getPropertyObject(BACKREF_NAME) != null; } /** * Converts the given point to a parent-nodeAfter point, splitting a * text node if necessary. * * @param point * @return a point at the same location, between node boundaries */ public static Point.El<Node> forceElementPoint(Point<Node> point) { Point.El<Node> elementPoint = point.asElementPoint(); if (elementPoint != null) { return elementPoint; } Element parent; Node nodeAfter; Text text = point.getContainer().cast(); parent = text.getParentElement(); int offset = point.getTextOffset(); if (offset == 0) { nodeAfter = text; } else if (offset == text.getLength()) { nodeAfter = text.getNextSibling(); } else { nodeAfter = text.splitText(offset); } return Point.inElement(parent, nodeAfter); } /** * Sets or clears the value returned by * {@link #mayContainSelectionEvenWhenDeep(Element)} */ public static void setMayContainSelectionEvenWhenDeep(Element e, boolean may) { e.setPropertyBoolean(MAY_CONTAIN_SELECTION, may); } /** * We usually ignore deep nodes completely when it comes to the selection - if * the selection is in a deep node, we usually report there is no selection. * * If this method returns true, we should make an exception, and report the * selection as just outside this node (perhaps recursing as necessary). */ public static boolean mayContainSelectionEvenWhenDeep(Element e) { return e.getPropertyBoolean(MAY_CONTAIN_SELECTION); } }