| OLD | NEW |
| (Empty) | |
| 1 // Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file |
| 2 // for details. All rights reserved. Use of this source code is governed by a |
| 3 // BSD-style license that can be found in the LICENSE file. |
| 4 |
| 5 library trydart.interaction_manager; |
| 6 |
| 7 import 'dart:html'; |
| 8 |
| 9 import 'dart:convert' show |
| 10 JSON; |
| 11 |
| 12 import 'dart:math' show |
| 13 max, |
| 14 min; |
| 15 |
| 16 import 'package:compiler/implementation/scanner/scannerlib.dart' |
| 17 show |
| 18 EOF_TOKEN, |
| 19 StringScanner, |
| 20 Token; |
| 21 |
| 22 import 'package:compiler/implementation/source_file.dart' show |
| 23 StringSourceFile; |
| 24 |
| 25 import 'compilation.dart' show |
| 26 scheduleCompilation; |
| 27 |
| 28 import 'ui.dart' show |
| 29 currentTheme, |
| 30 hackDiv, |
| 31 inputPre, |
| 32 observer, |
| 33 outputDiv; |
| 34 |
| 35 import 'decoration.dart' show |
| 36 CodeCompletionDecoration, |
| 37 Decoration, |
| 38 DiagnosticDecoration, |
| 39 error, |
| 40 info, |
| 41 warning; |
| 42 |
| 43 import 'editor.dart' as editor; |
| 44 |
| 45 import 'mock.dart' as mock; |
| 46 |
| 47 import 'settings.dart' as settings; |
| 48 |
| 49 /** |
| 50 * UI interaction manager for the entire application. |
| 51 */ |
| 52 abstract class InteractionManager { |
| 53 // Design note: All UI interactions go through one instance of this |
| 54 // class. This is by design. |
| 55 // |
| 56 // Simplicity in UI is in the eye of the beholder, not the implementor. Great |
| 57 // 'natural UI' is usually achieved with substantial implementation |
| 58 // complexity that doesn't modularise well and has nasty complicated state |
| 59 // dependencies. |
| 60 // |
| 61 // In rare cases, some UI components can be independent of this state |
| 62 // machine. For example, animation and auto-save loops. |
| 63 |
| 64 // Implementation note: The state machine is actually implemented by |
| 65 // [InteractionContext], this class represents public event handlers. |
| 66 |
| 67 factory InteractionManager() => new InteractionContext(); |
| 68 |
| 69 InteractionManager.internal(); |
| 70 |
| 71 void onInput(Event event); |
| 72 |
| 73 void onKeyUp(KeyboardEvent event); |
| 74 |
| 75 void onMutation(List<MutationRecord> mutations, MutationObserver observer); |
| 76 |
| 77 void onSelectionChange(Event event); |
| 78 } |
| 79 |
| 80 /** |
| 81 * State machine for UI interactions. |
| 82 */ |
| 83 class InteractionContext extends InteractionManager { |
| 84 InteractionState state; |
| 85 |
| 86 InteractionContext() |
| 87 : super.internal() { |
| 88 state = new InitialState(this); |
| 89 } |
| 90 |
| 91 void onInput(Event event) => state.onInput(event); |
| 92 |
| 93 void onKeyUp(KeyboardEvent event) => state.onKeyUp(event); |
| 94 |
| 95 void onMutation(List<MutationRecord> mutations, MutationObserver observer) { |
| 96 return state.onMutation(mutations, observer); |
| 97 } |
| 98 |
| 99 void onSelectionChange(Event event) => state.onSelectionChange(event); |
| 100 } |
| 101 |
| 102 abstract class InteractionState implements InteractionManager { |
| 103 void onStateChanged(InteractionState previous) { |
| 104 print('State change ${previous.runtimeType} -> ${runtimeType}.'); |
| 105 } |
| 106 } |
| 107 |
| 108 class InitialState extends InteractionState { |
| 109 final InteractionContext context; |
| 110 bool requestCodeCompletion = false; |
| 111 |
| 112 InitialState(this.context); |
| 113 |
| 114 void set state(InteractionState state) { |
| 115 InteractionState previous = context.state; |
| 116 if (previous != state) { |
| 117 context.state = state; |
| 118 state.onStateChanged(previous); |
| 119 } |
| 120 } |
| 121 |
| 122 void onInput(Event event) { |
| 123 state = new PendingInputState(context); |
| 124 } |
| 125 |
| 126 void onKeyUp(KeyboardEvent event) { |
| 127 if (computeHasModifier(event)) { |
| 128 print('onKeyUp (modified)'); |
| 129 onModifiedKeyUp(event); |
| 130 } else { |
| 131 print('onKeyUp (unmodified)'); |
| 132 onUnmodifiedKeyUp(event); |
| 133 } |
| 134 } |
| 135 |
| 136 void onModifiedKeyUp(KeyboardEvent event) { |
| 137 } |
| 138 |
| 139 void onUnmodifiedKeyUp(KeyboardEvent event) { |
| 140 switch (event.keyCode) { |
| 141 case KeyCode.ENTER: { |
| 142 event.preventDefault(); |
| 143 Selection selection = window.getSelection(); |
| 144 if (selection.isCollapsed && selection.anchorNode is Text) { |
| 145 Text text = selection.anchorNode; |
| 146 int offset = selection.anchorOffset; |
| 147 text.insertData(offset, '\n'); |
| 148 selection.collapse(text, offset + 1); |
| 149 } |
| 150 break; |
| 151 } |
| 152 } |
| 153 |
| 154 // editor.scheduleRemoveCodeCompletion(); |
| 155 |
| 156 // This is a hack to get Safari (iOS) to send mutation events on |
| 157 // contenteditable. |
| 158 // TODO(ahe): Move to onInput? |
| 159 var newDiv = new DivElement(); |
| 160 hackDiv.replaceWith(newDiv); |
| 161 hackDiv = newDiv; |
| 162 } |
| 163 |
| 164 // TODO(ahe): This method should be cleaned up. It is too large. |
| 165 void onMutation(List<MutationRecord> mutations, MutationObserver observer) { |
| 166 print('onMutation'); |
| 167 |
| 168 for (String query in const ['a.diagnostic>span', |
| 169 '.dart-code-completion', |
| 170 '.hazed-suggestion']) { |
| 171 for (Element element in inputPre.querySelectorAll(query)) { |
| 172 element.remove(); |
| 173 } |
| 174 } |
| 175 |
| 176 // Discard clean-up mutations. |
| 177 observer.takeRecords(); |
| 178 |
| 179 Selection selection = window.getSelection(); |
| 180 |
| 181 while (!mutations.isEmpty) { |
| 182 for (MutationRecord record in mutations) { |
| 183 String type = record.type; |
| 184 switch (type) { |
| 185 |
| 186 case 'characterData': |
| 187 bool hasSelection = false; |
| 188 int offset = selection.anchorOffset; |
| 189 if (selection.isCollapsed && |
| 190 selection.anchorNode == record.target) { |
| 191 hasSelection = true; |
| 192 } |
| 193 var parent = record.target.parentNode; |
| 194 if (parent != inputPre) { |
| 195 editor.inlineChildren(parent); |
| 196 } |
| 197 if (hasSelection) { |
| 198 selection.collapse(record.target, offset); |
| 199 } |
| 200 break; |
| 201 |
| 202 default: |
| 203 if (!record.addedNodes.isEmpty) { |
| 204 for (var node in record.addedNodes) { |
| 205 |
| 206 if (node.nodeType != Node.ELEMENT_NODE) continue; |
| 207 |
| 208 if (node is BRElement) { |
| 209 if (selection.anchorNode != node) { |
| 210 node.replaceWith(new Text('\n')); |
| 211 } |
| 212 } else { |
| 213 var parent = node.parentNode; |
| 214 if (parent == null) continue; |
| 215 var nodes = new List.from(node.nodes); |
| 216 var style = node.getComputedStyle(); |
| 217 if (style.display != 'inline') { |
| 218 var previous = node.previousNode; |
| 219 if (previous is Text) { |
| 220 previous.appendData('\n'); |
| 221 } else { |
| 222 parent.insertBefore(new Text('\n'), node); |
| 223 } |
| 224 } |
| 225 for (Node child in nodes) { |
| 226 child.remove(); |
| 227 parent.insertBefore(child, node); |
| 228 } |
| 229 node.remove(); |
| 230 } |
| 231 } |
| 232 } |
| 233 } |
| 234 } |
| 235 mutations = observer.takeRecords(); |
| 236 } |
| 237 |
| 238 if (!inputPre.nodes.isEmpty && inputPre.nodes.last is Text) { |
| 239 Text text = inputPre.nodes.last; |
| 240 if (!text.text.endsWith('\n')) { |
| 241 text.appendData('\n'); |
| 242 } |
| 243 } |
| 244 |
| 245 int offset = 0; |
| 246 int anchorOffset = 0; |
| 247 bool hasSelection = false; |
| 248 Node anchorNode = selection.anchorNode; |
| 249 // TODO(ahe): Try to share walk4 methods. |
| 250 void walk4(Node node) { |
| 251 // TODO(ahe): Use TreeWalker when that is exposed. |
| 252 // function textNodesUnder(root){ |
| 253 // var n, a=[], walk=document.createTreeWalker( |
| 254 // root,NodeFilter.SHOW_TEXT,null,false); |
| 255 // while(n=walk.nextNode()) a.push(n); |
| 256 // return a; |
| 257 // } |
| 258 int type = node.nodeType; |
| 259 if (type == Node.TEXT_NODE || type == Node.CDATA_SECTION_NODE) { |
| 260 CharacterData text = node; |
| 261 if (anchorNode == node) { |
| 262 hasSelection = true; |
| 263 anchorOffset = selection.anchorOffset + offset; |
| 264 return; |
| 265 } |
| 266 offset += text.length; |
| 267 } |
| 268 |
| 269 var child = node.firstChild; |
| 270 while (child != null) { |
| 271 walk4(child); |
| 272 if (hasSelection) return; |
| 273 child = child.nextNode; |
| 274 } |
| 275 } |
| 276 if (selection.isCollapsed) { |
| 277 walk4(inputPre); |
| 278 } |
| 279 |
| 280 editor.currentSource = inputPre.text; |
| 281 inputPre.nodes.clear(); |
| 282 inputPre.appendText(editor.currentSource); |
| 283 if (hasSelection) { |
| 284 selection.collapse(inputPre.firstChild, anchorOffset); |
| 285 } |
| 286 |
| 287 editor.isMalformedInput = false; |
| 288 for (var n in new List.from(inputPre.nodes)) { |
| 289 if (n is! Text) continue; |
| 290 Text node = n; |
| 291 String text = node.text; |
| 292 |
| 293 Token token = tokenize(text); |
| 294 int offset = 0; |
| 295 editor.seenIdentifiers = new Set<String>.from(mock.identifiers); |
| 296 for (; token.kind != EOF_TOKEN; token = token.next) { |
| 297 Decoration decoration = editor.getDecoration(token); |
| 298 if (decoration == null) continue; |
| 299 bool hasSelection = false; |
| 300 int selectionOffset = selection.anchorOffset; |
| 301 |
| 302 if (selection.isCollapsed && selection.anchorNode == node) { |
| 303 hasSelection = true; |
| 304 selectionOffset = selection.anchorOffset; |
| 305 } |
| 306 int splitPoint = token.charOffset - offset; |
| 307 Text str = node.splitText(splitPoint); |
| 308 Text after = str.splitText(token.charCount); |
| 309 offset += splitPoint + token.charCount; |
| 310 inputPre.insertBefore(after, node.nextNode); |
| 311 inputPre.insertBefore(decoration.applyTo(str), after); |
| 312 |
| 313 if (hasSelection && selectionOffset > node.length) { |
| 314 selectionOffset -= node.length; |
| 315 if (selectionOffset > str.length) { |
| 316 selectionOffset -= str.length; |
| 317 selection.collapse(after, selectionOffset); |
| 318 } else { |
| 319 selection.collapse(str, selectionOffset); |
| 320 } |
| 321 } |
| 322 node = after; |
| 323 } |
| 324 } |
| 325 |
| 326 window.localStorage['currentSource'] = editor.currentSource; |
| 327 print('Saved source'); |
| 328 |
| 329 // Discard highlighting mutations. |
| 330 observer.takeRecords(); |
| 331 } |
| 332 |
| 333 void onSelectionChange(Event event) { |
| 334 } |
| 335 |
| 336 void onStateChanged(InteractionState previous) { |
| 337 super.onStateChanged(previous); |
| 338 scheduleCompilation(); |
| 339 } |
| 340 } |
| 341 |
| 342 class PendingInputState extends InitialState { |
| 343 PendingInputState(InteractionContext context) |
| 344 : super(context); |
| 345 |
| 346 void onInput(Event event) { |
| 347 // Do nothing. |
| 348 } |
| 349 |
| 350 void onMutation(List<MutationRecord> mutations, MutationObserver observer) { |
| 351 super.onMutation(mutations, observer); |
| 352 |
| 353 InteractionState nextState = new InitialState(context); |
| 354 if (settings.enableCodeCompletion.value) { |
| 355 Element parent = editor.getElementAtSelection(); |
| 356 Element ui; |
| 357 if (parent != null) { |
| 358 ui = parent.querySelector('.dart-code-completion'); |
| 359 if (ui != null) { |
| 360 nextState = new CodeCompletionState(context, parent, ui); |
| 361 } |
| 362 } |
| 363 } |
| 364 state = nextState; |
| 365 } |
| 366 } |
| 367 |
| 368 class CodeCompletionState extends InitialState { |
| 369 final Element activeCompletion; |
| 370 final Element ui; |
| 371 int minWidth = 0; |
| 372 DivElement staticResults; |
| 373 SpanElement inline; |
| 374 DivElement serverResults; |
| 375 String inlineSuggestion; |
| 376 |
| 377 CodeCompletionState(InteractionContext context, |
| 378 this.activeCompletion, |
| 379 this.ui) |
| 380 : super(context); |
| 381 |
| 382 void onInput(Event event) { |
| 383 // Do nothing. |
| 384 } |
| 385 |
| 386 void onModifiedKeyUp(KeyboardEvent event) { |
| 387 // TODO(ahe): Handle DOWN (jump to server results). |
| 388 } |
| 389 |
| 390 void onUnmodifiedKeyUp(KeyboardEvent event) { |
| 391 switch (event.keyCode) { |
| 392 case KeyCode.DOWN: |
| 393 return moveDown(event); |
| 394 |
| 395 case KeyCode.UP: |
| 396 return moveUp(event); |
| 397 |
| 398 case KeyCode.ESC: |
| 399 event.preventDefault(); |
| 400 return endCompletion(); |
| 401 |
| 402 case KeyCode.TAB: |
| 403 case KeyCode.RIGHT: |
| 404 case KeyCode.ENTER: |
| 405 event.preventDefault(); |
| 406 return endCompletion(acceptSuggestion: true); |
| 407 } |
| 408 } |
| 409 |
| 410 void moveDown(Event event) { |
| 411 event.preventDefault(); |
| 412 move(1); |
| 413 } |
| 414 |
| 415 void moveUp(Event event) { |
| 416 event.preventDefault(); |
| 417 move(-1); |
| 418 } |
| 419 |
| 420 void move(int direction) { |
| 421 Element element = editor.moveActive(direction); |
| 422 if (element == null) return; |
| 423 var text = activeCompletion.firstChild; |
| 424 String prefix = ""; |
| 425 if (text is Text) prefix = text.data.trim(); |
| 426 updateInlineSuggestion(prefix, element.text); |
| 427 } |
| 428 |
| 429 void endCompletion({bool acceptSuggestion: false}) { |
| 430 if (acceptSuggestion) { |
| 431 suggestionAccepted(); |
| 432 } |
| 433 activeCompletion.classes.remove('active'); |
| 434 inputPre.querySelectorAll('.hazed-suggestion').forEach((e) => e.remove()); |
| 435 // The above changes create mutation records. This implicitly fire mutation |
| 436 // events that result in saving the source code in local storage. |
| 437 // TODO(ahe): Consider making this more explicit. |
| 438 state = new InitialState(context); |
| 439 } |
| 440 |
| 441 void suggestionAccepted() { |
| 442 if (inlineSuggestion != null) { |
| 443 Text text = new Text(inlineSuggestion); |
| 444 activeCompletion.replaceWith(text); |
| 445 window.getSelection().collapse(text, inlineSuggestion.length); |
| 446 } |
| 447 } |
| 448 |
| 449 void onMutation(List<MutationRecord> mutations, MutationObserver observer) { |
| 450 for (MutationRecord record in mutations) { |
| 451 if (!activeCompletion.contains(record.target)) { |
| 452 endCompletion(); |
| 453 return super.onMutation(mutations, observer); |
| 454 } |
| 455 } |
| 456 |
| 457 var text = activeCompletion.firstChild; |
| 458 if (text is! Text) return endCompletion(); |
| 459 updateSuggestions(text.data.trim()); |
| 460 } |
| 461 |
| 462 void onStateChanged(InteractionState previous) { |
| 463 super.onStateChanged(previous); |
| 464 displayCodeCompletion(); |
| 465 } |
| 466 |
| 467 void displayCodeCompletion() { |
| 468 Selection selection = window.getSelection(); |
| 469 if (selection.anchorNode is! Text) { |
| 470 return endCompletion(); |
| 471 } |
| 472 Text text = selection.anchorNode; |
| 473 if (!activeCompletion.contains(text)) { |
| 474 return endCompletion(); |
| 475 } |
| 476 |
| 477 int anchorOffset = selection.anchorOffset; |
| 478 |
| 479 String prefix = text.data.substring(0, anchorOffset).trim(); |
| 480 if (prefix.isEmpty) { |
| 481 return endCompletion(); |
| 482 } |
| 483 |
| 484 num height = activeCompletion.getBoundingClientRect().height; |
| 485 activeCompletion.classes.add('active'); |
| 486 ui.nodes.clear(); |
| 487 |
| 488 inline = new SpanElement() |
| 489 ..classes.add('hazed-suggestion'); |
| 490 Text rest = text.splitText(anchorOffset); |
| 491 text.parentNode.insertBefore(inline, text.nextNode); |
| 492 activeCompletion.parentNode.insertBefore( |
| 493 rest, activeCompletion.nextNode); |
| 494 |
| 495 staticResults = new DivElement() |
| 496 ..classes.addAll(['dart-static', 'dart-limited-height']); |
| 497 serverResults = new DivElement() |
| 498 ..style.display = 'none' |
| 499 ..classes.add('dart-server'); |
| 500 ui.nodes.addAll([staticResults, serverResults]); |
| 501 ui.style.top = '${height}px'; |
| 502 |
| 503 staticResults.nodes.add(buildCompletionEntry(prefix)); |
| 504 |
| 505 updateSuggestions(prefix); |
| 506 } |
| 507 |
| 508 void updateInlineSuggestion(String prefix, String suggestion) { |
| 509 inlineSuggestion = suggestion; |
| 510 |
| 511 minWidth = max(minWidth, activeCompletion.getBoundingClientRect().width); |
| 512 |
| 513 activeCompletion.style |
| 514 ..display = 'inline-block' |
| 515 ..minWidth = '${minWidth}px'; |
| 516 |
| 517 inline |
| 518 ..nodes.clear() |
| 519 ..appendText(suggestion.substring(prefix.length)) |
| 520 ..style.display = ''; |
| 521 |
| 522 observer.takeRecords(); // Discard mutations. |
| 523 } |
| 524 |
| 525 void updateSuggestions(String prefix) { |
| 526 if (prefix.isEmpty) { |
| 527 return endCompletion(); |
| 528 } |
| 529 |
| 530 Token first = tokenize(prefix); |
| 531 for (Token token = first; token.kind != EOF_TOKEN; token = token.next) { |
| 532 String tokenInfo = token.info.value; |
| 533 if (token != first || |
| 534 tokenInfo != 'identifier' && |
| 535 tokenInfo != 'keyword') { |
| 536 return endCompletion(); |
| 537 } |
| 538 } |
| 539 |
| 540 var borderHeight = 2; // 1 pixel border top & bottom. |
| 541 num height = ui.getBoundingClientRect().height - borderHeight; |
| 542 ui.style.minHeight = '${height}px'; |
| 543 |
| 544 minWidth = |
| 545 max(minWidth, activeCompletion.getBoundingClientRect().width); |
| 546 |
| 547 staticResults.nodes.clear(); |
| 548 serverResults.nodes.clear(); |
| 549 |
| 550 if (inlineSuggestion != null && inlineSuggestion.startsWith(prefix)) { |
| 551 inline |
| 552 ..nodes.clear() |
| 553 ..appendText(inlineSuggestion.substring(prefix.length)); |
| 554 } |
| 555 |
| 556 List<String> results = editor.seenIdentifiers.where( |
| 557 (String identifier) { |
| 558 return identifier != prefix && identifier.startsWith(prefix); |
| 559 }).toList(growable: false); |
| 560 results.sort(); |
| 561 if (results.isEmpty) results = <String>[prefix]; |
| 562 |
| 563 results.forEach((String completion) { |
| 564 staticResults.nodes.add(buildCompletionEntry(completion)); |
| 565 }); |
| 566 |
| 567 if (settings.enableDartMind) { |
| 568 // TODO(ahe): Move this code to its own function or class. |
| 569 String encodedArg0 = Uri.encodeComponent('"$prefix"'); |
| 570 String mindQuery = |
| 571 'http://dart-mind.appspot.com/rpc' |
| 572 '?action=GetExportingPubCompletions' |
| 573 '&arg0=$encodedArg0'; |
| 574 try { |
| 575 var serverWatch = new Stopwatch()..start(); |
| 576 HttpRequest.getString(mindQuery).then((String responseText) { |
| 577 serverWatch.stop(); |
| 578 List<String> serverSuggestions = JSON.decode(responseText); |
| 579 if (!serverSuggestions.isEmpty) { |
| 580 updateInlineSuggestion(prefix, serverSuggestions.first); |
| 581 } |
| 582 for (int i = 1; i < serverSuggestions.length; i++) { |
| 583 String completion = serverSuggestions[i]; |
| 584 DivElement where = staticResults; |
| 585 int index = results.indexOf(completion); |
| 586 if (index != -1) { |
| 587 List<Element> entries = |
| 588 document.querySelectorAll('.dart-static>.dart-entry'); |
| 589 entries[index].classes.add('doubleplusgood'); |
| 590 } else { |
| 591 if (results.length > 3) { |
| 592 serverResults.style.display = 'block'; |
| 593 where = serverResults; |
| 594 } |
| 595 Element entry = buildCompletionEntry(completion); |
| 596 entry.classes.add('doubleplusgood'); |
| 597 where.nodes.add(entry); |
| 598 } |
| 599 } |
| 600 serverResults.appendHtml( |
| 601 '<div>${serverWatch.elapsedMilliseconds}ms</div>'); |
| 602 // Discard mutations. |
| 603 observer.takeRecords(); |
| 604 }).catchError((error, stack) { |
| 605 window.console.dir(error); |
| 606 window.console.error('$stack'); |
| 607 }); |
| 608 } catch (error, stack) { |
| 609 window.console.dir(error); |
| 610 window.console.error('$stack'); |
| 611 } |
| 612 } |
| 613 // Discard mutations. |
| 614 observer.takeRecords(); |
| 615 } |
| 616 |
| 617 Element buildCompletionEntry(String completion) { |
| 618 return new DivElement() |
| 619 ..classes.add('dart-entry') |
| 620 ..appendText(completion); |
| 621 } |
| 622 } |
| 623 |
| 624 Token tokenize(String text) { |
| 625 var file = new StringSourceFile('', text); |
| 626 return new StringScanner(file, includeComments: true).tokenize(); |
| 627 } |
| 628 |
| 629 bool computeHasModifier(KeyboardEvent event) { |
| 630 return |
| 631 event.getModifierState("Alt") || |
| 632 event.getModifierState("AltGraph") || |
| 633 event.getModifierState("CapsLock") || |
| 634 event.getModifierState("Control") || |
| 635 event.getModifierState("Fn") || |
| 636 event.getModifierState("Meta") || |
| 637 event.getModifierState("NumLock") || |
| 638 event.getModifierState("ScrollLock") || |
| 639 event.getModifierState("Scroll") || |
| 640 event.getModifierState("Win") || |
| 641 event.getModifierState("Shift") || |
| 642 event.getModifierState("SymbolLock") || |
| 643 event.getModifierState("OS"); |
| 644 } |
| OLD | NEW |