package org.swellrt.beta.client.js.editor; import java.util.Map; import java.util.function.Consumer; import org.swellrt.beta.client.ServiceBasis; import org.swellrt.beta.client.ServiceBasis.ConnectionHandler; import org.swellrt.beta.client.ServiceFrontend; import org.swellrt.beta.client.js.Console; import org.swellrt.beta.client.js.JsUtils; import org.swellrt.beta.client.js.editor.annotation.Annotation; import org.swellrt.beta.client.js.editor.annotation.AnnotationAction; import org.swellrt.beta.client.js.editor.annotation.AnnotationInstance; import org.swellrt.beta.client.js.editor.annotation.AnnotationRegistry; import org.swellrt.beta.client.js.editor.annotation.ParagraphAnnotation; import org.swellrt.beta.common.SException; import org.waveprotocol.wave.client.account.ProfileManager; import org.waveprotocol.wave.client.common.util.JsoView; import org.waveprotocol.wave.client.common.util.LogicalPanel; import org.waveprotocol.wave.client.common.util.UserAgent; import org.waveprotocol.wave.client.doodad.link.LinkAnnotationHandler; import org.waveprotocol.wave.client.doodad.link.LinkAnnotationHandler.LinkAttributeAugmenter; import org.waveprotocol.wave.client.doodad.selection.CaretAnnotationHandler; import org.waveprotocol.wave.client.doodad.selection.SelectionExtractor; import org.waveprotocol.wave.client.editor.Editor; import org.waveprotocol.wave.client.editor.EditorImpl; import org.waveprotocol.wave.client.editor.EditorImplWebkitMobile; import org.waveprotocol.wave.client.editor.EditorSettings; import org.waveprotocol.wave.client.editor.EditorStaticDeps; import org.waveprotocol.wave.client.editor.EditorUpdateEvent; import org.waveprotocol.wave.client.editor.EditorUpdateEvent.EditorUpdateListener; import org.waveprotocol.wave.client.editor.Editors; import org.waveprotocol.wave.client.editor.content.CMutableDocument; import org.waveprotocol.wave.client.editor.content.ContentDocument; import org.waveprotocol.wave.client.editor.content.misc.StyleAnnotationHandler; import org.waveprotocol.wave.client.editor.content.paragraph.LineRendering; import org.waveprotocol.wave.client.editor.keys.KeyBindingRegistry; import org.waveprotocol.wave.client.editor.util.EditorAnnotationUtil; import org.waveprotocol.wave.client.scheduler.SchedulerInstance; import org.waveprotocol.wave.client.widget.popup.PopupChrome; import org.waveprotocol.wave.client.widget.popup.PopupChromeProvider; import org.waveprotocol.wave.client.widget.popup.simple.Popup; import org.waveprotocol.wave.common.logging.AbstractLogger; import org.waveprotocol.wave.common.logging.AbstractLogger.Level; import org.waveprotocol.wave.common.logging.LogSink; import org.waveprotocol.wave.model.conversation.Blips; import org.waveprotocol.wave.model.document.RangedAnnotation; import org.waveprotocol.wave.model.document.util.DocHelper; import org.waveprotocol.wave.model.document.util.LineContainers; import org.waveprotocol.wave.model.document.util.Range; import org.waveprotocol.wave.model.util.StringSet; import com.google.gwt.core.client.JavaScriptObject; import com.google.gwt.core.client.JsArrayString; import com.google.gwt.dom.client.Document; import com.google.gwt.dom.client.Element; import com.google.gwt.user.client.DOM; import jsinterop.annotations.JsFunction; import jsinterop.annotations.JsIgnore; import jsinterop.annotations.JsOptional; import jsinterop.annotations.JsType; @JsType(namespace = "swellrt", name = "Editor") public class SEditor implements EditorUpdateListener { public final static String COMPAT_MODE_NONE = "none"; public final static String COMPAT_MODE_READONLY = "readonly"; public final static String COMPAT_MODE_EDIT = "edit"; @JsFunction public interface SelectionChangeHandler { void exec(Range range, SEditor editor, SSelection node); } // // Static private properties // private static final String TOPLEVEL_CONTAINER_TAGNAME = "body"; /* * A browser's console log */ protected static class ConsoleLogSink extends LogSink { @Override public void log(Level level, String message) { Console.log("[" + level.name() + "] " + message); } @Override public void lazyLog(Level level, Object... messages) { for (Object o : messages) { log(level, o.toString()); } } } /** * */ protected static class CustomLogger extends AbstractLogger { private boolean enabled = SEditorConfig.enableLog(); public CustomLogger(LogSink sink) { super(sink); } @Override public boolean isModuleEnabled() { return enabled; } @Override protected boolean shouldLog(Level level) { return enabled; } } // // Put together here all static initialization. // Delegate all registry instances to those in Editor class, // but put here all initialization logic. // // Use always Editor.ROOT_REGISTRIES as reference for editor's registers protected static CaretAnnotationHandler caretAnnotationHandler; static { if (SEditorConfig.enableLog()) EditorStaticDeps.logger = new CustomLogger(new ConsoleLogSink()); Editors.initRootRegistries(); EditorStaticDeps.setPopupProvider(Popup.LIGHTWEIGHT_POPUP_PROVIDER); EditorStaticDeps.setPopupChromeProvider(new PopupChromeProvider() { public PopupChrome createPopupChrome() { return null; } }); // // Register Doodads: all are statically handled // // Code taken from RegistriesHolder Blips.init(); LineRendering.registerContainer(TOPLEVEL_CONTAINER_TAGNAME, Editor.ROOT_REGISTRIES.getElementHandlerRegistry()); LineContainers.setTopLevelContainerTagname(TOPLEVEL_CONTAINER_TAGNAME); StyleAnnotationHandler.register(Editor.ROOT_REGISTRIES); // Listen for Diff annotations to paint new content or to insert a // delete-content tag // to be rendered by the DiffDeleteRendere /* DiffAnnotationHandler.register( Editor.ROOT_REGISTRIES.getAnnotationHandlerRegistry(), Editor.ROOT_REGISTRIES.getPaintRegistry()); DiffDeleteRenderer.register( Editor.ROOT_REGISTRIES.getElementHandlerRegistry()); */ caretAnnotationHandler = CaretAnnotationHandler.register(Editor.ROOT_REGISTRIES); // // Reuse existing link annotation handler, but also support external // controller to get notified on mutation or input events // LinkAnnotationHandler.register(Editor.ROOT_REGISTRIES, new LinkAttributeAugmenter() { @Override public Map<String, String> augment(Map<String, Object> annotations, boolean isEditing, Map<String, String> current) { return current; } }); // TODO register widgets. Widgets definitions are (so far) statically registered // so they shouldn't be associated with any particular instance of SEditor /* widgetRegistry.each(new ProcV<JsoWidgetController>() { @Override public void apply(String key, JsoWidgetController value) { value.setEditorJsFacade(editorJsFacade); } }); WidgetDoodad.register(Editor.ROOT_REGISTRIES.getElementHandlerRegistry(), widgetRegistry); */ // Debugging user agent if (SEditorConfig.enableLog()) { EditorStaticDeps.logger.trace().log("User Agent String: "+UserAgent.debugUserAgentString()); String s = ""; s += "Android: "+UserAgent.isAndroid()+", "; s += "IPhone: "+UserAgent.isIPhone()+", "; s += "Linux: "+UserAgent.isLinux()+", "; s += "Mac: "+UserAgent.isMac()+", "; s += "Win: "+UserAgent.isWin()+", "; s += "Mobile Webkit: "+UserAgent.isMobileWebkit()+", "; s += "Webkit: "+UserAgent.isWebkit()+", "; s += "Safari: "+UserAgent.isSafari()+", "; s += "Chrome: "+UserAgent.isChrome()+", "; s += "Firefox: "+UserAgent.isFirefox()+", "; s += "IE: "+UserAgent.isIE()+", "; s += "IE7: "+UserAgent.isIE7()+", "; s += "IE8: "+UserAgent.isIE8()+", "; EditorStaticDeps.logger.trace().log("User Agent Properties: "+s); } } public static SEditor createWithId(String containerId, @JsOptional ServiceFrontend sf) throws SException { Element containerElement = DOM.getElementById(containerId); if (containerElement == null || !containerElement.getNodeName().equalsIgnoreCase("div")) throw new SException(SException.INTERNAL_ERROR, null, "Container element must be a div"); SEditor se = new SEditor(containerElement); if (sf != null) se.registerService(sf); return se; } public static SEditor createWithElement(Element containerElement, @JsOptional ServiceFrontend sf) throws SException { if (containerElement == null || !containerElement.getNodeName().equalsIgnoreCase("div")) throw new SException(SException.INTERNAL_ERROR, null, "Container element must be a div"); SEditor se = new SEditor(containerElement); if (sf != null) se.registerService(sf); return se; } public static SEditor create() { SEditor se = new SEditor(); return se; } private LogicalPanel.Impl editorPanel; /** Don't use this prop directly, use getter instead */ private EditorImpl editor; /** Don't use this prop directly, use getter instead */ private KeyBindingRegistry keyBindingRegistry; /** A service to listen to connection events */ ServiceBasis service; private SelectionExtractor selectionExtractor; private ProfileManager profileManager; private SelectionChangeHandler selectionHandler = null; private boolean canEdit = true; private ConnectionHandler connectionHandler = new ConnectionHandler() { @Override public void exec(String state, SException e) { if (editor == null) return; canEdit = state.equals(ServiceFrontend.STATUS_CONNECTED); edit(canEdit); } }; /** * Create editor instance tied to a DOM element * @param containerElement */ protected SEditor(final Element containerElement) { this.editorPanel = new LogicalPanel.Impl() { { setElement(containerElement); } }; } /** * Create editor instance no tied to a DOM element */ protected SEditor() { this.editorPanel = new LogicalPanel.Impl() { { setElement(Document.get().createDivElement()); } }; } /** * Attach the editor panel to an existing DOM element * iff the panel is not already attached. * * @param element the parent element */ public void setParent(Element element) { if (editorPanel.getParent() != null) { editorPanel.getElement().removeFromParent(); } if (element != null) { element.appendChild(editorPanel.getElement()); } } /** * Attach a text object to this editor. The text will be * shown instantly. * * @param text a text object * @throws SException */ public void set(STextWeb text) throws SException { if (checkBrowserCompat().equals(COMPAT_MODE_NONE)) throw new SEditorException("Browser not supported"); Editor e = getEditor(); clean(); ContentDocument doc = text.getContentDocument(); // Ensure the document is rendered and listen for events // in a deattached DOM node text.setInteractive(); // Add the document's root DOM node to the editor's panel editorPanel.getElement() .appendChild(doc.getFullContentView().getDocumentElement().getImplNodelet()); // make editor aware of the document e.setContent(doc); // start live carets if (selectionExtractor != null) selectionExtractor.start(e); AnnotationRegistry.muteHandlers(false); } /** * Enable or disable edit mode. * @param editOn */ public void edit(boolean editOn) { if (editor != null && editor.hasDocument() && checkBrowserCompat().equals(COMPAT_MODE_EDIT)) { if (editor.isEditing() != editOn) { editor.setEditing(editOn); } } } /** * Reset the state of the editor, deattaching the document * if it is necessary. */ public void clean() { if (editor != null && editor.hasDocument()) { if (selectionExtractor != null) { selectionExtractor.stop(editor); // ensures selection extractor is create for each new doc. selectionExtractor = null; } editor.removeContentAndUnrender(); editor.reset(); editor.addUpdateListener(this); AnnotationRegistry.muteHandlers(true); caretAnnotationHandler.clear(); } } /** * @return true iff the editor is in edit mode */ public boolean isEditing() { return editor != null && editor.isEditing(); } /** * @return true iff a document is attached to this editor. */ public boolean hasDocument() { return editor != null && editor.hasDocument(); } public void focus() { if (editor != null && editor.hasDocument()) { editor.focus(false); } } public void blur() { if (editor != null && editor.hasDocument()) { editor.blur(); } } public void setSelectionHandler(SelectionChangeHandler handler) { this.selectionHandler = handler; } // // Annotation methods // /** * Implements a safe logic to a range argument. * * @param range * @return the original range or the current selection * @throws SEditorException if not valid range can be privided */ protected Range checkRangeArgument(Range range) throws SEditorException { if (Range.ALL.equals(range)) range = SEditorHelper.getFullValidRange(editor); if (range == null) range = editor.getSelectionHelper().getOrderedSelectionRange(); if (range == null) throw new SEditorException("A valid range must be provided or a selection must be active"); return range; } /** * Set annotation in an specific doc range or in the current selection otherwise. * * @param name annotation's name * @param value a valid value for the annotation * @param range a range or null */ public AnnotationInstance setAnnotation(String name, String value, @JsOptional Range range) throws SEditorException { if (!editor.isEditing()) return null; Annotation antn = AnnotationRegistry.get(name); if (antn == null) throw new SEditorException(SEditorException.UNKNOWN_ANNOTATION, "Unknown annotation"); final Range effectiveRange = checkRangeArgument(range); final Editor editor = getEditor(); if (antn instanceof ParagraphAnnotation) { editor.undoableSequence(new Runnable(){ @Override public void run() { antn.set(editor.getDocument(), editor.getContent().getLocationMapper(), editor.getContent().getLocalAnnotations(), editor.getCaretAnnotations() , effectiveRange, value); } }); return null; } else { antn.set(editor.getDocument(), editor.getContent().getLocationMapper(), editor.getContent().getLocalAnnotations(), editor.getCaretAnnotations() , effectiveRange, value); return AnnotationInstance.create(editor.getContent(), name, value, effectiveRange, AnnotationInstance.MATCH_IN); } } /** * Reset annotations in an specific doc range or in the current selection otherwise. * * TODO(pablojan) consider to avoid AnnotationAction to implement this method and to replace * with a more straightforward approach like in seekTextAnnotations() * * @param names * @param range * @throws SEditorException */ public void clearAnnotation(JavaScriptObject names, @JsOptional Range range) throws SEditorException { if (!editor.isEditing()) return; Range effectiveRange = checkRangeArgument(range); AnnotationAction resetAction = new AnnotationAction(editor, effectiveRange); if (names != null) { if (JsUtils.isArray(names)) { resetAction.add((JsArrayString) names); } else if (JsUtils.isString(names)){ String name = names.toString(); resetAction.add(name); } else { throw new SEditorException("Expected array or string as first argument"); } } editor.undoableSequence(new Runnable(){ @Override public void run() { resetAction.reset(); } }); } /** * Get annotations in an specific doc range or in the current selection otherwise. * <p> * By default, get only effective annotations, that is those containing entirely the range. * <p> * TODO(pablojan) consider to avoid AnnotationAction to implement this method and to replace * with a more straightforward approach like in seekTextAnnotations( * * @param names a string or array of string * @param range optional document range of search * @param all retrieve effective or not annotations * @return * @throws SEditorException */ public JavaScriptObject getAnnotation(JavaScriptObject names, @JsOptional Range range, @JsOptional Boolean all) throws SEditorException { range = checkRangeArgument(range); AnnotationAction getAction = new AnnotationAction(editor, range); getAction.deepTraverse(all != null ? all : false); // By default only consider effective annotations getAction.onlyEffectiveAnnotations( !(all != null && all.equals(Boolean.TRUE)) ); if (names != null) { if (JsUtils.isArray(names)) { getAction.add((JsArrayString) names); } else if (JsUtils.isString(names) && !names.toString().isEmpty()){ getAction.add(names.toString()); } else { throw new SEditorException("Expected array or string as first argument"); } } return getAction.get(); } /** * Seeks text annotations in the provided range, or in the current selection otherwise, * for a set of keys. * * @param keys * @param range * @param onlyWithinRange only return annotations fully within the range * @return * @throws SEditorException */ public JavaScriptObject seekTextAnnotations(JavaScriptObject keys, @JsOptional Range range, @JsOptional Boolean onlyWithinRange) throws SEditorException { final Range actualRange = checkRangeArgument(range); JsoView result = JsoView.as(JavaScriptObject.createObject()); boolean withinRange = onlyWithinRange != null ? onlyWithinRange : true; StringSet keySet = JsUtils.toStringSet(keys); editor.getDocument().rangedAnnotations(actualRange.getStart(), actualRange.getEnd(), keySet) .forEach(new Consumer<RangedAnnotation<String>>(){ @Override public void accept(RangedAnnotation<String> t) { // ignore annotations with null value, are just editor's internal stuff if (t.value() == null) return; if (!result.containsKey(t.key())) { result.setJso(t.key(), JavaScriptObject.createArray()); } Range anotRange = new Range(t.start(), t.end()); int rangeMatch = AnnotationInstance.getRangeMatch(actualRange, anotRange); if (withinRange && !actualRange.contains(anotRange)) return; // skip AnnotationInstance anot = AnnotationInstance.create(editor.getContent(), t.key(), t.value(), anotRange, rangeMatch); JsUtils.addToArray(result.getJso(t.key()), anot); } }); return result; } /** * Seeks all annotations having same key and same value in the provided range. * * @param key * @param value * @param range * @return * @throws SEditorException */ public JavaScriptObject seekTextAnnotationsByValue(String key, String value, @JsOptional Range range) throws SEditorException { final Range actualRange = checkRangeArgument(range); JsoView result = JsoView.as(JavaScriptObject.createObject()); result.setJso(key, JavaScriptObject.createArray()); EditorAnnotationUtil.getAnnotationSpread(editor.getDocument(), key, value, actualRange.getStart(), actualRange.getEnd()) .forEach(new Consumer<RangedAnnotation<String>>(){ @Override public void accept(RangedAnnotation<String> t) { // ignore annotations with null value, are just editor's internal stuff if (t.value() == null) return; Range anotRange = new Range(t.start(), t.end()); int rangeMatch = AnnotationInstance.getRangeMatch(actualRange, anotRange); AnnotationInstance anot = AnnotationInstance.create(editor.getContent(), t.key(), t.value(), anotRange, rangeMatch); JsUtils.addToArray(result.getJso(key), anot); } }); return result; } /** * Set a text annotation in the provided range creating or updating annotations in overlapped locations * * @param key * @param value * @param range * * @return * @throws SEditorException */ public void setTextAnnotationOverlap(String key, String value, @JsOptional Range range) throws SEditorException { final Range actualRange = checkRangeArgument(range); final CMutableDocument doc = editor.getDocument(); if (value == null) throw new SEditorException("Null value not allowed for overlapping annotations"); editor.undoableSequence(new Runnable(){ @Override public void run() { doc.beginMutationGroup(); EditorAnnotationUtil.setAnnotationWithOverlap(doc, key, value, actualRange.getStart(), actualRange.getEnd()); doc.endMutationGroup(); } }); } /** * Clear a text annotation in the provided range deleting or updating annotations in overlapped locations * * @param key * @param value * @param range * @throws SEditorException */ public void clearTextAnnotationOverlap(String key, String value, @JsOptional Range range) throws SEditorException { final Range actualRange = checkRangeArgument(range); final CMutableDocument doc = editor.getDocument(); editor.undoableSequence(new Runnable(){ @Override public void run() { EditorAnnotationUtil.getAnnotationSpread(editor.getDocument(), key, value, actualRange.getStart(), actualRange.getEnd()) .forEach(new Consumer<RangedAnnotation<String>>(){ @Override public void accept(RangedAnnotation<String> t) { // ignore annotations with null value, are just editor's internal stuff if (t.value() == null) return; String newValue = null; if (t.value().contains(",")) { newValue = t.value().replace(value, ""); newValue = newValue.replace(",,", ","); if (newValue.charAt(0) == ',') newValue = newValue.substring(1, newValue.length()); if (newValue.charAt(newValue.length()-1) == ',') newValue = newValue.substring(0, newValue.length()-1); } // This can remove or update an annotation doc.setAnnotation(t.start(), t.end(), key, newValue); } }); } }); } public String getText(@JsOptional Range range) { try { range = checkRangeArgument(range); } catch (Exception e) { return null; } return DocHelper.getText(editor.getDocument(), range.getStart(), range.getEnd()); } public SSelection getSelection() { try { Range r = checkRangeArgument(null); return SSelection.get(r); } catch (Exception e) { return null; } } // // Internal stuff // /** * Make editor instance to listen to service's connection * events. * <p> * We prefer to register the service on the editor instead * the contrary way, because service must be agnostic * from any platform dependent component. * * @param serviceFrontend */ public void registerService(ServiceBasis service) { if (this.service != null) unregisterService(); this.service = service; this.service.addConnectionHandler(connectionHandler); this.profileManager = service.getProfilesManager(); if (this.profileManager != null) caretAnnotationHandler.setProfileManager(profileManager); } public void unregisterService() { if (service != null) service.removeConnectionHandler(connectionHandler); caretAnnotationHandler.setProfileManager(null); this.profileManager = null; } protected EditorSettings getSettings() { return new EditorSettings() .setHasDebugDialog(SEditorConfig.debugDialog()) .setUndoEnabled(SEditorConfig.undo()) .setUseFancyCursorBias(SEditorConfig.fancyCursorBias()) .setUseSemanticCopyPaste(SEditorConfig.semanticCopyPaste()) .setUseWhitelistInEditor(SEditorConfig.whitelistEditor()) .setUseWebkitCompositionEvents(SEditorConfig.webkitComposition()); } protected KeyBindingRegistry getKeyBindingRegistry() { if (keyBindingRegistry == null) keyBindingRegistry = new KeyBindingRegistry(); return keyBindingRegistry; } protected Editor getEditor() { if (editor == null) { editor = UserAgent.isMobileWebkit() ? new EditorImplWebkitMobile(false, editorPanel.getElement()) : new EditorImpl(false, editorPanel.getElement()); editor.init(null, getKeyBindingRegistry(), getSettings()); editor.addUpdateListener(this); } if (selectionExtractor == null && profileManager != null) { selectionExtractor = new SelectionExtractor(SchedulerInstance.getLowPriorityTimer(), profileManager); } return editor; } Element caretMarker = null; @JsIgnore @Override public void onUpdate(EditorUpdateEvent event) { Editor editor = this.getEditor(); if (selectionHandler != null) { Range range = editor.getSelectionHelper().getOrderedSelectionRange(); selectionHandler.exec(range, this, SSelection.get(range)); } } public String checkBrowserCompat() { if (UserAgent.isAndroid() || (UserAgent.isIPhone() && UserAgent.isChrome())) return COMPAT_MODE_READONLY; return COMPAT_MODE_EDIT; } }