/** * Copyright 2009 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.model.richtext; import org.waveprotocol.wave.model.document.ReadableDocument; import org.waveprotocol.wave.model.document.operation.Attributes; import org.waveprotocol.wave.model.document.operation.Nindo; import org.waveprotocol.wave.model.document.operation.automaton.DocumentSchema.PermittedCharacters; import org.waveprotocol.wave.model.document.operation.impl.AttributesImpl; import org.waveprotocol.wave.model.document.operation.util.ImmutableStateMap.Attribute; import org.waveprotocol.wave.model.document.util.LineContainers; import org.waveprotocol.wave.model.document.util.Point; import org.waveprotocol.wave.model.richtext.RichTextTokenizer.Type; import org.waveprotocol.wave.model.util.CollectionUtils; import org.waveprotocol.wave.model.util.Preconditions; import org.waveprotocol.wave.model.util.ReadableStringSet; import org.waveprotocol.wave.model.util.StringMap; import org.waveprotocol.wave.model.util.StringSet; import org.waveprotocol.wave.model.util.ValueUtils; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Stack; /** * Builds up a sequence of operations from DOM that has been parsed with * a RichTextTokenizer. * */ public class RichTextMutationBuilder { // TODO(user): Centralize these constants. private static final String TYPE_ATTR = "t"; private static final String LI_STYLE_ATTR = "listyle"; private static final String INDENT_ATTR = "i"; private static final String STYLE_KEY_FONT_WEIGHT = "style/fontWeight"; private static final String STYLE_KEY_FONT_STYLE = "style/fontStyle"; private static final String STYLE_KEY_TEXT_DECORATION = "style/textDecoration"; private static final String STYLE_KEY_COLOR = "style/color"; private static final String STYLE_KEY_BG_COLOR = "style/backgroundColor"; private static final String STYLE_KEY_FONT_FAMILY = "style/fontFamily"; /** Default annotation values for certain keys */ private static final StringMap<ReadableStringSet> defaultAnnotations = CollectionUtils.createStringMap(); static { defaultAnnotations.put(STYLE_KEY_TEXT_DECORATION, CollectionUtils.newStringSet("none")); defaultAnnotations.put(STYLE_KEY_FONT_WEIGHT, CollectionUtils.newStringSet("normal")); defaultAnnotations.put(STYLE_KEY_FONT_STYLE, CollectionUtils.newStringSet("normal")); defaultAnnotations.put(STYLE_KEY_BG_COLOR, CollectionUtils.newStringSet("initial", "transparent")); // Default font family and color are dependent on user-agent settings, but // make up a default color anyway defaultAnnotations.put(STYLE_KEY_COLOR, CollectionUtils.newStringSet("black")); } /** This map records all annotations currently started. */ private final Map<String, Stack<String>> startedAnnotations = new HashMap<String, Stack<String>>(); /** The current delta offset. */ private int offset; /** Current indentation level */ private final List<Type> structureStack = new ArrayList<Type>(); /** Count of open elements. */ private int elementCount; /** Last valid cursor position relative from the start. */ private int lastGoodCursorOffset; private int indentationLevel = 0; private boolean isFirstToken; /** * Annotation keys that have been touched. */ private final StringSet affectedKeys = CollectionUtils.createStringSet(); /** Mapping of key/value pairs to use when particular keys have no mappings in the builder. */ private final StringMap<String> defaultValueMap; public RichTextMutationBuilder() { this(CollectionUtils.<String>createStringMap()); } /** * @param defaultValueMap annotation (key, value) pairs that are to be used * when no explicit annotation value is set for that key. */ public RichTextMutationBuilder(StringMap<String> defaultValueMap) { this.defaultValueMap = defaultValueMap; reset(); } private final void reset() { offset = 0; structureStack.clear(); elementCount = 0; lastGoodCursorOffset = 0; startedAnnotations.clear(); affectedKeys.clear(); isFirstToken = true; } /** * Returns the offset location that is safe to place the cursor. It is * relative from the current location when mutations were applied. For * example, if <p></p> is inserted, the full offset would be 2 while this * method would return 1 (to be placed within the p). * * @return A good location offset. */ public int getLastGoodCursorOffset() { return lastGoodCursorOffset; } /** * Applies the mutations to a document mutation builder based on a tokenized * stream of rich text. Assumes that the current location of the document * mutation builder is inside of a <p> tag. Returns a delta offset from * the current location typically used to place the caret at the location of * the last mutation. * * @param <N> Node type. * @param <E> Element type. * @param <T> Text type. * @param tokenizer The processed tokenizer. * @param builder The Nindo builder. * @param doc Readable document used to get element information. * @param splitContainer In case of an initial split, this is the container * to use. * @return The annotation keys that have been affected. */ public <N, E extends N, T extends N> ReadableStringSet applyMutations(RichTextTokenizer tokenizer, Nindo.Builder builder, ReadableDocument<N, E, T> doc, N splitContainer) { reset(); while (tokenizer.hasNext()) { Type tokenType = tokenizer.next(); switch (tokenType) { case NEW_LINE: case LIST_ITEM: handleNewLine(tokenizer, builder, doc, splitContainer, structuralAttributes(tokenizer)); break; default: handleBasicMutation(tokenizer, builder, doc, splitContainer); break; } isFirstToken = false; } // Make sure all annotations have been ended. for (String key : startedAnnotations.keySet()) { builder.endAnnotation(key); } lastGoodCursorOffset = offset; // Close any remaining open tags. Not adding this to offset because we want // the cursor to remain. for (int i = 0; i < elementCount; ++i) { builder.elementEnd(); ++offset; } return affectedKeys; } private Attributes structuralAttributes(RichTextTokenizer tokenizer) { Type currentStructureType = peek(); List<Attribute> attrList = new ArrayList<Attribute>(); if (currentStructureType != null) { int indent = indentationLevel; if (tokenizer.getCurrentType() == Type.LIST_ITEM) { switch (currentStructureType) { case UNORDERED_LIST_START: indent--; attrList.add(new Attribute(TYPE_ATTR, "li")); break; case ORDERED_LIST_START: indent--; attrList.add(new Attribute(TYPE_ATTR, "li")); attrList.add(new Attribute(LI_STYLE_ATTR, "decimal")); break; } } else if (tokenizer.getData() != null) { attrList.add(new Attribute(TYPE_ATTR, tokenizer.getData())); } if (indent > 0) { attrList.add(new Attribute(INDENT_ATTR, "" + indent)); } } AttributesImpl attributes = AttributesImpl.fromUnsortedAttributes(attrList); return attributes; } private <N, E extends N, T extends N> void handleBasicMutation(RichTextTokenizer tokenizer, Nindo.Builder builder, ReadableDocument<N, E, T> doc, N splitContainer) { Type currentType = tokenizer.getCurrentType(); switch (currentType) { case TEXT: if (tokenizer.getData() != null) { builder.characters(PermittedCharacters.BLIP_TEXT.coerceString(tokenizer.getData())); offset += tokenizer.getData().length(); } break; case STYLE_FONT_WEIGHT_START: startAnnotation(builder, STYLE_KEY_FONT_WEIGHT, tokenizer.getData()); break; case STYLE_FONT_WEIGHT_END: endAnnotation(builder, STYLE_KEY_FONT_WEIGHT); break; case STYLE_FONT_STYLE_START: startAnnotation(builder, STYLE_KEY_FONT_STYLE, tokenizer.getData()); break; case STYLE_FONT_STYLE_END: endAnnotation(builder, STYLE_KEY_FONT_STYLE); break; case STYLE_TEXT_DECORATION_START: startAnnotation(builder, STYLE_KEY_TEXT_DECORATION, tokenizer.getData()); break; case STYLE_TEXT_DECORATION_END: endAnnotation(builder, STYLE_KEY_TEXT_DECORATION); break; case STYLE_COLOR_START: startAnnotation(builder, STYLE_KEY_COLOR, tokenizer.getData()); break; case STYLE_COLOR_END: endAnnotation(builder, STYLE_KEY_COLOR); break; case STYLE_BG_COLOR_START: startAnnotation(builder, STYLE_KEY_BG_COLOR, tokenizer.getData()); break; case STYLE_BG_COLOR_END: endAnnotation(builder, STYLE_KEY_BG_COLOR); break; case STYLE_FONT_FAMILY_START: startAnnotation(builder, STYLE_KEY_FONT_FAMILY, tokenizer.getData()); break; case STYLE_FONT_FAMILY_END: endAnnotation(builder, STYLE_KEY_FONT_FAMILY); break; case LINK_START: startAnnotation(builder, "link/manual", tokenizer.getData()); break; case LINK_END: endAnnotation(builder, "link/manual"); break; case UNORDERED_LIST_START: case ORDERED_LIST_START: push(currentType); break; case UNORDERED_LIST_END: case ORDERED_LIST_END: pop(); break; default: throw new IllegalStateException("Unhandled token: " + currentType.toString()); } } private void push(Type type) { structureStack.add(type); indentationLevel += type.indent(); } private void pop() { if (structureStack.size() > 0) { indentationLevel -= structureStack.remove(structureStack.size() - 1).indent(); } } private Type peek() { if (structureStack.size() == 0) { return null; } return structureStack.get(structureStack.size() - 1); } private void startAnnotation(Nindo.Builder builder, String annotationKey, String annotationValue) { Stack<String> annotationStack = startedAnnotations.get(annotationKey); if (annotationStack == null) { annotationStack = new Stack<String>(); startedAnnotations.put(annotationKey, annotationStack); affectedKeys.add(annotationKey); } String current = annotationStack.isEmpty() ? null : annotationStack.peek(); // Avoid no-ops if (ValueUtils.notEqual(annotationValue, current)) { if (current == null && isDefaultValue(annotationKey, annotationValue)) { // If the current annotation is the default, and the new annotation // value is also the default, we don't need to set the annotation value } else { builder.startAnnotation(annotationKey, annotationValue); } } annotationStack.push(annotationValue); } private void endAnnotation(Nindo.Builder builder, String annotationKey) { Stack<String> annotationStack = startedAnnotations.get(annotationKey); Preconditions.checkNotNull(annotationStack, "cannot end unstarted annotation"); String current = annotationStack.pop(); // If there are no more entries, and the current annotation is non-null. if (annotationStack.isEmpty() && current != null) { // avoid no-ops if (!isDefaultValue(annotationKey, current)) { builder.endAnnotation(annotationKey); } // Conditionally null out certain keys if they are in a special list if (defaultValueMap.containsKey(annotationKey)) { builder.startAnnotation(annotationKey, defaultValueMap.get(annotationKey)); } else { startedAnnotations.remove(annotationKey); } } else { String nextAnnotation = annotationStack.peek(); if (ValueUtils.notEqual(current, nextAnnotation)) { // There's another entry in the stack and it is different from the // current- // If the entry in the stack is the same as default, just end the annotation. // Otherwise, start the next one. if (isDefaultValue(annotationKey, nextAnnotation)) { builder.endAnnotation(annotationKey); } else { builder.startAnnotation(annotationKey, nextAnnotation); } } } } private void startElement(Nindo.Builder builder, String tagName, Attributes attributes) { ++elementCount; builder.elementStart(tagName, attributes); ++offset; } private void endElement(Nindo.Builder builder) { if (--elementCount < 0) { throw new IllegalStateException("Element count is negative."); } builder.elementEnd(); ++offset; } // TODO(user): Consider and handle the case where the current container might // not be a line container (i.e. inside a caption) private <N, E extends N, T extends N> void handleNewLine(RichTextTokenizer tokenizer, Nindo.Builder builder, ReadableDocument<N, E, T> doc, N splitContainer, Attributes attributes) { // HACK(user): splitContainer is the container at insertion point. We // only want to append new lines if we are pasting into a line container. // There are more cases, i.e. if we are pasting in a caption, but that // doesn't happen at the moment. This catches most cases, and the problem // should go away when we stop using Nindo.Builder and use // mutable document instead. if (lcCanHandleNewLine(doc, splitContainer)) { lcHandleNewLine(tokenizer, builder, doc, splitContainer, attributes); } } private <N, E extends N, T extends N> void lcHandleNewLine(RichTextTokenizer tokenizer, Nindo.Builder builder, ReadableDocument<N, E, T> doc, N splitContainer, Attributes attributes) { // TODO(user): Don't create a new paragraph if the attributes are the same, // and is first token. boolean isLastToken = !tokenizer.hasNext(); if ((!isFirstToken && !isLastToken) || !attributes.isEmpty()) { startElement(builder, LineContainers.LINE_TAGNAME, attributes); endElement(builder); } } private <N, E extends N, T extends N> boolean lcCanHandleNewLine(ReadableDocument<N, E, T> doc, N container) { return LineContainers.isLineContainer(doc, Point.enclosingElement(doc, container)); } private boolean isDefaultValue(String key, String value) { return defaultAnnotations.containsKey(key) && defaultAnnotations.get(key).contains(value); } }