diff --git a/super_editor/.run/Flutter - Text Field.run.xml b/super_editor/.run/Flutter - Text Field.run.xml new file mode 100644 index 000000000..bc96023a3 --- /dev/null +++ b/super_editor/.run/Flutter - Text Field.run.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/super_editor/example/lib/demos/in_the_lab/feature_ios_native_context_menu.dart b/super_editor/example/lib/demos/in_the_lab/feature_ios_native_context_menu.dart new file mode 100644 index 000000000..d91b07a5b --- /dev/null +++ b/super_editor/example/lib/demos/in_the_lab/feature_ios_native_context_menu.dart @@ -0,0 +1,183 @@ +import 'package:example/demos/in_the_lab/in_the_lab_scaffold.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:follow_the_leader/follow_the_leader.dart'; +import 'package:super_editor/super_editor.dart'; + +/// Super Editor demo that uses the native iOS context menu as the floating toolbar +/// for both Super Editor and Super Text Field. +/// +/// By default, Super Editor and Super Text Field display a floating toolbar that's +/// painted by Flutter. By using Flutter, you gain full control over appearance, and +/// the available options. However, recent versions of iOS have security settings +/// that bring up an annoying warning if you attempt to run a "paste" command without +/// using their native iOS toolbar. For that reason, Super Editor makes it possible +/// to show the native iOS toolbar. +class NativeIosContextMenuFeatureDemo extends StatefulWidget { + const NativeIosContextMenuFeatureDemo({super.key}); + + @override + State createState() => _NativeIosContextMenuFeatureDemoState(); +} + +class _NativeIosContextMenuFeatureDemoState extends State { + final _documentLayoutKey = GlobalKey(); + + late final MutableDocument _document; + late final MutableDocumentComposer _composer; + late final Editor _editor; + late final CommonEditorOperations _commonEditorOperations; + + late final SuperEditorIosControlsController _toolbarController; + + @override + void initState() { + super.initState(); + + _document = MutableDocument.empty(); + _composer = MutableDocumentComposer(); + _editor = Editor( + editables: { + Editor.documentKey: _document, + Editor.composerKey: _composer, + }, + requestHandlers: [ + ...defaultRequestHandlers, + ], + ); + _commonEditorOperations = CommonEditorOperations( + document: _document, + editor: _editor, + composer: _composer, + documentLayoutResolver: () => _documentLayoutKey.currentState as DocumentLayout, + ); + + _toolbarController = SuperEditorIosControlsController( + toolbarBuilder: _buildToolbar, + ); + } + + @override + void dispose() { + _toolbarController.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return InTheLabScaffold( + content: _buildEditor(), + supplemental: _buildTextField(), + ); + } + + Widget _buildEditor() { + return SuperEditorIosControlsScope( + controller: _toolbarController, + child: IntrinsicHeight( + child: SuperEditor( + editor: _editor, + documentLayoutKey: _documentLayoutKey, + selectionStyle: SelectionStyles( + selectionColor: Colors.red.withOpacity(0.3), + ), + stylesheet: defaultStylesheet.copyWith( + addRulesAfter: [ + ...darkModeStyles, + ], + ), + documentOverlayBuilders: [ + if (defaultTargetPlatform == TargetPlatform.iOS) ...[ + // 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( + handleColor: Colors.red, + ), + ], + + if (defaultTargetPlatform == TargetPlatform.android) ...[ + // Adds a Leader around the document selection at a focal point for the + // Android floating toolbar. + SuperEditorAndroidToolbarFocalPointDocumentLayerBuilder(), + // Displays caret and drag handles, specifically for Android. + SuperEditorAndroidHandlesDocumentLayerBuilder( + caretColor: Colors.red, + ), + ], + + // Displays caret for typical desktop use-cases. + DefaultCaretOverlayBuilder( + caretStyle: const CaretStyle().copyWith(color: Colors.redAccent), + ), + ], + ), + ), + ); + } + + Widget _buildToolbar( + BuildContext context, + Key mobileToolbarKey, + LeaderLink focalPoint, + ) { + return iOSSystemPopoverEditorToolbarWithFallbackBuilder( + context, + mobileToolbarKey, + focalPoint, + _commonEditorOperations, + SuperEditorIosControlsScope.rootOf(context), + ); + } + + Widget _buildTextField() { + return Padding( + padding: const EdgeInsets.all(24), + child: _SuperTextFieldWithNativeContextMenu(), + ); + } +} + +class _SuperTextFieldWithNativeContextMenu extends StatefulWidget { + const _SuperTextFieldWithNativeContextMenu({Key? key}) : super(key: key); + + @override + State<_SuperTextFieldWithNativeContextMenu> createState() => _SuperTextFieldWithNativeContextMenuState(); +} + +class _SuperTextFieldWithNativeContextMenuState extends State<_SuperTextFieldWithNativeContextMenu> { + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + border: Border.all(color: Colors.grey), + borderRadius: BorderRadius.circular(4), + ), + child: SuperIOSTextField( + padding: const EdgeInsets.all(12), + caretStyle: CaretStyle(color: Colors.red), + selectionColor: defaultSelectionColor, + handlesColor: Colors.red, + textStyleBuilder: (attributions) { + return defaultTextFieldStyleBuilder(attributions).copyWith( + color: Colors.white, + fontSize: 18, + ); + }, + hintBehavior: HintBehavior.displayHintUntilTextEntered, + hintBuilder: (_) { + return Text( + "Enter text and open toolbar", + style: TextStyle( + color: Colors.grey, + fontSize: 18, + ), + ); + }, + popoverToolbarBuilder: iOSSystemPopoverTextFieldToolbarWithFallback, + ), + ); + } +} diff --git a/super_editor/example/lib/demos/in_the_lab/feature_pattern_tags.dart b/super_editor/example/lib/demos/in_the_lab/feature_pattern_tags.dart index 120121381..fc39e28be 100644 --- a/super_editor/example/lib/demos/in_the_lab/feature_pattern_tags.dart +++ b/super_editor/example/lib/demos/in_the_lab/feature_pattern_tags.dart @@ -68,27 +68,27 @@ class _HashTagsFeatureDemoState extends State { inlineTextStyler: (attributions, existingStyle) { TextStyle style = defaultInlineTextStyler(attributions, existingStyle); - if (attributions.whereType().isNotEmpty) { - style = style.copyWith( - color: Colors.orange, - ); - } - - return style; - }, - addRulesAfter: [ - ...darkModeStyles, - ], - ), - documentOverlayBuilders: [ - DefaultCaretOverlayBuilder( - caretStyle: CaretStyle().copyWith(color: Colors.redAccent), + if (attributions.whereType().isNotEmpty) { + style = style.copyWith( + color: Colors.orange, + ); + } + + return style; + }, + addRulesAfter: [ + ...darkModeStyles, + ], ), - ], - plugins: { - _hashTagPlugin, - }, - ); + documentOverlayBuilders: [ + DefaultCaretOverlayBuilder( + caretStyle: CaretStyle().copyWith(color: Colors.redAccent), + ), + ], + plugins: { + _hashTagPlugin, + }, + ); } Widget _buildTagList() { diff --git a/super_editor/example/lib/demos/in_the_lab/in_the_lab_scaffold.dart b/super_editor/example/lib/demos/in_the_lab/in_the_lab_scaffold.dart index 2a63470ce..b78395df5 100644 --- a/super_editor/example/lib/demos/in_the_lab/in_the_lab_scaffold.dart +++ b/super_editor/example/lib/demos/in_the_lab/in_the_lab_scaffold.dart @@ -30,15 +30,7 @@ class InTheLabScaffold extends StatelessWidget { body: Stack( children: [ Positioned.fill( - child: Row( - children: [ - Expanded( - child: content, - ), - if (supplemental != null) // - _buildSupplementalPanel(), - ], - ), + child: _buildContent(), ), if (overlay != null) // Positioned.fill( @@ -52,7 +44,31 @@ class InTheLabScaffold extends StatelessWidget { ); } - Widget _buildSupplementalPanel() { + Widget _buildContent() { + return LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxWidth / constraints.maxHeight >= 1) { + return _buildContentForDesktop(); + } else { + return _buildContentForMobile(); + } + }, + ); + } + + Widget _buildContentForDesktop() { + return Row( + children: [ + Expanded( + child: content, + ), + if (supplemental != null) // + _buildSupplementalSidePanel(), + ], + ); + } + + Widget _buildSupplementalSidePanel() { return Container( width: 250, height: double.infinity, @@ -82,6 +98,58 @@ class InTheLabScaffold extends StatelessWidget { ), ); } + + Widget _buildContentForMobile() { + return SafeArea( + left: false, + right: false, + bottom: false, + child: Padding( + // Push the content down below the nav drawer menu button. + padding: const EdgeInsets.only(top: 24), + child: Column( + children: [ + Expanded( + child: content, + ), + if (supplemental != null) // + _buildSupplementalBottomPanel(), + ], + ), + ), + ); + } + + Widget _buildSupplementalBottomPanel() { + return Container( + width: double.infinity, + height: 200, + decoration: BoxDecoration( + border: Border(top: 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. diff --git a/super_editor/example/lib/demos/supertextfield/ios/demo_superiostextfield.dart b/super_editor/example/lib/demos/supertextfield/ios/demo_superiostextfield.dart index 07f6df7e0..0ca007697 100644 --- a/super_editor/example/lib/demos/supertextfield/ios/demo_superiostextfield.dart +++ b/super_editor/example/lib/demos/supertextfield/ios/demo_superiostextfield.dart @@ -76,6 +76,7 @@ class _SuperIOSTextFieldDemoState extends State { maxLines: config.maxLines, lineHeight: lineHeight, textInputAction: TextInputAction.done, + popoverToolbarBuilder: iOSSystemPopoverTextFieldToolbarWithFallback, showDebugPaint: config.showDebugPaint, ), ); diff --git a/super_editor/example/lib/flutter_demos/main_flutter_textfield.dart b/super_editor/example/lib/flutter_demos/main_flutter_textfield.dart new file mode 100644 index 000000000..873461833 --- /dev/null +++ b/super_editor/example/lib/flutter_demos/main_flutter_textfield.dart @@ -0,0 +1,63 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +void main() { + runApp(_FlutterTextFieldDemoApp()); +} + +class _FlutterTextFieldDemoApp extends StatelessWidget { + const _FlutterTextFieldDemoApp(); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + body: Padding( + padding: const EdgeInsets.all(24), + child: Center( + child: _DemoTextField(), + ), + ), + ), + ); + } +} + +class _DemoTextField extends StatefulWidget { + const _DemoTextField(); + + @override + State<_DemoTextField> createState() => _DemoTextFieldState(); +} + +class _DemoTextFieldState extends State<_DemoTextField> { + final _textController = TextEditingController(); + + @override + void dispose() { + _textController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return TextField( + controller: _textController, + decoration: InputDecoration( + hintText: "Enter text...", + ), + contextMenuBuilder: (BuildContext context, EditableTextState editableTextState) { + // If supported, show the system context menu. + if (SystemContextMenu.isSupported(context)) { + return SystemContextMenu.editableText( + editableTextState: editableTextState, + ); + } + // Otherwise, show the flutter-rendered context menu for the current + // platform. + return AdaptiveTextSelectionToolbar.editableText( + editableTextState: editableTextState, + ); + }); + } +} diff --git a/super_editor/example/lib/main.dart b/super_editor/example/lib/main.dart index a8435a378..0b1b12c3d 100644 --- a/super_editor/example/lib/main.dart +++ b/super_editor/example/lib/main.dart @@ -15,6 +15,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/feature_action_tags.dart'; +import 'package:example/demos/in_the_lab/feature_ios_native_context_menu.dart'; import 'package:example/demos/in_the_lab/feature_pattern_tags.dart'; import 'package:example/demos/in_the_lab/feature_stable_tags.dart'; import 'package:example/demos/in_the_lab/selected_text_colors_demo.dart'; @@ -324,6 +325,13 @@ final _menu = <_MenuGroup>[ return const ActionTagsFeatureDemo(); }, ), + _MenuItem( + icon: Icons.apple, + title: 'Native iOS Toolbar', + pageBuilder: (context) { + return const NativeIosContextMenuFeatureDemo(); + }, + ), ], ), _MenuGroup( diff --git a/super_editor/lib/src/default_editor/super_editor.dart b/super_editor/lib/src/default_editor/super_editor.dart index 98c3a478d..ad907131d 100644 --- a/super_editor/lib/src/default_editor/super_editor.dart +++ b/super_editor/lib/src/default_editor/super_editor.dart @@ -38,6 +38,7 @@ import 'package:super_editor/src/infrastructure/render_sliver_ext.dart'; import 'package:super_text_layout/super_text_layout.dart'; import '../infrastructure/document_gestures_interaction_overrides.dart'; +import '../infrastructure/platforms/ios/ios_system_context_menu.dart'; import '../infrastructure/platforms/mobile_documents.dart'; import 'attributions.dart'; import 'blockquote.dart'; @@ -867,6 +868,42 @@ class SuperEditorState extends State { } } +/// A [DocumentFloatingToolbarBuilder] that displays the iOS system popover toolbar, if the version of +/// iOS is recent enough, otherwise builds [defaultIosEditorToolbarBuilder]. +Widget iOSSystemPopoverEditorToolbarWithFallbackBuilder( + BuildContext context, + Key floatingToolbarKey, + LeaderLink focalPoint, + CommonEditorOperations editorOps, + SuperEditorIosControlsController editorControlsController, +) { + if (CurrentPlatform.isWeb) { + // On web, we defer to the browser's internal overlay controls for mobile. + return const SizedBox(); + } + + if (focalPoint.offset == null || focalPoint.leaderSize == null) { + // It's unclear when/why this might happen. But there seem to be some + // cases, such as placing a caret in an empty document and tapping again + // to show the toolbar. + return const SizedBox(); + } + + if (IOSSystemContextMenu.isSupported(context)) { + return IOSSystemContextMenu( + anchor: focalPoint.offset! & focalPoint.leaderSize!, + ); + } + + return defaultIosEditorToolbarBuilder( + context, + floatingToolbarKey, + focalPoint, + editorOps, + editorControlsController, + ); +} + /// Builds a standard editor-style iOS floating toolbar. Widget defaultIosEditorToolbarBuilder( BuildContext context, diff --git a/super_editor/lib/src/infrastructure/platforms/ios/ios_system_context_menu.dart b/super_editor/lib/src/infrastructure/platforms/ios/ios_system_context_menu.dart new file mode 100644 index 000000000..32c0a5af1 --- /dev/null +++ b/super_editor/lib/src/infrastructure/platforms/ios/ios_system_context_menu.dart @@ -0,0 +1,87 @@ +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +/// Displays the iOS system context menu on top of the Flutter view. +/// +/// This class was copied and adjusted from Flutter's [SystemContextMenu]. +/// +/// Currently, only supports iOS 16.0 and above. +/// +/// The context menu is the menu that appears, for example, when doing text +/// selection. Flutter typically draws this menu itself, but this class deals +/// with the platform-rendered context menu instead. +/// +/// There can only be one system context menu visible at a time. Building this +/// widget when the system context menu is already visible will hide the old one +/// and display this one. A system context menu that is hidden is informed via +/// [onSystemHide]. +/// +/// To check if the current device supports showing the system context menu, +/// call [isSupported]. +/// +/// See also: +/// +/// * [SystemContextMenuController], which directly controls the hiding and +/// showing of the system context menu. +class IOSSystemContextMenu extends StatefulWidget { + /// Whether the current device supports showing the system context menu. + /// + /// Currently, this is only supported on iOS 16.0 and above. + static bool isSupported(BuildContext context) { + return MediaQuery.maybeSupportsShowingSystemContextMenu(context) ?? false; + } + + const IOSSystemContextMenu({ + super.key, + required this.anchor, + this.onSystemHide, + }); + + /// The [Rect] that the context menu should point to. + final Rect anchor; + + /// Called when the system hides this context menu. + /// + /// For example, tapping outside of the context menu typically causes the + /// system to hide the menu. + /// + /// This is not called when showing a new system context menu causes another + /// to be hidden. + final VoidCallback? onSystemHide; + + @override + State createState() => _IOSSystemContextMenuState(); +} + +class _IOSSystemContextMenuState extends State { + late final SystemContextMenuController _systemContextMenuController; + + @override + void initState() { + super.initState(); + _systemContextMenuController = SystemContextMenuController( + onSystemHide: widget.onSystemHide, + ); + _systemContextMenuController.show(widget.anchor); + } + + @override + void didUpdateWidget(IOSSystemContextMenu oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.anchor != oldWidget.anchor) { + _systemContextMenuController.show(widget.anchor); + } + } + + @override + void dispose() { + _systemContextMenuController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + assert(IOSSystemContextMenu.isSupported(context)); + return const SizedBox.shrink(); + } +} diff --git a/super_editor/lib/src/super_textfield/ios/_caret.dart b/super_editor/lib/src/super_textfield/ios/caret.dart similarity index 100% rename from super_editor/lib/src/super_textfield/ios/_caret.dart rename to super_editor/lib/src/super_textfield/ios/caret.dart diff --git a/super_editor/lib/src/super_textfield/ios/_editing_controls.dart b/super_editor/lib/src/super_textfield/ios/editing_controls.dart similarity index 100% rename from super_editor/lib/src/super_textfield/ios/_editing_controls.dart rename to super_editor/lib/src/super_textfield/ios/editing_controls.dart diff --git a/super_editor/lib/src/super_textfield/ios/_floating_cursor.dart b/super_editor/lib/src/super_textfield/ios/floating_cursor.dart similarity index 100% rename from super_editor/lib/src/super_textfield/ios/_floating_cursor.dart rename to super_editor/lib/src/super_textfield/ios/floating_cursor.dart diff --git a/super_editor/lib/src/super_textfield/ios/ios_textfield.dart b/super_editor/lib/src/super_textfield/ios/ios_textfield.dart index 1a85b2bf1..60b43fdbe 100644 --- a/super_editor/lib/src/super_textfield/ios/ios_textfield.dart +++ b/super_editor/lib/src/super_textfield/ios/ios_textfield.dart @@ -15,17 +15,20 @@ import 'package:super_editor/src/super_textfield/infrastructure/fill_width_if_co import 'package:super_editor/src/super_textfield/infrastructure/hint_text.dart'; import 'package:super_editor/src/super_textfield/infrastructure/text_scrollview.dart'; import 'package:super_editor/src/super_textfield/input_method_engine/_ime_text_editing_controller.dart'; -import 'package:super_editor/src/super_textfield/ios/_editing_controls.dart'; +import 'package:super_editor/src/super_textfield/ios/editing_controls.dart'; import 'package:super_text_layout/super_text_layout.dart'; import '../metrics.dart'; import '../styles.dart'; -import '_floating_cursor.dart'; -import '_user_interaction.dart'; +import 'floating_cursor.dart'; +import '../../infrastructure/platforms/ios/ios_system_context_menu.dart'; +import 'user_interaction.dart'; export '../infrastructure/magnifier.dart'; -export '_caret.dart'; -export '_user_interaction.dart'; +export 'caret.dart'; +export 'editing_controls.dart'; +export '../../infrastructure/platforms/ios/ios_system_context_menu.dart'; +export 'user_interaction.dart'; final _log = iosTextFieldLog; @@ -50,7 +53,7 @@ class SuperIOSTextField extends StatefulWidget { this.textInputAction, this.imeConfiguration, this.showComposingUnderline = true, - this.popoverToolbarBuilder = _defaultPopoverToolbarBuilder, + this.popoverToolbarBuilder = defaultIosPopoverToolbarBuilder, this.showDebugPaint = false, }) : super(key: key); @@ -149,7 +152,7 @@ class SuperIOSTextField extends StatefulWidget { final bool showComposingUnderline; /// Builder that creates the popover toolbar widget that appears when text is selected. - final Widget Function(BuildContext, IOSEditingOverlayController) popoverToolbarBuilder; + final IOSPopoverToolbarBuilder popoverToolbarBuilder; /// Whether to paint debug guides. final bool showDebugPaint; @@ -694,7 +697,23 @@ class SuperIOSTextFieldState extends State } } -Widget _defaultPopoverToolbarBuilder(BuildContext context, IOSEditingOverlayController controller) { +/// Builder that returns a widget for an iOS-style popover editing toolbar. +typedef IOSPopoverToolbarBuilder = Widget Function(BuildContext, IOSEditingOverlayController); + +/// An [IOSPopoverToolbarBuilder] that displays the iOS system popover toolbar, if the version of +/// iOS is recent enough, otherwise builds [defaultIosPopoverToolbarBuilder]. +Widget iOSSystemPopoverTextFieldToolbarWithFallback(BuildContext context, IOSEditingOverlayController controller) { + if (IOSSystemContextMenu.isSupported(context)) { + return IOSSystemContextMenu( + anchor: controller.toolbarFocalPoint.offset! & controller.toolbarFocalPoint.leaderSize!, + ); + } + + return defaultIosPopoverToolbarBuilder(context, controller); +} + +/// Returns a widget for the default/standard iOS-style popover provided by Super Text Field. +Widget defaultIosPopoverToolbarBuilder(BuildContext context, IOSEditingOverlayController controller) { return IOSTextEditingFloatingToolbar( focalPoint: controller.toolbarFocalPoint, onCutPressed: () { diff --git a/super_editor/lib/src/super_textfield/ios/_user_interaction.dart b/super_editor/lib/src/super_textfield/ios/user_interaction.dart similarity index 99% rename from super_editor/lib/src/super_textfield/ios/_user_interaction.dart rename to super_editor/lib/src/super_textfield/ios/user_interaction.dart index be66c761d..e5963bed4 100644 --- a/super_editor/lib/src/super_textfield/ios/_user_interaction.dart +++ b/super_editor/lib/src/super_textfield/ios/user_interaction.dart @@ -10,7 +10,7 @@ import 'package:super_editor/src/super_textfield/super_textfield.dart'; import 'package:super_editor/src/test/test_globals.dart'; import 'package:super_text_layout/super_text_layout.dart'; -import '_editing_controls.dart'; +import 'editing_controls.dart'; final _log = iosTextFieldLog; diff --git a/super_editor/lib/super_editor.dart b/super_editor/lib/super_editor.dart index 26f6f9dc0..0cb39bee3 100644 --- a/super_editor/lib/super_editor.dart +++ b/super_editor/lib/super_editor.dart @@ -75,6 +75,7 @@ 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/ios_system_context_menu.dart'; export 'src/infrastructure/platforms/ios/floating_cursor.dart'; export 'src/infrastructure/platforms/ios/toolbar.dart'; export 'src/infrastructure/platforms/ios/magnifier.dart';