diff --git a/super_editor/example/lib/demos/editor_configs/demo_mobile_editing_ios.dart b/super_editor/example/lib/demos/editor_configs/demo_mobile_editing_ios.dart index 76a14491d3..6cecb55da6 100644 --- a/super_editor/example/lib/demos/editor_configs/demo_mobile_editing_ios.dart +++ b/super_editor/example/lib/demos/editor_configs/demo_mobile_editing_ios.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:example/demos/editor_configs/keyboard_overlay_clipper.dart'; import 'package:flutter/material.dart'; +import 'package:follow_the_leader/follow_the_leader.dart'; import 'package:super_editor/super_editor.dart'; /// Mobile iOS document editing demo. @@ -21,10 +22,15 @@ class _MobileEditingIOSDemoState extends State { late MutableDocumentComposer _composer; late Editor _docEditor; late CommonEditorOperations _docOps; - late MagnifierAndToolbarController _overlayController; FocusNode? _editorFocusNode; + final _selectionLayerLinks = SelectionLayerLinks(); + + // TODO: get rid of overlay controller once Android is refactored to use a control scope (as follow up to: https://github.com/superlistapp/super_editor/pull/1470) + late MagnifierAndToolbarController _overlayController; + late final SuperEditorIosControlsController _iosEditorControlsController; + @override void initState() { super.initState(); @@ -38,11 +44,19 @@ class _MobileEditingIOSDemoState extends State { documentLayoutResolver: () => _docLayoutKey.currentState as DocumentLayout, ); _editorFocusNode = FocusNode(); + + // TODO: get rid of the overlay controller _overlayController = MagnifierAndToolbarController(); + _iosEditorControlsController = SuperEditorIosControlsController( + toolbarBuilder: _buildIosToolbar, + magnifierBuilder: _buildIosMagnifier, + ); } @override void dispose() { + _iosEditorControlsController.dispose(); + _editorFocusNode!.dispose(); _composer.dispose(); _doc.removeListener(_onDocumentChange); @@ -53,17 +67,23 @@ class _MobileEditingIOSDemoState extends State { void _cut() { _docOps.cut(); + // TODO: get rid of overlay controller once Android is refactored to use a control scope (as follow up to: https://github.com/superlistapp/super_editor/pull/1470) _overlayController.hideToolbar(); + _iosEditorControlsController.hideToolbar(); } void _copy() { _docOps.copy(); + // TODO: get rid of overlay controller once Android is refactored to use a control scope (as follow up to: https://github.com/superlistapp/super_editor/pull/1470) _overlayController.hideToolbar(); + _iosEditorControlsController.hideToolbar(); } void _paste() { _docOps.paste(); + // TODO: get rid of overlay controller once Android is refactored to use a control scope (as follow up to: https://github.com/superlistapp/super_editor/pull/1470) _overlayController.hideToolbar(); + _iosEditorControlsController.hideToolbar(); } @override @@ -72,25 +92,23 @@ class _MobileEditingIOSDemoState extends State { child: Column( children: [ Expanded( - child: SuperEditor( - focusNode: _editorFocusNode, - documentLayoutKey: _docLayoutKey, - editor: _docEditor, - document: _doc, - composer: _composer, - overlayController: _overlayController, - gestureMode: DocumentGestureMode.iOS, - inputSource: TextInputSource.ime, - iOSToolbarBuilder: (_) => IOSTextEditingFloatingToolbar( - onCutPressed: _cut, - onCopyPressed: _copy, - onPastePressed: _paste, - focalPoint: _overlayController.toolbarTopAnchor!, - ), - stylesheet: defaultStylesheet.copyWith( - documentPadding: const EdgeInsets.all(16), + child: SuperEditorIosControlsScope( + controller: _iosEditorControlsController, + child: SuperEditor( + focusNode: _editorFocusNode, + documentLayoutKey: _docLayoutKey, + editor: _docEditor, + document: _doc, + composer: _composer, + gestureMode: DocumentGestureMode.iOS, + inputSource: TextInputSource.ime, + selectionLayerLinks: _selectionLayerLinks, + stylesheet: defaultStylesheet.copyWith( + documentPadding: const EdgeInsets.all(16), + ), + overlayController: _overlayController, + createOverlayControlsClipper: (_) => const KeyboardToolbarClipper(), ), - createOverlayControlsClipper: (_) => const KeyboardToolbarClipper(), ), ), MultiListenableBuilder( @@ -105,6 +123,26 @@ class _MobileEditingIOSDemoState extends State { ); } + Widget _buildIosToolbar(BuildContext context, Key mobileToolbarKey, LeaderLink focalPoint) { + return IOSTextEditingFloatingToolbar( + key: mobileToolbarKey, + focalPoint: focalPoint, + onCutPressed: _cut, + onCopyPressed: _copy, + onPastePressed: _paste, + ); + } + + Widget _buildIosMagnifier(BuildContext context, Key magnifierKey, LeaderLink focalPoint) { + return Center( + child: IOSFollowingMagnifier.roundedRectangle( + magnifierKey: magnifierKey, + leaderLink: focalPoint, + offsetFromFocalPoint: const Offset(0, -72), + ), + ); + } + Widget _buildMountedToolbar() { final selection = _composer.selection; 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 8a48a08df4..0825609e2b 100644 --- a/super_editor/example/lib/demos/example_editor/example_editor.dart +++ b/super_editor/example/lib/demos/example_editor/example_editor.dart @@ -43,9 +43,12 @@ class _ExampleEditorState extends State { final _imageFormatBarOverlayController = OverlayPortalController(); final _imageSelectionAnchor = ValueNotifier(null); + // TODO: get rid of overlay controller once Android is refactored to use a control scope (as follow up to: https://github.com/superlistapp/super_editor/pull/1470) final _overlayController = MagnifierAndToolbarController() // ..screenPadding = const EdgeInsets.all(20.0); + late final SuperEditorIosControlsController _iosControlsController; + @override void initState() { super.initState(); @@ -61,10 +64,13 @@ class _ExampleEditorState extends State { ); _editorFocusNode = FocusNode(); _scrollController = ScrollController()..addListener(_hideOrShowToolbar); + + _iosControlsController = SuperEditorIosControlsController(); } @override void dispose() { + _iosControlsController.dispose(); _scrollController.dispose(); _editorFocusNode.dispose(); _composer.dispose(); @@ -199,17 +205,23 @@ class _ExampleEditorState extends State { void _cut() { _docOps.cut(); + // TODO: get rid of overlay controller once Android is refactored to use a control scope (as follow up to: https://github.com/superlistapp/super_editor/pull/1470) _overlayController.hideToolbar(); + _iosControlsController.hideToolbar(); } void _copy() { _docOps.copy(); + // TODO: get rid of overlay controller once Android is refactored to use a control scope (as follow up to: https://github.com/superlistapp/super_editor/pull/1470) _overlayController.hideToolbar(); + _iosControlsController.hideToolbar(); } void _paste() { _docOps.paste(); + // TODO: get rid of overlay controller once Android is refactored to use a control scope (as follow up to: https://github.com/superlistapp/super_editor/pull/1470) _overlayController.hideToolbar(); + _iosControlsController.hideToolbar(); } void _selectAll() => _docOps.selectAll(); @@ -277,7 +289,16 @@ class _ExampleEditorState extends State { ), Align( alignment: Alignment.bottomRight, - child: _buildCornerFabs(), + child: ListenableBuilder( + listenable: _composer.selectionNotifier, + builder: (context, child) { + return Padding( + padding: EdgeInsets.only(bottom: _isMobile && _composer.selection != null ? 48 : 0), + child: child, + ); + }, + child: _buildCornerFabs(), + ), ), ], ), @@ -351,72 +372,67 @@ class _ExampleEditorState extends State { config: _debugConfig ?? const SuperEditorDebugVisualsConfig(), child: KeyedSubtree( key: _viewportKey, - child: SuperEditor( - editor: _docEditor, - document: _doc, - composer: _composer, - focusNode: _editorFocusNode, - scrollController: _scrollController, - documentLayoutKey: _docLayoutKey, - documentOverlayBuilders: [ - DefaultCaretOverlayBuilder( - caretStyle: const CaretStyle().copyWith(color: isLight ? Colors.black : Colors.redAccent), + child: SuperEditorIosControlsScope( + controller: _iosControlsController, + child: SuperEditor( + editor: _docEditor, + document: _doc, + composer: _composer, + focusNode: _editorFocusNode, + scrollController: _scrollController, + documentLayoutKey: _docLayoutKey, + documentOverlayBuilders: [ + DefaultCaretOverlayBuilder( + caretStyle: const CaretStyle().copyWith(color: isLight ? Colors.black : Colors.redAccent), + ), + SuperEditorIosToolbarFocalPointDocumentLayerBuilder(), + SuperEditorIosHandlesDocumentLayerBuilder(), + ], + selectionLayerLinks: _selectionLayerLinks, + selectionStyle: isLight + ? defaultSelectionStyle + : SelectionStyles( + selectionColor: Colors.red.withOpacity(0.3), + ), + stylesheet: defaultStylesheet.copyWith( + addRulesAfter: [ + if (!isLight) ..._darkModeStyles, + taskStyles, + ], ), - ], - selectionLayerLinks: _selectionLayerLinks, - selectionStyle: isLight - ? defaultSelectionStyle - : SelectionStyles( - selectionColor: Colors.red.withOpacity(0.3), - ), - stylesheet: defaultStylesheet.copyWith( - addRulesAfter: [ - if (!isLight) ..._darkModeStyles, - taskStyles, + componentBuilders: [ + TaskComponentBuilder(_docEditor), + ...defaultComponentBuilders, ], + gestureMode: _gestureMode, + inputSource: _inputSource, + keyboardActions: _inputSource == TextInputSource.ime ? defaultImeKeyboardActions : defaultKeyboardActions, + androidToolbarBuilder: (_) => _buildAndroidFloatingToolbar(), + overlayController: _overlayController, ), - componentBuilders: [ - TaskComponentBuilder(_docEditor), - ...defaultComponentBuilders, - ], - gestureMode: _gestureMode, - inputSource: _inputSource, - keyboardActions: _inputSource == TextInputSource.ime ? defaultImeKeyboardActions : defaultKeyboardActions, - androidToolbarBuilder: (_) => ListenableBuilder( - listenable: _brightness, - builder: (context, _) { - return Theme( - data: ThemeData(brightness: _brightness.value), - child: AndroidTextEditingFloatingToolbar( - onCutPressed: _cut, - onCopyPressed: _copy, - onPastePressed: _paste, - onSelectAllPressed: _selectAll, - ), - ); - }, - ), - iOSToolbarBuilder: (_) => ListenableBuilder( - listenable: _brightness, - builder: (context, _) { - return Theme( - data: ThemeData(brightness: _brightness.value), - child: IOSTextEditingFloatingToolbar( - onCutPressed: _cut, - onCopyPressed: _copy, - onPastePressed: _paste, - focalPoint: _overlayController.toolbarTopAnchor!, - ), - ); - }, - ), - overlayController: _overlayController, ), ), ), ); } + Widget _buildAndroidFloatingToolbar() { + return ListenableBuilder( + listenable: _brightness, + builder: (context, _) { + return Theme( + data: ThemeData(brightness: _brightness.value), + child: AndroidTextEditingFloatingToolbar( + onCutPressed: _cut, + onCopyPressed: _copy, + onPastePressed: _paste, + onSelectAllPressed: _selectAll, + ), + ); + }, + ); + } + Widget _buildMountedToolbar() { return MultiListenableBuilder( listenables: { diff --git a/super_editor/example/lib/demos/interaction_spot_checks/spot_check_scaffold.dart b/super_editor/example/lib/demos/interaction_spot_checks/spot_check_scaffold.dart new file mode 100644 index 0000000000..28f528c6ed --- /dev/null +++ b/super_editor/example/lib/demos/interaction_spot_checks/spot_check_scaffold.dart @@ -0,0 +1,133 @@ +import 'package:flutter/material.dart'; +import 'package:super_editor/super_editor.dart'; + +/// A scaffold to be used by all spot check demos +class SpotCheckScaffold extends StatelessWidget { + const SpotCheckScaffold({ + super.key, + required this.content, + this.supplemental, + this.overlay, + }); + + /// Primary demo content. + final Widget content; + + /// An (optional) supplemental control panel for the demo. + final Widget? supplemental; + + /// An (optional) widget that's displayed on top of all content in this scaffold. + final Widget? overlay; + + @override + Widget build(BuildContext context) { + return Theme( + data: ThemeData.dark(), + child: Builder( + builder: (context) { + return Scaffold( + backgroundColor: const Color(0xFF222222), + body: Stack( + children: [ + Positioned.fill( + child: Row( + children: [ + Expanded( + child: content, + ), + if (supplemental != null) // + _buildSupplementalPanel(), + ], + ), + ), + if (overlay != null) // + Positioned.fill( + child: overlay!, + ), + ], + ), + ); + }, + ), + ); + } + + Widget _buildSupplementalPanel() { + return Container( + width: 250, + height: double.infinity, + decoration: BoxDecoration( + border: Border(left: BorderSide(color: Colors.white.withOpacity(0.1))), + ), + child: Stack( + children: [ + Center( + child: Icon( + Icons.biotech, + color: Colors.white.withOpacity(0.05), + size: 84, + ), + ), + Positioned.fill( + child: Center( + child: SizedBox( + width: double.infinity, + child: SingleChildScrollView( + child: supplemental!, + ), + ), + ), + ), + ], + ), + ); + } +} + +// Makes text light, for use during dark mode styling. +final darkModeStyles = [ + StyleRule( + BlockSelector.all, + (doc, docNode) { + return { + "textStyle": const TextStyle( + color: Color(0xFFCCCCCC), + fontSize: 32, + ), + }; + }, + ), + StyleRule( + const BlockSelector("header1"), + (doc, docNode) { + return { + "textStyle": const TextStyle( + color: Color(0xFF888888), + fontSize: 48, + ), + }; + }, + ), + StyleRule( + const BlockSelector("header2"), + (doc, docNode) { + return { + "textStyle": const TextStyle( + color: Color(0xFF888888), + fontSize: 42, + ), + }; + }, + ), + StyleRule( + const BlockSelector("header3"), + (doc, docNode) { + return { + "textStyle": const TextStyle( + color: Color(0xFF888888), + fontSize: 36, + ), + }; + }, + ), +]; diff --git a/super_editor/example/lib/demos/interaction_spot_checks/toolbar_following_content_in_layer.dart b/super_editor/example/lib/demos/interaction_spot_checks/toolbar_following_content_in_layer.dart new file mode 100644 index 0000000000..9044b19cdd --- /dev/null +++ b/super_editor/example/lib/demos/interaction_spot_checks/toolbar_following_content_in_layer.dart @@ -0,0 +1,207 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:follow_the_leader/follow_the_leader.dart'; +import 'package:overlord/follow_the_leader.dart'; +import 'package:overlord/overlord.dart'; +import 'package:super_editor/super_editor.dart'; + +import 'spot_check_scaffold.dart'; + +class ToolbarFollowingContentInLayer extends StatefulWidget { + const ToolbarFollowingContentInLayer({super.key}); + + @override + State createState() => _ToolbarFollowingContentInLayerState(); +} + +class _ToolbarFollowingContentInLayerState extends State { + final _leaderLink = LeaderLink(); + final _viewportKey = GlobalKey(); + final _leaderBoundsKey = GlobalKey(); + + final _baseContentWidth = 10.0; + final _expansionExtent = ValueNotifier(0); + + OverlayState? _ancestorOverlay; + late final OverlayEntry _toolbarEntry; + + @override + void initState() { + super.initState(); + + _toolbarEntry = OverlayEntry(builder: (_) { + return _buildToolbarOverlay(); + }); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + // Any time our dependencies change, our ancestor Overlay may have changed. + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + final newOverlay = Overlay.of(context); + if (newOverlay == _ancestorOverlay) { + // Overlay didn't change. Nothing to do. + return; + } + + if (_ancestorOverlay != null) { + _toolbarEntry.remove(); + } + + _ancestorOverlay = newOverlay; + newOverlay.insert(_toolbarEntry); + }); + } + + @override + void dispose() { + _toolbarEntry.remove(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SpotCheckScaffold( + content: KeyedSubtree( + key: _viewportKey, + child: ContentLayers( + overlays: [ + (_) => LeaderLayoutLayer( + leaderLink: _leaderLink, + leaderBoundsKey: _leaderBoundsKey, + ), + ], + content: (_) => Center( + child: Column( + children: [ + const Spacer(), + ValueListenableBuilder( + valueListenable: _expansionExtent, + builder: (context, expansionExtent, _) { + return Container( + height: 12, + width: _baseContentWidth + (2 * expansionExtent) + 2, // +2 for border + decoration: BoxDecoration( + border: Border.all(color: Colors.white.withOpacity(0.1)), + ), + child: Align( + alignment: Alignment.centerLeft, + child: Container( + key: _leaderBoundsKey, + width: _baseContentWidth + expansionExtent, + height: 10, + color: Colors.white.withOpacity(0.2), + ), + ), + ); + }, + ), + const SizedBox(height: 96), + TextButton( + onPressed: () { + _expansionExtent.value = Random().nextDouble() * 200; + }, + child: Text("Change Size"), + ), + const Spacer(), + ], + ), + ), + ), + ), + ); + } + + Widget _buildToolbarOverlay() { + return FollowerFadeOutBeyondBoundary( + link: _leaderLink, + boundary: WidgetFollowerBoundary( + boundaryKey: _viewportKey, + devicePixelRatio: MediaQuery.devicePixelRatioOf(context), + ), + child: Follower.withAligner( + link: _leaderLink, + aligner: CupertinoPopoverToolbarAligner(_viewportKey), + child: CupertinoPopoverToolbar( + focalPoint: LeaderMenuFocalPoint(link: _leaderLink), + children: [ + CupertinoPopoverToolbarMenuItem( + label: 'Cut', + onPressed: () { + print("Pressed 'Cut'"); + }, + ), + CupertinoPopoverToolbarMenuItem( + label: 'Copy', + onPressed: () { + print("Pressed 'Copy'"); + }, + ), + CupertinoPopoverToolbarMenuItem( + label: 'Paste', + onPressed: () { + print("Pressed 'Paste'"); + }, + ), + ], + ), + ), + ); + } +} + +class LeaderLayoutLayer extends ContentLayerStatefulWidget { + const LeaderLayoutLayer({ + super.key, + required this.leaderLink, + required this.leaderBoundsKey, + }); + + final LeaderLink leaderLink; + final GlobalKey leaderBoundsKey; + + @override + ContentLayerState createState() => LeaderLayoutLayerState(); +} + +class LeaderLayoutLayerState extends ContentLayerState { + @override + Rect? computeLayoutData(Element? contentElement, RenderObject? contentLayout) { + final boundsBox = widget.leaderBoundsKey.currentContext?.findRenderObject() as RenderBox?; + if (boundsBox == null) { + return null; + } + + return Rect.fromLTWH(0, 0, boundsBox.size.width, boundsBox.size.height); + } + + @override + Widget doBuild(BuildContext context, Rect? layoutData) { + if (layoutData == null) { + return const SizedBox(); + } + + return Center( + child: SizedBox( + width: layoutData.size.width * 2, + height: layoutData.size.height, + child: Align( + alignment: Alignment.centerLeft, + child: Leader( + link: widget.leaderLink, + child: SizedBox.fromSize( + size: layoutData.size, + child: ColoredBox( + color: Colors.red, + ), + ), + ), + ), + ), + ); + } +} diff --git a/super_editor/example/lib/demos/super_reader/demo_super_reader.dart b/super_editor/example/lib/demos/super_reader/demo_super_reader.dart index 83792d4d5b..ca510a086a 100644 --- a/super_editor/example/lib/demos/super_reader/demo_super_reader.dart +++ b/super_editor/example/lib/demos/super_reader/demo_super_reader.dart @@ -14,13 +14,24 @@ class SuperReaderDemo extends StatefulWidget { class _SuperReaderDemoState extends State { late final Document _document; final _selection = ValueNotifier(null); + final _selectionLayerLinks = SelectionLayerLinks(); late MagnifierAndToolbarController _overlayController; + late final SuperReaderIosControlsController _iosReaderControlsController; @override void initState() { super.initState(); _document = createInitialDocument(); _overlayController = MagnifierAndToolbarController(); + _iosReaderControlsController = SuperReaderIosControlsController( + toolbarBuilder: _buildToolbar, + ); + } + + @override + void dispose() { + _iosReaderControlsController.dispose(); + super.dispose(); } void _copy() { @@ -117,18 +128,26 @@ class _SuperReaderDemoState extends State { @override Widget build(BuildContext context) { - return SuperReader( - document: _document, - selection: _selection, - overlayController: _overlayController, - androidToolbarBuilder: (_) => AndroidTextEditingFloatingToolbar( - onCopyPressed: _copy, - onSelectAllPressed: _selectAll, - ), - iOSToolbarBuilder: (_) => IOSTextEditingFloatingToolbar( - onCopyPressed: _copy, - focalPoint: _overlayController.toolbarTopAnchor!, + return SuperReaderIosControlsScope( + controller: _iosReaderControlsController, + child: SuperReader( + document: _document, + selection: _selection, + overlayController: _overlayController, + selectionLayerLinks: _selectionLayerLinks, + androidToolbarBuilder: (_) => AndroidTextEditingFloatingToolbar( + onCopyPressed: _copy, + onSelectAllPressed: _selectAll, + ), ), ); } + + Widget _buildToolbar(context, mobileToolbarKey, focalPoint) { + return IOSTextEditingFloatingToolbar( + key: mobileToolbarKey, + focalPoint: focalPoint, + onCopyPressed: _copy, + ); + } } diff --git a/super_editor/example/lib/main.dart b/super_editor/example/lib/main.dart index a6053be507..08aa449041 100644 --- a/super_editor/example/lib/main.dart +++ b/super_editor/example/lib/main.dart @@ -18,6 +18,7 @@ import 'package:example/demos/flutter_features/demo_inline_widgets.dart'; import 'package:example/demos/flutter_features/textinputclient/basic_text_input_client.dart'; import 'package:example/demos/flutter_features/textinputclient/textfield.dart'; import 'package:example/demos/in_the_lab/selected_text_colors_demo.dart'; +import 'package:example/demos/interaction_spot_checks/toolbar_following_content_in_layer.dart'; import 'package:example/demos/scrolling/demo_task_and_chat_with_customscrollview.dart'; import 'package:example/demos/sliver_example_editor.dart'; import 'package:example/demos/styles/demo_doc_styles.dart'; @@ -360,6 +361,18 @@ final _menu = <_MenuGroup>[ ), ], ), + _MenuGroup( + title: 'Spot Checks', + items: [ + _MenuItem( + icon: Icons.layers, + title: 'Toolbar Following Content Layer', + pageBuilder: (context) { + return ToolbarFollowingContentInLayer(); + }, + ), + ], + ), _MenuGroup( title: 'SCROLLING', items: [ diff --git a/super_editor/example/lib/main_super_editor.dart b/super_editor/example/lib/main_super_editor.dart index 8156040cd7..2a391ada27 100644 --- a/super_editor/example/lib/main_super_editor.dart +++ b/super_editor/example/lib/main_super_editor.dart @@ -1,5 +1,7 @@ import 'package:example/demos/example_editor/example_editor.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:logging/logging.dart'; import 'package:super_editor/super_editor.dart'; @@ -14,6 +16,7 @@ void main() { // longPressSelectionLog, // editorImeLog, // editorImeDeltasLog, + // editorIosFloatingCursorLog, // editorKeyLog, // editorOpsLog, // editorLayoutLog, @@ -29,6 +32,17 @@ void main() { home: Scaffold( body: ExampleEditor(), ), + supportedLocales: const [ + Locale('en', ''), + Locale('es', ''), + ], + localizationsDelegates: const [ + ...AppLocalizations.localizationsDelegates, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + debugShowCheckedModeBanner: false, ), ); } diff --git a/super_editor/example/pubspec.yaml b/super_editor/example/pubspec.yaml index 5ba8c16d49..c438038054 100644 --- a/super_editor/example/pubspec.yaml +++ b/super_editor/example/pubspec.yaml @@ -53,8 +53,8 @@ dependencies: git: url: https://github.com/superlistapp/super_editor.git path: super_text_layout - follow_the_leader: ^0.0.4+2 - overlord: ^0.0.3+2 + follow_the_leader: ^0.0.4+5 + overlord: ^0.0.3+4 dependency_overrides: # Override to local mono-repo path so devs can test this repo diff --git a/super_editor/lib/src/core/document.dart b/super_editor/lib/src/core/document.dart index 06bf8cc078..e4382a50a5 100644 --- a/super_editor/lib/src/core/document.dart +++ b/super_editor/lib/src/core/document.dart @@ -400,11 +400,17 @@ abstract class NodeSelection { // marker interface } -/// Marker interface for all node positions. -/// -/// A node position is a logical position within a [DocumentNode], -/// e.g., a [TextNodePosition] within a [ParagraphNode], or a [BinaryNodePosition] -/// within an [ImageNode]. +/// A logical position within a [DocumentNode], e.g., a [TextNodePosition] +/// within a [ParagraphNode], or a [BinaryNodePosition] within an [ImageNode]. abstract class NodePosition { - // marker interface + /// Whether this [NodePosition] is equivalent to the [other] [NodePosition]. + /// + /// Typically, [isEquivalentTo] should return the same value as [==], however, + /// some [NodePosition]s have properties that don't impact equivalency. For + /// example, a [TextNodePosition] has a concept of affinity (upstream/downstream), + /// which are used when making particular selection decisions, but affinity + /// doesn't impact equivalency. Two [TextNodePosition]s, which refer to the same + /// text offset, but have different affinities, returns `true` from [isEquivalentTo], + /// even though [==] returns `false`. + bool isEquivalentTo(NodePosition other); } diff --git a/super_editor/lib/src/core/document_selection.dart b/super_editor/lib/src/core/document_selection.dart index 36ebc6a0df..361f629a6d 100644 --- a/super_editor/lib/src/core/document_selection.dart +++ b/super_editor/lib/src/core/document_selection.dart @@ -63,9 +63,8 @@ class DocumentSelection extends DocumentRange { /// selection is expanded. /// /// A [DocumentSelection] is "collapsed" when its [base] and [extent] are - /// equal ([DocumentPosition.==]). Otherwise, the [DocumentSelection] is - /// "expanded". - bool get isCollapsed => base == extent; + /// equivalent. Otherwise, the [DocumentSelection] is "expanded". + bool get isCollapsed => base.nodeId == extent.nodeId && base.nodePosition.isEquivalentTo(extent.nodePosition); @override String toString() { 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 0385536e9e..da8cb71053 100644 --- a/super_editor/lib/src/default_editor/document_caret_overlay.dart +++ b/super_editor/lib/src/default_editor/document_caret_overlay.dart @@ -3,11 +3,11 @@ import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:super_editor/src/core/document_composer.dart'; import 'package:super_editor/src/core/document_layout.dart'; -import 'package:super_editor/src/infrastructure/content_layers.dart'; +import 'package:super_editor/src/infrastructure/documents/document_layers.dart'; import 'package:super_text_layout/super_text_layout.dart'; /// Document overlay that paints a caret with the given [caretStyle]. -class CaretDocumentOverlay extends ContentLayerStatefulWidget { +class CaretDocumentOverlay extends DocumentLayoutLayerStatefulWidget { const CaretDocumentOverlay({ Key? key, required this.composer, @@ -45,10 +45,10 @@ class CaretDocumentOverlay extends ContentLayerStatefulWidget { final BlinkTimingMode blinkTimingMode; @override - ContentLayerState createState() => _CaretDocumentOverlayState(); + DocumentLayoutLayerState createState() => _CaretDocumentOverlayState(); } -class _CaretDocumentOverlayState extends ContentLayerState +class _CaretDocumentOverlayState extends DocumentLayoutLayerState with SingleTickerProviderStateMixin { late final BlinkController _blinkController; @@ -110,7 +110,7 @@ class _CaretDocumentOverlayState extends ContentLayerState + context.dependOnInheritedWidgetOfExactType()!.controller; + + static SuperEditorIosControlsController? maybeNearestOf(BuildContext context) => + context.dependOnInheritedWidgetOfExactType()?.controller; + + const SuperEditorIosControlsScope({ + super.key, + required this.controller, + required super.child, + }); + + final SuperEditorIosControlsController controller; + + @override + bool updateShouldNotify(SuperEditorIosControlsScope oldWidget) { + return controller != oldWidget.controller; + } +} + +/// A controller, which coordinates the state of various iOS editor controls, including +/// the caret, handles, floating cursor, magnifier, and toolbar. +class SuperEditorIosControlsController { + SuperEditorIosControlsController({ + this.handleColor, + FloatingCursorController? floatingCursorController, + this.magnifierBuilder, + this.toolbarBuilder, + this.createOverlayControlsClipper, + }) : floatingCursorController = floatingCursorController ?? FloatingCursorController(); + + void dispose() { + floatingCursorController.dispose(); + _shouldCaretBlink.dispose(); + _shouldShowMagnifier.dispose(); + _shouldShowToolbar.dispose(); + } + + /// Color of the text selection drag handles on iOS. + final Color? handleColor; + + /// Whether the caret (collapsed handle) should blink right now. + ValueListenable get shouldCaretBlink => _shouldCaretBlink; + final _shouldCaretBlink = ValueNotifier(false); + + /// Tells the caret to blink by setting [shouldCaretBlink] to `true`. + void blinkCaret() => _shouldCaretBlink.value = true; + + /// Tells the caret to stop blinking by setting [shouldCaretBlink] to `false`. + void doNotBlinkCaret() => _shouldCaretBlink.value = false; + + /// Controls the iOS floating cursor. + late final FloatingCursorController floatingCursorController; + + /// Whether the iOS magnifier should be displayed right now. + ValueListenable get shouldShowMagnifier => _shouldShowMagnifier; + final _shouldShowMagnifier = ValueNotifier(false); + + /// Shows the magnifier by setting [shouldShowMagnifier] to `true`. + void showMagnifier() => _shouldShowMagnifier.value = true; + + /// Hides the magnifier by setting [shouldShowMagnifier] to `false`. + void hideMagnifier() => _shouldShowMagnifier.value = false; + + /// Toggles [shouldShowMagnifier]. + void toggleMagnifier() => _shouldShowMagnifier.value = !_shouldShowMagnifier.value; + + /// Link to a location where a magnifier should be focused. + final magnifierFocalPoint = LeaderLink(); + + /// (Optional) Builder to create the visual representation of the magnifier. + /// + /// If [magnifierBuilder] is `null`, a default iOS magnifier is displayed. + final DocumentMagnifierBuilder? magnifierBuilder; + + /// Whether the iOS floating toolbar should be displayed right now. + ValueListenable get shouldShowToolbar => _shouldShowToolbar; + final _shouldShowToolbar = ValueNotifier(false); + + /// Shows the toolbar by setting [shouldShowToolbar] to `true`. + void showToolbar() => _shouldShowToolbar.value = true; + + /// Hides the toolbar by setting [shouldShowToolbar] to `false`. + void hideToolbar() => _shouldShowToolbar.value = false; + + /// Toggles [shouldShowToolbar]. + void toggleToolbar() => _shouldShowToolbar.value = !_shouldShowToolbar.value; + + /// Link to a location where a toolbar should be focused. + /// + /// This link probably points to a rectangle, such as a bounding rectangle + /// around the user's selection. Therefore, the toolbar builder shouldn't + /// assume that this focal point is a single pixel. + final toolbarFocalPoint = LeaderLink(); + + /// (Optional) Builder to create the visual representation of the floating + /// toolbar. + /// + /// If [toolbarBuilder] is `null`, a default iOS toolbar is displayed. + final DocumentFloatingToolbarBuilder? toolbarBuilder; + + /// Creates a clipper that restricts where the toolbar and magnifier can + /// appear in the overlay. + /// + /// If no clipper factory method is provided, then the overlay controls + /// will be allowed to appear anywhere in the overlay in which they sit + /// (probably the entire screen). + final CustomClipper Function(BuildContext overlayContext)? createOverlayControlsClipper; +} + /// Document gesture interactor that's designed for iOS touch input, e.g., -/// drag to scroll, and handles to control selection. -class IOSDocumentTouchInteractor extends StatefulWidget { - const IOSDocumentTouchInteractor({ +/// drag to scroll, tap to place the caret, double tap to select a word, +/// triple tap to select a paragraph. +/// +/// Depends upon an ancestor [SuperEditorIosControlsScope], which coordinates the +/// state of visual iOS controls, e.g., caret, handles, magnifier, toolbar. +/// +/// [IosDocumentTouchInteractor] coordinates half of the iOS floating cursor behavior. +/// This widget handles the following: +/// * Listens for the user to start moving the floating cursor, notifies the ancestor +/// [SuperEditorIosControlsScope] that the floating cursor is active, and starts +/// managing auto-scrolling based on the floating cursor offset in the viewport. +/// * Listens for all user movements of the floating cursor, maps the floating +/// cursor offset to a document position, chooses an appropriate size for the +/// floating cursor based on the content beneath it, and then notifies the ancestor +/// [SuperEditorIosControlsScope] of the new floating cursor position and size. +/// * Listens for the user to stop using the floating cursor, notifies the ancestor +/// [SuperEditorIosControlsScope] that the floating cursor is inactive, and stops +/// managing auto-scrolling based on the floating cursor offset in the viewport. +/// +/// This widget does NOT paint a floating cursor. That responsibility is left to +/// other widgets. +class IosDocumentTouchInteractor extends StatefulWidget { + const IosDocumentTouchInteractor({ Key? key, required this.focusNode, required this.editor, required this.document, - required this.documentKey, - required this.documentLayoutLink, required this.getDocumentLayout, required this.selection, - required this.selectionLinks, required this.scrollController, this.contentTapHandler, this.dragAutoScrollBoundary = const AxisOffset.symmetric(54), - required this.handleColor, - required this.popoverToolbarBuilder, - required this.floatingCursorController, - this.createOverlayControlsClipper, this.showDebugPaint = false, - this.overlayController, this.child, }) : super(key: key); @@ -54,22 +237,15 @@ class IOSDocumentTouchInteractor extends StatefulWidget { final Editor editor; final Document document; - final GlobalKey documentKey; - final LayerLink documentLayoutLink; final DocumentLayout Function() getDocumentLayout; final ValueListenable selection; - final SelectionLayerLinks selectionLinks; - /// Optional handler that responds to taps on content, e.g., opening /// a link when the user taps on text with a link attribution. final ContentTapDelegate? contentTapHandler; final ScrollController scrollController; - /// Shows, hides, and positions a floating toolbar and magnifier. - final MagnifierAndToolbarController? overlayController; - /// The closest that the user's selection drag gesture can get to the /// document boundary before auto-scrolling. /// @@ -77,49 +253,26 @@ class IOSDocumentTouchInteractor extends StatefulWidget { /// edges. final AxisOffset dragAutoScrollBoundary; - /// Color the iOS-style text selection drag handles. - final Color handleColor; - - final WidgetBuilder popoverToolbarBuilder; - - /// Controller that reports the current offset of the iOS floating - /// cursor. - final FloatingCursorController floatingCursorController; - - /// Creates a clipper that applies to overlay controls, preventing - /// the overlay controls from appearing outside the given clipping - /// region. - /// - /// If no clipper factory method is provided, then the overlay controls - /// will be allowed to appear anywhere in the overlay in which they sit - /// (probably the entire screen). - final CustomClipper Function(BuildContext overlayContext)? createOverlayControlsClipper; - final bool showDebugPaint; final Widget? child; @override - State createState() => _IOSDocumentTouchInteractorState(); + State createState() => _IosDocumentTouchInteractorState(); } -class _IOSDocumentTouchInteractorState extends State +class _IosDocumentTouchInteractorState extends State with WidgetsBindingObserver, SingleTickerProviderStateMixin { bool _isScrolling = false; - /// Shows, hides, and positions a floating toolbar and magnifier. - late MagnifierAndToolbarController _overlayController; // The ScrollPosition attached to the _ancestorScrollable. ScrollPosition? _ancestorScrollPosition; // The actual ScrollPosition that's used for the document layout, either // the Scrollable installed by this interactor, or an ancestor Scrollable. ScrollPosition? _activeScrollPosition; - // OverlayEntry that displays editing controls, e.g., - // drag handles, magnifier, and toolbar. - OverlayEntry? _controlsOverlayEntry; - late IosDocumentGestureEditingController _editingController; - final _magnifierFocalPointLink = LayerLink(); + SuperEditorIosControlsController? _controlsController; + late FloatingCursorListener _floatingCursorListener; late DragHandleAutoScroller _handleAutoScrolling; Offset? _globalStartDragOffset; @@ -127,6 +280,7 @@ class _IOSDocumentTouchInteractorState extends State Offset? _startDragPositionOffset; double? _dragStartScrollOffset; Offset? _globalDragOffset; + final _magnifierOffset = ValueNotifier(null); Offset? _dragEndInInteractor; DragMode? _dragMode; // TODO: HandleType is the wrong type here, we need collapsed/base/extent, @@ -138,20 +292,6 @@ class _IOSDocumentTouchInteractorState extends State bool get _isLongPressInProgress => _longPressStrategy != null; IosLongPressSelectionStrategy? _longPressStrategy; - // Whether we're currently waiting to see if the user taps - // again on the document. - // - // We track this for the following reason: on iOS, there is - // no collapsed handle. Instead, the caret is the handle. This - // means that the caret must be draggable. But this creates an - // issue. If the user tries to double tap, first the user taps - // and places the caret and then the user taps again. But the - // 2nd tap gets consumed by the tappable caret, when instead the - // 2nd tap should hit the document again. To allow for double and - // triple taps on iOS, we explicitly tell the overlay controls to - // avoid handling gestures while we are `_waitingForMoreTaps`. - bool _waitingForMoreTaps = false; - @override void initState() { super.initState(); @@ -163,33 +303,14 @@ class _IOSDocumentTouchInteractorState extends State getViewportBox: () => viewportBox, ); - widget.focusNode.addListener(_onFocusChange); - if (widget.focusNode.hasFocus) { - // During Hot Reload, the gesture mode could be changed. - // If that's the case, initState is called while the Overlay is being - // built. This could crash the app. Because of that, we show the editing - // controls overlay in the next frame. - onNextFrame((_) => _showEditingControlsOverlay()); - } - _configureScrollController(); - _overlayController = widget.overlayController ?? MagnifierAndToolbarController(); - - _editingController = IosDocumentGestureEditingController( - documentLayoutLink: widget.documentLayoutLink, - selectionLinks: widget.selectionLinks, - magnifierFocalPointLink: _magnifierFocalPointLink, - overlayController: _overlayController, - ); - widget.document.addListener(_onDocumentChange); - widget.selection.addListener(_onSelectionChange); - // If we already have a selection, we need to display the caret. - if (widget.selection.value != null) { - _onSelectionChange(); - } + _floatingCursorListener = FloatingCursorListener( + onStart: _onFloatingCursorStart, + onStop: _onFloatingCursorStop, + ); WidgetsBinding.instance.addObserver(this); } @@ -198,6 +319,15 @@ class _IOSDocumentTouchInteractorState extends State void didChangeDependencies() { super.didChangeDependencies(); + if (_controlsController != null) { + _controlsController!.floatingCursorController.removeListener(_floatingCursorListener); + _controlsController!.floatingCursorController.cursorGeometryInViewport + .removeListener(_onFloatingCursorGeometryChange); + } + _controlsController = SuperEditorIosControlsScope.rootOf(context); + _controlsController!.floatingCursorController.addListener(_floatingCursorListener); + _controlsController!.floatingCursorController.cursorGeometryInViewport.addListener(_onFloatingCursorGeometryChange); + _ancestorScrollPosition = _findAncestorScrollable(context)?.position; // On the next frame, check if our active scroll position changed to a @@ -213,113 +343,54 @@ class _IOSDocumentTouchInteractorState extends State } setState(() { - _activeScrollPosition?.removeListener(_onScrollChange); - newScrollPosition.addListener(_onScrollChange); _activeScrollPosition = newScrollPosition; }); }); } @override - void didUpdateWidget(IOSDocumentTouchInteractor oldWidget) { + void didUpdateWidget(IosDocumentTouchInteractor oldWidget) { super.didUpdateWidget(oldWidget); - if (widget.focusNode != oldWidget.focusNode) { - oldWidget.focusNode.removeListener(_onFocusChange); - widget.focusNode.addListener(_onFocusChange); - } - if (widget.document != oldWidget.document) { oldWidget.document.removeListener(_onDocumentChange); widget.document.addListener(_onDocumentChange); } - if (widget.selection != oldWidget.selection) { - oldWidget.selection.removeListener(_onSelectionChange); - widget.selection.addListener(_onSelectionChange); - - // Selection has changed, we need to update the caret. - if (widget.selection.value != oldWidget.selection.value) { - _onSelectionChange(); - } - } - if (widget.scrollController != oldWidget.scrollController) { _teardownScrollController(); _configureScrollController(); } - - if (widget.overlayController != oldWidget.overlayController) { - _overlayController = widget.overlayController ?? MagnifierAndToolbarController(); - _editingController.overlayController = _overlayController; - } - } - - @override - void reassemble() { - super.reassemble(); - - if (widget.focusNode.hasFocus) { - // On Hot Reload we need to remove any visible overlay controls and then - // bring them back a frame later to avoid having the controls attempt - // to access the layout of the text. The text layout is not immediately - // available upon Hot Reload. Accessing it results in an exception. - // TODO: this was copied from Super Textfield, see if the timing - // problem exists for documents, too. - _removeEditingOverlayControls(); - - onNextFrame((_) { - // During Hot Reload, the gesture mode could be changed, - // so it's possible that we are no longer mounted after - // the post frame callback. - _showEditingControlsOverlay(); - }); - } } @override void dispose() { WidgetsBinding.instance.removeObserver(this); - widget.document.removeListener(_onDocumentChange); - widget.selection.removeListener(_onSelectionChange); + _controlsController!.floatingCursorController.removeListener(_floatingCursorListener); + _controlsController!.floatingCursorController.cursorGeometryInViewport + .removeListener(_onFloatingCursorGeometryChange); - _removeEditingOverlayControls(); + widget.document.removeListener(_onDocumentChange); _teardownScrollController(); - _activeScrollPosition?.removeListener(_onScrollChange); _handleAutoScrolling.dispose(); - widget.focusNode.removeListener(_onFocusChange); - super.dispose(); } @override void didChangeMetrics() { // The available screen dimensions may have changed, e.g., due to keyboard - // appearance/disappearance. Reflow the layout. Use a post-frame callback - // to give the rest of the UI a chance to reflow, first. + // appearance/disappearance. Ensure the extent is still visible. Use a + // post-frame callback to give the rest of the UI a chance to reflow, first. onNextFrame((_) { _ensureSelectionExtentIsVisible(); - _updateHandlesAfterSelectionOrLayoutChange(); - - setState(() { - // reflow document layout - }); }); } void _configureScrollController() { - // I added this listener directly to our ScrollController because the listener we added - // to the ScrollPosition wasn't triggering once the user makes an initial selection. I'm - // not sure why that happened. It's as if the ScrollPosition was replaced, but I don't - // know why the ScrollPosition would be replaced. In the meantime, adding this listener - // keeps the toolbar positioning logic working. - // TODO: rely solely on a ScrollPosition listener, not a ScrollController listener. - widget.scrollController.addListener(_onScrollChange); - onNextFrame((_) => scrollPosition.isScrollingNotifier.addListener(_onScrollActivityChange)); } @@ -348,84 +419,34 @@ class _IOSDocumentTouchInteractorState extends State void _ensureSelectionExtentIsVisible() { editorGesturesLog.fine("Ensuring selection extent is visible"); - final collapsedHandleOffset = _editingController.collapsedHandleOffset; - final extentHandleOffset = _editingController.downstreamHandleOffset; - if (collapsedHandleOffset == null && extentHandleOffset == null) { + final selection = widget.selection.value; + if (selection == null) { // There's no selection. We don't need to take any action. return; } - // Determines the offset of the editor in the viewport coordinate - final editorBox = widget.documentKey.currentContext!.findRenderObject() as RenderBox; - final editorInViewportOffset = viewportBox.localToGlobal(Offset.zero) - editorBox.localToGlobal(Offset.zero); - - // Determines the offset of the bottom of the handle in the viewport coordinate - late Offset handleInViewportOffset; + // Calculate the y-value of the selection extent side of the selected content so that we + // can ensure they're visible. + final selectionRectInDocumentLayout = + widget.getDocumentLayout().getRectForSelection(selection.base, selection.extent)!; + final extentOffsetInViewport = widget.document.getAffinityForSelection(selection) == TextAffinity.downstream + ? _documentOffsetToViewportOffset(selectionRectInDocumentLayout.bottomCenter) + : _documentOffsetToViewportOffset(selectionRectInDocumentLayout.topCenter); - if (collapsedHandleOffset != null) { - editorGesturesLog.fine("The selection is collapsed"); - handleInViewportOffset = collapsedHandleOffset - editorInViewportOffset; - } else { - editorGesturesLog.fine("The selection is expanded"); - handleInViewportOffset = extentHandleOffset! - editorInViewportOffset; - } - _handleAutoScrolling.ensureOffsetIsVisible(handleInViewportOffset); - } - - void _onFocusChange() { - if (widget.focusNode.hasFocus) { - // TODO: the text field only showed the editing controls if the text input - // client wasn't attached yet. Do we need a similar check here? - _showEditingControlsOverlay(); - } else { - _removeEditingOverlayControls(); - } + _handleAutoScrolling.ensureOffsetIsVisible(extentOffsetInViewport); } void _onDocumentChange(_) { - _editingController.hideToolbar(); + _controlsController!.hideToolbar(); onNextFrame((_) { // The user may have changed the type of node, e.g., paragraph to - // blockquote, which impacts the caret size and position. Reposition - // the caret on the next frame. - // TODO: find a way to only do this when something relevant changes - _updateHandlesAfterSelectionOrLayoutChange(); - + // blockquote, which impacts the caret size and position. Ensure + // the extent is still visible. _ensureSelectionExtentIsVisible(); }); } - void _onSelectionChange() { - // The selection change might correspond to new content that's not - // laid out yet. Wait until the next frame to update visuals. - onNextFrame((_) => _updateHandlesAfterSelectionOrLayoutChange()); - } - - void _updateHandlesAfterSelectionOrLayoutChange() { - final newSelection = widget.selection.value; - - if (newSelection == null) { - _editingController - ..removeCaret() - ..hideToolbar() - ..collapsedHandleOffset = null - ..upstreamHandleOffset = null - ..downstreamHandleOffset = null - ..collapsedHandleOffset = null; - } else if (newSelection.isCollapsed) { - _positionCaret(); - _positionCollapsedHandle(); - } else { - // The selection is expanded - _positionExpandedSelectionHandles(); - } - } - - void _onScrollChange() { - _positionToolbar(); - } - /// Returns the layout for the current document, which answers questions /// about the locations and sizes of visual components within the layout. DocumentLayout get _docLayout => widget.getDocumentLayout(); @@ -453,22 +474,25 @@ class _IOSDocumentTouchInteractorState extends State RenderBox get viewportBox => (_findAncestorScrollable(context)?.context.findRenderObject() ?? context.findRenderObject()) as RenderBox; + Offset _documentOffsetToViewportOffset(Offset documentOffset) { + final globalOffset = _docLayout.getGlobalOffsetFromDocumentOffset(documentOffset); + return viewportBox.globalToLocal(globalOffset); + } + + Offset _viewportOffsetToDocumentOffset(Offset viewportOffset) { + final globalOffset = viewportBox.localToGlobal(viewportOffset); + return _docLayout.getDocumentOffsetFromAncestorOffset(globalOffset); + } + RenderBox get interactorBox => context.findRenderObject() as RenderBox; /// Converts the given [interactorOffset] from the [DocumentInteractor]'s coordinate /// space to the [DocumentLayout]'s coordinate space. - Offset _interactorOffsetToDocOffset(Offset interactorOffset) { + Offset _interactorOffsetToDocumentOffset(Offset interactorOffset) { final globalOffset = (context.findRenderObject() as RenderBox).localToGlobal(interactorOffset); return _docLayout.getDocumentOffsetFromAncestorOffset(globalOffset); } - /// Converts the given [documentOffset] to an `Offset` in the interactor's - /// coordinate space. - Offset _docOffsetToInteractorOffset(Offset documentOffset) { - final globalOffset = _docLayout.getGlobalOffsetFromDocumentOffset(documentOffset); - return (context.findRenderObject() as RenderBox).globalToLocal(globalOffset); - } - /// Maps the given [interactorOffset] within the interactor's coordinate space /// to the same screen position in the viewport's coordinate space. /// @@ -477,7 +501,7 @@ class _IOSDocumentTouchInteractorState extends State /// /// When this interactor defers to an ancestor `Scrollable`, then the /// [interactorOffset] is transformed into the ancestor coordinate space. - Offset _interactorOffsetInViewport(Offset interactorOffset) { + Offset _interactorOffsetToViewportOffset(Offset interactorOffset) { // Viewport might be our box, or an ancestor box if we're inside someone // else's Scrollable. return viewportBox.globalToLocal( @@ -507,12 +531,16 @@ class _IOSDocumentTouchInteractorState extends State _globalTapDownOffset = details.globalPosition; _tapDownLongPressTimer?.cancel(); _tapDownLongPressTimer = Timer(kLongPressTimeout, _onLongPressDown); + + // Stop the caret from blinking, in case this tap down turns into a long-press drag, + // or a caret drag. + _controlsController!.doNotBlinkCaret(); } // Runs when a tap down has lasted long enough to signify a long-press. void _onLongPressDown() { final interactorOffset = interactorBox.globalToLocal(_globalTapDownOffset!); - final tapDownDocumentOffset = _interactorOffsetToDocOffset(interactorOffset); + final tapDownDocumentOffset = _interactorOffsetToDocumentOffset(interactorOffset); final tapDownDocumentPosition = _docLayout.getDocumentPositionNearestToOffset(tapDownDocumentOffset); if (tapDownDocumentPosition == null) { return; @@ -540,9 +568,10 @@ class _IOSDocumentTouchInteractorState extends State return; } - _editingController.hideToolbar(); - _editingController.showMagnifier(); - _controlsOverlayEntry?.markNeedsBuild(); + _magnifierOffset.value = _interactorOffsetToDocumentOffset(interactorBox.globalToLocal(_globalTapDownOffset!)); + _controlsController! + ..hideToolbar() + ..showMagnifier(); widget.focusNode.requestFocus(); } @@ -551,6 +580,7 @@ class _IOSDocumentTouchInteractorState extends State // Stop waiting for a long-press to start. _globalTapDownOffset = null; _tapDownLongPressTimer?.cancel(); + _controlsController!.hideMagnifier(); if (_wasScrollingOnTapDown) { // The scrollable was scrolling when the user touched down. We expect that the @@ -559,17 +589,18 @@ class _IOSDocumentTouchInteractorState extends State return; } + _controlsController!.blinkCaret(); + final selection = widget.selection.value; if (selection != null && !selection.isCollapsed && (_isOverBaseHandle(details.localPosition) || _isOverExtentHandle(details.localPosition))) { - _editingController.toggleToolbar(); - _positionToolbar(); + _controlsController!.toggleToolbar(); return; } editorGesturesLog.info("Tap down on document"); - final docOffset = _interactorOffsetToDocOffset(details.localPosition); + final docOffset = _interactorOffsetToDocumentOffset(details.localPosition); editorGesturesLog.fine(" - document offset: $docOffset"); final docPosition = _docLayout.getDocumentPositionNearestToOffset(docOffset); editorGesturesLog.fine(" - tapped document position: $docPosition"); @@ -588,26 +619,20 @@ class _IOSDocumentTouchInteractorState extends State !selection.isCollapsed && widget.document.doesSelectionContainPosition(selection, docPosition)) { // The user tapped on an expanded selection. Toggle the toolbar. - _editingController.toggleToolbar(); - _positionToolbar(); + _controlsController!.toggleToolbar(); return; } - setState(() { - _waitingForMoreTaps = true; - _controlsOverlayEntry?.markNeedsBuild(); - }); - if (docPosition != null) { final didTapOnExistingSelection = selection != null && selection.isCollapsed && selection.extent == docPosition; if (didTapOnExistingSelection) { // Toggle the toolbar display when the user taps on the collapsed caret, // or on top of an existing selection. - _editingController.toggleToolbar(); + _controlsController!.toggleToolbar(); } else { // The user tapped somewhere else in the document. Hide the toolbar. - _editingController.hideToolbar(); + _controlsController!.hideToolbar(); } final tappedComponent = _docLayout.getComponentByNodeId(docPosition.nodeId)!; @@ -632,11 +657,9 @@ class _IOSDocumentTouchInteractorState extends State widget.editor.execute([ const ClearSelectionRequest(), ]); - _editingController.hideToolbar(); + _controlsController!.hideToolbar(); } - _positionToolbar(); - widget.focusNode.requestFocus(); } @@ -649,7 +672,7 @@ class _IOSDocumentTouchInteractorState extends State } editorGesturesLog.info("Double tap down on document"); - final docOffset = _interactorOffsetToDocOffset(details.localPosition); + final docOffset = _interactorOffsetToDocumentOffset(details.localPosition); editorGesturesLog.fine(" - document offset: $docOffset"); final docPosition = _docLayout.getDocumentPositionNearestToOffset(docOffset); editorGesturesLog.fine(" - tapped document position: $docPosition"); @@ -691,10 +714,9 @@ class _IOSDocumentTouchInteractorState extends State final newSelection = widget.selection.value; if (newSelection == null || newSelection.isCollapsed) { - _editingController.hideToolbar(); + _controlsController!.hideToolbar(); } else { - _editingController.showToolbar(); - _positionToolbar(); + _controlsController!.showToolbar(); } widget.focusNode.requestFocus(); @@ -728,7 +750,7 @@ class _IOSDocumentTouchInteractorState extends State void _onTripleTapUp(TapUpDetails details) { editorGesturesLog.info("Triple down down on document"); - final docOffset = _interactorOffsetToDocOffset(details.localPosition); + final docOffset = _interactorOffsetToDocumentOffset(details.localPosition); editorGesturesLog.fine(" - document offset: $docOffset"); final docPosition = _docLayout.getDocumentPositionNearestToOffset(docOffset); editorGesturesLog.fine(" - tapped document position: $docPosition"); @@ -765,10 +787,9 @@ class _IOSDocumentTouchInteractorState extends State final selection = widget.selection.value; if (selection == null || selection.isCollapsed) { - _editingController.hideToolbar(); + _controlsController!.hideToolbar(); } else { - _editingController.showToolbar(); - _positionToolbar(); + _controlsController!.showToolbar(); } widget.focusNode.requestFocus(); @@ -809,12 +830,14 @@ class _IOSDocumentTouchInteractorState extends State return; } - _editingController.hideToolbar(); + _controlsController!.doNotBlinkCaret(); + _controlsController!.hideToolbar(); + _controlsController!.showToolbar(); _globalStartDragOffset = details.globalPosition; final interactorBox = context.findRenderObject() as RenderBox; final handleOffsetInInteractor = interactorBox.globalToLocal(details.globalPosition); - _dragStartInDoc = _interactorOffsetToDocOffset(handleOffsetInInteractor); + _dragStartInDoc = _interactorOffsetToDocumentOffset(handleOffsetInInteractor); if (_dragHandleType != null) { _startDragPositionOffset = _docLayout @@ -839,9 +862,7 @@ class _IOSDocumentTouchInteractorState extends State _handleAutoScrolling.startAutoScrollHandleMonitoring(); - scrollPosition.addListener(_updateDragSelection); - - _controlsOverlayEntry!.markNeedsBuild(); + scrollPosition.addListener(_onAutoScrollChange); } bool _isOverCollapsedHandle(Offset interactorOffset) { @@ -853,7 +874,7 @@ class _IOSDocumentTouchInteractorState extends State final extentRect = _docLayout.getRectForPosition(collapsedPosition)!; final caretRect = Rect.fromLTWH(extentRect.left - 1, extentRect.center.dy, 1, 1).inflate(24); - final docOffset = _interactorOffsetToDocOffset(interactorOffset); + final docOffset = _interactorOffsetToDocumentOffset(interactorOffset); return caretRect.contains(docOffset); } @@ -868,7 +889,7 @@ class _IOSDocumentTouchInteractorState extends State // on trying to drag the handle from various locations near the handle. final caretRect = Rect.fromLTWH(baseRect.left - 24, baseRect.top - 24, 48, baseRect.height + 48); - final docOffset = _interactorOffsetToDocOffset(interactorOffset); + final docOffset = _interactorOffsetToDocumentOffset(interactorOffset); return caretRect.contains(docOffset); } @@ -883,7 +904,7 @@ class _IOSDocumentTouchInteractorState extends State // on trying to drag the handle from various locations near the handle. final caretRect = Rect.fromLTWH(extentRect.left - 24, extentRect.top, 48, extentRect.height + 32); - final docOffset = _interactorOffsetToDocOffset(interactorOffset); + final docOffset = _interactorOffsetToDocumentOffset(interactorOffset); return caretRect.contains(docOffset); } @@ -892,14 +913,14 @@ class _IOSDocumentTouchInteractorState extends State // scroll the document. Scroll it, accordingly. if (_dragMode == null) { scrollPosition.jumpTo(scrollPosition.pixels - details.delta.dy); - _positionToolbar(); return; } _globalDragOffset = details.globalPosition; final interactorBox = context.findRenderObject() as RenderBox; + _dragEndInInteractor = interactorBox.globalToLocal(details.globalPosition); - final dragEndInViewport = _interactorOffsetInViewport(_dragEndInInteractor!); + final dragEndInViewport = _interactorOffsetToViewportOffset(_dragEndInInteractor!); if (_isLongPressInProgress) { final fingerDragDelta = _globalDragOffset! - _globalStartDragOffset!; @@ -918,9 +939,7 @@ class _IOSDocumentTouchInteractorState extends State dragEndInViewport: dragEndInViewport, ); - _editingController.showMagnifier(); - - _controlsOverlayEntry!.markNeedsBuild(); + _magnifierOffset.value = _interactorOffsetToDocumentOffset(interactorBox.globalToLocal(details.globalPosition)); } void _updateSelectionForNewDragHandleLocation() { @@ -966,6 +985,9 @@ class _IOSDocumentTouchInteractorState extends State } void _onPanEnd(DragEndDetails details) { + _magnifierOffset.value = null; + _controlsController!.hideMagnifier(); + if (_dragMode == null) { // User was dragging the scroll area. Go ballistic. if (scrollPosition is ScrollPositionWithSingleContext) { @@ -974,9 +996,7 @@ class _IOSDocumentTouchInteractorState extends State if (_activeScrollPosition != scrollPosition) { // We add the scroll change listener again, because going ballistic // seems to switch out the scroll position. - _activeScrollPosition?.removeListener(_onScrollChange); _activeScrollPosition = scrollPosition; - scrollPosition.addListener(_onScrollChange); } } } else { @@ -987,6 +1007,8 @@ class _IOSDocumentTouchInteractorState extends State } void _onPanCancel() { + _magnifierOffset.value = null; + if (_dragMode != null) { _onDragSelectionEnd(); } @@ -999,8 +1021,9 @@ class _IOSDocumentTouchInteractorState extends State _onHandleDragEnd(); } + _controlsController!.blinkCaret(); _handleAutoScrolling.stopAutoScrollHandleMonitoring(); - scrollPosition.removeListener(_updateDragSelection); + scrollPosition.removeListener(_onAutoScrollChange); } void _onLongPressEnd() { @@ -1018,23 +1041,25 @@ class _IOSDocumentTouchInteractorState extends State } void _updateOverlayControlsAfterFinishingDragSelection() { - _editingController.hideMagnifier(); + _controlsController!.hideMagnifier(); if (!widget.selection.value!.isCollapsed) { - _editingController.showToolbar(); - _positionToolbar(); + _controlsController!.showToolbar(); } + } - _controlsOverlayEntry!.markNeedsBuild(); + void _onAutoScrollChange() { + _updateDocumentSelectionOnAutoScrollFrame(); + _updateMagnifierFocalPointOnAutoScrollFrame(); } - void _onTapTimeout() { - setState(() { - _waitingForMoreTaps = false; - _controlsOverlayEntry?.markNeedsBuild(); - }); + void _updateMagnifierFocalPointOnAutoScrollFrame() { + if (_magnifierOffset.value != null) { + final interactorBox = context.findRenderObject() as RenderBox; + _magnifierOffset.value = _interactorOffsetToDocumentOffset(interactorBox.globalToLocal(_globalDragOffset!)); + } } - void _updateDragSelection() { + void _updateDocumentSelectionOnAutoScrollFrame() { if (_dragStartInDoc == null) { return; } @@ -1044,7 +1069,7 @@ class _IOSDocumentTouchInteractorState extends State return; } - final dragEndInDoc = _interactorOffsetToDocOffset(_dragEndInInteractor!); + final dragEndInDoc = _interactorOffsetToDocumentOffset(_dragEndInInteractor!); final dragPosition = _docLayout.getDocumentPositionNearestToOffset(dragEndInDoc); editorGesturesLog.info("Selecting new position during drag: $dragPosition"); @@ -1086,180 +1111,6 @@ class _IOSDocumentTouchInteractorState extends State editorGesturesLog.fine("Selected region: ${widget.selection.value}"); } - void _showEditingControlsOverlay() { - if (_controlsOverlayEntry != null) { - return; - } - - _controlsOverlayEntry = OverlayEntry(builder: (overlayContext) { - return IosDocumentTouchEditingControls( - editingController: _editingController, - floatingCursorController: widget.floatingCursorController, - documentLayout: _docLayout, - document: widget.document, - selection: widget.selection, - changeSelection: (newSelection, changeType, reason) { - widget.editor.execute([ - ChangeSelectionRequest(newSelection, changeType, reason), - ]); - }, - handleColor: widget.handleColor, - onDoubleTapOnCaret: _selectWordAtCaret, - onTripleTapOnCaret: _selectParagraphAtCaret, - onFloatingCursorStart: _onFloatingCursorStart, - onFloatingCursorMoved: _moveSelectionToFloatingCursor, - onFloatingCursorStop: _onFloatingCursorStop, - magnifierFocalPointOffset: _globalDragOffset, - popoverToolbarBuilder: widget.popoverToolbarBuilder, - createOverlayControlsClipper: widget.createOverlayControlsClipper, - disableGestureHandling: _waitingForMoreTaps, - showDebugPaint: false, - ); - }); - - Overlay.of(context).insert(_controlsOverlayEntry!); - } - - void _positionCaret() { - final extentRect = _docLayout.getRectForPosition(widget.selection.value!.extent)!; - - _editingController.updateCaret( - top: extentRect.topLeft, - height: extentRect.height, - ); - } - - void _positionCollapsedHandle() { - final selection = widget.selection.value; - if (selection == null) { - editorGesturesLog.shout("Tried to update collapsed handle offset but there is no document selection"); - return; - } - if (!selection.isCollapsed) { - editorGesturesLog.shout("Tried to update collapsed handle offset but the selection is expanded"); - return; - } - - // Calculate the new (x,y) offset for the collapsed handle. - final extentRect = _docLayout.getRectForPosition(selection.extent); - late Offset handleOffset = extentRect!.bottomLeft; - - _editingController - ..collapsedHandleOffset = handleOffset - ..upstreamHandleOffset = null - ..upstreamCaretHeight = null - ..downstreamHandleOffset = null - ..downstreamCaretHeight = null; - } - - void _positionExpandedSelectionHandles() { - final selection = widget.selection.value; - if (selection == null) { - editorGesturesLog.shout("Tried to update expanded handle offsets but there is no document selection"); - return; - } - if (selection.isCollapsed) { - editorGesturesLog.shout("Tried to update expanded handle offsets but the selection is collapsed"); - return; - } - - // Calculate the new (x,y) offsets for the upstream and downstream handles. - final baseRect = _docLayout.getRectForPosition(selection.base)!; - final baseHandleOffset = baseRect.bottomLeft; - - final extentRect = _docLayout.getRectForPosition(selection.extent)!; - final extentHandleOffset = extentRect.bottomRight; - - final affinity = widget.document.getAffinityForSelection(selection); - - final upstreamHandleOffset = affinity == TextAffinity.downstream ? baseHandleOffset : extentHandleOffset; - final upstreamHandleHeight = affinity == TextAffinity.downstream ? baseRect.height : extentRect.height; - - final downstreamHandleOffset = affinity == TextAffinity.downstream ? extentHandleOffset : baseHandleOffset; - final downstreamHandleHeight = affinity == TextAffinity.downstream ? extentRect.height : baseRect.height; - - _editingController - ..removeCaret() - ..collapsedHandleOffset = null - ..upstreamHandleOffset = upstreamHandleOffset - ..upstreamCaretHeight = upstreamHandleHeight - ..downstreamHandleOffset = downstreamHandleOffset - ..downstreamCaretHeight = downstreamHandleHeight; - } - - void _positionToolbar() { - if (!_editingController.shouldDisplayToolbar) { - return; - } - - late Rect selectionRect; - Offset toolbarTopAnchor; - Offset toolbarBottomAnchor; - - final selection = widget.selection.value!; - if (selection.isCollapsed) { - final extentRectInDoc = _docLayout.getRectForPosition(selection.extent)!; - selectionRect = Rect.fromPoints( - _docLayout.getGlobalOffsetFromDocumentOffset(extentRectInDoc.topLeft), - _docLayout.getGlobalOffsetFromDocumentOffset(extentRectInDoc.bottomRight), - ); - } else { - final baseRectInDoc = _docLayout.getRectForPosition(selection.base)!; - final extentRectInDoc = _docLayout.getRectForPosition(selection.extent)!; - final selectionRectInDoc = Rect.fromPoints( - Offset( - min(baseRectInDoc.left, extentRectInDoc.left), - min(baseRectInDoc.top, extentRectInDoc.top), - ), - Offset( - max(baseRectInDoc.right, extentRectInDoc.right), - max(baseRectInDoc.bottom, extentRectInDoc.bottom), - ), - ); - selectionRect = Rect.fromPoints( - _docLayout.getGlobalOffsetFromDocumentOffset(selectionRectInDoc.topLeft), - _docLayout.getGlobalOffsetFromDocumentOffset(selectionRectInDoc.bottomRight), - ); - } - - // TODO: fix the horizontal placement - // The logic to position the toolbar horizontally is wrong. - // The toolbar should appear horizontally centered between the - // left-most and right-most edge of the selection. However, the - // left-most and right-most edge of the selection may not match - // the handle locations. Consider the situation where multiple - // lines/blocks of content are selected, but both handles sit near - // the left side of the screen. This logic will position the - // toolbar near the left side of the content, when the toolbar should - // instead be centered across the full width of the document. - toolbarTopAnchor = selectionRect.topCenter - const Offset(0, gapBetweenToolbarAndContent); - toolbarBottomAnchor = selectionRect.bottomCenter + const Offset(0, gapBetweenToolbarAndContent); - - _editingController.positionToolbar( - topAnchor: toolbarTopAnchor, - bottomAnchor: toolbarBottomAnchor, - ); - } - - void _removeEditingOverlayControls() { - if (_controlsOverlayEntry != null) { - _controlsOverlayEntry!.remove(); - _controlsOverlayEntry = null; - } - } - - void _selectWordAtCaret() { - final docSelection = widget.selection.value; - if (docSelection == null) { - return; - } - - _selectWordAt( - docPosition: docSelection.extent, - docLayout: _docLayout, - ); - } - bool _selectWordAt({ required DocumentPosition docPosition, required DocumentLayout docLayout, @@ -1283,18 +1134,6 @@ class _IOSDocumentTouchInteractorState extends State ]); } - void _selectParagraphAtCaret() { - final docSelection = widget.selection.value; - if (docSelection == null) { - return; - } - - _selectParagraphAt( - docPosition: docSelection.extent, - docLayout: _docLayout, - ); - } - bool _selectParagraphAt({ required DocumentPosition docPosition, required DocumentLayout docLayout, @@ -1315,14 +1154,22 @@ class _IOSDocumentTouchInteractorState extends State } void _onFloatingCursorStart() { + if (widget.selection.value == null) { + // The floating cursor doesn't mean anything when nothing is selected. + return; + } + _handleAutoScrolling.startAutoScrollHandleMonitoring(); } - void _moveSelectionToFloatingCursor(Offset documentOffset) { - final nearestDocumentPosition = _docLayout.getDocumentPositionNearestToOffset(documentOffset)!; - _selectPosition(nearestDocumentPosition); + void _onFloatingCursorGeometryChange() { + final cursorGeometry = _controlsController!.floatingCursorController.cursorGeometryInViewport.value; + if (cursorGeometry == null) { + return; + } + _handleAutoScrolling.updateAutoScrollHandleMonitoring( - dragEndInViewport: _docOffsetToInteractorOffset(documentOffset), + dragEndInViewport: cursorGeometry.center, ); } @@ -1371,10 +1218,7 @@ class _IOSDocumentTouchInteractorState extends State scheduleBuildAfterBuild(); } else { if (scrollPosition != _activeScrollPosition) { - _activeScrollPosition?.removeListener(_onScrollChange); - _activeScrollPosition = scrollPosition; - _activeScrollPosition?.addListener(_onScrollChange); } } } @@ -1391,7 +1235,6 @@ class _IOSDocumentTouchInteractorState extends State ..onTapUp = _onTapUp ..onDoubleTapUp = _onDoubleTapUp ..onTripleTapUp = _onTripleTapUp - ..onTimeout = _onTapTimeout ..gestureSettings = gestureSettings; }, ), @@ -1412,7 +1255,35 @@ class _IOSDocumentTouchInteractorState extends State }, ), }, - child: widget.child, + child: Stack( + children: [ + widget.child ?? const SizedBox(), + _buildMagnifierFocalPoint(), + ], + ), + ); + } + + Widget _buildMagnifierFocalPoint() { + return ValueListenableBuilder( + valueListenable: _magnifierOffset, + builder: (context, magnifierOffset, child) { + if (magnifierOffset == null) { + return const SizedBox(); + } + + // When the user is dragging a handle in this overlay, we + // are responsible for positioning the focal point for the + // magnifier to follow. We do that here. + return Positioned( + left: magnifierOffset.dx, + top: magnifierOffset.dy, + child: Leader( + link: _controlsController!.magnifierFocalPoint, + child: const SizedBox(width: 1, height: 1), + ), + ); + }, ); } } @@ -1428,3 +1299,515 @@ enum DragMode { // around the selected word. longPress, } + +/// Adds and removes an iOS-style editor toolbar, as dictated by an ancestor +/// [SuperEditorIosControlsScope]. +class SuperEditorIosToolbarOverlayManager extends StatefulWidget { + const SuperEditorIosToolbarOverlayManager({ + super.key, + this.defaultToolbarBuilder, + this.child, + }); + + final DocumentFloatingToolbarBuilder? defaultToolbarBuilder; + + final Widget? child; + + @override + State createState() => SuperEditorIosToolbarOverlayManagerState(); +} + +@visibleForTesting +class SuperEditorIosToolbarOverlayManagerState extends State { + SuperEditorIosControlsController? _controlsContext; + OverlayEntry? _toolbarOverlayEntry; + + @visibleForTesting + bool get wantsToDisplayToolbar => _controlsContext!.shouldShowToolbar.value; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + _controlsContext = SuperEditorIosControlsScope.rootOf(context); + + // Add our overlay on the next frame. If we did it immediately, it would + // cause a setState() to be called during didChangeDependencies, which is + // a framework violation. + onNextFrame((timeStamp) { + _addToolbarOverlay(); + }); + } + + @override + void dispose() { + _removeToolbarOverlay(); + super.dispose(); + } + + void _addToolbarOverlay() { + if (_toolbarOverlayEntry != null) { + return; + } + + _toolbarOverlayEntry = OverlayEntry(builder: (overlayContext) { + return IosFloatingToolbarOverlay( + shouldShowToolbar: _controlsContext!.shouldShowToolbar, + toolbarFocalPoint: _controlsContext!.toolbarFocalPoint, + floatingToolbarBuilder: + _controlsContext!.toolbarBuilder ?? widget.defaultToolbarBuilder ?? (_, __, ___) => const SizedBox(), + createOverlayControlsClipper: _controlsContext!.createOverlayControlsClipper, + showDebugPaint: false, + ); + }); + + Overlay.of(context).insert(_toolbarOverlayEntry!); + } + + void _removeToolbarOverlay() { + if (_toolbarOverlayEntry == null) { + return; + } + + _toolbarOverlayEntry!.remove(); + _toolbarOverlayEntry = null; + } + + @override + Widget build(BuildContext context) { + return widget.child ?? const SizedBox(); + } +} + +/// Adds and removes an iOS-style editor magnifier, as dictated by an ancestor +/// [SuperEditorIosControlsScope]. +class SuperEditorIosMagnifierOverlayManager extends StatefulWidget { + const SuperEditorIosMagnifierOverlayManager({ + super.key, + this.child, + }); + + final Widget? child; + + @override + State createState() => SuperEditorIosMagnifierOverlayManagerState(); +} + +@visibleForTesting +class SuperEditorIosMagnifierOverlayManagerState extends State { + SuperEditorIosControlsController? _controlsContext; + OverlayEntry? _magnifierOverlayEntry; + + @visibleForTesting + bool get wantsToDisplayMagnifier => _controlsContext!.shouldShowMagnifier.value; + + @override + void initState() { + super.initState(); + + // Add our overlay on the next frame. If we did it immediately, it would + // cause a setState() to be called during didChangeDependencies, which is + // a framework violation. + onNextFrame((timeStamp) { + _addMagnifierOverlay(); + }); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + _controlsContext = SuperEditorIosControlsScope.rootOf(context); + } + + @override + void dispose() { + _removeMagnifierOverlay(); + super.dispose(); + } + + void _addMagnifierOverlay() { + if (_magnifierOverlayEntry != null) { + return; + } + + _magnifierOverlayEntry = OverlayEntry(builder: (_) => _buildMagnifier()); + Overlay.of(context).insert(_magnifierOverlayEntry!); + } + + void _removeMagnifierOverlay() { + if (_magnifierOverlayEntry == null) { + return; + } + + _magnifierOverlayEntry!.remove(); + _magnifierOverlayEntry = null; + } + + @override + Widget build(BuildContext context) { + return widget.child ?? const SizedBox(); + } + + Widget _buildMagnifier() { + // Display a magnifier that tracks a focal point. + // + // When the user is dragging an overlay handle, SuperEditor + // position a Leader with a LeaderLink. This magnifier follows that Leader + // via the LeaderLink. + return ValueListenableBuilder( + valueListenable: _controlsContext!.shouldShowMagnifier, + builder: (context, shouldShowMagnifier, child) { + if (!shouldShowMagnifier) { + return const SizedBox(); + } + + return child!; + }, + child: _controlsContext!.magnifierBuilder != null // + ? _controlsContext!.magnifierBuilder!(context, DocumentKeys.magnifier, _controlsContext!.magnifierFocalPoint) + : _buildDefaultMagnifier(context, DocumentKeys.magnifier, _controlsContext!.magnifierFocalPoint), + ); + } + + Widget _buildDefaultMagnifier(BuildContext context, Key magnifierKey, LeaderLink magnifierFocalPoint) { + if (isWeb) { + // Defer to the browser to display overlay controls on mobile. + return const SizedBox(); + } + + return IOSFollowingMagnifier.roundedRectangle( + magnifierKey: magnifierKey, + leaderLink: magnifierFocalPoint, + offsetFromFocalPoint: const Offset(0, -72), + ); + } +} + +/// Displays an iOS floating cursor for a document editor experience. +/// +/// An [EditorFloatingCursor] also tracks the floating cursor focal point, sets the +/// floating cursor geometry on an ancestor [SuperEditorIosControlsController], as well as +/// toggling the magnifier and toolbar, and updates the [Editor]s [DocumentSelection] +/// as the user moves the floating cursor, or scrolls the document. +/// +/// [EditorFloatingCursor] should wrap the editor's viewport (not the full document layout), +/// because the floating cursor moves around the visible area of the UI, it's position +/// is not tied directly to the document layout. +/// +/// [EditorFloatingCursor] must be a descendant of an ancestor [SuperEditorIosControlsScope]. +class EditorFloatingCursor extends StatefulWidget { + const EditorFloatingCursor({ + super.key, + required this.editor, + required this.document, + required this.getDocumentLayout, + required this.selection, + required this.scrollChangeSignal, + required this.child, + }); + + final Editor editor; + final Document document; + final DocumentLayoutResolver getDocumentLayout; + final ValueListenable selection; + final SignalNotifier scrollChangeSignal; + final Widget child; + + @override + State createState() => _EditorFloatingCursorState(); +} + +class _EditorFloatingCursorState extends State { + SuperEditorIosControlsController? _controlsContext; + late FloatingCursorListener _floatingCursorListener; + + Offset? _initialFloatingCursorOffsetInViewport; + Offset? _floatingCursorFocalPointInViewport; + Offset? _floatingCursorFocalPointInDocument; + double _floatingCursorHeight = FloatingCursorPolicies.defaultFloatingCursorHeight; + + @override + void initState() { + super.initState(); + + _floatingCursorListener = FloatingCursorListener( + onStart: _onFloatingCursorStart, + onMove: _onFloatingCursorMove, + onStop: _onFloatingCursorStop, + ); + + widget.scrollChangeSignal.addListener(_onScrollChange); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + if (_controlsContext != null) { + _controlsContext!.floatingCursorController.removeListener(_floatingCursorListener); + } + _controlsContext = SuperEditorIosControlsScope.rootOf(context); + _controlsContext!.floatingCursorController.addListener(_floatingCursorListener); + } + + @override + void didUpdateWidget(EditorFloatingCursor oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.scrollChangeSignal != oldWidget.scrollChangeSignal) { + oldWidget.scrollChangeSignal.removeListener(_onScrollChange); + widget.scrollChangeSignal.addListener(_onScrollChange); + } + } + + @override + void dispose() { + widget.scrollChangeSignal.removeListener(_onScrollChange); + + super.dispose(); + } + + /// Returns the layout for the current document, which answers questions + /// about the locations and sizes of visual components within the layout. + DocumentLayout get _docLayout => widget.getDocumentLayout(); + + /// Returns the `RenderBox` for the scrolling viewport. + /// + /// This widget expects to wrap the viewport, so this widget's box is the same + /// place and size as the actual viewport. + RenderBox get viewportBox => context.findRenderObject() as RenderBox; + + Offset _documentOffsetToViewportOffset(Offset documentOffset) { + final globalOffset = _docLayout.getGlobalOffsetFromDocumentOffset(documentOffset); + return viewportBox.globalToLocal(globalOffset); + } + + Offset _viewportOffsetToDocumentOffset(Offset viewportOffset) { + final globalOffset = viewportBox.localToGlobal(viewportOffset); + return _docLayout.getDocumentOffsetFromAncestorOffset(globalOffset); + } + + void _onFloatingCursorStart() { + editorIosFloatingCursorLog.fine("Floating cursor started."); + if (widget.selection.value == null) { + // The floating cursor doesn't mean anything when nothing is selected. + return; + } + + final initialSelectionExtent = widget.selection.value!.extent; + final nearestPositionRect = _docLayout.getRectForPosition(initialSelectionExtent)!; + final verticalCenterOfCaret = nearestPositionRect.center; + final initialFloatingCursorOffsetInDocument = verticalCenterOfCaret + const Offset(-1, 0); + _initialFloatingCursorOffsetInViewport = _documentOffsetToViewportOffset(initialFloatingCursorOffsetInDocument); + _floatingCursorFocalPointInViewport = _initialFloatingCursorOffsetInViewport!; + _floatingCursorFocalPointInDocument = _viewportOffsetToDocumentOffset(_floatingCursorFocalPointInViewport!); + + _controlsContext!.hideToolbar(); + _controlsContext!.hideMagnifier(); + + _updateFloatingCursorGeometryForCurrentFloatingCursorFocalPoint(); + } + + void _onFloatingCursorMove(Offset? offset) { + editorIosFloatingCursorLog.finer("Floating cursor moved: $offset"); + if (offset == null) { + return; + } + + if (widget.selection.value == null) { + // The floating cursor doesn't mean anything when nothing is selected. + return; + } + if (!widget.selection.value!.isCollapsed) { + // This shouldn't happen. An expanded selection should be collapsed for + // we get to movement methods. + editorIosFloatingCursorLog + .shout("Floating cursor move reported with an expanded selection. The selection should be collapsed!"); + } + + // Update our floating cursor focal point trackers. + final cursorViewportFocalPointUnbounded = _initialFloatingCursorOffsetInViewport! + offset; + editorIosFloatingCursorLog.finer(" - unbounded cursor focal point: $cursorViewportFocalPointUnbounded"); + + final viewportHeight = (context.findRenderObject() as RenderBox).size.height; + _floatingCursorFocalPointInViewport = + Offset(cursorViewportFocalPointUnbounded.dx, cursorViewportFocalPointUnbounded.dy.clamp(0, viewportHeight)); + editorIosFloatingCursorLog.finer(" - bounded cursor focal point: $_floatingCursorFocalPointInViewport"); + + _floatingCursorFocalPointInDocument = _viewportOffsetToDocumentOffset(_floatingCursorFocalPointInViewport!); + editorIosFloatingCursorLog.finer(" - floating cursor offset in document: $_floatingCursorFocalPointInDocument"); + + // Calculate an updated floating cursor rectangle and document selection. + _updateFloatingCursorGeometryForCurrentFloatingCursorFocalPoint(); + _selectPositionUnderFloatingCursor(); + } + + void _onScrollChange() { + if (!_controlsContext!.floatingCursorController.isActive.value) { + return; + } + + _updateFloatingCursorGeometryForCurrentFloatingCursorFocalPoint(); + _selectPositionUnderFloatingCursor(); + } + + /// Updates the offset and height of the floating cursor, based on the current + /// floating cursor focal point. + /// + /// If anything impacted the focal point, such as user movement, or scroll changes, + /// those changes must be made to the focal point before calling this method. This + /// method doesn't update or alter the focal point. + void _updateFloatingCursorGeometryForCurrentFloatingCursorFocalPoint() { + final focalPointInDocument = _viewportOffsetToDocumentOffset(_floatingCursorFocalPointInViewport!); + final nearestDocumentPosition = _docLayout.getDocumentPositionNearestToOffset(focalPointInDocument)!; + editorIosFloatingCursorLog.finer(" - nearest position to floating cursor: $nearestDocumentPosition"); + + if (nearestDocumentPosition.nodePosition is TextNodePosition) { + final nearestPositionRect = _docLayout.getRectForPosition(nearestDocumentPosition)!; + _floatingCursorHeight = nearestPositionRect.height; + + final distance = _floatingCursorFocalPointInDocument! - nearestPositionRect.topLeft + const Offset(1.0, 0.0); + _controlsContext!.floatingCursorController.isNearText.value = + distance.dx.abs() <= FloatingCursorPolicies.maximumDistanceToBeNearText; + } else { + final nearestComponent = _docLayout.getComponentByNodeId(nearestDocumentPosition.nodeId)!; + _floatingCursorHeight = (nearestComponent.context.findRenderObject() as RenderBox).size.height; + _controlsContext!.floatingCursorController.isNearText.value = false; + } + + _controlsContext!.floatingCursorController.cursorGeometryInViewport.value = Rect.fromLTWH( + _floatingCursorFocalPointInViewport!.dx, + _floatingCursorFocalPointInViewport!.dy - (_floatingCursorHeight / 2), + FloatingCursorPolicies.defaultFloatingCursorWidth, + _floatingCursorHeight, + ); + editorIosFloatingCursorLog.finer( + "Set floating cursor geometry to: ${_controlsContext!.floatingCursorController.cursorGeometryInViewport.value}"); + } + + /// Inspects the viewport focal point offset of the floating cursor, finds the nearest position + /// in the document, and moves the selection to that position. + void _selectPositionUnderFloatingCursor() { + editorIosFloatingCursorLog.finer("Updating document selection based on floating cursor focal point."); + final floatingCursorRectInViewport = _controlsContext!.floatingCursorController.cursorGeometryInViewport.value; + if (floatingCursorRectInViewport == null) { + editorIosFloatingCursorLog.finer(" - the floating cursor rect is null. Not selecting anything."); + return; + } + + final nearestDocumentPosition = _docLayout + .getDocumentPositionNearestToOffset(_viewportOffsetToDocumentOffset(floatingCursorRectInViewport.center))!; + + editorIosFloatingCursorLog.finer(" - selecting nearest position: $nearestDocumentPosition"); + _selectPosition(nearestDocumentPosition); + } + + void _selectPosition(DocumentPosition position) { + widget.editor.execute([ + ChangeSelectionRequest( + DocumentSelection.collapsed( + position: position, + ), + SelectionChangeType.placeCaret, + SelectionReason.userInteraction, + ), + ]); + } + + void _onFloatingCursorStop() { + editorIosFloatingCursorLog.fine("Floating cursor stopped."); + _controlsContext!.floatingCursorController.isNearText.value = false; + _controlsContext!.floatingCursorController.cursorGeometryInViewport.value = null; + + _floatingCursorFocalPointInDocument = null; + _floatingCursorFocalPointInViewport = null; + _floatingCursorHeight = FloatingCursorPolicies.defaultFloatingCursorHeight; + } + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + widget.child, + _buildFloatingCursor(), + ], + ); + } + + Widget _buildFloatingCursor() { + return ValueListenableBuilder( + valueListenable: _controlsContext!.floatingCursorController.cursorGeometryInViewport, + builder: (context, floatingCursorRect, child) { + if (floatingCursorRect == null) { + return const SizedBox(); + } + + return Positioned.fromRect( + rect: floatingCursorRect, + child: IgnorePointer( + child: ColoredBox( + color: Colors.red.withOpacity(0.75), + ), + ), + ); + }, + ); + } +} + +/// A [SuperEditorDocumentLayerBuilder] that builds a [IosToolbarFocalPointDocumentLayer], which +/// positions a `Leader` widget around the document selection, as a focal point for an +/// iOS floating toolbar. +class SuperEditorIosToolbarFocalPointDocumentLayerBuilder implements SuperEditorLayerBuilder { + const SuperEditorIosToolbarFocalPointDocumentLayerBuilder({ + // ignore: unused_element + this.showDebugLeaderBounds = false, + }); + + /// Whether to paint colorful bounds around the leader widget. + final bool showDebugLeaderBounds; + + @override + ContentLayerWidget build(BuildContext context, SuperEditorContext editorContext) { + return IosToolbarFocalPointDocumentLayer( + document: editorContext.document, + selection: editorContext.composer.selectionNotifier, + toolbarFocalPointLink: SuperEditorIosControlsScope.rootOf(context).toolbarFocalPoint, + showDebugLeaderBounds: showDebugLeaderBounds, + ); + } +} + +/// A [SuperEditorLayerBuilder], which builds a [IosHandlesDocumentLayer], +/// which displays iOS-style caret and handles. +class SuperEditorIosHandlesDocumentLayerBuilder implements SuperEditorLayerBuilder { + const SuperEditorIosHandlesDocumentLayerBuilder({ + this.handleColor, + }); + + final Color? handleColor; + + @override + ContentLayerWidget build(BuildContext context, SuperEditorContext editContext) { + if (defaultTargetPlatform != TargetPlatform.iOS) { + return const ContentLayerProxyWidget(child: SizedBox()); + } + + return IosHandlesDocumentLayer( + document: editContext.document, + documentLayout: editContext.documentLayout, + selection: editContext.composer.selectionNotifier, + changeSelection: (newSelection, changeType, reason) { + editContext.editor.execute([ + ChangeSelectionRequest(newSelection, changeType, reason), + ]); + }, + handleColor: handleColor ?? + SuperEditorIosControlsScope.maybeRootOf(context)?.handleColor ?? + Theme.of(context).primaryColor, + shouldCaretBlink: SuperEditorIosControlsScope.rootOf(context).shouldCaretBlink, + floatingCursorController: SuperEditorIosControlsScope.rootOf(context).floatingCursorController, + ); + } +} diff --git a/super_editor/lib/src/default_editor/document_ime/document_ime_communication.dart b/super_editor/lib/src/default_editor/document_ime/document_ime_communication.dart index a98ab60118..2dfe00d316 100644 --- a/super_editor/lib/src/default_editor/document_ime/document_ime_communication.dart +++ b/super_editor/lib/src/default_editor/document_ime/document_ime_communication.dart @@ -25,7 +25,7 @@ class DocumentImeInputClient extends TextInputConnectionDecorator with TextInput required this.textDeltasDocumentEditor, required this.imeConnection, required this.onPerformSelector, - FloatingCursorController? floatingCursorController, + this.floatingCursorController, }) { // Note: we don't listen to document changes because we expect that any change during IME // editing will also include a selection change. If we listen to documents and selections, then @@ -35,7 +35,6 @@ class DocumentImeInputClient extends TextInputConnectionDecorator with TextInput selection.addListener(_onContentChange); composingRegion.addListener(_onContentChange); imeConnection.addListener(_onImeConnectionChange); - _floatingCursorController = floatingCursorController; if (attached) { _sendDocumentToIme(); @@ -60,14 +59,14 @@ class DocumentImeInputClient extends TextInputConnectionDecorator with TextInput final ValueListenable imeConnection; - // TODO: get floating cursor out of here. Use a multi-client IME decorator to split responsibilities - late FloatingCursorController? _floatingCursorController; - /// Handles a selector generated by the IME. /// /// For the list of selectors, see [MacOsSelectors]. final void Function(String selectorName) onPerformSelector; + // TODO: get floating cursor out of here. Use a multi-client IME decorator to split responsibilities + late FloatingCursorController? floatingCursorController; + /// Whether the floating cursor is being displayed. /// /// This value is updated on [updateFloatingCursor]. @@ -291,17 +290,23 @@ class DocumentImeInputClient extends TextInputConnectionDecorator with TextInput @override void updateFloatingCursor(RawFloatingCursorPoint point) { + if (floatingCursorController == null) { + return; + } + switch (point.state) { case FloatingCursorDragState.Start: _isFloatingCursorVisible = true; - _floatingCursorController?.offset = point.offset; + floatingCursorController! + ..onStart() + ..onMove(point.offset); break; case FloatingCursorDragState.Update: - _floatingCursorController?.offset = point.offset; + floatingCursorController!.onMove(point.offset); break; case FloatingCursorDragState.End: _isFloatingCursorVisible = false; - _floatingCursorController?.offset = null; + floatingCursorController!.onStop(); break; } } 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 9e181870e4..52ce896a9a 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 @@ -6,6 +6,7 @@ import 'package:flutter/services.dart'; import 'package:super_editor/src/core/document_layout.dart'; 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/document_gestures_touch_ios.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_scheduler.dart'; @@ -123,6 +124,9 @@ class SuperEditorImeInteractor extends StatefulWidget { /// The floating cursor is an iOS-only feature. Flutter reports floating cursor /// messages through the IME API, which is why this controller is offered as /// a property on this IME interactor. + /// + /// If no [floatingCursorController] is provided, this widget attempts to obtain + /// one from an ancestor [SuperEditorIosControlsScope] final FloatingCursorController? floatingCursorController; /// Handlers for all Mac OS "selectors" reported by the IME. @@ -141,6 +145,8 @@ class SuperEditorImeInteractor extends StatefulWidget { class SuperEditorImeInteractorState extends State implements ImeInputOwner { late FocusNode _focusNode; + SuperEditorIosControlsController? _controlsController; + final _imeConnection = ValueNotifier(null); late TextInputConfiguration _textInputConfiguration; late DocumentImeInputClient _documentImeClient; @@ -172,12 +178,22 @@ class SuperEditorImeInteractorState extends State impl _textInputConfiguration = widget.imeConfiguration.toTextInputConfiguration(); } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _controlsController = SuperEditorIosControlsScope.maybeRootOf(context); + _documentImeClient.floatingCursorController = + widget.floatingCursorController ?? _controlsController?.floatingCursorController; + } + @override void didUpdateWidget(SuperEditorImeInteractor oldWidget) { super.didUpdateWidget(oldWidget); if (widget.editContext != oldWidget.editContext) { _setupImeConnection(); + _documentImeClient.floatingCursorController = + widget.floatingCursorController ?? _controlsController?.floatingCursorController; _imeConnection.notifyListeners(); } @@ -222,17 +238,6 @@ class SuperEditorImeInteractorState extends State impl _createDocumentImeClient(); } - void _createDocumentImeClient() { - _documentImeClient = DocumentImeInputClient( - selection: widget.editContext.composer.selectionNotifier, - composingRegion: widget.editContext.composer.composingRegion, - textDeltasDocumentEditor: _textDeltasDocumentEditor, - imeConnection: _imeConnection, - floatingCursorController: widget.floatingCursorController, - onPerformSelector: _onPerformSelector, - ); - } - void _createTextDeltasDocumentEditor() { _textDeltasDocumentEditor = TextDeltasDocumentEditor( editor: widget.editContext.editor, @@ -246,6 +251,16 @@ class SuperEditorImeInteractorState extends State impl ); } + void _createDocumentImeClient() { + _documentImeClient = DocumentImeInputClient( + selection: widget.editContext.composer.selectionNotifier, + composingRegion: widget.editContext.composer.composingRegion, + textDeltasDocumentEditor: _textDeltasDocumentEditor, + imeConnection: _imeConnection, + onPerformSelector: _onPerformSelector, + ); + } + void _onImeConnectionChange() { if (_imeConnection.value == null) { _documentImeConnection.value = null; diff --git a/super_editor/lib/src/default_editor/selection_binary.dart b/super_editor/lib/src/default_editor/selection_binary.dart index 06a5113b39..c703482278 100644 --- a/super_editor/lib/src/default_editor/selection_binary.dart +++ b/super_editor/lib/src/default_editor/selection_binary.dart @@ -11,6 +11,9 @@ class BinaryNodePosition implements NodePosition { final bool isIncluded; + @override + bool isEquivalentTo(NodePosition other) => this == other; + @override String toString() => "[BinaryNodePosition] - is included: $isIncluded"; diff --git a/super_editor/lib/src/default_editor/selection_upstream_downstream.dart b/super_editor/lib/src/default_editor/selection_upstream_downstream.dart index d2e847dc66..155480c4ed 100644 --- a/super_editor/lib/src/default_editor/selection_upstream_downstream.dart +++ b/super_editor/lib/src/default_editor/selection_upstream_downstream.dart @@ -11,6 +11,9 @@ class UpstreamDownstreamNodePosition implements NodePosition { final TextAffinity affinity; + @override + bool isEquivalentTo(NodePosition other) => this == other; + @override String toString() => "[UpstreamDownstreamNodePosition] - $affinity"; diff --git a/super_editor/lib/src/default_editor/super_editor.dart b/super_editor/lib/src/default_editor/super_editor.dart index ce8f091b18..7405fbace8 100644 --- a/super_editor/lib/src/default_editor/super_editor.dart +++ b/super_editor/lib/src/default_editor/super_editor.dart @@ -2,6 +2,7 @@ import 'package:attributed_text/attributed_text.dart'; import 'package:flutter/foundation.dart' show defaultTargetPlatform; import 'package:flutter/material.dart' hide SelectableText; import 'package:flutter/services.dart'; +import 'package:follow_the_leader/follow_the_leader.dart'; import 'package:super_editor/src/core/document.dart'; import 'package:super_editor/src/core/document_composer.dart'; import 'package:super_editor/src/core/document_debug_paint.dart'; @@ -21,10 +22,11 @@ import 'package:super_editor/src/infrastructure/_logging.dart'; import 'package:super_editor/src/infrastructure/content_layers.dart'; import 'package:super_editor/src/infrastructure/documents/document_scaffold.dart'; import 'package:super_editor/src/infrastructure/documents/document_scroller.dart'; +import 'package:super_editor/src/infrastructure/documents/selection_leader_document_layer.dart'; import 'package:super_editor/src/infrastructure/links.dart'; -import 'package:super_editor/src/infrastructure/platforms/ios/ios_document_controls.dart'; +import 'package:super_editor/src/infrastructure/platforms/ios/toolbar.dart'; import 'package:super_editor/src/infrastructure/platforms/mac/mac_ime.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/text_input.dart'; import 'package:super_text_layout/super_text_layout.dart'; @@ -113,15 +115,16 @@ class SuperEditor extends StatefulWidget { this.selectorHandlers, this.gestureMode, this.contentTapDelegateFactory = superEditorLaunchLinkTapHandlerFactory, + this.selectionLayerLinks, + this.documentUnderlayBuilders = const [], + this.documentOverlayBuilders = defaultSuperEditorDocumentOverlayBuilders, + this.autofocus = false, + this.overlayController, this.androidHandleColor, this.androidToolbarBuilder, this.iOSHandleColor, this.iOSToolbarBuilder, this.createOverlayControlsClipper, - this.selectionLayerLinks, - this.documentOverlayBuilders = const [DefaultCaretOverlayBuilder()], - this.autofocus = false, - this.overlayController, this.plugins = const {}, this.debugPaint = const DebugPaintConfig(), }) : stylesheet = stylesheet ?? defaultStylesheet, @@ -144,9 +147,6 @@ class SuperEditor extends StatefulWidget { /// `Scrollable`. final ScrollController? scrollController; - /// Shows, hides, and positions a floating toolbar and magnifier. - final MagnifierAndToolbarController? overlayController; - /// [GlobalKey] that's bound to the [DocumentLayout] within /// this `SuperEditor`. /// @@ -224,27 +224,6 @@ class SuperEditor extends StatefulWidget { /// when a user taps on a link. final SuperEditorContentTapDelegateFactory? contentTapDelegateFactory; - /// Color of the text selection drag handles on Android. - final Color? androidHandleColor; - - /// Builder that creates a floating toolbar when running on Android. - final WidgetBuilder? androidToolbarBuilder; - - /// Color of the text selection drag handles on iOS. - final Color? iOSHandleColor; - - /// Builder that creates a floating toolbar when running on iOS. - final WidgetBuilder? iOSToolbarBuilder; - - /// Creates a clipper that applies to overlay controls, like drag - /// handles, magnifiers, and popover toolbars, preventing the overlay - /// controls from appearing outside the given clipping region. - /// - /// If no clipper factory method is provided, then the overlay controls - /// will be allowed to appear anywhere in the overlay in which they sit - /// (probably the entire screen). - final CustomClipper Function(BuildContext overlayContext)? createOverlayControlsClipper; - /// Leader links that connect leader widgets near the user's selection /// to carets, handles, and other things that want to follow the selection. /// @@ -259,6 +238,10 @@ class SuperEditor extends StatefulWidget { /// The [Document] that's edited by the [editor]. final Document document; + /// Layers that are displayed under the document layout, aligned + /// with the location and size of the document layout. + final List documentUnderlayBuilders; + /// Layers that are displayed on top of the document layout, aligned /// with the location and size of the document layout. final List documentOverlayBuilders; @@ -287,6 +270,33 @@ class SuperEditor extends StatefulWidget { /// defined as a mapping from selector names to handler functions. final Map? selectorHandlers; + /// Shows, hides, and positions a floating toolbar and magnifier. + final MagnifierAndToolbarController? overlayController; + + /// Color of the text selection drag handles on Android. + final Color? androidHandleColor; + + /// Builder that creates a floating toolbar when running on Android. + final WidgetBuilder? androidToolbarBuilder; + + /// Color of the text selection drag handles on iOS. + @Deprecated("To configure handle color, surround SuperEditor with an IosEditorControlsScope, instead") + final Color? iOSHandleColor; + + /// Builder that creates a floating toolbar when running on iOS. + @Deprecated("To configure a toolbar builder, surround SuperEditor with an IosEditorControlsScope, instead") + final WidgetBuilder? iOSToolbarBuilder; + + /// Creates a clipper that applies to overlay controls, like drag + /// handles, magnifiers, and popover toolbars, preventing the overlay + /// controls from appearing outside the given clipping region. + /// + /// If no clipper factory method is provided, then the overlay controls + /// will be allowed to appear anywhere in the overlay in which they sit + /// (probably the entire screen). + // TODO: remove this once both iOS and Android overlay controls are moved to ancestor scopes. + final CustomClipper Function(BuildContext overlayContext)? createOverlayControlsClipper; + /// Plugins that add sets of behaviors to the editing experience. final Set plugins; @@ -316,16 +326,23 @@ class SuperEditorState extends State { late DocumentComposer _composer; - late DocumentScroller _scroller; + DocumentScroller? _scroller; late ScrollController _scrollController; late AutoScrollController _autoScrollController; + // Signal that's notified every time the scroll offset changes for SuperEditor, + // including the cases where SuperEditor controls an ancestor Scrollable. + final _scrollChangeSignal = SignalNotifier(); @visibleForTesting late SuperEditorContext editContext; ContentTapDelegate? _contentTapDelegate; - final _floatingCursorController = FloatingCursorController(); + // GlobalKey for the iOS editor controls context so that the context data doesn't + // continuously replace itself every time we rebuild. We want to retain the same + // controls because they're shared throughout a number of disconnected widgets. + final _iosControlsContextKey = GlobalKey(); + final _iosControlsController = SuperEditorIosControlsController(); // Leader links that connect leader widgets near the user's selection // to carets, handles, and other things that want to follow the selection. @@ -411,6 +428,8 @@ class SuperEditorState extends State { void dispose() { _contentTapDelegate?.dispose(); + _iosControlsController.dispose(); + widget.editor.context.remove(Editor.layoutKey); _focusNode.removeListener(_onFocusChange); @@ -423,14 +442,17 @@ class SuperEditorState extends State { } void _createEditContext() { - _scroller = DocumentScroller(); + if (_scroller != null) { + _scroller!.dispose(); + } + _scroller = DocumentScroller()..addScrollChangeListener(_scrollChangeSignal.notifyListeners); editContext = SuperEditorContext( editor: widget.editor, document: widget.document, composer: _composer, getDocumentLayout: () => _docLayoutKey.currentState as DocumentLayout, - scroller: _scroller, + scroller: _scroller!, commonOps: CommonEditorOperations( editor: widget.editor, document: widget.document, @@ -521,51 +543,91 @@ class SuperEditorState extends State { @override Widget build(BuildContext context) { - return SuperEditorFocusDebugVisuals( - focusNode: _focusNode, - child: EditorSelectionAndFocusPolicy( - focusNode: _focusNode, - editor: widget.editor, - document: widget.document, - selection: _composer.selectionNotifier, - isDocumentLayoutAvailable: () => _docLayoutKey.currentContext != null, - getDocumentLayout: () => editContext.documentLayout, - placeCaretAtEndOfDocumentOnGainFocus: widget.selectionPolicies.placeCaretAtEndOfDocumentOnGainFocus, - restorePreviousSelectionOnGainFocus: widget.selectionPolicies.restorePreviousSelectionOnGainFocus, - clearSelectionWhenEditorLosesFocus: widget.selectionPolicies.clearSelectionWhenEditorLosesFocus, - child: _buildInputSystem( - child: DocumentScaffold( - documentLayoutLink: _documentLayoutLink, - documentLayoutKey: _docLayoutKey, - gestureBuilder: _buildGestureInteractor, - scrollController: _scrollController, - autoScrollController: _autoScrollController, - scroller: _scroller, - presenter: presenter, - componentBuilders: widget.componentBuilders, - overlays: [ - // Layer that positions and sizes leader widgets at the bounds - // of the users selection so that carets, handles, toolbars, and - // other things can follow the selection. - (context) { - return _SelectionLeadersDocumentLayerBuilder( - links: _selectionLinks, - ).build(context, editContext); - }, - // Add all overlays that the app wants. - for (final overlayBuilder in widget.documentOverlayBuilders) // - (context) => overlayBuilder.build(context, editContext), - ], - debugPaint: widget.debugPaint, + return _buildGestureControlsScope( + // We add a Builder immediately beneath the gesture controls scope so that + // all descendant widgets built within SuperEditor can access that scope. + child: Builder(builder: (controlsScopeContext) { + return SuperEditorFocusDebugVisuals( + focusNode: _focusNode, + child: EditorSelectionAndFocusPolicy( + focusNode: _focusNode, + editor: widget.editor, + document: widget.document, + selection: _composer.selectionNotifier, + isDocumentLayoutAvailable: () => _docLayoutKey.currentContext != null, + getDocumentLayout: () => editContext.documentLayout, + placeCaretAtEndOfDocumentOnGainFocus: widget.selectionPolicies.placeCaretAtEndOfDocumentOnGainFocus, + restorePreviousSelectionOnGainFocus: widget.selectionPolicies.restorePreviousSelectionOnGainFocus, + clearSelectionWhenEditorLosesFocus: widget.selectionPolicies.clearSelectionWhenEditorLosesFocus, + child: _buildTextInputSystem( + child: _buildPlatformSpecificViewportDecorations( + controlsScopeContext, + child: DocumentScaffold( + documentLayoutLink: _documentLayoutLink, + documentLayoutKey: _docLayoutKey, + gestureBuilder: _buildGestureInteractor, + scrollController: _scrollController, + autoScrollController: _autoScrollController, + scroller: _scroller, + presenter: presenter, + componentBuilders: widget.componentBuilders, + underlays: [ + // Add all underlays that the app wants. + for (final underlayBuilder in widget.documentUnderlayBuilders) // + (context) => underlayBuilder.build(context, editContext), + ], + overlays: [ + // Layer that positions and sizes leader widgets at the bounds + // of the users selection so that carets, handles, toolbars, and + // other things can follow the selection. + (context) { + return _SelectionLeadersDocumentLayerBuilder( + links: _selectionLinks, + showDebugLeaderBounds: false, + ).build(context, editContext); + }, + // Add all overlays that the app wants. + for (final overlayBuilder in widget.documentOverlayBuilders) // + (context) => overlayBuilder.build(context, editContext), + ], + debugPaint: widget.debugPaint, + ), + ), + ), ), - ), - ), + ); + }), ); } + /// Builds an [InheritedWidget] that holds a shared context for editor controls, + /// e.g., caret, handles, magnifier, toolbar. + /// + /// This context may be shared by multiple widgets within [SuperEditor]. It's also + /// possible that a client app has wrapped [SuperEditor] with its own context + /// [InheritedWidget], in which case the context is shared with widgets inside + /// of [SuperEditor], and widgets outside of [SuperEditor]. + Widget _buildGestureControlsScope({ + required Widget child, + }) { + switch (gestureMode) { + // case DocumentGestureMode.mouse: + // // TODO: create context for mouse mode (#1533) + // case DocumentGestureMode.android: + // // TODO: create context for Android (#1509) + case DocumentGestureMode.iOS: + default: + return SuperEditorIosControlsScope( + key: _iosControlsContextKey, + controller: _iosControlsController, + child: child, + ); + } + } + /// Builds the widget tree that applies user input, e.g., key /// presses from a keyboard, or text deltas from the IME. - Widget _buildInputSystem({ + Widget _buildTextInputSystem({ required Widget child, }) { switch (inputSource) { @@ -601,76 +663,152 @@ class SuperEditorState extends State { ..._keyboardActions, ], selectorHandlers: widget.selectorHandlers ?? defaultEditorSelectorHandlers, - floatingCursorController: _floatingCursorController, child: child, ); } } + /// Builds any widgets that a platform wants to wrap around the editor viewport, + /// e.g., editor toolbar, floating cursor display for iOS. + Widget _buildPlatformSpecificViewportDecorations( + BuildContext context, { + required Widget child, + }) { + switch (gestureMode) { + case DocumentGestureMode.iOS: + return SuperEditorIosToolbarOverlayManager( + defaultToolbarBuilder: (overlayContext, mobileToolbarKey, focalPoint) => defaultIosEditorToolbarBuilder( + overlayContext, + mobileToolbarKey, + focalPoint, + editContext.commonOps, + SuperEditorIosControlsScope.rootOf(context), + ), + child: SuperEditorIosMagnifierOverlayManager( + child: EditorFloatingCursor( + editor: widget.editor, + document: widget.document, + getDocumentLayout: () => _docLayoutKey.currentState as DocumentLayout, + selection: widget.composer.selectionNotifier, + scrollChangeSignal: _scrollChangeSignal, + child: child, + ), + ), + ); + case DocumentGestureMode.mouse: + case DocumentGestureMode.android: + default: + return child; + } + } + Widget _buildGestureInteractor(BuildContext context) { switch (gestureMode) { case DocumentGestureMode.mouse: - return _buildDesktopGestureSystem(); + return DocumentMouseInteractor( + focusNode: _focusNode, + editor: editContext.editor, + document: editContext.document, + getDocumentLayout: () => editContext.documentLayout, + selectionChanges: editContext.composer.selectionChanges, + selectionNotifier: editContext.composer.selectionNotifier, + contentTapHandler: _contentTapDelegate, + autoScroller: _autoScrollController, + showDebugPaint: widget.debugPaint.gestures, + ); case DocumentGestureMode.android: - return _buildAndroidGestureSystem(); + return AndroidDocumentTouchInteractor( + focusNode: _focusNode, + editor: editContext.editor, + document: editContext.document, + getDocumentLayout: () => editContext.documentLayout, + selection: editContext.composer.selectionNotifier, + contentTapHandler: _contentTapDelegate, + scrollController: _scrollController, + documentKey: _docLayoutKey, + documentLayoutLink: _documentLayoutLink, + selectionLinks: _selectionLinks, + handleColor: widget.androidHandleColor ?? Theme.of(context).primaryColor, + popoverToolbarBuilder: widget.androidToolbarBuilder ?? (_) => const SizedBox(), + createOverlayControlsClipper: widget.createOverlayControlsClipper, + overlayController: widget.overlayController, + showDebugPaint: widget.debugPaint.gestures, + ); case DocumentGestureMode.iOS: - return _buildIOSGestureSystem(); + return IosDocumentTouchInteractor( + focusNode: _focusNode, + editor: editContext.editor, + document: editContext.document, + getDocumentLayout: () => editContext.documentLayout, + selection: editContext.composer.selectionNotifier, + contentTapHandler: _contentTapDelegate, + scrollController: _scrollController, + showDebugPaint: widget.debugPaint.gestures, + ); } } +} - Widget _buildDesktopGestureSystem() { - return DocumentMouseInteractor( - focusNode: _focusNode, - editor: editContext.editor, - document: editContext.document, - getDocumentLayout: () => editContext.documentLayout, - selectionChanges: editContext.composer.selectionChanges, - selectionNotifier: editContext.composer.selectionNotifier, - contentTapHandler: _contentTapDelegate, - autoScroller: _autoScrollController, - showDebugPaint: widget.debugPaint.gestures, - ); +/// Builds a standard editor-style iOS floating toolbar. +Widget defaultIosEditorToolbarBuilder( + BuildContext context, + Key floatingToolbarKey, + LeaderLink focalPoint, + CommonEditorOperations editorOps, + SuperEditorIosControlsController editorControlsController, +) { + if (isWeb) { + // On web, we defer to the browser's internal overlay controls for mobile. + return const SizedBox(); } - Widget _buildAndroidGestureSystem() { - return AndroidDocumentTouchInteractor( - focusNode: _focusNode, - editor: editContext.editor, - document: editContext.document, - getDocumentLayout: () => editContext.documentLayout, - selection: editContext.composer.selectionNotifier, - contentTapHandler: _contentTapDelegate, - scrollController: _scrollController, - documentKey: _docLayoutKey, - documentLayoutLink: _documentLayoutLink, - selectionLinks: _selectionLinks, - handleColor: widget.androidHandleColor ?? Theme.of(context).primaryColor, - popoverToolbarBuilder: widget.androidToolbarBuilder ?? (_) => const SizedBox(), - createOverlayControlsClipper: widget.createOverlayControlsClipper, - overlayController: widget.overlayController, - showDebugPaint: widget.debugPaint.gestures, + return DefaultIosEditorToolbar( + floatingToolbarKey: floatingToolbarKey, + focalPoint: focalPoint, + editorOps: editorOps, + editorControlsController: editorControlsController, + ); +} + +/// An iOS floating toolbar, which includes standard buttons for an editor use-case. +class DefaultIosEditorToolbar extends StatelessWidget { + const DefaultIosEditorToolbar({ + super.key, + this.floatingToolbarKey, + required this.focalPoint, + required this.editorOps, + required this.editorControlsController, + }); + + final Key? floatingToolbarKey; + final LeaderLink focalPoint; + final CommonEditorOperations editorOps; + final SuperEditorIosControlsController editorControlsController; + + @override + Widget build(BuildContext context) { + return IOSTextEditingFloatingToolbar( + floatingToolbarKey: floatingToolbarKey, + focalPoint: focalPoint, + onCutPressed: _cut, + onCopyPressed: _copy, + onPastePressed: _paste, ); } - Widget _buildIOSGestureSystem() { - return IOSDocumentTouchInteractor( - focusNode: _focusNode, - editor: editContext.editor, - document: editContext.document, - getDocumentLayout: () => editContext.documentLayout, - selection: editContext.composer.selectionNotifier, - contentTapHandler: _contentTapDelegate, - scrollController: _scrollController, - documentKey: _docLayoutKey, - documentLayoutLink: _documentLayoutLink, - selectionLinks: _selectionLinks, - handleColor: widget.iOSHandleColor ?? Theme.of(context).primaryColor, - popoverToolbarBuilder: widget.iOSToolbarBuilder ?? (_) => const SizedBox(), - floatingCursorController: _floatingCursorController, - createOverlayControlsClipper: widget.createOverlayControlsClipper, - overlayController: widget.overlayController, - showDebugPaint: widget.debugPaint.gestures, - ); + void _cut() { + editorOps.cut(); + editorControlsController.hideToolbar(); + } + + void _copy() { + editorOps.copy(); + editorControlsController.hideToolbar(); + } + + void _paste() { + editorOps.paste(); + editorControlsController.hideToolbar(); } } @@ -696,7 +834,6 @@ class _SelectionLeadersDocumentLayerBuilder implements SuperEditorLayerBuilder { return SelectionLeadersDocumentLayer( document: editContext.document, selection: editContext.composer.selectionNotifier, - documentLayoutResolver: () => editContext.documentLayout, links: links, showDebugLeaderBounds: showDebugLeaderBounds, ); @@ -864,12 +1001,24 @@ class DefaultCaretOverlayBuilder implements SuperEditorLayerBuilder { /// /// These builders are in priority order. The first builder /// to return a non-null component is used. -final defaultComponentBuilders = [ - const BlockquoteComponentBuilder(), - const ParagraphComponentBuilder(), - const ListItemComponentBuilder(), - const ImageComponentBuilder(), - const HorizontalRuleComponentBuilder(), +const defaultComponentBuilders = [ + BlockquoteComponentBuilder(), + ParagraphComponentBuilder(), + ListItemComponentBuilder(), + ImageComponentBuilder(), + HorizontalRuleComponentBuilder(), +]; + +/// Default list of document overlays that are displayed on top of the document +/// layout in a [SuperEditor]. +const defaultSuperEditorDocumentOverlayBuilders = [ + // Adds a Leader around the document selection at a focal point for the + // iOS floating toolbar. + SuperEditorIosToolbarFocalPointDocumentLayerBuilder(), + // Displays caret and drag handles, specifically for iOS. + SuperEditorIosHandlesDocumentLayerBuilder(), + // Displays caret for typical desktop use-cases. + DefaultCaretOverlayBuilder(), ]; /// Keyboard actions for the standard [SuperEditor]. diff --git a/super_editor/lib/src/default_editor/text.dart b/super_editor/lib/src/default_editor/text.dart index 7e58cb609d..663a0c12d3 100644 --- a/super_editor/lib/src/default_editor/text.dart +++ b/super_editor/lib/src/default_editor/text.dart @@ -312,6 +312,18 @@ class TextNodePosition extends TextPosition implements NodePosition { TextAffinity affinity = TextAffinity.downstream, }) : super(offset: offset, affinity: affinity); + @override + bool isEquivalentTo(NodePosition other) { + if (other is! TextNodePosition) { + return false; + } + + // Equivalency is determined by text offset. Affinity is ignored, because + // affinity doesn't alter the actual location in the text that a + // TextNodePosition refers to. + return offset == other.offset; + } + TextNodePosition copyWith({ int? offset, TextAffinity? affinity, diff --git a/super_editor/lib/src/infrastructure/_logging.dart b/super_editor/lib/src/infrastructure/_logging.dart index a065714e4d..31a82776c8 100644 --- a/super_editor/lib/src/infrastructure/_logging.dart +++ b/super_editor/lib/src/infrastructure/_logging.dart @@ -11,6 +11,7 @@ class LogNames { static const editorIme = 'editor.ime'; static const editorImeConnection = 'editor.ime.connection'; static const editorImeDeltas = 'editor.ime.deltas'; + static const editorIosFloatingCursor = 'editor.ios.floatingCursor'; static const editorLayout = 'editor.layout'; static const editorStyle = 'editor.style'; static const editorDocument = 'editor.document'; @@ -54,6 +55,7 @@ final editorKeyLog = logging.Logger(LogNames.editorKeys); final editorImeLog = logging.Logger(LogNames.editorIme); final editorImeConnectionLog = logging.Logger(LogNames.editorImeConnection); final editorImeDeltasLog = logging.Logger(LogNames.editorImeDeltas); +final editorIosFloatingCursorLog = logging.Logger(LogNames.editorIosFloatingCursor); final editorLayoutLog = logging.Logger(LogNames.editorLayout); final editorStyleLog = logging.Logger(LogNames.editorStyle); final editorDocLog = logging.Logger(LogNames.editorDocument); diff --git a/super_editor/lib/src/infrastructure/attribution_layout_bounds.dart b/super_editor/lib/src/infrastructure/attribution_layout_bounds.dart index 2055500525..a13f20fae0 100644 --- a/super_editor/lib/src/infrastructure/attribution_layout_bounds.dart +++ b/super_editor/lib/src/infrastructure/attribution_layout_bounds.dart @@ -55,7 +55,7 @@ class _AttributionBoundsState extends ContentLayerState? computeLayoutData(RenderObject? contentLayout) { + List? computeLayoutData(Element? contentElement, RenderObject? contentLayout) { final bounds = []; for (final node in widget.document.nodes) { diff --git a/super_editor/lib/src/infrastructure/blinking_caret.dart b/super_editor/lib/src/infrastructure/blinking_caret.dart index 6df36084d7..a91e332a89 100644 --- a/super_editor/lib/src/infrastructure/blinking_caret.dart +++ b/super_editor/lib/src/infrastructure/blinking_caret.dart @@ -39,6 +39,7 @@ class BlinkingCaretState extends State with SingleTickerProviderS BlinkController( tickerProvider: this, ); + if (widget.caretOffset != null) { _caretBlinkController.jumpToOpaque(); } @@ -48,6 +49,17 @@ class BlinkingCaretState extends State with SingleTickerProviderS void didUpdateWidget(BlinkingCaret oldWidget) { super.didUpdateWidget(oldWidget); + if (widget.controller != oldWidget.controller) { + if (oldWidget.controller == null) { + _caretBlinkController.dispose(); + } + + _caretBlinkController = widget.controller ?? + BlinkController( + tickerProvider: this, + ); + } + if (widget.caretOffset != oldWidget.caretOffset) { if (widget.caretOffset != null) { _caretBlinkController.jumpToOpaque(); diff --git a/super_editor/lib/src/infrastructure/content_layers.dart b/super_editor/lib/src/infrastructure/content_layers.dart index eb37c7fba7..be799e1c9c 100644 --- a/super_editor/lib/src/infrastructure/content_layers.dart +++ b/super_editor/lib/src/infrastructure/content_layers.dart @@ -195,7 +195,7 @@ class ContentLayersElement extends RenderObjectElement { if (isContentDirty && isAnyLayerDirty) { contentLayersLog.fine("Marking needs build because content and at least one layer are both dirty."); - _deactivateLayers(); + _temporarilyForgetLayers(); } }); } @@ -236,7 +236,7 @@ class ContentLayersElement extends RenderObjectElement { } void _onContentBuildScheduled() { - _deactivateLayers(); + _temporarilyForgetLayers(); } @override @@ -284,17 +284,23 @@ class ContentLayersElement extends RenderObjectElement { return newChild; } - void _deactivateLayers() { - contentLayersLog.finer("ContentLayersElement - deactivating layers"); + /// Forgets the overlay and underlay children so that they don't run build at a + /// problematic time, but the same layers can be brought back later, with retained + /// `Element` and `State` objects. + /// + /// Note: If the layers are deactivated, rather than forgotten, new `Element`s and + /// `State`s will be created on every build, which prevents layer `State` objects + /// from retaining information across builds, thus defeating the purpose of using + /// a `StatefulWidget`. + void _temporarilyForgetLayers() { + contentLayersLog.finer("ContentLayersElement - temporarily forgetting layers"); for (final underlay in _underlays) { - deactivateChild(underlay); + forgetChild(underlay); } - _underlays = []; for (final overlay in _overlays) { - deactivateChild(overlay); + forgetChild(overlay); } - _overlays = []; } @override @@ -749,17 +755,16 @@ typedef ContentLayerWidgetBuilder = ContentLayerWidget Function(BuildContext con /// Flutter's build order. This timing issue is only a concern when a layer /// widget inspects content layout within [ContentLayers]. However, to prevent /// developer confusion and mistakes, all layer widgets are forced to be -/// a [ContentLayerWidget]. +/// [ContentLayerWidget]s. /// /// Extend [ContentLayerStatefulWidget] to create a layer that's based on the -/// content layout within the ancestor [ContentLayers], or a layer that requires -/// mutable state. +/// content layout within the ancestor [ContentLayers], and requires mutable state. /// -/// Extend [ContentLayerStatelessWidget] to create a layer that doesn't need to -/// inspect the content layout within the ancestor [ContentLayers], and doesn't -/// need mutable state. +/// Extend [ContentLayerStatelessWidget] to create a layer that's based on the +/// content layout within the ancestor [ContentLayers], but doesn't require mutable +/// state. /// -/// To quickly and easily build a traditional layer widget tree, create a +/// To quickly and easily build a layer from a traditional widget tree, create a /// [ContentLayerProxyWidget] with the desired subtree. This approach is a /// quicker and more convenient alternative to [ContentLayerStatelessWidget] /// for the simplest of layer trees. @@ -771,6 +776,9 @@ abstract class ContentLayerWidget implements Widget { /// subtree, as represented by the given [child]. /// /// The [child] subtree must NOT access the content layout within [ContentLayers]. +/// +/// This widget is an escape hatch to easily display traditional widget subtrees +/// as content layers, when those layers don't care about the layout of the content. class ContentLayerProxyWidget extends ContentLayerStatelessWidget { const ContentLayerProxyWidget({ super.key, @@ -780,40 +788,49 @@ class ContentLayerProxyWidget extends ContentLayerStatelessWidget { final Widget child; @override - Widget doBuild(BuildContext context, RenderObject? contentLayout) { + Widget doBuild(BuildContext context, Element? contentElement, RenderObject? contentLayout) { return child; } } /// Widget that builds a stateless [ContentLayers] layer, which is given access -/// to the ancestor [ContentLayers] content. +/// to the ancestor [ContentLayers] content [Element] and [RenderObject]. abstract class ContentLayerStatelessWidget extends StatelessWidget implements ContentLayerWidget { const ContentLayerStatelessWidget({super.key}); @override Widget build(BuildContext context) { final contentLayers = (context as Element).findAncestorContentLayers(); - final contentLayout = contentLayers?._content?.findRenderObject(); + final contentElement = contentLayers?._content; + final contentLayout = contentElement?.findRenderObject(); - return doBuild(context, contentLayout); + return doBuild(context, contentElement, contentLayout); } @protected - Widget doBuild(BuildContext context, RenderObject? contentLayout); + Widget doBuild(BuildContext context, Element? contentElement, RenderObject? contentLayout); } -abstract class ContentLayerStatefulWidget extends StatefulWidget implements ContentLayerWidget { +/// Widget that builds a stateful [ContentLayers] layer, which is given access +/// to the ancestor [ContentLayers] content [Element] and [RenderObject]. +/// +/// See [ContentLayerState] for information about why a special type of [StatefulWidget] +/// is required for use within [ContentLayers]. +abstract class ContentLayerStatefulWidget extends StatefulWidget implements ContentLayerWidget { const ContentLayerStatefulWidget({super.key}); @override - StatefulElement createElement() { - return ContentLayerStatefulElement(this); - } + StatefulElement createElement() => ContentLayerStatefulElement(this); @override - ContentLayerState createState(); + ContentLayerState createState(); } +/// A [StatefulElement] that looks for an ancestor [ContentLayersElement] and marks +/// that element as needing to rebuild any time that this [ContentLayerStatefulElement] +/// needs to rebuild. +/// +/// In effect, this [Element] connects its dirty state to an ancestor [ContentLayersElement]. class ContentLayerStatefulElement extends StatefulElement { ContentLayerStatefulElement(super.widget); @@ -848,6 +865,8 @@ class ContentLayerStatefulElement extends StatefulElement { } extension on Element { + /// Finds and returns a [ContentLayersElement] by walking up the [Element] tree, + /// beginning with this [Element]. ContentLayersElement? findAncestorContentLayers() { ContentLayersElement? contentLayersElement; @@ -864,24 +883,57 @@ extension on Element { } } +/// A state object for a [ContentLayerStatefulWidget]. +/// +/// A [ContentLayerState] needs to be implemented a little bit differently than +/// a traditional [StatefulWidget]. Calling `setState()` will cause this widget +/// to rebuild, but the ancestor [ContentLayers] has no control over WHEN this +/// widget will rebuild. This widget might rebuild before the content layer can +/// run its layout. If this widget then attempts to query the content layout, +/// Flutter throws an exception. +/// +/// To work around the rebuild timing issues, a [ContentLayerState] separates +/// layout inspection from the build process. A [ContentLayerState] should +/// collect all the layout information it needs in [computeLayoutData] and then +/// it should build its subtree in [doBuild]. +/// +/// A [ContentLayerState] should NOT implement [build] - that implementation is +/// handled on your behalf, and it coordinates between [computeLayoutData] and +/// [doBuild]. abstract class ContentLayerState extends State { + @protected + LayoutDataType? get layoutData => _layoutData; LayoutDataType? _layoutData; + /// Traditional build method for this widget - this method should not be overridden + /// in subclasses. @override Widget build(BuildContext context) { final contentLayers = (context as Element).findAncestorContentLayers(); - final contentLayout = contentLayers?._content?.findRenderObject(); + final contentElement = contentLayers?._content; + final contentLayout = contentElement?.findRenderObject(); if (contentLayers != null && !contentLayers.renderObject.contentNeedsLayout) { - _layoutData = computeLayoutData(contentLayout); + _layoutData = computeLayoutData(contentElement, contentLayout); } return doBuild(context, _layoutData); } - LayoutDataType? computeLayoutData(RenderObject? contentLayout); + /// Computes and returns cached layout data, derived from the content layer's [Element] + /// and [RenderObject]. + /// + /// Subclasses can choose what action to take when the [contentElement] or [contentLayout] + /// are `null`, and therefore unavailable. + LayoutDataType? computeLayoutData(Element? contentElement, RenderObject? contentLayout); + /// Composes and returns the subtree for this widget. + /// + /// This method should be treated as the replacement for the traditional [build] method. + /// + /// [doBuild] is provided with the latest available layout data, which was computed + /// by [computeLayoutData]. @protected Widget doBuild(BuildContext context, LayoutDataType? layoutData); } diff --git a/super_editor/lib/src/infrastructure/documents/document_layers.dart b/super_editor/lib/src/infrastructure/documents/document_layers.dart new file mode 100644 index 0000000000..59acab27d9 --- /dev/null +++ b/super_editor/lib/src/infrastructure/documents/document_layers.dart @@ -0,0 +1,55 @@ +import 'package:flutter/widgets.dart'; +import 'package:super_editor/src/core/document_layout.dart'; +import 'package:super_editor/src/infrastructure/content_layers.dart'; + +/// A [ContentLayerStatelessWidget] that expects a content layer [Element] that +/// implements [DocumentLayout]. +/// +/// {@template document_layout_layer} +/// When working with documents, there might be any number of layers that need to +/// inspect the document layout. Each layer could manually locate the associated +/// [DocumentLayout], but to remove that repeated effort, this widget finds and +/// provides the [DocumentLayout] to its subclasses, so the subclasses can focus +/// on inspecting that layout. +/// {@endtemplate} +abstract class DocumentLayoutLayerStatelessWidget extends ContentLayerStatelessWidget { + const DocumentLayoutLayerStatelessWidget({super.key}); + + @override + Widget doBuild(BuildContext context, Element? contentElement, RenderObject? contentLayout) { + if (contentElement == null || contentElement is! StatefulElement || contentElement.state is! DocumentLayout) { + return const SizedBox(); + } + + return buildWithDocumentLayout(context, contentElement.state as DocumentLayout); + } + + @protected + Widget buildWithDocumentLayout(BuildContext context, DocumentLayout documentLayout); +} + +/// A [ContentLayerStatefulWidget] that expects a content layer [Element] that +/// implements [DocumentLayout]. +/// +/// {@macro document_layout_layer} +abstract class DocumentLayoutLayerStatefulWidget extends ContentLayerStatefulWidget { + const DocumentLayoutLayerStatefulWidget({super.key}); + + @override + DocumentLayoutLayerState createState(); +} + +abstract class DocumentLayoutLayerState + extends ContentLayerState { + @override + LayoutDataType? computeLayoutData(Element? contentElement, RenderObject? contentLayout) { + if (contentElement == null || contentElement is! StatefulElement || contentElement.state is! DocumentLayout) { + return null; + } + + return computeLayoutDataWithDocumentLayout(context, contentElement.state as DocumentLayout); + } + + @protected + LayoutDataType? computeLayoutDataWithDocumentLayout(BuildContext context, DocumentLayout documentLayout); +} diff --git a/super_editor/lib/src/infrastructure/documents/document_scaffold.dart b/super_editor/lib/src/infrastructure/documents/document_scaffold.dart index 700b18c01f..0e36259c63 100644 --- a/super_editor/lib/src/infrastructure/documents/document_scaffold.dart +++ b/super_editor/lib/src/infrastructure/documents/document_scaffold.dart @@ -137,19 +137,19 @@ class _DocumentScaffoldState extends State { Widget _buildDocumentLayout() { return Align( alignment: Alignment.topCenter, - child: ContentLayers( - content: (onBuildScheduled) => CompositedTransformTarget( - link: widget.documentLayoutLink, - child: SingleColumnDocumentLayout( + child: CompositedTransformTarget( + link: widget.documentLayoutLink, + child: ContentLayers( + content: (onBuildScheduled) => SingleColumnDocumentLayout( key: widget.documentLayoutKey, presenter: widget.presenter, componentBuilders: widget.componentBuilders, onBuildScheduled: onBuildScheduled, showDebugPaint: widget.debugPaint.layout, ), + underlays: widget.underlays, + overlays: widget.overlays, ), - underlays: widget.underlays, - overlays: widget.overlays, ), ); } diff --git a/super_editor/lib/src/infrastructure/documents/document_scroller.dart b/super_editor/lib/src/infrastructure/documents/document_scroller.dart index 748958042f..b6f6402733 100644 --- a/super_editor/lib/src/infrastructure/documents/document_scroller.dart +++ b/super_editor/lib/src/infrastructure/documents/document_scroller.dart @@ -8,6 +8,10 @@ import 'package:flutter/widgets.dart'; /// to an ancestor `Scrollable`, if the document experience chooses to use an /// ancestor `Scrollable`. class DocumentScroller { + void dispose() { + _scrollChangeListeners.clear(); + } + /// The height of a vertically scrolling viewport, or the width of a horizontally /// scrolling viewport. double get viewportDimension => _scrollPosition!.viewportDimension; @@ -48,9 +52,23 @@ class DocumentScroller { void attach(ScrollPosition scrollPosition) { _scrollPosition = scrollPosition; + _scrollPosition!.addListener(_notifyScrollChangeListeners); } void detach() { + _scrollPosition?.removeListener(_notifyScrollChangeListeners); _scrollPosition = null; } + + final _scrollChangeListeners = {}; + + void addScrollChangeListener(VoidCallback listener) => _scrollChangeListeners.add(listener); + + void removeScrollChangeListener(VoidCallback listener) => _scrollChangeListeners.remove(listener); + + void _notifyScrollChangeListeners() { + for (final listener in _scrollChangeListeners) { + listener(); + } + } } diff --git a/super_editor/lib/src/infrastructure/documents/document_selection.dart b/super_editor/lib/src/infrastructure/documents/document_selection.dart new file mode 100644 index 0000000000..67acc833a3 --- /dev/null +++ b/super_editor/lib/src/infrastructure/documents/document_selection.dart @@ -0,0 +1,60 @@ +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/core/document_selection.dart'; + +/// Given a [DocumentSelection], which might span text and non-text content, extracts +/// all text from that selection as an un-styled `String`. +String extractTextFromSelection({ + required Document document, + required DocumentSelection documentSelection, +}) { + final selectedNodes = document.getNodesInside( + documentSelection.base, + documentSelection.extent, + ); + + final buffer = StringBuffer(); + for (int i = 0; i < selectedNodes.length; ++i) { + final selectedNode = selectedNodes[i]; + dynamic nodeSelection; + + if (i == 0) { + // This is the first node and it may be partially selected. + final baseSelectionPosition = selectedNode.id == documentSelection.base.nodeId + ? documentSelection.base.nodePosition + : documentSelection.extent.nodePosition; + + final extentSelectionPosition = + selectedNodes.length > 1 ? selectedNode.endPosition : documentSelection.extent.nodePosition; + + nodeSelection = selectedNode.computeSelection( + base: baseSelectionPosition, + extent: extentSelectionPosition, + ); + } else if (i == selectedNodes.length - 1) { + // This is the last node and it may be partially selected. + final nodePosition = selectedNode.id == documentSelection.base.nodeId + ? documentSelection.base.nodePosition + : documentSelection.extent.nodePosition; + + nodeSelection = selectedNode.computeSelection( + base: selectedNode.beginningPosition, + extent: nodePosition, + ); + } else { + // This node is fully selected. Copy the whole thing. + nodeSelection = selectedNode.computeSelection( + base: selectedNode.beginningPosition, + extent: selectedNode.endPosition, + ); + } + + final nodeContent = selectedNode.copyContent(nodeSelection); + if (nodeContent != null) { + buffer.write(nodeContent); + if (i < selectedNodes.length - 1) { + buffer.writeln(); + } + } + } + return buffer.toString(); +} diff --git a/super_editor/lib/src/infrastructure/selection_leader_document_layer.dart b/super_editor/lib/src/infrastructure/documents/selection_leader_document_layer.dart similarity index 92% rename from super_editor/lib/src/infrastructure/selection_leader_document_layer.dart rename to super_editor/lib/src/infrastructure/documents/selection_leader_document_layer.dart index 7c9943f80e..bfe621d6a9 100644 --- a/super_editor/lib/src/infrastructure/selection_leader_document_layer.dart +++ b/super_editor/lib/src/infrastructure/documents/selection_leader_document_layer.dart @@ -6,6 +6,7 @@ import 'package:super_editor/src/core/document.dart'; import 'package:super_editor/src/core/document_layout.dart'; import 'package:super_editor/src/core/document_selection.dart'; import 'package:super_editor/src/infrastructure/content_layers.dart'; +import 'package:super_editor/src/infrastructure/documents/document_layers.dart'; /// A document layer that positions leader widgets at the user's selection bounds. /// @@ -15,12 +16,11 @@ import 'package:super_editor/src/infrastructure/content_layers.dart'; /// on the document's self-reported caret height for the given document position. /// /// When no selection exists, no leaders are built in the layer's widget tree. -class SelectionLeadersDocumentLayer extends ContentLayerStatefulWidget { +class SelectionLeadersDocumentLayer extends DocumentLayoutLayerStatefulWidget { const SelectionLeadersDocumentLayer({ Key? key, required this.document, required this.selection, - required this.documentLayoutResolver, required this.links, this.showDebugLeaderBounds = false, }) : super(key: key); @@ -32,10 +32,6 @@ class SelectionLeadersDocumentLayer extends ContentLayerStatefulWidget { /// The current user's selection within a document. final ValueListenable selection; - /// Delegate that returns a reference to the editor's [DocumentLayout], so - /// that the current selection can be mapped to an (x,y) offset and a height. - final DocumentLayout Function() documentLayoutResolver; - /// Collections of [LayerLink]s, which are given to leader widgets that are /// positioned at the selection bounds, and around the full selection. final SelectionLayerLinks links; @@ -44,12 +40,12 @@ class SelectionLeadersDocumentLayer extends ContentLayerStatefulWidget { final bool showDebugLeaderBounds; @override - ContentLayerState createState() => + DocumentLayoutLayerState createState() => _SelectionLeadersDocumentLayerState(); } class _SelectionLeadersDocumentLayerState - extends ContentLayerState + extends DocumentLayoutLayerState with SingleTickerProviderStateMixin { @override void initState() { @@ -86,13 +82,12 @@ class _SelectionLeadersDocumentLayerState /// Updates the caret rect, immediately, without scheduling a rebuild. @override - DocumentSelectionLayout? computeLayoutData(RenderObject? contentLayout) { + DocumentSelectionLayout? computeLayoutDataWithDocumentLayout(BuildContext context, DocumentLayout documentLayout) { final documentSelection = widget.selection.value; if (documentSelection == null) { return null; } - final documentLayout = widget.documentLayoutResolver(); final selectedComponent = documentLayout.getComponentByNodeId(widget.selection.value!.extent.nodeId); if (selectedComponent == null) { // Assume that we're in a momentary transitive state where the document layout diff --git a/super_editor/lib/src/infrastructure/flutter/flutter_scheduler.dart b/super_editor/lib/src/infrastructure/flutter/flutter_scheduler.dart index 3ea5b015ab..e9cba97949 100644 --- a/super_editor/lib/src/infrastructure/flutter/flutter_scheduler.dart +++ b/super_editor/lib/src/infrastructure/flutter/flutter_scheduler.dart @@ -47,10 +47,16 @@ extension Frames on State { /// current build phase completes. Otherwise, [stateChange] is run immediately. void setStateAsSoonAsPossible(VoidCallback stateChange) { WidgetsBinding.instance.runAsSoonAsPossible( - // ignore: invalid_use_of_protected_member - () => setState(() { - stateChange(); - }), + () { + if (!mounted) { + return; + } + + // ignore: invalid_use_of_protected_member + setState(() { + stateChange(); + }); + }, ); } diff --git a/super_editor/lib/src/infrastructure/platforms/ios/floating_cursor.dart b/super_editor/lib/src/infrastructure/platforms/ios/floating_cursor.dart new file mode 100644 index 0000000000..c24993eb00 --- /dev/null +++ b/super_editor/lib/src/infrastructure/platforms/ios/floating_cursor.dart @@ -0,0 +1,11 @@ +/// Values that reflect standard or default floating cursor policies. +class FloatingCursorPolicies { + static const defaultFloatingCursorHeight = 20.0; + static const defaultFloatingCursorWidth = 2.0; + + /// The maximum horizontal distance from the bounds of selectable text, for which we want to render + /// the floating cursor. + /// + /// Beyond this distance, no floating cursor is rendered. + static const maximumDistanceToBeNearText = 30.0; +} 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 cfecc159a1..3f822fc3af 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 @@ -1,55 +1,55 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; import 'package:follow_the_leader/follow_the_leader.dart'; +import 'package:overlord/follow_the_leader.dart'; import 'package:super_editor/src/core/document.dart'; 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/default_editor/text.dart'; +import 'package:super_editor/src/default_editor/document_gestures_touch_ios.dart'; import 'package:super_editor/src/infrastructure/_logging.dart'; +import 'package:super_editor/src/infrastructure/content_layers.dart'; +import 'package:super_editor/src/infrastructure/documents/document_layers.dart'; +import 'package:super_editor/src/infrastructure/documents/selection_leader_document_layer.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/platforms/ios/selection_handles.dart'; import 'package:super_editor/src/infrastructure/platforms/mobile_documents.dart'; -import 'package:super_editor/src/infrastructure/text_input.dart'; -import 'package:super_editor/src/infrastructure/toolbar_position_delegate.dart'; import 'package:super_editor/src/infrastructure/touch_controls.dart'; import 'package:super_text_layout/super_text_layout.dart'; -import 'magnifier.dart'; +class DocumentKeys { + static const mobileToolbar = ValueKey("document_mobile_toolbar"); + static const magnifier = ValueKey("document_magnifier"); + static const iOsCaret = ValueKey("document_ios_caret"); + static const androidCaret = ValueKey("document_android_caret"); + static const androidCaretHandle = ValueKey("document_android_caret_handle"); + static const upstreamHandle = ValueKey("document_upstream_handle"); + static const downstreamHandle = ValueKey("document_downstream_handle"); -class IosDocumentTouchEditingControls extends StatefulWidget { - const IosDocumentTouchEditingControls({ + DocumentKeys._(); +} + +/// An application overlay that displays an iOS-style toolbar. +class IosFloatingToolbarOverlay extends StatefulWidget { + const IosFloatingToolbarOverlay({ Key? key, - required this.editingController, - required this.floatingCursorController, - required this.documentLayout, - required this.document, - required this.selection, - required this.changeSelection, - required this.handleColor, - this.onDoubleTapOnCaret, - this.onTripleTapOnCaret, - this.onFloatingCursorStart, - this.onFloatingCursorMoved, - this.onFloatingCursorStop, - this.magnifierFocalPointOffset, - required this.popoverToolbarBuilder, + required this.shouldShowToolbar, + required this.toolbarFocalPoint, + required this.floatingToolbarBuilder, this.createOverlayControlsClipper, - this.disableGestureHandling = false, this.showDebugPaint = false, }) : super(key: key); - final IosDocumentGestureEditingController editingController; - - final Document document; - - final ValueListenable selection; - - final void Function(DocumentSelection?, SelectionChangeType, String selectionReason) changeSelection; + final ValueListenable shouldShowToolbar; - final FloatingCursorController floatingCursorController; - - final DocumentLayout documentLayout; + /// The focal point, which determines where the toolbar is positioned, and + /// where the toolbar points. + /// + /// In the case that the associated [Leader] has meaningful width and height, + /// the toolbar focuses on the center of the [Leader]'s bounding box. + final LeaderLink toolbarFocalPoint; /// Creates a clipper that applies to overlay controls, preventing /// the overlay controls from appearing outside the given clipping @@ -60,404 +60,48 @@ class IosDocumentTouchEditingControls extends StatefulWidget { /// (probably the entire screen). final CustomClipper Function(BuildContext overlayContext)? createOverlayControlsClipper; - /// Color the iOS-style text selection drag handles. - final Color handleColor; - - /// Callback invoked on iOS when the user double taps on the caret. - final VoidCallback? onDoubleTapOnCaret; - - /// Callback invoked on iOS when the user triple taps on the caret. - final VoidCallback? onTripleTapOnCaret; - - /// Callback invoked when the floating cursor becomes visible. - final VoidCallback? onFloatingCursorStart; - - /// Callback invoked whenever the iOS floating cursor moves to a new - /// position. - final void Function(Offset)? onFloatingCursorMoved; - - /// Callback invoked when the floating cursor disappears. - final VoidCallback? onFloatingCursorStop; - - /// Offset where the magnifier should focus. - /// - /// The magnifier is displayed whenever this offset is non-null, otherwise - /// the magnifier is not shown. - final Offset? magnifierFocalPointOffset; - - /// Builder that constructs the popover toolbar that's displayed above + /// Builder that constructs the floating toolbar that's displayed above /// selected text. /// /// Typically, this bar includes actions like "copy", "cut", "paste", etc. - final WidgetBuilder popoverToolbarBuilder; - - /// Disables all gesture interaction for these editing controls, - /// allowing gestures to pass through these controls to whatever - /// content currently sits beneath them. - /// - /// While this is `true`, the user can't tap or drag on selection - /// handles or other controls. - final bool disableGestureHandling; + final DocumentFloatingToolbarBuilder floatingToolbarBuilder; final bool showDebugPaint; @override - State createState() => _IosDocumentTouchEditingControlsState(); + State createState() => _IosFloatingToolbarOverlayState(); } -class _IosDocumentTouchEditingControlsState extends State - with SingleTickerProviderStateMixin { - /// The maximum horizontal distance from the bounds of selectable text, for which we want to render - /// the floating cursor. - /// - /// Beyond this distance, no floating cursor is rendered. - static const _maximumDistanceToBeNearText = 30.0; - - static const _defaultFloatingCursorHeight = 20.0; - static const _defaultFloatingCursorWidth = 2.0; - - // These global keys are assigned to each draggable handle to - // prevent a strange dragging issue. - // - // Without these keys, if the user drags into the auto-scroll area - // for a period of time, we never receive a - // "pan end" or "pan cancel" callback. I have no idea why this is - // the case. These handles sit in an Overlay, so it's not as if they - // suffered some conflict within a ScrollView. I tried many adjustments - // to recover the end/cancel callbacks. Finally, I tried adding these - // global keys based on a hunch that perhaps the gesture detector was - // somehow getting switched out, or assigned to a different widget, and - // that was somehow disrupting the callback series. For now, these keys - // seem to solve the problem. - final _collapsedHandleKey = GlobalKey(); - final _upstreamHandleKey = GlobalKey(); - final _downstreamHandleKey = GlobalKey(); - - late BlinkController _caretBlinkController; - Offset? _prevCaretOffset; - - final _isShowingFloatingCursor = ValueNotifier(false); - final _isFloatingCursorOverOrNearText = ValueNotifier(false); - final _floatingCursorKey = GlobalKey(); - Offset? _initialFloatingCursorOffset; - final _floatingCursorOffset = ValueNotifier(null); - double _floatingCursorHeight = _defaultFloatingCursorHeight; - - @override - void initState() { - super.initState(); - _caretBlinkController = BlinkController(tickerProvider: this); - _prevCaretOffset = widget.editingController.caretTop; - widget.editingController.addListener(_onEditingControllerChange); - widget.floatingCursorController.addListener(_onFloatingCursorChange); - } - - @override - void didUpdateWidget(IosDocumentTouchEditingControls oldWidget) { - super.didUpdateWidget(oldWidget); - - if (widget.editingController != oldWidget.editingController) { - oldWidget.editingController.removeListener(_onEditingControllerChange); - widget.editingController.addListener(_onEditingControllerChange); - } - if (widget.floatingCursorController != oldWidget.floatingCursorController) { - oldWidget.floatingCursorController.removeListener(_onFloatingCursorChange); - widget.floatingCursorController.addListener(_onFloatingCursorChange); - } - } - - @override - void dispose() { - widget.floatingCursorController.removeListener(_onFloatingCursorChange); - widget.editingController.removeListener(_onEditingControllerChange); - _caretBlinkController.dispose(); - super.dispose(); - } - - void _onEditingControllerChange() { - if (_prevCaretOffset != widget.editingController.caretTop) { - if (widget.editingController.caretTop == null) { - _caretBlinkController.stopBlinking(); - } else { - _caretBlinkController.jumpToOpaque(); - } - - _prevCaretOffset = widget.editingController.caretTop; - } - } - - void _onFloatingCursorChange() { - if (widget.floatingCursorController.offset == null) { - if (_floatingCursorOffset.value != null) { - _isShowingFloatingCursor.value = false; - - _caretBlinkController.startBlinking(); - - _isFloatingCursorOverOrNearText.value = false; - _initialFloatingCursorOffset = null; - _floatingCursorOffset.value = null; - _floatingCursorHeight = _defaultFloatingCursorHeight; - - widget.onFloatingCursorStop?.call(); - } - - return; - } - - if (widget.selection.value == null) { - // The floating cursor doesn't mean anything when nothing is selected. - return; - } - - if (!widget.selection.value!.isCollapsed) { - // The selection is expanded. First we need to collapse it, then - // we can start showing the floating cursor. - widget.changeSelection( - widget.selection.value!.collapseDownstream(widget.document), - SelectionChangeType.expandSelection, - SelectionReason.userInteraction, - ); - onNextFrame((_) => _onFloatingCursorChange()); - return; - } - - if (_floatingCursorOffset.value == null) { - // The floating cursor just started. - widget.onFloatingCursorStart?.call(); - _isShowingFloatingCursor.value = true; - } - - _caretBlinkController.stopBlinking(); - widget.editingController.hideToolbar(); - widget.editingController.hideMagnifier(); - - _initialFloatingCursorOffset ??= - widget.editingController.caretTop! + const Offset(-1, 0) + Offset(0, widget.editingController.caretHeight! / 2); - _floatingCursorOffset.value = _initialFloatingCursorOffset! + widget.floatingCursorController.offset!; - - final nearestDocPosition = widget.documentLayout.getDocumentPositionNearestToOffset(_floatingCursorOffset.value!)!; - if (nearestDocPosition.nodePosition is TextNodePosition) { - final nearestPositionRect = widget.documentLayout.getRectForPosition(nearestDocPosition)!; - _floatingCursorHeight = nearestPositionRect.height; - - final distance = _floatingCursorOffset.value! - nearestPositionRect.topLeft + const Offset(1.0, 0.0); - _isFloatingCursorOverOrNearText.value = distance.dx.abs() <= _maximumDistanceToBeNearText; - } else { - final nearestComponent = widget.documentLayout.getComponentByNodeId(nearestDocPosition.nodeId)!; - _floatingCursorHeight = (nearestComponent.context.findRenderObject() as RenderBox).size.height; - _isFloatingCursorOverOrNearText.value = false; - } - - widget.onFloatingCursorMoved?.call(_floatingCursorOffset.value!); - } +class _IosFloatingToolbarOverlayState extends State with SingleTickerProviderStateMixin { + final _boundsKey = GlobalKey(); @override Widget build(BuildContext context) { return ListenableBuilder( - listenable: widget.editingController, - builder: (context, _) { - return Padding( - // Remove the keyboard from the space that we occupy so that - // clipping calculations apply to the expected visual borders, - // instead of applying underneath the keyboard. - padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), - child: ClipRect( - clipper: widget.createOverlayControlsClipper?.call(context), - child: SizedBox( - // ^ SizedBox tries to be as large as possible, because - // a Stack will collapse into nothing unless something - // expands it. - width: double.infinity, - height: double.infinity, - child: Stack( - children: [ - // Build caret or drag handles - _buildHandles(), - // Build the floating cursor - _buildFloatingCursor(), - // Build the editing toolbar. - // We don't show toolbar on web because the browser already displays the native toolbar. - if (!isWeb && - widget.editingController.shouldDisplayToolbar && - widget.editingController.isToolbarPositioned) - _buildToolbar(), - // Build the focal point for the magnifier. - // Don't build the focal point on web because, on web, we defer to the native magnifier. - if (!isWeb && widget.magnifierFocalPointOffset != null) _buildMagnifierFocalPoint(), - // Build the magnifier. - // We don't show magnifier on web because the browser already displays the native magnifier. - if (!isWeb && widget.editingController.shouldDisplayMagnifier) _buildMagnifier(), - if (widget.showDebugPaint) - IgnorePointer( - child: Container( - width: double.infinity, - height: double.infinity, - color: Colors.yellow.withOpacity(0.2), - ), - ), - ], - ), - ), - ), - ); - }); - } - - Widget _buildHandles() { - // When the floating cursor is over text or near text, - // we don't show the drag handles. - // - // Every time the floating cursor moves to a position which - // changes this state or when it changes its visibility, - // this widget is rebuilt. - return ValueListenableBuilder( - valueListenable: _isFloatingCursorOverOrNearText, - builder: (context, isNearText, __) { - if (isNearText) { - return const SizedBox.shrink(); - } - - if (!widget.editingController.shouldDisplayCollapsedHandle && - !widget.editingController.shouldDisplayExpandedHandles) { - editorGesturesLog.finer('Not building overlay handles because they aren\'t desired'); - return const SizedBox.shrink(); - } - - late List handles; - - if (widget.editingController.shouldDisplayCollapsedHandle) { - handles = [ - _buildCollapsedHandle(), - ]; - } else { - handles = _buildExpandedHandles(); - } - - return Stack( - children: handles, - ); - }, - ); - } - - Widget _buildCollapsedHandle() { - return _buildHandleOld( - handleKey: _collapsedHandleKey, - handleType: HandleType.collapsed, - debugColor: Colors.blue, - ); - } - - List _buildExpandedHandles() { - return [ - // Left-bounding handle touch target - _buildHandleOld( - handleKey: _upstreamHandleKey, - handleType: HandleType.upstream, - debugColor: Colors.green, - ), - // right-bounding handle touch target - _buildHandleOld( - handleKey: _downstreamHandleKey, - handleType: HandleType.downstream, - debugColor: Colors.red, - ), - ]; - } - - Widget _buildHandleOld({ - required Key handleKey, - required HandleType handleType, - required Color debugColor, - }) { - const ballDiameter = 8.0; - - late LeaderLink handleLink; - late Widget handle; - switch (handleType) { - case HandleType.collapsed: - handleLink = widget.editingController.selectionLinks.caretLink; - handle = ValueListenableBuilder( - valueListenable: _isShowingFloatingCursor, - builder: (context, isShowingFloatingCursor, child) { - return IOSCollapsedHandle( - controller: _caretBlinkController, - color: isShowingFloatingCursor ? Colors.grey : widget.handleColor, - caretHeight: widget.editingController.caretHeight!, - ); - }, - ); - break; - case HandleType.upstream: - handleLink = widget.editingController.selectionLinks.upstreamLink; - handle = IOSSelectionHandle.upstream( - color: widget.handleColor, - handleType: handleType, - caretHeight: widget.editingController.upstreamCaretHeight!, - ballRadius: ballDiameter / 2, - ); - break; - case HandleType.downstream: - handleLink = widget.editingController.selectionLinks.downstreamLink; - handle = IOSSelectionHandle.upstream( - color: widget.handleColor, - handleType: handleType, - caretHeight: widget.editingController.downstreamCaretHeight!, - ballRadius: ballDiameter / 2, - ); - break; - } - - return _buildHandle( - handleKey: handleKey, - handle: handle, - handleLink: handleLink, - debugColor: debugColor, - ); - } - - Widget _buildHandle({ - required Key handleKey, - required Widget handle, - required LeaderLink handleLink, - required Color debugColor, - }) { - return Follower.withOffset( - key: handleKey, - link: handleLink, - leaderAnchor: Alignment.center, - followerAnchor: Alignment.center, - showWhenUnlinked: false, - child: IgnorePointer( - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 5), - color: widget.showDebugPaint ? debugColor : Colors.transparent, - child: handle, - ), - ), - ); - } - - Widget _buildFloatingCursor() { - return ValueListenableBuilder( - valueListenable: _floatingCursorOffset, - builder: (context, floatingCursorOffset, child) { - if (floatingCursorOffset == null) { - return const SizedBox(); - } - - return CompositedTransformFollower( - key: _floatingCursorKey, - link: widget.editingController.documentLayoutLink, - offset: floatingCursorOffset - Offset(0, _floatingCursorHeight / 2) + const Offset(-5, 0), - child: IgnorePointer( - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 5), - color: widget.showDebugPaint ? Colors.blue : Colors.transparent, - child: Container( - width: _defaultFloatingCursorWidth, - height: _floatingCursorHeight, - color: Colors.red.withOpacity(0.75), + listenable: widget.shouldShowToolbar, + builder: (context, _) { + return Padding( + // Remove the keyboard from the space that we occupy so that + // clipping calculations apply to the expected visual borders, + // instead of applying underneath the keyboard. + padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom), + child: ClipRect( + clipper: widget.createOverlayControlsClipper?.call(context), + child: SizedBox( + // ^ SizedBox tries to be as large as possible, because + // a Stack will collapse into nothing unless something + // expands it. + key: _boundsKey, + width: double.infinity, + height: double.infinity, + child: Stack( + children: [ + // Build the editing toolbar + if (widget.shouldShowToolbar.value) // + _buildToolbar(), + if (widget.showDebugPaint) // + _buildDebugPaint(), + ], ), ), ), @@ -466,65 +110,31 @@ class _IosDocumentTouchEditingControlsState extends State _newMagnifierLink; + LayerLink? _newMagnifierLink; + set newMagnifierLink(LayerLink? link) { + if (_newMagnifierLink == link) { + return; + } + + _newMagnifierLink = link; + notifyListeners(); + } } -class FloatingCursorController with ChangeNotifier { +class FloatingCursorController { + void dispose() { + isActive.dispose(); + isNearText.dispose(); + cursorGeometryInViewport.dispose(); + _listeners.clear(); + } + + /// Whether the user is currently interacting with the floating cursor via the + /// software keyboard. + final isActive = ValueNotifier(false); + + /// Whether the floating cursor is currently near text, which impacts whether + /// or not a standard gray caret should be displayed. + final isNearText = ValueNotifier(false); + + /// The offset, width, and height of the active floating cursor. + final cursorGeometryInViewport = ValueNotifier(null); + + /// Report that the user has activated the floating cursor. + void onStart() { + isActive.value = true; + for (final listener in _listeners) { + listener.onStart(); + } + } + Offset? get offset => _offset; Offset? _offset; - set offset(Offset? newOffset) { + + /// Report that the user has moved the floating cursor. + void onMove(Offset? newOffset) { if (newOffset == _offset) { return; } _offset = newOffset; - notifyListeners(); + + for (final listener in _listeners) { + listener.onMove(newOffset); + } + } + + /// Report that the user has deactivated the floating cursor. + void onStop() { + isActive.value = false; + for (final listener in _listeners) { + listener.onStop(); + } + } + + final _listeners = {}; + + void addListener(FloatingCursorListener listener) { + _listeners.add(listener); + } + + void removeListener(FloatingCursorListener listener) { + _listeners.remove(listener); } } + +class FloatingCursorListener { + FloatingCursorListener({ + VoidCallback? onStart, + void Function(Offset?)? onMove, + VoidCallback? onStop, + }) : _onStart = onStart, + _onMove = onMove, + _onStop = onStop; + + final VoidCallback? _onStart; + final void Function(Offset?)? _onMove; + final VoidCallback? _onStop; + + void onStart() => _onStart?.call(); + + void onMove(Offset? newOffset) => _onMove?.call(newOffset); + + void onStop() => _onStop?.call(); +} + +/// A document layer that positions a leader widget around the user's selection, +/// as a focal point for an iOS-style toolbar display. +/// +/// By default, the toolbar focal point [LeaderLink] is obtained from an ancestor +/// [SuperEditorIosControlsScope]. +class IosToolbarFocalPointDocumentLayer extends DocumentLayoutLayerStatefulWidget { + const IosToolbarFocalPointDocumentLayer({ + Key? key, + required this.document, + required this.selection, + required this.toolbarFocalPointLink, + this.showDebugLeaderBounds = false, + }) : super(key: key); + + /// The editor's [Document], which is used to find the start and end of + /// the user's expanded selection. + final Document document; + + /// The current user's selection within a document. + final ValueListenable selection; + + /// The [LeaderLink], which is attached to the toolbar focal point bounds. + /// + /// By default, this [LeaderLink] is obtained from an ancestor [SuperEditorIosControlsScope]. + /// If [toolbarFocalPointLink] is non-null, it's used instead of the ancestor value. + final LeaderLink toolbarFocalPointLink; + + /// Whether to paint colorful bounds around the leader widgets, for debugging purposes. + final bool showDebugLeaderBounds; + + @override + DocumentLayoutLayerState createState() => _IosToolbarFocalPointDocumentLayerState(); +} + +class _IosToolbarFocalPointDocumentLayerState extends DocumentLayoutLayerState + with SingleTickerProviderStateMixin { + bool _wasSelectionExpanded = false; + + @override + void initState() { + super.initState(); + + widget.selection.addListener(_onSelectionChange); + } + + @override + void didUpdateWidget(IosToolbarFocalPointDocumentLayer oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.selection != oldWidget.selection) { + oldWidget.selection.removeListener(_onSelectionChange); + widget.selection.addListener(_onSelectionChange); + } + } + + @override + void dispose() { + widget.selection.removeListener(_onSelectionChange); + + super.dispose(); + } + + void _onSelectionChange() { + final selection = widget.selection.value; + _wasSelectionExpanded = !(selection?.isCollapsed == true); + + if (selection == null && !_wasSelectionExpanded) { + // There's no selection now, and in the previous frame there either was no selection, + // or a collapsed selection. We don't need to worry about re-calculating or rebuilding + // our bounds. + return; + } + if (selection != null && selection.isCollapsed && !_wasSelectionExpanded) { + // The current selection is collapsed, and the selection in the previous frame was + // either null, or was also collapsed. We only need to position bounds when the selection + // is expanded, or goes from expanded to collapsed, or from collapsed to expanded. + return; + } + + // The current selection is expanded, or we went from expanded in the previous frame + // to non-expanded in this frame. Either way, we need to recalculate the toolbar focal + // point bounds. + setStateAsSoonAsPossible(() { + // The selection bounds, and Leader build, will take place in methods that + // run in response to setState(). + }); + } + + @override + Rect? computeLayoutDataWithDocumentLayout(BuildContext context, DocumentLayout documentLayout) { + final documentSelection = widget.selection.value; + if (documentSelection == null) { + return null; + } + + final selectedComponent = documentLayout.getComponentByNodeId(widget.selection.value!.extent.nodeId); + if (selectedComponent == null) { + // Assume that we're in a momentary transitive state where the document layout + // just gained or lost a component. We expect this method to run again in a moment + // to correct for this. + return null; + } + + if (documentSelection.isCollapsed) { + return null; + } + + return documentLayout.getRectForSelection( + documentSelection.base, + documentSelection.extent, + ); + } + + @override + Widget doBuild(BuildContext context, Rect? expandedSelectionBounds) { + if (expandedSelectionBounds == null) { + return const SizedBox(); + } + + return IgnorePointer( + child: Stack( + children: [ + Positioned.fromRect( + rect: expandedSelectionBounds, + child: Leader( + link: widget.toolbarFocalPointLink, + child: widget.showDebugLeaderBounds + ? DecoratedBox( + decoration: BoxDecoration( + border: Border.all( + width: 4, + color: const Color(0xFFFF00FF), + ), + ), + ) + : null, + ), + ), + ], + ), + ); + } +} + +/// A document layer that displays an iOS-style caret and handles. +/// +/// This layer positions the caret and handles directly, rather than using +/// `Leader`s and `Follower`s, because their position is based on the document +/// layout, rather than the user's gesture behavior. +class IosHandlesDocumentLayer extends DocumentLayoutLayerStatefulWidget { + const IosHandlesDocumentLayer({ + super.key, + required this.document, + required this.documentLayout, + required this.selection, + required this.changeSelection, + required this.handleColor, + required this.shouldCaretBlink, + this.floatingCursorController, + this.showDebugPaint = false, + }); + + final Document document; + + final DocumentLayout documentLayout; + + final ValueListenable selection; + + final void Function(DocumentSelection?, SelectionChangeType, String selectionReason) changeSelection; + + /// Color the iOS-style text selection drag handles. + final Color handleColor; + + /// Whether the caret should blink, whenever the caret is visible. + final ValueListenable shouldCaretBlink; + + /// Floating cursor state, used to determine when the floating cursor is active, + /// during which the regular caret is either hidden, or is displayed as a gray + /// caret when the floating cursor is far away from its nearest text. + final FloatingCursorController? floatingCursorController; + + final bool showDebugPaint; + + @override + DocumentLayoutLayerState createState() => + IosControlsDocumentLayerState(); +} + +@visibleForTesting +class IosControlsDocumentLayerState extends DocumentLayoutLayerState + with SingleTickerProviderStateMixin { + /// The diameter of the small circle that appears on the top and bottom of + /// expanded iOS text handles. + static const ballDiameter = 8.0; + + // These global keys are assigned to each draggable handle to + // prevent a strange dragging issue. + // + // Without these keys, if the user drags into the auto-scroll area + // for a period of time, we never receive a + // "pan end" or "pan cancel" callback. I have no idea why this is + // the case. These handles sit in an Overlay, so it's not as if they + // suffered some conflict within a ScrollView. I tried many adjustments + // to recover the end/cancel callbacks. Finally, I tried adding these + // global keys based on a hunch that perhaps the gesture detector was + // somehow getting switched out, or assigned to a different widget, and + // that was somehow disrupting the callback series. For now, these keys + // seem to solve the problem. + final _collapsedHandleKey = GlobalKey(); + final _upstreamHandleKey = GlobalKey(); + final _downstreamHandleKey = GlobalKey(); + + late BlinkController _caretBlinkController; + + @override + void initState() { + super.initState(); + _caretBlinkController = BlinkController(tickerProvider: this); + + widget.selection.addListener(_onSelectionChange); + widget.shouldCaretBlink.addListener(_onBlinkModeChange); + widget.floatingCursorController?.isActive.addListener(_onFloatingCursorActivationChange); + + _onBlinkModeChange(); + } + + @override + void didUpdateWidget(IosHandlesDocumentLayer oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.selection != oldWidget.selection) { + oldWidget.selection.removeListener(_onSelectionChange); + widget.selection.addListener(_onSelectionChange); + } + + if (widget.shouldCaretBlink != oldWidget.shouldCaretBlink) { + oldWidget.shouldCaretBlink.removeListener(_onBlinkModeChange); + widget.shouldCaretBlink.addListener(_onBlinkModeChange); + } + + if (widget.floatingCursorController != oldWidget.floatingCursorController) { + oldWidget.floatingCursorController?.isActive.removeListener(_onFloatingCursorActivationChange); + widget.floatingCursorController?.isActive.addListener(_onFloatingCursorActivationChange); + } + } + + @override + void dispose() { + widget.selection.removeListener(_onSelectionChange); + widget.shouldCaretBlink.removeListener(_onBlinkModeChange); + widget.floatingCursorController?.isActive.removeListener(_onFloatingCursorActivationChange); + + _caretBlinkController.dispose(); + super.dispose(); + } + + @visibleForTesting + Rect? get caret => layoutData?.caret; + + @visibleForTesting + Color get caretColor => widget.handleColor; + + @visibleForTesting + bool get isCaretDisplayed => layoutData?.caret != null; + + @visibleForTesting + bool get isUpstreamHandleDisplayed => layoutData?.upstream != null; + + @visibleForTesting + bool get isDownstreamHandleDisplayed => layoutData?.downstream != null; + + void _onSelectionChange() { + setState(() { + // Schedule a new layout computation because the caret and/or handles need to move. + }); + } + + void _onBlinkModeChange() { + if (widget.shouldCaretBlink.value) { + _caretBlinkController.startBlinking(); + } else { + _caretBlinkController.stopBlinking(); + } + } + + void _onFloatingCursorActivationChange() { + if (widget.floatingCursorController?.isActive.value == true) { + _caretBlinkController.stopBlinking(); + } else { + _caretBlinkController.startBlinking(); + } + } + + @override + DocumentSelectionLayout? computeLayoutDataWithDocumentLayout(BuildContext context, DocumentLayout documentLayout) { + final selection = widget.selection.value; + if (selection == null) { + return null; + } + + if (selection.isCollapsed) { + return DocumentSelectionLayout( + caret: documentLayout.getRectForPosition(selection.extent)!, + ); + } else { + return DocumentSelectionLayout( + upstream: documentLayout.getRectForPosition( + widget.document.selectUpstreamPosition(selection.base, selection.extent), + )!, + downstream: documentLayout.getRectForPosition( + widget.document.selectDownstreamPosition(selection.base, selection.extent), + )!, + expandedSelectionBounds: documentLayout.getRectForSelection( + selection.base, + selection.extent, + ), + ); + } + } + + @override + Widget doBuild(BuildContext context, DocumentSelectionLayout? layoutData) { + return IgnorePointer( + child: SizedBox.expand( + child: layoutData != null // + ? _buildHandles(layoutData) + : const SizedBox(), + ), + ); + } + + Widget _buildHandles(DocumentSelectionLayout layoutData) { + if (widget.selection.value == null) { + editorGesturesLog.finer("Not building overlay handles because there's no selection."); + return const SizedBox.shrink(); + } + + return Stack( + children: [ + if (layoutData.caret != null) // + _buildCollapsedHandle(caret: layoutData.caret!), + if (layoutData.upstream != null && layoutData.downstream != null) ...[ + _buildUpstreamHandle( + upstream: layoutData.upstream!, + debugColor: Colors.green, + ), + _buildDownstreamHandle( + downstream: layoutData.downstream!, + debugColor: Colors.red, + ), + ], + ], + ); + } + + Widget _buildCollapsedHandle({ + required Rect caret, + }) { + return Positioned( + key: _collapsedHandleKey, + left: caret.left, + top: caret.top, + child: MultiListenableBuilder( + listenables: { + if (widget.floatingCursorController != null) ...{ + widget.floatingCursorController!.isActive, + widget.floatingCursorController!.isNearText, + } + }, + builder: (context) { + final isShowingFloatingCursor = widget.floatingCursorController?.isActive.value == true; + final isNearText = widget.floatingCursorController?.isNearText.value == true; + if (isShowingFloatingCursor && isNearText) { + // The floating cursor is active and it's near some text. We don't want to + // paint a collapsed handle/caret. + return const SizedBox(); + } + + return IOSCollapsedHandle( + key: DocumentKeys.iOsCaret, + controller: _caretBlinkController, + color: isShowingFloatingCursor ? Colors.grey : widget.handleColor, + caretHeight: caret.height, + ); + }, + ), + ); + } + + Widget _buildUpstreamHandle({ + required Rect upstream, + required Color debugColor, + }) { + return Positioned( + key: _upstreamHandleKey, + left: upstream.left, + top: upstream.top - ballDiameter, + child: FractionalTranslation( + translation: const Offset(-0.5, 0), + child: IOSSelectionHandle.upstream( + key: DocumentKeys.upstreamHandle, + color: widget.handleColor, + handleType: HandleType.upstream, + caretHeight: upstream.height, + ballRadius: ballDiameter / 2, + ), + ), + ); + } + + Widget _buildDownstreamHandle({ + required Rect downstream, + required Color debugColor, + }) { + return Positioned( + key: _downstreamHandleKey, + left: downstream.left, + top: downstream.top, + child: FractionalTranslation( + translation: const Offset(-0.5, 0), + child: IOSSelectionHandle.downstream( + key: DocumentKeys.downstreamHandle, + color: widget.handleColor, + handleType: HandleType.downstream, + caretHeight: downstream.height, + ballRadius: ballDiameter / 2, + ), + ), + ); + } +} + +/// Builds a full-screen floating toolbar display, with the toolbar positioned near the +/// [focalPoint], and with the toolbar attached to the given [mobileToolbarKey]. +/// +/// The [mobileToolbarKey] is used to find the toolbar in the widget tree for various purposes, +/// e.g., within tests to verify the presence or absence of a toolbar. If your builder chooses +/// not to build a toolbar, e.g., returns a `SizedBox()` instead of a toolbar, then the +/// you shouldn't use the [mobileToolbarKey]. +/// +/// The [mobileToolbarKey] must be attached to the toolbar, not the top-level widget returned +/// from this builder, because the [mobileToolbarKey] might be used to verify the size and location +/// of the toolbar. For example: +/// +/// ```dart +/// Widget buildMagnifier(context, mobileToolbarKey, focalPoint) { +/// return Follower( +/// link: focalPoint, +/// child: Toolbar( +/// key: mobileToolbarKey, +/// width: 100, +/// height: 42, +/// magnification: 1.5, +/// ), +/// ); +/// } +/// ``` +typedef DocumentFloatingToolbarBuilder = Widget Function(BuildContext, Key mobileToolbarKey, LeaderLink focalPoint); + +/// Builds a full-screen magnifier display, with the magnifier following the given [focalPoint], +/// and with the magnifier attached to the given [magnifierKey]. +/// +/// The [magnifierKey] is used to find the magnifier in the widget tree for various purposes, +/// e.g., within tests to verify the presence or absence of a magnifier. If your builder chooses +/// not to build a magnifier, e.g., returns a `SizedBox()` instead of a magnifier, then the +/// you shouldn't use the [magnifierKey]. +/// +/// The [magnifierKey] must be attached to the magnifier, not the top-level widget returned +/// from this builder, because the [magnifierKey] might be used to verify the size and location +/// of the magnifier. For example: +/// +/// ```dart +/// Widget buildMagnifier(context, magnifierKey, focalPoint) { +/// return Follower( +/// link: focalPoint, +/// child: Magnifier( +/// key: magnifierKey, +/// width: 100, +/// height: 42, +/// magnification: 1.5, +/// ), +/// ); +/// } +/// ``` +typedef DocumentMagnifierBuilder = Widget Function(BuildContext, Key magnifierKey, LeaderLink focalPoint); diff --git a/super_editor/lib/src/infrastructure/platforms/ios/magnifier.dart b/super_editor/lib/src/infrastructure/platforms/ios/magnifier.dart index 5934cb042f..4de6c92f5a 100644 --- a/super_editor/lib/src/infrastructure/platforms/ios/magnifier.dart +++ b/super_editor/lib/src/infrastructure/platforms/ios/magnifier.dart @@ -1,4 +1,5 @@ import 'package:flutter/widgets.dart'; +import 'package:follow_the_leader/follow_the_leader.dart'; import 'package:super_editor/src/super_textfield/infrastructure/magnifier.dart'; import 'package:super_editor/src/super_textfield/infrastructure/outer_box_shadow.dart'; @@ -6,51 +7,58 @@ import 'package:super_editor/src/super_textfield/infrastructure/outer_box_shadow class IOSFollowingMagnifier extends StatelessWidget { const IOSFollowingMagnifier.roundedRectangle({ Key? key, - required this.layerLink, + this.magnifierKey, + required this.leaderLink, this.offsetFromFocalPoint = Offset.zero, }) : magnifierBuilder = _roundedRectangleMagnifierBuilder; const IOSFollowingMagnifier.circle({ Key? key, - required this.layerLink, + this.magnifierKey, + required this.leaderLink, this.offsetFromFocalPoint = Offset.zero, }) : magnifierBuilder = _circleMagnifierBuilder; const IOSFollowingMagnifier({ Key? key, - required this.layerLink, + this.magnifierKey, + required this.leaderLink, this.offsetFromFocalPoint = Offset.zero, required this.magnifierBuilder, }) : super(key: key); - final LayerLink layerLink; + final Key? magnifierKey; + final LeaderLink leaderLink; final Offset offsetFromFocalPoint; final MagnifierBuilder magnifierBuilder; @override Widget build(BuildContext context) { - return CompositedTransformFollower( - link: layerLink, + return Follower.withOffset( + link: leaderLink, + leaderAnchor: Alignment.topCenter, + followerAnchor: Alignment.bottomCenter, offset: offsetFromFocalPoint, - child: FractionalTranslation( - translation: const Offset(-0.5, -0.5), - child: magnifierBuilder( - context, - offsetFromFocalPoint, - ), + child: magnifierBuilder( + context, + offsetFromFocalPoint, + magnifierKey, ), ); } } -typedef MagnifierBuilder = Widget Function(BuildContext, Offset offsetFromFocalPoint); +typedef MagnifierBuilder = Widget Function(BuildContext, Offset offsetFromFocalPoint, [Key? magnifierKey]); -Widget _roundedRectangleMagnifierBuilder(BuildContext context, Offset offsetFromFocalPoint) => +Widget _roundedRectangleMagnifierBuilder(BuildContext context, Offset offsetFromFocalPoint, [Key? magnifierKey]) => IOSRoundedRectangleMagnifyingGlass( + key: magnifierKey, offsetFromFocalPoint: offsetFromFocalPoint, ); -Widget _circleMagnifierBuilder(BuildContext context, Offset offsetFromFocalPoint) => IOSCircleMagnifyingGlass( +Widget _circleMagnifierBuilder(BuildContext context, Offset offsetFromFocalPoint, [Key? magnifierKey]) => + IOSCircleMagnifyingGlass( + key: magnifierKey, offsetFromFocalPoint: offsetFromFocalPoint, ); @@ -58,6 +66,7 @@ class IOSRoundedRectangleMagnifyingGlass extends StatelessWidget { static const _magnification = 1.5; const IOSRoundedRectangleMagnifyingGlass({ + super.key, this.offsetFromFocalPoint = Offset.zero, }); @@ -100,6 +109,7 @@ class IOSCircleMagnifyingGlass extends StatelessWidget { static const _magnification = 2.0; const IOSCircleMagnifyingGlass({ + super.key, this.offsetFromFocalPoint = Offset.zero, }); diff --git a/super_editor/lib/src/infrastructure/platforms/ios/selection_handles.dart b/super_editor/lib/src/infrastructure/platforms/ios/selection_handles.dart index 139c9ac198..b18c67f4f1 100644 --- a/super_editor/lib/src/infrastructure/platforms/ios/selection_handles.dart +++ b/super_editor/lib/src/infrastructure/platforms/ios/selection_handles.dart @@ -69,40 +69,36 @@ class IOSSelectionHandle extends StatelessWidget { Widget _buildExpandedHandle() { final ballDiameter = ballRadius * 2; - final verticalOffset = handleType == HandleType.upstream ? -ballRadius : ballRadius; - - return Transform.translate( - offset: Offset(0, verticalOffset), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Show the ball on the top for an upstream handle - if (handleType == HandleType.upstream) - Container( - width: ballDiameter, - height: ballDiameter, - decoration: BoxDecoration( - color: color, - shape: BoxShape.circle, - ), - ), + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Show the ball on the top for an upstream handle + if (handleType == HandleType.upstream) Container( - width: 2, - height: caretHeight + ballRadius, - color: color, + width: ballDiameter, + height: ballDiameter, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + ), ), - // Show the ball on the bottom for a downstream handle - if (handleType == HandleType.downstream) - Container( - width: ballDiameter, - height: ballDiameter, - decoration: BoxDecoration( - color: color, - shape: BoxShape.circle, - ), + Container( + width: 2, + height: caretHeight + ballRadius, + color: color, + ), + // Show the ball on the bottom for a downstream handle + if (handleType == HandleType.downstream) + Container( + width: ballDiameter, + height: ballDiameter, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, ), - ], - ), + ), + ], ); } } diff --git a/super_editor/lib/src/infrastructure/platforms/ios/toolbar.dart b/super_editor/lib/src/infrastructure/platforms/ios/toolbar.dart index 1c99ff8c0f..e587db9011 100644 --- a/super_editor/lib/src/infrastructure/platforms/ios/toolbar.dart +++ b/super_editor/lib/src/infrastructure/platforms/ios/toolbar.dart @@ -1,25 +1,28 @@ import 'package:flutter/material.dart'; +import 'package:follow_the_leader/follow_the_leader.dart'; +import 'package:overlord/follow_the_leader.dart'; import 'package:overlord/overlord.dart'; import 'package:super_editor/src/infrastructure/platforms/ios/colors.dart'; class IOSTextEditingFloatingToolbar extends StatelessWidget { const IOSTextEditingFloatingToolbar({ Key? key, + this.floatingToolbarKey, + required this.focalPoint, this.onCutPressed, this.onCopyPressed, this.onPastePressed, - required this.focalPoint, }) : super(key: key); + final Key? floatingToolbarKey; + + /// Direction that the toolbar arrow should point. + final LeaderLink focalPoint; + final VoidCallback? onCutPressed; final VoidCallback? onCopyPressed; final VoidCallback? onPastePressed; - /// The point where the toolbar should point to. - /// - /// Represented as global coordinates. - final Offset focalPoint; - @override Widget build(BuildContext context) { final brightness = Theme.of(context).brightness; @@ -31,7 +34,8 @@ class IOSTextEditingFloatingToolbar extends StatelessWidget { : const ColorScheme.dark(primary: Colors.white), ), child: CupertinoPopoverToolbar( - focalPoint: StationaryMenuFocalPoint(focalPoint), + key: floatingToolbarKey, + focalPoint: LeaderMenuFocalPoint(link: focalPoint), elevation: 8.0, backgroundColor: brightness == Brightness.dark // ? iOSToolbarDarkBackgroundColor @@ -67,6 +71,12 @@ class IOSTextEditingFloatingToolbar extends StatelessWidget { required String title, required VoidCallback onPressed, }) { + // TODO: Bring this back after its updated to support theming (Overlord #17) + // return CupertinoPopoverToolbarMenuItem( + // label: title, + // onPressed: onPressed, + // ); + return TextButton( onPressed: onPressed, style: TextButton.styleFrom( diff --git a/super_editor/lib/src/infrastructure/platforms/mobile_documents.dart b/super_editor/lib/src/infrastructure/platforms/mobile_documents.dart index da83e5746b..8b9bb2cf13 100644 --- a/super_editor/lib/src/infrastructure/platforms/mobile_documents.dart +++ b/super_editor/lib/src/infrastructure/platforms/mobile_documents.dart @@ -2,7 +2,7 @@ import 'package:flutter/widgets.dart'; import 'package:super_editor/src/infrastructure/_logging.dart'; import 'package:super_editor/src/infrastructure/_scrolling.dart'; import 'package:super_editor/src/infrastructure/document_gestures.dart'; -import 'package:super_editor/src/infrastructure/selection_leader_document_layer.dart'; +import 'package:super_editor/src/infrastructure/documents/selection_leader_document_layer.dart'; /// Controls the display and position of a magnifier and a floating toolbar. class MagnifierAndToolbarController with ChangeNotifier { 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 ffc15d6875..dac0a50095 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,13 +12,13 @@ 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/documents/selection_leader_document_layer.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'; 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 0dda2fbd1d..84684ff79b 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 @@ -1,72 +1,215 @@ import 'dart:async'; -import 'dart:math'; -import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:follow_the_leader/follow_the_leader.dart'; import 'package:super_editor/src/core/document.dart'; 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/document_gestures_touch_ios.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/content_layers.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_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'; +import 'package:super_editor/src/infrastructure/platforms/ios/magnifier.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/touch_controls.dart'; -import 'package:super_editor/src/super_textfield/metrics.dart'; +import 'package:super_editor/src/super_reader/reader_context.dart'; +import 'package:super_editor/src/super_reader/super_reader.dart'; + +import '../infrastructure/text_input.dart'; + +/// An [InheritedWidget] that provides shared access to a [SuperReaderIosControlsController], +/// which coordinates the state of iOS controls like drag handles, magnifier, and toolbar. +/// +/// This widget and its associated controller exist so that [SuperReader] has maximum freedom +/// in terms of where to implement iOS gestures vs handles vs the magnifier vs the toolbar. +/// Each of these responsibilities have some unique differences, which make them difficult +/// or impossible to implement within a single widget. By sharing a controller, a group of +/// independent widgets can work together to cover those various responsibilities. +/// +/// Centralizing a controller in an [InheritedWidget] also allows [SuperReader] to share that +/// control with application code outside of [SuperReader], by placing an [SuperReaderIosControlsScope] +/// above the [SuperReader] in the widget tree. For this reason, [SuperReader] should access +/// the [SuperReaderIosControlsScope] through [rootOf]. +class SuperReaderIosControlsScope extends InheritedWidget { + /// Finds the highest [SuperReaderIosControlsScope] in the widget tree, above the given + /// [context], and returns its associated [SuperReaderIosControlsController]. + static SuperReaderIosControlsController rootOf(BuildContext context) { + final data = maybeRootOf(context); + + if (data == null) { + throw Exception("Tried to depend upon the root IosReaderControlsScope but no such ancestor widget exists."); + } + + return data; + } + + static SuperReaderIosControlsController? maybeRootOf(BuildContext context) { + InheritedElement? root; + + context.visitAncestorElements((element) { + if (element is! InheritedElement || element.widget is! SuperReaderIosControlsScope) { + // Keep visiting. + return true; + } + + root = element; + + // Keep visiting, to ensure we get the root scope. + return true; + }); + + if (root == null) { + return null; + } + + // Create build dependency on the iOS controls context. + context.dependOnInheritedElement(root!); + + // Return the current iOS controls data. + return (root!.widget as SuperReaderIosControlsScope).controller; + } + + /// Finds the nearest [SuperReaderIosControlsScope] in the widget tree, above the given + /// [context], and returns its associated [SuperReaderIosControlsController]. + static SuperReaderIosControlsController nearestOf(BuildContext context) => + context.dependOnInheritedWidgetOfExactType()!.controller; + + static SuperReaderIosControlsController? maybeNearestOf(BuildContext context) => + context.dependOnInheritedWidgetOfExactType()?.controller; + + const SuperReaderIosControlsScope({ + super.key, + required this.controller, + required super.child, + }); + + final SuperReaderIosControlsController controller; + + @override + bool updateShouldNotify(SuperReaderIosControlsScope oldWidget) { + return controller != oldWidget.controller; + } +} + +/// A controller, which coordinates the state of various iOS reader controls, including +/// drag handles, magnifier, and toolbar. +class SuperReaderIosControlsController { + SuperReaderIosControlsController({ + this.handleColor, + this.magnifierBuilder, + this.toolbarBuilder, + this.createOverlayControlsClipper, + }); + + void dispose() { + _shouldShowMagnifier.dispose(); + _shouldShowToolbar.dispose(); + } + + /// Color of the text selection drag handles on iOS. + final Color? handleColor; + + /// Whether the iOS magnifier should be displayed right now. + ValueListenable get shouldShowMagnifier => _shouldShowMagnifier; + final _shouldShowMagnifier = ValueNotifier(false); + + /// Shows the magnifier by setting [shouldShowMagnifier] to `true`. + void showMagnifier() => _shouldShowMagnifier.value = true; + + /// Hides the magnifier by setting [shouldShowMagnifier] to `false`. + void hideMagnifier() => _shouldShowMagnifier.value = false; + + /// Toggles [shouldShowMagnifier]. + void toggleMagnifier() => _shouldShowMagnifier.value = !_shouldShowMagnifier.value; + + /// Link to a location where a magnifier should be focused. + final magnifierFocalPoint = LeaderLink(); + + /// (Optional) Builder to create the visual representation of the magnifier. + /// + /// If [magnifierBuilder] is `null`, a default iOS magnifier is displayed. + final DocumentMagnifierBuilder? magnifierBuilder; + + /// Whether the iOS floating toolbar should be displayed right now. + ValueListenable get shouldShowToolbar => _shouldShowToolbar; + final _shouldShowToolbar = ValueNotifier(false); + + /// Shows the toolbar by setting [shouldShowToolbar] to `true`. + void showToolbar() => _shouldShowToolbar.value = true; + + /// Hides the toolbar by setting [shouldShowToolbar] to `false`. + void hideToolbar() => _shouldShowToolbar.value = false; + + /// Toggles [shouldShowToolbar]. + void toggleToolbar() => _shouldShowToolbar.value = !_shouldShowToolbar.value; + + /// Link to a location where a toolbar should be focused. + /// + /// This link probably points to a rectangle, such as a bounding rectangle + /// around the user's selection. Therefore, the toolbar builder shouldn't + /// assume that this focal point is a single pixel. + final toolbarFocalPoint = LeaderLink(); + + /// (Optional) Builder to create the visual representation of the floating + /// toolbar. + /// + /// If [toolbarBuilder] is `null`, a default iOS toolbar is displayed. + final DocumentFloatingToolbarBuilder? toolbarBuilder; + + /// Creates a clipper that restricts where the toolbar and magnifier can + /// appear in the overlay. + /// + /// If no clipper factory method is provided, then the overlay controls + /// will be allowed to appear anywhere in the overlay in which they sit + /// (probably the entire screen). + final CustomClipper Function(BuildContext overlayContext)? createOverlayControlsClipper; +} /// Document gesture interactor that's designed for iOS touch input, e.g., -/// drag to scroll, and handles to control selection. +/// drag to scroll, double and triple tap to select content, and drag +/// selection ends to expand selection. /// /// The primary difference between a read-only touch interactor, and an /// editing touch interactor, is that read-only documents don't support /// collapsed selections, i.e., caret display. When the user taps on /// a read-only document, nothing happens. The user must drag an expanded /// selection, or double/triple tap to select content. -class ReadOnlyIOSDocumentTouchInteractor extends StatefulWidget { - const ReadOnlyIOSDocumentTouchInteractor({ +class SuperReaderIosDocumentTouchInteractor extends StatefulWidget { + const SuperReaderIosDocumentTouchInteractor({ Key? key, required this.focusNode, required this.document, required this.documentKey, required this.getDocumentLayout, required this.selection, - required this.selectionLinks, required this.scrollController, this.contentTapHandler, this.dragAutoScrollBoundary = const AxisOffset.symmetric(54), - required this.handleColor, - required this.popoverToolbarBuilder, - this.createOverlayControlsClipper, - this.overlayController, this.showDebugPaint = false, this.child, }) : super(key: key); final FocusNode focusNode; + final Document document; final GlobalKey documentKey; final DocumentLayout Function() getDocumentLayout; - final ValueNotifier selection; - final SelectionLayerLinks selectionLinks; + final ScrollController scrollController; /// Optional handler that responds to taps on content, e.g., opening /// a link when the user taps on text with a link attribution. final ContentTapDelegate? contentTapHandler; - final ScrollController scrollController; - - /// Shows, hides, and positions a floating toolbar and magnifier. - final MagnifierAndToolbarController? overlayController; - /// The closest that the user's selection drag gesture can get to the /// document boundary before auto-scrolling. /// @@ -74,29 +217,15 @@ class ReadOnlyIOSDocumentTouchInteractor extends StatefulWidget { /// edges. final AxisOffset dragAutoScrollBoundary; - /// Color the iOS-style text selection drag handles. - final Color handleColor; - - final WidgetBuilder popoverToolbarBuilder; - - /// Creates a clipper that applies to overlay controls, preventing - /// the overlay controls from appearing outside the given clipping - /// region. - /// - /// If no clipper factory method is provided, then the overlay controls - /// will be allowed to appear anywhere in the overlay in which they sit - /// (probably the entire screen). - final CustomClipper Function(BuildContext overlayContext)? createOverlayControlsClipper; - final bool showDebugPaint; final Widget? child; @override - State createState() => _ReadOnlyIOSDocumentTouchInteractorState(); + State createState() => _SuperReaderIosDocumentTouchInteractorState(); } -class _ReadOnlyIOSDocumentTouchInteractorState extends State +class _SuperReaderIosDocumentTouchInteractorState extends State with WidgetsBindingObserver, SingleTickerProviderStateMixin { // The ScrollPosition attached to the _ancestorScrollable. ScrollPosition? _ancestorScrollPosition; @@ -104,12 +233,7 @@ class _ReadOnlyIOSDocumentTouchInteractorState extends State(null); Timer? _tapDownLongPressTimer; Offset? _globalTapDownOffset; bool get _isLongPressInProgress => _longPressStrategy != null; IosLongPressSelectionStrategy? _longPressStrategy; - /// Shows, hides, and positions a floating toolbar and magnifier. - late MagnifierAndToolbarController _overlayController; - @override void initState() { super.initState(); @@ -158,32 +265,10 @@ class _ReadOnlyIOSDocumentTouchInteractorState extends State viewportBox, ); - widget.focusNode.addListener(_onFocusChange); - if (widget.focusNode.hasFocus) { - _showEditingControlsOverlay(); - } - - // I added this listener directly to our ScrollController because the listener we added - // to the ScrollPosition wasn't triggering once the user makes an initial selection. I'm - // not sure why that happened. It's as if the ScrollPosition was replaced, but I don't - // know why the ScrollPosition would be replaced. In the meantime, adding this listener - // keeps the toolbar positioning logic working. - // TODO: rely solely on a ScrollPosition listener, not a ScrollController listener. - widget.scrollController.addListener(_onScrollChange); - - _overlayController = widget.overlayController ?? MagnifierAndToolbarController(); - - _editingController = IosDocumentGestureEditingController( - documentLayoutLink: _documentLayerLink, - selectionLinks: widget.selectionLinks, - magnifierFocalPointLink: _magnifierFocalPointLink, - overlayController: _overlayController, - ); - widget.document.addListener(_onDocumentChange); widget.selection.addListener(_onSelectionChange); - // If we already have a selection, we need to display the caret. + // If we already have a selection, we may need to display drag handles. if (widget.selection.value != null) { _onSelectionChange(); } @@ -195,6 +280,8 @@ class _ReadOnlyIOSDocumentTouchInteractorState extends State _showEditingControlsOverlay()); - } - } - @override void dispose() { WidgetsBinding.instance.removeObserver(this); @@ -276,71 +329,37 @@ class _ReadOnlyIOSDocumentTouchInteractorState extends State widget.getDocumentLayout(); @@ -410,7 +417,7 @@ class _ReadOnlyIOSDocumentTouchInteractorState extends State createState() => SuperReaderIosToolbarOverlayManagerState(); +} + +@visibleForTesting +class SuperReaderIosToolbarOverlayManagerState extends State { + SuperReaderIosControlsController? _controlsContext; + OverlayEntry? _toolbarOverlayEntry; + + @visibleForTesting + bool get wantsToDisplayToolbar => _controlsContext!.shouldShowToolbar.value; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + _controlsContext = SuperReaderIosControlsScope.rootOf(context); + + // Add our overlay on the next frame. If we did it immediately, it would + // cause a setState() to be called during didChangeDependencies, which is + // a framework violation. + onNextFrame((timeStamp) { + _addToolbarOverlay(); + }); + } + + @override + void dispose() { + _removeToolbarOverlay(); + super.dispose(); + } + + void _addToolbarOverlay() { + if (_toolbarOverlayEntry != null) { + return; + } + + _toolbarOverlayEntry = OverlayEntry(builder: (overlayContext) { + return IosFloatingToolbarOverlay( + shouldShowToolbar: _controlsContext!.shouldShowToolbar, + toolbarFocalPoint: _controlsContext!.toolbarFocalPoint, + floatingToolbarBuilder: + _controlsContext!.toolbarBuilder ?? widget.defaultToolbarBuilder ?? (_, __, ___) => const SizedBox(), + createOverlayControlsClipper: _controlsContext!.createOverlayControlsClipper, + showDebugPaint: false, + ); + }); + + Overlay.of(context).insert(_toolbarOverlayEntry!); + } + + void _removeToolbarOverlay() { + if (_toolbarOverlayEntry == null) { + return; + } + + _toolbarOverlayEntry!.remove(); + _toolbarOverlayEntry = null; + } + + @override + Widget build(BuildContext context) { + return widget.child ?? const SizedBox(); + } +} + +/// Adds and removes an iOS-style editor magnifier, as dictated by an ancestor +/// [SuperReaderIosControlsScope]. +class SuperReaderIosMagnifierOverlayManager extends StatefulWidget { + const SuperReaderIosMagnifierOverlayManager({ + super.key, + this.child, + }); + + final Widget? child; + + @override + State createState() => SuperReaderIosMagnifierOverlayManagerState(); +} + +@visibleForTesting +class SuperReaderIosMagnifierOverlayManagerState extends State { + SuperReaderIosControlsController? _controlsContext; + OverlayEntry? _magnifierOverlayEntry; + + @visibleForTesting + bool get wantsToDisplayMagnifier => _controlsContext!.shouldShowMagnifier.value; + + @override + void initState() { + super.initState(); + + // Add our overlay on the next frame. If we did it immediately, it would + // cause a setState() to be called during initState(), which is + // a framework violation. + onNextFrame((timeStamp) { + _addMagnifierOverlay(); + }); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + _controlsContext = SuperReaderIosControlsScope.rootOf(context); + } + + @override + void dispose() { + _removeMagnifierOverlay(); + super.dispose(); + } + + void _addMagnifierOverlay() { + if (_magnifierOverlayEntry != null) { + return; + } + + _magnifierOverlayEntry = OverlayEntry(builder: (_) => _buildMagnifier()); + Overlay.of(context).insert(_magnifierOverlayEntry!); + } + + void _removeMagnifierOverlay() { + if (_magnifierOverlayEntry == null) { + return; + } + + _magnifierOverlayEntry!.remove(); + _magnifierOverlayEntry = null; + } + + @override + Widget build(BuildContext context) { + return widget.child ?? const SizedBox(); + } + + Widget _buildMagnifier() { + // Display a magnifier that tracks a focal point. + // + // When the user is dragging an overlay handle, SuperEditor + // position a Leader with a LeaderLink. This magnifier follows that Leader + // via the LeaderLink. + return ValueListenableBuilder( + valueListenable: _controlsContext!.shouldShowMagnifier, + builder: (context, shouldShowMagnifier, child) { + if (!shouldShowMagnifier) { + return const SizedBox(); + } + + return child!; + }, + child: _controlsContext!.magnifierBuilder != null // + ? _controlsContext!.magnifierBuilder!(context, DocumentKeys.magnifier, _controlsContext!.magnifierFocalPoint) + : _buildDefaultMagnifier(context, DocumentKeys.magnifier, _controlsContext!.magnifierFocalPoint), + ); + } + + Widget _buildDefaultMagnifier(BuildContext context, Key magnifierKey, LeaderLink magnifierFocalPoint) { + if (isWeb) { + // Defer to the browser to display overlay controls on mobile. + return const SizedBox(); + } + + return IOSFollowingMagnifier.roundedRectangle( + magnifierKey: magnifierKey, + leaderLink: magnifierFocalPoint, + offsetFromFocalPoint: const Offset(0, -72), + ); + } +} + +/// A [SuperReaderLayerBuilder], which builds a [IosHandlesDocumentLayer], +/// which displays iOS-style handles. +class SuperReaderIosHandlesDocumentLayerBuilder implements SuperReaderDocumentLayerBuilder { + const SuperReaderIosHandlesDocumentLayerBuilder({ + this.handleColor, + }); + + final Color? handleColor; + + @override + ContentLayerWidget build(BuildContext context, SuperReaderContext readerContext) { + if (defaultTargetPlatform != TargetPlatform.iOS) { + return const ContentLayerProxyWidget(child: SizedBox()); + } + + return IosHandlesDocumentLayer( + document: readerContext.document, + documentLayout: readerContext.documentLayout, + selection: readerContext.selection, + changeSelection: (newSelection, changeType, reason) { + readerContext.selection.value = newSelection; + }, + handleColor: handleColor ?? Theme.of(context).primaryColor, + shouldCaretBlink: ValueNotifier(false), ); } } diff --git a/super_editor/lib/src/super_reader/super_reader.dart b/super_editor/lib/src/super_reader/super_reader.dart index 606a89e4bc..145389505f 100644 --- a/super_editor/lib/src/super_reader/super_reader.dart +++ b/super_editor/lib/src/super_reader/super_reader.dart @@ -1,6 +1,8 @@ import 'package:attributed_text/attributed_text.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:follow_the_leader/follow_the_leader.dart'; import 'package:super_editor/src/core/document.dart'; import 'package:super_editor/src/core/document_debug_paint.dart'; import 'package:super_editor/src/core/document_interaction.dart'; @@ -25,10 +27,14 @@ import 'package:super_editor/src/infrastructure/content_layers.dart'; import 'package:super_editor/src/infrastructure/document_gestures_interaction_overrides.dart'; import 'package:super_editor/src/infrastructure/documents/document_scaffold.dart'; import 'package:super_editor/src/infrastructure/documents/document_scroller.dart'; +import 'package:super_editor/src/infrastructure/documents/document_selection.dart'; +import 'package:super_editor/src/infrastructure/documents/selection_leader_document_layer.dart'; import 'package:super_editor/src/infrastructure/links.dart'; -import 'package:super_editor/src/infrastructure/selection_leader_document_layer.dart'; +import 'package:super_editor/src/infrastructure/platforms/ios/ios_document_controls.dart'; +import 'package:super_editor/src/infrastructure/platforms/ios/toolbar.dart'; import '../infrastructure/platforms/mobile_documents.dart'; +import '../infrastructure/text_input.dart'; import 'read_only_document_android_touch_interactor.dart'; import 'read_only_document_ios_touch_interactor.dart'; import 'read_only_document_keyboard_interactor.dart'; @@ -42,22 +48,24 @@ class SuperReader extends StatefulWidget { required this.document, this.documentLayoutKey, this.selection, + this.selectionLayerLinks, this.scrollController, Stylesheet? stylesheet, this.customStylePhases = const [], - this.documentOverlayBuilders = const [], + this.documentUnderlayBuilders = const [], + this.documentOverlayBuilders = defaultSuperReaderDocumentOverlayBuilders, List? componentBuilders, List? keyboardActions, SelectionStyles? selectionStyle, this.gestureMode, this.contentTapDelegateFactory = superReaderLaunchLinkTapHandlerFactory, + this.autofocus = false, + this.overlayController, this.androidHandleColor, this.androidToolbarBuilder, this.iOSHandleColor, this.iOSToolbarBuilder, this.createOverlayControlsClipper, - this.autofocus = false, - this.overlayController, this.debugPaint = const DebugPaintConfig(), }) : stylesheet = stylesheet ?? readOnlyDefaultStylesheet, selectionStyles = selectionStyle ?? readOnlyDefaultSelectionStyle, @@ -81,6 +89,14 @@ class SuperReader extends StatefulWidget { final ValueNotifier? selection; + /// Leader links that connect leader widgets near the user's selection + /// to carets, handles, and other things that want to follow the selection. + /// + /// These links are always created and used within [SuperEditor]. By providing + /// an explicit [selectionLayerLinks], external widgets can also follow the + /// user's selection. + final SelectionLayerLinks? selectionLayerLinks; + /// The [ScrollController] that governs this [SuperReader]'s scroll /// offset. /// @@ -88,9 +104,6 @@ class SuperReader extends StatefulWidget { /// [Scrollable]. final ScrollController? scrollController; - /// Shows, hides, and positions a floating toolbar and magnifier. - final MagnifierAndToolbarController? overlayController; - /// Style rules applied through the document presentation. final Stylesheet stylesheet; @@ -115,9 +128,13 @@ class SuperReader extends StatefulWidget { /// knows how to interpret and apply table styles for your visual table component. final List customStylePhases; + /// Layers that are displayed beneath the document layout, aligned + /// with the location and size of the document layout. + final List documentUnderlayBuilders; + /// Layers that are displayed on top of the document layout, aligned /// with the location and size of the document layout. - final List documentOverlayBuilders; + final List documentOverlayBuilders; /// Priority list of widget factories that create instances of /// each visual component displayed in the document layout, e.g., @@ -142,6 +159,9 @@ class SuperReader extends StatefulWidget { /// when a user taps on a link. final SuperReaderContentTapDelegateFactory? contentTapDelegateFactory; + /// Shows, hides, and positions a floating toolbar and magnifier. + final MagnifierAndToolbarController? overlayController; + /// Color of the text selection drag handles on Android. final Color? androidHandleColor; @@ -149,9 +169,11 @@ class SuperReader extends StatefulWidget { final WidgetBuilder? androidToolbarBuilder; /// Color of the text selection drag handles on iOS. + @Deprecated("To configure handle color, surround SuperEditor with an IosEditorControlsScope, instead") final Color? iOSHandleColor; /// Builder that creates a floating toolbar when running on iOS. + @Deprecated("To configure a toolbar builder, surround SuperEditor with an IosEditorControlsScope, instead") final WidgetBuilder? iOSToolbarBuilder; /// Creates a clipper that applies to overlay controls, like drag @@ -205,7 +227,13 @@ class SuperReaderState extends State { // Leader links that connect leader widgets near the user's selection // to carets, handles, and other things that want to follow the selection. - final _selectionLinks = SelectionLayerLinks(); + late SelectionLayerLinks _selectionLinks; + + // GlobalKey for the iOS editor controls context so that the context data doesn't + // continuously replace itself every time we rebuild. We want to retain the same + // controls because they're shared throughout a number of disconnected widgets. + final _iosControlsContextKey = GlobalKey(); + final _iosControlsController = SuperReaderIosControlsController(); @override void initState() { @@ -218,6 +246,8 @@ class SuperReaderState extends State { _scrollController = widget.scrollController ?? ScrollController(); _autoScrollController = AutoScrollController(); + _selectionLinks = widget.selectionLayerLinks ?? SelectionLayerLinks(); + _docLayoutKey = widget.documentLayoutKey ?? GlobalKey(); _createReaderContext(); @@ -237,6 +267,10 @@ class SuperReaderState extends State { _scrollController = widget.scrollController ?? ScrollController(); } + if (widget.selectionLayerLinks != oldWidget.selectionLayerLinks) { + _selectionLinks = widget.selectionLayerLinks ?? SelectionLayerLinks(); + } + if (widget.document != oldWidget.document || widget.selection != oldWidget.selection || widget.scrollController != oldWidget.scrollController) { @@ -332,103 +366,229 @@ class SuperReaderState extends State { @override Widget build(BuildContext context) { - return ReadOnlyDocumentKeyboardInteractor( - // In a read-only document, we don't expect the software keyboard - // to ever be open. Therefore, we only respond to key presses, such - // as arrow keys. - focusNode: _focusNode, - readerContext: _readerContext, - keyboardActions: widget.keyboardActions, - autofocus: widget.autofocus, - child: DocumentScaffold( - documentLayoutLink: _documentLayoutLink, - documentLayoutKey: _docLayoutKey, - gestureBuilder: _buildGestureInteractor, - scrollController: _scrollController, - autoScrollController: _autoScrollController, - scroller: _scroller, - presenter: _docLayoutPresenter!, - componentBuilders: widget.componentBuilders, - underlays: [ - // Layer that positions and sizes leader widgets at the bounds - // of the users selection so that carets, handles, toolbars, and - // other things can follow the selection. - (context) => _SelectionLeadersDocumentLayerBuilder( - links: _selectionLinks, - ).build(context, _readerContext), - ], - overlays: [ - for (final overlayBuilder in widget.documentOverlayBuilders) // - (context) => overlayBuilder.build(context, _readerContext), - ], - debugPaint: widget.debugPaint, - ), + return _buildGestureControlsScope( + // We add a Builder immediately beneath the gesture controls scope so that + // all descendant widgets built within SuperReader can access that scope. + child: Builder(builder: (controlsScopeContext) { + return ReadOnlyDocumentKeyboardInteractor( + // In a read-only document, we don't expect the software keyboard + // to ever be open. Therefore, we only respond to key presses, such + // as arrow keys. + focusNode: _focusNode, + readerContext: _readerContext, + keyboardActions: widget.keyboardActions, + autofocus: widget.autofocus, + child: _buildPlatformSpecificViewportDecorations( + controlsScopeContext, + child: DocumentScaffold( + documentLayoutLink: _documentLayoutLink, + documentLayoutKey: _docLayoutKey, + gestureBuilder: _buildGestureInteractor, + scrollController: _scrollController, + autoScrollController: _autoScrollController, + scroller: _scroller, + presenter: _docLayoutPresenter!, + componentBuilders: widget.componentBuilders, + underlays: [ + // Add any underlays that were provided by the client. + for (final underlayBuilder in widget.documentUnderlayBuilders) // + (context) => underlayBuilder.build(context, _readerContext), + ], + overlays: [ + // Layer that positions and sizes leader widgets at the bounds + // of the users selection so that carets, handles, toolbars, and + // other things can follow the selection. + (context) => _SelectionLeadersDocumentLayerBuilder( + links: _selectionLinks, + ).build(context, _readerContext), + // Add any overlays that were provided by the client. + for (final overlayBuilder in widget.documentOverlayBuilders) // + (context) => overlayBuilder.build(context, _readerContext), + ], + debugPaint: widget.debugPaint, + ), + ), + ); + }), ); } + /// Builds an [InheritedWidget] that holds a shared context for editor controls, + /// e.g., caret, handles, magnifier, toolbar. + /// + /// This context may be shared by multiple widgets within [SuperEditor]. It's also + /// possible that a client app has wrapped [SuperEditor] with its own context + /// [InheritedWidget], in which case the context is shared with widgets inside + /// of [SuperEditor], and widgets outside of [SuperEditor]. + Widget _buildGestureControlsScope({ + required Widget child, + }) { + switch (_gestureMode) { + // case DocumentGestureMode.mouse: + // TODO: create context for mouse mode (#1533) + // case DocumentGestureMode.android: + // TODO: create context for Android (#1509) + case DocumentGestureMode.iOS: + default: + return SuperReaderIosControlsScope( + key: _iosControlsContextKey, + controller: _iosControlsController, + child: child, + ); + } + } + + /// Builds any widgets that a platform wants to wrap around the editor viewport, + /// e.g., reader toolbar. + Widget _buildPlatformSpecificViewportDecorations( + BuildContext context, { + required Widget child, + }) { + switch (_gestureMode) { + case DocumentGestureMode.iOS: + return SuperReaderIosToolbarOverlayManager( + defaultToolbarBuilder: (overlayContext, mobileToolbarKey, focalPoint) => defaultIosReaderToolbarBuilder( + overlayContext, + mobileToolbarKey, + focalPoint, + document, + _selection, + SuperReaderIosControlsScope.rootOf(context), + ), + child: SuperReaderIosMagnifierOverlayManager( + child: child, + ), + ); + case DocumentGestureMode.mouse: + case DocumentGestureMode.android: + default: + return child; + } + } + Widget _buildGestureInteractor(BuildContext context) { switch (_gestureMode) { case DocumentGestureMode.mouse: - return _buildDesktopGestureSystem(); + return ReadOnlyDocumentMouseInteractor( + focusNode: _focusNode, + readerContext: _readerContext, + contentTapHandler: _contentTapDelegate, + autoScroller: _autoScrollController, + showDebugPaint: widget.debugPaint.gestures, + child: const SizedBox(), + ); case DocumentGestureMode.android: - return _buildAndroidGestureSystem(); + return ReadOnlyAndroidDocumentTouchInteractor( + focusNode: _focusNode, + document: _readerContext.document, + documentKey: _docLayoutKey, + getDocumentLayout: () => _readerContext.documentLayout, + selection: _readerContext.selection, + selectionLinks: _selectionLinks, + contentTapHandler: _contentTapDelegate, + scrollController: _scrollController, + handleColor: widget.androidHandleColor ?? Theme.of(context).primaryColor, + popoverToolbarBuilder: widget.androidToolbarBuilder ?? (_) => const SizedBox(), + createOverlayControlsClipper: widget.createOverlayControlsClipper, + showDebugPaint: widget.debugPaint.gestures, + overlayController: widget.overlayController, + ); case DocumentGestureMode.iOS: - return _buildIOSGestureSystem(); + return SuperReaderIosDocumentTouchInteractor( + focusNode: _focusNode, + document: _readerContext.document, + documentKey: _docLayoutKey, + getDocumentLayout: () => _readerContext.documentLayout, + selection: _readerContext.selection, + contentTapHandler: _contentTapDelegate, + scrollController: _scrollController, + showDebugPaint: widget.debugPaint.gestures, + ); } } +} - Widget _buildDesktopGestureSystem() { - return ReadOnlyDocumentMouseInteractor( - focusNode: _focusNode, - readerContext: _readerContext, - contentTapHandler: _contentTapDelegate, - autoScroller: _autoScrollController, - showDebugPaint: widget.debugPaint.gestures, - child: const SizedBox(), - ); +/// Builds a standard reader-style iOS floating toolbar. +Widget defaultIosReaderToolbarBuilder( + BuildContext context, + Key floatingToolbarKey, + LeaderLink focalPoint, + Document document, + ValueListenable selection, + SuperReaderIosControlsController editorControlsController, +) { + if (isWeb) { + // On web, we defer to the browser's internal overlay controls for mobile. + return const SizedBox(); } - Widget _buildAndroidGestureSystem() { - return ReadOnlyAndroidDocumentTouchInteractor( - focusNode: _focusNode, - document: _readerContext.document, - documentKey: _docLayoutKey, - getDocumentLayout: () => _readerContext.documentLayout, - selection: _readerContext.selection, - selectionLinks: _selectionLinks, - contentTapHandler: _contentTapDelegate, - scrollController: _scrollController, - handleColor: widget.androidHandleColor ?? Theme.of(context).primaryColor, - popoverToolbarBuilder: widget.androidToolbarBuilder ?? (_) => const SizedBox(), - createOverlayControlsClipper: widget.createOverlayControlsClipper, - showDebugPaint: widget.debugPaint.gestures, - overlayController: widget.overlayController, + return DefaultIosReaderToolbar( + floatingToolbarKey: floatingToolbarKey, + focalPoint: focalPoint, + document: document, + selection: selection, + editorControlsController: editorControlsController, + ); +} + +/// An iOS floating toolbar, which includes standard buttons for a reader use-case. +class DefaultIosReaderToolbar extends StatelessWidget { + const DefaultIosReaderToolbar({ + super.key, + this.floatingToolbarKey, + required this.focalPoint, + required this.document, + required this.selection, + required this.editorControlsController, + }); + + final Key? floatingToolbarKey; + final LeaderLink focalPoint; + final Document document; + final ValueListenable selection; + final SuperReaderIosControlsController editorControlsController; + + @override + Widget build(BuildContext context) { + return IOSTextEditingFloatingToolbar( + floatingToolbarKey: floatingToolbarKey, + focalPoint: focalPoint, + onCopyPressed: _copy, ); } - Widget _buildIOSGestureSystem() { - return ReadOnlyIOSDocumentTouchInteractor( - focusNode: _focusNode, - document: _readerContext.document, - getDocumentLayout: () => _readerContext.documentLayout, - selection: _readerContext.selection, - selectionLinks: _selectionLinks, - contentTapHandler: _contentTapDelegate, - scrollController: _scrollController, - documentKey: _docLayoutKey, - handleColor: widget.iOSHandleColor ?? Theme.of(context).primaryColor, - popoverToolbarBuilder: widget.iOSToolbarBuilder ?? (_) => const SizedBox(), - createOverlayControlsClipper: widget.createOverlayControlsClipper, - showDebugPaint: widget.debugPaint.gestures, - overlayController: widget.overlayController, + /// Copies selected content to the OS clipboard. + void _copy() { + editorControlsController.hideToolbar(); + + if (selection.value == null) { + return; + } + + final textToCopy = extractTextFromSelection( + document: document, + documentSelection: selection.value!, ); + // TODO: figure out a general approach for asynchronous behaviors that + // need to be carried out in response to user input. + Clipboard.setData(ClipboardData(text: textToCopy)); } } -/// A [ReadOnlyDocumentLayerBuilder] that builds a [SelectionLeadersDocumentLayer], which positions +/// Default list of document overlays that are displayed on top of the document +/// layout in a [SuperReader]. +const defaultSuperReaderDocumentOverlayBuilders = [ + // Adds a Leader around the document selection at a focal point for the + // iOS floating toolbar. + SuperReaderIosToolbarFocalPointDocumentLayerBuilder(), + // Displays caret and drag handles, specifically for iOS. + SuperReaderIosHandlesDocumentLayerBuilder(), +]; + +/// A [SuperReaderDocumentLayerBuilder] that builds a [SelectionLeadersDocumentLayer], which positions /// leader widgets at the base and extent of the user's selection, so that other widgets /// can position themselves relative to the user's selection. -class _SelectionLeadersDocumentLayerBuilder implements ReadOnlyDocumentLayerBuilder { +class _SelectionLeadersDocumentLayerBuilder implements SuperReaderDocumentLayerBuilder { const _SelectionLeadersDocumentLayerBuilder({ required this.links, // ignore: unused_element @@ -447,16 +607,38 @@ class _SelectionLeadersDocumentLayerBuilder implements ReadOnlyDocumentLayerBuil return SelectionLeadersDocumentLayer( document: readerContext.document, selection: readerContext.selection, - documentLayoutResolver: () => readerContext.documentLayout, links: links, showDebugLeaderBounds: showDebugLeaderBounds, ); } } +/// A [SuperReaderDocumentLayerBuilder] that builds a [IosToolbarFocalPointDocumentLayer], which +/// positions a `Leader` widget around the document selection, as a focal point for an +/// iOS floating toolbar. +class SuperReaderIosToolbarFocalPointDocumentLayerBuilder implements SuperReaderDocumentLayerBuilder { + const SuperReaderIosToolbarFocalPointDocumentLayerBuilder({ + // ignore: unused_element + this.showDebugLeaderBounds = false, + }); + + /// Whether to paint colorful bounds around the leader widget. + final bool showDebugLeaderBounds; + + @override + ContentLayerWidget build(BuildContext context, SuperReaderContext readerContext) { + return IosToolbarFocalPointDocumentLayer( + document: readerContext.document, + selection: readerContext.selection, + toolbarFocalPointLink: SuperReaderIosControlsScope.rootOf(context).toolbarFocalPoint, + showDebugLeaderBounds: showDebugLeaderBounds, + ); + } +} + /// Builds widgets that are displayed at the same position and size as /// the document layout within a [SuperReader]. -abstract class ReadOnlyDocumentLayerBuilder { +abstract class SuperReaderDocumentLayerBuilder { ContentLayerWidget build(BuildContext context, SuperReaderContext documentContext); } 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 901a194cc0..26dd868bfe 100644 --- a/super_editor/lib/src/super_textfield/ios/_editing_controls.dart +++ b/super_editor/lib/src/super_textfield/ios/_editing_controls.dart @@ -1,6 +1,7 @@ import 'dart:math'; import 'package:flutter/material.dart'; +import 'package:follow_the_leader/follow_the_leader.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'; @@ -517,7 +518,7 @@ class _IOSEditingControlsState extends State with WidgetsBin return Positioned( left: _localDragOffset!.dx, top: _localDragOffset!.dy, - child: CompositedTransformTarget( + child: Leader( link: widget.editingController.magnifierFocalPoint, child: const SizedBox(width: 1, height: 1), ), @@ -535,7 +536,7 @@ class _IOSEditingControlsState extends State with WidgetsBin // positioning the LayerLink target. return Center( child: IOSFollowingMagnifier.roundedRectangle( - layerLink: widget.editingController.magnifierFocalPoint, + leaderLink: widget.editingController.magnifierFocalPoint, offsetFromFocalPoint: const Offset(0, -72), ), ); @@ -549,9 +550,11 @@ class _IOSEditingControlsState extends State with WidgetsBin class IOSEditingOverlayController with ChangeNotifier { IOSEditingOverlayController({ required this.textController, - required LayerLink magnifierFocalPoint, + required LeaderLink toolbarFocalPoint, + required LeaderLink magnifierFocalPoint, required this.overlayController, - }) : _magnifierFocalPoint = magnifierFocalPoint { + }) : _toolbarFocalPoint = toolbarFocalPoint, + _magnifierFocalPoint = magnifierFocalPoint { overlayController.addListener(_overlayControllerChanged); } @@ -577,6 +580,9 @@ class IOSEditingOverlayController with ChangeNotifier { /// Shows, hides, and positions a floating toolbar and magnifier. final MagnifierAndToolbarController overlayController; + LeaderLink get toolbarFocalPoint => _toolbarFocalPoint; + final LeaderLink _toolbarFocalPoint; + void toggleToolbar() { overlayController.toggleToolbar(); } @@ -589,8 +595,8 @@ class IOSEditingOverlayController with ChangeNotifier { overlayController.hideToolbar(); } - final LayerLink _magnifierFocalPoint; - LayerLink get magnifierFocalPoint => _magnifierFocalPoint; + LeaderLink get magnifierFocalPoint => _magnifierFocalPoint; + final LeaderLink _magnifierFocalPoint; bool get isMagnifierVisible => overlayController.shouldDisplayMagnifier; 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 fc2313bc2f..4156f46427 100644 --- a/super_editor/lib/src/super_textfield/ios/_user_interaction.dart +++ b/super_editor/lib/src/super_textfield/ios/_user_interaction.dart @@ -1,5 +1,6 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:follow_the_leader/follow_the_leader.dart'; import 'package:super_editor/src/infrastructure/_logging.dart'; import 'package:super_editor/src/infrastructure/flutter/flutter_scheduler.dart'; import 'package:super_editor/src/infrastructure/flutter/text_selection.dart'; @@ -105,10 +106,14 @@ class IOSTextFieldTouchInteractorState extends State(null); + @override void initState() { super.initState(); + widget.textController.addListener(_onSelectionChange); widget.textScrollController.addListener(_onScrollChange); } @@ -116,6 +121,10 @@ class IOSTextFieldTouchInteractorState extends State widget.selectableTextKey.currentState!.textLayout; + void _onSelectionChange() { + if (widget.textController.selection != _previousToolbarFocusSelection) { + // Update the selection bounds focal point + WidgetsBinding.instance.runAsSoonAsPossible(_computeSelectionRect); + } + } + void _onTapDown(TapDownDetails details) { _log.fine("User tapped down"); if (!widget.focusNode.hasFocus) { @@ -390,7 +408,8 @@ class IOSTextFieldTouchInteractorState extends State= 0) _buildExtentTrackerForMagnifier(), + _buildExtentTrackerForMagnifier(), + _buildTrackerForToolbarFocus(), _buildTapAndDragDetector(), ], ), @@ -436,18 +455,54 @@ class IOSTextFieldTouchInteractorState extends State late ImeAttributedTextEditingController _textEditingController; late FloatingCursorController _floatingCursorController; - final _magnifierLayerLink = LayerLink(); + final _toolbarLeaderLink = LeaderLink(); + final _magnifierLeaderLink = LeaderLink(); late IOSEditingOverlayController _editingOverlayController; late TextScrollController _textScrollController; @@ -203,7 +205,8 @@ class SuperIOSTextFieldState extends State _editingOverlayController = IOSEditingOverlayController( textController: _textEditingController, - magnifierFocalPoint: _magnifierLayerLink, + toolbarFocalPoint: _toolbarLeaderLink, + magnifierFocalPoint: _magnifierLeaderLink, overlayController: _overlayController, ); @@ -596,7 +599,7 @@ class SuperIOSTextFieldState extends State Widget _defaultPopoverToolbarBuilder(BuildContext context, IOSEditingOverlayController controller) { return IOSTextEditingFloatingToolbar( - focalPoint: controller.overlayController.toolbarTopAnchor!, + focalPoint: controller.toolbarFocalPoint, onCutPressed: () { final textController = controller.textController; final selection = textController.selection; diff --git a/super_editor/lib/src/test/flutter_extensions/finders.dart b/super_editor/lib/src/test/flutter_extensions/finders.dart new file mode 100644 index 0000000000..a9f20b411c --- /dev/null +++ b/super_editor/lib/src/test/flutter_extensions/finders.dart @@ -0,0 +1,38 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter_test/flutter_test.dart'; + +extension Finders on CommonFinders { + /// Finds [StatefulElement]s whose [State] is of type [StateType], optionally + /// scoped to the given [subtreeScope], and returns the element's associated + /// [StateType] object. + /// + /// This method expects to find, at most, one [StateType] object. + /// + /// Example - assume a widget MyWidget with a state MyWidgetState: + /// + /// final state = find.state(); + /// state?.myCustomStateMethod(); + /// + StateType? state([Finder? subtreeScope]) { + final elementFinder = + find.byElementPredicate((element) => element is StatefulElement && element.state is StateType); + final Finder stateFinder = + subtreeScope != null ? find.descendant(of: subtreeScope, matching: elementFinder) : elementFinder; + + final finderResult = stateFinder.evaluate(); + if (finderResult.length > 1) { + throw Exception("Expected to find no more than one $StateType, but found ${finderResult.length}"); + } + if (finderResult.isEmpty) { + return null; + } + + final foundElement = stateFinder.evaluate().single as StatefulElement; + return foundElement.state as StateType; + } +} + +class FindsNothing extends Finder { + @override + String get description => "Finder that matches nothing so that a Finder may be returned in defunct situations"; +} diff --git a/super_editor/lib/src/test/super_editor_test/supereditor_inspector.dart b/super_editor/lib/src/test/super_editor_test/supereditor_inspector.dart index 026c326630..19a981a12b 100644 --- a/super_editor/lib/src/test/super_editor_test/supereditor_inspector.dart +++ b/super_editor/lib/src/test/super_editor_test/supereditor_inspector.dart @@ -1,7 +1,8 @@ import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:super_editor/src/default_editor/document_gestures_touch_android.dart'; +import 'package:super_editor/src/test/flutter_extensions/finders.dart'; import 'package:super_editor/super_editor.dart'; import 'package:super_text_layout/super_text_layout.dart'; @@ -95,10 +96,10 @@ class SuperEditorInspector { return androidControls.editingController.caretTop!; } - final iOSControls = - find.byType(IosDocumentTouchEditingControls).evaluate().lastOrNull?.widget as IosDocumentTouchEditingControls?; - if (iOSControls != null) { - return iOSControls.editingController.caretTop!; + final iOSControls = (find.byType(IosHandlesDocumentLayer).evaluate().lastOrNull as StatefulElement?)?.state + as IosControlsDocumentLayerState?; + if (iOSControls != null && iOSControls.caret != null) { + return iOSControls.caret!.topCenter; } throw Exception('Could not locate caret in document'); @@ -223,15 +224,8 @@ class SuperEditorInspector { } /// Locates the first line break in a text node, or throws an exception if it cannot find one. - static int findOffsetOfLineBreak(String nodeId, [Finder? finder]) { - late final Finder layoutFinder; - if (finder != null) { - layoutFinder = find.descendant(of: finder, matching: find.byType(SingleColumnDocumentLayout)); - } else { - layoutFinder = find.byType(SingleColumnDocumentLayout); - } - final documentLayoutElement = layoutFinder.evaluate().single as StatefulElement; - final documentLayout = documentLayoutElement.state as DocumentLayout; + static int findOffsetOfLineBreak(String nodeId, [Finder? superEditorFinder]) { + final documentLayout = _findDocumentLayout(superEditorFinder); final componentState = documentLayout.getComponentByNodeId(nodeId) as State; late final GlobalKey textComponentKey; @@ -262,5 +256,187 @@ class SuperEditorInspector { return documentLayoutElement.state as DocumentLayout; } + /// Returns `true` if [SuperEditor]'s policy believes that a mobile toolbar should + /// be visible right now, or `false` otherwise. + /// + /// This inspection is different from [isMobileToolbarVisible] in a couple ways: + /// * On mobile web, [SuperEditor] defers to the browser's built-in overlay + /// controls. Therefore, [wantsMobileToolbarToBeVisible] is `true` but + /// [isMobileToolbarVisible] is `false`. + /// * When an app customizes the toolbar, [SuperEditor] might want to build + /// and display a toolbar, but the app overrode the toolbar widget and chose + /// to build empty space instead of a toolbar. In this case + /// [wantsMobileToolbarToBeVisible] is `true`, but [isMobileToolbarVisible] + /// is `false`. + static bool wantsMobileToolbarToBeVisible([Finder? superEditorFinder]) { + // TODO: add Android support + final toolbarManager = find.state(superEditorFinder); + if (toolbarManager == null) { + throw Exception( + "Tried to verify that SuperEditor wants mobile toolbar to be visible, but couldn't find the toolbar manager widget."); + } + + return toolbarManager.wantsToDisplayToolbar; + } + + /// Returns `true` if the mobile floating toolbar is currently visible, or `false` + /// if it's not. + /// + /// The mobile floating toolbar looks different for iOS and Android, but on both + /// platforms it appears on top of the editor, near selected content. + /// + /// This method doesn't take a `superEditorFinder` because the toolbar is displayed + /// in the application overlay, and is therefore completely independent from the + /// [SuperEditor] subtree. There's no obvious way to associate a toolbar with + /// a specific [SuperEditor]. + /// + /// See also: [wantsMobileToolbarToBeVisible]. + static bool isMobileToolbarVisible() { + return find.byKey(DocumentKeys.mobileToolbar).evaluate().isNotEmpty; + } + + /// Returns `true` if [SuperEditor]'s policy believes that a mobile magnifier + /// should be visible right now, or `false` otherwise. + /// + /// This inspection is different from [isMobileMagnifierVisible] in a couple ways: + /// * On mobile web, [SuperEditor] defers to the browser's built-in overlay + /// controls. Therefore, [wantsMobileMagnifierToBeVisible] is `true` but + /// [isMobileMagnifierVisible] is `false`. + /// * When an app customizes the magnifier, [SuperEditor] might want to build + /// and display a magnifier, but the app overrode the magnifier widget and chose + /// to build empty space instead of a magnifier. In this case + /// [wantsMobileMagnifierToBeVisible] is `true`, but [isMobileMagnifierVisible] + /// is `false`. + static bool wantsMobileMagnifierToBeVisible([Finder? superEditorFinder]) { + // TODO: add Android support + final magnifierManager = find.state(superEditorFinder); + if (magnifierManager == null) { + throw Exception( + "Tried to verify that SuperEditor wants mobile magnifier to be visible, but couldn't find the magnifier manager widget."); + } + + return magnifierManager.wantsToDisplayMagnifier; + } + + /// Returns `true` if a mobile magnifier is currently visible, or `false` if it's + /// not. + /// + /// The mobile magnifier looks different for iOS and Android. The magnifier also + /// follows different focal points depending on whether it's iOS or Android. + /// But in both cases, a magnifier is a small shape near the user's finger or + /// selection, which shows the editor content at an enlarged/magnified level. + /// + /// This method doesn't take a `superEditorFinder` because the magnifier is displayed + /// in the application overlay, and is therefore completely independent from the + /// [SuperEditor] subtree. There's no obvious way to associate a magnifier with + /// a specific [SuperEditor]. + /// + /// See also: [wantsMobileMagnifierToBeVisible] + static bool isMobileMagnifierVisible() { + return find.byKey(DocumentKeys.magnifier).evaluate().isNotEmpty; + } + + /// Returns `true` if any type of mobile drag handles are visible, or `false` + /// if not. + /// + /// On iOS, drag handles include the caret, as well as the upstream and downstream + /// handles. + /// + /// On Android, drag handles include the caret handle, as well as the upstream and + /// downstream drag handles. The caret drag handle on Android disappears after a brief + /// period of inactivity, and reappears upon another user interaction. + static Finder findAllMobileDragHandles([Finder? superEditorFinder]) { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + return find.byWidgetPredicate( + (widget) => + widget.key == DocumentKeys.androidCaretHandle || + widget.key == DocumentKeys.upstreamHandle || + widget.key == DocumentKeys.downstreamHandle, + ); + case TargetPlatform.iOS: + return find.byWidgetPredicate( + (widget) => + widget.key == DocumentKeys.iOsCaret || + widget.key == DocumentKeys.upstreamHandle || + widget.key == DocumentKeys.downstreamHandle, + ); + case TargetPlatform.macOS: + case TargetPlatform.windows: + case TargetPlatform.linux: + case TargetPlatform.fuchsia: + return FindsNothing(); + } + } + + static Finder findMobileCaret([Finder? superEditorFinder]) { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + return find.byKey(DocumentKeys.androidCaret); + case TargetPlatform.iOS: + return find.byKey(DocumentKeys.iOsCaret); + case TargetPlatform.macOS: + case TargetPlatform.windows: + case TargetPlatform.linux: + case TargetPlatform.fuchsia: + return FindsNothing(); + } + } + + static Finder findMobileCaretDragHandle([Finder? superEditorFinder]) { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + return find.byKey(DocumentKeys.androidCaretHandle); + case TargetPlatform.iOS: + return find.byKey(DocumentKeys.iOsCaret); + case TargetPlatform.macOS: + case TargetPlatform.windows: + case TargetPlatform.linux: + case TargetPlatform.fuchsia: + return FindsNothing(); + } + } + + static Finder findMobileExpandedDragHandles([Finder? superEditorFinder]) { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.iOS: + return find.byWidgetPredicate( + (widget) => widget.key == DocumentKeys.upstreamHandle || widget.key == DocumentKeys.downstreamHandle, + ); + case TargetPlatform.macOS: + case TargetPlatform.windows: + case TargetPlatform.linux: + case TargetPlatform.fuchsia: + return FindsNothing(); + } + } + + static Finder findMobileUpstreamDragHandle([Finder? superEditorFinder]) { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.iOS: + return find.byKey(DocumentKeys.upstreamHandle); + case TargetPlatform.macOS: + case TargetPlatform.windows: + case TargetPlatform.linux: + case TargetPlatform.fuchsia: + return FindsNothing(); + } + } + + static Finder findMobileDownstreamDragHandle([Finder? superEditorFinder]) { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.iOS: + return find.byKey(DocumentKeys.downstreamHandle); + case TargetPlatform.macOS: + case TargetPlatform.windows: + case TargetPlatform.linux: + case TargetPlatform.fuchsia: + return FindsNothing(); + } + } + SuperEditorInspector._(); } diff --git a/super_editor/lib/src/test/super_reader_test/super_reader_inspector.dart b/super_editor/lib/src/test/super_reader_test/super_reader_inspector.dart index 54260db4d0..d47a615932 100644 --- a/super_editor/lib/src/test/super_reader_test/super_reader_inspector.dart +++ b/super_editor/lib/src/test/super_reader_test/super_reader_inspector.dart @@ -1,5 +1,7 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:super_editor/src/test/flutter_extensions/finders.dart'; import 'package:super_editor/super_editor.dart'; import 'package:super_text_layout/super_text_layout.dart'; @@ -161,5 +163,187 @@ class SuperReaderInspector { return documentLayoutElement.state as DocumentLayout; } + /// Returns `true` if [SuperReader]'s policy believes that a mobile toolbar should + /// be visible right now, or `false` otherwise. + /// + /// This inspection is different from [isMobileToolbarVisible] in a couple ways: + /// * On mobile web, [SuperReader] defers to the browser's built-in overlay + /// controls. Therefore, [wantsMobileToolbarToBeVisible] is `true` but + /// [isMobileToolbarVisible] is `false`. + /// * When an app customizes the toolbar, [SuperReader] might want to build + /// and display a toolbar, but the app overrode the toolbar widget and chose + /// to build empty space instead of a toolbar. In this case + /// [wantsMobileToolbarToBeVisible] is `true`, but [isMobileToolbarVisible] + /// is `false`. + static bool wantsMobileToolbarToBeVisible([Finder? superReaderFinder]) { + // TODO: add Android support + final toolbarManager = find.state(superReaderFinder); + if (toolbarManager == null) { + throw Exception( + "Tried to verify that SuperReader wants mobile toolbar to be visible, but couldn't find the toolbar manager widget."); + } + + return toolbarManager.wantsToDisplayToolbar; + } + + /// Returns `true` if the mobile floating toolbar is currently visible, or `false` + /// if it's not. + /// + /// The mobile floating toolbar looks different for iOS and Android, but on both + /// platforms it appears on top of the editor, near selected content. + /// + /// This method doesn't take a `superReaderFinder` because the toolbar is displayed + /// in the application overlay, and is therefore completely independent from the + /// [SuperReader] subtree. There's no obvious way to associate a toolbar with + /// a specific [SuperReader]. + /// + /// See also: [wantsMobileToolbarToBeVisible]. + static bool isMobileToolbarVisible() { + return find.byKey(DocumentKeys.mobileToolbar).evaluate().isNotEmpty; + } + + /// Returns `true` if [SuperReader]'s policy believes that a mobile magnifier + /// should be visible right now, or `false` otherwise. + /// + /// This inspection is different from [isMobileMagnifierVisible] in a couple ways: + /// * On mobile web, [SuperReader] defers to the browser's built-in overlay + /// controls. Therefore, [wantsMobileMagnifierToBeVisible] is `true` but + /// [isMobileMagnifierVisible] is `false`. + /// * When an app customizes the magnifier, [SuperReader] might want to build + /// and display a magnifier, but the app overrode the magnifier widget and chose + /// to build empty space instead of a magnifier. In this case + /// [wantsMobileMagnifierToBeVisible] is `true`, but [isMobileMagnifierVisible] + /// is `false`. + static bool wantsMobileMagnifierToBeVisible([Finder? superReaderFinder]) { + // TODO: add Android support + final magnifierManager = find.state(superReaderFinder); + if (magnifierManager == null) { + throw Exception( + "Tried to verify that SuperReader wants mobile magnifier to be visible, but couldn't find the magnifier manager widget."); + } + + return magnifierManager.wantsToDisplayMagnifier; + } + + /// Returns `true` if a mobile magnifier is currently visible, or `false` if it's + /// not. + /// + /// The mobile magnifier looks different for iOS and Android. The magnifier also + /// follows different focal points depending on whether it's iOS or Android. + /// But in both cases, a magnifier is a small shape near the user's finger or + /// selection, which shows the editor content at an enlarged/magnified level. + /// + /// This method doesn't take a `superReaderFinder` because the magnifier is displayed + /// in the application overlay, and is therefore completely independent from the + /// [SuperReader] subtree. There's no obvious way to associate a magnifier with + /// a specific [SuperReader]. + /// + /// See also: [wantsMobileMagnifierToBeVisible] + static bool isMobileMagnifierVisible() { + return find.byKey(DocumentKeys.magnifier).evaluate().isNotEmpty; + } + + /// Returns `true` if any type of mobile drag handles are visible, or `false` + /// if not. + /// + /// On iOS, drag handles include the caret, as well as the upstream and downstream + /// handles. + /// + /// On Android, drag handles include the caret handle, as well as the upstream and + /// downstream drag handles. The caret drag handle on Android disappears after a brief + /// period of inactivity, and reappears upon another user interaction. + static Finder findAllMobileDragHandles([Finder? superReaderFinder]) { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + return find.byWidgetPredicate( + (widget) => + widget.key == DocumentKeys.androidCaretHandle || + widget.key == DocumentKeys.upstreamHandle || + widget.key == DocumentKeys.downstreamHandle, + ); + case TargetPlatform.iOS: + return find.byWidgetPredicate( + (widget) => + widget.key == DocumentKeys.iOsCaret || + widget.key == DocumentKeys.upstreamHandle || + widget.key == DocumentKeys.downstreamHandle, + ); + case TargetPlatform.macOS: + case TargetPlatform.windows: + case TargetPlatform.linux: + case TargetPlatform.fuchsia: + return FindsNothing(); + } + } + + static Finder findMobileCaret([Finder? superReaderFinder]) { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + return find.byKey(DocumentKeys.androidCaret); + case TargetPlatform.iOS: + return find.byKey(DocumentKeys.iOsCaret); + case TargetPlatform.macOS: + case TargetPlatform.windows: + case TargetPlatform.linux: + case TargetPlatform.fuchsia: + return FindsNothing(); + } + } + + static Finder findMobileCaretDragHandle([Finder? superReaderFinder]) { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + return find.byKey(DocumentKeys.androidCaretHandle); + case TargetPlatform.iOS: + return find.byKey(DocumentKeys.iOsCaret); + case TargetPlatform.macOS: + case TargetPlatform.windows: + case TargetPlatform.linux: + case TargetPlatform.fuchsia: + return FindsNothing(); + } + } + + static Finder findMobileExpandedDragHandles([Finder? superReaderFinder]) { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.iOS: + return find.byWidgetPredicate( + (widget) => widget.key == DocumentKeys.upstreamHandle || widget.key == DocumentKeys.downstreamHandle, + ); + case TargetPlatform.macOS: + case TargetPlatform.windows: + case TargetPlatform.linux: + case TargetPlatform.fuchsia: + return FindsNothing(); + } + } + + static Finder findMobileUpstreamDragHandle([Finder? superReaderFinder]) { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.iOS: + return find.byKey(DocumentKeys.upstreamHandle); + case TargetPlatform.macOS: + case TargetPlatform.windows: + case TargetPlatform.linux: + case TargetPlatform.fuchsia: + return FindsNothing(); + } + } + + static Finder findMobileDownstreamDragHandle([Finder? superReaderFinder]) { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.iOS: + return find.byKey(DocumentKeys.downstreamHandle); + case TargetPlatform.macOS: + case TargetPlatform.windows: + case TargetPlatform.linux: + case TargetPlatform.fuchsia: + return FindsNothing(); + } + } + SuperReaderInspector._(); } diff --git a/super_editor/lib/super_editor.dart b/super_editor/lib/super_editor.dart index f508d7e0bd..98736b4924 100644 --- a/super_editor/lib/super_editor.dart +++ b/super_editor/lib/super_editor.dart @@ -28,6 +28,8 @@ export 'src/default_editor/document_focus_and_selection_policies.dart'; export 'src/infrastructure/document_gestures.dart'; export 'src/default_editor/document_gestures_mouse.dart'; export 'src/infrastructure/document_gestures_interaction_overrides.dart'; +export 'src/default_editor/document_gestures_touch_ios.dart'; +export 'src/default_editor/document_gestures_touch_android.dart'; export 'src/default_editor/document_ime/document_input_ime.dart'; export 'src/default_editor/document_layers/attributed_text_bounds_overlay.dart'; export 'src/default_editor/document_hardware_keyboard/document_input_keyboard.dart'; @@ -59,7 +61,9 @@ export 'src/infrastructure/attributed_text_styles.dart'; export 'src/infrastructure/attribution_layout_bounds.dart'; export 'src/infrastructure/composable_text.dart'; export 'src/infrastructure/content_layers.dart'; +export 'src/infrastructure/documents/document_layers.dart'; export 'src/infrastructure/documents/document_scroller.dart'; +export 'src/infrastructure/documents/selection_leader_document_layer.dart'; export 'src/infrastructure/focus.dart'; export 'src/infrastructure/ime_input_owner.dart'; export 'src/infrastructure/keyboard.dart'; @@ -70,10 +74,11 @@ 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'; +export 'src/infrastructure/platforms/ios/floating_cursor.dart'; export 'src/infrastructure/platforms/ios/toolbar.dart'; +export 'src/infrastructure/platforms/ios/magnifier.dart'; export 'src/infrastructure/platforms/mobile_documents.dart'; export 'src/infrastructure/scrolling_diagnostics/scrolling_diagnostics.dart'; -export 'src/infrastructure/selection_leader_document_layer.dart'; export 'src/infrastructure/signal_notifier.dart'; export 'src/infrastructure/strings.dart'; export 'src/super_textfield/super_textfield.dart'; diff --git a/super_editor/pubspec.yaml b/super_editor/pubspec.yaml index 3aa63771ed..d2dfeccceb 100644 --- a/super_editor/pubspec.yaml +++ b/super_editor/pubspec.yaml @@ -31,7 +31,7 @@ dependencies: super_text_layout: ^0.1.7 url_launcher: ^6.1.9 uuid: ^3.0.3 - overlord: ^0.0.3 + overlord: ^0.0.3+4 # Dependencies for testing tools that we ship with super_editor flutter_test: diff --git a/super_editor/test/infrastructure/content_layers_test.dart b/super_editor/test/infrastructure/content_layers_test.dart index a0d7bcef7e..49beaa7da1 100644 --- a/super_editor/test/infrastructure/content_layers_test.dart +++ b/super_editor/test/infrastructure/content_layers_test.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:super_editor/src/infrastructure/content_layers.dart'; import 'package:super_editor/super_editor.dart'; import 'package:super_editor/super_editor_test.dart'; @@ -495,7 +494,7 @@ class _SizeValidatingLayer extends ContentLayerStatefulWidget { class _SizeValidatingLayerState extends ContentLayerState<_SizeValidatingLayer, Object> { @override - Object? computeLayoutData(RenderObject? contentLayout) => null; + Object? computeLayoutData(Element? contentElement, RenderObject? contentLayout) => null; @override Widget doBuild(BuildContext context, Object? layoutData) { @@ -717,7 +716,7 @@ class _RebuildableContentLayerWidgetState extends ContentLayerState<_Rebuildable } @override - Object? computeLayoutData(RenderObject? contentLayout) => null; + Object? computeLayoutData(Element? contentElement, RenderObject? contentLayout) => null; @override Widget doBuild(BuildContext context, Object? object) { diff --git a/super_editor/test/super_editor/mobile/super_editor_ios_overlay_controls_test.dart b/super_editor/test/super_editor/mobile/super_editor_ios_overlay_controls_test.dart new file mode 100644 index 0000000000..f3cfb920bc --- /dev/null +++ b/super_editor/test/super_editor/mobile/super_editor_ios_overlay_controls_test.dart @@ -0,0 +1,111 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/super_editor_test.dart'; + +import '../../test_runners.dart'; +import '../supereditor_test_tools.dart'; + +void main() { + group("SuperEditor > iOS > overlay controls >", () { + group("on device and web > shows ", () { + testWidgetsOnIosDeviceAndWeb("caret", (tester) async { + await _pumpApp(tester); + + // Create a collapsed selection. + await tester.tapInParagraph("1", 1); + + // Ensure we have a collapsed selection. + expect(SuperEditorInspector.findDocumentSelection(), isNotNull); + expect(SuperEditorInspector.findDocumentSelection()!.isCollapsed, isTrue); + + // Ensure caret (and only caret) is visible. + expect(SuperEditorInspector.findMobileCaret(), findsOneWidget); + expect(SuperEditorInspector.findMobileExpandedDragHandles(), findsNothing); + }); + + testWidgetsOnIosDeviceAndWeb("upstream and downstream handles", (tester) async { + await _pumpApp(tester); + + // Create an expanded selection. + await tester.doubleTapInParagraph("1", 1); + + // Ensure we have an expanded selection. + expect(SuperEditorInspector.findDocumentSelection(), isNotNull); + expect(SuperEditorInspector.findDocumentSelection()!.isCollapsed, isFalse); + + // Ensure expanded handles are visible, but caret isn't. + expect(SuperEditorInspector.findMobileCaret(), findsNothing); + expect(SuperEditorInspector.findMobileUpstreamDragHandle(), findsOneWidget); + expect(SuperEditorInspector.findMobileDownstreamDragHandle(), findsOneWidget); + }); + }); + + group("on device >", () { + group("shows", () { + testWidgetsOnIos("the magnifier", (tester) async { + await _pumpApp(tester); + + // Long press, and hold, so that the magnifier appears. + await tester.longPressDownInParagraph("1", 1); + + // Ensure the magnifier is wanted AND visible. + expect(SuperEditorInspector.wantsMobileMagnifierToBeVisible(), isTrue); + expect(SuperEditorInspector.isMobileMagnifierVisible(), isTrue); + }); + + testWidgetsOnIos("the floating toolbar", (tester) async { + await _pumpApp(tester); + + // Create an expanded selection. + await tester.doubleTapInParagraph("1", 1); + + // Ensure we have an expanded selection. + expect(SuperEditorInspector.findDocumentSelection(), isNotNull); + expect(SuperEditorInspector.findDocumentSelection()!.isCollapsed, isFalse); + + // Ensure that the toolbar is desired AND displayed. + expect(SuperEditorInspector.wantsMobileToolbarToBeVisible(), isTrue); + expect(SuperEditorInspector.isMobileToolbarVisible(), isTrue); + }); + }); + }); + + group("on web >", () { + group("defers to browser to show", () { + testWidgetsOnWebIos("the magnifier", (tester) async { + await _pumpApp(tester); + + // Long press, and hold, so that the magnifier appears. + await tester.longPressDownInParagraph("1", 1); + + // Ensure the magnifier is desired, but not displayed. + expect(SuperEditorInspector.wantsMobileMagnifierToBeVisible(), isTrue); + expect(SuperEditorInspector.isMobileMagnifierVisible(), isFalse); + }); + + testWidgetsOnWebIos("the floating toolbar", (tester) async { + await _pumpApp(tester); + + // Create an expanded selection. + await tester.doubleTapInParagraph("1", 1); + + // Ensure we have an expanded selection. + expect(SuperEditorInspector.findDocumentSelection(), isNotNull); + expect(SuperEditorInspector.findDocumentSelection()!.isCollapsed, isFalse); + + // Ensure that the toolbar is desired, but not displayed. + expect(SuperEditorInspector.wantsMobileToolbarToBeVisible(), isTrue); + expect(SuperEditorInspector.isMobileToolbarVisible(), isFalse); + }); + }); + }); + }); +} + +Future _pumpApp(WidgetTester tester) async { + await tester + .createDocument() + // Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor... + .withSingleParagraph() + .pump(); +} diff --git a/super_editor/test/super_editor/mobile/super_editor_ios_selection_test.dart b/super_editor/test/super_editor/mobile/super_editor_ios_selection_test.dart index c5c8251445..d407a75c8a 100644 --- a/super_editor/test/super_editor/mobile/super_editor_ios_selection_test.dart +++ b/super_editor/test/super_editor/mobile/super_editor_ios_selection_test.dart @@ -1,6 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test_runners/flutter_test_runners.dart'; -import 'package:super_editor/src/infrastructure/platforms/ios/magnifier.dart'; import 'package:super_editor/src/infrastructure/platforms/ios/selection_handles.dart'; import 'package:super_editor/super_editor.dart'; import 'package:super_editor/super_editor_test.dart'; @@ -199,7 +198,8 @@ Future _pumpAppWithLongText(WidgetTester tester) async { .createDocument() // "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod...", .withSingleParagraph() - .withiOSToolbarBuilder((context) => const IOSTextEditingFloatingToolbar(focalPoint: Offset.zero)) + .withiOSToolbarBuilder((context, mobileToolbarKey, focalPoint) => + IOSTextEditingFloatingToolbar(key: mobileToolbarKey, focalPoint: focalPoint)) .pump(); } diff --git a/super_editor/test/super_editor/supereditor_caret_test.dart b/super_editor/test/super_editor/supereditor_caret_test.dart index e4ed908d02..77df56f151 100644 --- a/super_editor/test/super_editor/supereditor_caret_test.dart +++ b/super_editor/test/super_editor/supereditor_caret_test.dart @@ -246,7 +246,7 @@ void main() { }); group('on iOS', () { - testWidgets('from portrait to landscape updates caret position', (WidgetTester tester) async { + testWidgetsOnIos('from portrait to landscape updates caret position', (WidgetTester tester) async { tester.view ..devicePixelRatio = 1.0 ..platformDispatcher.textScaleFactorTestValue = 1.0 @@ -282,7 +282,7 @@ void main() { expect(finalCaretOffset, expectedFinalCaretOffset); }); - testWidgets('from landscape to portrait updates caret position', (WidgetTester tester) async { + testWidgetsOnIos('from landscape to portrait updates caret position', (WidgetTester tester) async { tester.view ..devicePixelRatio = 1.0 ..platformDispatcher.textScaleFactorTestValue = 1.0 @@ -367,8 +367,12 @@ Offset _getCurrentAndroidCaretOffset(WidgetTester tester) { /// The reason for having different implementations is that depending on the gesture mode, /// the widget that holds the caret offset is different Offset _getIosCurrentCaretOffset(WidgetTester tester) { - final controls = tester.widget(find.byType(IosDocumentTouchEditingControls).last); - return controls.editingController.caretTop!; + // TODO: provide new way to query the top of the caret now that we're using an iOS controls context and not an edit controller + // final controls = tester.widget(find.byType(IosEditingToolbarOverlay).last); + // return controls.editingController.caretTop!; + + final controls = tester.state(find.byType(IosHandlesDocumentLayer).last) as IosControlsDocumentLayerState; + return controls.caret!.topCenter; } /// Given a [textPosition], compute the expected (x,y) for the caret diff --git a/super_editor/test/super_editor/supereditor_component_selection_test.dart b/super_editor/test/super_editor/supereditor_component_selection_test.dart index b9cf064f4d..8ffe70f7fb 100644 --- a/super_editor/test/super_editor/supereditor_component_selection_test.dart +++ b/super_editor/test/super_editor/supereditor_component_selection_test.dart @@ -556,19 +556,23 @@ Future _pumpEditorWithUnselectableHrsAndFakeToolbar( await tester.pumpWidget( MaterialApp( home: Scaffold( - body: SuperEditor( - editor: editor, - document: document, - composer: composer, - gestureMode: debugDefaultTargetPlatformOverride == TargetPlatform.android - ? DocumentGestureMode.android - : DocumentGestureMode.iOS, - androidToolbarBuilder: (_) => SizedBox(key: toolbarKey), - iOSToolbarBuilder: (_) => SizedBox(key: toolbarKey), - componentBuilders: [ - const _UnselectableHrComponentBuilder(), - ...defaultComponentBuilders, - ], + body: SuperEditorIosControlsScope( + controller: SuperEditorIosControlsController( + toolbarBuilder: (_, __, ___) => SizedBox(key: toolbarKey), + ), + child: SuperEditor( + editor: editor, + document: document, + composer: composer, + gestureMode: debugDefaultTargetPlatformOverride == TargetPlatform.android + ? DocumentGestureMode.android + : DocumentGestureMode.iOS, + androidToolbarBuilder: (_) => SizedBox(key: toolbarKey), + componentBuilders: const [ + _UnselectableHrComponentBuilder(), + ...defaultComponentBuilders, + ], + ), ), ), ), diff --git a/super_editor/test/super_editor/supereditor_floating_cursor_test.dart b/super_editor/test/super_editor/supereditor_floating_cursor_test.dart index ef83a9f52f..0a2541122f 100644 --- a/super_editor/test/super_editor/supereditor_floating_cursor_test.dart +++ b/super_editor/test/super_editor/supereditor_floating_cursor_test.dart @@ -178,7 +178,7 @@ void main() { DocumentSelection.collapsed( position: DocumentPosition( nodeId: nodeId, - nodePosition: const TextNodePosition(offset: 4, affinity: TextAffinity.upstream), + nodePosition: const TextNodePosition(offset: 4), ), ), ); diff --git a/super_editor/test/super_editor/supereditor_gestures_test.dart b/super_editor/test/super_editor/supereditor_gestures_test.dart index 1f1baa5dbf..c9860a8771 100644 --- a/super_editor/test/super_editor/supereditor_gestures_test.dart +++ b/super_editor/test/super_editor/supereditor_gestures_test.dart @@ -478,7 +478,7 @@ spans multiple lines.''', await tester.placeCaretInParagraph(SuperEditorInspector.findDocument()!.nodes.first.id, 0); // Ensure the drag handle is displayed. - expect(find.byType(IosDocumentTouchEditingControls), findsOneWidget); + expect(find.byType(IosFloatingToolbarOverlay), findsOneWidget); }); testWidgetsOnDesktop('configures default gesture mode', (tester) async { @@ -491,7 +491,7 @@ spans multiple lines.''', // Ensure no drag handle is displayed. expect(find.byType(AndroidSelectionHandle), findsNothing); - expect(find.byType(IosDocumentTouchEditingControls), findsNothing); + expect(find.byType(IosFloatingToolbarOverlay), findsNothing); }); group("interaction mode", () { diff --git a/super_editor/test/super_editor/supereditor_scrolling_test.dart b/super_editor/test/super_editor/supereditor_scrolling_test.dart index 915ada9b00..8a527dca19 100644 --- a/super_editor/test/super_editor/supereditor_scrolling_test.dart +++ b/super_editor/test/super_editor/supereditor_scrolling_test.dart @@ -544,7 +544,7 @@ void main() { expect(caretOffset.dy, greaterThanOrEqualTo(screenSizeWithKeyboard.height - trailingBoundary)); }); - testWidgets('on iOS, keeps caret visible when keyboard appears', (WidgetTester tester) async { + testWidgetsOnIos('on iOS, keeps caret visible when keyboard appears', (WidgetTester tester) async { tester.view ..physicalSize = screenSizeWithoutKeyboard ..platformDispatcher.textScaleFactorTestValue = 1.0 @@ -559,6 +559,7 @@ void main() { // Select text near the bottom of the screen, where the keyboard will appear final tapPosition = Offset(screenSizeWithoutKeyboard.width / 2, screenSizeWithoutKeyboard.height - 1); await tester.tapAt(tapPosition); + await tester.pump(); // Shrink the screen height, as if the keyboard appeared. await _simulateKeyboardAppearance( @@ -569,16 +570,15 @@ void main() { ); // Ensure that the editor auto-scrolled to keep the caret visible. - // TODO: there are 2 `BlinkingCaret` at the same time. There should be only 1 caret final caretFinder = find.byType(BlinkingCaret); - final caretOffset = tester.getBottomLeft(caretFinder.last); + final caretOffset = tester.getBottomLeft(caretFinder); // The default trailing boundary of the default `SuperEditor` const trailingBoundary = 54.0; // The caret should be at the trailing boundary, within a small margin of error - expect(caretOffset.dy, lessThanOrEqualTo(screenSizeWithKeyboard.height - trailingBoundary)); - expect(caretOffset.dy, greaterThanOrEqualTo(screenSizeWithKeyboard.height - trailingBoundary)); + expect(caretOffset.dy, lessThanOrEqualTo(screenSizeWithKeyboard.height - trailingBoundary + 2)); + expect(caretOffset.dy, greaterThanOrEqualTo(screenSizeWithKeyboard.height - trailingBoundary - 2)); }); testWidgetsOnMobile('scrolling doesn\'t cause the keyboard to open', (tester) async { diff --git a/super_editor/test/super_editor/supereditor_test_tools.dart b/super_editor/test/super_editor/supereditor_test_tools.dart index d6bfa2df6e..0c9c65dc57 100644 --- a/super_editor/test/super_editor/supereditor_test_tools.dart +++ b/super_editor/test/super_editor/supereditor_test_tools.dart @@ -252,7 +252,7 @@ class TestSuperEditorConfigurator { } /// Configures the [SuperEditor] to use the given [builder] as its iOS toolbar builder. - TestSuperEditorConfigurator withiOSToolbarBuilder(WidgetBuilder? builder) { + TestSuperEditorConfigurator withiOSToolbarBuilder(DocumentFloatingToolbarBuilder? builder) { _config.iOSToolbarBuilder = builder; return this; } @@ -400,37 +400,41 @@ class TestSuperEditorConfigurator { /// Builds a [SuperEditor] widget based on the configuration of the given /// [testDocumentContext], as well as other configurations in this class. Widget _buildSuperEditor(TestDocumentContext testDocumentContext) { - return SuperEditor( - key: _config.key, - focusNode: testDocumentContext.focusNode, - editor: testDocumentContext.editor, - document: testDocumentContext.document, - composer: testDocumentContext.composer, - documentLayoutKey: testDocumentContext.layoutKey, - inputSource: _config.inputSource, - selectionPolicies: _config.selectionPolicies ?? const SuperEditorSelectionPolicies(), - selectionStyle: _config.selectionStyles, - softwareKeyboardController: _config.softwareKeyboardController, - imePolicies: _config.imePolicies ?? const SuperEditorImePolicies(), - imeConfiguration: _config.imeConfiguration, - imeOverrides: _config.imeOverrides, - keyboardActions: [ - ..._config.prependedKeyboardActions, - ...(_config.inputSource == TextInputSource.ime ? defaultImeKeyboardActions : defaultKeyboardActions), - ..._config.appendedKeyboardActions, - ], - selectorHandlers: _config.selectorHandlers, - gestureMode: _config.gestureMode, - androidToolbarBuilder: _config.androidToolbarBuilder, - iOSToolbarBuilder: _config.iOSToolbarBuilder, - stylesheet: _config.stylesheet, - componentBuilders: [ - ..._config.addedComponents, - ...(_config.componentBuilders ?? defaultComponentBuilders), - ], - autofocus: _config.autoFocus, - scrollController: _config.scrollController, - plugins: _config.plugins, + return SuperEditorIosControlsScope( + controller: SuperEditorIosControlsController( + toolbarBuilder: _config.iOSToolbarBuilder, + ), + child: SuperEditor( + key: _config.key, + focusNode: testDocumentContext.focusNode, + editor: testDocumentContext.editor, + document: testDocumentContext.document, + composer: testDocumentContext.composer, + documentLayoutKey: testDocumentContext.layoutKey, + inputSource: _config.inputSource, + selectionPolicies: _config.selectionPolicies ?? const SuperEditorSelectionPolicies(), + selectionStyle: _config.selectionStyles, + softwareKeyboardController: _config.softwareKeyboardController, + imePolicies: _config.imePolicies ?? const SuperEditorImePolicies(), + imeConfiguration: _config.imeConfiguration, + imeOverrides: _config.imeOverrides, + keyboardActions: [ + ..._config.prependedKeyboardActions, + ...(_config.inputSource == TextInputSource.ime ? defaultImeKeyboardActions : defaultKeyboardActions), + ..._config.appendedKeyboardActions, + ], + selectorHandlers: _config.selectorHandlers, + gestureMode: _config.gestureMode, + androidToolbarBuilder: _config.androidToolbarBuilder, + stylesheet: _config.stylesheet, + componentBuilders: [ + ..._config.addedComponents, + ...(_config.componentBuilders ?? defaultComponentBuilders), + ], + autofocus: _config.autoFocus, + scrollController: _config.scrollController, + plugins: _config.plugins, + ), ); } } @@ -463,7 +467,7 @@ class SuperEditorTestConfiguration { final appendedKeyboardActions = []; final addedComponents = []; WidgetBuilder? androidToolbarBuilder; - WidgetBuilder? iOSToolbarBuilder; + DocumentFloatingToolbarBuilder? iOSToolbarBuilder; DocumentSelection? selection; @@ -720,6 +724,9 @@ class FakeDocumentLayout with Mock implements DocumentLayout {} /// with logical resources but do not depend upon a real widget /// tree with a real `Scrollable`. class FakeSuperEditorScroller implements DocumentScroller { + @override + void dispose() {} + @override double get viewportDimension => throw UnimplementedError(); @@ -746,4 +753,10 @@ class FakeSuperEditorScroller implements DocumentScroller { @override void detach() => throw UnimplementedError(); + + @override + void addScrollChangeListener(ui.VoidCallback listener) => throw UnimplementedError(); + + @override + void removeScrollChangeListener(ui.VoidCallback listener) => throw UnimplementedError(); } diff --git a/super_editor/test/super_editor/text_entry/super_editor_common_text_entry_test.dart b/super_editor/test/super_editor/text_entry/super_editor_common_text_entry_test.dart index 9e4de6e3c1..80e4ba49f9 100644 --- a/super_editor/test/super_editor/text_entry/super_editor_common_text_entry_test.dart +++ b/super_editor/test/super_editor/text_entry/super_editor_common_text_entry_test.dart @@ -1,3 +1,5 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test_runners/flutter_test_runners.dart'; @@ -9,11 +11,7 @@ import '../supereditor_test_tools.dart'; void main() { group("SuperEditor common text entry >", () { testWidgetsOnDesktop("control keys don't impact content", (tester) async { - await tester // - .createDocument() - .withSingleParagraph() - .withInputSource(_desktopInputSourceAndControlKeyVariant.currentValue!.inputSource) - .pump(); + await _pumpApp(tester, _desktopInputSourceAndControlKeyVariant.currentValue!.inputSource); final initialParagraphText = SuperEditorInspector.findTextInParagraph("1"); @@ -28,7 +26,7 @@ void main() { // Press a control key. await tester.sendKeyEvent( _desktopInputSourceAndControlKeyVariant.currentValue!.controlKey, - platform: _desktopInputSourceAndControlKeyVariant.currentValue!.platform, + platform: _platformNames[defaultTargetPlatform]!, ); // Make sure the content and selection remains the same. @@ -37,11 +35,7 @@ void main() { }, variant: _desktopInputSourceAndControlKeyVariant); testWidgetsOnMobile("control keys don't impact content", (tester) async { - await tester // - .createDocument() - .withSingleParagraph() - .withInputSource(_mobileInputSourceAndControlKeyVariant.currentValue!.inputSource) - .pump(); + await _pumpApp(tester, _mobileInputSourceAndControlKeyVariant.currentValue!.inputSource); final initialParagraphText = SuperEditorInspector.findTextInParagraph("1"); @@ -56,7 +50,7 @@ void main() { // Press a control key. await tester.sendKeyEvent( _mobileInputSourceAndControlKeyVariant.currentValue!.controlKey, - platform: _mobileInputSourceAndControlKeyVariant.currentValue!.platform, + platform: _platformNames[defaultTargetPlatform]!, ); // Make sure the content and selection remains the same. @@ -66,41 +60,65 @@ void main() { }); } +Future _pumpApp(WidgetTester tester, TextInputSource inputSource) async { + await tester // + .createDocument() + .withSingleParagraph() + .withInputSource(inputSource) + .withCustomWidgetTreeBuilder((superEditor) { + return MaterialApp( + home: Scaffold( + body: Column( + children: [ + // Add focusable widgets before and after SuperEditor so that we + // catch any keys that try to move focus forward or backward. + const Focus(child: SizedBox(width: double.infinity, height: 54)), + Expanded( + child: superEditor, + ), + const Focus(child: SizedBox(width: double.infinity, height: 54)), + ], + ), + ), + ); + }).pump(); +} + final _mobileInputSourceAndControlKeyVariant = ValueVariant({ - for (final platform in _mobilePlatforms) - for (final inputSource in TextInputSource.values) - for (final controlKey in _allPlatformControlKeys) // - _InputSourceAndControlKey(inputSource, controlKey, platform), + for (final inputSource in TextInputSource.values) + for (final controlKey in _allPlatformControlKeys) // + _InputSourceAndControlKey(inputSource, controlKey), }); final _desktopInputSourceAndControlKeyVariant = ValueVariant({ - for (final platform in _desktopPlatforms) - for (final inputSource in TextInputSource.values) - for (final controlKey in _desktopControlKeys) // - _InputSourceAndControlKey(inputSource, controlKey, platform), + for (final inputSource in TextInputSource.values) + for (final controlKey in _desktopControlKeys) // + _InputSourceAndControlKey(inputSource, controlKey), }); // TODO: Replace raw strings with constants when Flutter offers them (https://github.com/flutter/flutter/issues/133295) -final _mobilePlatforms = ["android", "ios"]; -final _desktopPlatforms = ["macos", "windows", "linux"]; +final _platformNames = { + TargetPlatform.android: "android", + TargetPlatform.iOS: "ios", + TargetPlatform.macOS: "macos", + TargetPlatform.windows: "windows", + TargetPlatform.linux: "linux", +}; class _InputSourceAndControlKey { _InputSourceAndControlKey( this.inputSource, this.controlKey, - this.platform, ); final TextInputSource inputSource; final LogicalKeyboardKey controlKey; - final String platform; @override - String toString() => "$inputSource, ${controlKey.keyLabel}, $platform"; + String toString() => "$inputSource, ${controlKey.keyLabel}"; } final _allPlatformControlKeys = { - LogicalKeyboardKey.tab, LogicalKeyboardKey.capsLock, LogicalKeyboardKey.shift, LogicalKeyboardKey.control, diff --git a/super_editor/test/super_editor/text_entry/text_test.dart b/super_editor/test/super_editor/text_entry/text_test.dart index fac3e18486..8c8ee98236 100644 --- a/super_editor/test/super_editor/text_entry/text_test.dart +++ b/super_editor/test/super_editor/text_entry/text_test.dart @@ -1,4 +1,3 @@ -import 'package:flutter/cupertino.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:super_editor/super_editor.dart'; diff --git a/super_editor/test/super_reader/mobile/super_reader_ios_overlay_controls_test.dart b/super_editor/test/super_reader/mobile/super_reader_ios_overlay_controls_test.dart new file mode 100644 index 0000000000..948ee84145 --- /dev/null +++ b/super_editor/test/super_reader/mobile/super_reader_ios_overlay_controls_test.dart @@ -0,0 +1,97 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/src/test/super_editor_test/supereditor_robot.dart'; +import 'package:super_editor/src/test/super_reader_test/super_reader_inspector.dart'; + +import '../../test_runners.dart'; +import '../reader_test_tools.dart'; + +void main() { + group("SuperReader > iOS > overlay controls >", () { + group("on device and web > shows ", () { + testWidgetsOnIosDeviceAndWeb("upstream and downstream handles", (tester) async { + await _pumpApp(tester); + + // Create an expanded selection. + await tester.doubleTapInParagraph("1", 1); + + // Ensure we have an expanded selection. + expect(SuperReaderInspector.findDocumentSelection(), isNotNull); + expect(SuperReaderInspector.findDocumentSelection()!.isCollapsed, isFalse); + + // Ensure expanded handles are visible, but caret isn't. + expect(SuperReaderInspector.findMobileCaret(), findsNothing); + expect(SuperReaderInspector.findMobileUpstreamDragHandle(), findsOneWidget); + expect(SuperReaderInspector.findMobileDownstreamDragHandle(), findsOneWidget); + }); + }); + + group("on device >", () { + group("shows", () { + testWidgetsOnIos("the magnifier", (tester) async { + await _pumpApp(tester); + + // Long press, and hold, so that the magnifier appears. + await tester.longPressDownInParagraph("1", 1); + + // Ensure the magnifier is wanted AND visible. + expect(SuperReaderInspector.wantsMobileMagnifierToBeVisible(), isTrue); + expect(SuperReaderInspector.isMobileMagnifierVisible(), isTrue); + }); + + testWidgetsOnIos("the floating toolbar", (tester) async { + await _pumpApp(tester); + + // Create an expanded selection. + await tester.doubleTapInParagraph("1", 1); + + // Ensure we have an expanded selection. + expect(SuperReaderInspector.findDocumentSelection(), isNotNull); + expect(SuperReaderInspector.findDocumentSelection()!.isCollapsed, isFalse); + + // Ensure that the toolbar is desired AND displayed. + expect(SuperReaderInspector.wantsMobileToolbarToBeVisible(), isTrue); + expect(SuperReaderInspector.isMobileToolbarVisible(), isTrue); + }); + }); + }); + + group("on web >", () { + group("defers to browser to show", () { + testWidgetsOnWebIos("the magnifier", (tester) async { + await _pumpApp(tester); + + // Long press, and hold, so that the magnifier appears. + await tester.longPressDownInParagraph("1", 1); + + // Ensure the magnifier is desired, but not displayed. + expect(SuperReaderInspector.wantsMobileMagnifierToBeVisible(), isTrue); + expect(SuperReaderInspector.isMobileMagnifierVisible(), isFalse); + }); + + testWidgetsOnWebIos("the floating toolbar", (tester) async { + await _pumpApp(tester); + + // Create an expanded selection. + await tester.doubleTapInParagraph("1", 1); + + // Ensure we have an expanded selection. + expect(SuperReaderInspector.findDocumentSelection(), isNotNull); + expect(SuperReaderInspector.findDocumentSelection()!.isCollapsed, isFalse); + + // Ensure that the toolbar is desired, but not displayed. + expect(SuperReaderInspector.wantsMobileToolbarToBeVisible(), isTrue); + expect(SuperReaderInspector.isMobileToolbarVisible(), isFalse); + }); + }); + }); + }); +} + +Future _pumpApp(WidgetTester tester) async { + await tester + .createDocument() + // Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor... + .withSingleParagraph() + .pump(); +} diff --git a/super_editor/test/super_reader/mobile/super_reader_ios_selection_test.dart b/super_editor/test/super_reader/mobile/super_reader_ios_selection_test.dart index 11025a32c9..810d4a6c9b 100644 --- a/super_editor/test/super_reader/mobile/super_reader_ios_selection_test.dart +++ b/super_editor/test/super_reader/mobile/super_reader_ios_selection_test.dart @@ -1,6 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test_runners/flutter_test_runners.dart'; -import 'package:super_editor/src/infrastructure/platforms/ios/magnifier.dart'; import 'package:super_editor/src/infrastructure/platforms/ios/selection_handles.dart'; import 'package:super_editor/src/test/super_editor_test/supereditor_robot.dart'; import 'package:super_editor/super_editor.dart'; @@ -17,7 +16,8 @@ void main() { .createDocument() // "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod...", .withSingleParagraph() - .withiOSToolbarBuilder((context) => const IOSTextEditingFloatingToolbar(focalPoint: Offset.zero)) + .withiOSToolbarBuilder((context, mobileToolbarKey, focalPoint) => + IOSTextEditingFloatingToolbar(key: mobileToolbarKey, focalPoint: focalPoint)) .pump(); // Ensure that no overlay controls are visible. @@ -56,7 +56,8 @@ void main() { .createDocument() // "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod...", .withSingleParagraph() - .withiOSToolbarBuilder((context) => const IOSTextEditingFloatingToolbar(focalPoint: Offset.zero)) + .withiOSToolbarBuilder((context, mobileToolbarKey, focalPoint) => + IOSTextEditingFloatingToolbar(key: mobileToolbarKey, focalPoint: focalPoint)) .pump(); // Long press on the middle of "do|lor". @@ -98,7 +99,8 @@ void main() { .createDocument() // "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod...", .withSingleParagraph() - .withiOSToolbarBuilder((context) => const IOSTextEditingFloatingToolbar(focalPoint: Offset.zero)) + .withiOSToolbarBuilder((context, mobileToolbarKey, focalPoint) => + IOSTextEditingFloatingToolbar(key: mobileToolbarKey, focalPoint: focalPoint)) .pump(); // Long press on the middle of "do|lor". @@ -173,7 +175,8 @@ void main() { .createDocument() // "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod...", .withSingleParagraph() - .withiOSToolbarBuilder((context) => const IOSTextEditingFloatingToolbar(focalPoint: Offset.zero)) + .withiOSToolbarBuilder((context, mobileToolbarKey, focalPoint) => + IOSTextEditingFloatingToolbar(key: mobileToolbarKey, focalPoint: focalPoint)) .pump(); // Long press on the middle of "do|lor". diff --git a/super_editor/test/super_reader/reader_test_tools.dart b/super_editor/test/super_reader/reader_test_tools.dart index 49bd0e07ff..cc5e8ebba1 100644 --- a/super_editor/test/super_reader/reader_test_tools.dart +++ b/super_editor/test/super_reader/reader_test_tools.dart @@ -94,7 +94,7 @@ class TestDocumentConfigurator { FocusNode? _focusNode; DocumentSelection? _selection; WidgetBuilder? _androidToolbarBuilder; - WidgetBuilder? _iOSToolbarBuilder; + DocumentFloatingToolbarBuilder? _iOSToolbarBuilder; /// Configures the [SuperReader] for standard desktop interactions, /// e.g., mouse and keyboard input. @@ -184,7 +184,7 @@ class TestDocumentConfigurator { } /// Configures the [SuperEditor] to use the given [builder] as its iOS toolbar builder. - TestDocumentConfigurator withiOSToolbarBuilder(WidgetBuilder? builder) { + TestDocumentConfigurator withiOSToolbarBuilder(DocumentFloatingToolbarBuilder? builder) { _iOSToolbarBuilder = builder; return this; } @@ -243,22 +243,26 @@ class TestDocumentConfigurator { ); final superDocument = _buildContent( - SuperReader( - focusNode: testContext.focusNode, - document: documentContext.document, - documentLayoutKey: layoutKey, - selection: documentContext.selection, - selectionStyle: _selectionStyles, - gestureMode: _gestureMode ?? _defaultGestureMode, - stylesheet: _stylesheet, - componentBuilders: [ - ..._addedComponents, - ...(_componentBuilders ?? defaultComponentBuilders), - ], - autofocus: _autoFocus, - scrollController: _scrollController, - androidToolbarBuilder: _androidToolbarBuilder, - iOSToolbarBuilder: _iOSToolbarBuilder, + SuperReaderIosControlsScope( + controller: SuperReaderIosControlsController( + toolbarBuilder: _iOSToolbarBuilder, + ), + child: SuperReader( + focusNode: testContext.focusNode, + document: documentContext.document, + documentLayoutKey: layoutKey, + selection: documentContext.selection, + selectionStyle: _selectionStyles, + gestureMode: _gestureMode ?? _defaultGestureMode, + stylesheet: _stylesheet, + componentBuilders: [ + ..._addedComponents, + ...(_componentBuilders ?? defaultComponentBuilders), + ], + autofocus: _autoFocus, + scrollController: _scrollController, + androidToolbarBuilder: _androidToolbarBuilder, + ), ), ); diff --git a/super_editor/test/test_runners.dart b/super_editor/test/test_runners.dart index 1ed75589fc..2809faf665 100644 --- a/super_editor/test/test_runners.dart +++ b/super_editor/test/test_runners.dart @@ -42,7 +42,19 @@ void testWidgetsOnWebDesktop( testWidgetsOnLinuxWeb("$description (on Linux Web)", test, skip: skip, variant: variant); } -// A widget test that runs for macOS web. +/// A widget test that runs a variant for every mobile platform on web, e.g., +/// iOS, Android. +@isTestGroup +void testWidgetsOnWebMobile( + String description, + WidgetTesterCallback test, { + bool skip = false, + TestVariant variant = const DefaultTestVariant(), +}) { + testWidgetsOnWebIos("$description (on iOS Web)", test, skip: skip, variant: variant); + testWidgetsOnWebAndroid("$description (on Android Web)", test, skip: skip, variant: variant); +} + @isTestGroup void testWidgetsOnMacWeb( String description, @@ -67,7 +79,57 @@ void testWidgetsOnMacWeb( }, variant: variant, skip: skip); } -// A widget test that runs for Windows web. +@isTestGroup +void testWidgetsOnIosDeviceAndWeb( + String description, + WidgetTesterCallback test, { + bool skip = false, + TestVariant variant = const DefaultTestVariant(), +}) { + testWidgetsOnIos(description, test, skip: skip, variant: variant); + testWidgetsOnWebIos(description, test, skip: skip, variant: variant); +} + +@isTestGroup +void testWidgetsOnWebIos( + String description, + WidgetTesterCallback test, { + bool skip = false, + TestVariant variant = const DefaultTestVariant(), +}) { + testWidgets(description, (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + debugIsWebOverride = WebPlatformOverride.web; + + try { + await test(tester); + } finally { + debugDefaultTargetPlatformOverride = null; + debugIsWebOverride = null; + } + }, variant: variant, skip: skip); +} + +@isTestGroup +void testWidgetsOnWebAndroid( + String description, + WidgetTesterCallback test, { + bool skip = false, + TestVariant variant = const DefaultTestVariant(), +}) { + testWidgets(description, (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + debugIsWebOverride = WebPlatformOverride.web; + + try { + await test(tester); + } finally { + debugDefaultTargetPlatformOverride = null; + debugIsWebOverride = null; + } + }, variant: variant, skip: skip); +} + @isTestGroup void testWidgetsOnWindowsWeb( String description, @@ -92,7 +154,6 @@ void testWidgetsOnWindowsWeb( }, variant: variant, skip: skip); } -// A widget test that runs for Linux web. @isTestGroup void testWidgetsOnLinuxWeb( String description, diff --git a/super_text_layout/lib/src/infrastructure/blink_controller.dart b/super_text_layout/lib/src/infrastructure/blink_controller.dart index 413d530a74..db84e2c8c8 100644 --- a/super_text_layout/lib/src/infrastructure/blink_controller.dart +++ b/super_text_layout/lib/src/infrastructure/blink_controller.dart @@ -96,13 +96,16 @@ class BlinkController with ChangeNotifier { /// Make the object completely opaque, and restart the blink timer. void jumpToOpaque() { + final wasBlinking = isBlinking; stopBlinking(); if (!_isBlinkingEnabled) { return; } - startBlinking(); + if (wasBlinking) { + startBlinking(); + } } void _onTick(Duration elapsedTime) {