/**
* 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;
import com.google.common.annotations.VisibleForTesting;
import com.google.gwt.core.client.Duration;
import com.google.gwt.core.client.GWT;
import com.google.gwt.core.client.JavaScriptException;
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.StyleInjector;
import com.google.gwt.dom.client.Text;
import com.google.gwt.event.dom.client.KeyCodes;
import com.google.gwt.resources.client.ClientBundle;
import com.google.gwt.resources.client.CssResource;
import com.google.gwt.resources.client.CssResource.NotStrict;
import com.google.gwt.user.client.Event;
import com.google.gwt.user.client.ui.Widget;
import com.google.gwt.user.client.ui.impl.FocusImpl;
import org.waveprotocol.wave.client.common.util.ClientDebugException;
import org.waveprotocol.wave.client.common.util.DomHelper;
import org.waveprotocol.wave.client.common.util.DomHelper.HandlerReference;
import org.waveprotocol.wave.client.common.util.DomHelper.JavaScriptEventListener;
import org.waveprotocol.wave.client.common.util.EventWrapper;
import org.waveprotocol.wave.client.common.util.KeyCombo;
import org.waveprotocol.wave.client.common.util.KeySignalListener;
import org.waveprotocol.wave.client.common.util.LogicalPanel;
import org.waveprotocol.wave.client.common.util.QuirksConstants;
import org.waveprotocol.wave.client.common.util.SignalEvent;
import org.waveprotocol.wave.client.common.util.SignalEvent.KeyModifier;
import org.waveprotocol.wave.client.common.util.SignalEvent.KeySignalType;
import org.waveprotocol.wave.client.common.util.SignalEventImpl;
import org.waveprotocol.wave.client.common.util.UserAgent;
import org.waveprotocol.wave.client.debug.logger.LogLevel;
import org.waveprotocol.wave.client.editor.EditorInstrumentor.Action;
import org.waveprotocol.wave.client.editor.EditorInstrumentor.TimedAction;
import org.waveprotocol.wave.client.editor.Responsibility.Manager;
import org.waveprotocol.wave.client.editor.constants.BrowserEvents;
import org.waveprotocol.wave.client.editor.content.AnnotationPainter;
import org.waveprotocol.wave.client.editor.content.CMutableDocument;
import org.waveprotocol.wave.client.editor.content.ContentDocument;
import org.waveprotocol.wave.client.editor.content.ContentDocument.Level;
import org.waveprotocol.wave.client.editor.content.ContentDocument.LocalOperationException;
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.content.ContentView;
import org.waveprotocol.wave.client.editor.content.FocusedContentRange;
import org.waveprotocol.wave.client.editor.content.NodeEventRouter;
import org.waveprotocol.wave.client.editor.content.Registries;
import org.waveprotocol.wave.client.editor.content.Renderer;
import org.waveprotocol.wave.client.editor.content.misc.CaretAnnotations;
import org.waveprotocol.wave.client.editor.content.misc.CaretAnnotations.AnnotationResolver;
import org.waveprotocol.wave.client.editor.content.misc.DisplayEditModeHandler;
import org.waveprotocol.wave.client.editor.content.misc.StyleAnnotationHandler;
import org.waveprotocol.wave.client.editor.content.paragraph.Line;
import org.waveprotocol.wave.client.editor.content.paragraph.Paragraph;
import org.waveprotocol.wave.client.editor.debug.DebugPopupFactory;
import org.waveprotocol.wave.client.editor.event.EditorEvent;
import org.waveprotocol.wave.client.editor.event.EditorEventHandler;
import org.waveprotocol.wave.client.editor.event.EditorEventImpl;
import org.waveprotocol.wave.client.editor.event.EditorEventsSubHandler;
import org.waveprotocol.wave.client.editor.event.EditorInteractor;
import org.waveprotocol.wave.client.editor.extract.DomMutationReverter;
import org.waveprotocol.wave.client.editor.extract.DomMutationReverter.RevertListener;
import org.waveprotocol.wave.client.editor.extract.ImeExtractor;
import org.waveprotocol.wave.client.editor.extract.InconsistencyException.HtmlInserted;
import org.waveprotocol.wave.client.editor.extract.InconsistencyException.HtmlMissing;
import org.waveprotocol.wave.client.editor.extract.PasteExtractor;
import org.waveprotocol.wave.client.editor.extract.RepairListener;
import org.waveprotocol.wave.client.editor.extract.Repairer;
import org.waveprotocol.wave.client.editor.extract.TypingExtractor;
import org.waveprotocol.wave.client.editor.extract.TypingExtractor.SelectionSource;
import org.waveprotocol.wave.client.editor.extract.TypingExtractor.TypingSink;
import org.waveprotocol.wave.client.editor.impl.NodeManager;
import org.waveprotocol.wave.client.editor.keys.KeyBindingRegistry;
import org.waveprotocol.wave.client.editor.selection.content.AggressiveSelectionHelper;
import org.waveprotocol.wave.client.editor.selection.content.CaretMovementHelper;
import org.waveprotocol.wave.client.editor.selection.content.CaretMovementHelperImpl;
import org.waveprotocol.wave.client.editor.selection.content.CaretMovementHelperWebkitImpl;
import org.waveprotocol.wave.client.editor.selection.content.PassiveSelectionHelper;
import org.waveprotocol.wave.client.editor.selection.content.SelectionHelper;
import org.waveprotocol.wave.client.editor.selection.content.SelectionUtil;
import org.waveprotocol.wave.client.editor.selection.html.HtmlSelectionHelper;
import org.waveprotocol.wave.client.editor.selection.html.NativeSelectionUtil;
import org.waveprotocol.wave.client.editor.sugg.InteractiveSuggestionsManager;
import org.waveprotocol.wave.client.editor.sugg.SuggestionsManager;
import org.waveprotocol.wave.client.editor.util.AnnotationBehaviourLogic;
import org.waveprotocol.wave.client.editor.webdriver.EditorJsniHelpers;
import org.waveprotocol.wave.client.editor.webdriver.EditorWebDriverUtil;
import org.waveprotocol.wave.client.scheduler.CommandQueue;
import org.waveprotocol.wave.client.scheduler.ScheduleCommand;
import org.waveprotocol.wave.client.scheduler.Scheduler;
import org.waveprotocol.wave.client.scheduler.Scheduler.Task;
import org.waveprotocol.wave.client.scheduler.SchedulerInstance;
import org.waveprotocol.wave.client.widget.popup.UniversalPopup;
import org.waveprotocol.wave.model.document.AnnotationBehaviour.BiasDirection;
import org.waveprotocol.wave.model.document.AnnotationBehaviour.ContentType;
import org.waveprotocol.wave.model.document.AnnotationBehaviour.CursorDirection;
import org.waveprotocol.wave.model.document.ReadableDocument;
import org.waveprotocol.wave.model.document.indexed.LocationMapper;
import org.waveprotocol.wave.model.document.operation.DocInitialization;
import org.waveprotocol.wave.model.document.operation.DocOp;
import org.waveprotocol.wave.model.document.operation.Nindo;
import org.waveprotocol.wave.model.document.operation.automaton.DocumentSchema;
import org.waveprotocol.wave.model.document.operation.automaton.DocumentSchema.PermittedCharacters;
import org.waveprotocol.wave.model.document.util.Annotations;
import org.waveprotocol.wave.model.document.util.DocHelper;
import org.waveprotocol.wave.model.document.util.FocusedPointRange;
import org.waveprotocol.wave.model.document.util.FocusedRange;
import org.waveprotocol.wave.model.document.util.Point;
import org.waveprotocol.wave.model.document.util.Point.El;
import org.waveprotocol.wave.model.document.util.PointRange;
import org.waveprotocol.wave.model.document.util.Range;
import org.waveprotocol.wave.model.document.util.RangeTracker;
import org.waveprotocol.wave.model.operation.OperationRuntimeException;
import org.waveprotocol.wave.model.operation.OperationSequencer;
import org.waveprotocol.wave.model.operation.SilentOperationSink;
import org.waveprotocol.wave.model.undo.UndoManagerFactory;
import org.waveprotocol.wave.model.util.CollectionUtils;
import org.waveprotocol.wave.model.util.CopyOnWriteSet;
import org.waveprotocol.wave.model.util.IdentityMap;
import org.waveprotocol.wave.model.util.IdentitySet;
import org.waveprotocol.wave.model.util.Preconditions;
import org.waveprotocol.wave.model.util.ReadableIdentitySet.Proc;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Queue;
import java.util.Set;
/**
* The DOM structure for an editor is as follows:
*
* <pre>
* <div class="editor" contentEditable="..."> unselectable="on"
* <div contentEditable="..."> unselectable="on">
* (this is the 'pre' decorator element, and the editor doesn't care what goes in here)
* </div>
* <div>(editable content)</div>
* </div>
* </pre>
*
* The decorator is required for adding inline decorations on IE7 (because a contentEditable
* region must be rectangular, so decorations must be included in that region).
* The editor contains logic to ensure that, even though the editable region includes a
* decorator, editing actions (including making selections) are restricted to the document.
*
* @author danilatos@google.com (Daniel Danilatos)
*/
// (and the above again in ASCII)
// <div class="editor" contentEditable="..." unselectable="on">
// <div contentEditable="false" unselectable="on">
// (this is the decorator element, and the editor doesn't care what goes in here)
// </div>
// <div>(editable content)</div>
// </div>
//
public class EditorImpl extends LogicalPanel.Impl implements
Editor, LogicalPanel, HtmlSelectionHelper, JavaScriptEventListener {
/** CSS class applied to editor document's top level html element when in edit mode */
public static final String WAVE_EDITOR_EDIT_ON = "wave-editor-on";
/** CSS class applied to editor document's top level html element when not in edit mode */
public static final String WAVE_EDITOR_EDIT_OFF = "wave-editor-off";
/**
* Minimal set of dependencies on the editor for ContentDocument
* (mostly the fault of various doodads).
*/
public interface MiniBundle {
/***/
TypingExtractor getTypingExtractor();
/***/
boolean inEditMode();
/** Spell suggestions */
SuggestionsManager getSuggestionsManager();
/***/
PassiveSelectionHelper getPassiveSelectionHelper();
/** ContentDocument updates this registry of "elements with display modes" :( */
CopyOnWriteSet<ContentElement> getElementsWithDisplayModes();
RepairListener getRepairListener();
EditorContext getEditorContext();
/**
* Brings the editor into a consistent state, possibly asynchronously.
*
* "Consistent" means that the editor's document state includes the effects of
* all browser-events before now, and that that state is consistent with the
* operations that this editor has pushed out to the outgoing operation stream
* (i.e., no operations are buffered).
*
* This should be called immediately prior to applying operations to the
* document, i.e.
* {@link ContentDocument#consume(org.waveprotocol.wave.model.document.operation.DocOp)}
*
* NOTE(danilatos): While this method is re-entrant, if it returns false,
* there is not much point calling it again until the continuation command has
* been executed.
*
* @param resume if the editor's document is not in a consistent state, a
* callback to fire as soon as consistency is reached.
* @return true if the editor's document is in a consistent state, false
* otherwise (note: {@code resume} is not called if this method
* returns true).
*/
boolean flush(Runnable resume);
/**
* Notifies the editor that an external source (i.e. incoming op) has applied
* a DocOp to the document.
*
* @param op
*/
void onIncomingOp(DocOp op);
}
@VisibleForTesting final MiniBundle editorPackage = new MiniBundle() {
/** {@inheritDoc} */
public TypingExtractor getTypingExtractor() {
return typing;
}
/** {@inheritDoc} */
public boolean inEditMode() {
return EditorImpl.this.isEditing();
}
/** {@inheritDoc} */
public SuggestionsManager getSuggestionsManager() {
return suggestionsManager;
}
/** {@inheritDoc} */
public PassiveSelectionHelper getPassiveSelectionHelper() {
return passiveSelectionHelper;
}
@Override
public CopyOnWriteSet<ContentElement> getElementsWithDisplayModes() {
return elementsWithDisplayEditModes;
}
@Override
public RepairListener getRepairListener() {
return repairListener;
}
@Override
public EditorContext getEditorContext() {
return EditorImpl.this;
}
@Override
public boolean flush(Runnable resume) {
return EditorImpl.this.flush(resume);
}
@Override
public void onIncomingOp(DocOp op) {
EditorImpl.this.onIncomingOp(op);
}
};
/**
* Resources used in the Editor.
*/
public interface Resources extends ClientBundle {
/** Css resource */
// TODO(danilatos): extends CssResource to get obfuscated class name
// TODO(danilatos): factor our CSS into per-widget bundles (e.g., image thumbnail, paragraph)
@Source("Editor.css")
@NotStrict // TODO(danilatos): make Strict by including all classes in the CssResource
CssResource css();
}
/**
* Singleton instance of resource bundle
*/
private static final CssResource css = GWT.<Resources>create(Resources.class).css();
/**
* Attribute used to mark the editable document element.
*/
static final String EDITABLE_DOC_MARKER = "editableDocMarker";
/**
* Shorthand for the main div of the editor
*/
private final Element div;
/**
* List of ContentElements that needs to be notified when we change mode
*/
private final CopyOnWriteSet<ContentElement> elementsWithDisplayEditModes
= CopyOnWriteSet.create();
/**
* Node manager
*/
private NodeManager nodeManager;
private CaretMovementHelper caretMoveHelper;
private HtmlSelectionHelper htmlSelectionHelper;
/**
* Always use this unless you know what you are doing
*/
private PassiveSelectionHelper passiveSelectionHelper;
/**
* Use this to possibly correct problems in the document (side effect of
* generating operations) whilst getting the selection. If in doubt,
* use the passive selection helper.
*/
private AggressiveSelectionHelper aggressiveSelectionHelper;
/**
* Editor's content document, including doc div
*/
protected ContentDocument content;
/**
* Configuration
*/
private EditorSettings settings = EditorSettings.DEFAULT;
private SilentOperationSink<? super DocOp> innerOutputSink;
/**
* Permits sending out operations, and receiving local operations.
* Currently useful for debugging purposes only.
*/
private boolean permitOperations = true;
/** Buffer of outgoing ops, for when we suppress sending them. */
List<DocOp> suppressedOutgoingOps;
/**
* Sink for outgoing operations
*
* TODO(mtsui/danilatos): Move this out of the editor as well.
*/
private final SilentOperationSink<DocOp> outgoingOperationSink =
new SilentOperationSink<DocOp>() {
public void consume(DocOp op) {
try {
if (permitOperations) {
innerOutputSink.consume(op);
} else {
suppressedOutgoingOps.add(op);
}
if (responsibility.withinDirectSequence()) {
editorUndoManager.undoableOp(op);
} else {
editorUndoManager.nonUndoableOp(op);
}
// And schedule notification update
// NOTE(danilatos): We might want the coord updated parameter to be true,
// but see the discussion in #execute(op) for details...
scheduleUpdateNotification(false, false, true, responsibility.withinDirectSequence());
} catch (Exception e) {
if (innerOutputSink == null) {
EditorStaticDeps.logger.fatal().logPlainText("Output sink is null",
new ClientDebugException("", e));
} else {
EditorStaticDeps.logger.fatal().logPlainText("Output sink threw exception", e);
}
}
}
};
/**
* A command-queue for commands that need to run when the editor is in a
* consistent state. The addition of commands to this queue triggers a
* deferred command to poll the consistency state and executing queued
* commands until the queue is empty.
*/
private class ConsistentStateCommandRunner implements Scheduler.Task {
/** Queued commands, FIFO. */
private Queue<Runnable> commands = CollectionUtils.createQueue();
private ConsistentStateCommandRunner() {
}
public void schedule(Runnable c) {
commands.add(c);
if (permitOperations) { // Prevent constant rescheduling
ScheduleCommand.addCommand(this);
}
}
@Override
public void execute() {
Queue<Runnable> backup = commands;
commands = CollectionUtils.createQueue();
while (isConsistent() && !backup.isEmpty()) {
backup.poll().run();
}
// Move any unexecuted commands back into the main queue, preserving any
// commands that were scheduled during the above execution.
backup.addAll(commands);
// Restore the copy
commands = backup;
if (!commands.isEmpty()) {
if (permitOperations) { // Prevent constant rescheduling
ScheduleCommand.addCommand(this);
}
}
}
}
/**
* Deferred command for checking if we can resume incoming operations
*/
private final ConsistentStateCommandRunner consistencyQueue = new ConsistentStateCommandRunner();
/**
* Handler for the results of typing
*/
TypingSink typingSink = new TypingSink() {
@Override
public void aboutToFlush() {
domMutationReverter.flush();
// Some errors here aren't fatal, we can probably just ignore them, but
// let's log them because they were unexpected, and might imply other problems...
if (webkitEndOfLinkHackTextNode != null) {
EditorStaticDeps.startIgnoreMutations();
try {
Point<Node> caret = getHtmlSelection().getFocus();
if (caret.isInTextNode()) {
Text hackedTextNode = caret.getContainer().cast();
Node link = hackedTextNode.getPreviousSibling();
while (link != null && DomHelper.isTextNode(link)) {
link = link.getPreviousSibling();
}
if (link == null) {
EditorStaticDeps.logger.error().log("No link before the link hack?");
return;
}
Element anchor = link.cast();
if (!anchor.getTagName().equalsIgnoreCase("a")) {
EditorStaticDeps.logger.error().log(
"Some other element before the link hack? (" + anchor.getTagName() + ")");
// Don't return, we still want to move the text nodes...
}
Node nodeAfter;
if (caret.getTextOffset() == hackedTextNode.getLength()) {
nodeAfter = hackedTextNode.getNextSibling();
} else {
nodeAfter = hackedTextNode.splitText(caret.getTextOffset());
}
DomHelper.moveNodes(anchor, anchor.getNextSibling(), nodeAfter, null);
NativeSelectionUtil.setCaret(Point.<Node>inText(hackedTextNode, hackedTextNode.getLength()));
// TODO: Set the annotation caret to turn the link off.
} else {
EditorStaticDeps.logger.error().log("End of link hack caret not in text node!?");
}
} finally {
webkitEndOfLinkHackTextNode = null;
EditorStaticDeps.endIgnoreMutations();
}
}
}
@Override
public void typingReplace(Point<ContentNode> start, int deletionSize, String text,
RestrictedRange<ContentNode> range) {
int location = mapper().getLocation(start);
Nindo op = generateReplaceTextOp(location, deletionSize, text);
try {
applyRepairingOperation(op);
typingFinished(range);
} catch (LocalOperationException e) {
EditorStaticDeps.logger.error().log(
"Swallowing Error: Invalid operation sent to TypingSink (" + e + ")");
}
currentSelectionBias = BiasDirection.LEFT;
}
void typingFinished(RestrictedRange<ContentNode> range) {
FocusedPointRange<Node> selection = getHtmlSelection();
boolean selectionAffected =
repairer.zipRange(range, selection == null ? null : selection.getFocus().getContainer());
if (selectionAffected) {
assert selection != null && selection.getFocus() != null;
Node n = selection.getFocus().getContainer();
// Find selection in fragmented text nodes. What if we can't find it?
Node newTextNode = n;
// NOTE(user): Ideally, the selection here should always be inside a
// text node. However, since we are just calling getHtmlSelection here,
// there is no obvious guarantee. In particular, when tracing
// TypingExtractor.flush() it may be called inside a deferred command,
// which means the selection can be anywhere.
// TODO(user): Refactor this code to make it more explicit. Consider
// passing in the selection rather than calling getHtmlSelection.
if (LogLevel.showErrors() && !selection.getFocus().isInTextNode()) {
EditorStaticDeps.logger.error().log(
"selection while typing is expected to be in text node, but is not ",
selection.getFocus());
} else {
int newTextOffset = selection.getFocus().getTextOffset();
while (DomHelper.isTextNode(n) && n.<Text> cast().getLength() < newTextOffset) {
newTextOffset -= n.<Text> cast().getLength();
newTextNode = n.getNextSibling();
}
NativeSelectionUtil.setCaret(Point.inText(newTextNode, newTextOffset));
}
}
}
/**
* Apply an operation only to the content wrapper dom. The usual use case is
* that the html has been affected in a certain way already, and we are
* matching that in the content with a series of operations. Hence we only
* apply the operation to the wrapper dom.
*
* @param op
*/
private void applyRepairingOperation(Nindo op) {
responsibility.startDirectSequence();
try {
content.sourceNindoWithoutModifyingHtml(op);
} finally {
responsibility.endDirectSequence();
}
}
};
/**
* A typing operation extractor for the editor
*/
protected TypingExtractor typing;
/**
* IME composition extractor helper
*/
private final ImeExtractor imeExtractor = new ImeExtractor();
/**
* Our paste extractor
*/
private PasteExtractor pasteExtractor;
/**
* The thing that makes the content match the html or vice versa
*/
private Repairer repairer;
/**
* Flag if editor is editing or displaying
*/
protected boolean editing = false;
/**
* Keeps track of what styles are to be applied to the collapsed caret.
*/
private CaretAnnotations caretStyles;
/**
* Keeps track of what the cursor's current bias is.
*/
private BiasDirection currentSelectionBias = BiasDirection.LEFT;
/**
* My keyboard listeners
*/
protected Set<KeySignalListener> keySignalListeners;
/**
* Registries for everything
*/
protected Registries registries;
/**
* Our friendly equally friendly suggestion manager
*/
protected SuggestionsManager suggestionsManager = null;
private DomMutationReverter domMutationReverter;
/** Stores all known key -> action bindings. */
private KeyBindingRegistry keyBindings = KeyBindingRegistry.NONE;
/**
* Application specific editor event handler.
*/
private final EditorEventsSubHandler eventsSubHandler = new EditorEventsSubHandlerImpl();
/** Application specific annotation logic manager. */
AnnotationBehaviourLogic<ContentNode> annotationLogic = null;
private final EditorUpdateEventImpl updateEvent = new EditorUpdateEventImpl(this);
/** stored scrollTops used by maybeSave/RestoreAncestorScrollPositions */
private IdentityMap<Element, Integer> ancestorScrollTops;
// TODO(user): This class can be broken down further. Things like paste/dom
// mutation extraction are not really application specific and can be handled
// in a separate class.
private class EditorEventsSubHandlerImpl implements EditorEventsSubHandler {
@Override
public boolean handleBlockLevelCommands(EditorEvent event, ContentRange selection) {
Point<ContentNode> start = selection.getFirst();
Point<ContentNode> end = selection.getSecond();
// Shonky key combos until toolbar
if (KeyModifier.CTRL.check(event)) {
int startLoc = mapper().getLocation(start);
int endLoc = mapper().getLocation(end);
int num = event.getKeyCode() - '1' + 1;
if (num >= 1 && num <= 6) {
if (num == 5) {
final String listStyle;
Line l = Paragraph.getFirstLine(mapper(), mapper().getLocation(start));
if (Paragraph.LIST_TYPE.equals(l.getAttribute(Paragraph.SUBTYPE_ATTR)) &&
!Paragraph.LIST_STYLE_DECIMAL.equals(l.getAttribute(Paragraph.LIST_STYLE_ATTR))) {
listStyle = Paragraph.LIST_STYLE_DECIMAL;
} else {
listStyle = null; // default style
}
Paragraph.apply(mapper(), startLoc, endLoc, Paragraph.listStyle(listStyle), true);
} else {
final String type;
if (num == 6) {
type = null;
} else {
type = "h" + num;
}
Paragraph.toggle(mapper(), startLoc, endLoc, Paragraph.regularStyle(type));
}
settings.getInstrumentor().record(Action.SHORTCUT_HEADINGSTYLE);
return true;
} else if (num == 7) {
Paragraph.apply(mapper(), startLoc, endLoc, Paragraph.Alignment.LEFT, true);
Paragraph.apply(mapper(), startLoc, endLoc, Paragraph.Direction.LTR, true);
settings.getInstrumentor().record(Action.SHORTCUT_ALIGNMENT);
return true;
} else if (num == 8) {
Paragraph.apply(mapper(), startLoc, endLoc, Paragraph.Alignment.RIGHT, true);
Paragraph.apply(mapper(), startLoc, endLoc, Paragraph.Direction.RTL, true);
settings.getInstrumentor().record(Action.SHORTCUT_ALIGNMENT);
return true;
}
} else if (event.getKeyCode() == KeyCodes.KEY_TAB) {
handleTab(start, end, event.getShiftKey());
return true;
}
return false;
}
@Override
public boolean handleRangeKeyCombo(EditorEvent event, ContentRange selection) {
assert !selection.isCollapsed();
KeyCombo combo = EventWrapper.getKeyCombo(event.asEvent());
// TODO(patcoleman): separate collapsed and normal, maybe also incorporate handled flag.
if (keyBindings.hasAction(combo)) {
keyBindings.getAction(combo).execute(EditorImpl.this);
return true;
}
Point<ContentNode> start = selection.getFirst();
Point<ContentNode> end = selection.getSecond();
switch (combo) {
// NOTE(user): Ideally, these should be changed to ORDER_B, ORDER_I,
// ORDER_U. However, Safari on OSX does not emit any key events for
// those combos, so we're allowing both on mac.
// Also EventWrapper is broken at the moment, so registering ORDER_B
// actually overrides CTRL_B
case CTRL_B:
case ORDER_B:
doStyle(start, end, "fontWeight", "bold");
settings.getInstrumentor().record(Action.SHORTCUT_BOLD);
return true;
case CTRL_I:
case ORDER_I:
doStyle(start, end, "fontStyle", "italic");
settings.getInstrumentor().record(Action.SHORTCUT_ITALIC);
return true;
case CTRL_U:
case ORDER_U:
doStyle(start, end, "textDecoration", "underline");
settings.getInstrumentor().record(Action.SHORTCUT_UNDERLINE);
return true;
}
return false;
}
@Override
public boolean handleCollapsedKeyCombo(EditorEvent event, Point<ContentNode> caret) {
// Handle events with carets
// TODO(user): handle lots more events here
KeyCombo combo = EventWrapper.getKeyCombo(event.asEvent());
// TODO(patcoleman): separate collapsed and normal, maybe also incorporate handled flag.
if (keyBindings.hasAction(combo)) {
keyBindings.getAction(combo).execute(EditorImpl.this);
return true;
}
switch (combo) {
// NOTE(user): Ideally, these should be changed to ORDER_B, ORDER_I,
// ORDER_U. However, Safari on OSX does not emit any key events for
// those combos, so we're allowing both on mac.
case CTRL_SPACE:
// TODO(danilatos): Factor out configuration of popup key combo
suggestionsManager.showSuggestionsNearestTo(caret);
settings.getInstrumentor().record(Action.SHORTCUT_OPENNEARBYPOPUP);
return true;
case CTRL_B:
case ORDER_B:
doCollapsedStyle(caret, "fontWeight", "bold");
settings.getInstrumentor().record(Action.SHORTCUT_BOLD);
return true;
case CTRL_I:
case ORDER_I:
doCollapsedStyle(caret, "fontStyle", "italic");
settings.getInstrumentor().record(Action.SHORTCUT_ITALIC);
return true;
case CTRL_U:
case ORDER_U:
doCollapsedStyle(caret, "textDecoration", "underline");
settings.getInstrumentor().record(Action.SHORTCUT_UNDERLINE);
return true;
}
return false;
}
@Override
public void handleDomMutation(SignalEvent event) {
domMutationReverter.handleMutationEvent(event);
}
@Override
public boolean handleCommand(EditorEvent event) {
if (event.isUndoCombo()) {
assert responsibility.withinDirectSequence();
settings.getInstrumentor().record(Action.UNDO);
editorUndoManager.undo();
return true;
}
if (event.isRedoCombo()) {
assert responsibility.withinDirectSequence();
settings.getInstrumentor().record(Action.REDO);
editorUndoManager.redo();
return true;
}
KeyCombo combo = new EventWrapper(event.asEvent()).getKeyCombo();
switch (combo) {
// TODO(user): deprecate CTRL_ALT_D in favour of CTRL_ALT_G, ctrl alt d
// is a bad combo for linux as it minimizes the window in many
// window managers.
case CTRL_ALT_D:
case CTRL_ALT_G:
debugToggleDebugDialog();
return true;
}
return false;
}
/**
* Handles tab behaviour.
*
* @param start
* @param end
* @param shiftDown
*/
private void handleTab(Point<ContentNode> start, Point<ContentNode> end, boolean shiftDown) {
ContentNode node = start.getContainer();
while (node != null) {
if (isTabTarget(node)) {
break;
}
node = node.getParentElement();
}
// If we're not in a caption, tab = indent/outdent
if (node == null) {
applyParagraphIndent(start, end, shiftDown);
settings.getInstrumentor().record(shiftDown ?
Action.SHORTCUT_TABOUTDENT : Action.SHORTCUT_TABINDENT);
} else {
settings.getInstrumentor().record(Action.SHORTCUT_TABFIELDS);
// traverse until we find the next caption:
do {
node = DocHelper.getNextOrPrevNodeDepthFirst(mutable(), node, null, true, !shiftDown);
if (isTabTarget(node)) {
break;
}
} while (node != null);
// found the next caption, set the selection:
if (node != null) {
Point<ContentNode> fixedStart = Point.start(mutable(), (ContentElement) node);
Point<ContentNode> fixedEnd = Point.end(node);
passiveSelectionHelper.setSelectionPoints(fixedStart, fixedEnd);
// NOTE(patcoleman): scroll into view should happen automagically?
}
}
}
public boolean isTabTarget(ContentNode node) {
if (node == null || node.asElement() == null) {
return false;
}
ContentElement elem = node.asElement();
String nodeTagName = elem.getTagName();
return TAB_TARGETS.contains(nodeTagName);
}
@Override
public boolean handleCut(EditorEvent event) {
editorUndoManager.maybeCheckpoint();
EditorStaticDeps.logger.trace().log("handling cut");
return pasteExtractor.handleCutEvent(EditorImpl.this);
}
@Override
public boolean handlePaste(EditorEvent event) {
editorUndoManager.maybeCheckpoint();
EditorStaticDeps.logger.trace().log("handling paste");
return pasteExtractor.handlePasteEvent(currentSelectionBias);
}
@Override
public boolean handleCopy(EditorEvent event) {
EditorStaticDeps.logger.trace().log("handling copy");
return pasteExtractor.handleCopyEvent(EditorImpl.this);
}
/**
* Set a style over a range, or clear it if the range is already entirely
* covered by the given value for that style.
*/
private void doStyle(Point<ContentNode> start, Point<ContentNode> end, String key,
String value) {
key = StyleAnnotationHandler.key(key);
applyPaint(mapper().getLocation(start), mapper().getLocation(end), key, value);
}
/**
* Set a style over a caret, or clear it if the style is already set
* by the given value for that style.
*/
private void doCollapsedStyle(Point<ContentNode> caret,
String key, String value) {
key = StyleAnnotationHandler.key(key);
if (caretStyles.isAnnotated(key, value)) {
caretStyles.setAnnotation(key, null);
} else {
caretStyles.setAnnotation(key, value);
}
scheduleUpdateNotification(false, false, true, false);
}
private void applyPaint(int startLocation, int endLocation, String key, String value) {
boolean isFullyStyled =
mutable().firstAnnotationChange(startLocation, endLocation, key, value) == -1;
editorUndoManager.maybeCheckpoint(startLocation, endLocation);
mutable().setAnnotation(startLocation, endLocation, key,
isFullyStyled ? null : value);
}
/**
* Apply paragraph indent/outdent
*
* @param start
* @param end
* @param outdent true to indent/false to outdent
*/
private void applyParagraphIndent(Point<ContentNode> start, Point<ContentNode> end,
boolean outdent) {
// Indent paragraph:
LocationMapper<ContentNode> m = mapper();
Paragraph.traverse(m, m.getLocation(start), m.getLocation(end), outdent ? Paragraph.OUTDENTER
: Paragraph.INDENTER);
}
}
/**
* Our event handling logic
*
* Package private for testing
*/
EditorEventHandler eventHandler = null;
private Text webkitEndOfLinkHackTextNode = null;
private Responsibility.Manager responsibility = null;
private EditorUndoManager editorUndoManager = null;
/**
* Resolves annotations by checking what the annotation of the character on the side of
* the cursor bias is.
*/
AnnotationResolver annotationResolver = new AnnotationResolver() {
public String getAnnotation(String key) {
FocusedRange browserSelection = passiveSelectionHelper.getSelectionRange();
if (browserSelection == null) {
EditorStaticDeps.logger.error().log(
"No selection when resolving editor annotations.");
return null; // safely consume for now...
} else if (!browserSelection.isCollapsed()) {
EditorStaticDeps.logger.error().log("Resolving selection annotations is only supported "
+ "while the browser selection is collapsed");
return null;
}
// TODO(patcoleman): optimise by caching the selection?
int at = browserSelection.getFocus();
boolean biasLeft = (currentSelectionBias != BiasDirection.RIGHT); // default to left
return Annotations.getAlignedAnnotation(mutable(), at, key, biasLeft);
}
};
/// Editor instrumentation
/** Time for key process (bundled to be reported with a post-key process time). */
private double processKeyPressTimer = 0.0;
/** Timer for tracking the rendering speed after a keypress. */
private Duration postKeyPressTimer = null;
/** Task scheduled to run after the layout/paint after an input event. */
private final Task instrumentationTask = new Task() {
@Override
public void execute() {
settings.getInstrumentor().recordDuration(
TimedAction.INPUT_PROCESS, processKeyPressTimer);
settings.getInstrumentor().recordDuration(
TimedAction.INPUT_POSTPROCESS, postKeyPressTimer.elapsedMillis());
}
};
/** Instrumentation for repairing */
// TODO(patcoleman): extract out instrumentation logic into separate class
private final RepairListener repairListener = new RepairListener() {
@Override
public void onFullDocumentRevert(
ReadableDocument<ContentNode, ContentElement, ContentTextNode> doc) {
settings.getInstrumentor().record(Action.FULL_REPAIR);
}
@Override
public void onRangeRevert(El<ContentNode> start, El<ContentNode> end) {
settings.getInstrumentor().record(Action.PARTIAL_REPAIR);
}
};
/**
* Restricted interface that allows the event handler to interact with the editor.
*/
private class EditorInteractorImpl implements EditorInteractor {
@Override
public void forceFlush() {
EditorImpl.this.flushSynchronous();
}
@Override
public FocusedContentRange getSelectionPoints() {
return getAggressiveSelectionHelper().getSelectionPoints();
}
@Override
public boolean selectionIsOrdered() {
return NativeSelectionUtil.isOrdered();
}
@Override
public FocusedPointRange<Node> getHtmlSelection() {
return EditorImpl.this.getHtmlSelection();
}
@Override
public boolean hasContentSelection() {
return isTyping() ||
(SelectionUtil.filterNonContentSelection(getHtmlSelection()) != null);
}
@Override
public Point<ContentNode> normalizePoint(Point<ContentNode> caret) {
// left align caret within full, then filter it to hard, then normalise within hard:
caret = DocHelper.normalizePoint(caret, content.getSelectionFilter());
caret = DocHelper.leftAlign(caret, full(), content.getSelectionFilter());
caret = DocHelper.getFilteredPoint(content.getSelectionFilter(), caret);
return caret;
}
@Override
public boolean notifyListeners(SignalEvent event) {
boolean handled = false;
// Fire keyboard event to listeners but cancel their bubbling
// but only if the event is actually relevant to editor (i.e,
// in editing mode or target is in form element)
if (event.isKeyEvent()) {
if (editorRelevantEvent(event)) {
handled = fireKeyboardEvent(event);
}
// // Hack(user): special case submit because currently the owner
// // may turn the editor to display mode from submit. Rethink
// // where + how submits get handled
// if (isEditing() && event.isCombo(' ', KeyModifier.SHIFT)) {
// return true;
// }
}
return handled;
}
@Override
public boolean shouldIgnoreMutations() {
return EditorStaticDeps.shouldIgnoreMutations();
}
@Override
public boolean isExpectingMutationEvents() {
return EditorImpl.this.isTyping();
}
@Override
public boolean isTyping() {
return EditorImpl.this.isTyping();
}
@Override
public boolean notifyTypingExtractor(Point<ContentNode> caret, boolean useHtmlCaret,
boolean isReplace) {
Point<Node> htmlCaret;
if (caret != null) {
assert !useHtmlCaret;
htmlCaret = getNodeManager().wrapperPointToNodeletPoint(caret);
} else {
if (useHtmlCaret) {
// HACK(danilatos): Just use the html caret.
// See NOTE #XYZ in EditorEventHandler
NativeSelectionUtil.cacheClear();
FocusedPointRange<Node> htmlSelection = NativeSelectionUtil.get();
htmlCaret = htmlSelection != null ? htmlSelection.getFocus() : null;
} else {
// The caret might be null for some other reason, such as it's in a
// text input box in a doodad, or something... leave it as null.
// Only use the HTML caret if explicitly told to do so.
htmlCaret = null;
}
}
if (htmlCaret != null) {
// NOTE(patcoleman): caret == null shows we're in the middle of typing, so it is correct
// to not supplement annotations (they should have been supplemented on typing start).
if (!isReplace && caret != null) {
annotationLogic.supplementAnnotations(mutable().getLocation(caret),
currentSelectionBias, ContentType.PLAIN_TEXT);
}
EditorImpl.this.notifyTypingExtractor(htmlCaret);
return false;
} else {
EditorStaticDeps.logger.error().logPlainText(
"Null html caret in EditorImpl's notifyTypingExtractor, content caret: " + caret);
// allow - probably that weird IME issue.
// or maybe it's in a funky text input box in some doodad. who knows.
// hopefully not in a broken place.
return false;
}
}
@Override
public void setCaret(Point<ContentNode> caret) {
getAggressiveSelectionHelper().setCaret(caret);
}
@Override
public Point<ContentNode> deleteRange(Point<ContentNode> first, Point<ContentNode> second,
boolean isReplace) {
if (isReplace) {
annotationLogic.supplementAnnotations(mutable().getLocation(first), BiasDirection.RIGHT,
ContentType.PLAIN_TEXT);
}
return mutable().deleteRange(getFilteredPoint(first), getFilteredPoint(second)).getFirst();
}
@Override
public Point<ContentNode> insertText(Point<ContentNode> at, String text, boolean isReplace) {
return EditorImpl.this.insertText(at, text, isReplace);
}
@Override
public ContentElement findElementWrapper(Element target) {
return getNodeManager().findElementWrapper(target);
}
@Override
public boolean isEditing() {
return EditorImpl.this.isEditing();
}
@Override
public void noteWebkitEndOfLinkHackOccurred(Text textNode) {
webkitEndOfLinkHackTextNode = textNode;
}
public void clearCaretAnnotations() {
caretStyles.clear();
}
@Override
public void deleteWordEndingAt(Point<ContentNode> caret) {
Point<ContentNode> end = normalizePoint(caret);
Point<ContentNode> start = caretMoveHelper.getWordBoundary(false);
if (start != null) {
mutable().deleteRange(getFilteredPoint(start), getFilteredPoint(end));
rebiasSelection(CursorDirection.FROM_RIGHT);
}
}
@Override
public void deleteWordStartingAt(Point<ContentNode> caret) {
Point<ContentNode> start = normalizePoint(caret);
Point<ContentNode> end = caretMoveHelper.getWordBoundary(true);
if (end != null) {
mutable().deleteRange(getFilteredPoint(start), getFilteredPoint(end));
rebiasSelection(CursorDirection.FROM_LEFT);
}
}
@Override
public void compositionStart(Point<ContentNode> caret) {
// NOTE(danilatos): Is it safe to have the start & end ignore mutations in this
// manner? Or should we ignore based on the event handler state? I guess it's
// the same effect.
EditorStaticDeps.startIgnoreMutations();
if (caret != null) {
imeExtractor.activate(content.getContext(), caret);
}
annotationLogic.supplementAnnotations(mutable().getLocation(caret), currentSelectionBias,
ContentType.PLAIN_TEXT);
}
@Override
public void compositionUpdate() {
// TODO(danilatos): Some event or other, so e.g. we can show other users
// the composition state.
}
@Override
public FocusedContentRange compositionEnd() {
try {
if (!imeExtractor.isActive()) {
EditorStaticDeps.logger.error().log(
"Composition end called with inactive ImeExtractor! "
+ "Maybe caret was null initially?");
return null;
}
// HACK(danilatos): prevent CC from sending the insertion before the annotation update.
// TODO(zdwang/danilatos): Implement batching in CcBasedWavelet, or something, and send
// out in a deferred command, so synchronously generated ops always go in the same
// delta, whether or not CC is waiting for an unacknowledged op.
mutable().hackConsume(new Nindo.Builder().build());
String composition = imeExtractor.getContent();
assert composition != null : "Composition should not be null with active IME extractor";
Point<ContentNode> contentPoint = imeExtractor.deactivate(content.getAnnotatableContent());
Point<ContentNode> caret = insertText(contentPoint, composition, true);
aggressiveSelectionHelper.setCaret(caret);
rebiasSelection(CursorDirection.FROM_LEFT);
// HACK(danilatos): Flush updates, so that listeners to the ime state get an immediate
// update, to synchronously clear the composition state from any selection annotations,
// so that there's no instant where the other side sees both the inserted text and
// the last composed bit.
// This can be avoided by keeping track of the selection annotations directly in the
// editor, something worth considering.
responsibility.startIndirectSequence();
updateEvent.flushUpdates();
responsibility.endIndirectSequence();
return passiveSelectionHelper.getSelectionPoints();
} finally {
EditorStaticDeps.endIgnoreMutations();
}
}
@Override
public void checkpoint(FocusedContentRange currentRange) {
if (currentRange != null) {
editorUndoManager.maybeCheckpoint(mutable().getLocation(currentRange.getFocus()), mutable()
.getLocation(currentRange.getAnchor()));
} else {
editorUndoManager.maybeCheckpoint();
}
}
@Override
public void rebiasSelection(CursorDirection lastDirection) {
EditorImpl.this.rebiasSelection(lastDirection);
}
}
/**
* Rebias the user's selection to whichever side is desired by annotation / document state.
* NOTE(patcoleman): occurs in a deferred command so the selection is correct when retrieved.
*/
private void rebiasSelection(final CursorDirection lastDirection) {
// short-cut for extraction
if (isTyping()) {
currentSelectionBias = BiasDirection.LEFT;
return;
}
SchedulerInstance.getHighPriorityTimer().schedule(new Task() {
@Override
public void execute() {
CursorDirection current = lastDirection;
if (!settings.useFancyCursorBias()) {
current = CursorDirection.FROM_LEFT;
}
FocusedRange focused = getSelectionHelper().getSelectionRange();
if (focused != null) {
Range range = focused.asRange();
currentSelectionBias = annotationLogic.rebias(range.getStart(), range.getEnd(), current);
} else {
// no selection, so have default bias
currentSelectionBias = BiasDirection.LEFT;
}
}
});
}
/**
* Utility that inserts some text into the document
* @param isReplace Whether this insertion is the second half of a replacement.
*/
private Point<ContentNode> insertText(Point<ContentNode> at, String text, boolean isReplace) {
text = PermittedCharacters.BLIP_TEXT.coerceString(text);
int location = mutable().getLocation(getFilteredPoint(at));
if (!isReplace) {
annotationLogic.supplementAnnotations(location, currentSelectionBias, ContentType.PLAIN_TEXT);
}
Nindo.Builder builder = new Nindo.Builder();
builder.skip(location);
caretStyles.buildAnnotationStarts(builder);
builder.characters(text);
caretStyles.buildAnnotationEnds(builder, true);
mutable().hackConsume(builder.build());
return mutable().locate(location + text.length());
}
/**
* Internal utility that handles document deletion and text insertion, writing the
* result to a builder, including extra annotation logic.
* NOTE(patcoleman): *does not* delete the content if it is not all text,
* due to being unable to do that in an op.
*
* @param at Location in the document to delete then insert.
* @param deletionSize Size of content to delete.
* @param text Text to insert at the location.
* @return Builder that when built and
*/
private Nindo generateReplaceTextOp(int at, int deletionSize, String text) {
Nindo.Builder builder = new Nindo.Builder();
builder.skip(at);
// Delete only when necessary
if (deletionSize > 0) {
// Desired behaviour: inherit styles from the left of the deleted range:
annotationLogic.supplementAnnotations(at, BiasDirection.RIGHT, ContentType.PLAIN_TEXT);
builder.deleteCharacters(deletionSize); // NOTE(patcoleman): everything deleted must be text.
}
// Insert only when necessary
if (text.length() > 0) {
caretStyles.buildAnnotationStarts(builder);
builder.characters(text);
caretStyles.buildAnnotationEnds(builder, true);
}
return builder.build();
}
private Point<ContentNode> getFilteredPoint(Point<ContentNode> unfiltered) {
return DocHelper.getFilteredPoint(persistent(), unfiltered);
}
@Override
public Manager getResponsibilityManager() {
return responsibility;
}
@Override
public void undoableSequence(Runnable cmd) {
editorUndoManager.maybeCheckpoint();
responsibility.startDirectSequence();
try {
cmd.run();
} finally {
responsibility.endDirectSequence();
}
}
private UniversalPopup debugPopup = null;
static {
StyleInjector.inject(css.getText(), true);
EditorJsniHelpers.nativeSetupWebDriverTestPins();
NativeSelectionUtil.setTransientMutationListener(new NativeSelectionUtil.MutationListener() {
@Override
public void startTransientMutations() {
EditorStaticDeps.startIgnoreMutations();
}
@Override
public void endTransientMutations() {
EditorStaticDeps.endIgnoreMutations();
}
});
}
/**
* Method used to trigger the static initializer on this class.
*/
public static void init() {
}
/**
* Determines the lifecycle relationship between this editor and the document
* it edits. If true, this editor owns the document, so it is responsible for
* creating it and destroying it (i.e., the lifetime of the document is within
* the lifetime of the editor). If false, this editor does not own the
* document, so it must not create or destroy it, and must leave it in the
* same state as it found it (i.e., the lifetime of the editor is within the
* lifetime of the document).
*/
private final boolean ownsDocument;
/**
* Constructor
*/
protected EditorImpl(boolean ownsDocument, Element div) {
this.ownsDocument = ownsDocument;
this.div = div;
setElement(div);
}
private final IdentitySet<HandlerReference> domHandlers = CollectionUtils.createIdentitySet();
private void registerDomEventHandling() {
for (String event : BrowserEvents.HANDLED_EVENTS) {
domHandlers.add(DomHelper.registerEventHandler(getDocumentHtmlElement(), event, this));
}
}
private void unregisterDomEventHandling() {
domHandlers.each(new Proc<HandlerReference>() {
@Override
public void apply(HandlerReference handler) {
handler.unregister();
}
});
domHandlers.clear();
}
@Override
public Widget getWidget() {
return this;
}
@Override
public void init(Registries registries, KeyBindingRegistry bindings, EditorSettings settings) {
Preconditions.checkState(
ownsDocument == (registries != null), "Can only set registries on owned documents");
this.registries = registries;
this.keyBindings = bindings;
this.settings = settings;
eventHandler = new EditorEventHandler(
new EditorInteractorImpl(), eventsSubHandler, NodeEventRouter.INSTANCE,
settings.useWhitelistInEditor(), settings.useWebkitCompositionEvents());
setEditing(false);
}
@Override
public void reset() {
// TODO(danilatos): only bring this back once all the jobs associated with the editor (e.g.
// painter) have been reimplemented such that:
// a) they notice that an editor has been reset, and cancel themselves; or
// b) an editor can cancel jobs associated with it here on reset(); or
// c) happy/sad/ecstatic packages have only immutable state, rather than pulling out
// mutable editor state that can be reset here.
// factoryRegistry = null;
// GWT widget cleanup
// clear();
updateEvent.clear();
elementsWithDisplayEditModes.clear();
Iterator<Widget> i = iterator();
while (i.hasNext()) {
Widget w = i.next();
doOrphan(w);
// Resume iteration.
i = iterator();
}
repairer = null;
keyBindings.clear();
caretStyles = null;
clearContent();
annotationLogic = null;
}
private void clearContent() {
if (content != null) {
updateDocumentEditState(false);
EditorStaticDeps.startIgnoreMutations();
try {
unregisterDomEventHandling();
if (ownsDocument) {
div.removeChild(getDocumentHtmlElement());
} else {
// Restore the document's original sink and DOM state.
Object oldSink = content.replaceOutgoingSink(innerOutputSink);
if (oldSink != outgoingOperationSink) {
throw new RuntimeException("Document had a mysterious sink. Restoration is unsafe");
}
div.removeClassName("editor");
}
} finally {
EditorStaticDeps.endIgnoreMutations();
}
}
content = null;
}
@Override
public void cleanup() {
reset();
}
/**
* {@inheritDoc}
*/
@Override
public void setOutputSink(final SilentOperationSink<DocOp> sink) {
this.innerOutputSink = sink;
}
/**
* {@inheritDoc}
*/
@Override
public void clearOutputSink() {
this.innerOutputSink = null;
}
/**
* Causes all pending operations to be fired as events.
*/
@VisibleForTesting
void flushSynchronous() {
if (content != null) {
typing.flush();
}
}
private boolean flush(Runnable resume) {
if (!canApplyIncomingOperations()) {
EditorStaticDeps.logger.trace().log("Deferring incoming operation");
consistencyQueue.schedule(resume);
return false;
} else {
// In case this event cycle is running before the -continuation command queue's runner,
// and there are previously queued resume commands.
consistencyQueue.execute();
return true;
}
}
@Override
public ContentView getPersistentDocument() {
return content.getPersistentView();
}
@Override
public void setContent(final DocInitialization op, DocumentSchema schema) {
Preconditions.checkState(ownsDocument, "Can not replace content not owned");
setContent(new ContentDocument(registries, op, schema));
}
@Override
public ContentDocument getContent() {
return content;
}
@Override
public ContentDocument removeContent() {
ContentDocument oldDoc = content;
clearContent();
if (ownsDocument) {
oldDoc.setRendering();
oldDoc.replaceOutgoingSink(SilentOperationSink.Void.get());
} else {
oldDoc.setInteractive();
}
// TODO(danilatos): Clear all the stuff initialised in setContent()
return oldDoc;
}
@Override
public ContentDocument removeContentAndUnrender() {
ContentDocument oldDoc = content;
clearContent();
oldDoc.setShelved();
return oldDoc;
}
@Override
public void setContent(ContentDocument newDoc) {
flushSynchronous();
EditorStaticDeps.startIgnoreMutations();
try {
if (suggestionsManager != null) {
suggestionsManager.clear();
}
elementsWithDisplayEditModes.clear();
clearContent();
content = newDoc;
if (ownsDocument) {
// Attach the document to the editor.
content.replaceOutgoingSink(outgoingOperationSink);
} else {
// Attach the editor to the document.
innerOutputSink = content.replaceOutgoingSink(outgoingOperationSink);
}
/////////////////////////////
/////////////////////////////
if (!content.getLevel().isAtLeast(Level.RENDERED)) {
// TODO(danilatos): Use setRenderingFast() once it also initialises nodeManager, etc.
content.setRendering();
}
repairer = content.getRepairer();
nodeManager = content.getNodeManager();
htmlSelectionHelper = new HtmlSelectionHelperImpl(getDocumentHtmlElement());
passiveSelectionHelper =
new PassiveSelectionHelper(htmlSelectionHelper, content.getNodeManager(),
content.getRenderedView(), content.getLocationMapper());
suggestionsManager = new InteractiveSuggestionsManager(
passiveSelectionHelper, settings.closeSuggestionsMenuDelayMs());
aggressiveSelectionHelper =
new AggressiveSelectionHelper(htmlSelectionHelper, nodeManager, content.getRenderedView(),
content.getLocationMapper(), content.getMutableDoc()) {
@Override
protected void flushForUnextractedText() {
flushSynchronous();
// TODO(danilatos): Schedule another typing pass since we're forcing a flush?
// Do something else?
}
};
caretMoveHelper = UserAgent.isWebkit() ? new CaretMovementHelperWebkitImpl(nodeManager)
: new CaretMovementHelperImpl(persistent(), passiveSelectionHelper);
final OperationSequencer<Nindo> sequencer = content.getOpSequencer();
typing = new TypingExtractor(typingSink, nodeManager,
content.getFilteredHtmlView(), content.getRenderedView(), repairer,
new SelectionSource() {
/** {@inheritDoc} */
public Point<Node> getSelectionStart() {
PointRange<Node> range = getOrderedHtmlSelection();
return range == null ? null : range.getFirst();
}
/** {@inheritDoc} */
public Point<Node> getSelectionEnd() {
PointRange<Node> range = getOrderedHtmlSelection();
return range == null ? null : range.getSecond();
}
});
responsibility = new ResponsibilityManagerImpl();
if (settings.undoEnabled()) {
editorUndoManager = new EditorUndoManagerImpl(
UndoManagerFactory.createUndoManager(),
new SilentOperationSink<DocOp>() {
@Override
public void consume(DocOp op) {
// This applies to the content document as well as sending
// it out remotely.
content.sourceNindo(Nindo.fromDocOp(op, true));
}
}, passiveSelectionHelper);
} else {
editorUndoManager = EditorUndoManager.NOP_IMPL;
}
OperationSequencer<Nindo> undoingSequencer = new UndoableSequencer(sequencer, responsibility);
CMutableDocument undoableDocument = content.createSequencedDocumentWrapper(undoingSequencer);
pasteExtractor = new PasteExtractor(CommandQueue.HIGH_PRIORITY,
aggressiveSelectionHelper,
undoableDocument,
content.getRenderedView(),
content.getPersistentView(),
content.getRegistries().getAnnotationHandlerRegistry(),
undoingSequencer,
content.getValidator(),
settings.getInstrumentor(),
settings.useSemanticCopyPaste());
final Scheduler.Task revertTask = new Scheduler.Task() {
public void execute() {
domMutationReverter.flush();
}
};
domMutationReverter = new DomMutationReverter(new RevertListener() {
@Override
public void scheduleRevert() {
if (!isTyping()) {
EditorStaticDeps.logger.trace().log(
"WARNING: Dom removal outside of known typing context");
ScheduleCommand.addCommand(revertTask);
}
}
});
repairer.hideDeath(full().getDocumentElement());
// set up empty styles
caretStyles = new CaretAnnotations();
caretStyles.setAnnotationResolver(annotationResolver);
// Force rendering of annotations
// TODO(danilatos): Make this only apply to the current editor.
AnnotationPainter.repaintNow(content.getContext());
savedSelection = new RangeTracker(content.getLocalAnnotations(), "savedsel");
passiveSelectionHelper.setSelectionTracker(savedSelection);
// initialise the annotation behavioural logic:
annotationLogic = new AnnotationBehaviourLogic<ContentNode>(
ROOT_ANNOTATION_REGISTRY, content.getMutableDoc(), caretStyles);
// /////////////////////////////
// /////////////////////////////
content.attachEditor(editorPackage, ownsDocument ? this : null);
Element docDiv = getDocumentHtmlElement();
if (ownsDocument) {
div.appendChild(docDiv);
// TODO(danilatos): Is this style name needed anymore? It was needed
// in the past for webdriver, not anymore. Anything else?
docDiv.addClassName("document");
} else {
Preconditions.checkArgument(div == docDiv.getParentElement(), "wrong content document");
}
docDiv.setAttribute(EDITABLE_DOC_MARKER, "true");
DomHelper.setNativeSpellCheck(docDiv, false);
// setup event handling
registerDomEventHandling();
// Initialise editing state related things
setEditing(isEditing());
// TODO(danilatos): If this is necessary, add comment why. Also consider
// scheduling with delay or at low priority, so as not to slow down initial
// rendering.
// scheduleUpdateNotification();
// Check health if in debug build
debugCheckHealth();
} finally {
EditorStaticDeps.endIgnoreMutations();
}
}
/**
* Location Mapper for use by editor classes (only exposed on EditorImpl)
*/
public LocationMapper<ContentNode> mapper() {
return content.getLocationMapper();
}
/**
* Full Content View for use by editor classes (only exposed on EditorImpl)
*/
public ContentView full() {
return content.getFullContentView();
}
/**
* Mutable Docuemnt for use by editor classes (only exposed on EditorImpl)
*/
public CMutableDocument mutable() {
return content.getMutableDoc();
}
/**
* Peristent Content View for use by editor classes (only exposed on EditorImpl)
*/
public ContentView persistent() {
return content.getPersistentView();
}
/**
* NodeManager for use by editor classes (only exposed on EditorImpl)
*/
public NodeManager getNodeManager() {
return nodeManager;
}
/**
* AggressiveSelectionHelper for use by editor classes (only exposed on EditorImpl)
*/
public AggressiveSelectionHelper getAggressiveSelectionHelper() {
return aggressiveSelectionHelper;
}
/**
* {@inheritDoc}
*/
@Override
public boolean isEditing() {
return editing;
}
/**
* {@inheritDoc}
*/
@Override
public void setEditing(final boolean editing) {
this.editing = editing;
if (content != null) {
updateDocumentEditState(editing);
editorUndoManager.maybeCheckpoint();
// there should be any edit notification when we are not editing. There was a bug
// where we modifies the document after stopEditing is called.
if (editing) {
if (innerOutputSink == null) {
EditorStaticDeps.logger.error().log("Scheduling update with no inner output sink...");
}
scheduleUpdateNotification(false, true, false, false);
}
}
}
/**
* Removes the various editor bits on the document
*/
private void updateDocumentEditState(boolean editing) {
Element topLevel = getDocumentHtmlElement();
// Set property to some arbitrary non-null value if we're in editing mode.
full().getDocumentElement().setProperty(AnnotationPainter.DOCUMENT_MODE, editing);
topLevel.removeClassName(WAVE_EDITOR_EDIT_ON);
topLevel.removeClassName(WAVE_EDITOR_EDIT_OFF);
topLevel.addClassName(editing ? WAVE_EDITOR_EDIT_ON : WAVE_EDITOR_EDIT_OFF);
AnnotationPainter.maybeScheduleRepaint(content.getContext(), 0, mutable().size());
DomHelper.setContentEditable(topLevel, editing, true);
for (ContentElement element : elementsWithDisplayEditModes) {
if (element.getParentElement() != null) {
DisplayEditModeHandler.onEditModeChange(element, editing);
} else {
elementsWithDisplayEditModes.remove(element);
}
}
}
private void maybeSaveAncestorScrollPositions(Element e) {
if (QuirksConstants.ADJUSTS_SCROLL_TOP_WHEN_FOCUSING) {
ancestorScrollTops = CollectionUtils.createIdentityMap();
while (e != null) {
ancestorScrollTops.put(e, e.getScrollTop());
e = e.getParentElement();
}
}
}
private void maybeRestoreAncestorScrollPositions(Element e) {
if (QuirksConstants.ADJUSTS_SCROLL_TOP_WHEN_FOCUSING && ancestorScrollTops != null) {
ancestorScrollTops.each(new IdentityMap.ProcV<Element, Integer>() {
public void apply(Element e, Integer i) {
e.setScrollTop(i);
}
});
ancestorScrollTops = null;
}
}
/**
* TODO(user): use content document to set caret, and issue operation
*
* TODO(danilatos): This stuff seems out of date...
*
* TODO(danilatos): Make this method trivially idempotent
*
* {@inheritDoc}
*/
@Override
public void focus(boolean collapsed) {
if (!isAttached()) {
EditorStaticDeps.logger.error().log("Shouldn't focus a detached editor");
return;
}
// focus document
if (isEditing() && content != null) {
// first, handle DOM focus
FocusedPointRange<Node> htmlSelection = getHtmlSelection(); // save before focusing.
// element causes webkit based browsers to automatically scroll the element into view
// In wave, we want to be in charge of how things move, so we cancel this behaviour
// here by first recording the scrollTops of all the editor's ancestors, and
// then resetting them after calling focus.
Element docElement = getDocumentHtmlElement();
maybeSaveAncestorScrollPositions(docElement);
FocusImpl.getFocusImplForWidget().focus(DomHelper.castToOld(docElement));
maybeRestoreAncestorScrollPositions(docElement);
// then, handle the case when selection already existed inside the element:
if (htmlSelection != null) {
// NOTE(patcoleman): we may have killed it with the DOM focusing above, so restore
NativeSelectionUtil.set(htmlSelection);
if (!collapsed) {
// if we have selection, and we're not forcibly collapsing it, then nothing needs doing.
return;
} else {
// Otherwise, we might need to adjust it if we're collapsing it. So we'll fall through to
// the manual selection-restore-with-collapse, but first we save what we have anyway.
EditorStaticDeps.logger.trace().log("Saving...");
doSaveSelection();
}
}
// finally, make sure selection is correct:
safelyRestoreSelection(aggressiveSelectionHelper, collapsed);
scheduleUpdateNotification(true, true, false, false);
}
}
@Override
public boolean hasDocument() {
return content != null;
}
@Override
public Element getDocumentHtmlElement() {
assert content != null : "getDocumentHtmlElement: content is null";
return content.getFullContentView().getDocumentElement().getImplNodelet();
}
/**
* Small helper that sets the selection safely by altering the selection
* if the initial selections are invalid.
*/
private void safelyRestoreSelection(SelectionHelper selectionHelper, boolean collapsed) {
assert savedSelection != null;
boolean selectionRestored = false;
FocusedRange sel = savedSelection.getFocusedRange();
if (sel != null) {
EditorStaticDeps.logger.trace().log("Focusing, set selection at: " + sel.getFocus());
// either set to the focus point (to give visual edit cue) or keep entire selection
if (collapsed) {
selectionHelper.setCaret(sel.getFocus());
} else {
selectionHelper.setSelectionRange(sel);
}
// Check if it successfully is restored, even in the content view.
selectionRestored = selectionHelper.getSelectionPoints() != null;
}
if (!selectionRestored) {
EditorStaticDeps.logger.trace().log("Focusing at last valid point as a catch-all");
// Either we didn't have a saved selection, or the saved selection
// ended up being invalid and we didn't set it. In that case,
// just set it to the end of the document.
selectionHelper.setCaret(selectionHelper.getLastValidSelectionPoint());
}
}
/**
* {@inheritDoc}
*/
@Override
public void blur() {
if (content != null) {
// The typing extractor may have a pending flush(), which requires a selection to exist.
// We must therefore force the flush to happen now, because we're about to remove the
// selection.
flushSynchronous();
// NOTE(mtsui: Save selection whenever we stop editing (i.e. submit). This
// allows us to return to the spot when we reedit the wave. However, this
// doesn't work if the user clicks on another wave.
if (NativeSelectionUtil.get() != null) {
DomHelper.blur(getDocumentHtmlElement());
NativeSelectionUtil.clear();
}
scheduleUpdateNotification(false, true, false, false);
}
}
// Selection saving code
// TODO(danilatos): Pull this out into a helper class
private RangeTracker savedSelection;
private final Scheduler.Task selectionSavingTask = new Scheduler.Task() {
public void execute() {
doSaveSelection();
}
};
private void doSaveSelection() {
if (passiveSelectionHelper != null) {
assert savedSelection != null;
FocusedRange range = passiveSelectionHelper.getSelectionRange();
if (range != null) {
// We don't want to clear it - only remember the last existing selection
savedSelection.trackRange(range);
}
}
}
@Override
public void flushSaveSelection() {
doSaveSelection();
SchedulerInstance.getHighPriorityTimer().cancel(selectionSavingTask);
}
private void scheduleSaveSelection() {
SchedulerInstance.getHighPriorityTimer().schedule(selectionSavingTask);
}
// End selection saving code
// Note: This class implements HtmlSelectionHelper for historic reasons.
// Components that still use an editor object as their HtmlSelectionHelper
// impl can assume that that selection helper has the lifetime of the editor.
// However, the htmlSelectionHelper object, because it is constructed on a
// content document's doc element, only has the lifetime of an
// editor<->content association, and it gets replaced in setContent.
@Override
public FocusedPointRange<Node> getHtmlSelection() {
// NOTE(user): If content is null, we shoudln't be trying to get the
// selection in the first place. However, we often don't know when an
// editor is closed, such as from inside defered commands. i.e.
// PasteExtractor, AnnotationPainter.
return hasDocument() ? htmlSelectionHelper.getHtmlSelection() : null;
}
@Override
public PointRange<Node> getOrderedHtmlSelection() {
return htmlSelectionHelper.getOrderedHtmlSelection();
}
/**
* TODO(user): consider relaying this question to the ContentElemtn owning
* the HTML target rather than hard-coding the logic here..
*
* @param event
* @return true if the target of the event is editable in display mode, e.g.,
* an input field or radio button
*/
protected boolean isTargetEditableInDisplayMode(SignalEvent event) {
Element target = event.getTarget();
if (DomHelper.isTextNode(target)) {
target = target.getParentElement();
}
return DomHelper.isEditable(target);
}
/**
* @param event
* @return true if the editor has cause to care about this event at all
*/
protected boolean editorRelevantEvent(SignalEvent event) {
// TODO(danilatos): Investigate if the second check is innefficient, and if so,
// do something smarter (such as, remember if we are in an editable context
// as long as it doesn't change, rather than check each time).
return isEditing() || isTargetEditableInDisplayMode(event) ||
event.isCopyEvent(); // Handle copy event even when editor is not in edit mode.
}
/**
* {@inheritDoc}
*/
public void onJavaScriptEvent(String name, Event rawEvent) {
double processTiming = 0;
Duration instrumentedDuration = null;
// These events should never be stopped by the editor since other handlers
// rely on getting to handle them.
// TODO(danilatos): clean this mess up.
boolean hackEditorNeverConsumes = name.equals("contextmenu") || name.equals("click")
|| name.equals("mousedown");
try {
EditorEvent event = SignalEventImpl.create(EditorEventImpl.FACTORY,
rawEvent, !hackEditorNeverConsumes);
try {
if (UserAgent.isMac() &&
rawEvent.getCtrlKey() && rawEvent.getAltKey()
&& rawEvent.getTypeInt() == Event.ONKEYPRESS) {
// In mac safari, Ctrl+Alt+something often inserts weird invisible
// characters in the dom!!! Always cancel the event, no matter what
// (We can still handle it programmatically if we wish)
// Cancel for FF as well just in case - but if this causes an issue
// it should be OK to enable for FF. We get keypress events, but
// no dom munge.
// Don't cancel it on Windows, because that prevents AltGr combos
// from working in european languages
EditorStaticDeps.logger.trace().log("Cancelling dangerous: " + rawEvent.getType());
rawEvent.preventDefault();
}
} catch (JavaScriptException e) {
// If this fails, swallow it. Seems to cause issues with dom mutation
// events when setting contentEditable with JUnit tests.
}
if (event == null) {
if (!hackEditorNeverConsumes) {
try {
rawEvent.stopPropagation();
} catch (JavaScriptException e) {
// If this fails, swallow it. Seems to cause issues with dom mutation
// events when setting contentEditable with JUnit tests.
}
}
return;
}
boolean cancel = false;
if (!hackEditorNeverConsumes) {
if (editorRelevantEvent(event)) {
try {
event.stopPropagation();
} catch (JavaScriptException e) {
// If this fails, swallow it. Seems to cause issues with dom mutation
// events when setting contentEditable with JUnit tests.
}
} else {
return;
}
}
boolean isMutationEvent = event.isMutationEvent();
if (!isMutationEvent || !EditorStaticDeps.shouldIgnoreMutations()) {
EditorStaticDeps.startIgnoreMutations();
try {
if (debugDisabled) {
cancel = false;
} else if (!canHandleBrowserEvents()) {
// If we're too busy for it to be safe to do anything at all,
// we cancel the event altogether
EditorStaticDeps.logger.trace().log("Too busy to handle: ", event);
cancel = true;
} else {
// Cache the selection across multiple calls to Selection.get(), for the
// duration of this event handler. We turn caching off again in the finally
// block below.
NativeSelectionUtil.cacheOn();
// Normal event handling
responsibility.startDirectSequence();
try {
if (settings.getInstrumentor().shouldInstrument(event)) {
Duration timer = new Duration();
cancel = eventHandler.handleEvent(event);
processTiming = timer.elapsedMillis();
instrumentedDuration = new Duration();
} else {
cancel = eventHandler.handleEvent(event);
}
} finally {
responsibility.endDirectSequence();
}
// Alert others that something has happened:
// NOTE(user): We notify with "user-input" true if we believe this to
// be a navigation or input event that moves the selection. Events such
// as mouse scroll are not classified as "user-input" because a user
// might want to scroll and look at other blips while in edit mode, we
// shouldn't change the viewport back to the cursor if the user is
// trying to look at another page
if (!isMutationEvent) {
// TODO(user): Remove this check
boolean trackCursor = shouldTrackCursor(event);
scheduleSaveSelection();
scheduleUpdateNotification(trackCursor, trackCursor, false, false);
}
}
if (cancel && !isMutationEvent) {
EditorStaticDeps.logger.trace().log("Prevent default: ", event);
// The above code handled the event, so we prevent the browser's
// default action
rawEvent.preventDefault();
// Check health if in debug build
debugCheckHealth();
}
} finally {
EditorStaticDeps.endIgnoreMutations();
}
}
// NOTE(danilatos): Comment this block for decreased reliability
// but increased ease of bug finding
} catch (OperationRuntimeException e) {
EditorStaticDeps.logger.error().log("Operation Exception - probably an invalid operation -> "
+ "All bets are off!!! Not even going to try to repair!!");
GWT.getUncaughtExceptionHandler().onUncaughtException(e);
repairer.showDeath(full().getDocumentElement());
rawEvent.preventDefault();
throw e;
} catch (LocalOperationException e) {
// Propagate exception if assertions are turned on.
try {
assert false;
EditorStaticDeps.logger.error().logPlainText("Invalid local operation swallowed " + e);
} catch (AssertionError ae) {
throw e;
}
} catch (RuntimeException e) {
try {
// We'll try to repair even though we're re-throwing, because some users keep
// using the client after it shinies, and some clients using the editor might
// have a different uncaught exception behaviour.
EditorStaticDeps.logger.error().log("Repairing: " + e);
// TODO(danilatos): This is a bit coarse, do more accurate/user friendly handling
ContentElement el = Element.is(rawEvent.getEventTarget())
? nodeManager.findElementWrapper(Element.as(rawEvent.getEventTarget())) : null;
if (el == null) {
repairListener.onFullDocumentRevert(mutable());
// Destroy all rendering
ContentDocument savedDoc = removeContent();
savedDoc.setShelved();
// Re-insert document to re-render from scratch
setContent(savedDoc);
repairer.flashShowRepair(full().getDocumentElement());
} else {
repairer.revert(
Point.inElement(el, el.getFirstChild()),
Point.inElement(el, (ContentNode)null));
}
rawEvent.preventDefault();
} finally {
// Rethrow on to GWT's uncaught exception handler.
// Placed in a finally block to guarantee the original, more
// interesting exception is re-thrown, not an exception potentially
// thrown by this catch block.
// if (true) workaround for an eclipse warning about a finally block
// not completing normally.
if (true) {
throw e;
}
}
} finally {
NativeSelectionUtil.cacheOff();
}
if (instrumentedDuration != null) { // was instrumented
// schedule, executing the previous one first if it hasn't been reported yet.
if (SchedulerInstance.getHighPriorityTimer().isScheduled(instrumentationTask)) {
instrumentationTask.execute();
}
// set up and instrument
processKeyPressTimer = processTiming;
postKeyPressTimer = instrumentedDuration;
SchedulerInstance.getHighPriorityTimer().schedule(instrumentationTask);
}
}
// TODO(user): Remove this code, it does not belong in the editor.
private boolean shouldTrackCursor(SignalEvent event) {
if (event.isMouseButtonEvent()) {
return true;
}
if (event.isKeyEvent()) {
KeySignalType keySignalType = event.getKeySignalType();
// The cursor location should move if the user either has modified the
// content (typed or delete), or move the cursor deliberately. However, page up/down
// doesn't actually move the cursor, so we don't want to move the view port
int keyCode = event.getKeyCode();
return keySignalType == KeySignalType.INPUT ||
keySignalType == KeySignalType.DELETE ||
keySignalType == KeySignalType.NAVIGATION && (
keyCode != KeyCodes.KEY_PAGEDOWN &&
keyCode != KeyCodes.KEY_PAGEUP);
}
return false;
}
/**
* @param evt
*/
private boolean fireKeyboardEvent(SignalEvent evt) {
boolean handled = false;
if (keySignalListeners != null) {
for (KeySignalListener l : keySignalListeners) {
// "|| handled" at end of line to avoid short circuiting
handled = l.onKeySignal(this, evt) || handled;
}
}
return handled;
}
private boolean debugDisabled = false;
/**
* If disabled, the editor will essentially just turn into a content editable div.
* It will still pass events to listeners in any case.
* @param isDisabled
*/
public void debugSetDisabled(boolean isDisabled) {
debugDisabled = isDisabled;
}
/**
* @return Whether or not the editor is in a disabled state
*/
public boolean debugIsDisabled() {
return debugDisabled;
}
/**
* @return a reference to the update event implementation for further debugging
*/
public EditorUpdateEventImpl debugGetUpdateEventImpl() {
return updateEvent;
}
// public boolean debug
//
// "Syntatic" (interpretation-free) editor states.
//
private boolean isPasting() {
return pasteExtractor != null && pasteExtractor.isBusy();
}
private boolean isTyping() {
// if typing != null, then domMutationReverter != null, so that's an adequate check.
// if typing == null, then domMutationReverter will most likely also be null.
assert (typing == null) == (domMutationReverter == null);
return typing != null && (typing.isBusy() || domMutationReverter.hasPendingReverts());
}
//
// Intermediate semantic (interpretation-full) states. These states should be defined in terms
// of either the syntactic states above, or other intermediate semantic states.
//
/**
* @return whether the editor is handling a browser-event, and the handling
* lgoic does not require other events to be blocked.
*/
private boolean isHandlingNonblockingEvent() {
return isTyping();
}
/**
* @return whether the editor is handling a browser-event, where the
* handling logic requires all future browser-events to be cancelled
* (prevented) until the handling of this event has completed.
*/
private boolean isHandlingBlockingEvent() {
return isPasting();
}
/**
* Tests whether this editor's document includes the intent of all past
* browser-events. The document may become inconsistent because some
* document mutations are caused by delayed-inspection of mutations performed
* by the browser, and some other mutations are carried out in a time-sliced
* asynchronous manner.
*
* @return whether the editor's document is consistent with all past
* browser-events.
*/
private boolean isConsistentWithEventHistory() {
return !isHandlingBlockingEvent() && !isHandlingNonblockingEvent();
}
/**
* Tests whether this editor's document is in a consistent state with
* respect to the operation stream to which it pushes operations.
* Specifically, this means that the current document state is precisely
* that which would be produced by applying all operations that this editor
* has output to the state with which this editor was
* {@link #setContent(DocInitialization, DocumentSchema) initialized}.
*
* @return whether the editor's document is consistent with the outgoing
* operation stream.
*/
private boolean isConsistentWithOutgoingOperationStream() {
// NOTE(danilatos): Used to be false sometimes when the editor used to
// queue ops. Clean this logic up later.
return true;
}
/**
* Tests whether the editor's document is in a consistent state.
*
* Consistency is defined in terms of the stack in which the editor sits,
* with an operation stream beneath it and a (browser-)event generator on
* top. This editor's document is in a consistent state if it includes the
* intent of all browser-events that have happened before now (i.e., is
* consistent with the state from the layer above), and has pushed out
* operations to the operation stream that reflect those mutations (i.e.,
* is also consistent with the layer below).
*
* Pragmatically, consistency means that if the editor's browser-event stream
* is stopped, then the editor can be discarded without loss of information.
*
* @return whether the editor's document is consistent.
*/
public boolean isConsistent() {
if (content == null) {
return true;
}
return isConsistentWithEventHistory() && isConsistentWithOutgoingOperationStream();
}
//
// Top-level semantic states. These should be defined only in terms of intermediate semantic
// states (or syntactic states) in order to keep top-level concerns separated.
//
/**
* Tests whether this editor is in a state where it can handle an arbitrary
* browser-event.
*
* @return whether the editor can handle browser events.
*/
private boolean canHandleBrowserEvents() {
return !isHandlingBlockingEvent();
}
/**
* Tests whether this editor is in a state where it is safe to apply an
* incoming operation to its document.
*
* @return whether an incoming operation can be applied to this editor's
* document.
*/
private boolean canApplyIncomingOperations() {
return permitOperations && isConsistent();
}
private UniversalPopup getDebugDialog() {
if (debugPopup == null) {
debugPopup = DebugPopupFactory.create(this);
}
return debugPopup;
}
@Override
public void debugToggleDebugDialog() {
if (settings.hasDebugDialog()) {
UniversalPopup popup = getDebugDialog();
if (popup.isShowing()) {
popup.hide();
} else {
popup.show();
}
}
}
private void notifyTypingExtractor(Point<Node> htmlCaret) {
Preconditions.checkNotNull(htmlCaret, "Notifying typing extractor with invalid selection");
try {
typing.somethingHappened(htmlCaret);
} catch (HtmlMissing e) {
repairer.handleMissing(e);
} catch (HtmlInserted e) {
repairer.handleInserted(e);
}
}
/**
* {@inheritDoc}
*/
@Override
public void addKeySignalListener(KeySignalListener listener) {
if (keySignalListeners == null) {
keySignalListeners = new HashSet<KeySignalListener>();
}
keySignalListeners.add(listener);
}
@Override
public void removeKeySignalListener(KeySignalListener listener) {
if (keySignalListeners != null) {
keySignalListeners.remove(listener);
}
}
private void onIncomingOp(DocOp operation) {
EditorStaticDeps.logger.trace().logLazyObjects("Incoming operation", operation);
if (!hasDocument()) {
// This is potentially recoverable, consider throwing an exception that
// can be handled by the wave client.
throw new IllegalStateException("Cannot apply op to uninitialized editor");
}
try {
editorUndoManager.nonUndoableOp(operation);
debugCheckHealth();
} finally {
// alert others on change:
// TODO(mtsui/danilatos): We sometimes want incoming ops to notify that the
// selection coordinates might have changed - if the user cares about the
// focus of their caret. If they've scrolled away though, this could be annoying.
// Therefore, we actually need some stateful logic to handle this,
scheduleUpdateNotification(false, false, true, false);
}
}
/**
* {@inheritDoc}
*/
@Override
public DocInitialization getDocumentInitialization() {
return content.asOperation();
}
/**
* Asserts that editor is healthy
*/
private void debugAssertHealthy() {
assert content.debugCheckHealthy2();
//TODO(danilatos): Re-implement something equivalent
// Assert.assertEquals("Content should match shadow",
// ContentXmlString.createChildren(shadow), ContentXmlString.createChildren(content));
// content.debugAssertHealthy();
}
/**
* Checks health if in debug build + logs errors
*/
@VisibleForTesting
void debugCheckHealth() {
if (LogLevel.showErrors()) {
try {
debugAssertHealthy();
} catch (Throwable t) {
GWT.getUncaughtExceptionHandler().onUncaughtException(t);
}
}
}
@Override
protected void onAttach() {
super.onAttach();
EditorWebDriverUtil.register(this, div);
}
@Override
protected void onDetach() {
EditorWebDriverUtil.unregister(div);
super.onDetach();
}
/// Editor updates listening system
private void scheduleUpdateNotification(
boolean selectionCoordsChanged,
boolean selectionLocationChanged,
boolean contentChanged,
boolean userDirectlyChangedContent) {
updateEvent.scheduleUpdateNotification(
selectionCoordsChanged, selectionLocationChanged, contentChanged,
userDirectlyChangedContent);
}
@Override
public void addUpdateListener(EditorUpdateEvent.EditorUpdateListener listener) {
updateEvent.addUpdateListener(listener);
}
@Override
public void removeUpdateListener(EditorUpdateEvent.EditorUpdateListener listener) {
updateEvent.removeUpdateListener(listener);
}
@Override
public void flushUpdates() {
updateEvent.flushUpdates();
}
public EditorEventHandler debugGetEventHandler() {
return eventHandler;
}
////// Editor context methods
@Override
public String getImeCompositionState() {
return imeExtractor.getContent();
}
@Override
public CMutableDocument getDocument() {
checkContextConsistency();
return mutable();
}
@Override
public CaretAnnotations getCaretAnnotations() {
Preconditions.checkState(caretStyles != null,
"Using the caret annotations of an editor not set up.");
checkContextConsistency();
return caretStyles;
}
@Override
public SelectionHelper getSelectionHelper() {
Preconditions.checkState(passiveSelectionHelper != null,
"Using the selection helper of an editor not set up.");
checkContextConsistency();
return passiveSelectionHelper;
}
/** Called when the editor should be consistent - forces it, and logs if update needed. */
private void checkContextConsistency() {
if (!isConsistentWithEventHistory()) {
flushSynchronous();
EditorStaticDeps.logger.error().log(
"Editor context methods called while editor is not consistent.");
}
}
/**
* Only use for unit tests!!
*/
public PasteExtractor debugGetPasteExtractor() {
return pasteExtractor;
}
/**
* Disconnects or connects the editor from the CC stack by rejecting all
* incoming operations (forcing them to defer until later) and bufferring all
* outgoing operations.
*
* It is safe to reconnect the editor at a later time after disconnecting, by
* calling method.
*
* @param isConnected true to enable suppression, false to disable
*/
public void debugConnectOpSinks(boolean isConnected) {
if (permitOperations == isConnected) {
return;
}
permitOperations = isConnected;
if (permitOperations) {
for (DocOp op : suppressedOutgoingOps) {
outgoingOperationSink.consume(op);
}
suppressedOutgoingOps = null;
ScheduleCommand.addCommand(consistencyQueue);
} else {
suppressedOutgoingOps = new ArrayList<DocOp>();
}
}
/**
* @see #debugConnectOpSinks(boolean)
*/
public boolean debugIsConnected() {
return permitOperations;
}
@Override
public void flushAnnotationPainting() {
content.flushAnnotationPainting();
}
@Override
public String toString() {
return "Editor: [Content: " + content + "]";
}
public static final Renderer SIMPLE_RENDERER = new Renderer() {
@Override
public Element createDomImpl(Renderable element) {
return element.setAutoAppendContainer(Document.get().createElement(element.getTagName()));
}
};
}