| /** |
| * 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); |
| } |
| } |