From ce11f26ce6bea1552992360f2f46e0348382fc0d Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Tue, 17 Oct 2023 12:20:09 -0700 Subject: [PATCH] Mobile mounted toolbar now appears above Android overlay controls, migrated mounted toolbar and Android controls to OverlayPortal (Resolves #893)(Resolves #1510) (#1524) --- .../demos/example_editor/example_editor.dart | 175 ++++--- .../supertextfield/_interactive_demo.dart | 197 ++++---- .../document_caret_overlay.dart | 8 +- .../document_gestures_mouse.dart | 2 +- .../document_gestures_touch_android.dart | 135 +++--- .../document_gestures_touch_ios.dart | 3 +- .../document_ime_interaction_policies.dart | 3 +- .../document_ime/mobile_toolbar.dart | 430 ++++++++++++------ .../supereditor_ime_interactor.dart | 2 +- .../default_editor/document_scrollable.dart | 2 +- .../flutter/flutter_pipeline.dart | 51 --- .../flutter/flutter_scheduler.dart | 103 +++++ .../flutter/overlay_with_groups.dart | 107 +++++ .../src/infrastructure/flutter_scheduler.dart | 37 -- .../platforms/ios/ios_document_controls.dart | 2 +- .../platforms/mobile_documents.dart | 6 +- .../_scrolling_minimap.dart | 2 +- ...nly_document_android_touch_interactor.dart | 135 +++--- ...ad_only_document_ios_touch_interactor.dart | 2 +- .../read_only_document_mouse_interactor.dart | 2 +- .../android/_editing_controls.dart | 2 +- .../android/_user_interaction.dart | 2 +- .../android/android_textfield.dart | 2 +- .../desktop/desktop_textfield.dart | 2 +- .../infrastructure/text_scrollview.dart | 2 +- .../ios/_editing_controls.dart | 2 +- .../ios/_user_interaction.dart | 2 +- .../super_textfield/ios/ios_textfield.dart | 2 +- super_editor/lib/super_editor.dart | 2 + 29 files changed, 842 insertions(+), 580 deletions(-) delete mode 100644 super_editor/lib/src/infrastructure/flutter/flutter_pipeline.dart create mode 100644 super_editor/lib/src/infrastructure/flutter/flutter_scheduler.dart create mode 100644 super_editor/lib/src/infrastructure/flutter/overlay_with_groups.dart delete mode 100644 super_editor/lib/src/infrastructure/flutter_scheduler.dart diff --git a/super_editor/example/lib/demos/example_editor/example_editor.dart b/super_editor/example/lib/demos/example_editor/example_editor.dart index 224c3b82ed..8a48a08df4 100644 --- a/super_editor/example/lib/demos/example_editor/example_editor.dart +++ b/super_editor/example/lib/demos/example_editor/example_editor.dart @@ -37,10 +37,10 @@ class _ExampleEditorState extends State { SuperEditorDebugVisualsConfig? _debugConfig; - OverlayEntry? _textFormatBarOverlayEntry; + final _textFormatBarOverlayController = OverlayPortalController(); final _textSelectionAnchor = ValueNotifier(null); - OverlayEntry? _imageFormatBarOverlayEntry; + final _imageFormatBarOverlayController = OverlayPortalController(); final _imageSelectionAnchor = ValueNotifier(null); final _overlayController = MagnifierAndToolbarController() // @@ -65,10 +65,6 @@ class _ExampleEditorState extends State { @override void dispose() { - if (_textFormatBarOverlayEntry != null) { - _textFormatBarOverlayEntry!.remove(); - } - _scrollController.dispose(); _editorFocusNode.dispose(); _composer.dispose(); @@ -139,35 +135,13 @@ class _ExampleEditorState extends State { } void _showEditorToolbar() { - if (_textFormatBarOverlayEntry == null) { - // Create an overlay entry to build the editor toolbar. - // TODO: add an overlay to the Editor widget to avoid using the - // application overlay - _textFormatBarOverlayEntry ??= OverlayEntry(builder: (context) { - return EditorToolbar( - editorViewportKey: _viewportKey, - anchor: _selectionLayerLinks.expandedSelectionBoundsLink, - editorFocusNode: _editorFocusNode, - editor: _docEditor, - document: _doc, - composer: _composer, - closeToolbar: _hideEditorToolbar, - ); - }); - - // Display the toolbar in the application overlay. - final overlay = Overlay.of(context); - overlay.insert(_textFormatBarOverlayEntry!); - } + _textFormatBarOverlayController.show(); // Schedule a callback after this frame to locate the selection // bounds on the screen and display the toolbar near the selected // text. + // TODO: switch this to use a Leader and Follower WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - if (_textFormatBarOverlayEntry == null) { - return; - } - final docBoundingBox = (_docLayoutKey.currentState as DocumentLayout) .getRectForSelection(_composer.selection!.base, _composer.selection!.extent)!; final docBox = _docLayoutKey.currentContext!.findRenderObject() as RenderBox; @@ -185,22 +159,14 @@ class _ExampleEditorState extends State { // the bar doesn't momentarily "flash" at its old anchor position. _textSelectionAnchor.value = null; - if (_textFormatBarOverlayEntry != null) { - // Remove the toolbar overlay and null-out the entry. - // We null out the entry because we can't query whether - // or not the entry exists in the overlay, so in our - // case, null implies the entry is not in the overlay, - // and non-null implies the entry is in the overlay. - _textFormatBarOverlayEntry!.remove(); - _textFormatBarOverlayEntry = null; - - // Ensure that focus returns to the editor. - // - // I tried explicitly unfocus()'ing the URL textfield - // in the toolbar but it didn't return focus to the - // editor. I'm not sure why. - _editorFocusNode.requestFocus(); - } + _textFormatBarOverlayController.hide(); + + // Ensure that focus returns to the editor. + // + // I tried explicitly unfocus()'ing the URL textfield + // in the toolbar but it didn't return focus to the + // editor. I'm not sure why. + _editorFocusNode.requestFocus(); } DocumentGestureMode get _gestureMode { @@ -249,37 +215,11 @@ class _ExampleEditorState extends State { void _selectAll() => _docOps.selectAll(); void _showImageToolbar() { - if (_imageFormatBarOverlayEntry == null) { - // Create an overlay entry to build the image toolbar. - _imageFormatBarOverlayEntry ??= OverlayEntry(builder: (context) { - return ImageFormatToolbar( - anchor: _imageSelectionAnchor, - composer: _composer, - setWidth: (nodeId, width) { - final node = _doc.getNodeById(nodeId)!; - final currentStyles = SingleColumnLayoutComponentStyles.fromMetadata(node); - SingleColumnLayoutComponentStyles( - width: width, - padding: currentStyles.padding, - ).applyTo(node); - }, - closeToolbar: _hideImageToolbar, - ); - }); - - // Display the toolbar in the application overlay. - final overlay = Overlay.of(context); - overlay.insert(_imageFormatBarOverlayEntry!); - } - // Schedule a callback after this frame to locate the selection // bounds on the screen and display the toolbar near the selected // text. + // TODO: switch to a Leader and Follower for this WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - if (_imageFormatBarOverlayEntry == null) { - return; - } - final docBoundingBox = (_docLayoutKey.currentState as DocumentLayout) .getRectForSelection(_composer.selection!.base, _composer.selection!.extent)!; final docBox = _docLayoutKey.currentContext!.findRenderObject() as RenderBox; @@ -290,6 +230,8 @@ class _ExampleEditorState extends State { _imageSelectionAnchor.value = overlayBoundingBox.center; }); + + _imageFormatBarOverlayController.show(); } void _hideImageToolbar() { @@ -297,38 +239,40 @@ class _ExampleEditorState extends State { // it doesn't momentarily "flash" at its old anchor position. _imageSelectionAnchor.value = null; - if (_imageFormatBarOverlayEntry != null) { - // Remove the image toolbar overlay and null-out the entry. - // We null out the entry because we can't query whether - // or not the entry exists in the overlay, so in our - // case, null implies the entry is not in the overlay, - // and non-null implies the entry is in the overlay. - _imageFormatBarOverlayEntry!.remove(); - _imageFormatBarOverlayEntry = null; - - // Ensure that focus returns to the editor. - _editorFocusNode.requestFocus(); - } + _imageFormatBarOverlayController.hide(); + + // Ensure that focus returns to the editor. + _editorFocusNode.requestFocus(); } @override Widget build(BuildContext context) { - return ListenableBuilder( - listenable: _brightness, - builder: (context, _) { + return ValueListenableBuilder( + valueListenable: _brightness, + builder: (context, brightness, child) { return Theme( - data: ThemeData(brightness: _brightness.value), - child: Builder( - builder: (themedContext) { - // This builder captures the new theme - return Stack( + data: ThemeData(brightness: brightness), + child: child!, + ); + }, + child: Builder( + // This builder captures the new theme + builder: (themedContext) { + return OverlayPortal( + controller: _textFormatBarOverlayController, + overlayChildBuilder: _buildFloatingToolbar, + child: OverlayPortal( + controller: _imageFormatBarOverlayController, + overlayChildBuilder: _buildImageToolbar, + child: Stack( children: [ Column( children: [ Expanded( child: _buildEditor(themedContext), ), - if (_isMobile) _buildMountedToolbar(), + if (_isMobile) // + _buildMountedToolbar(), ], ), Align( @@ -336,11 +280,11 @@ class _ExampleEditorState extends State { child: _buildCornerFabs(), ), ], - ); - }, - ), - ); - }, + ), + ), + ); + }, + ), ); } @@ -495,6 +439,39 @@ class _ExampleEditorState extends State { }, ); } + + Widget _buildFloatingToolbar(BuildContext context) { + return EditorToolbar( + editorViewportKey: _viewportKey, + anchor: _selectionLayerLinks.expandedSelectionBoundsLink, + editorFocusNode: _editorFocusNode, + editor: _docEditor, + document: _doc, + composer: _composer, + closeToolbar: _hideEditorToolbar, + ); + } + + Widget _buildImageToolbar(BuildContext context) { + return ImageFormatToolbar( + anchor: _imageSelectionAnchor, + composer: _composer, + setWidth: (nodeId, width) { + print("Applying width $width to node $nodeId"); + final node = _doc.getNodeById(nodeId)!; + final currentStyles = SingleColumnLayoutComponentStyles.fromMetadata(node); + SingleColumnLayoutComponentStyles( + width: width, + padding: currentStyles.padding, + ).applyTo(node); + + // TODO: schedule a presentation reflow so that the image changes size immediately (https://github.com/superlistapp/super_editor/issues/1529) + // Right now, nothing happens when pressing the button, unless we force a + // rebuild/reflow. + }, + closeToolbar: _hideImageToolbar, + ); + } } // Makes text light, for use during dark mode styling. diff --git a/super_editor/example/lib/demos/supertextfield/_interactive_demo.dart b/super_editor/example/lib/demos/supertextfield/_interactive_demo.dart index bdfa7c8d2d..4545c20eba 100644 --- a/super_editor/example/lib/demos/supertextfield/_interactive_demo.dart +++ b/super_editor/example/lib/demos/supertextfield/_interactive_demo.dart @@ -26,7 +26,7 @@ class _InteractiveTextFieldDemoState extends State { ), ); - OverlayEntry? _popupEntry; + final _popupOverlayController = OverlayPortalController(); Offset _popupOffset = Offset.zero; FocusNode? _focusNode; @@ -55,119 +55,114 @@ class _InteractiveTextFieldDemoState extends State { final textFieldBox = textFieldContext.findRenderObject() as RenderBox; _popupOffset = textFieldBox.localToGlobal(localOffset, ancestor: overlayBox); - if (_popupEntry == null) { - _popupEntry = OverlayEntry(builder: (context) { - return GestureDetector( - behavior: HitTestBehavior.translucent, - onTapDown: (_) { - _closePopup(); - }, - child: Container( - width: double.infinity, - height: double.infinity, - color: Colors.transparent, - child: Stack( - children: [ - Positioned( - left: _popupOffset.dx, - top: _popupOffset.dy, - child: Container( - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(4), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.3), - blurRadius: 5, - offset: const Offset(3, 3), - ), - ], - ), - child: Column( - children: [ - TextButton( - onPressed: () { - Clipboard.setData(ClipboardData( - text: textController.selection.textInside(textController.text.text), - )); - _closePopup(); - }, - child: const Text('Copy'), - ), - ], - ), - ), - ), - ], - ), - ), - ); - }); - - overlay.insert(_popupEntry!); - } else { - _popupEntry!.markNeedsBuild(); - } + _popupOverlayController.show(); } void _closePopup() { - if (_popupEntry == null) { - return; - } - - _popupEntry!.remove(); - _popupEntry = null; + _popupOverlayController.hide(); } @override Widget build(BuildContext context) { - return TapRegion( - groupId: _tapRegionGroupId, - onTapOutside: (_) { - // Remove focus from text field when the user taps anywhere else. - _focusNode!.unfocus(); - }, - child: Center( - child: SizedBox( - width: 400, + return OverlayPortal( + controller: _popupOverlayController, + overlayChildBuilder: _buildPopover, + child: TapRegion( + groupId: _tapRegionGroupId, + onTapOutside: (_) { + // Remove focus from text field when the user taps anywhere else. + _focusNode!.unfocus(); + }, + child: Center( child: SizedBox( - width: double.infinity, - child: SuperDesktopTextField( - focusNode: _focusNode, - tapRegionGroupId: _tapRegionGroupId, - textController: _textFieldController, - inputSource: TextInputSource.ime, - textStyleBuilder: demoTextStyleBuilder, - blinkTimingMode: BlinkTimingMode.timer, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decorationBuilder: (context, child) { - return Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(4), - border: Border.all( - color: _focusNode!.hasFocus ? Colors.blue : Colors.grey.shade300, - width: 1, + width: 400, + child: SizedBox( + width: double.infinity, + child: SuperDesktopTextField( + focusNode: _focusNode, + tapRegionGroupId: _tapRegionGroupId, + textController: _textFieldController, + inputSource: TextInputSource.ime, + textStyleBuilder: demoTextStyleBuilder, + blinkTimingMode: BlinkTimingMode.timer, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decorationBuilder: (context, child) { + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + border: Border.all( + color: _focusNode!.hasFocus ? Colors.blue : Colors.grey.shade300, + width: 1, + ), + ), + child: child, + ); + }, + hintBuilder: (context) { + return const Text( + 'enter some text', + style: TextStyle( + color: Colors.grey, ), - ), - child: child, - ); - }, - hintBuilder: (context) { - return const Text( - 'enter some text', - style: TextStyle( - color: Colors.grey, - ), - ); - }, - hintBehavior: HintBehavior.displayHintUntilTextEntered, - minLines: 5, - maxLines: 5, - onRightClick: _onRightClick, + ); + }, + hintBehavior: HintBehavior.displayHintUntilTextEntered, + minLines: 5, + maxLines: 5, + onRightClick: _onRightClick, + ), ), ), ), ), ); } + + Widget _buildPopover(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.translucent, + onTapDown: (_) { + _closePopup(); + }, + child: Container( + width: double.infinity, + height: double.infinity, + color: Colors.transparent, + child: Stack( + children: [ + Positioned( + left: _popupOffset.dx, + top: _popupOffset.dy, + child: Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(4), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.3), + blurRadius: 5, + offset: const Offset(3, 3), + ), + ], + ), + child: Column( + children: [ + TextButton( + onPressed: () { + Clipboard.setData(ClipboardData( + text: _textFieldController.selection.textInside(_textFieldController.text.text), + )); + _closePopup(); + }, + child: const Text('Copy'), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } } diff --git a/super_editor/lib/src/default_editor/document_caret_overlay.dart b/super_editor/lib/src/default_editor/document_caret_overlay.dart index b289fb7741..0385536e9e 100644 --- a/super_editor/lib/src/default_editor/document_caret_overlay.dart +++ b/super_editor/lib/src/default_editor/document_caret_overlay.dart @@ -101,11 +101,12 @@ class _CaretDocumentOverlayState extends ContentLayerState{ - TapSequenceGestureRecognizer: GestureRecognizerFactoryWithHandlers( - () => TapSequenceGestureRecognizer(), - (TapSequenceGestureRecognizer recognizer) { - recognizer - ..onTapDown = _onTapDown - ..onTapUp = _onTapUp - ..onDoubleTapDown = _onDoubleTapDown - ..onTripleTapDown = _onTripleTapDown - ..gestureSettings = gestureSettings; - }, - ), - PanGestureRecognizer: GestureRecognizerFactoryWithHandlers( - () => PanGestureRecognizer(), - (PanGestureRecognizer recognizer) { - recognizer - ..onStart = _onPanStart - ..onUpdate = _onPanUpdate - ..onEnd = _onPanEnd - ..onCancel = _onPanCancel - ..gestureSettings = gestureSettings; - }, - ), - }, - child: widget.child, + return OverlayPortal( + controller: _overlayPortalController, + overlayChildBuilder: _buildControlsOverlay, + child: RawGestureDetector( + behavior: HitTestBehavior.translucent, + gestures: { + TapSequenceGestureRecognizer: GestureRecognizerFactoryWithHandlers( + () => TapSequenceGestureRecognizer(), + (TapSequenceGestureRecognizer recognizer) { + recognizer + ..onTapDown = _onTapDown + ..onTapUp = _onTapUp + ..onDoubleTapDown = _onDoubleTapDown + ..onTripleTapDown = _onTripleTapDown + ..gestureSettings = gestureSettings; + }, + ), + PanGestureRecognizer: GestureRecognizerFactoryWithHandlers( + () => PanGestureRecognizer(), + (PanGestureRecognizer recognizer) { + recognizer + ..onStart = _onPanStart + ..onUpdate = _onPanUpdate + ..onEnd = _onPanEnd + ..onCancel = _onPanCancel + ..gestureSettings = gestureSettings; + }, + ), + }, + child: widget.child, + ), ); } + + Widget _buildControlsOverlay(BuildContext context) { + return ListenableBuilder( + listenable: _overlayPortalRebuildSignal, + builder: (context, child) { + return AndroidDocumentTouchEditingControls( + editingController: _editingController, + documentKey: widget.documentKey, + documentLayout: _docLayout, + createOverlayControlsClipper: widget.createOverlayControlsClipper, + handleColor: widget.handleColor, + onHandleDragStart: _onHandleDragStart, + onHandleDragUpdate: _onHandleDragUpdate, + onHandleDragEnd: _onHandleDragEnd, + popoverToolbarBuilder: widget.popoverToolbarBuilder, + longPressMagnifierGlobalOffset: _longPressMagnifierGlobalOffset, + showDebugPaint: false, + ); + }); + } } class AndroidDocumentTouchEditingControls extends StatefulWidget { diff --git a/super_editor/lib/src/default_editor/document_gestures_touch_ios.dart b/super_editor/lib/src/default_editor/document_gestures_touch_ios.dart index d4ad20c7fe..5a16d7b42b 100644 --- a/super_editor/lib/src/default_editor/document_gestures_touch_ios.dart +++ b/super_editor/lib/src/default_editor/document_gestures_touch_ios.dart @@ -9,11 +9,10 @@ import 'package:super_editor/src/core/document_composer.dart'; import 'package:super_editor/src/core/document_layout.dart'; import 'package:super_editor/src/core/document_selection.dart'; import 'package:super_editor/src/core/editor.dart'; -import 'package:super_editor/src/default_editor/text.dart'; import 'package:super_editor/src/default_editor/text_tools.dart'; import 'package:super_editor/src/document_operations/selection_operations.dart'; import 'package:super_editor/src/infrastructure/_logging.dart'; -import 'package:super_editor/src/infrastructure/flutter/flutter_pipeline.dart'; +import 'package:super_editor/src/infrastructure/flutter/flutter_scheduler.dart'; import 'package:super_editor/src/infrastructure/multi_tap_gesture.dart'; import 'package:super_editor/src/infrastructure/platforms/ios/ios_document_controls.dart'; import 'package:super_editor/src/infrastructure/platforms/ios/long_press_selection.dart'; diff --git a/super_editor/lib/src/default_editor/document_ime/document_ime_interaction_policies.dart b/super_editor/lib/src/default_editor/document_ime/document_ime_interaction_policies.dart index de23392d09..755e6b513b 100644 --- a/super_editor/lib/src/default_editor/document_ime/document_ime_interaction_policies.dart +++ b/super_editor/lib/src/default_editor/document_ime/document_ime_interaction_policies.dart @@ -5,8 +5,7 @@ import 'package:super_editor/src/core/document_composer.dart'; import 'package:super_editor/src/core/document_selection.dart'; import 'package:super_editor/src/core/editor.dart'; import 'package:super_editor/src/infrastructure/_logging.dart'; -import 'package:super_editor/src/infrastructure/flutter/flutter_pipeline.dart'; -import 'package:super_editor/src/infrastructure/flutter_scheduler.dart'; +import 'package:super_editor/src/infrastructure/flutter/flutter_scheduler.dart'; /// Widget that opens and closes an [imeConnection] based on the [focusNode] gaining /// and losing primary focus. diff --git a/super_editor/lib/src/default_editor/document_ime/mobile_toolbar.dart b/super_editor/lib/src/default_editor/document_ime/mobile_toolbar.dart index 9da7ee597c..8a2465ec5f 100644 --- a/super_editor/lib/src/default_editor/document_ime/mobile_toolbar.dart +++ b/super_editor/lib/src/default_editor/document_ime/mobile_toolbar.dart @@ -9,49 +9,152 @@ import 'package:super_editor/src/default_editor/list_items.dart'; import 'package:super_editor/src/default_editor/multi_node_editing.dart'; import 'package:super_editor/src/default_editor/paragraph.dart'; import 'package:super_editor/src/default_editor/text.dart'; +import 'package:super_editor/src/infrastructure/flutter/flutter_scheduler.dart'; +import 'package:super_editor/src/infrastructure/flutter/overlay_with_groups.dart'; import '../attributions.dart'; -/// Toolbar that provides document editing capabilities, like converting -/// paragraphs to blockquotes and list items, and inserting horizontal -/// rules. +/// A mobile document editing toolbar, which is displayed in the application +/// [Overlay], and is mounted just above the software keyboard. /// -/// This toolbar is intended to be placed just above the keyboard on a -/// mobile device. -class KeyboardEditingToolbar extends StatelessWidget { - KeyboardEditingToolbar({ +/// Despite displaying the toolbar in the application [Overlay], [KeyboardEditingToolbar] +/// also (optionally) inserts some blank space into the current subtree, which takes up +/// the same amount of height as the toolbar that appears in the [Overlay]. +/// +/// Provides document editing capabilities, like converting paragraphs to blockquotes +/// and list items, and inserting horizontal rules. +class KeyboardEditingToolbar extends StatefulWidget { + const KeyboardEditingToolbar({ Key? key, required this.editor, required this.document, required this.composer, required this.commonOps, this.brightness, - }) : super(key: key) { - _toolbarOps = KeyboardEditingToolbarOperations( - editor: editor, - document: document, - composer: composer, - commonOps: commonOps, - ); - } + this.takeUpSameSpaceAsToolbar = false, + }) : super(key: key); final Editor editor; final Document document; final DocumentComposer composer; final CommonEditorOperations commonOps; + + @Deprecated("To change the brightness, wrap KeyboardEditingToolbar with a Theme, instead") final Brightness? brightness; - late final KeyboardEditingToolbarOperations _toolbarOps; + /// Whether this widget should take up empty space in the current subtree that + /// matches the space taken up by the toolbar in the application [Overlay]. + /// + /// If `true`, space is taken up that's equivalent to the toolbar height. If + /// `false`, no space is taken up by this widget at all. + /// + /// Taking up empty space is useful when this widget is positioned at the same + /// location on the screen as the toolbar that's in the overlay. By adding extra + /// space, other content in this subtree won't flow behind the toolbar in the + /// [Overlay]. + final bool takeUpSameSpaceAsToolbar; + + @override + State createState() => _KeyboardEditingToolbarState(); +} + +class _KeyboardEditingToolbarState extends State with WidgetsBindingObserver { + late KeyboardEditingToolbarOperations _toolbarOps; + + final _portalController = GroupedOverlayPortalController(displayPriority: OverlayGroupPriority.windowChrome); + + double _toolbarHeight = 0; + + @override + void initState() { + super.initState(); + + _toolbarOps = KeyboardEditingToolbarOperations( + editor: widget.editor, + document: widget.document, + composer: widget.composer, + commonOps: widget.commonOps, + ); + + WidgetsBinding.instance.runAsSoonAsPossible(() { + _portalController.show(); + }); + } + + @override + void didUpdateWidget(KeyboardEditingToolbar oldWidget) { + super.didUpdateWidget(oldWidget); + + _toolbarOps = KeyboardEditingToolbarOperations( + editor: widget.editor, + document: widget.document, + composer: widget.composer, + commonOps: widget.commonOps, + ); + } + + @override + void dispose() { + if (_portalController.isShowing) { + _portalController.hide(); + } + super.dispose(); + } + + void _onToolbarLayout(double toolbarHeight) { + if (toolbarHeight == _toolbarHeight) { + return; + } + + // The toolbar in the overlay changed its height. Our child needs to take up the + // same amount of height so that content doesn't go behind our toolbar. Rebuild + // with the latest toolbar height and take up an equal amount of height. + setStateAsSoonAsPossible(() { + _toolbarHeight = toolbarHeight; + }); + } @override Widget build(BuildContext context) { - final selection = composer.selection; + return OverlayPortal( + controller: _portalController, + overlayChildBuilder: _buildToolbarOverlay, + // Take up empty space that's as tall as the toolbar so that other content + // doesn't layout behind it. + child: SizedBox(height: widget.takeUpSameSpaceAsToolbar ? _toolbarHeight : 0), + ); + } + Widget _buildToolbarOverlay(BuildContext context) { + final selection = widget.composer.selection; if (selection == null) { return const SizedBox(); } - final brightness = this.brightness ?? MediaQuery.of(context).platformBrightness; + return KeyboardHeightBuilder(builder: (context, keyboardHeight) { + return Padding( + // Add padding that takes up the height of the software keyboard so + // that the toolbar sits just above the keyboard. + padding: EdgeInsets.only(bottom: keyboardHeight), + child: Align( + alignment: Alignment.bottomLeft, + child: _buildTheming( + child: Builder( + // Add a Builder so that _buildToolbar() uses theming from _buildTheming(). + builder: (themedContext) { + return _buildToolbar(themedContext); + }, + ), + ), + ), + ); + }); + } + + Widget _buildTheming({ + required Widget child, + }) { + final brightness = widget.brightness ?? MediaQuery.of(context).platformBrightness; return Theme( data: Theme.of(context).copyWith( @@ -62,128 +165,187 @@ class KeyboardEditingToolbar extends StatelessWidget { data: IconThemeData( color: brightness == Brightness.light ? Colors.black : Colors.white, ), - child: Material( - child: Container( - width: double.infinity, - height: 48, - color: brightness == Brightness.light ? const Color(0xFFDDDDDD) : const Color(0xFF222222), - child: Row( - children: [ - Expanded( - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: ListenableBuilder( - listenable: composer, - builder: (context, _) { - final selectedNode = document.getNodeById(selection.extent.nodeId); - final isSingleNodeSelected = selection.extent.nodeId == selection.base.nodeId; - - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - onPressed: selectedNode is TextNode ? _toolbarOps.toggleBold : null, - icon: const Icon(Icons.format_bold), - color: _toolbarOps.isBoldActive ? Theme.of(context).primaryColor : null, - ), - IconButton( - onPressed: selectedNode is TextNode ? _toolbarOps.toggleItalics : null, - icon: const Icon(Icons.format_italic), - color: _toolbarOps.isItalicsActive ? Theme.of(context).primaryColor : null, - ), - IconButton( - onPressed: selectedNode is TextNode ? _toolbarOps.toggleUnderline : null, - icon: const Icon(Icons.format_underline), - color: _toolbarOps.isUnderlineActive ? Theme.of(context).primaryColor : null, - ), - IconButton( - onPressed: selectedNode is TextNode ? _toolbarOps.toggleStrikethrough : null, - icon: const Icon(Icons.strikethrough_s), - color: _toolbarOps.isStrikethroughActive ? Theme.of(context).primaryColor : null, - ), - IconButton( - onPressed: isSingleNodeSelected && - (selectedNode is TextNode && - selectedNode.getMetadataValue('blockType') != header1Attribution) - ? _toolbarOps.convertToHeader1 - : null, - icon: const Icon(Icons.title), - ), - IconButton( - onPressed: isSingleNodeSelected && - (selectedNode is TextNode && - selectedNode.getMetadataValue('blockType') != header2Attribution) - ? _toolbarOps.convertToHeader2 - : null, - icon: const Icon(Icons.title), - iconSize: 18, - ), - IconButton( - onPressed: isSingleNodeSelected && - ((selectedNode is ParagraphNode && - selectedNode.hasMetadataValue('blockType')) || - (selectedNode is TextNode && selectedNode is! ParagraphNode)) - ? _toolbarOps.convertToParagraph - : null, - icon: const Icon(Icons.wrap_text), - ), - IconButton( - onPressed: isSingleNodeSelected && - (selectedNode is TextNode && selectedNode is! ListItemNode || - (selectedNode is ListItemNode && selectedNode.type != ListItemType.ordered)) - ? _toolbarOps.convertToOrderedListItem - : null, - icon: const Icon(Icons.looks_one_rounded), - ), - IconButton( - onPressed: isSingleNodeSelected && - (selectedNode is TextNode && selectedNode is! ListItemNode || - (selectedNode is ListItemNode && - selectedNode.type != ListItemType.unordered)) - ? _toolbarOps.convertToUnorderedListItem - : null, - icon: const Icon(Icons.list), - ), - IconButton( - onPressed: isSingleNodeSelected && - selectedNode is TextNode && - (selectedNode is! ParagraphNode || - selectedNode.getMetadataValue('blockType') != blockquoteAttribution) - ? _toolbarOps.convertToBlockquote - : null, - icon: const Icon(Icons.format_quote), - ), - IconButton( - onPressed: isSingleNodeSelected && - selectedNode is ParagraphNode && - selectedNode.text.text.isEmpty - ? _toolbarOps.convertToHr - : null, - icon: const Icon(Icons.horizontal_rule), - ), - ], - ); - }), - ), - ), - Container( - width: 1, - height: 32, - color: const Color(0xFFCCCCCC), - ), - IconButton( - onPressed: _toolbarOps.closeKeyboard, - icon: const Icon(Icons.keyboard_hide), + child: child, + ), + ); + } + + Widget _buildToolbar(BuildContext context) { + final selection = widget.composer.selection!; + + return Material( + child: Container( + width: double.infinity, + height: 48, + color: Theme.of(context).brightness == Brightness.light ? const Color(0xFFDDDDDD) : const Color(0xFF222222), + child: LayoutBuilder(builder: (context, constraints) { + _onToolbarLayout(constraints.maxHeight); + + return Row( + children: [ + Expanded( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: ListenableBuilder( + listenable: widget.composer, + builder: (context, _) { + final selectedNode = widget.document.getNodeById(selection.extent.nodeId); + final isSingleNodeSelected = selection.extent.nodeId == selection.base.nodeId; + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + onPressed: selectedNode is TextNode ? _toolbarOps.toggleBold : null, + icon: const Icon(Icons.format_bold), + color: _toolbarOps.isBoldActive ? Theme.of(context).primaryColor : null, + ), + IconButton( + onPressed: selectedNode is TextNode ? _toolbarOps.toggleItalics : null, + icon: const Icon(Icons.format_italic), + color: _toolbarOps.isItalicsActive ? Theme.of(context).primaryColor : null, + ), + IconButton( + onPressed: selectedNode is TextNode ? _toolbarOps.toggleUnderline : null, + icon: const Icon(Icons.format_underline), + color: _toolbarOps.isUnderlineActive ? Theme.of(context).primaryColor : null, + ), + IconButton( + onPressed: selectedNode is TextNode ? _toolbarOps.toggleStrikethrough : null, + icon: const Icon(Icons.strikethrough_s), + color: _toolbarOps.isStrikethroughActive ? Theme.of(context).primaryColor : null, + ), + IconButton( + onPressed: isSingleNodeSelected && + (selectedNode is TextNode && + selectedNode.getMetadataValue('blockType') != header1Attribution) + ? _toolbarOps.convertToHeader1 + : null, + icon: const Icon(Icons.title), + ), + IconButton( + onPressed: isSingleNodeSelected && + (selectedNode is TextNode && + selectedNode.getMetadataValue('blockType') != header2Attribution) + ? _toolbarOps.convertToHeader2 + : null, + icon: const Icon(Icons.title), + iconSize: 18, + ), + IconButton( + onPressed: isSingleNodeSelected && + ((selectedNode is ParagraphNode && selectedNode.hasMetadataValue('blockType')) || + (selectedNode is TextNode && selectedNode is! ParagraphNode)) + ? _toolbarOps.convertToParagraph + : null, + icon: const Icon(Icons.wrap_text), + ), + IconButton( + onPressed: isSingleNodeSelected && + (selectedNode is TextNode && selectedNode is! ListItemNode || + (selectedNode is ListItemNode && selectedNode.type != ListItemType.ordered)) + ? _toolbarOps.convertToOrderedListItem + : null, + icon: const Icon(Icons.looks_one_rounded), + ), + IconButton( + onPressed: isSingleNodeSelected && + (selectedNode is TextNode && selectedNode is! ListItemNode || + (selectedNode is ListItemNode && selectedNode.type != ListItemType.unordered)) + ? _toolbarOps.convertToUnorderedListItem + : null, + icon: const Icon(Icons.list), + ), + IconButton( + onPressed: isSingleNodeSelected && + selectedNode is TextNode && + (selectedNode is! ParagraphNode || + selectedNode.getMetadataValue('blockType') != blockquoteAttribution) + ? _toolbarOps.convertToBlockquote + : null, + icon: const Icon(Icons.format_quote), + ), + IconButton( + onPressed: isSingleNodeSelected && + selectedNode is ParagraphNode && + selectedNode.text.text.isEmpty + ? _toolbarOps.convertToHr + : null, + icon: const Icon(Icons.horizontal_rule), + ), + ], + ); + }), ), - ], - ), - ), - ), + ), + Container( + width: 1, + height: 32, + color: const Color(0xFFCCCCCC), + ), + IconButton( + onPressed: _toolbarOps.closeKeyboard, + icon: const Icon(Icons.keyboard_hide), + ), + ], + ); + }), ), ); } } +/// Builds (and rebuilds) a [builder] with the current height of the software keyboard. +/// +/// There's no explicit property for the software keyboard height. This builder uses +/// `EdgeInsets.fromViewPadding(View.of(context).viewInsets, View.of(context).devicePixelRatio).bottom` +/// as a proxy for the height of the software keyboard. +class KeyboardHeightBuilder extends StatefulWidget { + const KeyboardHeightBuilder({ + super.key, + required this.builder, + }); + + final Widget Function(BuildContext, double keyboardHeight) builder; + + @override + State createState() => _KeyboardHeightBuilderState(); +} + +class _KeyboardHeightBuilderState extends State with WidgetsBindingObserver { + double _keyboardHeight = 0; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeMetrics() { + final keyboardHeight = + EdgeInsets.fromViewPadding(View.of(context).viewInsets, View.of(context).devicePixelRatio).bottom; + if (keyboardHeight == _keyboardHeight) { + return; + } + + setState(() { + _keyboardHeight = keyboardHeight; + }); + } + + @override + Widget build(BuildContext context) { + return widget.builder(context, _keyboardHeight); + } +} + @visibleForTesting class KeyboardEditingToolbarOperations { KeyboardEditingToolbarOperations({ diff --git a/super_editor/lib/src/default_editor/document_ime/supereditor_ime_interactor.dart b/super_editor/lib/src/default_editor/document_ime/supereditor_ime_interactor.dart index fc22dfa8b9..9e181870e4 100644 --- a/super_editor/lib/src/default_editor/document_ime/supereditor_ime_interactor.dart +++ b/super_editor/lib/src/default_editor/document_ime/supereditor_ime_interactor.dart @@ -8,7 +8,7 @@ import 'package:super_editor/src/core/edit_context.dart'; import 'package:super_editor/src/default_editor/debug_visualization.dart'; import 'package:super_editor/src/default_editor/text.dart'; import 'package:super_editor/src/infrastructure/_logging.dart'; -import 'package:super_editor/src/infrastructure/flutter/flutter_pipeline.dart'; +import 'package:super_editor/src/infrastructure/flutter/flutter_scheduler.dart'; import 'package:super_editor/src/infrastructure/ime_input_owner.dart'; import 'package:super_editor/src/infrastructure/platforms/ios/ios_document_controls.dart'; import 'package:super_editor/src/infrastructure/platforms/mac/mac_ime.dart'; diff --git a/super_editor/lib/src/default_editor/document_scrollable.dart b/super_editor/lib/src/default_editor/document_scrollable.dart index 1585c73512..3b9d6c9f24 100644 --- a/super_editor/lib/src/default_editor/document_scrollable.dart +++ b/super_editor/lib/src/default_editor/document_scrollable.dart @@ -5,7 +5,7 @@ import 'package:flutter/scheduler.dart'; import 'package:flutter/widgets.dart'; import 'package:super_editor/src/infrastructure/_logging.dart'; import 'package:super_editor/src/infrastructure/documents/document_scroller.dart'; -import 'package:super_editor/src/infrastructure/flutter/flutter_pipeline.dart'; +import 'package:super_editor/src/infrastructure/flutter/flutter_scheduler.dart'; import 'package:super_editor/src/infrastructure/scrolling_diagnostics/_scrolling_minimap.dart'; import '../infrastructure/document_gestures.dart'; diff --git a/super_editor/lib/src/infrastructure/flutter/flutter_pipeline.dart b/super_editor/lib/src/infrastructure/flutter/flutter_pipeline.dart deleted file mode 100644 index 197832ce33..0000000000 --- a/super_editor/lib/src/infrastructure/flutter/flutter_pipeline.dart +++ /dev/null @@ -1,51 +0,0 @@ -import 'package:flutter/widgets.dart'; - -/// Extensions on [State] that provide concise, convenient control over -/// common Flutter pipeline scheduling needs. -extension Frames on State { - /// Runs the given [work] in a post-frame callback, but only if the [State] - /// is still `mounted`. - void onNextFrame(void Function(Duration timeStamp) work) { - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - if (!mounted) { - return; - } - - // Do the work. - work(timeStamp); - }); - } - - /// Adds a post-frame callback, which then calls `setState()` to trigger - /// another build, which is useful when you discover during a build that - /// you need another build immediately. - /// - /// Discovering that you need another build during a build is typically - /// the result of what we call the "extra frame problem". Some piece of - /// information is unavailable until layout has run, which then reveals - /// that you need to adjust other widgets, resulting in the need to schedule - /// another build. Consider things like drag handles, a magnifier, or a - /// toolbar, which follow the user's selection. - /// - /// Developers should be very careful when using this method because it can - /// easily cause infinite rebuilds. It must only called in conditionals that - /// won't be triggered on every frame. Otherwise, every frame will schedule - /// another frame and the pipeline will never go idle. - /// - /// This method may be called with, or without state changes: - /// - /// scheduleBuildAfterBuild(); - /// - /// schedulerBuildAfterBuild(() { - /// myVar1 = "Hello"; - /// myVar2 = "World"; - /// }); - /// - void scheduleBuildAfterBuild([VoidCallback? stateChange]) { - onNextFrame((_) { - setState(() { - stateChange?.call(); - }); - }); - } -} diff --git a/super_editor/lib/src/infrastructure/flutter/flutter_scheduler.dart b/super_editor/lib/src/infrastructure/flutter/flutter_scheduler.dart new file mode 100644 index 0000000000..3ea5b015ab --- /dev/null +++ b/super_editor/lib/src/infrastructure/flutter/flutter_scheduler.dart @@ -0,0 +1,103 @@ +import 'package:flutter/scheduler.dart'; +import 'package:flutter/widgets.dart'; +import 'package:super_editor/src/infrastructure/_logging.dart'; + +extension Scheduler on WidgetsBinding { + /// Runs the given [action] as soon as possible, given the status of Flutter's pipeline. + /// + /// Flutter throws an error if a widget ever calls `setState()` while widget building + /// is already underway. This can happen when an [action] sends signals that might cause + /// a widget to call `setState()`. For example, setting a value on a `ValueNotifier` + /// might trigger a `ListenableBuilder` to rebuild somewhere else in the tree. As a + /// result, if code sets the value on a `ValueNotifier` during Flutter's build phase, + /// Flutter will crash. This extension helps avoid such a crash. + /// + /// When [runAsSoonAsPossible] is called *outside* of a Flutter build phase, [action] + /// is executed immediately. + /// + /// When [runAsSoonAsPossible] is called *during* a Flutter build phase, [action] is + /// executed at the end of the current frame with [addPostFrameCallback]. + void runAsSoonAsPossible(VoidCallback action, {String debugLabel = "anonymous action"}) { + schedulerLog.info("Running action as soon as possible: '$debugLabel'."); + if (schedulerPhase == SchedulerPhase.persistentCallbacks) { + // The Flutter pipeline is in the middle of a build phase. Schedule the desired + // action for the end of the current frame. + schedulerLog.info("Scheduling another frame to run '$debugLabel' because Flutter is building widgets right now."); + addPostFrameCallback((timeStamp) { + schedulerLog.info("Flutter is done building widgets. Running '$debugLabel' at the end of the frame."); + action(); + }); + } else { + // The Flutter pipeline isn't building widgets right now. Execute the action + // immediately. + schedulerLog.info("Flutter isn't building widgets right now. Running '$debugLabel' immediately."); + action(); + } + } +} + +/// Extensions on [State] that provide concise, convenient control over +/// common Flutter pipeline scheduling needs. +extension Frames on State { + /// Runs the given [stateChange] within `setState()` as early as possible. + /// + /// Given that `setState()` is called, it can't be run during Flutter's + /// build phase. If Flutter is currently in the middle of the build + /// phase, another frame is scheduled, and [stateChange] is run after the + /// current build phase completes. Otherwise, [stateChange] is run immediately. + void setStateAsSoonAsPossible(VoidCallback stateChange) { + WidgetsBinding.instance.runAsSoonAsPossible( + // ignore: invalid_use_of_protected_member + () => setState(() { + stateChange(); + }), + ); + } + + /// Runs the given [work] in a post-frame callback, but only if the [State] + /// is still `mounted`. + void onNextFrame(void Function(Duration timeStamp) work) { + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + if (!mounted) { + return; + } + + // Do the work. + work(timeStamp); + }); + } + + /// Adds a post-frame callback, which then calls `setState()` to trigger + /// another build, which is useful when you discover during a build that + /// you need another build immediately. + /// + /// Discovering that you need another build during a build is typically + /// the result of what we call the "extra frame problem". Some piece of + /// information is unavailable until layout has run, which then reveals + /// that you need to adjust other widgets, resulting in the need to schedule + /// another build. Consider things like drag handles, a magnifier, or a + /// toolbar, which follow the user's selection. + /// + /// Developers should be very careful when using this method because it can + /// easily cause infinite rebuilds. It must only be called in conditionals that + /// won't be triggered on every frame. Otherwise, every frame will schedule + /// another frame and the pipeline will never go idle. + /// + /// This method may be called with, or without state changes: + /// + /// scheduleBuildAfterBuild(); + /// + /// schedulerBuildAfterBuild(() { + /// myVar1 = "Hello"; + /// myVar2 = "World"; + /// }); + /// + void scheduleBuildAfterBuild([VoidCallback? stateChange]) { + onNextFrame((_) { + // ignore: invalid_use_of_protected_member + setState(() { + stateChange?.call(); + }); + }); + } +} diff --git a/super_editor/lib/src/infrastructure/flutter/overlay_with_groups.dart b/super_editor/lib/src/infrastructure/flutter/overlay_with_groups.dart new file mode 100644 index 0000000000..3db27e6893 --- /dev/null +++ b/super_editor/lib/src/infrastructure/flutter/overlay_with_groups.dart @@ -0,0 +1,107 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/widgets.dart'; + +/// An [OverlayPortalController], which re-orders itself with all other [GroupedOverlayPortalController]s +/// such that each controller's [displayPriority] is honored by their z-indices. +/// +/// For example, regardless of when they're shown, if there three [GroupedOverlayPortalController]s +/// with priorities of `10`, `100`, and `1`, those overlays will be displayed in the order of +/// `1`, `10`, `100`. In other words, the overlay with priority of `100` appears in front of +/// the one with `10`, which appears in front of the one with `1`. Z-index re-ordering occurs +/// every time a [GroupedOverlayPortalController] is [show]n. +/// +/// Priority is based on an [OverlayGroupPriority]. There are some priority levels that are already +/// defined for common use-cases, so that those use-cases remain consistent across apps. +class GroupedOverlayPortalController extends OverlayPortalController { + static final _visibleControllers = + PriorityQueue((a, b) => a.displayPriority.compareTo(b.displayPriority)); + + static bool _isReworkingOrder = false; + + static void _show(GroupedOverlayPortalController controller) { + if (_isReworkingOrder) { + return; + } + + if (controller.isShowing) { + return; + } + + if (!_visibleControllers.contains(controller)) { + _visibleControllers.add(controller); + } + + _isReworkingOrder = true; + + // When calling `show()` on an `OverlayPortalController` that's already visible, its + // overlay becomes the top overlay in the stack. Therefore, by calling `show()` on all + // of our controllers, from low priority to high priority, we ensure the desired painting order. + for (final visiblePortal in _visibleControllers.toList()) { + visiblePortal.show(); + } + + _isReworkingOrder = false; + } + + static void _hide(GroupedOverlayPortalController controller) { + if (_isReworkingOrder) { + return; + } + + _isReworkingOrder = true; + + _visibleControllers.remove(controller); + controller.hide(); + + _isReworkingOrder = false; + } + + GroupedOverlayPortalController({ + required this.displayPriority, + super.debugLabel, + }); + + /// Relative display priority which determines the z-index of this [GroupedOverlayPortalController] + /// relative to other [GroupedOverlayPortalController]s in the app [Overlay]. + final OverlayGroupPriority displayPriority; + + @override + void show() { + if (!_isReworkingOrder) { + _show(this); + return; + } + + super.show(); + } + + @override + void hide() { + if (!_isReworkingOrder) { + _hide(this); + return; + } + + super.hide(); + } +} + +class OverlayGroupPriority implements Comparable { + /// Standard group priority for editing controls, e.g., drag handles, toolbars, + /// magnifiers. + static const editingControls = OverlayGroupPriority(10000); + + /// Standard group priority for window chrome, e.g., a toolbar mounted above the + /// software keyboard. + static const windowChrome = OverlayGroupPriority(1000000); + + const OverlayGroupPriority(this.priority); + + /// Relative priority for display z-index - higher priority means higher on the + /// z-index stack, e.g., a priority of `1000` will appear in front of a priority + /// of `10`, which will appear in front of a priority of `1`. + final int priority; + + @override + int compareTo(OverlayGroupPriority other) => priority.compareTo(other.priority); +} diff --git a/super_editor/lib/src/infrastructure/flutter_scheduler.dart b/super_editor/lib/src/infrastructure/flutter_scheduler.dart deleted file mode 100644 index 26ce34576b..0000000000 --- a/super_editor/lib/src/infrastructure/flutter_scheduler.dart +++ /dev/null @@ -1,37 +0,0 @@ -import 'package:flutter/scheduler.dart'; -import 'package:flutter/widgets.dart'; -import 'package:super_editor/src/infrastructure/_logging.dart'; - -extension Scheduler on WidgetsBinding { - /// Runs the given [action] as soon as possible, given the status of Flutter's pipeline. - /// - /// Flutter throws an error if a widget ever calls `setState()` while widget building - /// is already underway. This can happen when an [action] sends signals that might cause - /// a widget to call `setState()`. For example, setting a value on a `ValueNotifier` - /// might trigger a `ListenableBuilder` to rebuild somewhere else in the tree. As a - /// result, if code sets the value on a `ValueNotifier` during Flutter's build phase, - /// Flutter will crash. This extension helps avoid such a crash. - /// - /// When [runAsSoonAsPossible] is called *outside* of a Flutter build phase, [action] - /// is executed immediately. - /// - /// When [runAsSoonAsPossible] is called *during* a Flutter build phase, [action] is - /// executed at the end of the current frame with [addPostFrameCallback]. - void runAsSoonAsPossible(VoidCallback action, {String debugLabel = "anonymous action"}) { - schedulerLog.info("Running action as soon as possible: '$debugLabel'."); - if (schedulerPhase == SchedulerPhase.persistentCallbacks) { - // The Flutter pipeline is in the middle of a build phase. Schedule the desired - // action for the end of the current frame. - schedulerLog.info("Scheduling another frame to run '$debugLabel' because Flutter is building widgets right now."); - addPostFrameCallback((timeStamp) { - schedulerLog.info("Flutter is done building widgets. Running '$debugLabel' at the end of the frame."); - action(); - }); - } else { - // The Flutter pipeline isn't building widgets right now. Execute the action - // immediately. - schedulerLog.info("Flutter isn't building widgets right now. Running '$debugLabel' immediately."); - action(); - } - } -} diff --git a/super_editor/lib/src/infrastructure/platforms/ios/ios_document_controls.dart b/super_editor/lib/src/infrastructure/platforms/ios/ios_document_controls.dart index 8db2d16237..cfecc159a1 100644 --- a/super_editor/lib/src/infrastructure/platforms/ios/ios_document_controls.dart +++ b/super_editor/lib/src/infrastructure/platforms/ios/ios_document_controls.dart @@ -7,7 +7,7 @@ import 'package:super_editor/src/core/document_layout.dart'; import 'package:super_editor/src/core/document_selection.dart'; import 'package:super_editor/src/default_editor/text.dart'; import 'package:super_editor/src/infrastructure/_logging.dart'; -import 'package:super_editor/src/infrastructure/flutter/flutter_pipeline.dart'; +import 'package:super_editor/src/infrastructure/flutter/flutter_scheduler.dart'; import 'package:super_editor/src/infrastructure/platforms/ios/selection_handles.dart'; import 'package:super_editor/src/infrastructure/platforms/mobile_documents.dart'; import 'package:super_editor/src/infrastructure/text_input.dart'; diff --git a/super_editor/lib/src/infrastructure/platforms/mobile_documents.dart b/super_editor/lib/src/infrastructure/platforms/mobile_documents.dart index c33b94920a..da83e5746b 100644 --- a/super_editor/lib/src/infrastructure/platforms/mobile_documents.dart +++ b/super_editor/lib/src/infrastructure/platforms/mobile_documents.dart @@ -307,7 +307,8 @@ class DragHandleAutoScroller { void updateAutoScrollHandleMonitoring({ required Offset dragEndInViewport, }) { - if (dragEndInViewport.dy < _dragAutoScrollBoundary.leading) { + if (dragEndInViewport.dy < _dragAutoScrollBoundary.leading && + _getScrollPosition().pixels > _getScrollPosition().minScrollExtent) { editorGesturesLog.finest('Metrics say we should try to scroll up'); final leadingScrollBoundary = _dragAutoScrollBoundary.leading; @@ -319,7 +320,8 @@ class DragHandleAutoScroller { _autoScroller.stopScrollingUp(); } - if (_getViewportBox().size.height - dragEndInViewport.dy < _dragAutoScrollBoundary.trailing) { + if (_getViewportBox().size.height - dragEndInViewport.dy < _dragAutoScrollBoundary.trailing && + _getScrollPosition().pixels < _getScrollPosition().maxScrollExtent) { editorGesturesLog.finest('Metrics say we should try to scroll down'); final trailingScrollBoundary = _dragAutoScrollBoundary.trailing; diff --git a/super_editor/lib/src/infrastructure/scrolling_diagnostics/_scrolling_minimap.dart b/super_editor/lib/src/infrastructure/scrolling_diagnostics/_scrolling_minimap.dart index e2007af061..24345f1ba8 100644 --- a/super_editor/lib/src/infrastructure/scrolling_diagnostics/_scrolling_minimap.dart +++ b/super_editor/lib/src/infrastructure/scrolling_diagnostics/_scrolling_minimap.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; -import 'package:super_editor/src/infrastructure/flutter/flutter_pipeline.dart'; +import 'package:super_editor/src/infrastructure/flutter/flutter_scheduler.dart'; import 'package:super_editor/src/infrastructure/multi_listenable_builder.dart'; // TODO: Write golden tests for scrolling minimap diff --git a/super_editor/lib/src/super_reader/read_only_document_android_touch_interactor.dart b/super_editor/lib/src/super_reader/read_only_document_android_touch_interactor.dart index e0f2d2ad10..b37313cebc 100644 --- a/super_editor/lib/src/super_reader/read_only_document_android_touch_interactor.dart +++ b/super_editor/lib/src/super_reader/read_only_document_android_touch_interactor.dart @@ -12,12 +12,14 @@ import 'package:super_editor/src/document_operations/selection_operations.dart'; import 'package:super_editor/src/infrastructure/_logging.dart'; import 'package:super_editor/src/infrastructure/document_gestures.dart'; import 'package:super_editor/src/infrastructure/document_gestures_interaction_overrides.dart'; -import 'package:super_editor/src/infrastructure/flutter/flutter_pipeline.dart'; +import 'package:super_editor/src/infrastructure/flutter/flutter_scheduler.dart'; +import 'package:super_editor/src/infrastructure/flutter/overlay_with_groups.dart'; import 'package:super_editor/src/infrastructure/multi_tap_gesture.dart'; import 'package:super_editor/src/infrastructure/platforms/android/android_document_controls.dart'; import 'package:super_editor/src/infrastructure/platforms/android/long_press_selection.dart'; import 'package:super_editor/src/infrastructure/platforms/mobile_documents.dart'; import 'package:super_editor/src/infrastructure/selection_leader_document_layer.dart'; +import 'package:super_editor/src/infrastructure/signal_notifier.dart'; import 'package:super_editor/src/infrastructure/touch_controls.dart'; import 'package:super_editor/src/super_textfield/metrics.dart'; @@ -107,9 +109,11 @@ class _ReadOnlyAndroidDocumentTouchInteractorState extends State{ - TapSequenceGestureRecognizer: GestureRecognizerFactoryWithHandlers( - () => TapSequenceGestureRecognizer(), - (TapSequenceGestureRecognizer recognizer) { - recognizer - ..onTapDown = _onTapDown - ..onTapUp = _onTapUp - ..onDoubleTapDown = _onDoubleTapDown - ..onTripleTapDown = _onTripleTapDown - ..gestureSettings = gestureSettings; - }, - ), - PanGestureRecognizer: GestureRecognizerFactoryWithHandlers( - () => PanGestureRecognizer(), - (PanGestureRecognizer recognizer) { - recognizer - ..onStart = _onPanStart - ..onUpdate = _onPanUpdate - ..onEnd = _onPanEnd - ..onCancel = _onPanCancel - ..gestureSettings = gestureSettings; - }, - ), - }, - child: widget.child, + return OverlayPortal( + controller: _overlayPortalController, + overlayChildBuilder: _buildControlsOverlay, + child: RawGestureDetector( + behavior: HitTestBehavior.translucent, + gestures: { + TapSequenceGestureRecognizer: GestureRecognizerFactoryWithHandlers( + () => TapSequenceGestureRecognizer(), + (TapSequenceGestureRecognizer recognizer) { + recognizer + ..onTapDown = _onTapDown + ..onTapUp = _onTapUp + ..onDoubleTapDown = _onDoubleTapDown + ..onTripleTapDown = _onTripleTapDown + ..gestureSettings = gestureSettings; + }, + ), + PanGestureRecognizer: GestureRecognizerFactoryWithHandlers( + () => PanGestureRecognizer(), + (PanGestureRecognizer recognizer) { + recognizer + ..onStart = _onPanStart + ..onUpdate = _onPanUpdate + ..onEnd = _onPanEnd + ..onCancel = _onPanCancel + ..gestureSettings = gestureSettings; + }, + ), + }, + child: widget.child, + ), ); } + + Widget _buildControlsOverlay(BuildContext context) { + return ListenableBuilder( + listenable: _overlayPortalRebuildSignal, + builder: (context, child) { + return AndroidDocumentTouchEditingControls( + editingController: _editingController, + documentKey: widget.documentKey, + documentLayout: _docLayout, + createOverlayControlsClipper: widget.createOverlayControlsClipper, + handleColor: widget.handleColor, + onHandleDragStart: _onHandleDragStart, + onHandleDragUpdate: _onHandleDragUpdate, + onHandleDragEnd: _onHandleDragEnd, + popoverToolbarBuilder: widget.popoverToolbarBuilder, + longPressMagnifierGlobalOffset: _longPressMagnifierGlobalOffset, + showDebugPaint: false, + ); + }); + } } diff --git a/super_editor/lib/src/super_reader/read_only_document_ios_touch_interactor.dart b/super_editor/lib/src/super_reader/read_only_document_ios_touch_interactor.dart index 985efa9c02..56072f678b 100644 --- a/super_editor/lib/src/super_reader/read_only_document_ios_touch_interactor.dart +++ b/super_editor/lib/src/super_reader/read_only_document_ios_touch_interactor.dart @@ -12,7 +12,7 @@ import 'package:super_editor/src/document_operations/selection_operations.dart'; import 'package:super_editor/src/infrastructure/_logging.dart'; import 'package:super_editor/src/infrastructure/document_gestures.dart'; import 'package:super_editor/src/infrastructure/document_gestures_interaction_overrides.dart'; -import 'package:super_editor/src/infrastructure/flutter/flutter_pipeline.dart'; +import 'package:super_editor/src/infrastructure/flutter/flutter_scheduler.dart'; import 'package:super_editor/src/infrastructure/multi_tap_gesture.dart'; import 'package:super_editor/src/infrastructure/platforms/ios/ios_document_controls.dart'; import 'package:super_editor/src/infrastructure/platforms/ios/long_press_selection.dart'; diff --git a/super_editor/lib/src/super_reader/read_only_document_mouse_interactor.dart b/super_editor/lib/src/super_reader/read_only_document_mouse_interactor.dart index 4e662ae9dd..97fce00654 100644 --- a/super_editor/lib/src/super_reader/read_only_document_mouse_interactor.dart +++ b/super_editor/lib/src/super_reader/read_only_document_mouse_interactor.dart @@ -10,7 +10,7 @@ import 'package:super_editor/src/default_editor/document_scrollable.dart'; import 'package:super_editor/src/document_operations/selection_operations.dart'; import 'package:super_editor/src/infrastructure/_logging.dart'; import 'package:super_editor/src/infrastructure/document_gestures_interaction_overrides.dart'; -import 'package:super_editor/src/infrastructure/flutter/flutter_pipeline.dart'; +import 'package:super_editor/src/infrastructure/flutter/flutter_scheduler.dart'; import 'package:super_editor/src/infrastructure/multi_tap_gesture.dart'; import 'reader_context.dart'; diff --git a/super_editor/lib/src/super_textfield/android/_editing_controls.dart b/super_editor/lib/src/super_textfield/android/_editing_controls.dart index deecf868e5..eaee34bcbe 100644 --- a/super_editor/lib/src/super_textfield/android/_editing_controls.dart +++ b/super_editor/lib/src/super_textfield/android/_editing_controls.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'dart:math'; import 'package:flutter/material.dart'; -import 'package:super_editor/src/infrastructure/flutter/flutter_pipeline.dart'; +import 'package:super_editor/src/infrastructure/flutter/flutter_scheduler.dart'; import 'package:super_editor/src/infrastructure/multi_listenable_builder.dart'; import 'package:super_editor/src/infrastructure/_logging.dart'; import 'package:super_editor/src/infrastructure/platforms/android/magnifier.dart'; diff --git a/super_editor/lib/src/super_textfield/android/_user_interaction.dart b/super_editor/lib/src/super_textfield/android/_user_interaction.dart index 91fe67a852..5e616f64b5 100644 --- a/super_editor/lib/src/super_textfield/android/_user_interaction.dart +++ b/super_editor/lib/src/super_textfield/android/_user_interaction.dart @@ -1,7 +1,7 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:super_editor/src/infrastructure/_logging.dart'; -import 'package:super_editor/src/infrastructure/flutter/flutter_pipeline.dart'; +import 'package:super_editor/src/infrastructure/flutter/flutter_scheduler.dart'; import 'package:super_editor/src/infrastructure/multi_tap_gesture.dart'; import 'package:super_editor/src/super_textfield/super_textfield.dart'; import 'package:super_text_layout/super_text_layout.dart'; diff --git a/super_editor/lib/src/super_textfield/android/android_textfield.dart b/super_editor/lib/src/super_textfield/android/android_textfield.dart index 73cb9d75fa..d1e90c2c14 100644 --- a/super_editor/lib/src/super_textfield/android/android_textfield.dart +++ b/super_editor/lib/src/super_textfield/android/android_textfield.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:super_editor/src/infrastructure/attributed_text_styles.dart'; -import 'package:super_editor/src/infrastructure/flutter/flutter_pipeline.dart'; +import 'package:super_editor/src/infrastructure/flutter/flutter_scheduler.dart'; import 'package:super_editor/src/infrastructure/focus.dart'; import 'package:super_editor/src/infrastructure/ime_input_owner.dart'; import 'package:super_editor/src/infrastructure/platforms/android/toolbar.dart'; diff --git a/super_editor/lib/src/super_textfield/desktop/desktop_textfield.dart b/super_editor/lib/src/super_textfield/desktop/desktop_textfield.dart index 50453b7e68..ea4c8daa4a 100644 --- a/super_editor/lib/src/super_textfield/desktop/desktop_textfield.dart +++ b/super_editor/lib/src/super_textfield/desktop/desktop_textfield.dart @@ -9,7 +9,7 @@ import 'package:flutter/services.dart'; import 'package:super_editor/src/core/document_layout.dart'; import 'package:super_editor/src/infrastructure/_logging.dart'; import 'package:super_editor/src/infrastructure/attributed_text_styles.dart'; -import 'package:super_editor/src/infrastructure/flutter/flutter_pipeline.dart'; +import 'package:super_editor/src/infrastructure/flutter/flutter_scheduler.dart'; import 'package:super_editor/src/infrastructure/focus.dart'; import 'package:super_editor/src/infrastructure/ime_input_owner.dart'; import 'package:super_editor/src/infrastructure/keyboard.dart'; diff --git a/super_editor/lib/src/super_textfield/infrastructure/text_scrollview.dart b/super_editor/lib/src/super_textfield/infrastructure/text_scrollview.dart index 3e539aff50..6960b89b50 100644 --- a/super_editor/lib/src/super_textfield/infrastructure/text_scrollview.dart +++ b/super_editor/lib/src/super_textfield/infrastructure/text_scrollview.dart @@ -4,7 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/scheduler.dart'; import 'package:super_editor/src/infrastructure/_logging.dart'; -import 'package:super_editor/src/infrastructure/flutter/flutter_pipeline.dart'; +import 'package:super_editor/src/infrastructure/flutter/flutter_scheduler.dart'; import 'package:super_text_layout/super_text_layout.dart'; import 'package:super_editor/src/super_textfield/super_textfield.dart'; diff --git a/super_editor/lib/src/super_textfield/ios/_editing_controls.dart b/super_editor/lib/src/super_textfield/ios/_editing_controls.dart index a4d5b5c579..901a194cc0 100644 --- a/super_editor/lib/src/super_textfield/ios/_editing_controls.dart +++ b/super_editor/lib/src/super_textfield/ios/_editing_controls.dart @@ -1,7 +1,7 @@ import 'dart:math'; import 'package:flutter/material.dart'; -import 'package:super_editor/src/infrastructure/flutter/flutter_pipeline.dart'; +import 'package:super_editor/src/infrastructure/flutter/flutter_scheduler.dart'; import 'package:super_editor/src/infrastructure/multi_listenable_builder.dart'; import 'package:super_editor/src/infrastructure/_logging.dart'; import 'package:super_editor/src/infrastructure/platforms/ios/selection_handles.dart'; diff --git a/super_editor/lib/src/super_textfield/ios/_user_interaction.dart b/super_editor/lib/src/super_textfield/ios/_user_interaction.dart index 134e6d99fd..fc2313bc2f 100644 --- a/super_editor/lib/src/super_textfield/ios/_user_interaction.dart +++ b/super_editor/lib/src/super_textfield/ios/_user_interaction.dart @@ -1,7 +1,7 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:super_editor/src/infrastructure/_logging.dart'; -import 'package:super_editor/src/infrastructure/flutter/flutter_pipeline.dart'; +import 'package:super_editor/src/infrastructure/flutter/flutter_scheduler.dart'; import 'package:super_editor/src/infrastructure/flutter/text_selection.dart'; import 'package:super_editor/src/infrastructure/multi_tap_gesture.dart'; import 'package:super_editor/src/super_textfield/super_textfield.dart'; diff --git a/super_editor/lib/src/super_textfield/ios/ios_textfield.dart b/super_editor/lib/src/super_textfield/ios/ios_textfield.dart index da98222741..6dfe089708 100644 --- a/super_editor/lib/src/super_textfield/ios/ios_textfield.dart +++ b/super_editor/lib/src/super_textfield/ios/ios_textfield.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:super_editor/src/infrastructure/_logging.dart'; import 'package:super_editor/src/infrastructure/attributed_text_styles.dart'; -import 'package:super_editor/src/infrastructure/flutter/flutter_pipeline.dart'; +import 'package:super_editor/src/infrastructure/flutter/flutter_scheduler.dart'; import 'package:super_editor/src/infrastructure/focus.dart'; import 'package:super_editor/src/infrastructure/ime_input_owner.dart'; import 'package:super_editor/src/infrastructure/platforms/ios/toolbar.dart'; diff --git a/super_editor/lib/super_editor.dart b/super_editor/lib/super_editor.dart index c50d5d8e03..f508d7e0bd 100644 --- a/super_editor/lib/super_editor.dart +++ b/super_editor/lib/super_editor.dart @@ -65,6 +65,8 @@ export 'src/infrastructure/ime_input_owner.dart'; export 'src/infrastructure/keyboard.dart'; export 'src/infrastructure/multi_tap_gesture.dart'; export 'src/infrastructure/pausable_value_notifier.dart'; +export 'src/infrastructure/flutter/overlay_with_groups.dart'; +export 'src/infrastructure/flutter/text_selection.dart'; export 'src/infrastructure/platforms/android/android_document_controls.dart'; export 'src/infrastructure/platforms/android/toolbar.dart'; export 'src/infrastructure/platforms/ios/ios_document_controls.dart';