diff --git a/lib/codelab.dart b/lib/codelab.dart index a104e9141..1b5d42250 100644 --- a/lib/codelab.dart +++ b/lib/codelab.dart @@ -1,18 +1,24 @@ import 'dart:async'; import 'dart:html' hide Console; +import 'package:dart_pad/context.dart'; import 'package:dart_pad/util/query_params.dart'; +import 'package:logging/logging.dart'; import 'package:markdown/markdown.dart' as markdown; import 'package:mdc_web/mdc_web.dart'; import 'package:split/split.dart'; +import 'package:stream_transform/stream_transform.dart'; import 'codelabs/codelabs.dart'; +import 'completion.dart'; import 'core/dependencies.dart'; import 'core/modules.dart'; import 'dart_pad.dart'; +import 'documentation.dart'; import 'editing/codemirror_options.dart'; import 'editing/editor.dart'; import 'editing/editor_codemirror.dart'; +import 'elements/analysis_results_controller.dart'; import 'elements/button.dart'; import 'elements/console.dart'; import 'elements/counter.dart'; @@ -28,11 +34,14 @@ import 'services/execution.dart'; import 'services/execution_iframe.dart'; import 'src/ga.dart'; import 'hljs.dart' as hljs; +import 'util/keymap.dart'; CodelabUi _codelabUi; CodelabUi get codelabUi => _codelabUi; +final Logger _logger = Logger('dartpad'); + void init() { _codelabUi = CodelabUi(); } @@ -51,6 +60,12 @@ class CodelabUi { MaterialTabController consolePanelTabController; Counter unreadConsoleCounter; Dialog dialog; + DocHandler docHandler; + Future _analysisRequest; + DartSourceProvider sourceProvider; + MDCButton formatButton; + DBusyLight busyLight; + AnalysisResultsController analysisResultsController; CodelabUi() { _init(); @@ -69,10 +84,12 @@ class CodelabUi { Future _init() async { _initDialogs(); await _loadCodelab(); + _initBusyLights(); _initHeader(); _updateInstructions(); await _initModules(); _initCodelabUi(); + _initKeyBindings(); _initEditor(); _initSplitters(); _initStepButtons(); @@ -81,6 +98,7 @@ class CodelabUi { _initButtons(); _updateCode(); _initTabs(); + _focusEditor(); } Future _initModules() async { @@ -97,6 +115,10 @@ class CodelabUi { dialog = Dialog(); } + void _initBusyLights() { + busyLight = DBusyLight(querySelector('#dartbusy')); + } + void _initEditor() { // Set up CodeMirror editor = (editorFactory as CodeMirrorFactory) @@ -104,6 +126,27 @@ class CodelabUi { ..theme = 'darkpad' ..mode = 'dart' ..showLineNumbers = true; + + sourceProvider = CodelabDartSourceProvider(editor); + docHandler = DocHandler(editor, sourceProvider); + + editor.document.onChange.listen((_) => busyLight.on()); + editor.document.onChange + .debounce(Duration(milliseconds: 1250)) + .listen((_) => _performAnalysis()); + + editorFactory.registerCompleter( + 'dart', DartCompleter(dartServices, editor.document)); + + // Listen for changes that would effect the documentation panel. + editor.onMouseDown.listen((e) { + // Delay to give codemirror time to process the mouse event. + Timer.run(() { + if (!_cursorPositionIsWhitespace()) { + docHandler.generateDoc([_documentationElement]); + } + }); + }); } void _initCodelabUi() { @@ -116,6 +159,69 @@ class CodelabUi { // Use null safety for codelabs (deps[DartservicesApi] as DartservicesApi).rootUrl = nullSafetyServerUrl; + + analysisResultsController = AnalysisResultsController( + DElement(querySelector('#issues')), + DElement(querySelector('#issues-message')), + DElement(querySelector('#issues-toggle'))) + ..onItemClicked.listen((item) { + _jumpTo(item.line, item.charStart, item.charLength, focus: true); + }); + + _updateVersion(); + + querySelector('#keyboard-button') + .onClick + .listen((_) => _showKeyboardDialog()); + } + + void _initKeyBindings() { + // set up key bindings + keys.bind(['ctrl-enter'], _handleRun, 'Run'); + keys.bind(['f1'], () { + ga.sendEvent('main', 'help'); + docHandler.generateDoc([_documentationElement]); + }, 'Documentation'); + + keys.bind(['alt-enter'], () { + editor.showCompletions(onlyShowFixes: true); + }, 'Quick fix'); + + keys.bind(['ctrl-space', 'macctrl-space'], () { + editor.showCompletions(); + }, 'Completion'); + + keys.bind(['shift-ctrl-/', 'shift-macctrl-/'], () { + _showKeyboardDialog(); + }, 'Keyboard Shortcuts'); + keys.bind(['shift-ctrl-f', 'shift-macctrl-f'], () { + _format(); + }, 'Format'); + + document.onKeyUp.listen((e) { + if (editor.completionActive || + DocHandler.cursorKeys.contains(e.keyCode)) { + docHandler.generateDoc([_documentationElement]); + } + _handleAutoCompletion(e); + }); + } + + void _handleAutoCompletion(KeyboardEvent e) { + if (editor.hasFocus) { + if (e.keyCode == KeyCode.PERIOD) { + editor.showCompletions(autoInvoked: true); + } + } + } + + void _updateVersion() { + dartServices.version().then((VersionResponse version) { + // "Based on Flutter 1.19.0-4.1.pre Dart SDK 2.8.4" + var versionText = 'Based on Flutter ${version.flutterVersion}' + ' Dart SDK ${version.sdkVersionFull}'; + querySelector('#dartpad-version').text = versionText; + }).catchError((e) => null); } Future _loadCodelab() async { @@ -189,6 +295,8 @@ class CodelabUi { showSolutionButton = MDCButton(querySelector('#show-solution-btn') as ButtonElement) ..onClick.listen((_) => _handleShowSolution()); + formatButton = MDCButton(querySelector('#format-button') as ButtonElement) + ..onClick.listen((_) => _format()); } void _updateSolutionButton() { @@ -213,8 +321,15 @@ class CodelabUi { _changeConsoleTab(name); })); } - // Set the current tab to UI Output - _changeConsoleTab('ui-output'); + + // Set the current tab to UI Output or console, depending on whether this is + // Dart or Flutter codelab. + if (_codelabState.codelab.type == CodelabType.dart) { + querySelector('#ui-output-tab').hidden = true; + consolePanelTabController.selectTab('console'); + } else { + consolePanelTabController.selectTab('ui-output'); + } } void _changeConsoleTab(String name) { @@ -272,6 +387,10 @@ class CodelabUi { '"gh_owner", "gh_repo", "gh_ref", and "gh_path"'); } + void _showKeyboardDialog() { + dialog.showOk('Keyboard shortcuts', keyMapToHtml(keys.inverseBindings)); + } + void _handleRun() async { ga.sendEvent('main', 'run'); runButton.disabled = true; @@ -330,6 +449,99 @@ class CodelabUi { } } + /// Perform static analysis of the source code. Return whether the code + /// analyzed cleanly (had no errors or warnings). + Future _performAnalysis() { + var input = SourceRequest()..source = sourceProvider.dartSource; + + var lines = Lines(input.source); + + var request = dartServices.analyze(input).timeout(serviceCallTimeout); + _analysisRequest = request; + + return request.then((AnalysisResults result) { + // Discard if we requested another analysis. + if (_analysisRequest != request) return false; + + // Discard if the document has been mutated since we requested analysis. + if (input.source != sourceProvider.dartSource) return false; + + busyLight.reset(); + + _displayIssues(result.issues); + + editor.document.setAnnotations(result.issues.map((AnalysisIssue issue) { + var startLine = lines.getLineForOffset(issue.charStart); + var endLine = + lines.getLineForOffset(issue.charStart + issue.charLength); + + var start = Position( + startLine, issue.charStart - lines.offsetForLine(startLine)); + var end = Position( + endLine, + issue.charStart + + issue.charLength - + lines.offsetForLine(startLine)); + + return Annotation(issue.kind, issue.message, issue.line, + start: start, end: end); + }).toList()); + + var hasErrors = result.issues.any((issue) => issue.kind == 'error'); + var hasWarnings = result.issues.any((issue) => issue.kind == 'warning'); + + return hasErrors == false && hasWarnings == false; + }).catchError((e) { + if (e is! TimeoutException) { + final message = e is ApiRequestError ? e.message : '$e'; + + _displayIssues([ + AnalysisIssue() + ..kind = 'error' + ..line = 1 + ..message = message + ]); + } else { + _logger.severe(e); + } + + editor.document.setAnnotations([]); + busyLight.reset(); + }); + } + + Future _format() { + var originalSource = sourceProvider.dartSource; + var input = SourceRequest()..source = originalSource; + formatButton.disabled = true; + + var request = dartServices.format(input).timeout(serviceCallTimeout); + return request.then((FormatResponse result) { + busyLight.reset(); + formatButton.disabled = false; + + if (result.newString == null || result.newString.isEmpty) { + _logger.fine('Format returned null/empty result'); + return; + } + + if (originalSource != result.newString) { + editor.document.updateValue(result.newString); + _showSnackbar('Format successful.'); + } else { + _showSnackbar('No formatting changes.'); + } + }).catchError((e) { + busyLight.reset(); + formatButton.disabled = false; + _logger.severe(e); + }); + } + + void _focusEditor() { + editor.focus(); + } + void _clearOutput() { _console.clear(); unreadConsoleCounter.clear(); @@ -358,6 +570,29 @@ class CodelabUi { showSolutionButton.disabled = true; } } + + void _displayIssues(List issues) { + analysisResultsController.display(issues); + } + + void _jumpTo(int line, int charStart, int charLength, {bool focus = false}) { + final doc = editor.document; + + doc.select( + doc.posFromIndex(charStart), doc.posFromIndex(charStart + charLength)); + + if (focus) editor.focus(); + } + + /// Return true if the current cursor position is in a whitespace char. + bool _cursorPositionIsWhitespace() { + var document = editor.document; + var str = document.value; + var index = document.indexFromPos(document.cursor); + if (index < 0 || index >= str.length) return false; + var char = str[index]; + return char != char.trim(); + } } class CodelabState { @@ -386,3 +621,15 @@ class CodelabState { bool get hasPreviousStep => _currentStepIndex > 0; } + +class CodelabDartSourceProvider implements DartSourceProvider { + final Editor editor; + + CodelabDartSourceProvider(this.editor); + + @override + String get dartSource => editor.document.value; + + @override + bool get isFocused => true; +} diff --git a/lib/context.dart b/lib/context.dart index cfcb2366b..7d9742fe8 100644 --- a/lib/context.dart +++ b/lib/context.dart @@ -6,14 +6,22 @@ library context; import 'services/dartservices.dart'; -abstract class Context { +abstract class DartSourceProvider { + bool get isFocused; + String get dartSource; +} + +abstract class Context implements DartSourceProvider { final List issues = []; String get focusedEditor; + @override + bool get isFocused => focusedEditor == 'dart'; String name; String description; + @override String dartSource; String htmlSource; String cssSource; diff --git a/lib/documentation.dart b/lib/documentation.dart index 9e514a5e0..fdced4b79 100644 --- a/lib/documentation.dart +++ b/lib/documentation.dart @@ -26,20 +26,20 @@ class DocHandler { }; final Editor _editor; - final Context _context; + final DartSourceProvider _sourceProvider; final NodeValidator _htmlValidator = PermissiveNodeValidator(); int /*?*/ _previousDocHash; - DocHandler(this._editor, this._context); + DocHandler(this._editor, this._sourceProvider); void generateDoc(List docElements) { if (docElements.isEmpty) { return; } - if (_context.focusedEditor != 'dart') { + if (!_sourceProvider.isFocused) { _previousDocHash = null; for (final docPanel in docElements) { docPanel.innerHtml = ''; @@ -59,9 +59,9 @@ class DocHandler { // completion popup was chosen, and ask for the documentation of that // source. request.source = - _sourceWithCompletionInserted(_context.dartSource, offset); + _sourceWithCompletionInserted(_sourceProvider.dartSource, offset); } else { - request.source = _context.dartSource; + request.source = _sourceProvider.dartSource; } dartServices @@ -96,9 +96,9 @@ class DocHandler { var lastSpace = source.substring(0, offset).lastIndexOf(' ') + 1; var lastDot = source.substring(0, offset).lastIndexOf('.') + 1; var insertOffset = math.max(lastSpace, lastDot); - return _context.dartSource.substring(0, insertOffset) + + return _sourceProvider.dartSource.substring(0, insertOffset) + completionText + - _context.dartSource.substring(offset); + _sourceProvider.dartSource.substring(offset); } _DocResult _getHtmlTextFor(DocumentResponse result) { diff --git a/lib/scss/shared.scss b/lib/scss/shared.scss index 49e80a9cd..d0a79a245 100644 --- a/lib/scss/shared.scss +++ b/lib/scss/shared.scss @@ -80,7 +80,46 @@ a { margin: 0; } +// Keyboard button +.keyboard { + display: inline-block; + background: url('../../pictures/keyboard.svg') center no-repeat; + background-size: 100%; + width: 22px; + height: 18px; + margin-top: 1px; + cursor: pointer; + opacity: 0.7; + + &:hover { + opacity: 1; + } +} + +// Busy light +.busylight { + width: 6px; + height: 6px; + border-radius: 50%; + margin: 9px; + + opacity: 0; + background-color: #fff; + + -webkit-transition: all 100ms cubic-bezier(0.640, 0.125, 0.235, 0.885); + -moz-transition: all 100ms cubic-bezier(0.640, 0.125, 0.235, 0.885); + -o-transition: all 100ms cubic-bezier(0.640, 0.125, 0.235, 0.885); + transition: all 100ms cubic-bezier(0.640, 0.125, 0.235, 0.885); + + -webkit-transition-timing-function: cubic-bezier(0.640, 0.125, 0.235, 0.885); + -moz-transition-timing-function: cubic-bezier(0.640, 0.125, 0.235, 0.885); + -o-transition-timing-function: cubic-bezier(0.640, 0.125, 0.235, 0.885); + transition-timing-function: cubic-bezier(0.640, 0.125, 0.235, 0.885); +} +.busylight.on { + opacity: 0.6; +} .clickable { cursor: pointer; diff --git a/pubspec.lock b/pubspec.lock index e58773a74..9a69e444a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -569,7 +569,7 @@ packages: source: hosted version: "2.1.0" stream_transform: - dependency: transitive + dependency: "direct main" description: name: stream_transform url: "https://pub.dartlang.org" diff --git a/pubspec.yaml b/pubspec.yaml index debc930ee..85a2bc3cf 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -23,6 +23,7 @@ dependencies: yaml: ^2.2.0 checked_yaml: ^1.0.0 js: ^0.6.0 + stream_transform: any dev_dependencies: build_runner: any diff --git a/web/codelabs.html b/web/codelabs.html index 83215f51c..be21fcf59 100644 --- a/web/codelabs.html +++ b/web/codelabs.html @@ -72,6 +72,12 @@ DartPad +
+ +
(No name)
@@ -202,6 +208,7 @@ Send feedback +
diff --git a/web/styles/codelabs.scss b/web/styles/codelabs.scss index 8e0ac2bad..4f44a73db 100644 --- a/web/styles/codelabs.scss +++ b/web/styles/codelabs.scss @@ -40,6 +40,12 @@ header { } } + button.mdc-button { + @include mdc-button-ink-color(#f8f9fa); + text-transform: none !important; + letter-spacing: normal; + } + #codelab-name { @include layout-flex; @include layout-horizontal; @@ -230,6 +236,36 @@ body>footer { } } +// Issues +#issues { + background-color: $dark-issues-background-color; + border: 8px solid $dark-issues-background-color; +} + +.issue .issuelabel { + color: $dark-issue-label-color; +} + +.issue:hover { + background-color: darken($dark-issues-background-color, 3%); +} + +.issue .message { + color: $dark-issue-label-color; +} + +#issues-toggle, .issue-anchor { + color: $mdc-theme-primary; + &:visited { + color: $mdc-theme-primary; + } + + &:hover { + color: darken($mdc-theme-primary, 12%); + } +} + + // Splitter .gutter { background-color: $dark-gutter-background-color; diff --git a/web/styles/styles.scss b/web/styles/styles.scss index 7705357de..d46c27305 100644 --- a/web/styles/styles.scss +++ b/web/styles/styles.scss @@ -254,32 +254,6 @@ a { opacity: 0; } -// Busy light - -.busylight { - width: 6px; - height: 6px; - border-radius: 50%; - margin: 9px; - - opacity: 0; - background-color: #fff; - - -webkit-transition: all 100ms cubic-bezier(0.640, 0.125, 0.235, 0.885); - -moz-transition: all 100ms cubic-bezier(0.640, 0.125, 0.235, 0.885); - -o-transition: all 100ms cubic-bezier(0.640, 0.125, 0.235, 0.885); - transition: all 100ms cubic-bezier(0.640, 0.125, 0.235, 0.885); - - -webkit-transition-timing-function: cubic-bezier(0.640, 0.125, 0.235, 0.885); - -moz-transition-timing-function: cubic-bezier(0.640, 0.125, 0.235, 0.885); - -o-transition-timing-function: cubic-bezier(0.640, 0.125, 0.235, 0.885); - transition-timing-function: cubic-bezier(0.640, 0.125, 0.235, 0.885); -} - -.busylight.on { - opacity: 0.6; -} - // Contenteditable elements [contenteditable]:focus { outline: 0 solid transparent; @@ -476,20 +450,6 @@ a { margin-left: 12px; } -.keyboard { - display: inline-block; - background: url('../../pictures/keyboard.svg') center no-repeat; - background-size: 100%; - width: 22px; - height: 18px; - margin-top: 1px; - cursor: pointer; - opacity: 0.7; - - &:hover { - opacity: 1; - } -} // Make the dialog show up even without everything initialized (for localStorage check) .mdc-dialog {