diff --git a/analysis_options.yaml b/analysis_options.yaml index 834f27b8c..ceb88a554 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -10,7 +10,7 @@ analyzer: - 'lib/bower/**' - 'lib/src/protos/**' - 'third_party/pkg/**' - + - 'web/example/codelabs/**' linter: rules: - await_only_futures diff --git a/build.yaml b/build.yaml index 9ce521afd..65f302e1b 100644 --- a/build.yaml +++ b/build.yaml @@ -1,8 +1,24 @@ targets: $default: builders: + # Don't clean up codelab examples, they need to be served. + build_web_compilers:dart_source_cleanup: + generate_for: + exclude: + - web/example/codelabs/**.dart + build_web_compilers|entrypoint: + # Don't compile codelab examples. + generate_for: + exclude: + - web/example/codelabs/**.dart options: dart2js_args: - --minify - --fast-startup + + json_serializable: + # Ignore codelab samples. + generate_for: + exclude: + - web/example/codelabs/**.dart diff --git a/lib/codelab.dart b/lib/codelab.dart new file mode 100644 index 000000000..10be3668a --- /dev/null +++ b/lib/codelab.dart @@ -0,0 +1,386 @@ +import 'dart:async'; +import 'dart:html' hide Console; + +import 'package:dart_pad/util/query_params.dart'; +import 'package:markdown/markdown.dart' as markdown; +import 'package:mdc_web/mdc_web.dart'; +import 'package:split/split.dart'; + +import 'codelabs/codelabs.dart'; +import 'core/dependencies.dart'; +import 'core/modules.dart'; +import 'dart_pad.dart'; +import 'editing/codemirror_options.dart'; +import 'editing/editor.dart'; +import 'editing/editor_codemirror.dart'; +import 'elements/button.dart'; +import 'elements/console.dart'; +import 'elements/counter.dart'; +import 'elements/dialog.dart'; +import 'elements/elements.dart'; +import 'elements/material_tab_controller.dart'; +import 'modules/codemirror_module.dart'; +import 'modules/dart_pad_module.dart'; +import 'modules/dartservices_module.dart'; +import 'services/common.dart'; +import 'services/dartservices.dart'; +import 'services/execution.dart'; +import 'services/execution_iframe.dart'; +import 'src/ga.dart'; + +CodelabUi _codelabUi; + +CodelabUi get codelabUi => _codelabUi; + +void init() { + _codelabUi = CodelabUi(); +} + +class CodelabUi { + CodelabState _codelabState; + Splitter splitter; + Splitter rightSplitter; + Editor editor; + DElement stepLabel; + DElement previousStepButton; + DElement nextStepButton; + Console _console; + MDCButton runButton; + MDCButton showSolutionButton; + MaterialTabController consolePanelTabController; + Counter unreadConsoleCounter; + Dialog dialog; + + CodelabUi() { + _init(); + } + + DivElement get _editorHost => querySelector('#editor-host') as DivElement; + + DivElement get _consoleElement => + querySelector('#output-panel-content') as DivElement; + + DivElement get _documentationElement => + querySelector('#doc-panel') as DivElement; + + IFrameElement get _frame => querySelector('#frame') as IFrameElement; + + Future _init() async { + _initDialogs(); + await _loadCodelab(); + _initHeader(); + _updateInstructions(); + await _initModules(); + _initCodelabUi(); + _initEditor(); + _initSplitters(); + _initStepButtons(); + _initStepListener(); + _initConsoles(); + _initButtons(); + _updateCode(); + _initTabs(); + } + + Future _initModules() async { + var modules = ModuleManager(); + + modules.register(DartPadModule()); + modules.register(DartServicesModule()); + modules.register(CodeMirrorModule()); + + await modules.start(); + } + + void _initDialogs() { + dialog = Dialog(); + } + + void _initEditor() { + // Set up CodeMirror + editor = (editorFactory as CodeMirrorFactory) + .createFromElement(_editorHost, options: codeMirrorOptions) + ..theme = 'darkpad' + ..mode = 'dart' + ..showLineNumbers = true; + } + + void _initCodelabUi() { + // Set up the iframe. + deps[ExecutionService] = ExecutionServiceIFrame(_frame); + executionService.onStdout.listen(_showOutput); + executionService.onStderr.listen((m) => _showOutput(m, error: true)); + // Set up Google Analytics. + deps[Analytics] = Analytics(); + + // Use null safety for codelabs + (deps[DartservicesApi] as DartservicesApi).rootUrl = nullSafetyServerUrl; + } + + Future _loadCodelab() async { + var fetcher = await _getFetcher(); + _codelabState = CodelabState(await fetcher.getCodelab()); + } + + void _initSplitters() { + var stepsPanel = querySelector('#steps-panel'); + var rightPanel = querySelector('#right-panel'); + var editorPanel = querySelector('#editor-panel'); + var outputPanel = querySelector('#output-panel'); + splitter = flexSplit( + [stepsPanel, rightPanel], + horizontal: true, + gutterSize: 6, + sizes: const [50, 50], + minSize: [100, 100], + ); + rightSplitter = flexSplit( + [editorPanel, outputPanel], + horizontal: false, + gutterSize: 6, + sizes: const [50, 50], + minSize: [100, 100], + ); + + // Resize Codemirror when the size of the panel changes. This keeps the + // virtual scrollbar in sync with the size of the panel. + ResizeObserver((entries, observer) { + editor.resize(); + }).observe(editorPanel); + } + + void _initHeader() { + querySelector('#codelab-name').text = _codelabState.codelab.name; + } + + void _initStepButtons() { + stepLabel = DElement(querySelector('#steps-label')); + previousStepButton = DElement(querySelector('#previous-step-btn')) + ..onClick.listen((event) { + _codelabState.currentStepIndex--; + }); + nextStepButton = DElement(querySelector('#next-step-btn')) + ..onClick.listen((event) { + _codelabState.currentStepIndex++; + }); + _updateStepButtons(); + } + + void _initStepListener() { + _codelabState.onStepChanged.listen((event) { + _updateInstructions(); + _updateStepButtons(); + _updateCode(); + _updateSolutionButton(); + }); + } + + void _initConsoles() { + _console = Console(DElement(_consoleElement)); + unreadConsoleCounter = + Counter(querySelector('#unread-console-counter') as SpanElement); + } + + void _initButtons() { + runButton = MDCButton(querySelector('#run-button') as ButtonElement) + ..onClick.listen((_) => _handleRun()); + + showSolutionButton = + MDCButton(querySelector('#show-solution-btn') as ButtonElement) + ..onClick.listen((_) => _handleShowSolution()); + } + + void _updateSolutionButton() { + if (_codelabState.currentStep.solution == null) { + showSolutionButton.element.style.visibility = 'hidden'; + } else { + showSolutionButton.element.style.visibility = null; + } + showSolutionButton.disabled = false; + } + + void _updateCode() { + editor.document.updateValue(_codelabState.currentStep.snippet); + } + + void _initTabs() { + var consoleTabBar = querySelector('#web-tab-bar'); + consolePanelTabController = MaterialTabController(MDCTabBar(consoleTabBar)); + for (var name in ['ui-output', 'console', 'documentation']) { + consolePanelTabController.registerTab( + TabElement(querySelector('#$name-tab'), name: name, onSelect: () { + _changeConsoleTab(name); + })); + } + // Set the current tab to UI Output + _changeConsoleTab('ui-output'); + } + + void _changeConsoleTab(String name) { + if (name == 'ui-output') { + _frame.hidden = false; + _consoleElement.hidden = true; + _documentationElement.hidden = true; + } else if (name == 'console') { + _frame.hidden = true; + _consoleElement.hidden = false; + _documentationElement.hidden = true; + } else if (name == 'documentation') { + _frame.hidden = true; + _consoleElement.hidden = true; + _documentationElement.hidden = false; + } + } + + void _updateInstructions() { + var div = querySelector('#markdown-content'); + div.children.clear(); + div.innerHtml = + markdown.markdownToHtml(_codelabState.currentStep.instructions); + } + + void _updateStepButtons() { + stepLabel.text = 'Step ${_codelabState.currentStepIndex + 1}'; + previousStepButton.toggleAttr('disabled', !_codelabState.hasPreviousStep); + nextStepButton.toggleAttr('disabled', !_codelabState.hasNextStep); + } + + Future _getFetcher() async { + var webServer = queryParams.webServer; + if (webServer != null && webServer.isNotEmpty) { + var uri = Uri.parse(webServer); + return WebServerCodelabFetcher(uri); + } + var ghOwner = queryParams.githubOwner; + var ghRepo = queryParams.githubRepo; + var ghRef = queryParams.githubRef; + var ghPath = queryParams.githubPath; + if (ghOwner != null && + ghOwner.isNotEmpty && + ghRepo != null && + ghRepo.isNotEmpty) { + return GithubCodelabFetcher( + owner: ghOwner, + repo: ghRepo, + ref: ghRef, + path: ghPath, + ); + } + throw ('Invalid parameters provided. Use either "webserver" or ' + '"gh_owner", "gh_repo", "gh_ref", and "gh_path"'); + } + + void _handleRun() async { + ga.sendEvent('main', 'run'); + runButton.disabled = true; + + var compilationTimer = Stopwatch()..start(); + + final compileRequest = CompileRequest()..source = editor.document.value; + + try { + if (_codelabState.codelab.type == CodelabType.flutter) { + final response = await dartServices + .compileDDC(compileRequest) + .timeout(longServiceCallTimeout); + + ga.sendTiming( + 'action-perf', + 'compilation-e2e', + compilationTimer.elapsedMilliseconds, + ); + + _clearOutput(); + + await executionService.execute( + '', + '', + response.result, + modulesBaseUrl: response.modulesBaseUrl, + ); + } else { + final response = await dartServices + .compile(compileRequest) + .timeout(longServiceCallTimeout); + + ga.sendTiming( + 'action-perf', + 'compilation-e2e', + compilationTimer.elapsedMilliseconds, + ); + + _clearOutput(); + + await executionService.execute( + '', + '', + response.result, + ); + } + } catch (e) { + ga.sendException('${e.runtimeType}'); + final message = e is ApiRequestError ? e.message : '$e'; + _showSnackbar('Error compiling to JavaScript'); + _clearOutput(); + _showOutput('Error compiling to JavaScript:\n$message', error: true); + } finally { + runButton.disabled = false; + } + } + + void _clearOutput() { + _console.clear(); + unreadConsoleCounter.clear(); + } + + void _showOutput(String message, {bool error = false}) { + _console.showOutput(message, error: error); + if (consolePanelTabController.selectedTab.name != 'console') { + unreadConsoleCounter.increment(); + } + } + + void _showSnackbar(String message) { + var div = querySelector('.mdc-snackbar'); + var snackbar = MDCSnackbar(div)..labelText = message; + snackbar.open(); + } + + Future _handleShowSolution() async { + var result = await dialog.showOkCancel( + 'Show solution', + 'Are you sure you want to show the solution? Your changes for this ' + 'step will be lost.'); + if (result == DialogResult.ok) { + editor.document.updateValue(_codelabState.currentStep.solution); + showSolutionButton.disabled = true; + } + } +} + +class CodelabState { + final StreamController _controller = StreamController.broadcast(); + int _currentStepIndex = 0; + + final Codelab codelab; + + CodelabState(this.codelab); + + Stream get onStepChanged => _controller.stream; + + Step get currentStep => codelab.steps[_currentStepIndex]; + + int get currentStepIndex => _currentStepIndex; + + set currentStepIndex(int stepIndex) { + if (stepIndex < 0 || stepIndex >= codelab.steps.length) { + throw ('Invalid step index: $stepIndex'); + } + _currentStepIndex = stepIndex; + _controller.add(codelab.steps[stepIndex]); + } + + bool get hasNextStep => _currentStepIndex < codelab.steps.length - 1; + + bool get hasPreviousStep => _currentStepIndex > 0; +} diff --git a/lib/codelabs/codelabs.dart b/lib/codelabs/codelabs.dart new file mode 100644 index 000000000..fbd04d098 --- /dev/null +++ b/lib/codelabs/codelabs.dart @@ -0,0 +1,5 @@ +export 'src/codelab.dart'; +export 'src/fetcher.dart'; +export 'src/github.dart'; +export 'src/step.dart'; +export 'src/web_server.dart'; diff --git a/lib/codelabs/src/codelab.dart b/lib/codelabs/src/codelab.dart new file mode 100644 index 000000000..a0bac48fc --- /dev/null +++ b/lib/codelabs/src/codelab.dart @@ -0,0 +1,10 @@ +import 'step.dart'; + +enum CodelabType { dart, flutter } + +class Codelab { + final String name; + final CodelabType type; + final List steps; + Codelab(this.name, this.type, this.steps); +} diff --git a/lib/codelabs/src/exceptions.dart b/lib/codelabs/src/exceptions.dart new file mode 100644 index 000000000..a8fe57f47 --- /dev/null +++ b/lib/codelabs/src/exceptions.dart @@ -0,0 +1,13 @@ +enum CodelabFetchExceptionType { + unknown, + contentNotFound, + rateLimitExceeded, + invalidMetadata, +} + +class CodelabFetchException implements Exception { + final CodelabFetchExceptionType failureType; + final String message; + + const CodelabFetchException(this.failureType, [this.message]); +} diff --git a/lib/codelabs/src/fetcher.dart b/lib/codelabs/src/fetcher.dart new file mode 100644 index 000000000..9644b04dd --- /dev/null +++ b/lib/codelabs/src/fetcher.dart @@ -0,0 +1,17 @@ +import 'package:dart_pad/codelabs/codelabs.dart'; +import 'package:meta/meta.dart'; + +import 'codelab.dart'; +import 'github.dart'; + +abstract class CodelabFetcher { + Future getCodelab(); + factory CodelabFetcher.github({ + @required String owner, + @required String repo, + String ref, + String path, + }) => + GithubCodelabFetcher(owner: owner, repo: repo, path: path, ref: ref); + factory CodelabFetcher.webserver(Uri uri) => WebServerCodelabFetcher(uri); +} diff --git a/lib/codelabs/src/fetcher_impl.dart b/lib/codelabs/src/fetcher_impl.dart new file mode 100644 index 000000000..974f0c146 --- /dev/null +++ b/lib/codelabs/src/fetcher_impl.dart @@ -0,0 +1,40 @@ +import 'package:checked_yaml/checked_yaml.dart'; + +import 'codelab.dart'; +import 'fetcher.dart'; +import 'meta.dart'; +import 'step.dart'; + +abstract class CodelabFetcherImpl implements CodelabFetcher { + Future loadFileContents(List relativePath); + + @override + Future getCodelab() async { + var metadata = await fetchMeta(); + var steps = await fetchSteps(metadata); + return Codelab('Example codelab', metadata.type, steps); + } + + Future fetchMeta() async { + var contents = await loadFileContents(['meta.yaml']); + return checkedYamlDecode(contents, (Map m) => Meta.fromJson(m)); + } + + Future> fetchSteps(Meta metadata) async { + var steps = []; + for (var stepConfig in metadata.steps) { + steps.add(await fetchStep(stepConfig)); + } + return steps; + } + + Future fetchStep(StepConfiguration config) async { + var directory = config.directory; + var instructions = await loadFileContents([directory, 'instructions.md']); + var snippet = await loadFileContents([directory, 'snippet.dart']); + var solution = config.hasSolution + ? await loadFileContents([directory, 'solution.dart']) + : null; + return Step(config.name, instructions, snippet, solution: solution); + } +} diff --git a/lib/codelabs/src/github.dart b/lib/codelabs/src/github.dart new file mode 100644 index 000000000..bcf26a531 --- /dev/null +++ b/lib/codelabs/src/github.dart @@ -0,0 +1,49 @@ +import 'package:dart_pad/util/github.dart'; +import 'package:http/http.dart' as http; +import 'package:meta/meta.dart'; + +import 'exceptions.dart'; +import 'fetcher_impl.dart'; + +class GithubCodelabFetcher extends CodelabFetcherImpl { + static const String _apiHostname = 'api.github.com'; + + final String owner; + final String repo; + final String ref; + final String path; + + GithubCodelabFetcher({ + @required this.owner, + @required this.repo, + this.ref, + this.path, + }); + + @override + Future loadFileContents(List relativePath) async { + var url = _buildFileUrl(relativePath); + var res = await http.get(url); + + var statusCode = res.statusCode; + if (statusCode == 404) { + throw CodelabFetchException(CodelabFetchExceptionType.contentNotFound); + } else if (statusCode == 403) { + throw CodelabFetchException(CodelabFetchExceptionType.rateLimitExceeded); + } else if (statusCode != 200) { + throw CodelabFetchException(CodelabFetchExceptionType.unknown); + } + + return extractGitHubResponseBody(res.body); + } + + Uri _buildFileUrl(List pathSegments) { + var filePath = [if (path != null) path, ...pathSegments]; + return Uri( + scheme: 'https', + host: _apiHostname, + pathSegments: ['repos', owner, repo, 'contents', ...filePath], + queryParameters: {if (ref != null) 'ref': ref}, + ); + } +} diff --git a/lib/codelabs/src/meta.dart b/lib/codelabs/src/meta.dart new file mode 100644 index 000000000..a32bec814 --- /dev/null +++ b/lib/codelabs/src/meta.dart @@ -0,0 +1,62 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:meta/meta.dart'; + +import 'codelab.dart'; + +part 'meta.g.dart'; + +@JsonSerializable( + anyMap: true, + checked: true, + disallowUnrecognizedKeys: true, + fieldRename: FieldRename.snake, +) +class Meta { + @JsonKey(required: true) + final String name; + + @JsonKey( + required: false, + defaultValue: CodelabType.dart, + ) + final CodelabType type; + + @JsonKey(required: true) + final List steps; + + Meta(this.name, this.steps, {this.type}); + + factory Meta.fromJson(Map json) => _$MetaFromJson(json); + + Map toJson() => _$MetaToJson(this); + + @override + String toString() => ' name: $name steps: $steps'; +} + +@JsonSerializable( + anyMap: true, + checked: true, + disallowUnrecognizedKeys: true, + fieldRename: FieldRename.snake, +) +class StepConfiguration { + final String name; + final String directory; + final bool hasSolution; + + StepConfiguration({ + @required this.name, + @required this.directory, + this.hasSolution = false, + }); + + factory StepConfiguration.fromJson(Map json) => + _$StepConfigurationFromJson(json); + + Map toJson() => _$StepConfigurationToJson(this); + + @override + String toString() => + ' name: $name has_solution: $hasSolution'; +} diff --git a/lib/codelabs/src/meta.g.dart b/lib/codelabs/src/meta.g.dart new file mode 100644 index 000000000..bb2b43653 --- /dev/null +++ b/lib/codelabs/src/meta.g.dart @@ -0,0 +1,91 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'meta.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Meta _$MetaFromJson(Map json) { + return $checkedNew('Meta', json, () { + $checkKeys(json, + allowedKeys: const ['name', 'type', 'steps'], + requiredKeys: const ['name', 'steps']); + final val = Meta( + $checkedConvert(json, 'name', (v) => v as String), + $checkedConvert( + json, + 'steps', + (v) => (v as List) + ?.map((e) => + e == null ? null : StepConfiguration.fromJson(e as Map)) + ?.toList()), + type: $checkedConvert(json, 'type', + (v) => _$enumDecodeNullable(_$CodelabTypeEnumMap, v)) ?? + CodelabType.dart, + ); + return val; + }); +} + +Map _$MetaToJson(Meta instance) => { + 'name': instance.name, + 'type': _$CodelabTypeEnumMap[instance.type], + 'steps': instance.steps, + }; + +T _$enumDecode( + Map enumValues, + dynamic source, { + T unknownValue, +}) { + if (source == null) { + throw ArgumentError('A value must be provided. Supported values: ' + '${enumValues.values.join(', ')}'); + } + + final value = enumValues.entries + .singleWhere((e) => e.value == source, orElse: () => null) + ?.key; + + if (value == null && unknownValue == null) { + throw ArgumentError('`$source` is not one of the supported values: ' + '${enumValues.values.join(', ')}'); + } + return value ?? unknownValue; +} + +T _$enumDecodeNullable( + Map enumValues, + dynamic source, { + T unknownValue, +}) { + if (source == null) { + return null; + } + return _$enumDecode(enumValues, source, unknownValue: unknownValue); +} + +const _$CodelabTypeEnumMap = { + CodelabType.dart: 'dart', + CodelabType.flutter: 'flutter', +}; + +StepConfiguration _$StepConfigurationFromJson(Map json) { + return $checkedNew('StepConfiguration', json, () { + $checkKeys(json, allowedKeys: const ['name', 'directory', 'has_solution']); + final val = StepConfiguration( + name: $checkedConvert(json, 'name', (v) => v as String), + directory: $checkedConvert(json, 'directory', (v) => v as String), + hasSolution: $checkedConvert(json, 'has_solution', (v) => v as bool), + ); + return val; + }, fieldKeyMap: const {'hasSolution': 'has_solution'}); +} + +Map _$StepConfigurationToJson(StepConfiguration instance) => + { + 'name': instance.name, + 'directory': instance.directory, + 'has_solution': instance.hasSolution, + }; diff --git a/lib/codelabs/src/step.dart b/lib/codelabs/src/step.dart new file mode 100644 index 000000000..db6eeb688 --- /dev/null +++ b/lib/codelabs/src/step.dart @@ -0,0 +1,8 @@ +class Step { + final String name; + final String instructions; + final String snippet; + final String solution; + + Step(this.name, this.instructions, this.snippet, {this.solution}); +} diff --git a/lib/codelabs/src/web_server.dart b/lib/codelabs/src/web_server.dart new file mode 100644 index 000000000..8a86f1f59 --- /dev/null +++ b/lib/codelabs/src/web_server.dart @@ -0,0 +1,17 @@ +import 'package:http/http.dart' as http; + +import 'fetcher_impl.dart'; + +class WebServerCodelabFetcher extends CodelabFetcherImpl { + final Uri uri; + + WebServerCodelabFetcher(this.uri); + + @override + Future loadFileContents(List relativePath) async { + var fileUri = + uri.replace(pathSegments: [...uri.pathSegments, ...relativePath]); + var response = await http.get(fileUri); + return response.body; + } +} diff --git a/lib/editing/codemirror_options.dart b/lib/editing/codemirror_options.dart new file mode 100644 index 000000000..a11414201 --- /dev/null +++ b/lib/editing/codemirror_options.dart @@ -0,0 +1,18 @@ +const codeMirrorOptions = { + 'continueComments': {'continueLineComment': false}, + 'autofocus': false, + 'autoCloseBrackets': true, + 'matchBrackets': true, + 'tabSize': 2, + 'lineWrapping': true, + 'indentUnit': 2, + 'cursorHeight': 0.85, + 'viewportMargin': 100, + 'extraKeys': { + 'Cmd-/': 'toggleComment', + 'Ctrl-/': 'toggleComment', + 'Tab': 'insertSoftTab' + }, + 'hintOptions': {'completeSingle': false}, + 'scrollbarStyle': 'simple', +}; diff --git a/lib/embed.dart b/lib/embed.dart index 570a2530b..106e55b47 100644 --- a/lib/embed.dart +++ b/lib/embed.dart @@ -16,6 +16,7 @@ import 'completion.dart'; import 'core/dependencies.dart'; import 'core/modules.dart'; import 'dart_pad.dart'; +import 'editing/codemirror_options.dart'; import 'editing/editor.dart'; import 'editing/editor_codemirror.dart'; import 'elements/analysis_results_controller.dart'; @@ -40,25 +41,6 @@ Embed get embed => _embed; Embed _embed; -const codeMirrorOptions = { - 'continueComments': {'continueLineComment': false}, - 'autofocus': false, - 'autoCloseBrackets': true, - 'matchBrackets': true, - 'tabSize': 2, - 'lineWrapping': true, - 'indentUnit': 2, - 'cursorHeight': 0.85, - 'viewportMargin': 100, - 'extraKeys': { - 'Cmd-/': 'toggleComment', - 'Ctrl-/': 'toggleComment', - 'Tab': 'insertSoftTab' - }, - 'hintOptions': {'completeSingle': false}, - 'scrollbarStyle': 'simple', -}; - void init(EmbedOptions options) { _embed = Embed(options); } diff --git a/lib/playground.dart b/lib/playground.dart index 8693bf15c..80981cc01 100644 --- a/lib/playground.dart +++ b/lib/playground.dart @@ -22,6 +22,7 @@ import 'core/keys.dart'; import 'core/modules.dart'; import 'dart_pad.dart'; import 'documentation.dart'; +import 'editing/codemirror_options.dart'; import 'editing/editor.dart'; import 'elements/analysis_results_controller.dart'; import 'elements/bind.dart'; @@ -49,25 +50,6 @@ import 'util/detect_flutter.dart'; import 'util/keymap.dart'; import 'util/query_params.dart' show queryParams; -const codeMirrorOptions = { - 'continueComments': {'continueLineComment': false}, - 'autofocus': false, - 'autoCloseBrackets': true, - 'matchBrackets': true, - 'tabSize': 2, - 'lineWrapping': true, - 'indentUnit': 2, - 'cursorHeight': 0.85, - 'viewportMargin': 100, - 'extraKeys': { - 'Cmd-/': 'toggleComment', - 'Ctrl-/': 'toggleComment', - 'Tab': 'insertSoftTab' - }, - 'hintOptions': {'completeSingle': false}, - 'scrollbarStyle': 'simple', -}; - Playground get playground => _playground; Playground _playground; diff --git a/lib/scss/shared.scss b/lib/scss/shared.scss index f1b66cda7..49e80a9cd 100644 --- a/lib/scss/shared.scss +++ b/lib/scss/shared.scss @@ -187,6 +187,19 @@ a { background-color: $scrollbar-color; } +// Unread console counter +.Counter { + margin-left: 4px; + display: inline-block; + padding: 2px 5px; + font-size: 12px; + font-weight: 600; + line-height: 1; + color: $dark-teal; + background-color: fade_out($dark-teal, 0.5); + border-radius: 20px; +} + // Misc .view-label { font-family: $editor-font; diff --git a/lib/sharing/gists.dart b/lib/sharing/gists.dart index 4bfc2ed0b..3d2e2deef 100644 --- a/lib/sharing/gists.dart +++ b/lib/sharing/gists.dart @@ -14,6 +14,7 @@ import 'package:http/http.dart' as http; import 'package:meta/meta.dart'; import 'package:yaml/yaml.dart' as yaml; import '../util/detect_flutter.dart' as detect_flutter; +import '../util/github.dart'; final String _dartpadLink = '[dartpad.dev](https://dartpad.dev)'; @@ -266,16 +267,6 @@ $styleRef$dartRef return gist; } - String _extractContents(String githubResponse) { - // GitHub's API returns file contents as the "contents" field in a JSON - // object. The field's value is in base64 encoding, but with line ending - // characters ('\n') included. - final contentJson = json.decode(githubResponse); - final encodedContentStr = - contentJson['content'].toString().replaceAll('\n', ''); - return utf8.decode(base64.decode(encodedContentStr)); - } - Uri _buildContentsUrl(String owner, String repo, String path, [String /*?*/ ref]) { return Uri.https( @@ -304,7 +295,7 @@ $styleRef$dartRef throw GistLoaderException(GistLoaderFailureType.unknown); } - final metadataContent = _extractContents(metadataResponse.body); + final metadataContent = extractGitHubResponseBody(metadataResponse.body); ExerciseMetadata metadata; @@ -340,7 +331,7 @@ $styleRef$dartRef throw GistLoaderException(GistLoaderFailureType.unknown); } - return _extractContents(contentResponse.body); + return extractGitHubResponseBody(contentResponse.body); }); // This will rethrow the first exception created above, if one is thrown. diff --git a/lib/util/github.dart b/lib/util/github.dart new file mode 100644 index 000000000..5bff0a3df --- /dev/null +++ b/lib/util/github.dart @@ -0,0 +1,13 @@ +import 'dart:convert'; + +/// Returns the contents of the file returned from +/// api.github.com/repos///contents/ +String extractGitHubResponseBody(String githubResponse) { + // GitHub's API returns file contents as the "contents" field in a JSON + // object. The field's value is in base64 encoding, but with line ending + // characters ('\n') included. + final contentJson = json.decode(githubResponse); + final encodedContentStr = + contentJson['content'].toString().replaceAll('\n', ''); + return utf8.decode(base64.decode(encodedContentStr)); +} diff --git a/lib/util/query_params.dart b/lib/util/query_params.dart index c908aa2d5..7aa7fa611 100644 --- a/lib/util/query_params.dart +++ b/lib/util/query_params.dart @@ -104,6 +104,10 @@ class _QueryParams { return _queryParam('gh_ref'); } + String /*?*/ get webServer { + return _queryParam('webserver'); + } + int /*?*/ get initialSplit { final split = _queryParam('split'); diff --git a/pubspec.lock b/pubspec.lock index 2079330e5..e58773a74 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -135,7 +135,7 @@ packages: source: hosted version: "1.2.0" checked_yaml: - dependency: transitive + dependency: "direct main" description: name: checked_yaml url: "https://pub.dartlang.org" @@ -324,12 +324,19 @@ packages: source: hosted version: "0.6.3" json_annotation: - dependency: transitive + dependency: "direct main" description: name: json_annotation url: "https://pub.dartlang.org" source: hosted - version: "4.0.1" + version: "3.1.1" + json_serializable: + dependency: "direct dev" + description: + name: json_serializable + url: "https://pub.dartlang.org" + source: hosted + version: "3.5.1" logging: dependency: "direct main" description: @@ -512,6 +519,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.2.4+1" + source_gen: + dependency: transitive + description: + name: source_gen + url: "https://pub.dartlang.org" + source: hosted + version: "0.9.10+3" source_map_stack_trace: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 49543305c..debc930ee 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -9,6 +9,7 @@ dependencies: codemirror: ^0.6.0-0 fluttering_phrases: ^0.3.1 http: '>=0.12.2 <0.13.0' + json_annotation: ^3.1.0 logging: '>=0.11.4 <0.12.0' markdown: ^3.0.0 meta: ^1.2.4 @@ -20,6 +21,7 @@ dependencies: sass_builder: ^2.1.0 mdc_web: ^0.5.0-pre yaml: ^2.2.0 + checked_yaml: ^1.0.0 js: ^0.6.0 dev_dependencies: @@ -29,6 +31,7 @@ dev_dependencies: collection: ^1.14.10 git: ^2.0.0 grinder: ^0.8.0 + json_serializable: ^3.5.0 pedantic: ^1.9.0 shelf: ^0.7.5 shelf_static: ^0.2.8 diff --git a/tool/grind.dart b/tool/grind.dart index 57171268d..4badd3777 100644 --- a/tool/grind.dart +++ b/tool/grind.dart @@ -113,7 +113,8 @@ serveCustomBackend() async { @Task('Build the `web/index.html` entrypoint') build() { - PubApp.local('build_runner').run(['build', '-r', '-o', 'web:build']); + PubApp.local('build_runner') + .run(['build', '-r', '-o', 'web:build', '--delete-conflicting-outputs']); var mainFile = _buildDir.join('scripts/playground.dart.js'); log('$mainFile compiled to ${_printSize(mainFile)}'); diff --git a/web/codelabs.html b/web/codelabs.html new file mode 100644 index 000000000..812f3989d --- /dev/null +++ b/web/codelabs.html @@ -0,0 +1,230 @@ + + + + + + + + + + + + + + + DartPad Codelabs + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + DartPad +
+
(No name)
+
+ +
+
+
+
+
+
+ +
Step 1
+ +
+ + +
+
+
+
+
+
+ +
+
+
+
+ +
+ +
+
+
+
+
+
+
+
+
+
+
+ + + +
+
+ + + +
+
+
+

+
+
+ + +
+
+
+
+
+ + diff --git a/web/example/codelabs/dart/meta.yaml b/web/example/codelabs/dart/meta.yaml new file mode 100644 index 000000000..cc68f8898 --- /dev/null +++ b/web/example/codelabs/dart/meta.yaml @@ -0,0 +1,9 @@ +name: Example codelab +type: dart # Optional +steps: + - name: Step 1 + directory: step_01 + has_solution: true + - name: Step 2 + directory: step_02 + has_solution: false diff --git a/web/example/codelabs/dart/step_01/instructions.md b/web/example/codelabs/dart/step_01/instructions.md new file mode 100644 index 000000000..30822d44d --- /dev/null +++ b/web/example/codelabs/dart/step_01/instructions.md @@ -0,0 +1,17 @@ +# Step 1 + +Change the code to do something: + +1. Example +2. List +3. Of +4. Steps + +## Hint +Try using a function: + +```dart +void myFunction() { + +} +``` diff --git a/web/example/codelabs/dart/step_01/snippet.dart b/web/example/codelabs/dart/step_01/snippet.dart new file mode 100644 index 000000000..1e8fc6ee2 --- /dev/null +++ b/web/example/codelabs/dart/step_01/snippet.dart @@ -0,0 +1,3 @@ +void main() { + print('Codelab'); +} diff --git a/web/example/codelabs/dart/step_01/solution.dart b/web/example/codelabs/dart/step_01/solution.dart new file mode 100644 index 000000000..521ae8519 --- /dev/null +++ b/web/example/codelabs/dart/step_01/solution.dart @@ -0,0 +1,3 @@ +void main() { + print('Solution'); +} diff --git a/web/example/codelabs/dart/step_02/instructions.md b/web/example/codelabs/dart/step_02/instructions.md new file mode 100644 index 000000000..54d4aef1f --- /dev/null +++ b/web/example/codelabs/dart/step_02/instructions.md @@ -0,0 +1,3 @@ +# Step 2 + +You're done! Have a coffee ☕️. diff --git a/web/example/codelabs/dart/step_02/snippet.dart b/web/example/codelabs/dart/step_02/snippet.dart new file mode 100644 index 000000000..1034c8860 --- /dev/null +++ b/web/example/codelabs/dart/step_02/snippet.dart @@ -0,0 +1,5 @@ +void main() { + for (var i = 0; i < 10; i++) { + print('Step 2'); + } +} diff --git a/web/example/codelabs/flutter/meta.yaml b/web/example/codelabs/flutter/meta.yaml new file mode 100644 index 000000000..f3d15fb04 --- /dev/null +++ b/web/example/codelabs/flutter/meta.yaml @@ -0,0 +1,9 @@ +name: Example codelab +type: flutter # Optional +steps: + - name: Step 1 + directory: step_01 + has_solution: true + - name: Step 2 + directory: step_02 + has_solution: false diff --git a/web/example/codelabs/flutter/step_01/instructions.md b/web/example/codelabs/flutter/step_01/instructions.md new file mode 100644 index 000000000..62fb0d07a --- /dev/null +++ b/web/example/codelabs/flutter/step_01/instructions.md @@ -0,0 +1,17 @@ +# Step 1 + +Flutter Codelab + +```dart +// Put this code in the build() method +children: [ + // Use this code + Text( + 'You have pushed the button this many times:', + ), + Text( + '$_counter', + style: Theme.of(context).textTheme.headline4, + ), +], +``` diff --git a/web/example/codelabs/flutter/step_01/snippet.dart b/web/example/codelabs/flutter/step_01/snippet.dart new file mode 100644 index 000000000..b5dddf4ce --- /dev/null +++ b/web/example/codelabs/flutter/step_01/snippet.dart @@ -0,0 +1,62 @@ +// Copyright (c) 2019, 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. + +import 'package:flutter/material.dart'; + +void main() => runApp(MyApp()); + +class MyApp extends StatelessWidget { + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Flutter Demo', + debugShowCheckedModeBanner: false, + theme: ThemeData( + primarySwatch: Colors.blue, + ), + home: MyHomePage(title: 'Flutter Demo Home Page'), + ); + } +} + +class MyHomePage extends StatefulWidget { + MyHomePage({Key? key, required this.title}) : super(key: key); + + final String title; + + @override + _MyHomePageState createState() => _MyHomePageState(); +} + +class _MyHomePageState extends State { + int _counter = 0; + + void _incrementCounter() { + setState(() { + _counter++; + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(widget.title), + ), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Add Code Here + ], + ), + ), + floatingActionButton: FloatingActionButton( + onPressed: _incrementCounter, + tooltip: 'Increment', + child: Icon(Icons.add), + ), + ); + } +} diff --git a/web/example/codelabs/flutter/step_01/solution.dart b/web/example/codelabs/flutter/step_01/solution.dart new file mode 100644 index 000000000..680ebe56b --- /dev/null +++ b/web/example/codelabs/flutter/step_01/solution.dart @@ -0,0 +1,68 @@ +// Copyright (c) 2019, 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. + +import 'package:flutter/material.dart'; + +void main() => runApp(MyApp()); + +class MyApp extends StatelessWidget { + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Flutter Demo', + debugShowCheckedModeBanner: false, + theme: ThemeData( + primarySwatch: Colors.blue, + ), + home: MyHomePage(title: 'Flutter Demo Home Page'), + ); + } +} + +class MyHomePage extends StatefulWidget { + MyHomePage({Key? key, required this.title}) : super(key: key); + + final String title; + + @override + _MyHomePageState createState() => _MyHomePageState(); +} + +class _MyHomePageState extends State { + int _counter = 0; + + void _incrementCounter() { + setState(() { + _counter++; + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(widget.title), + ), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'You have pushed the button this many times:', + ), + Text( + '$_counter', + style: Theme.of(context).textTheme.headline4, + ), + ], + ), + ), + floatingActionButton: FloatingActionButton( + onPressed: _incrementCounter, + tooltip: 'Increment', + child: Icon(Icons.add), + ), + ); + } +} diff --git a/web/example/codelabs/flutter/step_02/instructions.md b/web/example/codelabs/flutter/step_02/instructions.md new file mode 100644 index 000000000..54d4aef1f --- /dev/null +++ b/web/example/codelabs/flutter/step_02/instructions.md @@ -0,0 +1,3 @@ +# Step 2 + +You're done! Have a coffee ☕️. diff --git a/web/example/codelabs/flutter/step_02/snippet.dart b/web/example/codelabs/flutter/step_02/snippet.dart new file mode 100644 index 000000000..1034c8860 --- /dev/null +++ b/web/example/codelabs/flutter/step_02/snippet.dart @@ -0,0 +1,5 @@ +void main() { + for (var i = 0; i < 10; i++) { + print('Step 2'); + } +} diff --git a/web/main.py b/web/main.py index ff84fafcd..9afb151dc 100644 --- a/web/main.py +++ b/web/main.py @@ -13,6 +13,7 @@ # Files that the server is allowed to serve. Additional static files are # served via directives in app.yaml. VALID_FILES = [ + 'codelabs.html', 'dark_mode.js', 'dart-192.png', 'embed-dart.html', diff --git a/web/scripts/codelabs.dart b/web/scripts/codelabs.dart new file mode 100644 index 000000000..e581756dd --- /dev/null +++ b/web/scripts/codelabs.dart @@ -0,0 +1,8 @@ +import 'package:dart_pad/codelab.dart'; +import 'package:logging/logging.dart'; + +void main() { + init(); + + Logger.root.onRecord.listen(print); +} diff --git a/web/styles/codelabs.scss b/web/styles/codelabs.scss new file mode 100644 index 000000000..c894e7c71 --- /dev/null +++ b/web/styles/codelabs.scss @@ -0,0 +1,247 @@ +@import 'package:dart_pad/scss/colors'; +@import 'package:dart_pad/scss/variables'; +@import 'package:dart_pad/scss/shared'; + +@import 'layout'; + +// Material Design Web theme colors. Must be imported before importing +// material-components-web.scss. +$mdc-theme-primary: #168AFD; +$mdc-theme-secondary: #676767; +$mdc-theme-background: $playground-background-color; +$mdc-theme-surface: $playground-background-color; +$mdc-theme-error: $dark-red; + +// Layout constants +$doc-console-padding: 8px 24px 8px 24px; + +@import 'package:mdc_web/material-components-web'; + +header { + background-color: $playground-header-background-color; + height: 48px; + padding-left: 24px; + @include layout-center; + z-index: 4; + user-select: none; + + .header-title { + @include layout; + @include layout-center; + font-family: $google-sans; + font-weight: 400; + font-size: 16pt; + margin-right: 8px; + + img.logo { + height: 24px; + width: 24px; + margin-right: 8px; + } + } + + #codelab-name { + @include layout-flex; + @include layout-horizontal; + @include layout-center-justified; + font-size: 14pt; + text-overflow: ellipsis; + white-space: nowrap; + margin-left: 1em; + margin-right: 1em; + user-select: none; + } +} + +body { + background-color: $playground-background-color; + color: $playground-text-color; + font-family: $normal-font; + font-size: 14px; + overflow: hidden; + @include layout-vertical; + @include layout-fit; +} + +// Main section +section.main-section { + @include layout-flex; + @include layout; + @include layout-relative; + + .panels { + @include layout; + @include layout-fit; + } +} + +// Panels +#right-panel { + @include layout-vertical(); +} + +#editor-panel, #output-panel { + @include layout-flex; +} + +// Steps panel +#steps-row { + @include layout-horizontal(); + @include layout-center(); + @include layout-relative(); + width: 100%; +} + +#show-solution-btn { + margin: 8px; +} + +#steps-panel { + @include layout-vertical(); + @include layout-center(); +} + +#markdown-content { + @include layout-flex(); + width: 100%; +} + +#step-button-container { + @include layout-horizontal(); + @include layout-center(); + @include layout-flex(); + @include layout-center-justified(); +} + +#steps-label { + padding: 12px; +} + +#next-step-btn, #previous-step-btn { + @include mdc-icon-button-size(32px, 32px); +} + +// Editor panel +#editor-panel { + @include layout-vertical; + @include layout-relative; +} +#editor-host { + @include layout-vertical; + @include layout-flex; + margin: 8px 0 0 0; + padding: 0 8px; + + .CodeMirror { + @include layout-flex; + font-family: $editor-font; + font-size: $playground-editor-font-size; + } +} + +.button-group { + @include layout-horizontal; + @include layout-center; + position: absolute; + top: 0; + right: 0; + margin: 8px 24px 0 0; + z-index: 5; +} + +// Console panel +#output-panel { + @include layout-vertical(); +} + +#output-panel-content { + @include layout-flex(); + overflow-y: scroll; +} + +.console { + @include layout-flex; + font-family: $editor-font; + font-size: 14px; + line-height: 20px; + min-height: 50px; + overflow-y: auto; + white-space: pre-wrap; + padding: $doc-console-padding; + + .normal { + color: $dark-editor-text; + } + + .error-output { + color: $dark-pink; + } +} + +// iframe element + +iframe { + @include layout-flex; + border: none; +} + +// Links +a { + color: $playground-link-color; + fill: $playground-text-color; + + &:visited { + color: $playground-link-color; + fill: $playground-link-color; + } + + &:hover { + color: $playground-text-color; + fill: $playground-text-color; + } +} + +// Footer +body>footer { + background-color: $playground-footer-background-color; + @include layout-horizontal; + @include layout-center; + padding: 8px 24px; + .flex { + @include layout-flex; + } + + .footer-item { + margin-right: 14px; + } + + * { + margin-right: 4px; + } + + #dartpad-version { + margin-left: 12px; + } +} + +// Splitter +.gutter { + background-color: $dark-gutter-background-color; + background-repeat: no-repeat; + background-position: 50%; + margin-left: 0; + margin-right: 0; +} + +.gutter.gutter-horizontal { + background-repeat: repeat-y; + cursor: col-resize; + height: 100%; +} + +.gutter.gutter-vertical { + background-repeat: repeat-x; + cursor: row-resize; + width: 100%; +} + diff --git a/web/styles/styles.scss b/web/styles/styles.scss index a0c13487a..7705357de 100644 --- a/web/styles/styles.scss +++ b/web/styles/styles.scss @@ -491,20 +491,6 @@ a { } } -// Unread console counter -.Counter { - margin-left: 4px; - display: inline-block; - padding: 2px 5px; - font-size: 12px; - font-weight: 600; - line-height: 1; - color: $dark-teal; - background-color: fade_out($dark-teal, 0.5); - border-radius: 20px; -} - - // Make the dialog show up even without everything initialized (for localStorage check) .mdc-dialog { z-index: 1000;