/**
* 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.extract;
import com.google.common.annotations.VisibleForTesting;
import com.google.gwt.core.client.Duration;
import com.google.gwt.dom.client.Element;
import com.google.gwt.dom.client.Node;
import com.google.gwt.dom.client.Text;
import com.google.gwt.user.client.Command;
import org.waveprotocol.wave.client.clipboard.AnnotationSerializer;
import org.waveprotocol.wave.client.clipboard.Clipboard;
import org.waveprotocol.wave.client.clipboard.PasteBufferImpl;
import org.waveprotocol.wave.client.common.util.DomHelper;
import org.waveprotocol.wave.client.common.util.UserAgent;
import org.waveprotocol.wave.client.debug.logger.DomLogger;
import org.waveprotocol.wave.client.editor.EditorInstrumentor;
import org.waveprotocol.wave.client.editor.EditorInstrumentor.Action;
import org.waveprotocol.wave.client.editor.content.CMutableDocument;
import org.waveprotocol.wave.client.editor.content.ContentElement;
import org.waveprotocol.wave.client.editor.content.ContentNode;
import org.waveprotocol.wave.client.editor.content.ContentRange;
import org.waveprotocol.wave.client.editor.content.ContentTextNode;
import org.waveprotocol.wave.client.editor.impl.HtmlViewImpl;
import org.waveprotocol.wave.client.editor.selection.content.SelectionHelper;
import org.waveprotocol.wave.client.editor.selection.html.HtmlSelectionHelper;
import org.waveprotocol.wave.client.scheduler.CommandQueue;
import org.waveprotocol.wave.common.logging.LoggerBundle;
import org.waveprotocol.wave.model.document.MutableDocumentImpl;
import org.waveprotocol.wave.model.document.RangedAnnotation;
import org.waveprotocol.wave.model.document.AnnotationBehaviour.BiasDirection;
import org.waveprotocol.wave.model.document.AnnotationBehaviour.ContentType;
import org.waveprotocol.wave.model.document.indexed.LocationMapper;
import org.waveprotocol.wave.model.document.indexed.Validator;
import org.waveprotocol.wave.model.document.operation.Nindo;
import org.waveprotocol.wave.model.document.operation.Nindo.Builder;
import org.waveprotocol.wave.model.document.operation.automaton.DocumentSchema.PermittedCharacters;
import org.waveprotocol.wave.model.document.util.AnnotationRegistry;
import org.waveprotocol.wave.model.document.util.DocHelper;
import org.waveprotocol.wave.model.document.util.FocusedRange;
import org.waveprotocol.wave.model.document.util.Point;
import org.waveprotocol.wave.model.document.util.PointRange;
import org.waveprotocol.wave.model.document.util.ReadableDocumentView;
import org.waveprotocol.wave.model.document.util.XmlStringBuilder;
import org.waveprotocol.wave.model.operation.OperationException;
import org.waveprotocol.wave.model.operation.OperationSequencer;
import org.waveprotocol.wave.model.richtext.RichTextMutationBuilder;
import org.waveprotocol.wave.model.richtext.RichTextTokenizer;
import org.waveprotocol.wave.model.richtext.RichTextTokenizerImpl;
import org.waveprotocol.wave.model.richtext.RichTextTokenizerImplFirefox;
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 java.util.List;
/**
* Extractor to handle paste events
*
* Works by placing the caret in a hidden div, then allowing the paste to happen
* there. We then interpret the html, clean up/adapt as necessary, grab it as
* xml and mutate our document accordingly (and put the selection back)
*
* @author danilatos@google.com (Daniel Danilatos)
* @author davidbyttow@google.com (David Byttow)
*
* TODO(user): Scrollbar should be updated when pasting a lot of text.
*/
public class PasteExtractor {
public static final LoggerBundle LOG = new DomLogger("paste");
private static final Clipboard clipboard = Clipboard.get();
// NOTE(user): Remove this once PasteBuffer functionality is abstracted into clipboard
private static final PasteBufferImpl pasteBuffer = clipboard.getPasteBuffer();
private boolean busy = false;
private final CommandQueue deferredCommands;
private final SelectionHelper aggressiveSelectionHelper;
private final CMutableDocument mutableDocument;
private final OperationSequencer<Nindo> operationSequencer;
private final ReadableDocumentView<ContentNode, ContentElement, ContentTextNode>
renderedContent;
private final ReadableDocumentView<ContentNode, ContentElement, ContentTextNode>
persistentContent;
private static final PasteFormatRenderer PASTE_FORMAT_RENDERER = PasteFormatRenderer.get();
private final SubTreeXmlRenderer<ContentNode, ContentElement, ContentTextNode> subtreeRenderer;
private final PasteAnnotationLogic<ContentNode, ContentElement, ContentTextNode> annotationLogic;
private final Validator validator;
private final EditorInstrumentor instrumentor;
private final boolean useSemanticCopyPaste;
/**
* Constructor.
*
* @param deferredCommands
* @param aggressiveSelectionHelper
* @param mutableDocument
* @param operationSequencer
*/
public PasteExtractor(CommandQueue deferredCommands,
SelectionHelper aggressiveSelectionHelper,
CMutableDocument mutableDocument,
ReadableDocumentView<ContentNode, ContentElement, ContentTextNode> renderedContent,
ReadableDocumentView<ContentNode, ContentElement, ContentTextNode> persistentContent,
AnnotationRegistry annotationRegistry,
OperationSequencer<Nindo> operationSequencer,
Validator validator,
EditorInstrumentor instrumentor,
boolean useSemanticCopyPaste) {
this.deferredCommands = deferredCommands;
this.aggressiveSelectionHelper = aggressiveSelectionHelper;
this.mutableDocument = mutableDocument;
this.operationSequencer = operationSequencer;
this.renderedContent = renderedContent;
this.persistentContent = persistentContent;
this.validator = validator;
this.subtreeRenderer =
new SubTreeXmlRenderer<ContentNode, ContentElement, ContentTextNode>(mutableDocument);
this.annotationLogic =
new PasteAnnotationLogic<ContentNode, ContentElement, ContentTextNode>(
mutableDocument, annotationRegistry);
this.instrumentor = instrumentor;
this.useSemanticCopyPaste = useSemanticCopyPaste;
}
/**
* Handler for the browser's paste event
*
* @param cursorBias current bias direction of the cursor
* @return true to cancel browser's default, false otherwise. Generally, we'd
* only cancel if we cannot paste, i.e. selection not known
*/
public boolean handlePasteEvent(final BiasDirection cursorBias) {
// TODO(danilatos): Handle non-collapsed ranges
final ContentRange previousSelection = aggressiveSelectionHelper.getOrderedSelectionPoints();
// Selection shouldn't be null here, but its unsafe to make that assumption.
// Cancel paste if we don't have selection.
if (previousSelection == null) {
return true;
}
busy = true;
pasteBuffer.prepareForPaste();
deferredCommands.addCommand(new Command() {
public void execute() {
extract(pasteBuffer.getPasteContainer(), previousSelection, cursorBias);
busy = false;
}
});
return false;
}
/**
* @return true if we are in the middle of doing something
*/
public boolean isBusy() {
return busy;
}
@VisibleForTesting // For testing with p/line container.
void extract(Element srcContainer, ContentRange previousSelection, BiasDirection cursorBias) {
final CMutableDocument destDoc = mutableDocument;
final OperationSequencer<Nindo> destOperationSequencer =
operationSequencer;
final LocationMapper<ContentNode> mapper = mutableDocument;
Point<ContentNode> start = normalize(previousSelection.getFirst());
Point<ContentNode> end = normalize(previousSelection.getSecond());
// Delete content if a range was selected
if (!previousSelection.isCollapsed()) {
PointRange<ContentNode> range = destDoc.deleteRange(start, end);
start = range.getFirst();
end = range.getSecond();
}
Point<ContentNode> insertAt = end;
int pos = mapper.getLocation(insertAt);
String waveXml = null;
String annotations = null;
if (useSemanticCopyPaste) {
waveXml = clipboard.maybeGetWaveXml(srcContainer);
annotations = clipboard.maybeGetAnnotations(srcContainer);
}
// TODO(user): Pass in whether the pasted content is rich or play
// TODO(patcoleman): once we have non rich-text paste, fix cursor bias correctly
cursorBias = BiasDirection.LEFT;
if (useSemanticCopyPaste && waveXml != null) {
if (!waveXml.isEmpty()) {
instrumentor.record(Action.CLIPBOARD_PASTE_FROM_WAVE);
// initialise the XML:
Builder builder = at(pos);
XmlStringBuilder createdFromXmlString =
XmlStringBuilder.createFromXmlStringWithContraints(waveXml,
PermittedCharacters.BLIP_TEXT);
// Strip annotations based on behaviour:
StringMap<String> modified = annotationLogic.stripKeys(
destDoc, pos, cursorBias, ContentType.RICH_TEXT, builder);
double startTime = Duration.currentTimeMillis();
// apply xml change
MutableDocumentImpl.appendXmlToBuilder(createdFromXmlString, builder);
double timeTaken = Duration.currentTimeMillis() - startTime;
LOG.trace().log("time taken: " + timeTaken);
// handle the end of annotations
annotationLogic.unstripKeys(builder, modified.keySet(), CollectionUtils.createStringSet());
builder.finish();
Nindo nindo = builder.build();
try {
validator.maybeThrowOperationExceptionFor(nindo);
int locationAfter = destDoc.getLocation(insertAt) + createdFromXmlString.getLength();
destOperationSequencer.begin();
destOperationSequencer.consume(nindo);
destOperationSequencer.end();
aggressiveSelectionHelper.setCaret(locationAfter);
LOG.trace().log("annotations: " + String.valueOf(annotations));
if (annotations != null && !annotations.isEmpty()) {
List<RangedAnnotation<String>> deserialize =
AnnotationSerializer.deserialize(annotations);
for (RangedAnnotation<String> ann : deserialize) {
destDoc.setAnnotation(pos + ann.start(), pos + ann.end(), ann.key(), ann.value());
LOG.trace().log(
"pos: " + pos + "start: " + (pos + ann.start()) + " end: " + (pos + ann.end())
+ " key: " + ann.key() + " value: " + ann.value());
}
}
} catch (OperationException e) {
LOG.error().log("Semantic paste failed");
// Restore caret
aggressiveSelectionHelper.setCaret(insertAt);
}
}
} else {
instrumentor.record(Action.CLIPBOARD_PASTE_FROM_OUTSIDE);
// initialize tokenizer and builder
RichTextTokenizer tokenizer = createTokenizer(srcContainer);
Builder builder = at(pos);
// handle annotation starts
StringMap<String> modified = annotationLogic.stripKeys(
destDoc, pos, cursorBias, ContentType.RICH_TEXT, builder);
// parse the tokens and apply ops
RichTextMutationBuilder mutationBuilder = new RichTextMutationBuilder(modified);
ReadableStringSet affectedKeys =
mutationBuilder.applyMutations(tokenizer, builder, destDoc, insertAt.getContainer());
// close annotations and finish
annotationLogic.unstripKeys(builder, modified.keySet(), affectedKeys);
builder.finish();
Nindo nindo = builder.build();
try {
validator.maybeThrowOperationExceptionFor(nindo);
destOperationSequencer.begin();
destOperationSequencer.consume(nindo);
destOperationSequencer.end();
int cursorLocation = pos + mutationBuilder.getLastGoodCursorOffset();
Point<ContentNode> caret = mapper.locate(cursorLocation);
aggressiveSelectionHelper.setCaret(caret);
} catch (OperationException e) {
LOG.error().log("Paste failed");
aggressiveSelectionHelper.setCaret(insertAt);
}
}
srcContainer.setInnerHTML("");
// Restore focus back to the editor
// TODO(user): Write a webdriver to test selection is correct after paste.
DomHelper.focus(destDoc.getDocumentElement().getContainerNodelet());
}
private Nindo.Builder at(int pos) {
Nindo.Builder builder = new Nindo.Builder();
builder.begin();
builder.skip(pos);
return builder;
}
/**
* Handler for the browser's copy or cut event. The current idea is to copy
* the selected content to an offscreen div, move the browser selection to the
* content within that copy and then allow the action to happen. Afterwards,
* if it was a cut, fire off the proper delete event for the original
* selection.
*
* TODO(user): Move this into its own class.
* TODO(user): Add unit tests.
*
* @return true to cancel if the event should be cancelled.
*/
public boolean handleCopyOrCutEvent(HtmlSelectionHelper selectionHelper, final boolean isCut) {
// First gather the selection.
final ContentRange contentSelection =
aggressiveSelectionHelper.getOrderedSelectionPoints();
if (contentSelection == null) {
return true;
}
final FocusedRange selection = aggressiveSelectionHelper.getSelectionRange();
performCopyOrCut(selectionHelper, pasteBuffer.getContainer(), contentSelection, isCut);
busy = true;
deferredCommands.addCommand(new Command() {
public void execute() {
if (isCut) {
aggressiveSelectionHelper.setCaret(selection.asRange().getStart());
} else {
aggressiveSelectionHelper.setSelectionRange(selection);
}
busy = false;
}
});
return false;
}
protected void performCopyOrCut(
HtmlSelectionHelper selectionHelper, Element srcContainer, ContentRange contentSelection,
boolean isCut) {
if (contentSelection.isCollapsed()) {
// Nothing to do.
return;
}
Point<ContentNode> first = contentSelection.getFirst();
Point<ContentNode> second = contentSelection.getSecond();
SelectionMatcher selectionMatcher =
new SelectionMatcher(first, second);
ContentNode commonAncestor =
DocHelper.nearestCommonAncestor(renderedContent, first
.getContainer(), second.getContainer());
Node fragment = PASTE_FORMAT_RENDERER.renderTree(renderedContent, commonAncestor,
selectionMatcher);
Preconditions.checkNotNull(selectionMatcher.getHtmlStart(), "html start is null, first: " + first);
Preconditions.checkNotNull(selectionMatcher.getHtmlEnd(), "html end is null second: " + second);
assert fragment.isOrHasChild(selectionMatcher.getHtmlStart().getContainer()) :
"SelectionMatcher start not attached";
assert fragment.isOrHasChild(selectionMatcher.getHtmlEnd().getContainer()) :
"SelectionMatcher end not attached";
PointRange<Node> newRange = new PointRange<Node>(
selectionMatcher.getHtmlStart(),
selectionMatcher.getHtmlEnd());
Point<ContentNode> normalizedStart = normalize(contentSelection.getFirst());
Point<ContentNode> normalizedEnd = normalize(contentSelection.getSecond());
final XmlStringBuilder xmlInRange;
final List<RangedAnnotation<String>> normalizedAnnotations;
if (useSemanticCopyPaste) {
String debugString =
"Start: " + contentSelection.getFirst() + " End: " + contentSelection.getSecond()
+ " docDebug: " + mutableDocument.toDebugString();
try {
xmlInRange =
subtreeRenderer.renderRange(normalizedStart, normalizedEnd);
normalizedAnnotations =
annotationLogic.extractNormalizedAnnotation(normalizedStart, normalizedEnd);
} catch (Exception e) {
LOG.error().logPlainText(debugString);
throw new RuntimeException(e);
}
} else {
xmlInRange = null;
normalizedAnnotations = null;
}
if (isCut) {
// Delete the originally selected content.
mutableDocument.deleteRange(normalizedStart, normalizedEnd).getFirst();
}
// Set the browser's selection to the hidden div for the copy/cut.
clipboard
.fillBufferAndSetSelection(fragment, newRange, xmlInRange, normalizedAnnotations, false);
}
/**
* Normalize point with respect to the mutable doc.
* @param p
*/
Point<ContentNode> normalize(Point<ContentNode> p) {
return DocHelper.getFilteredPoint(persistentContent, p);
}
/**
* Handle copy event.
* @see #handleCopyOrCutEvent(HtmlSelectionHelper, boolean)
*
* @param selectionHelper
*/
public boolean handleCopyEvent(HtmlSelectionHelper selectionHelper) {
instrumentor.record(Action.CLIPBOARD_COPY);
return handleCopyOrCutEvent(selectionHelper, false);
}
/**
* Handle cut event.
* @see #handleCopyOrCutEvent(HtmlSelectionHelper, boolean)
*
* @param selectionHelper
*/
public boolean handleCutEvent(HtmlSelectionHelper selectionHelper) {
instrumentor.record(Action.CLIPBOARD_CUT);
return handleCopyOrCutEvent(selectionHelper, true);
}
/** Utility for creating tokenizer based on UA. */
private RichTextTokenizer createTokenizer(Element container) {
if (UserAgent.isFirefox()) {
return new RichTextTokenizerImplFirefox<Node, Element, Text>(new HtmlViewImpl(container));
} else {
return new RichTextTokenizerImpl<Node, Element, Text>(new HtmlViewImpl(container));
}
}
}