/**
* 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.model.document.util;
import org.waveprotocol.wave.model.document.MutableDocument;
import org.waveprotocol.wave.model.document.MutableDocument.Action;
import org.waveprotocol.wave.model.document.ReadableDocument;
import org.waveprotocol.wave.model.document.ReadableWDocument;
import org.waveprotocol.wave.model.document.operation.Attributes;
import org.waveprotocol.wave.model.util.Preconditions;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/**
* Constants and utilities for line containers
*
* TODO(danilatos): This should become a wrapper on the document, with the static
* methods no longer being static, and the tag dependencies being injected.
*
* @author danilatos@google.com (Daniel Danilatos)
*/
public final class LineContainers {
/**
* True if line containers should be used - false if still using old
* paragraphs.
*
* NOTE(danilatos): Setting this to true might break
* AggressiveSelectionHelperTest. It will need updating.
*/
public static final boolean USE_LINE_CONTAINERS_BY_DEFAULT = true;
public static final String LINE_TAGNAME = "line";
public static final String PARAGRAPH_NS = "l";
public static final String PARAGRAPH_TAGNAME = "p";
public static final String PARAGRAPH_FULL_TAGNAME = PARAGRAPH_NS + ":" + PARAGRAPH_TAGNAME;
// TODO(danilatos): Convert this class to no longer be static,
// and then these would be member variables.
private static String topLevelContainerTagname;
private static final Set<String> lineContainerTagnames = new HashSet<String>();
/**
* Sets the tag name for the top level line container
*
* MUST be set before attempting to append a line to an empty document
*
* @param tagName
*/
public static void setTopLevelContainerTagname(String tagName) {
Preconditions.checkNotNull(tagName, "Top level tag name must not be null");
topLevelContainerTagname = tagName;
registerLineContainerTagname(tagName);
}
/**
* Default tag name for the top level line container
*
* Implicitly created when appending lines to empty documents
*/
public static String topLevelContainerTagname() {
Preconditions.checkState(topLevelContainerTagname != null,
"Top level line container tag name not set!");
return topLevelContainerTagname;
}
/**
* Register a tag name as a line container, to recognise all
* elements with that tag name as being able to contain line elements.
*
* @param tagName
*/
public static void registerLineContainerTagname(String tagName) {
lineContainerTagnames.add(tagName);
}
/**
* Defines a text rounding granularity
*/
// TODO(danilatos/mtsui): Should this be unified with either of the two MoveUnit enums?
// This currently does not include any display logic (such as visual line vs logical line,
// or page), which is a bit different to the other move units, which contain both visual
// and logical units.
public enum Rounding {
/** No rounding */
NONE,
/** Round to word boundary */
WORD,
/** Round to sentence boundary */
SENTENCE,
/** Round to line boundary */
LINE
}
/**
* Defines in which direction rounding is to be applied.
*/
public enum RoundDirection {
LEFT,
RIGHT
}
/**
* @param doc
* @param rounding
* @param location
* @param direction Whether the rounding goes leftwards or rightwards
* @return the given location rounded rightwards to the requested granularity
*/
public static <N, E extends N, T extends N> Point<N> roundLocation(
ReadableDocument<N, E, T> doc, Rounding rounding, Point<N> location,
RoundDirection direction) {
Preconditions.checkNotNull(direction, "Rounding direction cannot be null.");
switch (rounding) {
case NONE:
return location;
case WORD:
case SENTENCE:
// TODO(mtsui/danilatos): Use and/or unify with TextLocator
throw new UnsupportedOperationException("Not implemented");
case LINE:
checkNotParagraphDocument(doc);
Point<N> point = jumpOutToContainer(doc, location);
if (point == null) {
return null;
}
E el = Point.enclosingElement(doc, point);
if (direction == RoundDirection.RIGHT) { // round to the right
N nodeAfter = point.isInTextNode()
? doc.getNextSibling(point.getContainer()) : point.getNodeAfter();
while (nodeAfter != null && !isLineElement(doc, nodeAfter)) {
nodeAfter = doc.getNextSibling(nodeAfter);
}
return Point.<N>inElement(el, nodeAfter);
} else { // otherwise, round left (backwards)
N nodeBefore = point.isInTextNode() ? doc.getPreviousSibling(point.getContainer())
: Point.nodeBefore(doc, point.asElementPoint());
while (nodeBefore != null && !isLineElement(doc, nodeBefore)) {
nodeBefore = doc.getPreviousSibling(nodeBefore);
}
return nodeBefore == null ? null : Point.before(doc, nodeBefore);
}
default:
throw new AssertionError("Missing rounding implementations");
}
}
/**
* Predicates a node being a line container
*/
public static final DocPredicate LINE_CONTAINER_PREDICATE = new DocPredicate() {
@Override
public <N, E extends N, T extends N> boolean apply(ReadableDocument<N, E, T> doc, N node) {
return isLineContainer(doc, node);
}
};
/**
* Jumps the point out to the enclosing line container, if any
*
* @see DocHelper#jumpOut(ReadableDocument, Point, DocPredicate)
*/
public static <N, E extends N, T extends N> Point<N> jumpOutToContainer(
ReadableDocument<N, E, T> doc, Point<N> location) {
return DocHelper.jumpOut(doc, location, LINE_CONTAINER_PREDICATE);
}
/**
* Finds the last line element that is before a given location, which should be within a
* line container element.
*
* @param doc
* @param at
* @return The line element or null if not found.
*/
public static <N, E extends N, T extends N> E getRelatedLineElement(
ReadableDocument<N, E, T> doc, Point<N> at) {
Point<N> atStart = roundLocation(doc, Rounding.LINE, at, RoundDirection.LEFT);
// atStart should now have the lineContainer as the parent and the line element as nodeAfter:
if (atStart == null || atStart.getNodeAfter() == null) {
return null; // nothing found
}
return doc.asElement(atStart.getNodeAfter());
}
/**
* @param doc
* @param point
* @return true if the given location is at the end of a line
*/
public static <N, E extends N, T extends N> boolean isAtLineEnd(
ReadableWDocument<N, E, T> doc, Point<N> point) {
return doc.getLocation(point) == doc.getLocation(LineContainers.roundLocation(
doc, Rounding.LINE, point, RoundDirection.RIGHT));
}
/**
* @param doc
* @param point
* @return true if the given location is at the start of a line
*/
public static <N, E extends N, T extends N> boolean isAtLineStart(
ReadableWDocument<N, E, T> doc, Point<N> point) {
E elementBefore = point == null ? null : Point.elementBefore(doc, point);
return elementBefore != null ? isLineElement(doc, elementBefore) : false;
}
/**
* @param doc
* @param point
* @return true if the given location is at an empty line
*/
public static <N, E extends N, T extends N> boolean isAtEmptyLine(
ReadableWDocument<N, E, T> doc, Point<N> point) {
return isAtLineStart(doc, point) && isAtLineEnd(doc, point);
}
/**
* Inserts content into a point that is within a line. If the point is not
* within a line, will create a new one at the next available location.
*
* @param doc the document to insert into.
* @param point the point within a line to insert.
* @param content the content to insert.
* @return the node that was inserted into.
*/
public static <N, E extends N, T extends N> N insertInto(MutableDocument<N, E, T> doc,
Point<N> point, XmlStringBuilder content) {
checkNotParagraphDocument(doc);
E lc = null;
for (E el : DocIterate.deepElementsReverse(doc, doc.getDocumentElement(), null)) {
if (isLineContainer(doc, el)) {
lc = el;
break;
}
}
if (lc != null) {
// This garbage code attempts to figure out if the current location
// is after a line declaration. Has to be an easier way, but this is
// quick and dirty.
int location = doc.getLocation(point);
// Find the first line.
for (N child = doc.getFirstChild(lc); child != null; child = doc.getNextSibling(lc)) {
if (isLineElement(doc, child)) {
if (doc.getLocation(child) < location) {
return doc.insertXml(point, content);
}
}
}
}
// Just insert a line here.
return insertContentOnNewLine(doc, Rounding.NONE, point, content);
}
/**
* Deletes a line inside of a line container. Takes care to not invalidate
* the schema by leaving an empty line container. If the line to be deleted
* is the last one, then it will be emptied and left alone instead.
*
* @param doc
* @param line the element marking the start of the line to remove.
*/
public static <N, E extends N, T extends N> void deleteLine(MutableDocument<N, E, T> doc,
E line) {
checkNotParagraphDocument(doc);
if (!isLineElement(doc, line)) {
Preconditions.illegalArgument("Not a line element: " + line);
}
E lc = doc.getParentElement(line);
if (!isLineContainer(doc, lc)) {
Preconditions.illegalArgument("Not a line container: " + lc);
}
boolean isFirstLine = doc.getFirstChild(lc) == line;
Point<N> deleteEndPoint =
roundLocation(doc, Rounding.LINE, Point.after(doc, line), RoundDirection.RIGHT);
// If this is not the first line or there is another line, then we can
// delete this one. Otherwise, empty it and leave it.
Point<N> deleteStartPoint = null;
if (!isFirstLine || isLineElement(doc, deleteEndPoint.getNodeAfter())) {
deleteStartPoint = Point.before(doc, line);
} else {
doc.emptyElement(line);
deleteStartPoint = Point.after(doc, line);
}
doc.deleteRange(deleteStartPoint, deleteEndPoint);
}
/**
* For a given document, will linearly scan all lines and return a list of
* ranges representing each. The start point of each range will be the first
* point after the end line element and the end point will be the point
* before the next line (or before the end tag of the line container in the
* case of the last line).
*
* @param doc
* @return list of ranges representing each line.
*/
public static <N, E extends N, T extends N> List<Range> getLineRanges(
MutableDocument<N, E, T> doc) {
checkNotParagraphDocument(doc);
List<Range> lines = new ArrayList<Range>();
N root = doc.getDocumentElement();
for (N lc = doc.getFirstChild(root); lc != null; lc = doc.getNextSibling(lc)) {
if (isLineContainer(doc, lc)) {
int start = -1;
for (N line = doc.getFirstChild(lc); line != null; line = doc.getNextSibling(line)) {
if (isLineElement(doc, line)) {
if (start > 0) {
int end = doc.getLocation(Point.before(doc, line));
lines.add(new Range(start, end));
}
start = doc.getLocation(Point.after(doc, line));
}
}
if (start > 0) {
lines.add(new Range(start, doc.getLocation(Point.end(lc))));
}
}
}
return lines;
}
/**
* Inserts a line at the given location
*
* @param doc
* @param rounding rightwards rounding to apply to the given location
* @param location
* @return the new line element
*
* Temporarily supports paragraphs as well
*/
public static <N, E extends N, T extends N> E insertLine(final MutableDocument<N, E, T> doc,
Rounding rounding, Point<N> location) {
return insertLine(doc, rounding, location, Attributes.EMPTY_MAP);
}
/**
* Inserts a line at the given location
*
* @param doc
* @param rounding rightwards rounding to apply to the given location
* @param location
* @param attributes
* @return the new line element
*
* Temporarily supports paragraphs as well
*/
public static <N, E extends N, T extends N> E insertLine(final MutableDocument<N, E, T> doc,
Rounding rounding, Point<N> location, Attributes attributes) {
Preconditions.checkNotNull(rounding, "rounding must not be null");
location = roundLocation(doc, rounding, location, RoundDirection.RIGHT);
Preconditions.checkArgument(location != null, "location is not a valid place to insert a line");
checkNotParagraphDocument(doc);
// Make sure this is a valid place to insert the line, even if it means
// dishonouring the rounding. Line rounding should already have done this.
if (rounding != Rounding.LINE) {
location = jumpOutToContainer(doc, location);
}
return doc.createElement(location, LINE_TAGNAME, attributes);
}
public static <N, E extends N, T extends N> N insertContentOnNewLine(
final MutableDocument<N, E, T> doc,
Rounding rounding, Point<N> location, XmlStringBuilder initialContent) {
return insertContentIntoLineStart(doc, insertLine(doc, rounding, location), initialContent);
}
public static <N, E extends N, T extends N> N appendContentOnNewLine(
MutableDocument<N, E, T> doc, XmlStringBuilder initialContent) {
// TODO(user): This is redundant to appendLine with content. Remove.
return insertContentIntoLineStart(doc, appendLine(doc, null), initialContent);
}
/**
* Inserts content into the end of the line specified by the element.
*
* @param doc
* @param line the line element to insert into
* @param content the content to insert
* @return the node that was inserted into.
*/
public static <N, E extends N, T extends N> N insertContentIntoLineEnd(
final MutableDocument<N, E, T> doc, E line, XmlStringBuilder content) {
// Find the next line and insert just before it.
Point<N> point = roundLocation(doc, Rounding.LINE, Point.start(doc, line),
RoundDirection.RIGHT);
if (point == null) {
throw new AssertionError("Not a valid line location.");
}
return doc.insertXml(point, content);
}
/**
* Inserts content into the start of the line specified by the element.
*
* @param doc
* @param line the line element to insert into
* @param initialContent the content to insert
* @return the node that was inserted.
*/
public static <N, E extends N, T extends N> N insertContentIntoLineStart(
final MutableDocument<N, E, T> doc, E line, XmlStringBuilder initialContent) {
doc.insertXml(Point.after(doc, line), initialContent);
return doc.getNextSibling(line);
}
public static void properAppendLine(final MutableDocument<?, ?, ?> doc,
final XmlStringBuilder content) {
doc.with(new Action() {
@Override
public <N, E extends N, T extends N> void exec(MutableDocument<N, E, T> doc) {
appendLine(doc, content);
}
});
}
public static <N, E extends N, T extends N> E appendLine(MutableDocument<N, E, T> doc,
XmlStringBuilder content) {
return appendLine(doc, content, Attributes.EMPTY_MAP);
}
/**
* Appends a line to the last line container of the document
*
* If the document has no line containers, one will be created at the end of
* the document.
*
* Temporarily also supports old style paragraphs for old documents, in which
* case the new paragraph is returned
*
* @param doc
* @param content optional content for the new line, may be null
* @return the line token representing the start of the new line
*/
public static <N, E extends N, T extends N> E appendLine(final MutableDocument<N, E, T> doc,
XmlStringBuilder content, Attributes attributes) {
checkNotParagraphDocument(doc);
E lc = null;
for (E el : DocIterate.deepElementsReverse(doc, doc.getDocumentElement(), null)) {
if (isLineContainer(doc, el)) {
lc = el;
break;
}
}
if (lc == null) {
// Create the <body><line></line></body> in one go.
lc = doc.appendXml(XmlStringBuilder.createEmpty().wrap(LINE_TAGNAME).wrap(
topLevelContainerTagname()));
// Add the content before </body>
if (content != null && content.getLength() > 0) {
doc.insertXml(Point.<N>end(lc), content);
}
E line = doc.asElement(doc.getFirstChild(lc));
assert line != null;
if (attributes != null) {
doc.setElementAttributes(line, attributes);
}
return line;
} else {
return appendLine(doc, lc, content, attributes);
}
}
/**
* Finds the last valid line and appends to the end of it.
*
* @param doc the document to insert into.
* @param content the content to append.
*/
public static <N, E extends N, T extends N> E appendToLastLine(MutableDocument<N, E, T> doc,
XmlStringBuilder content) {
checkNotParagraphDocument(doc);
// TODO(user): Don't duplicate the code below.
for (E el : DocIterate.deepElementsReverse(doc, doc.getDocumentElement(), null)) {
if (isLineContainer(doc, el)) {
// TODO(user): Check for at least a line tag? I'm assuming
// there is one...
Point<N> point = Point.inElement((N) el, null);
if (point != null) {
return doc.insertXml(point, content);
}
}
}
// Looks like no line to add to, just append.
return appendLine(doc, content);
}
public static <N, E extends N, T extends N> E appendLine(final MutableDocument<N, E, T> doc,
E lineContainer, XmlStringBuilder content) {
return appendLine(doc, lineContainer, content, Attributes.EMPTY_MAP);
}
/**
* Appends a line to the given line container
*
* @param doc
* @param lineContainer
* @param content optional content for the new line, may be null
* @param attributes optional attributes, may be null.
* @return the line token representing the start of the new line
*/
public static <N, E extends N, T extends N> E appendLine(final MutableDocument<N, E, T> doc,
E lineContainer, XmlStringBuilder content, Attributes attributes) {
E line = doc.createChildElement(lineContainer, LINE_TAGNAME, attributes);
if (content != null && content.getLength() > 0) {
doc.insertXml(Point.<N>end(lineContainer), content);
}
return line;
}
/**
* Returns true iff the tagname is a line container.
*
* NOTE(danilatos): In the future, the match may involve more than
* just a tag name check. Other element types, such as table cells,
* might be line containers.
*
* @param tagname tagname to check
* @return true iff the tagname is a line container
*/
public static boolean isLineContainerTagname(String tagname) {
return lineContainerTagnames.contains(tagname);
}
/**
* @param doc
* @return true if the given document is an old-style-paragraph document
*/
@Deprecated
private static <N, E extends N, T extends N> boolean isUnsupportedParagraphDocument(
ReadableDocument<N, E, T> doc) {
if (doc.getFirstChild(doc.getDocumentElement()) == null) {
// If the document is empty, check what the default global option is
return !USE_LINE_CONTAINERS_BY_DEFAULT;
}
// Testing all children in the case of special <input> tags
N root = doc.getDocumentElement();
for (N child = doc.getFirstChild(root); child != null; child = doc.getNextSibling(child)) {
if (isUnsupportedParagraphElement(doc, child)) {
return true;
}
}
return false;
}
/** For temporary assertion purposes */
public static <N, E extends N, T extends N> void checkNotParagraphDocument(
ReadableDocument<N, E, T> doc) {
Preconditions.checkArgument(!isUnsupportedParagraphDocument(doc),
"Paragraph docs no longer supported");
}
/**
* @param doc
* @param node
* @return true if the node is a line container element
*
* NOTE(danilatos): In the future, the match may involve more than
* just a tag name check. Other element types, such as table cells,
* might be line containers.
*/
public static <N, E extends N> boolean isLineContainer(
final ReadableDocument<N, E, ?> doc, N node) {
E el = doc.asElement(node);
if (el != null) {
return isLineContainerTagname(doc.getTagName(el));
} else {
return false;
}
}
/**
* @param doc
* @param node
* @return true if the node is a line token element
*/
public static <N, E extends N> boolean isLineElement(
final ReadableDocument<N, E, ?> doc, N node) {
return DocHelper.isMatchingElement(doc, node, LINE_TAGNAME);
}
/**
* @param doc
* @param element a line element
* @return true if the element is the first line in the document
*/
public static <N, E extends N> boolean isFirstLine(
final ReadableDocument<N, E, ?> doc, E element) {
Preconditions.checkArgument(isLineElement(doc, element), "not a line element");
return DocHelper.getPreviousSiblingElement(doc, element) == null;
}
/** to be deleted */
@Deprecated
public static <N, E extends N> boolean isUnsupportedParagraphElement(
final ReadableDocument<N, E, ?> doc, N node) {
return DocHelper.isMatchingElement(doc, node, PARAGRAPH_TAGNAME);
}
/**
* Used for testing purposes, wraps content with correct tags.
*
* @param lines the lines to wrap.
* @return the wrapped content.
*/
public static String debugLineWrap(String ... lines) {
StringBuilder body = new StringBuilder();
for (String line : lines) {
body.append("<" + LINE_TAGNAME + "></" + LINE_TAGNAME + ">" + line);
}
return body.toString();
}
/**
* Used for testing purposes, wraps content with correct tags.
*
* @param lines the lines to wrap. if null, will not add a new line.
* @return the wrapped content.
*/
public static String debugContainerWrap(String ... lines) {
return "<" + topLevelContainerTagname + ">" + debugLineWrap(lines)
+ "</" + topLevelContainerTagname + ">";
}
private LineContainers() {
}
}