diff --git a/site/lib/_sass/_site.scss b/site/lib/_sass/_site.scss index 477179e4b3..04ea3f55f3 100644 --- a/site/lib/_sass/_site.scss +++ b/site/lib/_sass/_site.scss @@ -35,6 +35,7 @@ @use 'components/tabs'; @use 'components/theming'; @use 'components/toc'; +@use 'components/tooltip'; @use 'components/trailing'; // Styles for specific pages, alphabetically ordered. diff --git a/site/lib/_sass/components/_tooltip.scss b/site/lib/_sass/components/_tooltip.scss new file mode 100644 index 0000000000..1348068979 --- /dev/null +++ b/site/lib/_sass/components/_tooltip.scss @@ -0,0 +1,62 @@ +.tooltip-wrapper { + position: relative; + + a.tooltip-target:has(+.tooltip) { + text-decoration: underline; + text-decoration-style: dotted; + } + + .tooltip { + visibility: hidden; + + display: flex; + position: absolute; + z-index: var(--site-z-floating); + top: 100%; + left: 50%; + transform: translateX(-50%); + + flex-flow: column nowrap; + width: 20rem; + + background: var(--site-raised-bgColor); + border: 0.05rem solid rgba(0, 0, 0, .125); + border-radius: 0.75rem; + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, .15); + padding: 0.8rem; + + font-size: 1rem; + font-weight: normal; + font-style: normal; + + .tooltip-header { + font-size: 1.2rem; + font-weight: 500; + margin-bottom: 0.25rem; + } + + .tooltip-content { + font-size: 0.875rem; + color: var(--site-secondary-textColor); + } + } + + // On non-touch devices, show tooltip on hover or focus. + @media all and not (pointer: coarse) { + &:hover .tooltip { + visibility: visible; + } + + &:focus-within .tooltip { + visibility: visible; + } + } + + // On touch devices, show tooltip on click. + @media all and (pointer: coarse) { + .tooltip.visible { + visibility: visible; + } + } + +} diff --git a/site/lib/jaspr_options.dart b/site/lib/jaspr_options.dart index 831027edc6..14bb2fb9b6 100644 --- a/site/lib/jaspr_options.dart +++ b/site/lib/jaspr_options.dart @@ -7,32 +7,34 @@ import 'package:jaspr/jaspr.dart'; import 'package:docs_flutter_dev_site/src/client/global_scripts.dart' as prefix0; -import 'package:docs_flutter_dev_site/src/components/common/client/cookie_notice.dart' +import 'package:docs_flutter_dev_site/src/components/common/client/api_link_tooltip.dart' as prefix1; -import 'package:docs_flutter_dev_site/src/components/common/client/copy_button.dart' +import 'package:docs_flutter_dev_site/src/components/common/client/cookie_notice.dart' as prefix2; -import 'package:docs_flutter_dev_site/src/components/common/client/download_latest_button.dart' +import 'package:docs_flutter_dev_site/src/components/common/client/copy_button.dart' as prefix3; -import 'package:docs_flutter_dev_site/src/components/common/client/feedback.dart' +import 'package:docs_flutter_dev_site/src/components/common/client/download_latest_button.dart' as prefix4; -import 'package:docs_flutter_dev_site/src/components/common/client/on_this_page_button.dart' +import 'package:docs_flutter_dev_site/src/components/common/client/feedback.dart' as prefix5; -import 'package:docs_flutter_dev_site/src/components/common/client/os_selector.dart' +import 'package:docs_flutter_dev_site/src/components/common/client/on_this_page_button.dart' as prefix6; -import 'package:docs_flutter_dev_site/src/components/dartpad/dartpad_injector.dart' +import 'package:docs_flutter_dev_site/src/components/common/client/os_selector.dart' as prefix7; -import 'package:docs_flutter_dev_site/src/components/layout/menu_toggle.dart' +import 'package:docs_flutter_dev_site/src/components/dartpad/dartpad_injector.dart' as prefix8; -import 'package:docs_flutter_dev_site/src/components/layout/site_switcher.dart' +import 'package:docs_flutter_dev_site/src/components/layout/menu_toggle.dart' as prefix9; -import 'package:docs_flutter_dev_site/src/components/layout/theme_switcher.dart' +import 'package:docs_flutter_dev_site/src/components/layout/site_switcher.dart' as prefix10; -import 'package:docs_flutter_dev_site/src/components/pages/archive_table.dart' +import 'package:docs_flutter_dev_site/src/components/layout/theme_switcher.dart' as prefix11; -import 'package:docs_flutter_dev_site/src/components/pages/learning_resource_filters.dart' +import 'package:docs_flutter_dev_site/src/components/pages/archive_table.dart' as prefix12; -import 'package:docs_flutter_dev_site/src/components/pages/learning_resource_filters_sidebar.dart' +import 'package:docs_flutter_dev_site/src/components/pages/learning_resource_filters.dart' as prefix13; +import 'package:docs_flutter_dev_site/src/components/pages/learning_resource_filters_sidebar.dart' + as prefix14; /// Default [JasprOptions] for use with your jaspr project. /// @@ -56,87 +58,96 @@ JasprOptions get defaultJasprOptions => JasprOptions( 'src/client/global_scripts', ), - prefix1.CookieNotice: ClientTarget( + prefix1.ApiLinkTooltip: ClientTarget( + 'src/components/common/client/api_link_tooltip', + params: _prefix1ApiLinkTooltip, + ), + + prefix2.CookieNotice: ClientTarget( 'src/components/common/client/cookie_notice', ), - prefix2.CopyButton: ClientTarget( + prefix3.CopyButton: ClientTarget( 'src/components/common/client/copy_button', - params: _prefix2CopyButton, + params: _prefix3CopyButton, ), - prefix3.DownloadLatestButton: ClientTarget( + prefix4.DownloadLatestButton: ClientTarget( 'src/components/common/client/download_latest_button', - params: _prefix3DownloadLatestButton, + params: _prefix4DownloadLatestButton, ), - prefix4.FeedbackComponent: ClientTarget( + prefix5.FeedbackComponent: ClientTarget( 'src/components/common/client/feedback', - params: _prefix4FeedbackComponent, + params: _prefix5FeedbackComponent, ), - prefix5.OnThisPageButton: ClientTarget( + prefix6.OnThisPageButton: ClientTarget( 'src/components/common/client/on_this_page_button', ), - prefix6.OsSelector: ClientTarget( + prefix7.OsSelector: ClientTarget( 'src/components/common/client/os_selector', ), - prefix7.DartPadInjector: ClientTarget( + prefix8.DartPadInjector: ClientTarget( 'src/components/dartpad/dartpad_injector', - params: _prefix7DartPadInjector, + params: _prefix8DartPadInjector, ), - prefix8.MenuToggle: ClientTarget( + prefix9.MenuToggle: ClientTarget( 'src/components/layout/menu_toggle', ), - prefix9.SiteSwitcher: ClientTarget( + prefix10.SiteSwitcher: ClientTarget( 'src/components/layout/site_switcher', ), - prefix10.ThemeSwitcher: ClientTarget( + prefix11.ThemeSwitcher: ClientTarget( 'src/components/layout/theme_switcher', ), - prefix11.ArchiveTable: ClientTarget( + prefix12.ArchiveTable: ClientTarget( 'src/components/pages/archive_table', - params: _prefix11ArchiveTable, + params: _prefix12ArchiveTable, ), - prefix12.LearningResourceFilters: - ClientTarget( + prefix13.LearningResourceFilters: + ClientTarget( 'src/components/pages/learning_resource_filters', ), - prefix13.LearningResourceFiltersSidebar: - ClientTarget( + prefix14.LearningResourceFiltersSidebar: + ClientTarget( 'src/components/pages/learning_resource_filters_sidebar', ), }, styles: () => [], ); -Map _prefix2CopyButton(prefix2.CopyButton c) => { +Map _prefix1ApiLinkTooltip(prefix1.ApiLinkTooltip c) => { + 'url': c.url, + 'text': c.text, +}; +Map _prefix3CopyButton(prefix3.CopyButton c) => { 'toCopy': c.toCopy, 'buttonText': c.buttonText, 'classes': c.classes, 'title': c.title, }; -Map _prefix3DownloadLatestButton( - prefix3.DownloadLatestButton c, +Map _prefix4DownloadLatestButton( + prefix4.DownloadLatestButton c, ) => {'os': c.os, 'arch': c.arch}; -Map _prefix4FeedbackComponent(prefix4.FeedbackComponent c) => { +Map _prefix5FeedbackComponent(prefix5.FeedbackComponent c) => { 'issueUrl': c.issueUrl, }; -Map _prefix7DartPadInjector(prefix7.DartPadInjector c) => { +Map _prefix8DartPadInjector(prefix8.DartPadInjector c) => { 'title': c.title, 'theme': c.theme, 'height': c.height, 'runAutomatically': c.runAutomatically, }; -Map _prefix11ArchiveTable(prefix11.ArchiveTable c) => { +Map _prefix12ArchiveTable(prefix12.ArchiveTable c) => { 'os': c.os, 'channel': c.channel, }; diff --git a/site/lib/src/components/common/client/api_link_tooltip.dart b/site/lib/src/components/common/client/api_link_tooltip.dart new file mode 100644 index 0000000000..e973fb83f0 --- /dev/null +++ b/site/lib/src/components/common/client/api_link_tooltip.dart @@ -0,0 +1,260 @@ +// Copyright 2025 The Flutter Authors. 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:http/http.dart' as http; +import 'package:jaspr/jaspr.dart'; +import 'package:meta/meta.dart'; +import 'package:universal_web/js_interop.dart'; +import 'package:universal_web/web.dart' as web; + +import '../../../util.dart'; +import '../../util/global_event_listener.dart'; + +@client +class ApiLinkTooltip extends StatefulComponent { + const ApiLinkTooltip({required this.url, required this.text, super.key}); + + final String url; + final String text; + + @override + State createState() => _InteractiveApiLinkState(); +} + +class _InteractiveApiLinkState extends State { + final wrapperKey = GlobalNodeKey(); + final tooltipKey = GlobalNodeKey(); + Component? tooltipContent; + + bool isTouchscreen = false; + bool isVisible = false; + double tooltipOffset = 0.0; + + @override + void initState() { + super.initState(); + + if (kIsWeb) { + setupTooltip(); + } + } + + @awaitNotRequired + Future setupTooltip() async { + final (extractedHeader, extractedDescription) = await scrapeApiDocs( + component.url, + ); + + if (extractedHeader == null && extractedDescription == null) { + return; + } + + if (!mounted) return; + setState(() { + tooltipContent = fragment([ + if (extractedHeader != null) + span(classes: 'tooltip-header', [raw(extractedHeader)]), + if (extractedDescription != null) + span(classes: 'tooltip-content', [raw(extractedDescription)]), + ]); + + isTouchscreen = web.window.matchMedia('(pointer: coarse)').matches; + }); + + context.binding.addPostFrameCallback(ensureVisible); + + // Reposition tooltips on window resize. + web.EventStreamProviders.resizeEvent.forTarget(web.window).listen((_) { + ensureVisible(); + }); + } + + /// Adjust the tooltip position to ensure it is fully inside the + /// ancestor .content element. + void ensureVisible() { + final tooltip = tooltipKey.currentNode; + if (tooltip == null) return; + + final tooltipRect = tooltip.getBoundingClientRect(); + final containerRect = tooltip.closest('.content')?.getBoundingClientRect(); + + final tooltipLeft = tooltipRect.left - tooltipOffset; + final tooltipRight = tooltipRect.right - tooltipOffset; + final containerLeft = containerRect?.left ?? 0.0; + final containerRight = containerRect?.right ?? web.window.innerWidth; + + if (tooltipLeft < containerLeft) { + setState(() => tooltipOffset = containerLeft - tooltipLeft); + } else if (tooltipRight > containerRight) { + setState(() => tooltipOffset = containerRight - tooltipRight); + } else { + setState(() => tooltipOffset = 0.0); + } + } + + @override + Component build(BuildContext context) { + Component? tooltip; + + if (tooltipContent != null) { + tooltip = span( + key: tooltipKey, + classes: ['tooltip', if (isVisible) 'visible'].toClasses, + styles: Styles( + raw: { + 'left': tooltipOffset == 0 + ? '50%' + : 'calc(50% + ${tooltipOffset}px)', + }, + ), + [tooltipContent!], + ); + + if (isTouchscreen) { + tooltip = GlobalEventListener( + // Close tooltip when clicking outside of this wrapper. + onClick: (e) { + if (wrapperKey.currentNode?.contains(e.target as web.Node?) == + true) { + return; + } + setState(() => isVisible = false); + }, + // On touchscreen devices, close tooltips when scrolling. + onScroll: (_) { + setState(() => isVisible = false); + }, + tooltip, + ); + } + } + + return span(key: wrapperKey, classes: 'tooltip-wrapper', [ + a( + href: component.url, + classes: 'tooltip-target', + events: { + if (tooltipContent != null && isTouchscreen) + 'click': (event) { + if (!isVisible) { + setState(() => isVisible = true); + event.preventDefault(); + } + }, + }, + [ + code([text(component.text)]), + ], + ), + ?tooltip, + ]); + } +} + +const contentId = 'dartdoc-main-content'; +// This seems to be a good limit to avoid overly small or large tooltips. +const maxDescriptionLength = 400; +const minTrailingParagraphLength = 20; + +@awaitNotRequired +Future<(String?, String?)> scrapeApiDocs(String url) async { + try { + final response = await http.get(Uri.parse(url)); + var content = response.body; + + final startIndex = content.indexOf(RegExp(' characters. + // This only removes full paragraphs and does not truncate individual ones. + var charCount = 0; + if (description != null) { + final children = description.childNodes; + var removeFrom = -1; + for (var i = 0; i < children.length; i++) { + final child = children.item(i)!; + + if (child.instanceOfString('HTMLHeadingElement')) { + // Stop at any headings. + removeFrom = i; + break; + } + + if (child.textContent?.startsWith('See also') == true) { + // Stop at "See also" sections. + removeFrom = i; + break; + } + + if (!child.instanceOfString('HTMLParagraphElement')) { + // Skip non-paragraph elements (such as video embeds, code snippets). + description.removeChild(child); + i--; + continue; + } + + charCount += child.textContent?.length ?? 0; + + if (charCount > maxDescriptionLength) { + removeFrom = i; + break; + } + } + + // Remove any extra paragraphs beyond the max characters. + if (removeFrom > 0) { + while (children.length > removeFrom) { + description.removeChild(children.item(children.length - 1)!); + } + + // If the last paragraph is very short, remove it as well. + // This avoids having trailing "See also" or similar. + while (children.length > 1 && + (children.item(children.length - 1)!.textContent?.length ?? 0) < + minTrailingParagraphLength) { + description.removeChild(children.item(children.length - 1)!); + } + } + + // Append a "Read more" link to the full docs. + description.appendChild( + web.document.createElement('a') + ..setAttribute('href', url) + ..textContent = 'Read more.', + ); + } + + return ( + (header?.innerHTML as JSString?)?.toDart, + (description?.innerHTML as JSString?)?.toDart, + ); + } catch (e) { + print('Error fetching API docs for $url: $e'); + return (null, null); + } +} diff --git a/site/lib/src/components/util/global_event_listener.dart b/site/lib/src/components/util/global_event_listener.dart index d761217ee7..50292859d7 100644 --- a/site/lib/src/components/util/global_event_listener.dart +++ b/site/lib/src/components/util/global_event_listener.dart @@ -8,11 +8,18 @@ import 'package:jaspr/jaspr.dart'; import 'package:universal_web/web.dart' as web; final class GlobalEventListener extends StatefulComponent { - const GlobalEventListener(this.child, {this.onClick, this.onKeyDown}); + const GlobalEventListener( + this.child, { + this.onClick, + this.onKeyDown, + this.onScroll, + super.key, + }); final Component child; final void Function(web.MouseEvent)? onClick; final void Function(web.KeyboardEvent)? onKeyDown; + final void Function(web.Event)? onScroll; @override State createState() => _GlobalClickListenerState(); @@ -21,6 +28,7 @@ final class GlobalEventListener extends StatefulComponent { class _GlobalClickListenerState extends State { StreamSubscription? _clickSubscription; StreamSubscription? _keyDownSubscription; + StreamSubscription? _scrollSubscription; @override void initState() { @@ -37,6 +45,11 @@ class _GlobalClickListenerState extends State { .forTarget(web.document) .listen(onKeyDown); } + if (component.onScroll case final onScroll?) { + _scrollSubscription = web.EventStreamProviders.scrollEvent + .forTarget(web.document) + .listen(onScroll); + } } } @@ -44,6 +57,7 @@ class _GlobalClickListenerState extends State { void dispose() { unawaited(_clickSubscription?.cancel()); unawaited(_keyDownSubscription?.cancel()); + unawaited(_scrollSubscription?.cancel()); super.dispose(); } diff --git a/site/lib/src/extensions/api_link_processor.dart b/site/lib/src/extensions/api_link_processor.dart new file mode 100644 index 0000000000..5a2ce8acbe --- /dev/null +++ b/site/lib/src/extensions/api_link_processor.dart @@ -0,0 +1,51 @@ +// Copyright 2025 The Flutter Authors. 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:jaspr_content/jaspr_content.dart'; + +import '../components/common/client/api_link_tooltip.dart'; + +/// A node-processing, page extension for Jaspr Content that looks for links to +/// the flutter API docs and enhances them with interactive tooltips. +class ApiLinkProcessor implements PageExtension { + const ApiLinkProcessor(); + + @override + Future> apply(Page page, List nodes) async { + return _processNodes(nodes); + } + + List _processNodes(List nodes) { + final processedNodes = []; + + for (var i = 0; i < nodes.length; i++) { + final node = nodes[i]; + + if (node case ElementNode( + tag: 'a', + attributes: {'href': final href}, + ) when href.startsWith('https://api.flutter.dev/')) { + if (node.children case [ + ElementNode(tag: 'code', children: [TextNode(:final text)]), + ]) { + // Only enable the tooltip for links that contain a code element. + processedNodes.add( + ComponentNode(ApiLinkTooltip(url: href, text: text)), + ); + continue; + } else { + processedNodes.add(node); + } + } else if (node is ElementNode && node.children != null) { + processedNodes.add( + ElementNode(node.tag, node.attributes, _processNodes(node.children!)), + ); + } else { + processedNodes.add(node); + } + } + + return processedNodes; + } +} diff --git a/site/lib/src/extensions/registry.dart b/site/lib/src/extensions/registry.dart index 0439d23d94..1e396552c6 100644 --- a/site/lib/src/extensions/registry.dart +++ b/site/lib/src/extensions/registry.dart @@ -4,6 +4,7 @@ import 'package:jaspr_content/jaspr_content.dart'; +import 'api_link_processor.dart'; import 'attribute_processor.dart'; import 'code_block_processor.dart'; import 'header_extractor.dart'; @@ -18,4 +19,5 @@ const List allNodeProcessingExtensions = [ HeaderWrapperExtension(), TableWrapperExtension(), CodeBlockProcessor(), + ApiLinkProcessor(), ]; diff --git a/site/lib/src/style_hash.dart b/site/lib/src/style_hash.dart index 9f473f0037..fb0c195270 100644 --- a/site/lib/src/style_hash.dart +++ b/site/lib/src/style_hash.dart @@ -2,4 +2,4 @@ // dart format off /// The generated hash of the `main.css` file. -const generatedStylesHash = 'ugxj95DjrnBJ'; +const generatedStylesHash = 'kRoCS1Xxb9ee';