| Index: dart/site/try/src/interaction_manager.dart
|
| diff --git a/dart/site/try/src/interaction_manager.dart b/dart/site/try/src/interaction_manager.dart
|
| new file mode 100644
|
| index 0000000000000000000000000000000000000000..b00d121dba328ddef448558315e220bcaacb14ef
|
| --- /dev/null
|
| +++ b/dart/site/try/src/interaction_manager.dart
|
| @@ -0,0 +1,644 @@
|
| +// Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file
|
| +// for details. All rights reserved. Use of this source code is governed by a
|
| +// BSD-style license that can be found in the LICENSE file.
|
| +
|
| +library trydart.interaction_manager;
|
| +
|
| +import 'dart:html';
|
| +
|
| +import 'dart:convert' show
|
| + JSON;
|
| +
|
| +import 'dart:math' show
|
| + max,
|
| + min;
|
| +
|
| +import 'package:compiler/implementation/scanner/scannerlib.dart'
|
| + show
|
| + EOF_TOKEN,
|
| + StringScanner,
|
| + Token;
|
| +
|
| +import 'package:compiler/implementation/source_file.dart' show
|
| + StringSourceFile;
|
| +
|
| +import 'compilation.dart' show
|
| + scheduleCompilation;
|
| +
|
| +import 'ui.dart' show
|
| + currentTheme,
|
| + hackDiv,
|
| + inputPre,
|
| + observer,
|
| + outputDiv;
|
| +
|
| +import 'decoration.dart' show
|
| + CodeCompletionDecoration,
|
| + Decoration,
|
| + DiagnosticDecoration,
|
| + error,
|
| + info,
|
| + warning;
|
| +
|
| +import 'editor.dart' as editor;
|
| +
|
| +import 'mock.dart' as mock;
|
| +
|
| +import 'settings.dart' as settings;
|
| +
|
| +/**
|
| + * UI interaction manager for the entire application.
|
| + */
|
| +abstract class InteractionManager {
|
| + // Design note: All UI interactions go through one instance of this
|
| + // class. This is by design.
|
| + //
|
| + // Simplicity in UI is in the eye of the beholder, not the implementor. Great
|
| + // 'natural UI' is usually achieved with substantial implementation
|
| + // complexity that doesn't modularise well and has nasty complicated state
|
| + // dependencies.
|
| + //
|
| + // In rare cases, some UI components can be independent of this state
|
| + // machine. For example, animation and auto-save loops.
|
| +
|
| + // Implementation note: The state machine is actually implemented by
|
| + // [InteractionContext], this class represents public event handlers.
|
| +
|
| + factory InteractionManager() => new InteractionContext();
|
| +
|
| + InteractionManager.internal();
|
| +
|
| + void onInput(Event event);
|
| +
|
| + void onKeyUp(KeyboardEvent event);
|
| +
|
| + void onMutation(List<MutationRecord> mutations, MutationObserver observer);
|
| +
|
| + void onSelectionChange(Event event);
|
| +}
|
| +
|
| +/**
|
| + * State machine for UI interactions.
|
| + */
|
| +class InteractionContext extends InteractionManager {
|
| + InteractionState state;
|
| +
|
| + InteractionContext()
|
| + : super.internal() {
|
| + state = new InitialState(this);
|
| + }
|
| +
|
| + void onInput(Event event) => state.onInput(event);
|
| +
|
| + void onKeyUp(KeyboardEvent event) => state.onKeyUp(event);
|
| +
|
| + void onMutation(List<MutationRecord> mutations, MutationObserver observer) {
|
| + return state.onMutation(mutations, observer);
|
| + }
|
| +
|
| + void onSelectionChange(Event event) => state.onSelectionChange(event);
|
| +}
|
| +
|
| +abstract class InteractionState implements InteractionManager {
|
| + void onStateChanged(InteractionState previous) {
|
| + print('State change ${previous.runtimeType} -> ${runtimeType}.');
|
| + }
|
| +}
|
| +
|
| +class InitialState extends InteractionState {
|
| + final InteractionContext context;
|
| + bool requestCodeCompletion = false;
|
| +
|
| + InitialState(this.context);
|
| +
|
| + void set state(InteractionState state) {
|
| + InteractionState previous = context.state;
|
| + if (previous != state) {
|
| + context.state = state;
|
| + state.onStateChanged(previous);
|
| + }
|
| + }
|
| +
|
| + void onInput(Event event) {
|
| + state = new PendingInputState(context);
|
| + }
|
| +
|
| + void onKeyUp(KeyboardEvent event) {
|
| + if (computeHasModifier(event)) {
|
| + print('onKeyUp (modified)');
|
| + onModifiedKeyUp(event);
|
| + } else {
|
| + print('onKeyUp (unmodified)');
|
| + onUnmodifiedKeyUp(event);
|
| + }
|
| + }
|
| +
|
| + void onModifiedKeyUp(KeyboardEvent event) {
|
| + }
|
| +
|
| + void onUnmodifiedKeyUp(KeyboardEvent event) {
|
| + switch (event.keyCode) {
|
| + case KeyCode.ENTER: {
|
| + event.preventDefault();
|
| + Selection selection = window.getSelection();
|
| + if (selection.isCollapsed && selection.anchorNode is Text) {
|
| + Text text = selection.anchorNode;
|
| + int offset = selection.anchorOffset;
|
| + text.insertData(offset, '\n');
|
| + selection.collapse(text, offset + 1);
|
| + }
|
| + break;
|
| + }
|
| + }
|
| +
|
| + // editor.scheduleRemoveCodeCompletion();
|
| +
|
| + // This is a hack to get Safari (iOS) to send mutation events on
|
| + // contenteditable.
|
| + // TODO(ahe): Move to onInput?
|
| + var newDiv = new DivElement();
|
| + hackDiv.replaceWith(newDiv);
|
| + hackDiv = newDiv;
|
| + }
|
| +
|
| + // TODO(ahe): This method should be cleaned up. It is too large.
|
| + void onMutation(List<MutationRecord> mutations, MutationObserver observer) {
|
| + print('onMutation');
|
| +
|
| + for (String query in const ['a.diagnostic>span',
|
| + '.dart-code-completion',
|
| + '.hazed-suggestion']) {
|
| + for (Element element in inputPre.querySelectorAll(query)) {
|
| + element.remove();
|
| + }
|
| + }
|
| +
|
| + // Discard clean-up mutations.
|
| + observer.takeRecords();
|
| +
|
| + Selection selection = window.getSelection();
|
| +
|
| + while (!mutations.isEmpty) {
|
| + for (MutationRecord record in mutations) {
|
| + String type = record.type;
|
| + switch (type) {
|
| +
|
| + case 'characterData':
|
| + bool hasSelection = false;
|
| + int offset = selection.anchorOffset;
|
| + if (selection.isCollapsed &&
|
| + selection.anchorNode == record.target) {
|
| + hasSelection = true;
|
| + }
|
| + var parent = record.target.parentNode;
|
| + if (parent != inputPre) {
|
| + editor.inlineChildren(parent);
|
| + }
|
| + if (hasSelection) {
|
| + selection.collapse(record.target, offset);
|
| + }
|
| + break;
|
| +
|
| + default:
|
| + if (!record.addedNodes.isEmpty) {
|
| + for (var node in record.addedNodes) {
|
| +
|
| + if (node.nodeType != Node.ELEMENT_NODE) continue;
|
| +
|
| + if (node is BRElement) {
|
| + if (selection.anchorNode != node) {
|
| + node.replaceWith(new Text('\n'));
|
| + }
|
| + } else {
|
| + var parent = node.parentNode;
|
| + if (parent == null) continue;
|
| + var nodes = new List.from(node.nodes);
|
| + var style = node.getComputedStyle();
|
| + if (style.display != 'inline') {
|
| + var previous = node.previousNode;
|
| + if (previous is Text) {
|
| + previous.appendData('\n');
|
| + } else {
|
| + parent.insertBefore(new Text('\n'), node);
|
| + }
|
| + }
|
| + for (Node child in nodes) {
|
| + child.remove();
|
| + parent.insertBefore(child, node);
|
| + }
|
| + node.remove();
|
| + }
|
| + }
|
| + }
|
| + }
|
| + }
|
| + mutations = observer.takeRecords();
|
| + }
|
| +
|
| + if (!inputPre.nodes.isEmpty && inputPre.nodes.last is Text) {
|
| + Text text = inputPre.nodes.last;
|
| + if (!text.text.endsWith('\n')) {
|
| + text.appendData('\n');
|
| + }
|
| + }
|
| +
|
| + int offset = 0;
|
| + int anchorOffset = 0;
|
| + bool hasSelection = false;
|
| + Node anchorNode = selection.anchorNode;
|
| + // TODO(ahe): Try to share walk4 methods.
|
| + void walk4(Node node) {
|
| + // TODO(ahe): Use TreeWalker when that is exposed.
|
| + // function textNodesUnder(root){
|
| + // var n, a=[], walk=document.createTreeWalker(
|
| + // root,NodeFilter.SHOW_TEXT,null,false);
|
| + // while(n=walk.nextNode()) a.push(n);
|
| + // return a;
|
| + // }
|
| + int type = node.nodeType;
|
| + if (type == Node.TEXT_NODE || type == Node.CDATA_SECTION_NODE) {
|
| + CharacterData text = node;
|
| + if (anchorNode == node) {
|
| + hasSelection = true;
|
| + anchorOffset = selection.anchorOffset + offset;
|
| + return;
|
| + }
|
| + offset += text.length;
|
| + }
|
| +
|
| + var child = node.firstChild;
|
| + while (child != null) {
|
| + walk4(child);
|
| + if (hasSelection) return;
|
| + child = child.nextNode;
|
| + }
|
| + }
|
| + if (selection.isCollapsed) {
|
| + walk4(inputPre);
|
| + }
|
| +
|
| + editor.currentSource = inputPre.text;
|
| + inputPre.nodes.clear();
|
| + inputPre.appendText(editor.currentSource);
|
| + if (hasSelection) {
|
| + selection.collapse(inputPre.firstChild, anchorOffset);
|
| + }
|
| +
|
| + editor.isMalformedInput = false;
|
| + for (var n in new List.from(inputPre.nodes)) {
|
| + if (n is! Text) continue;
|
| + Text node = n;
|
| + String text = node.text;
|
| +
|
| + Token token = tokenize(text);
|
| + int offset = 0;
|
| + editor.seenIdentifiers = new Set<String>.from(mock.identifiers);
|
| + for (; token.kind != EOF_TOKEN; token = token.next) {
|
| + Decoration decoration = editor.getDecoration(token);
|
| + if (decoration == null) continue;
|
| + bool hasSelection = false;
|
| + int selectionOffset = selection.anchorOffset;
|
| +
|
| + if (selection.isCollapsed && selection.anchorNode == node) {
|
| + hasSelection = true;
|
| + selectionOffset = selection.anchorOffset;
|
| + }
|
| + int splitPoint = token.charOffset - offset;
|
| + Text str = node.splitText(splitPoint);
|
| + Text after = str.splitText(token.charCount);
|
| + offset += splitPoint + token.charCount;
|
| + inputPre.insertBefore(after, node.nextNode);
|
| + inputPre.insertBefore(decoration.applyTo(str), after);
|
| +
|
| + if (hasSelection && selectionOffset > node.length) {
|
| + selectionOffset -= node.length;
|
| + if (selectionOffset > str.length) {
|
| + selectionOffset -= str.length;
|
| + selection.collapse(after, selectionOffset);
|
| + } else {
|
| + selection.collapse(str, selectionOffset);
|
| + }
|
| + }
|
| + node = after;
|
| + }
|
| + }
|
| +
|
| + window.localStorage['currentSource'] = editor.currentSource;
|
| + print('Saved source');
|
| +
|
| + // Discard highlighting mutations.
|
| + observer.takeRecords();
|
| + }
|
| +
|
| + void onSelectionChange(Event event) {
|
| + }
|
| +
|
| + void onStateChanged(InteractionState previous) {
|
| + super.onStateChanged(previous);
|
| + scheduleCompilation();
|
| + }
|
| +}
|
| +
|
| +class PendingInputState extends InitialState {
|
| + PendingInputState(InteractionContext context)
|
| + : super(context);
|
| +
|
| + void onInput(Event event) {
|
| + // Do nothing.
|
| + }
|
| +
|
| + void onMutation(List<MutationRecord> mutations, MutationObserver observer) {
|
| + super.onMutation(mutations, observer);
|
| +
|
| + InteractionState nextState = new InitialState(context);
|
| + if (settings.enableCodeCompletion.value) {
|
| + Element parent = editor.getElementAtSelection();
|
| + Element ui;
|
| + if (parent != null) {
|
| + ui = parent.querySelector('.dart-code-completion');
|
| + if (ui != null) {
|
| + nextState = new CodeCompletionState(context, parent, ui);
|
| + }
|
| + }
|
| + }
|
| + state = nextState;
|
| + }
|
| +}
|
| +
|
| +class CodeCompletionState extends InitialState {
|
| + final Element activeCompletion;
|
| + final Element ui;
|
| + int minWidth = 0;
|
| + DivElement staticResults;
|
| + SpanElement inline;
|
| + DivElement serverResults;
|
| + String inlineSuggestion;
|
| +
|
| + CodeCompletionState(InteractionContext context,
|
| + this.activeCompletion,
|
| + this.ui)
|
| + : super(context);
|
| +
|
| + void onInput(Event event) {
|
| + // Do nothing.
|
| + }
|
| +
|
| + void onModifiedKeyUp(KeyboardEvent event) {
|
| + // TODO(ahe): Handle DOWN (jump to server results).
|
| + }
|
| +
|
| + void onUnmodifiedKeyUp(KeyboardEvent event) {
|
| + switch (event.keyCode) {
|
| + case KeyCode.DOWN:
|
| + return moveDown(event);
|
| +
|
| + case KeyCode.UP:
|
| + return moveUp(event);
|
| +
|
| + case KeyCode.ESC:
|
| + event.preventDefault();
|
| + return endCompletion();
|
| +
|
| + case KeyCode.TAB:
|
| + case KeyCode.RIGHT:
|
| + case KeyCode.ENTER:
|
| + event.preventDefault();
|
| + return endCompletion(acceptSuggestion: true);
|
| + }
|
| + }
|
| +
|
| + void moveDown(Event event) {
|
| + event.preventDefault();
|
| + move(1);
|
| + }
|
| +
|
| + void moveUp(Event event) {
|
| + event.preventDefault();
|
| + move(-1);
|
| + }
|
| +
|
| + void move(int direction) {
|
| + Element element = editor.moveActive(direction);
|
| + if (element == null) return;
|
| + var text = activeCompletion.firstChild;
|
| + String prefix = "";
|
| + if (text is Text) prefix = text.data.trim();
|
| + updateInlineSuggestion(prefix, element.text);
|
| + }
|
| +
|
| + void endCompletion({bool acceptSuggestion: false}) {
|
| + if (acceptSuggestion) {
|
| + suggestionAccepted();
|
| + }
|
| + activeCompletion.classes.remove('active');
|
| + inputPre.querySelectorAll('.hazed-suggestion').forEach((e) => e.remove());
|
| + // The above changes create mutation records. This implicitly fire mutation
|
| + // events that result in saving the source code in local storage.
|
| + // TODO(ahe): Consider making this more explicit.
|
| + state = new InitialState(context);
|
| + }
|
| +
|
| + void suggestionAccepted() {
|
| + if (inlineSuggestion != null) {
|
| + Text text = new Text(inlineSuggestion);
|
| + activeCompletion.replaceWith(text);
|
| + window.getSelection().collapse(text, inlineSuggestion.length);
|
| + }
|
| + }
|
| +
|
| + void onMutation(List<MutationRecord> mutations, MutationObserver observer) {
|
| + for (MutationRecord record in mutations) {
|
| + if (!activeCompletion.contains(record.target)) {
|
| + endCompletion();
|
| + return super.onMutation(mutations, observer);
|
| + }
|
| + }
|
| +
|
| + var text = activeCompletion.firstChild;
|
| + if (text is! Text) return endCompletion();
|
| + updateSuggestions(text.data.trim());
|
| + }
|
| +
|
| + void onStateChanged(InteractionState previous) {
|
| + super.onStateChanged(previous);
|
| + displayCodeCompletion();
|
| + }
|
| +
|
| + void displayCodeCompletion() {
|
| + Selection selection = window.getSelection();
|
| + if (selection.anchorNode is! Text) {
|
| + return endCompletion();
|
| + }
|
| + Text text = selection.anchorNode;
|
| + if (!activeCompletion.contains(text)) {
|
| + return endCompletion();
|
| + }
|
| +
|
| + int anchorOffset = selection.anchorOffset;
|
| +
|
| + String prefix = text.data.substring(0, anchorOffset).trim();
|
| + if (prefix.isEmpty) {
|
| + return endCompletion();
|
| + }
|
| +
|
| + num height = activeCompletion.getBoundingClientRect().height;
|
| + activeCompletion.classes.add('active');
|
| + ui.nodes.clear();
|
| +
|
| + inline = new SpanElement()
|
| + ..classes.add('hazed-suggestion');
|
| + Text rest = text.splitText(anchorOffset);
|
| + text.parentNode.insertBefore(inline, text.nextNode);
|
| + activeCompletion.parentNode.insertBefore(
|
| + rest, activeCompletion.nextNode);
|
| +
|
| + staticResults = new DivElement()
|
| + ..classes.addAll(['dart-static', 'dart-limited-height']);
|
| + serverResults = new DivElement()
|
| + ..style.display = 'none'
|
| + ..classes.add('dart-server');
|
| + ui.nodes.addAll([staticResults, serverResults]);
|
| + ui.style.top = '${height}px';
|
| +
|
| + staticResults.nodes.add(buildCompletionEntry(prefix));
|
| +
|
| + updateSuggestions(prefix);
|
| + }
|
| +
|
| + void updateInlineSuggestion(String prefix, String suggestion) {
|
| + inlineSuggestion = suggestion;
|
| +
|
| + minWidth = max(minWidth, activeCompletion.getBoundingClientRect().width);
|
| +
|
| + activeCompletion.style
|
| + ..display = 'inline-block'
|
| + ..minWidth = '${minWidth}px';
|
| +
|
| + inline
|
| + ..nodes.clear()
|
| + ..appendText(suggestion.substring(prefix.length))
|
| + ..style.display = '';
|
| +
|
| + observer.takeRecords(); // Discard mutations.
|
| + }
|
| +
|
| + void updateSuggestions(String prefix) {
|
| + if (prefix.isEmpty) {
|
| + return endCompletion();
|
| + }
|
| +
|
| + Token first = tokenize(prefix);
|
| + for (Token token = first; token.kind != EOF_TOKEN; token = token.next) {
|
| + String tokenInfo = token.info.value;
|
| + if (token != first ||
|
| + tokenInfo != 'identifier' &&
|
| + tokenInfo != 'keyword') {
|
| + return endCompletion();
|
| + }
|
| + }
|
| +
|
| + var borderHeight = 2; // 1 pixel border top & bottom.
|
| + num height = ui.getBoundingClientRect().height - borderHeight;
|
| + ui.style.minHeight = '${height}px';
|
| +
|
| + minWidth =
|
| + max(minWidth, activeCompletion.getBoundingClientRect().width);
|
| +
|
| + staticResults.nodes.clear();
|
| + serverResults.nodes.clear();
|
| +
|
| + if (inlineSuggestion != null && inlineSuggestion.startsWith(prefix)) {
|
| + inline
|
| + ..nodes.clear()
|
| + ..appendText(inlineSuggestion.substring(prefix.length));
|
| + }
|
| +
|
| + List<String> results = editor.seenIdentifiers.where(
|
| + (String identifier) {
|
| + return identifier != prefix && identifier.startsWith(prefix);
|
| + }).toList(growable: false);
|
| + results.sort();
|
| + if (results.isEmpty) results = <String>[prefix];
|
| +
|
| + results.forEach((String completion) {
|
| + staticResults.nodes.add(buildCompletionEntry(completion));
|
| + });
|
| +
|
| + if (settings.enableDartMind) {
|
| + // TODO(ahe): Move this code to its own function or class.
|
| + String encodedArg0 = Uri.encodeComponent('"$prefix"');
|
| + String mindQuery =
|
| + 'http://dart-mind.appspot.com/rpc'
|
| + '?action=GetExportingPubCompletions'
|
| + '&arg0=$encodedArg0';
|
| + try {
|
| + var serverWatch = new Stopwatch()..start();
|
| + HttpRequest.getString(mindQuery).then((String responseText) {
|
| + serverWatch.stop();
|
| + List<String> serverSuggestions = JSON.decode(responseText);
|
| + if (!serverSuggestions.isEmpty) {
|
| + updateInlineSuggestion(prefix, serverSuggestions.first);
|
| + }
|
| + for (int i = 1; i < serverSuggestions.length; i++) {
|
| + String completion = serverSuggestions[i];
|
| + DivElement where = staticResults;
|
| + int index = results.indexOf(completion);
|
| + if (index != -1) {
|
| + List<Element> entries =
|
| + document.querySelectorAll('.dart-static>.dart-entry');
|
| + entries[index].classes.add('doubleplusgood');
|
| + } else {
|
| + if (results.length > 3) {
|
| + serverResults.style.display = 'block';
|
| + where = serverResults;
|
| + }
|
| + Element entry = buildCompletionEntry(completion);
|
| + entry.classes.add('doubleplusgood');
|
| + where.nodes.add(entry);
|
| + }
|
| + }
|
| + serverResults.appendHtml(
|
| + '<div>${serverWatch.elapsedMilliseconds}ms</div>');
|
| + // Discard mutations.
|
| + observer.takeRecords();
|
| + }).catchError((error, stack) {
|
| + window.console.dir(error);
|
| + window.console.error('$stack');
|
| + });
|
| + } catch (error, stack) {
|
| + window.console.dir(error);
|
| + window.console.error('$stack');
|
| + }
|
| + }
|
| + // Discard mutations.
|
| + observer.takeRecords();
|
| + }
|
| +
|
| + Element buildCompletionEntry(String completion) {
|
| + return new DivElement()
|
| + ..classes.add('dart-entry')
|
| + ..appendText(completion);
|
| + }
|
| +}
|
| +
|
| +Token tokenize(String text) {
|
| + var file = new StringSourceFile('', text);
|
| + return new StringScanner(file, includeComments: true).tokenize();
|
| +}
|
| +
|
| +bool computeHasModifier(KeyboardEvent event) {
|
| + return
|
| + event.getModifierState("Alt") ||
|
| + event.getModifierState("AltGraph") ||
|
| + event.getModifierState("CapsLock") ||
|
| + event.getModifierState("Control") ||
|
| + event.getModifierState("Fn") ||
|
| + event.getModifierState("Meta") ||
|
| + event.getModifierState("NumLock") ||
|
| + event.getModifierState("ScrollLock") ||
|
| + event.getModifierState("Scroll") ||
|
| + event.getModifierState("Win") ||
|
| + event.getModifierState("Shift") ||
|
| + event.getModifierState("SymbolLock") ||
|
| + event.getModifierState("OS");
|
| +}
|
|
|