// Copyright 2012 Google Inc. All Rights Reserved.
//
// 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 com.google.collide.client.editor;
import com.google.collide.client.AppContext;
import com.google.collide.client.code.parenmatch.ParenMatchHighlighter;
import com.google.collide.client.document.linedimensions.LineDimensionsCalculator;
import com.google.collide.client.editor.Buffer.ScrollListener;
import com.google.collide.client.editor.gutter.Gutter;
import com.google.collide.client.editor.gutter.LeftGutterManager;
import com.google.collide.client.editor.input.InputController;
import com.google.collide.client.editor.renderer.LineRenderer;
import com.google.collide.client.editor.renderer.RenderTimeExecutor;
import com.google.collide.client.editor.renderer.Renderer;
import com.google.collide.client.editor.search.SearchMatchRenderer;
import com.google.collide.client.editor.search.SearchModel;
import com.google.collide.client.editor.selection.CursorView;
import com.google.collide.client.editor.selection.LocalCursorController;
import com.google.collide.client.editor.selection.SelectionLineRenderer;
import com.google.collide.client.editor.selection.SelectionManager;
import com.google.collide.client.editor.selection.SelectionModel;
import com.google.collide.client.util.CssUtils;
import com.google.collide.client.util.Elements;
import com.google.collide.client.util.dom.FontDimensionsCalculator;
import com.google.collide.client.util.dom.FontDimensionsCalculator.FontDimensions;
import com.google.collide.json.shared.JsonArray;
import com.google.collide.mvp.CompositeView;
import com.google.collide.mvp.UiComponent;
import com.google.collide.shared.document.Document;
import com.google.collide.shared.document.Line;
import com.google.collide.shared.document.LineInfo;
import com.google.collide.shared.document.TextChange;
import com.google.collide.shared.util.JsonCollections;
import com.google.collide.shared.util.ListenerManager;
import com.google.collide.shared.util.ListenerRegistrar;
import com.google.collide.shared.util.ListenerManager.Dispatcher;
import com.google.common.annotations.VisibleForTesting;
import com.google.gwt.resources.client.CssResource;
import com.google.gwt.resources.client.ImageResource;
import org.waveprotocol.wave.client.common.util.SignalEvent;
import elemental.events.Event;
import elemental.html.Element;
/**
* The presenter for the Collide editor.
*
* This class composes many of the other classes that together form the editor.
* For example, the area where the text is displayed, the {@link Buffer}, is a
* nested presenter. Other components are not presenters, such as the input
* mechanism which is handled by the {@link InputController}.
*
* If an added element wants native browser selection, you must not inherit the
* "user-select" CSS property. See
* {@link CssUtils#setUserSelect(Element, boolean)}.
*/
public class Editor extends UiComponent<Editor.View> {
/**
* Static factory method for obtaining an instance of the Editor.
*/
public static Editor create(AppContext appContext) {
FontDimensionsCalculator fontDimensionsCalculator =
FontDimensionsCalculator.get(appContext.getResources().workspaceEditorCss().editorFont());
RenderTimeExecutor renderTimeExecutor = new RenderTimeExecutor();
LineDimensionsCalculator lineDimensions =
LineDimensionsCalculator.create(fontDimensionsCalculator);
Buffer buffer =
Buffer.create(appContext, fontDimensionsCalculator.getFontDimensions(), lineDimensions,
renderTimeExecutor);
InputController input = new InputController();
View view =
new View(appContext.getResources(), buffer.getView().getElement(), input.getInputElement());
FocusManager focusManager = new FocusManager(buffer, input.getInputElement());
return new Editor(appContext, view, buffer, input, focusManager, fontDimensionsCalculator,
renderTimeExecutor);
}
/**
* Animation CSS.
*/
@CssResource.Shared
public interface EditorSharedCss extends CssResource {
String animationEnabled();
String scrollable();
}
/**
* CssResource for the editor.
*/
public interface Css extends EditorSharedCss {
String leftGutter();
String editorFont();
String root();
String scrolled();
String gutter();
String lineRendererError();
}
/**
* A listener that is called when the user presses a key.
*/
public interface KeyListener {
/*
* The reason for preventDefault() not preventing default behavior is that
* Firefox does not have support the defaultPrevented attribute, so we have
* know way of knowing if it was prevented from the native event. We could
* create a proxy for SignalEvent to note calls to preventDefault(), but
* this would not catch the case that the implementor interacts directly to
* the native event.
*/
/**
* @param event the event for the key press. Note: Calling preventDefault()
* may not prevent the default behavior in some cases. The return
* value of this method is a better channel for indicating the
* default behavior should be prevented.
* @return true if the event was handled (the default behavior will not run
* in this case), false to proceed with the default behavior. Even
* if true is returned, other listeners will still get the callback
*/
boolean onKeyPress(SignalEvent event);
}
/**
* A listener that is called on "keyup" native event.
*/
public interface NativeKeyUpListener {
/**
* @param event the event for the key up
* @return true if the event was handled, false to proceed with default
* behavior
*/
boolean onNativeKeyUp(Event event);
}
/**
* ClientBundle for the editor.
*/
public interface Resources
extends
Buffer.Resources,
CursorView.Resources,
SelectionLineRenderer.Resources,
SearchMatchRenderer.Resources,
ParenMatchHighlighter.Resources {
@Source({"Editor.css", "constants.css"})
Css workspaceEditorCss();
@Source("squiggle.gif")
ImageResource squiggle();
}
/**
* A listener that is called after the user enters or deletes text and before
* it is applied to the document.
*/
public interface BeforeTextListener {
/**
* Note: You should not mutate the document within this callback, as this is
* not supported yet and can lead to other clients having stale position
* information inside the {@code textChange}.
*
* Note: The {@link TextChange} contains a reference to the live
* {@link Line} from the document model. If you hold on to a reference after
* {@link #onBeforeTextChange} returns, beware that the contents of the
* {@link Line} could change, invalidating some of the state in the
* {@link TextChange}.
*
* @param textChange the text change whose last line will be the same as the
* insertion point (since the text hasn't been inserted yet)
*/
void onBeforeTextChange(TextChange textChange);
}
/**
* A listener that is called when the user enters or deletes text.
*
* Similar to {@link Document.TextListener} except is only called when the
* text is entered/deleted by the local user.
*/
public interface TextListener {
/**
* Note: You should not mutate the document within this callback, as this is
* not supported yet and can lead to other clients having stale position
* information inside the {@code textChange}.
*
* Note: The {@link TextChange} contains a reference to the live
* {@link Line} from the document model. If you hold on to a reference after
* {@link #onTextChange} returns, beware that the contents of the
* {@link Line} could change, invalidating some of the state in the
* {@link TextChange}.
*/
void onTextChange(TextChange textChange);
}
/**
* A listener that is called when the document changes.
*
* This can be used by external clients of the editor; if the client is a
* component of the editor, use {@link Editor#setDocument(Document)} instead.
*/
public interface DocumentListener {
void onDocumentChanged(Document oldDocument, Document newDocument);
}
/**
* A listener that is called when the editor becomes or is no longer
* read-only.
*/
public interface ReadOnlyListener {
void onReadOnlyChanged(boolean isReadOnly);
}
/**
* The view for the editor, containing gutters and the buffer. This exposes
* only the ability to enable or disable animations.
*/
public static class View extends CompositeView<Void> {
private final Element bufferElement;
final Css css;
final Resources res;
private View(Resources res, Element bufferElement, Element inputElement) {
this.res = res;
this.bufferElement = bufferElement;
this.css = res.workspaceEditorCss();
Element rootElement = Elements.createDivElement(css.root());
rootElement.appendChild(bufferElement);
rootElement.appendChild(inputElement);
setElement(rootElement);
}
private void addGutter(Element gutterElement) {
getElement().insertBefore(gutterElement, bufferElement);
}
private void removeGutter(Element gutterElement) {
getElement().removeChild(gutterElement);
}
public void setAnimationEnabled(boolean enabled) {
// TODO: Re-enable animations when they are stable.
if (enabled) {
// getElement().addClassName(css.animationEnabled());
} else {
// getElement().removeClassName(css.animationEnabled());
}
}
public Resources getResources() {
return res;
}
}
public static final int ANIMATION_DURATION = 100;
private static int idCounter = 0;
private final AppContext appContext;
private final Buffer buffer;
private Document document;
private final ListenerManager<DocumentListener> documentListenerManager =
ListenerManager.create();
private final EditorDocumentMutator editorDocumentMutator;
private final FontDimensionsCalculator editorFontDimensionsCalculator;
private EditorUndoManager editorUndoManager;
private final FocusManager focusManager;
private final MouseHoverManager mouseHoverManager;
private final int id = idCounter++;
private final FontDimensionsCalculator.Callback fontDimensionsChangedCallback =
new FontDimensionsCalculator.Callback() {
@Override
public void onFontDimensionsChanged(FontDimensions fontDimensions) {
handleFontDimensionsChanged();
}
};
private final JsonArray<Gutter> gutters = JsonCollections.createArray();
private final InputController input;
private final LeftGutterManager leftGutterManager;
private LocalCursorController localCursorController;
private final ListenerManager<ReadOnlyListener> readOnlyListenerManager = ListenerManager
.create();
private Renderer renderer;
private SearchModel searchModel;
private SelectionManager selectionManager;
private final EditorActivityManager editorActivityManager;
private ViewportModel viewport;
private boolean isReadOnly;
private final RenderTimeExecutor renderTimeExecutor;
private Editor(AppContext appContext, View view, Buffer buffer, InputController input,
FocusManager focusManager, FontDimensionsCalculator editorFontDimensionsCalculator,
RenderTimeExecutor renderTimeExecutor) {
super(view);
this.appContext = appContext;
this.buffer = buffer;
this.input = input;
this.focusManager = focusManager;
this.editorFontDimensionsCalculator = editorFontDimensionsCalculator;
this.renderTimeExecutor = renderTimeExecutor;
Gutter leftGutter = createGutter(
false, Gutter.Position.LEFT, appContext.getResources().workspaceEditorCss().leftGutter());
leftGutterManager = new LeftGutterManager(leftGutter, buffer);
editorDocumentMutator = new EditorDocumentMutator(this);
mouseHoverManager = new MouseHoverManager(this);
editorActivityManager =
new EditorActivityManager(appContext.getUserActivityManager(),
buffer.getScrollListenerRegistrar(), getKeyListenerRegistrar());
// TODO: instantiate input from here
input.initializeFromEditor(this, editorDocumentMutator);
setAnimationEnabled(true);
addBoxShadowOnScrollHandler();
editorFontDimensionsCalculator.addCallback(fontDimensionsChangedCallback);
}
private void handleFontDimensionsChanged() {
buffer.repositionAnchoredElementsWithColumn();
if (renderer != null) {
/*
* TODO: think about a scheme where we don't have to rerender
* the whole viewport (currently we do because of the right-side gap
* fillers)
*/
renderer.renderAll();
}
}
/**
* Adds a scroll handler to the buffer scrollableElement so that a drop shadow
* can be added and removed when scrolled.
*/
private void addBoxShadowOnScrollHandler() {
if (true) {
// TODO: investigate why this kills performance
return;
}
this.buffer.getScrollListenerRegistrar().add(new ScrollListener() {
@Override
public void onScroll(Buffer buffer, int scrollTop) {
if (scrollTop < 20) {
getElement().removeClassName(getView().css.scrolled());
} else {
getElement().addClassName(getView().css.scrolled());
}
}
});
}
public void addLineRenderer(LineRenderer lineRenderer) {
/*
* TODO: Because the line renderer is document-scoped, line
* renderers have to re-add themselves whenever the document changes. This
* is unexpected.
*/
renderer.addLineRenderer(lineRenderer);
}
public Gutter createGutter(boolean overviewMode, Gutter.Position position, String cssClassName) {
Gutter gutter = Gutter.create(overviewMode, position, cssClassName, buffer);
if (viewport != null && renderer != null) {
gutter.handleDocumentChanged(viewport, renderer);
}
gutters.add(gutter);
gutter.getGutterElement().addClassName(getView().css.gutter());
getView().addGutter(gutter.getGutterElement());
return gutter;
}
public void removeGutter(Gutter gutter) {
getView().removeGutter(gutter.getGutterElement());
gutters.remove(gutter);
}
public void setAnimationEnabled(boolean enabled) {
getView().setAnimationEnabled(enabled);
}
public ListenerRegistrar<BeforeTextListener> getBeforeTextListenerRegistrar() {
return editorDocumentMutator.getBeforeTextListenerRegistrar();
}
public Buffer getBuffer() {
return buffer;
}
/*
* TODO: if left gutter manager gets public API, expose that
* instead of directly exposign the gutter. Or, if we don't want to expose
* Gutter#setWidth publicly for the left gutter, make LeftGutterManager the
* public API.
*/
public Gutter getLeftGutter() {
return leftGutterManager.getGutter();
}
public Document getDocument() {
return document;
}
/**
* Returns a document mutator that will also notify editor text listeners.
*/
public EditorDocumentMutator getEditorDocumentMutator() {
return editorDocumentMutator;
}
public Element getElement() {
return getView().getElement();
}
public FocusManager getFocusManager() {
return focusManager;
}
public MouseHoverManager getMouseHoverManager() {
return mouseHoverManager;
}
public ListenerRegistrar<KeyListener> getKeyListenerRegistrar() {
return input.getKeyListenerRegistrar();
}
public ListenerRegistrar<NativeKeyUpListener> getNativeKeyUpListenerRegistrar() {
return input.getNativeKeyUpListenerRegistrar();
}
public Renderer getRenderer() {
return renderer;
}
public SearchModel getSearchModel() {
return searchModel;
}
public SelectionModel getSelection() {
return selectionManager.getSelectionModel();
}
public LocalCursorController getCursorController() {
return localCursorController;
}
public ListenerRegistrar<TextListener> getTextListenerRegistrar() {
return editorDocumentMutator.getTextListenerRegistrar();
}
public ListenerRegistrar<DocumentListener> getDocumentListenerRegistrar() {
return documentListenerManager;
}
// TODO: need a public interface and impl
public ViewportModel getViewport() {
return viewport;
}
public boolean isMutatingDocumentFromUndoOrRedo() {
return editorUndoManager.isMutatingDocument();
}
public void removeLineRenderer(LineRenderer lineRenderer) {
renderer.removeLineRenderer(lineRenderer);
}
public void setDocument(final Document document) {
final Document oldDocument = this.document;
if (oldDocument != null) {
// Teardown the objects depending on the old document
renderer.teardown();
viewport.teardown();
selectionManager.teardown();
localCursorController.teardown();
editorUndoManager.teardown();
searchModel.teardown();
}
this.document = document;
/*
* TODO: dig into each component, figure out dependencies,
* break apart components so we can reduce circular dependencies which
* require the multiple stages of initialization
*/
// Core editor components
buffer.handleDocumentChanged(document);
leftGutterManager.handleDocumentChanged(document);
selectionManager =
SelectionManager.create(document, buffer, focusManager, appContext.getResources());
SelectionModel selection = selectionManager.getSelectionModel();
viewport = ViewportModel.create(document, selection, buffer);
input.handleDocumentChanged(document, selection, viewport);
renderer = Renderer.create(document,
viewport,
buffer,
getLeftGutter(),
selection,
focusManager,
this,
appContext.getResources(),
renderTimeExecutor);
// Delayed core editor component initialization
viewport.initialize();
selection.initialize(viewport);
selectionManager.initialize(renderer);
buffer.handleComponentsInitialized(viewport, renderer);
for (int i = 0, n = gutters.size(); i < n; i++) {
gutters.get(i).handleDocumentChanged(viewport, renderer);
}
// Non-core editor components
editorUndoManager = EditorUndoManager.create(this, document, selection);
searchModel = SearchModel.create(appContext,
document,
renderer,
viewport,
selection,
editorDocumentMutator);
localCursorController =
LocalCursorController.create(appContext, focusManager, selection, buffer, this);
documentListenerManager.dispatch(new Dispatcher<Editor.DocumentListener>() {
@Override
public void dispatch(DocumentListener listener) {
listener.onDocumentChanged(oldDocument, document);
}
});
}
public void undo() {
editorUndoManager.undo();
}
public void redo() {
editorUndoManager.redo();
}
public void scrollTo(int lineNumber, int column) {
if (document != null) {
LineInfo lineInfo = document.getLineFinder().findLine(lineNumber);
/*
* TODO: the cursor will be the last line in the viewport,
* fix this
*/
SelectionModel selectionModel = getSelection();
selectionModel.deselect();
selectionModel.setCursorPosition(lineInfo, column);
}
}
public void cleanup() {
editorFontDimensionsCalculator.removeCallback(fontDimensionsChangedCallback);
editorActivityManager.teardown();
}
public void setReadOnly(final boolean isReadOnly) {
if (this.isReadOnly == isReadOnly) {
return;
}
this.isReadOnly = isReadOnly;
readOnlyListenerManager.dispatch(new Dispatcher<Editor.ReadOnlyListener>() {
@Override
public void dispatch(ReadOnlyListener listener) {
listener.onReadOnlyChanged(isReadOnly);
}
});
}
public boolean isReadOnly() {
return isReadOnly;
}
public ListenerRegistrar<ReadOnlyListener> getReadOnlyListenerRegistrar() {
return readOnlyListenerManager;
}
public int getId() {
return id;
}
@VisibleForTesting
public InputController getInput() {
return input;
}
public void setLeftGutterVisible(boolean visible) {
Element gutterElement = leftGutterManager.getGutter().getGutterElement();
if (visible) {
getView().addGutter(gutterElement);
} else {
getView().removeGutter(gutterElement);
}
}
}