From a82ab170ce930c659524dbfe68e1812cf93fa381 Mon Sep 17 00:00:00 2001 From: Jonas Finnemann Jensen Date: Fri, 6 Sep 2024 17:59:43 +0200 Subject: [PATCH 1/3] Draft of how widgets could be organized --- pkg/web_app/lib/script.dart | 2 +- pkg/web_app/lib/src/web_util.dart | 6 + .../lib/src/widget/completion/completion.dart | 8 + .../completion/widget.dart} | 165 +++++++++--------- pkg/web_app/lib/src/widget/widget.dart | 99 +++++++++++ pkg/web_app/lib/src/widgets.dart | 35 ---- 6 files changed, 197 insertions(+), 118 deletions(-) create mode 100644 pkg/web_app/lib/src/widget/completion/completion.dart rename pkg/web_app/lib/src/{completion.dart => widget/completion/widget.dart} (86%) create mode 100644 pkg/web_app/lib/src/widget/widget.dart delete mode 100644 pkg/web_app/lib/src/widgets.dart diff --git a/pkg/web_app/lib/script.dart b/pkg/web_app/lib/script.dart index 44e06df75..91aefb2c8 100644 --- a/pkg/web_app/lib/script.dart +++ b/pkg/web_app/lib/script.dart @@ -16,7 +16,7 @@ import 'src/page_updater.dart'; import 'src/screenshot_carousel.dart'; import 'src/scroll.dart'; import 'src/search.dart'; -import 'src/widgets.dart' show setupWidgets; +import 'src/widget/widget.dart' show setupWidgets; void main() { window.onLoad.listen((_) => mdc.autoInit()); diff --git a/pkg/web_app/lib/src/web_util.dart b/pkg/web_app/lib/src/web_util.dart index af3abd2ae..5fe11d1a4 100644 --- a/pkg/web_app/lib/src/web_util.dart +++ b/pkg/web_app/lib/src/web_util.dart @@ -1,3 +1,5 @@ +import 'dart:js_interop'; + import 'package:web/web.dart'; extension NodeListTolist on NodeList { @@ -19,3 +21,7 @@ extension HTMLCollectionToList on HTMLCollection { /// [HTMLCollection]. List toList() => List.generate(length, (i) => item(i)!); } + +extension JSStringArrayIterable on JSArray { + Iterable get iterable => toDart.map((s) => s.toDart); +} diff --git a/pkg/web_app/lib/src/widget/completion/completion.dart b/pkg/web_app/lib/src/widget/completion/completion.dart new file mode 100644 index 000000000..496ff6ed7 --- /dev/null +++ b/pkg/web_app/lib/src/widget/completion/completion.dart @@ -0,0 +1,8 @@ +// Copyright (c) 2024, 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. + +// TODO: Create a function that can create an instance of Node on the server +// We might need to move a lot of code around since Node is currently +// defined in app/lib/frontend/dom/dom.dart +// It's also possible we can define the helper function somewhere else. diff --git a/pkg/web_app/lib/src/completion.dart b/pkg/web_app/lib/src/widget/completion/widget.dart similarity index 86% rename from pkg/web_app/lib/src/completion.dart rename to pkg/web_app/lib/src/widget/completion/widget.dart index fcff26d7a..e60e9892d 100644 --- a/pkg/web_app/lib/src/completion.dart +++ b/pkg/web_app/lib/src/widget/completion/widget.dart @@ -11,7 +11,85 @@ import 'package:collection/collection.dart'; import 'package:http/http.dart' deferred as http show read; import 'package:web/web.dart'; -import 'web_util.dart'; +import '../../web_util.dart'; + +/// Create a [_CompletionWidget] on [element]. +/// +/// Here [element] must: +/// * be an `` element, with +/// * `type="text"`, or, +/// * `type="search". +/// * have properties: +/// * `data-completion-src`, URL from which completion data should be +/// loaded. +/// * `data-completion-class` (optional), class that should be applied to +/// the dropdown that provides completion options. +/// Useful if styling multiple completer widgets. +/// +/// The dropdown that provides completions will be appended to +/// `document.body` and given the following classes: +/// * `completion-dropdown` for the completion dropdown. +/// * `completion-option` for each option in the dropdown, and, +/// * `completion-option-select` is applied to selected options. +void create(Element element, Map options) { + if (!element.isA()) { + throw UnsupportedError('Must be element'); + } + final input = element as HTMLInputElement; + + if (input.type != 'text' && input.type != 'search') { + throw UnsupportedError('Must have type="text" or type="search"'); + } + + final src = options['src'] ?? ''; + if (src.isEmpty) { + throw UnsupportedError('Must have completion-src=""'); + } + final srcUri = Uri.tryParse(src); + if (srcUri == null) { + throw UnsupportedError('completion-src="$src" must be a valid URI'); + } + final completionClass = options['class'] ?? ''; + + // Setup attributes + input.autocomplete = 'off'; + input.autocapitalize = 'off'; + input.spellcheck = false; + input.setAttribute('autocorrect', 'off'); // safari only + + scheduleMicrotask(() async { + // Don't do anymore setup before input has focus + if (document.activeElement != input) { + await input.onFocus.first; + } + + final _CompletionData data; + try { + data = await _CompletionWidget._completionDataFromUri(srcUri); + } on Exception catch (e) { + throw Exception( + 'Unable to load autocompletion-src="$src", error: $e', + ); + } + + // Create and style the dropdown element + final dropdown = HTMLDivElement() + ..style.display = 'none' + ..style.position = 'absolute' + ..classList.add('completion-dropdown'); + if (completionClass.isNotEmpty) { + dropdown.classList.add(completionClass); + } + + _CompletionWidget._( + input: input, + dropdown: dropdown, + data: data, + ); + // Add dropdown after the + document.body!.after(dropdown); + }); +} typedef _CompletionData = List< ({ @@ -93,7 +171,7 @@ final class _State { '_State(forced: $forced, triggered: $triggered, caret: $caret, text: $text, selected: $selectedIndex)'; } -final class CompletionWidget { +final class _CompletionWidget { static final _whitespace = RegExp(r'\s'); static final optionClass = 'completion-option'; static final selectedOptionClass = 'completion-option-selected'; @@ -103,7 +181,7 @@ final class CompletionWidget { final _CompletionData data; var state = _State(); - CompletionWidget._({ + _CompletionWidget._({ required this.input, required this.dropdown, required this.data, @@ -343,83 +421,6 @@ final class CompletionWidget { trackState(); } - /// Create a [CompletionWidget] on [element]. - /// - /// Here [element] must: - /// * be an `` element, with - /// * `type="text"`, or, - /// * `type="search". - /// * have properties: - /// * `data-completion-src`, URL from which completion data should be - /// loaded. - /// * `data-completion-class` (optional), class that should be applied to - /// the dropdown that provides completion options. - /// Useful if styling multiple completer widgets. - /// - /// The dropdown that provides completions will be appended to - /// `document.body` and given the following classes: - /// * `completion-dropdown` for the completion dropdown. - /// * `completion-option` for each option in the dropdown, and, - /// * `completion-option-select` is applied to selected options. - static void create(Element element) { - if (!element.isA()) { - throw UnsupportedError('Must be element'); - } - final input = element as HTMLInputElement; - - if (input.type != 'text' && input.type != 'search') { - throw UnsupportedError('Must have type="text" or type="search"'); - } - final src = input.getAttribute('data-completion-src') ?? ''; - if (src.isEmpty) { - throw UnsupportedError('Must have completion-src=""'); - } - final srcUri = Uri.tryParse(src); - if (srcUri == null) { - throw UnsupportedError('completion-src="$src" must be a valid URI'); - } - final completionClass = input.getAttribute('data-completion-class') ?? ''; - - // Setup attributes - input.autocomplete = 'off'; - input.autocapitalize = 'off'; - input.spellcheck = false; - input.setAttribute('autocorrect', 'off'); // safari only - - scheduleMicrotask(() async { - // Don't do anymore setup before input has focus - if (document.activeElement != input) { - await input.onFocus.first; - } - - final _CompletionData data; - try { - data = await _completionDataFromUri(srcUri); - } on Exception catch (e) { - throw Exception( - 'Unable to load autocompletion-src="$src", error: $e', - ); - } - - // Create and style the dropdown element - final dropdown = HTMLDivElement() - ..style.display = 'none' - ..style.position = 'absolute' - ..classList.add('completion-dropdown'); - if (completionClass.isNotEmpty) { - dropdown.classList.add(completionClass); - } - - CompletionWidget._( - input: input, - dropdown: dropdown, - data: data, - ); - // Add dropdown after the - document.body!.after(dropdown); - }); - } - /// Load completion data from [src]. /// /// Completion data must be a JSON response on the form: @@ -607,7 +608,7 @@ final class CompletionWidget { trigger: trigger, suggestions: completion.options .map((option) { - final overlap = lcs(prefix, option); + final overlap = _lcs(prefix, option); var html = option; if (overlap.isNotEmpty) { html = html.replaceAll(overlap, '$overlap'); @@ -632,7 +633,7 @@ final class CompletionWidget { } /// The longest common substring -String lcs(String S, String T) { +String _lcs(String S, String T) { final r = S.length; final n = T.length; var Lp = List.filled(n, 0); // ignore: non_constant_identifier_names diff --git a/pkg/web_app/lib/src/widget/widget.dart b/pkg/web_app/lib/src/widget/widget.dart new file mode 100644 index 000000000..147ea271d --- /dev/null +++ b/pkg/web_app/lib/src/widget/widget.dart @@ -0,0 +1,99 @@ +// Copyright (c) 2024, 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 'dart:async'; +import 'dart:js_interop'; + +import 'package:collection/collection.dart'; +import 'package:web/web.dart'; + +import '../web_util.dart'; +import 'completion/widget.dart' deferred as completion; + +/// Widget entry +typedef _WidgetEntry = ({ + /// Name of the widget referenced in `data-widget=""`. + String name, + + /// Function to create an instance of the widget given an element and options. + /// + /// [element] which carries `data-widget="$name"`. + /// [options] a map from options to values, where options are specified as + /// `data-$name-$option="$value"`. + /// + /// Hence, a widget called `completion` is created on an element by adding + /// `data-widget="completion"`. And option `src` is specified with: + /// `data-completion-src="$value"`. + FutureOr Function(Element element, Map options) create, + + /// Load widget library, if deferred (otherwise this can be `null`). + Future Function()? loadLibrary, +}); + +// List of registered widgets +final _widgets = <_WidgetEntry>[ + ( + name: 'completion', + create: completion.create, + loadLibrary: completion.loadLibrary, + ), +]; + +Future setupWidgets() async { + await Future.wait(document + // query for all elements with the property `data-widget="..."` + .querySelectorAll('[data-widget]') + .toList() // Convert NodeList to List + // We only care about elements + .where((node) => node.isA()) + .map((node) => node as HTMLElement) + // group by widget + .groupListsBy((element) => element.getAttribute('data-widget') ?? '') + .entries + // For each (widget, elements) load widget and create widgets + .map((entry) async { + // Get widget name and elements which it should be created for + final MapEntry(key: name, value: elements) = entry; + + // Find the widget create function and loadLibrary function + final (name: _, :create, :loadLibrary) = _widgets.firstWhere( + (widget) => widget.name == name, + orElse: () => ( + name: '', + create: (_, __) => throw AssertionError('no such widget'), + loadLibrary: null, + ), + ); + + // Load library if this a deferred widget + if (loadLibrary != null) { + await loadLibrary(); + } + + // Create widget for each element + await Future.wait(elements.map((element) async { + try { + final prefix = 'data-$name-'; + final options = Map.fromEntries(element + .getAttributeNames() + .iterable + .where((attr) => attr.startsWith(prefix)) + .map((attr) { + return MapEntry( + attr.substring(0, prefix.length), + element.getAttribute(attr) ?? '', + ); + })); + + await create(element, options); + } catch (e, st) { + console.error('Failed to initialize data-widget="$name"'.toJS); + console.error('Triggered by element:'.toJS); + console.error(element); + console.error(e.toString().toJS); + console.error(st.toString().toJS); + } + })); + })); +} diff --git a/pkg/web_app/lib/src/widgets.dart b/pkg/web_app/lib/src/widgets.dart deleted file mode 100644 index 74ac2cb05..000000000 --- a/pkg/web_app/lib/src/widgets.dart +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright (c) 2024, 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 'dart:js_interop'; - -import 'package:web/web.dart'; -import 'package:web_app/src/completion.dart'; - -import 'web_util.dart'; - -final _widgets = { - 'completion': CompletionWidget.create, -}; - -void setupWidgets() { - for (final node in document.querySelectorAll('[data-widget]').toList()) { - if (!node.isA()) continue; - final element = node as HTMLElement; - final widgetName = element.getAttribute('data-widget') ?? ''; - final widget = _widgets[widgetName]; - try { - if (widget == null) { - throw ArgumentError('Unknown widget in data-widget="$widgetName"'); - } - widget(element); - } catch (e, st) { - console.error('Failed to initialize data-widget="$widgetName"'.toJS); - console.error('Triggered by element:'.toJS); - console.error(element); - console.error(e.toString().toJS); - console.error(st.toString().toJS); - } - } -} From 00c243651737d85bf6c59e122057968cbaceed6d Mon Sep 17 00:00:00 2001 From: Jonas Finnemann Jensen Date: Thu, 12 Sep 2024 17:09:58 +0200 Subject: [PATCH 2/3] Fix nits --- app/test/frontend/static_files_test.dart | 4 ++-- pkg/web_app/lib/src/widget/widget.dart | 12 ++++++------ pkg/web_app/test/deferred_import_test.dart | 1 + 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/app/test/frontend/static_files_test.dart b/app/test/frontend/static_files_test.dart index 854c2d838..5fbbaf8f1 100644 --- a/app/test/frontend/static_files_test.dart +++ b/app/test/frontend/static_files_test.dart @@ -212,7 +212,7 @@ void main() { test('script.dart.js and parts size check', () { final file = cache.getFile('/static/js/script.dart.js'); expect(file, isNotNull); - expect((file!.bytes.length / 1024).round(), closeTo(343, 2)); + expect((file!.bytes.length / 1024).round(), closeTo(333, 2)); final parts = cache.paths .where((path) => @@ -223,7 +223,7 @@ void main() { final partsSize = parts .map((p) => cache.getFile(p)!.bytes.length) .reduce((a, b) => a + b); - expect((partsSize / 1024).round(), closeTo(212, 10)); + expect((partsSize / 1024).round(), closeTo(227, 10)); }); }); diff --git a/pkg/web_app/lib/src/widget/widget.dart b/pkg/web_app/lib/src/widget/widget.dart index 147ea271d..cd9df6193 100644 --- a/pkg/web_app/lib/src/widget/widget.dart +++ b/pkg/web_app/lib/src/widget/widget.dart @@ -40,8 +40,8 @@ final _widgets = <_WidgetEntry>[ ), ]; -Future setupWidgets() async { - await Future.wait(document +void setupWidgets() async { + final widgetAndElements = document // query for all elements with the property `data-widget="..."` .querySelectorAll('[data-widget]') .toList() // Convert NodeList to List @@ -49,10 +49,10 @@ Future setupWidgets() async { .where((node) => node.isA()) .map((node) => node as HTMLElement) // group by widget - .groupListsBy((element) => element.getAttribute('data-widget') ?? '') - .entries - // For each (widget, elements) load widget and create widgets - .map((entry) async { + .groupListsBy((element) => element.getAttribute('data-widget') ?? ''); + + // For each (widget, elements) load widget and create widgets + await Future.wait(widgetAndElements.entries.map((entry) async { // Get widget name and elements which it should be created for final MapEntry(key: name, value: elements) = entry; diff --git a/pkg/web_app/test/deferred_import_test.dart b/pkg/web_app/test/deferred_import_test.dart index ea7d0475b..41342f4d1 100644 --- a/pkg/web_app/test/deferred_import_test.dart +++ b/pkg/web_app/test/deferred_import_test.dart @@ -35,6 +35,7 @@ void main() { 'package:markdown/': [ 'lib/src/deferred/markdown.dart', ], + 'completion/': [], }; for (final file in files) { From fe792b41866390343cc86b2f4e0bb80aba0c8ece Mon Sep 17 00:00:00 2001 From: Jonas Finnemann Jensen Date: Thu, 12 Sep 2024 21:17:45 +0200 Subject: [PATCH 3/3] Fix deferred imports --- pkg/web_app/lib/src/widget/widget.dart | 66 ++++++++++---------------- 1 file changed, 25 insertions(+), 41 deletions(-) diff --git a/pkg/web_app/lib/src/widget/widget.dart b/pkg/web_app/lib/src/widget/widget.dart index cd9df6193..46c877fd0 100644 --- a/pkg/web_app/lib/src/widget/widget.dart +++ b/pkg/web_app/lib/src/widget/widget.dart @@ -11,34 +11,30 @@ import 'package:web/web.dart'; import '../web_util.dart'; import 'completion/widget.dart' deferred as completion; -/// Widget entry -typedef _WidgetEntry = ({ - /// Name of the widget referenced in `data-widget=""`. - String name, +/// Function to create an instance of the widget given an element and options. +/// +/// [element] which carries `data-widget="$name"`. +/// [options] a map from options to values, where options are specified as +/// `data-$name-$option="$value"`. +/// +/// Hence, a widget called `completion` is created on an element by adding +/// `data-widget="completion"`. And option `src` is specified with: +/// `data-completion-src="$value"`. +typedef _WidgetFn = FutureOr Function( + Element element, + Map options, +); - /// Function to create an instance of the widget given an element and options. - /// - /// [element] which carries `data-widget="$name"`. - /// [options] a map from options to values, where options are specified as - /// `data-$name-$option="$value"`. - /// - /// Hence, a widget called `completion` is created on an element by adding - /// `data-widget="completion"`. And option `src` is specified with: - /// `data-completion-src="$value"`. - FutureOr Function(Element element, Map options) create, +/// Function for loading a widget. +typedef _WidgetLoaderFn = FutureOr<_WidgetFn> Function(); - /// Load widget library, if deferred (otherwise this can be `null`). - Future Function()? loadLibrary, -}); +/// Map from widget name to widget loader +final _widgets = { + 'completion': () => completion.loadLibrary().then((_) => completion.create), +}; -// List of registered widgets -final _widgets = <_WidgetEntry>[ - ( - name: 'completion', - create: completion.create, - loadLibrary: completion.loadLibrary, - ), -]; +Future<_WidgetFn> _noSuchWidget() async => + (_, __) => throw AssertionError('no such widget'); void setupWidgets() async { final widgetAndElements = document @@ -56,20 +52,8 @@ void setupWidgets() async { // Get widget name and elements which it should be created for final MapEntry(key: name, value: elements) = entry; - // Find the widget create function and loadLibrary function - final (name: _, :create, :loadLibrary) = _widgets.firstWhere( - (widget) => widget.name == name, - orElse: () => ( - name: '', - create: (_, __) => throw AssertionError('no such widget'), - loadLibrary: null, - ), - ); - - // Load library if this a deferred widget - if (loadLibrary != null) { - await loadLibrary(); - } + // Find the widget and load it + final widget = await (_widgets[name] ?? _noSuchWidget)(); // Create widget for each element await Future.wait(elements.map((element) async { @@ -81,12 +65,12 @@ void setupWidgets() async { .where((attr) => attr.startsWith(prefix)) .map((attr) { return MapEntry( - attr.substring(0, prefix.length), + attr.substring(prefix.length), element.getAttribute(attr) ?? '', ); })); - await create(element, options); + await widget(element, options); } catch (e, st) { console.error('Failed to initialize data-widget="$name"'.toJS); console.error('Triggered by element:'.toJS);