blob: cbf4a2f2c927d2923968964f10b0fbb5eadc8800 [file] [log] [blame]
/**
* 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.client.editor.content;
import com.google.gwt.dom.client.Document;
import com.google.gwt.dom.client.Element;
import com.google.gwt.dom.client.Node;
import com.google.gwt.dom.client.Style;
import org.waveprotocol.wave.client.common.util.DomHelper;
import org.waveprotocol.wave.client.editor.content.misc.StyleAnnotationHandler;
import org.waveprotocol.wave.client.editor.content.paragraph.LineRendering;
import org.waveprotocol.wave.client.editor.impl.DiffManager;
import org.waveprotocol.wave.client.editor.impl.DiffManager.DiffType;
import org.waveprotocol.wave.model.conversation.AnnotationConstants;
import org.waveprotocol.wave.model.document.AnnotationInterval;
import org.waveprotocol.wave.model.document.MutableAnnotationSet;
import org.waveprotocol.wave.model.document.operation.AnnotationBoundaryMap;
import org.waveprotocol.wave.model.document.operation.Attributes;
import org.waveprotocol.wave.model.document.operation.AttributesUpdate;
import org.waveprotocol.wave.model.document.operation.DocOp;
import org.waveprotocol.wave.model.document.operation.DocOpCursor;
import org.waveprotocol.wave.model.document.operation.ModifiableDocument;
import org.waveprotocol.wave.model.document.util.Annotations;
import org.waveprotocol.wave.model.operation.OperationException;
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.ReadableIntMap;
import org.waveprotocol.wave.model.util.ReadableStringMap;
import org.waveprotocol.wave.model.util.ReadableStringSet;
import org.waveprotocol.wave.model.util.StringMap;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
/**
* A wrapper for a content document, for the purpose of displaying diffs.
*
* Operations applied will be rendered as diffs.
*
* @author danilatos@google.com (Daniel Danilatos)
* @author dyukon@gmail.com (Denis Konovalchik)
*/
public class DiffHighlightingFilter implements ModifiableDocument {
/**
* Wrapper for a bunch of deleted stuff, for diff highlighting
*/
public static final class DeleteInfo {
private final List<Element> htmlElements = new ArrayList<Element>();
/**
* The html of the deleted content
*/
public List<Element> getDeletedHtmlElements() {
return htmlElements;
}
}
/**
* Dependencies for implementing the diff filter
*/
public interface DiffHighlightTarget extends MutableAnnotationSet<Object>, ModifiableDocument {
/**
* To be called during application of an operation, to interleave local annotations
* in with the operation. Will only be called with local keys.
*/
void startLocalAnnotation(String key, Object value);
/**
* To be called during application of an operation, to interleave local annotations
* in with the operation. Will only be called with local keys.
*/
void endLocalAnnotation(String key);
/**
* IndexedDocumentImpl's "currentNode"
*
* This method breaks encapsulation, think of a better way to do this later.
*/
ContentNode getCurrentNode();
/**
* @return true only if the operation is currently being applied to the
* document itself - false otherwise (so we don't do the diff logic
* for, e.g. pretty printing or validation cursors)
*/
boolean isApplyingToDocument();
}
/**
* Prefix for diff local annotations
*/
public static final String DIFF_KEY = Annotations.makeUniqueLocal("diff");
/**
* Diff annotation marking inserted content
*/
public static final String DIFF_INSERT_KEY = DIFF_KEY + "/ins";
/**
* Diff annotation whose left boundary represents deleted content, the content
* being stored in the annotation value as a DeleteInfo.
*/
public static final String DIFF_DELETE_KEY = DIFF_KEY + "/del";
private static final Object INSERT_MARKER = new Object();
private final DiffHighlightTarget inner;
// Munging to wrap the op
private DocOpCursor target;
private DocOp operation;
// Diff state
private int diffDepth = 0;
private DeleteInfo currentDeleteInfo = null;
private int currentDeleteLocation = 0;
IntMap<Object> deleteInfos;
int currentLocation = 0;
public DiffHighlightingFilter(DiffHighlightTarget contentDocument) {
this.inner = contentDocument;
}
@Override
public void consume(DocOp op) throws OperationException {
Preconditions.checkState(target == null, "Diff inner target not initialised");
operation = op;
inner.consume(opWrapper);
final int size = inner.size();
deleteInfos.each(new ReadableIntMap.ProcV<Object>() {
public void apply(int location, Object _item) {
assert location <= size;
if (location == size) {
// TODO(danilatos): Figure out a way to render this.
// For now, do nothing, which is better than crashing.
return;
}
if (_item instanceof DeleteInfo) {
DeleteInfo item = (DeleteInfo) _item;
DeleteInfo existing = (DeleteInfo) inner.getAnnotation(location, DIFF_DELETE_KEY);
if (existing != null) {
item.htmlElements.addAll(existing.htmlElements);
}
inner.setAnnotation(location, location + 1, DIFF_DELETE_KEY, item);
}
}
});
}
private final DocOp opWrapper =
new DiffOpWrapperBase("The document isn't expected to call this method") {
@Override
public void apply(DocOpCursor innerCursor) {
if (!inner.isApplyingToDocument()) {
operation.apply(innerCursor);
return;
}
target = innerCursor;
deleteInfos = CollectionUtils.createIntMap();
currentDeleteInfo = null;
currentDeleteLocation = -1;
currentLocation = 0;
operation.apply(filter);
maybeSavePreviousDeleteInfo();
target = null;
}
@Override
public String toString() {
return "DiffOpWrapper(" + operation + ")";
}
};
private final DocOpCursor filter = new DocOpCursor() {
@Override
public void elementStart(String tagName, Attributes attributes) {
if (diffDepth == 0) {
inner.startLocalAnnotation(DIFF_INSERT_KEY, INSERT_MARKER);
}
diffDepth++;
target.elementStart(tagName, attributes);
currentLocation++;
}
@Override
public void elementEnd() {
target.elementEnd();
currentLocation++;
diffDepth--;
if (diffDepth == 0) {
inner.endLocalAnnotation(DIFF_INSERT_KEY);
}
}
@Override
public void characters(String characters) {
if (diffDepth == 0) {
inner.startLocalAnnotation(DIFF_INSERT_KEY, INSERT_MARKER);
}
target.characters(characters);
currentLocation += characters.length();
if (diffDepth == 0) {
inner.endLocalAnnotation(DIFF_INSERT_KEY);
}
}
private void updateDeleteInfo() {
if (currentLocation != currentDeleteLocation || currentDeleteInfo == null) {
maybeSavePreviousDeleteInfo();
currentDeleteInfo = (DeleteInfo) inner.getAnnotation(currentLocation, DIFF_DELETE_KEY);
if (currentDeleteInfo == null) {
currentDeleteInfo = new DeleteInfo();
}
}
currentDeleteLocation = currentLocation;
}
@Override
public void deleteElementStart(String type, Attributes attrs) {
if (diffDepth == 0 && isOutsideInsertionAnnotation()) {
ContentElement currentElement = (ContentElement) inner.getCurrentNode();
Element e = currentElement.getImplNodelet();
// HACK(danilatos): Line rendering is somewhat special, so special case it
// for now. Once there are more use cases, we can figure out an appropriate
// generalisation for this.
if (LineRendering.isLineElement(currentElement)) {
// This loses paragraph-level formatting, but is better than nothing.
// Indentation and direction inherit from the pervious line, which is
// quite acceptable.
e = Document.get().createBRElement();
}
if (e != null) {
e = e.cloneNode(true).cast();
deletify(e);
updateDeleteInfo();
currentDeleteInfo.htmlElements.add(e);
}
}
diffDepth++;
target.deleteElementStart(type, attrs);
}
@Override
public void deleteElementEnd() {
target.deleteElementEnd();
diffDepth--;
}
private boolean isOutsideInsertionAnnotation() {
int location = currentLocation;
return inner.firstAnnotationChange(location, location + 1, DIFF_INSERT_KEY, null) == -1;
}
private void deletify(Element element) {
if (element == null) {
// NOTE(danilatos): Not handling the case where the content element
// is transparent w.r.t. the rendered view, but has visible children.
return;
}
DiffManager.styleElement(element, DiffType.DELETE);
DomHelper.makeUnselectable(element);
for (Node n = element.getFirstChild(); n != null; n = n.getNextSibling()) {
if (!DomHelper.isTextNode(n)) {
deletify(n.<Element> cast());
}
}
}
@Override
public void deleteCharacters(String text) {
if (diffDepth == 0 && isOutsideInsertionAnnotation()) {
int endLocation = currentLocation + text.length();
updateDeleteInfo();
int scanLocation = currentLocation;
int nextScanLocation;
do {
DeleteInfo surroundedInfo = (DeleteInfo) inner.getAnnotation(scanLocation,
DIFF_DELETE_KEY);
nextScanLocation = inner.firstAnnotationChange(scanLocation, endLocation,
DIFF_DELETE_KEY, surroundedInfo);
if (nextScanLocation == -1) {
nextScanLocation = endLocation;
}
saveDeletedText(text, currentLocation, scanLocation, nextScanLocation);
if (surroundedInfo != null) {
currentDeleteInfo.htmlElements.addAll(surroundedInfo.htmlElements);
}
scanLocation = nextScanLocation;
} while (nextScanLocation < endLocation);
}
target.deleteCharacters(text);
}
@Override
public void annotationBoundary(AnnotationBoundaryMap map) {
target.annotationBoundary(map);
}
@Override
public void replaceAttributes(Attributes oldAttrs, Attributes newAttrs) {
currentLocation++;
target.replaceAttributes(oldAttrs, newAttrs);
}
@Override
public void retain(int itemCount) {
currentLocation += itemCount;
target.retain(itemCount);
}
@Override
public void updateAttributes(AttributesUpdate attrUpdate) {
currentLocation++;
target.updateAttributes(attrUpdate);
}
/**
* Creates text spans reflecting every combination of text formatting annotation values.
*
* @param text text to be saved
* @param textLocation location of the text beginning in the document
* @param startLocation start location of the deleted block
* @param finishLocation finish location of the deleted block
*/
private void saveDeletedText(String text, int textLocation, int startLocation, int finishLocation) {
// TODO(dyukon): This solution supports only text styles (weight, decoration, font etc.)
// which can be applied to text SPANs.
// It's necessary to add support for paragraph styles (headers ordered/numbered lists,
// indents) which cannot be kept in text SPANs.
Iterator<AnnotationInterval<Object>> aiIterator = inner.annotationIntervals(
startLocation, finishLocation, AnnotationConstants.DELETED_STYLE_KEYS).iterator();
if (aiIterator.hasNext()) { // Some annotations are changed throughout deleted text
while (aiIterator.hasNext()) {
AnnotationInterval<Object> ai = aiIterator.next();
createDeleteElement(text.substring(ai.start() - textLocation, ai.end() - textLocation),
ai.annotations());
}
} else { // No annotations are changed throughout deleted text
createDeleteElement(text.substring(startLocation - textLocation, finishLocation - textLocation),
findDeletedStyleAnnotations(startLocation));
}
}
private ReadableStringMap<Object> findDeletedStyleAnnotations(final int location) {
final StringMap<Object> annotations = CollectionUtils.createStringMap();
AnnotationConstants.DELETED_STYLE_KEYS.each(new ReadableStringSet.Proc() {
@Override
public void apply(String key) {
annotations.put(key, inner.getAnnotation(location, key));
}
});
return annotations;
}
private void createDeleteElement(String innerText, ReadableStringMap<Object> annotations) {
Element element = Document.get().createSpanElement();
applyAnnotationsToElement(element, annotations);
DiffManager.styleElement(element, DiffType.DELETE);
element.setInnerText(innerText);
currentDeleteInfo.htmlElements.add(element);
}
private void applyAnnotationsToElement(Element element, ReadableStringMap<Object> annotations) {
final Style style = element.getStyle();
annotations.each(new ReadableStringMap.ProcV<Object>() {
@Override
public void apply(String key, Object value) {
if (value != null && value instanceof String) {
String styleValue = (String) value;
if (!styleValue.isEmpty()) {
style.setProperty(StyleAnnotationHandler.suffix(key), styleValue);
}
}
}
});
}
};
/**
* Save previous delete info - assumes currentDeleteLocation and
* currentDeleteInfo still reflect the previous info.
*/
private void maybeSavePreviousDeleteInfo() {
if (currentDeleteInfo != null) {
deleteInfos.put(currentDeleteLocation, currentDeleteInfo);
}
}
/**
* Remove all diff markup
*/
public void clearDiffs() {
clearDiffs(inner);
}
public static void clearDiffs(MutableAnnotationSet.Local doc) {
clearDiffs((DiffHighlightTarget) doc);
}
public static void clearDiffs(DiffHighlightingFilter.DiffHighlightTarget target) {
// Guards to prevent setting the annotation when there is nothing
// to do, thus saving a repaint
Annotations.guardedResetAnnotation(target, 0, target.size(), DIFF_INSERT_KEY, null);
Annotations.guardedResetAnnotation(target, 0, target.size(), DIFF_DELETE_KEY, null);
}
}