diff --git a/super_editor/.run/Example - Chat - Bottom Mounted Sheet.run.xml b/super_editor/.run/Example - Chat - Bottom Mounted Sheet.run.xml new file mode 100644 index 0000000000..a89de14f26 --- /dev/null +++ b/super_editor/.run/Example - Chat - Bottom Mounted Sheet.run.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/super_editor/.run/Example - Chat - Floating Editor (Configured).run.xml b/super_editor/.run/Example - Chat - Floating Editor (Configured).run.xml new file mode 100644 index 0000000000..d84bf71f65 --- /dev/null +++ b/super_editor/.run/Example - Chat - Floating Editor (Configured).run.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/super_editor/.run/Example - Chat - Floating Editor (Default).run.xml b/super_editor/.run/Example - Chat - Floating Editor (Default).run.xml new file mode 100644 index 0000000000..dba67d7764 --- /dev/null +++ b/super_editor/.run/Example - Chat - Floating Editor (Default).run.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/super_editor/.run/Example - Chat.run.xml b/super_editor/.run/Example - Chat.run.xml deleted file mode 100644 index afb72a6399..0000000000 --- a/super_editor/.run/Example - Chat.run.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - \ No newline at end of file diff --git a/super_editor/example_chat/lib/floating_chat_editor_demo/fake_chat_thread.dart b/super_editor/example_chat/lib/floating_chat_editor_demo/fake_chat_thread.dart new file mode 100644 index 0000000000..735ca6ea9b --- /dev/null +++ b/super_editor/example_chat/lib/floating_chat_editor_demo/fake_chat_thread.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; + +/// A simulated chat conversation thread, which is simulated as a bottom-aligned +/// list of tiles. +class FakeChatThread extends StatelessWidget { + const FakeChatThread({super.key, this.scrollPadding = EdgeInsets.zero}); + + final EdgeInsets scrollPadding; + + @override + Widget build(BuildContext context) { + return ListView.builder( + padding: scrollPadding, + reverse: true, + // ^ The list starts at the bottom and grows upward. This is how + // we should layout chat conversations where the most recent + // message appears at the bottom, and you want to retain the + // scroll offset near the newest messages, not the oldest. + itemBuilder: (context, index) { + if (index == 8) { + // Arbitrarily placed text field to test moving focus between a non-editor + // and the editor. + return TextField( + decoration: InputDecoration( + hintText: "Content text field...", + ), + ); + } + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Material( + color: Colors.white.withValues(alpha: 0.5), + child: ListTile( + title: Text("This is item $index"), + subtitle: Text("This is a subtitle for $index"), + ), + ), + ); + }, + ); + } +} diff --git a/super_editor/example_chat/lib/floating_chat_editor_demo/floating_chat_editor.dart b/super_editor/example_chat/lib/floating_chat_editor_demo/floating_chat_editor.dart new file mode 100644 index 0000000000..060bd509fc --- /dev/null +++ b/super_editor/example_chat/lib/floating_chat_editor_demo/floating_chat_editor.dart @@ -0,0 +1,547 @@ +// import 'package:example_chat/floating_chat_editor_demo/floating_editor_toolbar.dart'; +// import 'package:flutter/material.dart'; +// import 'package:super_editor/super_editor.dart'; +// import 'package:super_keyboard/super_keyboard.dart'; +// +// class FloatingEditorSheetContent extends StatefulWidget { +// const FloatingEditorSheetContent({super.key, +// required this.messagePageController, +// }); +// +// final MessagePageController messagePageController; +// +// @override +// State createState() => _FloatingEditorSheetContentState(); +// } +// +// class _FloatingEditorSheetContentState extends State { +// final _dragIndicatorKey = GlobalKey(); +// +// final _scrollController = ScrollController(); +// +// final _editorFocusNode = FocusNode(); +// late GlobalKey _sheetKey; +// late final Editor _editor; +// late final SoftwareKeyboardController _softwareKeyboardController; +// +// final _hasSelection = ValueNotifier(false); +// +// @override +// void initState() { +// super.initState(); +// +// _softwareKeyboardController = SoftwareKeyboardController(); +// +// _sheetKey = widget.sheetKey ?? GlobalKey(); +// +// _editor = createDefaultDocumentEditor( +// document: MutableDocument.empty(), +// composer: MutableDocumentComposer(), +// ); +// _editor.composer.selectionNotifier.addListener(_onSelectionChange); +// } +// +// @override +// void didUpdateWidget(FloatingEditorSheetContent oldWidget) { +// super.didUpdateWidget(oldWidget); +// +// if (widget.sheetKey != _sheetKey) { +// _sheetKey = widget.sheetKey ?? GlobalKey(); +// } +// } +// +// @override +// void dispose() { +// _editor.composer.selectionNotifier.removeListener(_onSelectionChange); +// _editor.dispose(); +// +// _editorFocusNode.dispose(); +// +// _scrollController.dispose(); +// +// super.dispose(); +// } +// +// void _onSelectionChange() { +// _hasSelection.value = _editor.composer.selection != null; +// +// // If the editor doesn't have a selection then when it's collapsed it +// // should be in preview mode. If the editor does have a selection, then +// // when it's collapsed, it should be in intrinsic height mode. +// widget.messagePageController.collapsedMode = +// _hasSelection.value ? MessagePageSheetCollapsedMode.intrinsic : MessagePageSheetCollapsedMode.preview; +// } +// +// double _dragTouchOffsetFromIndicator = 0; +// +// void _onVerticalDragStart(DragStartDetails details) { +// _dragTouchOffsetFromIndicator = _dragFingerOffsetFromIndicator(details.globalPosition); +// +// widget.messagePageController.onDragStart( +// details.globalPosition.dy - _dragIndicatorOffsetFromTop - _dragTouchOffsetFromIndicator, +// ); +// } +// +// void _onVerticalDragUpdate(DragUpdateDetails details) { +// widget.messagePageController.onDragUpdate( +// details.globalPosition.dy - _dragIndicatorOffsetFromTop - _dragTouchOffsetFromIndicator, +// ); +// } +// +// void _onVerticalDragEnd(DragEndDetails details) { +// widget.messagePageController.onDragEnd(); +// } +// +// void _onVerticalDragCancel() { +// widget.messagePageController.onDragEnd(); +// } +// +// double get _dragIndicatorOffsetFromTop { +// final bottomSheetBox = _sheetKey.currentContext!.findRenderObject(); +// final dragIndicatorBox = _dragIndicatorKey.currentContext!.findRenderObject()! as RenderBox; +// +// return dragIndicatorBox.localToGlobal(Offset.zero, ancestor: bottomSheetBox).dy; +// } +// +// double _dragFingerOffsetFromIndicator(Offset globalDragOffset) { +// final dragIndicatorBox = _dragIndicatorKey.currentContext!.findRenderObject()! as RenderBox; +// +// return globalDragOffset.dy - dragIndicatorBox.localToGlobal(Offset.zero).dy; +// } +// +// @override +// Widget build(BuildContext context) { +// return Column( +// mainAxisSize: MainAxisSize.min, +// crossAxisAlignment: CrossAxisAlignment.stretch, +// children: [ +// _buildDragHandle(), +// Flexible( +// child: Row( +// crossAxisAlignment: CrossAxisAlignment.start, +// children: [ +// ListenableBuilder( +// listenable: _editorFocusNode, +// builder: (context, child) { +// if (_editorFocusNode.hasFocus) { +// return const SizedBox(); +// } +// +// return Padding( +// padding: const EdgeInsets.only(left: 12, bottom: 12), +// child: AttachmentButton(), +// ); +// }, +// ), +// Expanded( +// child: Padding( +// padding: const EdgeInsets.only(top: 4), +// child: _buildSheetContent(), +// ), +// ), +// ListenableBuilder( +// listenable: _editorFocusNode, +// builder: (context, child) { +// if (_editorFocusNode.hasFocus) { +// return const SizedBox(); +// } +// +// return Padding( +// padding: const EdgeInsets.only(right: 12, bottom: 12), +// child: DictationButton(), +// ); +// }, +// ), +// ], +// ), +// ), +// ListenableBuilder( +// listenable: _editorFocusNode, +// builder: (context, child) { +// if (!_editorFocusNode.hasFocus) { +// return const SizedBox(); +// } +// +// return _buildToolbar(); +// }, +// ) +// ], +// ); +// } +// +// Widget _buildSheetContent() { +// return BottomSheetEditorHeight( +// previewHeight: 32, +// child: _ChatEditor( +// key: _editorKey, +// editorFocusNode: _editorFocusNode, +// editor: _editor, +// messagePageController: widget.messagePageController, +// scrollController: _scrollController, +// softwareKeyboardController: _softwareKeyboardController, +// ), +// ); +// } +// +// // FIXME: Keyboard keeps closing without a bunch of global keys. Either +// final _editorKey = GlobalKey(); +// +// Widget _buildDragHandle() { +// return ListenableBuilder( +// listenable: _editorFocusNode, +// builder: (context, child) { +// if (!_editorFocusNode.hasFocus) { +// return const SizedBox(height: 12); +// } +// +// return Row( +// mainAxisAlignment: MainAxisAlignment.center, +// children: [ +// GestureDetector( +// onVerticalDragStart: _onVerticalDragStart, +// onVerticalDragUpdate: _onVerticalDragUpdate, +// onVerticalDragEnd: _onVerticalDragEnd, +// onVerticalDragCancel: _onVerticalDragCancel, +// behavior: HitTestBehavior.opaque, +// // ^ Opaque to handle tough events in our invisible padding. +// child: Padding( +// padding: const EdgeInsets.all(8), +// // ^ Expand the hit area with invisible padding. +// child: Container( +// key: _dragIndicatorKey, +// width: 48, +// height: 5, +// decoration: BoxDecoration( +// color: Colors.grey.shade300, +// borderRadius: BorderRadius.circular(3), +// ), +// ), +// ), +// ), +// ], +// ); +// }, +// ); +// } +// +// Widget _buildToolbar() { +// return Padding( +// padding: const EdgeInsets.only(left: 12, right: 12, bottom: 12), +// child: FloatingEditorToolbar( +// softwareKeyboardController: _softwareKeyboardController, +// ), +// ); +// } +// } +// +// /// An editor for composing chat messages. +// class _ChatEditor extends StatefulWidget { +// const _ChatEditor({ +// super.key, +// this.editorFocusNode, +// required this.editor, +// required this.messagePageController, +// required this.scrollController, +// required this.softwareKeyboardController, +// }); +// +// final FocusNode? editorFocusNode; +// +// final Editor editor; +// final MessagePageController messagePageController; +// final ScrollController scrollController; +// final SoftwareKeyboardController softwareKeyboardController; +// +// @override +// State<_ChatEditor> createState() => _ChatEditorState(); +// } +// +// class _ChatEditorState extends State<_ChatEditor> { +// final _editorKey = GlobalKey(); +// late FocusNode _editorFocusNode; +// +// late KeyboardPanelController<_Panel> _keyboardPanelController; +// final _isImeConnected = ValueNotifier(false); +// +// @override +// void initState() { +// super.initState(); +// +// _editorFocusNode = widget.editorFocusNode ?? FocusNode(); +// +// _keyboardPanelController = KeyboardPanelController( +// widget.softwareKeyboardController, +// ); +// +// widget.messagePageController.addListener(_onMessagePageControllerChange); +// +// _isImeConnected.addListener(_onImeConnectionChange); +// +// SuperKeyboard.instance.mobileGeometry.addListener(_onKeyboardChange); +// } +// +// @override +// void didUpdateWidget(_ChatEditor oldWidget) { +// super.didUpdateWidget(oldWidget); +// +// if (widget.editorFocusNode != oldWidget.editorFocusNode) { +// if (oldWidget.editorFocusNode == null) { +// _editorFocusNode.dispose(); +// } +// +// _editorFocusNode = widget.editorFocusNode ?? FocusNode(); +// } +// +// if (widget.messagePageController != oldWidget.messagePageController) { +// oldWidget.messagePageController.removeListener(_onMessagePageControllerChange); +// widget.messagePageController.addListener(_onMessagePageControllerChange); +// } +// +// if (widget.softwareKeyboardController != oldWidget.softwareKeyboardController) { +// _keyboardPanelController.dispose(); +// _keyboardPanelController = KeyboardPanelController(widget.softwareKeyboardController); +// } +// } +// +// @override +// void dispose() { +// SuperKeyboard.instance.mobileGeometry.removeListener(_onKeyboardChange); +// +// widget.messagePageController.removeListener(_onMessagePageControllerChange); +// +// _keyboardPanelController.dispose(); +// _isImeConnected.dispose(); +// +// if (widget.editorFocusNode == null) { +// _editorFocusNode.dispose(); +// } +// +// super.dispose(); +// } +// +// void _onKeyboardChange() { +// // On Android, we've found that when swiping to go back, the keyboard often +// // closes without Flutter reporting the closure of the IME connection. +// // Therefore, the keyboard closes, but editors and text fields retain focus, +// // selection, and a supposedly open IME connection. +// // +// // Flutter issue: https://github.com/flutter/flutter/issues/165734 +// // +// // To hack around this bug in Flutter, when super_keyboard reports keyboard +// // closure, and this controller thinks the keyboard is open, we give up +// // focus so that our app state synchronizes with the closed IME connection. +// final keyboardState = SuperKeyboard.instance.mobileGeometry.value.keyboardState; +// if (_isImeConnected.value && (keyboardState == KeyboardState.closing || keyboardState == KeyboardState.closed)) { +// _editorFocusNode.unfocus(); +// } +// } +// +// void _onImeConnectionChange() { +// widget.messagePageController.collapsedMode = +// _isImeConnected.value ? MessagePageSheetCollapsedMode.intrinsic : MessagePageSheetCollapsedMode.preview; +// } +// +// void _onMessagePageControllerChange() { +// if (widget.messagePageController.isPreview) { +// // Always scroll the editor to the top when in preview mode. +// widget.scrollController.position.jumpTo(0); +// } +// } +// +// @override +// Widget build(BuildContext context) { +// return KeyboardPanelScaffold( +// controller: _keyboardPanelController, +// isImeConnected: _isImeConnected, +// toolbarBuilder: (BuildContext context, _Panel? openPanel) { +// return SizedBox(); +// }, +// keyboardPanelBuilder: (BuildContext context, _Panel? openPanel) { +// return SizedBox(); +// }, +// contentBuilder: (BuildContext context, _Panel? openPanel) { +// return SuperEditorFocusOnTap( +// editorFocusNode: _editorFocusNode, +// editor: widget.editor, +// child: SuperEditorDryLayout( +// controller: widget.scrollController, +// superEditor: SuperEditor( +// key: _editorKey, +// focusNode: _editorFocusNode, +// editor: widget.editor, +// softwareKeyboardController: widget.softwareKeyboardController, +// isImeConnected: _isImeConnected, +// imePolicies: SuperEditorImePolicies(), +// selectionPolicies: SuperEditorSelectionPolicies(), +// shrinkWrap: false, +// stylesheet: _chatStylesheet, +// componentBuilders: [ +// const HintComponentBuilder("Send a message...", _hintTextStyleBuilder), +// ...defaultComponentBuilders, +// ], +// ), +// ), +// ); +// }, +// ); +// } +// } +// +// final _chatStylesheet = Stylesheet( +// rules: [ +// StyleRule( +// BlockSelector.all, +// (doc, docNode) { +// return { +// Styles.padding: const CascadingPadding.symmetric(horizontal: 12), +// Styles.textStyle: const TextStyle( +// color: Colors.black, +// fontSize: 16, +// height: 1.4, +// ), +// }; +// }, +// ), +// StyleRule( +// const BlockSelector("header1"), +// (doc, docNode) { +// return { +// Styles.textStyle: const TextStyle( +// color: Color(0xFF333333), +// fontSize: 38, +// fontWeight: FontWeight.bold, +// ), +// }; +// }, +// ), +// StyleRule( +// const BlockSelector("header2"), +// (doc, docNode) { +// return { +// Styles.textStyle: const TextStyle( +// color: Color(0xFF333333), +// fontSize: 26, +// fontWeight: FontWeight.bold, +// ), +// }; +// }, +// ), +// StyleRule( +// const BlockSelector("header3"), +// (doc, docNode) { +// return { +// Styles.textStyle: const TextStyle( +// color: Color(0xFF333333), +// fontSize: 22, +// fontWeight: FontWeight.bold, +// ), +// }; +// }, +// ), +// StyleRule( +// const BlockSelector("paragraph"), +// (doc, docNode) { +// return { +// Styles.padding: const CascadingPadding.only(bottom: 12), +// }; +// }, +// ), +// StyleRule( +// const BlockSelector("blockquote"), +// (doc, docNode) { +// return { +// Styles.textStyle: const TextStyle( +// color: Colors.grey, +// fontWeight: FontWeight.bold, +// height: 1.4, +// ), +// }; +// }, +// ), +// ], +// inlineTextStyler: defaultInlineTextStyler, +// inlineWidgetBuilders: defaultInlineWidgetBuilderChain, +// ); +// +// TextStyle _hintTextStyleBuilder(context) => TextStyle( +// color: Colors.grey, +// ); +// +// // FIXME: This widget is required because of the current shrink wrap behavior +// // of Super Editor. If we set `shrinkWrap` to `false` then the bottom +// // sheet always expands to max height. But if we set `shrinkWrap` to +// // `true`, when we manually expand the bottom sheet, the only +// // tappable area is wherever the document components actually appear. +// // In the average case, that means only the top area of the bottom +// // sheet can be tapped to place the caret. +// // +// // This widget should wrap Super Editor and make the whole area tappable. +// /// A widget, that when pressed, gives focus to the [editorFocusNode], and places +// /// the caret at the end of the content within an [editor]. +// /// +// /// It's expected that the [child] subtree contains the associated `SuperEditor`, +// /// which owns the [editor] and [editorFocusNode]. +// class SuperEditorFocusOnTap extends StatelessWidget { +// const SuperEditorFocusOnTap({ +// super.key, +// required this.editorFocusNode, +// required this.editor, +// required this.child, +// }); +// +// final FocusNode editorFocusNode; +// +// final Editor editor; +// +// /// The SuperEditor that we're wrapping with this tap behavior. +// final Widget child; +// +// @override +// Widget build(BuildContext context) { +// return ListenableBuilder( +// listenable: editorFocusNode, +// builder: (context, child) { +// return ListenableBuilder( +// listenable: editor.composer.selectionNotifier, +// builder: (context, child) { +// final shouldControlTap = editor.composer.selection == null || !editorFocusNode.hasFocus; +// return GestureDetector( +// onTap: editor.composer.selection == null || !editorFocusNode.hasFocus ? _selectEditor : null, +// behavior: HitTestBehavior.opaque, +// child: IgnorePointer( +// ignoring: shouldControlTap, +// // ^ Prevent the Super Editor from aggressively responding to +// // taps, so that we can respond. +// child: child, +// ), +// ); +// }, +// child: child, +// ); +// }, +// child: child, +// ); +// } +// +// void _selectEditor() { +// editorFocusNode.requestFocus(); +// +// final endNode = editor.document.last; +// editor.execute([ +// ChangeSelectionRequest( +// DocumentSelection.collapsed( +// position: DocumentPosition( +// nodeId: endNode.id, +// nodePosition: endNode.endPosition, +// ), +// ), +// SelectionChangeType.placeCaret, +// SelectionReason.userInteraction, +// ), +// ]); +// } +// } +// +// enum _Panel { +// thePanel; +// } diff --git a/super_editor/example_chat/lib/floating_chat_editor_demo/floating_chat_editor_demo_configured.dart b/super_editor/example_chat/lib/floating_chat_editor_demo/floating_chat_editor_demo_configured.dart new file mode 100644 index 0000000000..0c24b1334e --- /dev/null +++ b/super_editor/example_chat/lib/floating_chat_editor_demo/floating_chat_editor_demo_configured.dart @@ -0,0 +1,298 @@ +import 'package:example_chat/floating_chat_editor_demo/fake_chat_thread.dart'; +import 'package:example_chat/floating_chat_editor_demo/floating_editor_toolbar.dart'; +import 'package:flutter/material.dart'; +import 'package:super_editor/super_editor.dart' hide AttachmentButton; + +/// A floating chat page demo, which uses custom a custom editor sheet material, a custom +/// visual editor, and a custom editor toolbar. +class FloatingChatEditorBuilderDemo extends StatefulWidget { + const FloatingChatEditorBuilderDemo({super.key}); + + @override + State createState() => _FloatingChatEditorBuilderDemoState(); +} + +class _FloatingChatEditorBuilderDemoState extends State { + late final FloatingEditorPageController<_MessageEditingPanels> _pageController; + + final _editorFocusNode = FocusNode(debugLabel: "chat editor"); + late final Editor _editor; + final _softwareKeyboardController = SoftwareKeyboardController(); + + final _isImeConnected = ValueNotifier(false); + + var _showShadowSheetBanner = false; + + @override + void initState() { + super.initState(); + + _editor = createDefaultDocumentEditor( + document: MutableDocument.empty(), + composer: MutableDocumentComposer(), + ); + + _pageController = FloatingEditorPageController(_softwareKeyboardController); + } + + @override + void dispose() { + _isImeConnected.dispose(); + + _pageController.dispose(); + + _editorFocusNode.dispose(); + _editor.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Material( + child: FloatingEditorPageScaffold<_MessageEditingPanels>( + pageController: _pageController, + pageBuilder: (context, pageGeometry) { + return _ChatPage( + appBar: _buildAppBar(), + scrollPadding: EdgeInsets.only(bottom: pageGeometry.bottomSafeArea), + ); + }, + editorSheet: _buildEditorSheet(), + keyboardPanelBuilder: _buildKeyboardPanel, + style: FloatingEditorStyle( + margin: const EdgeInsets.only(left: 16, right: 16, top: 8, bottom: 16), + ), + ), + ); + } + + Widget _buildEditorSheet() { + return Container( + color: Colors.white, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildBanner(), + _buildEditor(), + _maybeBuildToolbar(), + ], + ), + ); + } + + Widget _buildBanner() { + return Container( + margin: const EdgeInsets.only(left: 14, right: 14, top: 8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: Colors.grey.shade300, + ), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Text.rich( + TextSpan( + children: [ + WidgetSpan( + child: Padding( + padding: const EdgeInsets.only(right: 4, bottom: 1), + child: Icon(Icons.supervised_user_circle_rounded, size: 13), + ), + ), + TextSpan( + text: "Ella Martinez", + style: TextStyle(fontWeight: FontWeight.bold), + ), + TextSpan(text: " is from Acme"), + ], + style: TextStyle( + fontSize: 13, + ), + ), + ), + ); + } + + Widget _buildEditor() { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row( + children: [ + _maybeBuildPreviewAttachmentButton(), + Expanded( + child: Padding( + padding: const EdgeInsets.only(top: 16.0), + child: SuperChatEditor( + editorFocusNode: _editorFocusNode, + editor: _editor, + pageController: _pageController, + softwareKeyboardController: _softwareKeyboardController, + isImeConnected: _isImeConnected, + ), + ), + ), + _maybeBuildPreviewDictationButton(), + ], + ), + ); + } + + Widget _maybeBuildPreviewAttachmentButton() { + return ListenableBuilder( + listenable: _editorFocusNode, + builder: (context, child) { + if (_editorFocusNode.hasFocus) { + return SizedBox(); + } + + return AttachmentButton( + onPressed: () { + _editorFocusNode.requestFocus(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + _pageController.showKeyboardPanel(_MessageEditingPanels.slashCommandPanel); + }); + }, + ); + }, + ); + } + + Widget _maybeBuildPreviewDictationButton() { + return ListenableBuilder( + listenable: _editorFocusNode, + builder: (context, child) { + if (_editorFocusNode.hasFocus) { + return SizedBox(); + } + + return FloatingToolbarIconButton( + icon: Icons.multitrack_audio, + onPressed: () {}, + ); + }, + ); + } + + Widget _maybeBuildToolbar() { + return ListenableBuilder( + listenable: Listenable.merge([_editorFocusNode, _pageController]), + builder: (context, child) { + if (!_editorFocusNode.hasFocus) { + return const SizedBox(); + } + + print("Building FloatingEditorToolbar - open panel: ${_pageController.openPanel}"); + return FloatingEditorToolbar( + onAttachPressed: () { + _pageController.toggleKeyboardPanel(_MessageEditingPanels.slashCommandPanel); + }, + isTextColorActivated: _pageController.openPanel == _MessageEditingPanels.textColorPanel, + onTextColorPressed: () { + _pageController.toggleKeyboardPanel(_MessageEditingPanels.textColorPanel); + }, + isBackgroundColorActivated: _pageController.openPanel == _MessageEditingPanels.backgroundColorPanel, + onBackgroundColorPressed: () { + _pageController.toggleKeyboardPanel(_MessageEditingPanels.backgroundColorPanel); + }, + onCloseKeyboardPressed: () { + _pageController.closeKeyboardAndPanel(); + }, + ); + }, + ); + } + + Widget _buildKeyboardPanel(BuildContext context, _MessageEditingPanels openPanel) { + switch (openPanel) { + case _MessageEditingPanels.slashCommandPanel: + return _SlashCommandPanel(); + case _MessageEditingPanels.textColorPanel: + return _TextColorPanel(); + case _MessageEditingPanels.backgroundColorPanel: + return _BackgroundColorPanel(); + } + } + + PreferredSizeWidget _buildAppBar() { + return AppBar( + title: Text("Floating Editor"), + backgroundColor: Colors.white, + elevation: 16, + actions: [ + IconButton( + onPressed: () { + setState(() { + _showShadowSheetBanner = !_showShadowSheetBanner; + }); + }, + icon: Icon(Icons.warning), + ), + ], + ); + } +} + +class _ChatPage extends StatelessWidget { + const _ChatPage({ + this.appBar, + this.scrollPadding = EdgeInsets.zero, + }); + + final PreferredSizeWidget? appBar; + final EdgeInsets scrollPadding; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: appBar ?? + AppBar( + title: Text("Floating Editor"), + backgroundColor: Colors.white, + elevation: 16, + ), + extendBodyBehindAppBar: true, + resizeToAvoidBottomInset: false, + backgroundColor: Colors.white, + body: ColoredBox( + color: Colors.white, + child: FakeChatThread( + scrollPadding: scrollPadding, + ), + ), + ); + } +} + +enum _MessageEditingPanels { + slashCommandPanel, + textColorPanel, + backgroundColorPanel; +} + +class _SlashCommandPanel extends StatelessWidget { + const _SlashCommandPanel(); + + @override + Widget build(BuildContext context) { + return ColoredBox(color: Colors.blue); + } +} + +class _TextColorPanel extends StatelessWidget { + const _TextColorPanel(); + + @override + Widget build(BuildContext context) { + return ColoredBox(color: Colors.red); + } +} + +class _BackgroundColorPanel extends StatelessWidget { + const _BackgroundColorPanel(); + + @override + Widget build(BuildContext context) { + return ColoredBox(color: Colors.green); + } +} diff --git a/super_editor/example_chat/lib/floating_chat_editor_demo/floating_chat_editor_demo_simple.dart b/super_editor/example_chat/lib/floating_chat_editor_demo/floating_chat_editor_demo_simple.dart new file mode 100644 index 0000000000..d6cfd4e409 --- /dev/null +++ b/super_editor/example_chat/lib/floating_chat_editor_demo/floating_chat_editor_demo_simple.dart @@ -0,0 +1,107 @@ +import 'package:example_chat/floating_chat_editor_demo/fake_chat_thread.dart'; +import 'package:flutter/material.dart'; +import 'package:super_editor/super_editor.dart'; + +/// A demo of the floating chat page in its simplest possible form, with minimal +/// configuration. +class FloatingChatEditorSimpleDemo extends StatefulWidget { + const FloatingChatEditorSimpleDemo({super.key}); + + @override + State createState() => _FloatingChatEditorSimpleDemoState(); +} + +class _FloatingChatEditorSimpleDemoState extends State { + late final Editor _editor; + + var _showShadowSheetBanner = false; + + @override + void initState() { + super.initState(); + + _editor = createDefaultDocumentEditor( + document: MutableDocument.empty(), + composer: MutableDocumentComposer(), + ); + } + + @override + Widget build(BuildContext context) { + return Material( + child: DefaultFloatingSuperChatPage( + pageBuilder: (context, bottomSpacing) => _ChatPage(appBar: _buildAppBar()), + editor: _editor, + shadowSheetBanner: _showShadowSheetBanner ? _buildBanner() : null, + ), + ); + } + + PreferredSizeWidget _buildAppBar() { + return AppBar( + title: Text("Floating Editor"), + backgroundColor: Colors.white, + elevation: 16, + actions: [ + IconButton( + onPressed: () { + setState(() { + _showShadowSheetBanner = !_showShadowSheetBanner; + }); + }, + icon: Icon(Icons.warning), + ), + ], + ); + } + + Widget _buildBanner() { + return Text.rich( + TextSpan( + children: [ + WidgetSpan( + child: Padding( + padding: const EdgeInsets.only(right: 4, bottom: 1), + child: Icon(Icons.supervised_user_circle_rounded, size: 13), + ), + ), + TextSpan( + text: "Ella Martinez", + style: TextStyle(fontWeight: FontWeight.bold), + ), + TextSpan(text: " is from Acme"), + ], + style: TextStyle( + fontSize: 13, + ), + ), + ); + } +} + +class _ChatPage extends StatelessWidget { + const _ChatPage({ + this.appBar, + }); + + final PreferredSizeWidget? appBar; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: appBar ?? + AppBar( + title: Text("Floating Editor"), + backgroundColor: Colors.white, + elevation: 16, + ), + extendBodyBehindAppBar: true, + resizeToAvoidBottomInset: false, + backgroundColor: Colors.white, + body: ColoredBox( + color: Colors.white, + child: FakeChatThread(), + ), + ); + } +} diff --git a/super_editor/example_chat/lib/floating_chat_editor_demo/floating_editor_toolbar.dart b/super_editor/example_chat/lib/floating_chat_editor_demo/floating_editor_toolbar.dart new file mode 100644 index 0000000000..be3ec94e88 --- /dev/null +++ b/super_editor/example_chat/lib/floating_chat_editor_demo/floating_editor_toolbar.dart @@ -0,0 +1,176 @@ +import 'package:flutter/material.dart'; +import 'package:super_editor/super_editor.dart'; + +class FloatingEditorToolbar extends StatelessWidget { + const FloatingEditorToolbar({ + super.key, + this.padding = const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + this.onAttachPressed, + this.isTextColorActivated = false, + this.onTextColorPressed, + this.isBackgroundColorActivated = false, + this.onBackgroundColorPressed, + this.onCloseKeyboardPressed, + }); + + final EdgeInsets padding; + + final VoidCallback? onAttachPressed; + + final bool isTextColorActivated; + final VoidCallback? onTextColorPressed; + + final bool isBackgroundColorActivated; + final VoidCallback? onBackgroundColorPressed; + + final VoidCallback? onCloseKeyboardPressed; + + @override + Widget build(BuildContext context) { + return Padding( + // Padding for the non-scrolling right-end of the toolbar. + padding: EdgeInsets.only(right: padding.right), + child: Row( + children: [ + Expanded( + child: SingleChildScrollView( + padding: padding, + scrollDirection: Axis.horizontal, + child: Row( + spacing: 4, + children: [ + if (onAttachPressed != null) // + AttachmentButton( + onPressed: onAttachPressed!, + ), + FloatingToolbarIconButton(icon: Icons.format_bold), + FloatingToolbarIconButton(icon: Icons.format_italic), + FloatingToolbarIconButton(icon: Icons.format_underline), + FloatingToolbarIconButton(icon: Icons.format_strikethrough), + if (onTextColorPressed != null) // + FloatingToolbarIconButton( + icon: Icons.format_color_text, + isActivated: isTextColorActivated, + onPressed: onTextColorPressed, + ), + if (onBackgroundColorPressed != null) // + FloatingToolbarIconButton( + icon: Icons.format_color_fill, + isActivated: isBackgroundColorActivated, + onPressed: onBackgroundColorPressed, + ), + ], + ), + ), + ), + if (onCloseKeyboardPressed != null) ...[ + // + _buildDivider(), + _CloseKeyboardButton(onPressed: onCloseKeyboardPressed!), + ], + _buildDivider(), + _SendButton(), + ], + ), + ); + } + + Widget _buildDivider() { + return Container( + width: 1, + height: 16, + margin: EdgeInsets.symmetric(horizontal: 8, vertical: 8), + color: Colors.grey.shade300, + ); + } +} + +class AttachmentButton extends StatelessWidget { + const AttachmentButton({ + super.key, + required this.onPressed, + }); + + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration(shape: BoxShape.circle, color: Colors.grey.shade200), + child: FloatingToolbarIconButton( + icon: Icons.add, + onPressed: onPressed, + ), + ); + } +} + +class DictationButton extends StatelessWidget { + const DictationButton({super.key}); + + @override + Widget build(BuildContext context) { + return FloatingToolbarIconButton(icon: Icons.multitrack_audio); + } +} + +class _CloseKeyboardButton extends StatelessWidget { + const _CloseKeyboardButton({ + required this.onPressed, + }); + + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return FloatingToolbarIconButton( + icon: Icons.keyboard_hide, + onPressed: onPressed, + ); + } +} + +class _SendButton extends StatelessWidget { + const _SendButton(); + + @override + Widget build(BuildContext context) { + return FloatingToolbarIconButton(icon: Icons.send); + } +} + +class FloatingToolbarIconButton extends StatelessWidget { + const FloatingToolbarIconButton({ + required this.icon, + this.isActivated = false, + this.onPressed, + }); + + final IconData icon; + + final bool isActivated; + + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onPressed, + child: Container( + width: 32, + height: 32, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + color: isActivated ? Colors.grey : Colors.transparent, + ), + child: Center( + child: Icon( + icon, + size: 20, + color: isActivated ? Colors.grey.shade300 : Colors.grey, + ), + ), + ), + ); + } +} diff --git a/super_editor/example_chat/lib/keyboard_panel_scaffold/keyboard_panel_scaffold_demo.dart b/super_editor/example_chat/lib/keyboard_panel_scaffold/keyboard_panel_scaffold_demo.dart new file mode 100644 index 0000000000..914cf58acd --- /dev/null +++ b/super_editor/example_chat/lib/keyboard_panel_scaffold/keyboard_panel_scaffold_demo.dart @@ -0,0 +1,324 @@ +import 'package:flutter/material.dart'; +import 'package:super_editor/super_editor.dart'; + +class KeyboardPanelScaffoldDemo extends StatefulWidget { + const KeyboardPanelScaffoldDemo({super.key}); + + @override + State createState() => _KeyboardPanelScaffoldDemoState(); +} + +class _KeyboardPanelScaffoldDemoState extends State { + final _editorFocusNode = FocusNode(debugLabel: "bottom-mounted-editor"); + late final Editor _editor; + + late final KeyboardPanelController<_Panel> _panelController; + final _softwareKeyboardController = SoftwareKeyboardController(); + final _isImeConnected = ValueNotifier(false); + + @override + void initState() { + super.initState(); + + // initLoggers(Level.ALL, {keyboardPanelLog}); + + _editor = createDefaultDocumentEditor( + document: MutableDocument.empty(), + composer: MutableDocumentComposer(), + ); + + _panelController = KeyboardPanelController(_softwareKeyboardController); + } + + @override + void dispose() { + _softwareKeyboardController.detach(); + + _panelController.dispose(); + _isImeConnected.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return KeyboardScaffoldSafeArea( + child: Scaffold( + resizeToAvoidBottomInset: false, + body: KeyboardPanelScaffold<_Panel>( + controller: _panelController, + isImeConnected: _isImeConnected, + contentBuilder: _buildContent, + toolbarBuilder: _buildToolbar, + keyboardPanelBuilder: _buildKeyboardPanel, + ), + ), + ); + } + + Widget _buildContent(BuildContext context, _Panel? openPanel) { + return SizedBox.expand( + child: Stack( + children: [ + Positioned.fill(child: Placeholder()), + Positioned( + left: 0, + right: 0, + bottom: 0, + child: Padding( + padding: const EdgeInsets.all(16), + child: Material( + borderRadius: BorderRadius.circular(16), + elevation: 10, + child: Padding( + padding: const EdgeInsets.only(top: 16), + child: SuperEditorFocusOnTap( + editorFocusNode: _editorFocusNode, + editor: _editor, + child: SuperEditorDryLayout( + superEditor: SuperEditor( + focusNode: _editorFocusNode, + editor: _editor, + softwareKeyboardController: _softwareKeyboardController, + isImeConnected: _isImeConnected, + imePolicies: SuperEditorImePolicies(), + selectionPolicies: SuperEditorSelectionPolicies(), + shrinkWrap: false, + stylesheet: _chatStylesheet, + componentBuilders: [ + const HintComponentBuilder("Send a message...", _hintTextStyleBuilder), + ...defaultComponentBuilders, + ], + ), + ), + ), + ), + ), + ), + ) + ], + ), + ); + } + + Widget _buildToolbar(BuildContext context, _Panel? openPanel) { + return Container( + width: double.infinity, + height: 54, + color: Colors.grey, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + IconButton( + onPressed: () { + _panelController.showKeyboardPanel(_Panel.one); + }, + icon: Text("1"), + ), + IconButton( + onPressed: () { + _panelController.showKeyboardPanel(_Panel.two); + }, + icon: Text("2"), + ), + IconButton( + onPressed: () { + _panelController.showKeyboardPanel(_Panel.three); + }, + icon: Text("3"), + ), + IconButton( + onPressed: () { + _panelController.showSoftwareKeyboard(); + }, + icon: Icon(Icons.keyboard_rounded), + ), + IconButton( + onPressed: () { + _panelController.closeKeyboardAndPanel(); + }, + icon: Icon(Icons.keyboard_hide), + ), + ], + ), + ); + } + + Widget _buildKeyboardPanel(BuildContext context, _Panel? openPanel) { + return Container( + width: double.infinity, + height: double.infinity, + color: Colors.red, + ); + } +} + +final _chatStylesheet = Stylesheet( + rules: [ + StyleRule( + BlockSelector.all, + (doc, docNode) { + return { + Styles.padding: const CascadingPadding.symmetric(horizontal: 24), + Styles.textStyle: const TextStyle( + color: Colors.black, + fontSize: 18, + height: 1.4, + ), + }; + }, + ), + StyleRule( + const BlockSelector("header1"), + (doc, docNode) { + return { + Styles.textStyle: const TextStyle( + color: Color(0xFF333333), + fontSize: 38, + fontWeight: FontWeight.bold, + ), + }; + }, + ), + StyleRule( + const BlockSelector("header2"), + (doc, docNode) { + return { + Styles.textStyle: const TextStyle( + color: Color(0xFF333333), + fontSize: 26, + fontWeight: FontWeight.bold, + ), + }; + }, + ), + StyleRule( + const BlockSelector("header3"), + (doc, docNode) { + return { + Styles.textStyle: const TextStyle( + color: Color(0xFF333333), + fontSize: 22, + fontWeight: FontWeight.bold, + ), + }; + }, + ), + StyleRule( + const BlockSelector("paragraph"), + (doc, docNode) { + return { + Styles.padding: const CascadingPadding.only(bottom: 12), + }; + }, + ), + StyleRule( + const BlockSelector("blockquote"), + (doc, docNode) { + return { + Styles.textStyle: const TextStyle( + color: Colors.grey, + fontSize: 20, + fontWeight: FontWeight.bold, + height: 1.4, + ), + }; + }, + ), + StyleRule( + BlockSelector.all.last(), + (doc, docNode) { + return { + Styles.padding: const CascadingPadding.only(bottom: 48), + }; + }, + ), + ], + inlineTextStyler: defaultInlineTextStyler, + inlineWidgetBuilders: defaultInlineWidgetBuilderChain, +); + +TextStyle _hintTextStyleBuilder(context) => TextStyle( + color: Colors.grey, + ); + +// FIXME: This widget is required because of the current shrink wrap behavior +// of Super Editor. If we set `shrinkWrap` to `false` then the bottom +// sheet always expands to max height. But if we set `shrinkWrap` to +// `true`, when we manually expand the bottom sheet, the only +// tappable area is wherever the document components actually appear. +// In the average case, that means only the top area of the bottom +// sheet can be tapped to place the caret. +// +// This widget should wrap Super Editor and make the whole area tappable. +/// A widget, that when pressed, gives focus to the [editorFocusNode], and places +/// the caret at the end of the content within an [editor]. +/// +/// It's expected that the [child] subtree contains the associated `SuperEditor`, +/// which owns the [editor] and [editorFocusNode]. +class SuperEditorFocusOnTap extends StatelessWidget { + const SuperEditorFocusOnTap({ + super.key, + required this.editorFocusNode, + required this.editor, + required this.child, + }); + + final FocusNode editorFocusNode; + + final Editor editor; + + /// The SuperEditor that we're wrapping with this tap behavior. + final Widget child; + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: editorFocusNode, + builder: (context, child) { + return ListenableBuilder( + listenable: editor.composer.selectionNotifier, + builder: (context, child) { + final shouldControlTap = editor.composer.selection == null || !editorFocusNode.hasFocus; + return GestureDetector( + onTap: editor.composer.selection == null || !editorFocusNode.hasFocus ? _selectEditor : null, + behavior: HitTestBehavior.opaque, + child: IgnorePointer( + ignoring: shouldControlTap, + // ^ Prevent the Super Editor from aggressively responding to + // taps, so that we can respond. + child: child, + ), + ); + }, + child: child, + ); + }, + child: child, + ); + } + + void _selectEditor() { + editorFocusNode.requestFocus(); + + final endNode = editor.document.last; + editor.execute([ + ChangeSelectionRequest( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: endNode.id, + nodePosition: endNode.endPosition, + ), + ), + SelectionChangeType.placeCaret, + SelectionReason.userInteraction, + ) + ]); + } +} + +enum _Panel { + one, + two, + three; +} diff --git a/super_editor/example_chat/lib/main.dart b/super_editor/example_chat/lib/main_bottom_mounted_sheet.dart similarity index 100% rename from super_editor/example_chat/lib/main.dart rename to super_editor/example_chat/lib/main_bottom_mounted_sheet.dart diff --git a/super_editor/example_chat/lib/main_configured_floating_chat_page.dart b/super_editor/example_chat/lib/main_configured_floating_chat_page.dart new file mode 100644 index 0000000000..77c6beb90d --- /dev/null +++ b/super_editor/example_chat/lib/main_configured_floating_chat_page.dart @@ -0,0 +1,16 @@ +import 'package:example_chat/floating_chat_editor_demo/floating_chat_editor_demo_configured.dart'; +import 'package:flutter/material.dart'; +import 'package:super_editor/super_editor.dart'; + +void main() { + initLoggers(Level.ALL, { + // messagePageLayoutLog, + // messageEditorHeightLog, + }); + + runApp( + MaterialApp( + home: FloatingChatEditorBuilderDemo(), + ), + ); +} diff --git a/super_editor/example_chat/lib/main_default_floating_chat_page.dart b/super_editor/example_chat/lib/main_default_floating_chat_page.dart new file mode 100644 index 0000000000..d99c74c268 --- /dev/null +++ b/super_editor/example_chat/lib/main_default_floating_chat_page.dart @@ -0,0 +1,16 @@ +import 'package:example_chat/floating_chat_editor_demo/floating_chat_editor_demo_simple.dart'; +import 'package:flutter/material.dart'; +import 'package:super_editor/super_editor.dart'; + +void main() { + initLoggers(Level.ALL, { + // messagePageLayoutLog, + // messageEditorHeightLog, + }); + + runApp( + MaterialApp( + home: FloatingChatEditorSimpleDemo(), + ), + ); +} diff --git a/super_editor/example_chat/lib/main_keyboard_panel_scaffold.dart b/super_editor/example_chat/lib/main_keyboard_panel_scaffold.dart new file mode 100644 index 0000000000..c092118d57 --- /dev/null +++ b/super_editor/example_chat/lib/main_keyboard_panel_scaffold.dart @@ -0,0 +1,16 @@ +import 'package:example_chat/keyboard_panel_scaffold/keyboard_panel_scaffold_demo.dart'; +import 'package:flutter/material.dart'; +import 'package:super_editor/super_editor.dart'; + +void main() { + initLoggers(Level.ALL, { + // messagePageLayoutLog, + // messageEditorHeightLog, + }); + + runApp( + MaterialApp( + home: KeyboardPanelScaffoldDemo(), + ), + ); +} diff --git a/super_editor/lib/src/chat/bottom_floating/default/default_editor_sheet.dart b/super_editor/lib/src/chat/bottom_floating/default/default_editor_sheet.dart new file mode 100644 index 0000000000..b0af5e876d --- /dev/null +++ b/super_editor/lib/src/chat/bottom_floating/default/default_editor_sheet.dart @@ -0,0 +1,433 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:super_editor/src/chat/bottom_floating/default/default_editor_toolbar.dart'; +import 'package:super_editor/src/chat/bottom_floating/ui_kit/floating_editor_page_scaffold.dart'; +import 'package:super_editor/src/chat/bottom_floating/ui_kit/floating_editor_sheet.dart'; +import 'package:super_editor/src/chat/chat_editor.dart'; +import 'package:super_editor/src/chat/message_page_scaffold.dart'; +import 'package:super_editor/src/core/editor.dart'; +import 'package:super_editor/src/default_editor/document_ime/document_input_ime.dart'; + +/// A super ellipse sheet, which contains a drag handle, editor, and toolbar. +/// +/// This sheet can optionally be composed into a larger sheet, which includes a shadow +/// sheet. +class DefaultFloatingEditorSheet extends StatefulWidget { + const DefaultFloatingEditorSheet({ + super.key, + required this.editor, + required this.messagePageController, + this.visualEditor, + this.style = const EditorSheetStyle(), + }); + + final Editor editor; + + final MessagePageController messagePageController; + + final Widget? visualEditor; + + final EditorSheetStyle style; + + @override + State createState() => _DefaultFloatingEditorSheetState(); +} + +class _DefaultFloatingEditorSheetState extends State { + final _dragIndicatorKey = GlobalKey(); + + final _scrollController = ScrollController(); + + final _editorFocusNode = FocusNode(); + late final SoftwareKeyboardController _softwareKeyboardController; + + final _hasSelection = ValueNotifier(false); + + bool _isUserPressingDown = false; + + @override + void initState() { + super.initState(); + + _softwareKeyboardController = SoftwareKeyboardController(); + + widget.editor.composer.selectionNotifier.addListener(_onSelectionChange); + } + + @override + void didUpdateWidget(DefaultFloatingEditorSheet oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.editor != oldWidget.editor) { + oldWidget.editor.composer.selectionNotifier.removeListener(_onSelectionChange); + widget.editor.composer.selectionNotifier.addListener(_onSelectionChange); + } + } + + @override + void dispose() { + widget.editor.composer.selectionNotifier.removeListener(_onSelectionChange); + + _editorFocusNode.dispose(); + + _scrollController.dispose(); + + super.dispose(); + } + + void _onSelectionChange() { + _hasSelection.value = widget.editor.composer.selection != null; + + // If the editor doesn't have a selection then when it's collapsed it + // should be in preview mode. If the editor does have a selection, then + // when it's collapsed, it should be in intrinsic height mode. + widget.messagePageController.collapsedMode = + _hasSelection.value ? MessagePageSheetCollapsedMode.intrinsic : MessagePageSheetCollapsedMode.preview; + } + + double _dragTouchOffsetFromIndicator = 0; + + void _onVerticalDragStart(DragStartDetails details) { + print("Drag handle start: ${details.globalPosition}"); + _dragTouchOffsetFromIndicator = _dragFingerOffsetFromIndicator(details.globalPosition); + + widget.messagePageController.onDragStart( + details.globalPosition.dy - _dragIndicatorOffsetFromTop - _dragTouchOffsetFromIndicator, + ); + } + + void _onVerticalDragUpdate(DragUpdateDetails details) { + print("Drag handle update: ${details.globalPosition}"); + widget.messagePageController.onDragUpdate( + details.globalPosition.dy - _dragIndicatorOffsetFromTop - _dragTouchOffsetFromIndicator, + ); + } + + void _onVerticalDragEnd(DragEndDetails details) { + print("Drag handle end."); + widget.messagePageController.onDragEnd(); + } + + void _onVerticalDragCancel() { + print("Drag handle cancel."); + widget.messagePageController.onDragEnd(); + } + + double get _dragIndicatorOffsetFromTop { + final bottomSheetBox = FloatingChatBottomSheet.of(context).findRenderObject(); + final dragIndicatorBox = _dragIndicatorKey.currentContext!.findRenderObject()! as RenderBox; + + return dragIndicatorBox.localToGlobal(Offset.zero, ancestor: bottomSheetBox).dy; + } + + double _dragFingerOffsetFromIndicator(Offset globalDragOffset) { + final dragIndicatorBox = _dragIndicatorKey.currentContext!.findRenderObject()! as RenderBox; + + return globalDragOffset.dy - dragIndicatorBox.localToGlobal(Offset.zero).dy; + } + + @override + Widget build(BuildContext context) { + return _buildSheet( + child: _buildSheetContent(), + ); + } + + Widget _buildSheet({ + required Widget child, + }) { + return Listener( + onPointerDown: (_) => setState(() { + _isUserPressingDown = true; + }), + onPointerUp: (_) => setState(() { + _isUserPressingDown = false; + }), + onPointerCancel: (_) => setState(() { + _isUserPressingDown = false; + }), + child: ClipRSuperellipse( + borderRadius: BorderRadius.all(widget.style.borderRadius), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 4, sigmaY: 4, tileMode: TileMode.decal), + child: Container( + decoration: ShapeDecoration( + color: widget.style.background.withValues(alpha: _isUserPressingDown ? 1.0 : 0.8), + shape: RoundedSuperellipseBorder( + borderRadius: BorderRadius.all(widget.style.borderRadius), + ), + ), + child: child, + ), + ), + ), + ); + } + + Widget _buildSheetContent() { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildDragHandle(), + Flexible( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ListenableBuilder( + listenable: _editorFocusNode, + builder: (context, child) { + if (_editorFocusNode.hasFocus) { + return const SizedBox(); + } + + return const Padding( + padding: EdgeInsets.only(left: 12, bottom: 12), + child: AttachmentButton(), + ); + }, + ), + Expanded( + child: Padding( + padding: const EdgeInsets.only(top: 4), + child: _buildVisualEditor(), + ), + ), + ListenableBuilder( + listenable: _editorFocusNode, + builder: (context, child) { + if (_editorFocusNode.hasFocus) { + return const SizedBox(); + } + + return const Padding( + padding: EdgeInsets.only(right: 12, bottom: 12), + child: DictationButton(), + ); + }, + ), + ], + ), + ), + ListenableBuilder( + listenable: _editorFocusNode, + builder: (context, child) { + if (!_editorFocusNode.hasFocus) { + return const SizedBox(); + } + + return _buildToolbar(); + }, + ) + ], + ); + } + + Widget _buildVisualEditor() { + return BottomSheetEditorHeight( + previewHeight: 32, + child: widget.visualEditor ?? + SuperChatEditor( + key: _editorKey, + editorFocusNode: _editorFocusNode, + editor: widget.editor, + pageController: widget.messagePageController, + scrollController: _scrollController, + softwareKeyboardController: _softwareKeyboardController, + ), + ); + } + + // FIXME: Keyboard keeps closing without a bunch of global keys. Either + final _editorKey = GlobalKey(); + + Widget _buildDragHandle() { + return ListenableBuilder( + listenable: _editorFocusNode, + builder: (context, child) { + if (!_editorFocusNode.hasFocus) { + return const SizedBox(height: 12); + } + + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + GestureDetector( + onVerticalDragStart: _onVerticalDragStart, + onVerticalDragUpdate: _onVerticalDragUpdate, + onVerticalDragEnd: _onVerticalDragEnd, + onVerticalDragCancel: _onVerticalDragCancel, + behavior: HitTestBehavior.opaque, + // ^ Opaque to handle tough events in our invisible padding. + child: Padding( + padding: const EdgeInsets.all(8), + // ^ Expand the hit area with invisible padding. + child: Container( + key: _dragIndicatorKey, + width: 48, + height: 5, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(3), + ), + ), + ), + ), + ], + ); + }, + ); + } + + Widget _buildToolbar() { + return SuperChatFloatingSheetToolbar( + editor: widget.editor, + softwareKeyboardController: _softwareKeyboardController, + onAttachPressed: () {}, + onSendPressed: () {}, + ); + + // return Padding( + // padding: const EdgeInsets.only(left: 12, right: 12, bottom: 12), + // child: FloatingEditorToolbar( + // softwareKeyboardController: _softwareKeyboardController, + // ), + // ); + } +} + +// // TODO: Delete the following fake toolbar in favor of a configurable real one +// class FloatingEditorToolbar extends StatelessWidget { +// const FloatingEditorToolbar({ +// super.key, +// required this.softwareKeyboardController, +// }); +// +// final SoftwareKeyboardController softwareKeyboardController; +// +// @override +// Widget build(BuildContext context) { +// return Row( +// children: [ +// const Expanded( +// child: SingleChildScrollView( +// scrollDirection: Axis.horizontal, +// child: Row( +// spacing: 4, +// children: [ +// AttachmentButton(), +// _IconButton(icon: Icons.format_bold), +// _IconButton(icon: Icons.format_italic), +// _IconButton(icon: Icons.format_underline), +// _IconButton(icon: Icons.format_strikethrough), +// _IconButton(icon: Icons.format_color_fill), +// _IconButton(icon: Icons.format_quote), +// _IconButton(icon: Icons.format_align_left), +// _IconButton(icon: Icons.format_align_center), +// _IconButton(icon: Icons.format_align_right), +// _IconButton(icon: Icons.format_align_justify), +// ], +// ), +// ), +// ), +// _buildDivider(), +// _CloseKeyboardButton( +// softwareKeyboardController: softwareKeyboardController, +// ), +// _buildDivider(), +// const _SendButton(), +// ], +// ); +// } +// +// Widget _buildDivider() { +// return Container( +// width: 1, +// height: 16, +// margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), +// color: Colors.grey.shade300, +// ); +// } +// } +// +class AttachmentButton extends StatelessWidget { + const AttachmentButton({super.key}); + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration(shape: BoxShape.circle, color: Colors.grey.shade200), + child: const _IconButton( + icon: Icons.add, + ), + ); + } +} + +class DictationButton extends StatelessWidget { + const DictationButton({super.key}); + + @override + Widget build(BuildContext context) { + return const _IconButton(icon: Icons.multitrack_audio); + } +} + +class _CloseKeyboardButton extends StatelessWidget { + const _CloseKeyboardButton({ + required this.softwareKeyboardController, + }); + + final SoftwareKeyboardController softwareKeyboardController; + + @override + Widget build(BuildContext context) { + return _IconButton( + icon: Icons.keyboard_hide, + onPressed: _closeKeyboard, + ); + } + + void _closeKeyboard() { + softwareKeyboardController.close(); + } +} + +class _SendButton extends StatelessWidget { + const _SendButton(); + + @override + Widget build(BuildContext context) { + return const _IconButton(icon: Icons.send); + } +} + +class _IconButton extends StatelessWidget { + const _IconButton({ + required this.icon, + this.onPressed, + }); + + final IconData icon; + + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onPressed, + child: SizedBox( + width: 32, + height: 32, + child: Center( + child: Icon( + icon, + size: 20, + color: Colors.grey, + ), + ), + ), + ); + } +} diff --git a/super_editor/lib/src/chat/bottom_floating/default/default_editor_toolbar.dart b/super_editor/lib/src/chat/bottom_floating/default/default_editor_toolbar.dart new file mode 100644 index 0000000000..77b2f9d07a --- /dev/null +++ b/super_editor/lib/src/chat/bottom_floating/default/default_editor_toolbar.dart @@ -0,0 +1,506 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:super_editor/src/chat/chat_editor.dart'; +import 'package:super_editor/src/core/editor.dart'; +import 'package:super_editor/src/default_editor/document_ime/document_input_ime.dart'; + +class SuperChatFloatingSheetToolbar extends StatefulWidget { + const SuperChatFloatingSheetToolbar({ + super.key, + required this.editor, + this.softwareKeyboardController, + this.onAttachPressed, + this.onSendPressed, + this.toolbarOptions, + }); + + final Editor editor; + + final SoftwareKeyboardController? softwareKeyboardController; + + final VoidCallback? onAttachPressed; + final VoidCallback? onSendPressed; + + final Set? toolbarOptions; + + @override + State createState() => _SuperChatFloatingSheetToolbarState(); +} + +class _SuperChatFloatingSheetToolbarState extends State { + var _toolbarOptions = {}; + + @override + void initState() { + super.initState(); + + _toolbarOptions = Set.from(widget.toolbarOptions ?? SimpleSuperChatToolbarOptions.values); + + _doSanityChecks(); + } + + void _doSanityChecks() { + assert( + widget.softwareKeyboardController != null || + !_toolbarOptions.contains(SimpleSuperChatToolbarOptions.closeKeyboard), + "If you want a button to close the keyboard, you must provide a SoftwareKeyboardController", + ); + if (widget.softwareKeyboardController == null) { + // In case we're in release mode, and the assert didn't trigger, remove the close-keyboard + // button from the options. + _toolbarOptions.remove(SimpleSuperChatToolbarOptions.closeKeyboard); + } + + assert( + widget.onAttachPressed != null || !_toolbarOptions.contains(SimpleSuperChatToolbarOptions.attach), + "If you want a button to attach media, you must provide an onAttachPressed callback", + ); + if (widget.onAttachPressed == null) { + // In case we're in release mode, and the assert didn't trigger, remove the "attach" + // button from the options. + _toolbarOptions.remove(SimpleSuperChatToolbarOptions.attach); + } + + assert( + widget.onSendPressed != null || !_toolbarOptions.contains(SimpleSuperChatToolbarOptions.send), + "If you want a button to send messages, you must provide an onSendPressed callback", + ); + if (widget.onSendPressed == null) { + // In case we're in release mode, and the assert didn't trigger, remove the "send" + // button from the options. + _toolbarOptions.remove(SimpleSuperChatToolbarOptions.send); + } + } + + @override + void didUpdateWidget(covariant SuperChatFloatingSheetToolbar oldWidget) { + super.didUpdateWidget(oldWidget); + + if (!const DeepCollectionEquality().equals(widget.toolbarOptions, oldWidget.toolbarOptions)) { + _toolbarOptions = Set.from(widget.toolbarOptions ?? SimpleSuperChatToolbarOptions.values); + } + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(left: 14, right: 14, top: 8, bottom: 14), + child: Row( + children: [ + if (_toolbarOptions.contains(SimpleSuperChatToolbarOptions.attach)) // + _AttachmentButton( + onPressed: () {}, + ), + const Expanded( + child: SingleChildScrollView( + child: Row( + children: [ + // + ], + ), + ), + ), + if (_toolbarOptions.contains(SimpleSuperChatToolbarOptions.closeKeyboard)) ...[ + const _Divider(), + _CloseKeyboardButton(softwareKeyboardController: widget.softwareKeyboardController!), + ], + if (_toolbarOptions.contains(SimpleSuperChatToolbarOptions.send)) ...[ + const _Divider(), + _SendButton(onPressed: () {}), + ], + ], + ), + ); + } + + List _buildTopLevelButtons() { + if (!_hasBlockTypes) { + // There are no block types, so instead of placing text formatting in a sub-menu + // we'll put them at the top level. + return [ + for (final option in _selectTextFormattingOptions()) // + _buildOptionButton(option), + if (_toolbarOptions.contains(SimpleSuperChatToolbarOptions.clearStyles)) // + const _ClearTextFormattingButton(), + ]; + } + + return [ + if (_hasTextFormatting) // + const _OpenTextFormattingButton(), + for (final option in _selectBlockTypeOptions()) // + _buildOptionButton(option), + ]; + } + + /// Whether this toolbar includes any options for converting types. + bool get _hasBlockTypes { + for (final blockType in _blockTypes) { + if (_toolbarOptions.contains(blockType)) { + return true; + } + } + + return false; + } + + List _selectBlockTypeOptions() { + final blockTypeOptions = []; + for (final blockType in _blockTypes) { + if (_toolbarOptions.contains(blockType)) { + blockTypeOptions.add(blockType); + } + } + return blockTypeOptions; + } + + bool get _hasTextFormatting { + for (final textFormat in _textFormats) { + if (_toolbarOptions.contains(textFormat)) { + return true; + } + } + + return false; + } + + List _selectTextFormattingOptions() { + final textFormattingOptions = []; + for (final textFormat in _textFormats) { + if (_toolbarOptions.contains(textFormat)) { + textFormattingOptions.add(textFormat); + } + } + return textFormattingOptions; + } + + Widget _buildOptionButton(SimpleSuperChatToolbarOptions option) { + return switch (option) { + // Text formatting. + SimpleSuperChatToolbarOptions.bold => throw UnimplementedError(), + SimpleSuperChatToolbarOptions.italics => throw UnimplementedError(), + SimpleSuperChatToolbarOptions.underline => throw UnimplementedError(), + SimpleSuperChatToolbarOptions.strikethrough => throw UnimplementedError(), + SimpleSuperChatToolbarOptions.code => throw UnimplementedError(), + SimpleSuperChatToolbarOptions.textColor => throw UnimplementedError(), + SimpleSuperChatToolbarOptions.backgroundColor => throw UnimplementedError(), + SimpleSuperChatToolbarOptions.indent => throw UnimplementedError(), + SimpleSuperChatToolbarOptions.clearStyles => throw UnimplementedError(), + + // Blocks. + SimpleSuperChatToolbarOptions.orderedListItem => throw UnimplementedError(), + SimpleSuperChatToolbarOptions.unorderedListItem => throw UnimplementedError(), + SimpleSuperChatToolbarOptions.blockquote => throw UnimplementedError(), + SimpleSuperChatToolbarOptions.codeBlock => throw UnimplementedError(), + + // Media. + SimpleSuperChatToolbarOptions.attach => _AttachmentButton( + onPressed: widget.onAttachPressed!, + ), + + // Control. + SimpleSuperChatToolbarOptions.dictation => const _DictationButton(), + SimpleSuperChatToolbarOptions.closeKeyboard => _CloseKeyboardButton( + softwareKeyboardController: widget.softwareKeyboardController!, + ), + SimpleSuperChatToolbarOptions.send => _SendButton( + onPressed: widget.onSendPressed!, + ), + }; + } +} + +const _blockTypes = [ + SimpleSuperChatToolbarOptions.blockquote, + SimpleSuperChatToolbarOptions.unorderedListItem, + SimpleSuperChatToolbarOptions.orderedListItem, + SimpleSuperChatToolbarOptions.codeBlock, +]; + +const _textFormats = [ + SimpleSuperChatToolbarOptions.bold, + SimpleSuperChatToolbarOptions.italics, + SimpleSuperChatToolbarOptions.underline, + SimpleSuperChatToolbarOptions.strikethrough, + SimpleSuperChatToolbarOptions.textColor, + SimpleSuperChatToolbarOptions.backgroundColor, +]; + +class _OpenTextFormattingButton extends StatelessWidget { + const _OpenTextFormattingButton({super.key}); + + @override + Widget build(BuildContext context) { + return const Placeholder(); + } +} + +class _BoldButton extends StatefulWidget { + const _BoldButton({super.key}); + + @override + State<_BoldButton> createState() => _BoldButtonState(); +} + +class _BoldButtonState extends State<_BoldButton> { + @override + Widget build(BuildContext context) { + return const Placeholder(); + } +} + +class _ItalicsButton extends StatefulWidget { + const _ItalicsButton({super.key}); + + @override + State<_ItalicsButton> createState() => _ItalicsButtonState(); +} + +class _ItalicsButtonState extends State<_ItalicsButton> { + @override + Widget build(BuildContext context) { + return const Placeholder(); + } +} + +class _UnderlineButton extends StatefulWidget { + const _UnderlineButton({super.key}); + + @override + State<_UnderlineButton> createState() => _UnderlineButtonState(); +} + +class _UnderlineButtonState extends State<_UnderlineButton> { + @override + Widget build(BuildContext context) { + return const Placeholder(); + } +} + +class _StrikethroughButton extends StatefulWidget { + const _StrikethroughButton({super.key}); + + @override + State<_StrikethroughButton> createState() => _StrikethroughButtonState(); +} + +class _StrikethroughButtonState extends State<_StrikethroughButton> { + @override + Widget build(BuildContext context) { + return const Placeholder(); + } +} + +class _TextColorButton extends StatefulWidget { + const _TextColorButton({super.key}); + + @override + State<_TextColorButton> createState() => _TextColorButtonState(); +} + +class _TextColorButtonState extends State<_TextColorButton> { + @override + Widget build(BuildContext context) { + return const Placeholder(); + } +} + +class _BackgroundColorButton extends StatefulWidget { + const _BackgroundColorButton({super.key}); + + @override + State<_BackgroundColorButton> createState() => _BackgroundColorButtonState(); +} + +class _BackgroundColorButtonState extends State<_BackgroundColorButton> { + @override + Widget build(BuildContext context) { + return const Placeholder(); + } +} + +class _ClearTextFormattingButton extends StatelessWidget { + const _ClearTextFormattingButton({super.key}); + + @override + Widget build(BuildContext context) { + return const Placeholder(); + } +} + +class _ParagraphBlockButton extends StatefulWidget { + const _ParagraphBlockButton({super.key}); + + @override + State<_ParagraphBlockButton> createState() => _ParagraphBlockButtonState(); +} + +class _ParagraphBlockButtonState extends State<_ParagraphBlockButton> { + @override + Widget build(BuildContext context) { + return const Placeholder(); + } +} + +class _BlockquoteButton extends StatefulWidget { + const _BlockquoteButton({super.key}); + + @override + State<_BlockquoteButton> createState() => _BlockquoteButtonState(); +} + +class _BlockquoteButtonState extends State<_BlockquoteButton> { + @override + Widget build(BuildContext context) { + return const Placeholder(); + } +} + +class _UnorderedListItemButton extends StatefulWidget { + const _UnorderedListItemButton({super.key}); + + @override + State<_UnorderedListItemButton> createState() => _UnorderedListItemButtonState(); +} + +class _UnorderedListItemButtonState extends State<_UnorderedListItemButton> { + @override + Widget build(BuildContext context) { + return const Placeholder(); + } +} + +class _OrderedListItemButton extends StatefulWidget { + const _OrderedListItemButton({super.key}); + + @override + State<_OrderedListItemButton> createState() => _OrderedListItemButtonState(); +} + +class _OrderedListItemButtonState extends State<_OrderedListItemButton> { + @override + Widget build(BuildContext context) { + return const Placeholder(); + } +} + +class _CodeBlockButton extends StatefulWidget { + const _CodeBlockButton({super.key}); + + @override + State<_CodeBlockButton> createState() => _CodeBlockButtonState(); +} + +class _CodeBlockButtonState extends State<_CodeBlockButton> { + @override + Widget build(BuildContext context) { + return const Placeholder(); + } +} + +class _AttachmentButton extends StatelessWidget { + const _AttachmentButton({ + required this.onPressed, + }); + + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration(shape: BoxShape.circle, color: Colors.grey.shade200), + child: _IconButton( + icon: Icons.add, + onPressed: onPressed, + ), + ); + } +} + +class _DictationButton extends StatelessWidget { + const _DictationButton({super.key}); + + @override + Widget build(BuildContext context) { + return const _IconButton(icon: Icons.multitrack_audio); + } +} + +class _CloseKeyboardButton extends StatelessWidget { + const _CloseKeyboardButton({ + required this.softwareKeyboardController, + }); + + final SoftwareKeyboardController softwareKeyboardController; + + @override + Widget build(BuildContext context) { + return _IconButton( + icon: Icons.keyboard_hide, + onPressed: _closeKeyboard, + ); + } + + void _closeKeyboard() { + softwareKeyboardController.close(); + } +} + +class _SendButton extends StatelessWidget { + const _SendButton({ + required this.onPressed, + }); + + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return _IconButton( + icon: Icons.send, + onPressed: onPressed, + ); + } +} + +class _IconButton extends StatelessWidget { + const _IconButton({ + required this.icon, + this.onPressed, + }); + + final IconData icon; + + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onPressed, + child: SizedBox( + width: 32, + height: 32, + child: Center( + child: Icon( + icon, + size: 20, + color: Colors.grey, + ), + ), + ), + ); + } +} + +class _Divider extends StatelessWidget { + const _Divider(); + + @override + Widget build(BuildContext context) { + return Container( + width: 1, + height: 16, + margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + color: Colors.grey.shade300, + ); + } +} diff --git a/super_editor/lib/src/chat/bottom_floating/default/default_floating_super_chat.dart b/super_editor/lib/src/chat/bottom_floating/default/default_floating_super_chat.dart new file mode 100644 index 0000000000..76cb0ecb5a --- /dev/null +++ b/super_editor/lib/src/chat/bottom_floating/default/default_floating_super_chat.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import 'package:super_editor/src/chat/bottom_floating/default/default_editor_sheet.dart'; +import 'package:super_editor/src/chat/bottom_floating/ui_kit/floating_editor_page_scaffold.dart'; +import 'package:super_editor/src/chat/bottom_floating/ui_kit/floating_editor_sheet.dart'; +import 'package:super_editor/src/core/editor.dart'; +import 'package:super_editor/src/default_editor/document_ime/document_input_ime.dart'; + +/// The standard/default floating chat page. +/// +/// This widget is meant to be a quick and easy drop-in floating chat solution. It +/// includes a specific configuration of [SuperEditor], a reasonable editor toolbar, +/// a drag handle to expand/collapse the bottom sheet, and it supports an optional shadow +/// sheet, which shows messages just above the editor sheet. +class DefaultFloatingSuperChatPage extends StatefulWidget { + const DefaultFloatingSuperChatPage({ + super.key, + required this.pageBuilder, + required this.editor, + this.shadowSheetBanner, + this.style = const FloatingEditorStyle(), + }); + + final FloatingEditorContentBuilder pageBuilder; + + final Editor editor; + + final Widget? shadowSheetBanner; + + final FloatingEditorStyle style; + + @override + State createState() => _DefaultFloatingSuperChatPageState(); +} + +class _DefaultFloatingSuperChatPageState extends State { + final _softwareKeyboardController = SoftwareKeyboardController(); + late final FloatingEditorPageController _pageController; + + @override + void initState() { + super.initState(); + _pageController = FloatingEditorPageController(_softwareKeyboardController); + } + + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return FloatingEditorPageScaffold( + pageController: _pageController, + pageBuilder: widget.pageBuilder, + editorSheet: DefaultFloatingEditorSheet( + editor: widget.editor, + messagePageController: _pageController, + style: widget.style.editorSheet, + ), + shadowSheetBanner: widget.shadowSheetBanner, + style: widget.style, + ); + } +} diff --git a/super_editor/lib/src/chat/bottom_floating/ui_kit/floating_editor_page_scaffold.dart b/super_editor/lib/src/chat/bottom_floating/ui_kit/floating_editor_page_scaffold.dart new file mode 100644 index 0000000000..d2e205d520 --- /dev/null +++ b/super_editor/lib/src/chat/bottom_floating/ui_kit/floating_editor_page_scaffold.dart @@ -0,0 +1,1818 @@ +import 'dart:math'; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/physics.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_keyboard/super_keyboard.dart'; + +/// A page scaffold that displays page content in a [child], with a floating editor sitting above and at +/// the bottom of the [child]. +class FloatingEditorPageScaffold extends StatefulWidget { + const FloatingEditorPageScaffold({ + super.key, + this.pageController, + this.softwareKeyboardController, + required this.pageBuilder, + required this.editorSheet, + this.keyboardPanelBuilder, + this.shadowSheetBanner, + this.style = const FloatingEditorStyle(), + }); + + final FloatingEditorPageController? pageController; + final SoftwareKeyboardController? softwareKeyboardController; + + final FloatingEditorContentBuilder pageBuilder; + + final Widget? shadowSheetBanner; + final Widget editorSheet; + + final KeyboardPanelBuilder? keyboardPanelBuilder; + + final FloatingEditorStyle style; + + @override + State createState() => _FloatingEditorPageScaffoldState(); +} + +class _FloatingEditorPageScaffoldState extends State> + with TickerProviderStateMixin { + late FloatingEditorPageController _pageController; + late SoftwareKeyboardController _softwareKeyboardController; + + @override + void initState() { + super.initState(); + _softwareKeyboardController = widget.softwareKeyboardController ?? SoftwareKeyboardController(); + _pageController = widget.pageController ?? FloatingEditorPageController(_softwareKeyboardController); + } + + @override + void didUpdateWidget(covariant FloatingEditorPageScaffold oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.pageController != oldWidget.pageController) { + if (oldWidget.pageController == null) { + _pageController.dispose(); + } + _pageController = widget.pageController ?? FloatingEditorPageController(_softwareKeyboardController); + } + + if (widget.softwareKeyboardController != oldWidget.softwareKeyboardController) { + _softwareKeyboardController = widget.softwareKeyboardController ?? SoftwareKeyboardController(); + } + } + + @override + void dispose() { + if (widget.pageController == null) { + _pageController.dispose(); + } + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return _AboveKeyboardMessagePageScaffold( + vsync: this, + pageController: _pageController, + contentBuilder: (contentContext, pageGeometry) { + return MediaQuery.removePadding( + context: contentContext, + removeBottom: true, + // ^ Remove bottom padding because if we don't, when the keyboard + // opens to edit the bottom sheet, this content behind the bottom + // sheet adds some phantom space at the bottom, slightly pushing + // it up for no reason. + child: widget.pageBuilder(contentContext, pageGeometry), + ); + }, + bottomSheetBuilder: (messageContext) { + return FloatingChatBottomSheet( + child: BottomFloatingChatSheet( + messagePageController: _pageController, + editorSheet: widget.editorSheet, + shadowSheetBanner: widget.shadowSheetBanner, + style: widget.style, + ), + ); + + // // TODO: Figure out where this KeyboardScaffoldSafeArea goes and why. We have a number of different + // // scaffolds and sheets being composed. Where exactly do we want to push up above the keyboard, + // // and where do we need to place a KeyboardScaffoldSafeArea to satisfy the expectations of a + // // KeyboardPanelScaffold? + // return KeyboardScaffoldSafeArea( + // child: FloatingChatBottomSheet( + // child: BottomFloatingChatSheet( + // messagePageController: _messagePageController, + // editorSheet: widget.editorSheet, + // shadowSheetBanner: widget.shadowSheetBanner, + // style: widget.style, + // ), + // ), + // ); + }, + bottomSheetMinimumTopGap: widget.style.margin.top, + bottomSheetMinimumHeight: widget.style.collapsedMinimumHeight, + bottomSheetCollapsedMaximumHeight: widget.style.collapsedMaximumHeight, + keyboardPanelBuilder: widget.keyboardPanelBuilder, + ); + } +} + +class FloatingEditorPageController extends MessagePageController { + FloatingEditorPageController( + this.softwareKeyboardController, + ); + + @override + void dispose() { + detach(); + super.dispose(); + } + + final SoftwareKeyboardController softwareKeyboardController; + + FloatingEditorPageControllerDelegate? _delegate; + + /// Whether this controller is currently attached to a delegate that + /// knows how to open/close the software keyboard and keyboard panel. + bool get hasDelegate => _delegate != null; + + /// Attaches this controller to a delegate that knows how to show a toolbar, open and + /// close the software keyboard, and the keyboard panel. + void attach(FloatingEditorPageControllerDelegate delegate) { + editorImeLog.finer("[KeyboardPanelController] - Attaching to delegate: $delegate"); + _delegate = delegate; + + // TODO: Do we really need listener proxying? We have clients that want to listen to this + // controller, but we're notifying listeners from the delegate (render object). We should + // probably rework this to become simpler and more clear. + for (final listener in _controllerListeners) { + _delegate?.addListener(listener); + } + } + + /// Detaches this controller from its delegate. + /// + /// This controller can't open or close the software keyboard, or keyboard panel, while + /// detached from a delegate that knows how to make that happen. + void detach() { + editorImeLog.finer("[KeyboardPanelController] - Detaching from delegate: $_delegate"); + for (final listener in _controllerListeners) { + _delegate?.removeListener(listener); + } + + _delegate = null; + } + + /// Whether the delegate currently wants a keyboard panel to be open. + /// + /// This is expressed as "want" because the keyboard panel has transitory states, + /// like opening and closing. Therefore, this property doesn't reflect actual + /// visibility. + bool get isSoftwareKeyboardOpen => _delegate?.isKeyboardPanelOpen ?? false; + + /// Shows the software keyboard, if it's hidden. + void showSoftwareKeyboard() { + _delegate?.showSoftwareKeyboard(); + } + + /// Hides (doesn't close) the software keyboard, if it's open. + void hideSoftwareKeyboard() { + _delegate?.hideSoftwareKeyboard(); + } + + /// Whether the delegate currently wants a keyboard panel to be open. + /// + /// This is expressed as "want" because the keyboard panel has transitory states, + /// like opening and closing. Therefore, this property doesn't reflect actual + /// visibility. + bool get isKeyboardPanelOpen => _delegate?.isKeyboardPanelOpen ?? false; + + PanelType? get openPanel => _delegate?.openPanel; + + /// Shows the keyboard panel, if it's closed, and hides (doesn't close) the + /// software keyboard, if it's open. + void showKeyboardPanel(PanelType panel) => _delegate?.showKeyboardPanel(panel); + + /// Opens or closes the given [panel], depending on whether it's already open. + /// + /// If the panel is closed, the software keyboard will be opened. + void toggleKeyboardPanel(PanelType panel) => _delegate?.toggleKeyboardPanel(panel); + + /// Hides the keyboard panel, if it's open. + void hideKeyboardPanel() { + _delegate?.hideKeyboardPanel(); + } + + /// Closes the software keyboard if it's open, or closes the keyboard panel if + /// it's open, and fully closes the keyboard (IME) connection. + void closeKeyboardAndPanel() { + _delegate?.closeKeyboardAndPanel(); + } + + final _controllerListeners = {}; + + @override + void addListener(VoidCallback listener) { + _controllerListeners.add(listener); + _delegate?.addListener(listener); + } + + @override + void removeListener(VoidCallback listener) { + _controllerListeners.remove(listener); + _delegate?.removeListener(listener); + } + + /// The height that we believe the keyboard occupies. + /// + /// This is a debug value and should only be used for logging. + final debugBestGuessKeyboardHeight = ValueNotifier(null); +} + +abstract interface class FloatingEditorPageControllerDelegate implements ChangeNotifier { + /// Whether this delegate currently wants the software keyboard to be open. + /// + /// This is expressed as "want" because the keyboard has transitory states, + /// like opening and closing. Therefore, this property doesn't reflect actual + /// visibility. + bool get isSoftwareKeyboardOpen; + + /// Shows the software keyboard, if it's hidden. + void showSoftwareKeyboard(); + + /// Hides (doesn't close) the software keyboard, if it's open. + void hideSoftwareKeyboard(); + + /// Whether this delegate currently wants a keyboard panel to be open. + /// + /// This is expressed as "want" because the keyboard panel has transitory states, + /// like opening and closing. Therefore, this property doesn't reflect actual + /// visibility. + bool get isKeyboardPanelOpen; + + PanelType? get openPanel; + + /// Shows the keyboard panel, if it's closed, and hides (doesn't close) the + /// software keyboard, if it's open. + void showKeyboardPanel(PanelType panel); + + /// Opens or closes the given [panel], depending on whether it's already open. + /// + /// If the panel is closed, the software keyboard will be opened. + void toggleKeyboardPanel(PanelType panel); + + /// Hides the keyboard panel, if it's open. + void hideKeyboardPanel(); + + /// Closes the software keyboard if it's open, or closes the keyboard panel if + /// it's open, and fully closes the keyboard (IME) connection. + void closeKeyboardAndPanel(); +} + +/// A page scaffold that displays page content in a [child], with a floating editor sitting above and at +/// the bottom of the [child]. +class _AboveKeyboardMessagePageScaffold extends RenderObjectWidget { + const _AboveKeyboardMessagePageScaffold({ + super.key, + required this.vsync, + required this.pageController, + required this.contentBuilder, + required this.bottomSheetBuilder, + this.bottomSheetMinimumTopGap = 200, + this.bottomSheetMinimumHeight = 150, + this.bottomSheetCollapsedMaximumHeight = double.infinity, + this.keyboardPanelBuilder, + this.fallbackKeyboardHeight = 250.0, + }); + + final TickerProvider vsync; + + final FloatingEditorPageController pageController; + + /// Builds the content within this scaffold, e.g., a chat conversation thread. + final FloatingEditorContentBuilder contentBuilder; + + /// Builds the bottom sheet within this scaffold, e.g., a chat message editor. + final WidgetBuilder bottomSheetBuilder; + + /// When dragging the bottom sheet up, or when filling it with content, + /// this is the minimum gap allowed between the sheet and the top of this + /// scaffold. + /// + /// When the bottom sheet reaches the minimum gap, it stops getting taller, + /// and its content scrolls. + final double bottomSheetMinimumTopGap; + + /// The shortest that the bottom sheet can ever be, regardless of content or + /// height mode. + final double bottomSheetMinimumHeight; + + /// The maximum height that the bottom sheet can expand to, as the intrinsic height + /// of the content increases. + /// + /// E.g., The user starts with a single line of text and then starts inserting + /// newlines. As the user continues to add newlines, this height is where the sheet + /// stops growing taller. + /// + /// This height applies when the sheet is collapsed, i.e., not expanded. If the user + /// expands the sheet, then the maximum height of the sheet would be the maximum allowed + /// layout height, minus [bottomSheetMinimumTopGap]. + final double bottomSheetCollapsedMaximumHeight; + + final KeyboardPanelBuilder? keyboardPanelBuilder; + + final double fallbackKeyboardHeight; + + @override + RenderObjectElement createElement() { + return _AboveKeyboardMessagePageElement(this); + } + + @override + _RenderAboveKeyboardPageScaffold createRenderObject(BuildContext context) { + return _RenderAboveKeyboardPageScaffold( + context as _AboveKeyboardMessagePageElement, + pageController, + vsync: vsync, + viewId: View.of(context).viewId, + bottomSheetMinimumTopGap: bottomSheetMinimumTopGap, + bottomSheetMinimumHeight: bottomSheetMinimumHeight, + bottomSheetCollapsedMaximumHeight: bottomSheetCollapsedMaximumHeight, + mediaQueryBottomInsets: MediaQuery.viewInsetsOf(context).bottom, + mediaQueryBottomPadding: MediaQuery.viewPaddingOf(context).bottom, + fallbackKeyboardHeight: fallbackKeyboardHeight, + ); + } + + @override + void updateRenderObject(BuildContext context, _RenderAboveKeyboardPageScaffold renderObject) { + renderObject + ..pageController = pageController + ..viewId = View.of(context).viewId + ..bottomSheetMinimumTopGap = bottomSheetMinimumTopGap + ..bottomSheetMinimumHeight = bottomSheetMinimumHeight + ..bottomSheetCollapsedMaximumHeight = bottomSheetCollapsedMaximumHeight + ..mediaQueryBottomInsets = MediaQuery.viewInsetsOf(context).bottom + .._mediaQueryBottomPadding = MediaQuery.viewPaddingOf(context).bottom + ..fallbackPanelHeight = fallbackKeyboardHeight; + } +} + +typedef FloatingEditorContentBuilder = Widget Function(BuildContext, FloatingEditorPageGeometry pageGeometry); + +typedef KeyboardPanelBuilder = Widget Function(BuildContext, PanelType openPanel); + +/// `Element` for a [FloatingEditorPageScaffold] widget. +class _AboveKeyboardMessagePageElement extends RenderObjectElement { + _AboveKeyboardMessagePageElement(_AboveKeyboardMessagePageScaffold super.widget); + + Element? _content; + Element? _bottomSheet; + Element? _keyboardPanel; + PanelType? _lastBuiltPanel; + + @override + _AboveKeyboardMessagePageScaffold get widget => + super.widget as _AboveKeyboardMessagePageScaffold; + + @override + _RenderAboveKeyboardPageScaffold get renderObject => super.renderObject as _RenderAboveKeyboardPageScaffold; + + @override + void mount(Element? parent, Object? newSlot) { + messagePageElementLog.info('ChatScaffoldElement - mounting'); + super.mount(parent, newSlot); + + _content = inflateWidget( + // Run initial build with zero bottom spacing because we haven't + // run layout on the message editor yet, which determines the content + // bottom spacing. + widget.contentBuilder(this, FloatingEditorPageGeometry.zero), + _contentSlot, + ); + + _bottomSheet = inflateWidget(widget.bottomSheetBuilder(this), _bottomSheetSlot); + + if (widget.keyboardPanelBuilder != null && widget.pageController.openPanel != null) { + updateOrInflateKeyboardPanel(); + } + + print("Element - mount() - adding listener to page controller"); + widget.pageController.addListener(markNeedsBuild); + } + + @override + void activate() { + messagePageElementLog.info('ContentLayersElement - activating'); + _didActivateSinceLastBuild = false; + super.activate(); + } + + // Whether this `Element` has been built since the last time `activate()` was run. + var _didActivateSinceLastBuild = false; + + @override + void deactivate() { + messagePageElementLog.info('ContentLayersElement - deactivating'); + _didDeactivateSinceLastBuild = false; + super.deactivate(); + } + + // Whether this `Element` has been built since the last time `deactivate()` was run. + bool _didDeactivateSinceLastBuild = false; + + @override + void unmount() { + messagePageElementLog.info('ContentLayersElement - unmounting'); + print("Element - unmount() - removing page controller listener"); + widget.pageController.removeListener(markNeedsBuild); + super.unmount(); + } + + @override + void markNeedsBuild() { + super.markNeedsBuild(); + + // Invalidate our content child's layout. + // + // Typically, nothing needs to be done in this method for children, because + // typically the superclass marks children as needing to rebuild and that's + // it. But our content only builds during layout. Therefore, to schedule a + // build for our content, we need to request a new layout pass, which we do + // here. + // + // Note: `markNeedsBuild()` is called when ancestor inherited widgets change + // their value. Failure to honor this method would result in our + // subtrees missing rebuilds related to ancestors changing. + _content?.renderObject?.markNeedsLayout(); + } + + @override + void performRebuild() { + super.performRebuild(); + + // Rebuild our child widgets, except for our content widget. + // + // We don't rebuild our content widget because we only want content to + // build during layout. + updateChild(_bottomSheet, widget.bottomSheetBuilder(this), _bottomSheetSlot); + + if (widget.pageController.isKeyboardPanelOpen) { + updateOrInflateKeyboardPanel(); + } + } + + void buildContent(FloatingEditorPageGeometry pageGeometry) { + messagePageElementLog.info('ContentLayersElement ($hashCode) - (re)building layers'); + // FIXME: The concept of bottom spacing doesn't apply to this scaffold because we report two heights. + widget.pageController.debugMostRecentBottomSpacing.value = pageGeometry.keyboardOrPanelHeight; + + owner!.buildScope(this, () { + if (_content == null) { + _content = inflateWidget( + widget.contentBuilder(this, pageGeometry), + _contentSlot, + ); + } else { + _content = super.updateChild( + _content, + widget.contentBuilder(this, pageGeometry), + _contentSlot, + ); + } + }); + + // The activation and deactivation processes involve visiting children, which + // we must honor, but the visitation happens some time after the actual call + // to activate and deactivate. So we remember when activation and deactivation + // happened, and now that we've built the `_content`, we clear those flags because + // we assume whatever visitation those processes need to do is now done, since + // we did a build. To learn more about this situation, look at `visitChildren`. + _didActivateSinceLastBuild = false; + _didDeactivateSinceLastBuild = false; + } + + @override + void update(_AboveKeyboardMessagePageScaffold newWidget) { + // Remove listener on previous widget. + print("Element - update() - removing listener from previous widget controller"); + widget.pageController.removeListener(markNeedsBuild); + + super.update(newWidget); + + _content = + updateChild(_content, widget.contentBuilder(this, FloatingEditorPageGeometry.zero), _contentSlot) ?? _content; + + _bottomSheet = updateChild(_bottomSheet, widget.bottomSheetBuilder(this), _bottomSheetSlot); + + // The page controller may have been switched out for another one. We want to + // take the same keyboard panel update behavior here that we take any time the + // controller changes - possibly inflate, or update, or deactivate the keyboard + // panel. + final openPanel = widget.pageController.openPanel; + if (openPanel != null && _keyboardPanel == null && widget.keyboardPanelBuilder != null) { + // We want to show a keyboard panel, but we haven't built one yet. Build it. + updateOrInflateKeyboardPanel(); + } else if (openPanel != null && openPanel != _lastBuiltPanel) { + // The user switched to a different panel. Rebuild the keyboard panel widget. + updateOrInflateKeyboardPanel(); + } else if (openPanel == null && _keyboardPanel != null) { + // We don't want to show a keyboard panel, but we still have a keyboard panel + // child. Throw it away. + deactivateChild(_keyboardPanel!); + _keyboardPanel = null; + _lastBuiltPanel = null; + } + + print("Element - update() - adding listener to new widget controller"); + widget.pageController.addListener(markNeedsBuild); + } + + void updateOrInflateKeyboardPanel() { + final panel = widget.pageController.openPanel; + if (panel == null) { + return; + } + + if (_keyboardPanel == null) { + _keyboardPanel = inflateWidget( + widget.keyboardPanelBuilder!(this, panel), + _keyboardPanelSlot, + ); + } else { + _keyboardPanel = updateChild( + _keyboardPanel, + widget.keyboardPanelBuilder!(this, panel), + _keyboardPanelSlot, + ); + } + _lastBuiltPanel = panel; + } + + @override + Element? updateChild(Element? child, Widget? newWidget, Object? newSlot) { + if (newSlot == _contentSlot) { + // Only rebuild the content during layout because it depends upon bottom + // spacing. Mark needs layout so that we ensure a rebuild happens. + renderObject.markNeedsLayout(); + return null; + } + + return super.updateChild(child, newWidget, newSlot); + } + + @override + void insertRenderObjectChild(RenderObject child, Object? slot) { + print("Element - insertRenderObjectChild - $child, slot: $slot"); + renderObject.insertChild(child, slot!); + } + + @override + void moveRenderObjectChild( + RenderObject child, + Object? oldSlot, + Object? newSlot, + ) { + assert( + child.parent == renderObject, + 'Render object protocol violation - tried to move a render object within a parent that already owns it.', + ); + assert( + oldSlot != null, + 'Render object protocol violation - tried to move a render object with a null oldSlot', + ); + assert( + newSlot != null, + 'Render object protocol violation - tried to move a render object with a null newSlot', + ); + assert( + _isChatScaffoldSlot(oldSlot!), + 'Invalid ChatScaffold child slot: $oldSlot', + ); + assert( + _isChatScaffoldSlot(newSlot!), + 'Invalid ChatScaffold child slot: $newSlot', + ); + assert( + child is RenderBox, + 'Expected RenderBox child but was given: ${child.runtimeType}', + ); + + if (child is! RenderBox) { + return; + } + + if (newSlot == _contentSlot) { + renderObject._content = child; + } else if (newSlot == _bottomSheetSlot) { + renderObject._bottomSheet = child; + } else if (newSlot == _keyboardPanelSlot) { + renderObject._keyboardPanel = child; + } + } + + @override + void removeRenderObjectChild(RenderObject child, Object? slot) { + assert( + child is RenderBox, + 'Invalid child type (${child.runtimeType}), expected RenderBox', + ); + assert( + child.parent == renderObject, + 'Render object protocol violation - tried to remove render object that is not owned by this parent', + ); + assert( + slot != null, + 'Render object protocol violation - tried to remove a render object for a null slot', + ); + assert( + _isChatScaffoldSlot(slot!), + 'Invalid ChatScaffold child slot: $slot', + ); + + renderObject.removeChild(child, slot!); + } + + @override + void visitChildren(ElementVisitor visitor) { + if (_bottomSheet != null) { + visitor(_bottomSheet!); + } + + if (_keyboardPanel != null) { + visitor(_keyboardPanel!); + } + + // Building the `_content` is tricky and we're still not sure how to do it + // correctly. Originally, we refused to visit `_content` when `WidgetsBinding.instance.locked` + // is `true`. The original warning about this was the following: + // + // WARNING: Do not visit content when "locked". If you do, then the pipeline + // owner will collect that child for rebuild, e.g., for hot reload, and the + // pipeline owner will tell it to build before the message editor is laid + // out. We only want the content to build during the layout phase, after the + // message editor is laid out. + // + // However, error stacktraces have been showing up for a while whenever the tree + // structure adds/removes widgets in the tree. One way to see this was to open the + // Flutter debugger and enable the widget selector. This adds the widget selector + // widget to tree, and seems to trigger the bug: + // + // 'package:flutter/src/widgets/framework.dart': Failed assertion: line 6164 pos 14: + // '_dependents.isEmpty': is not true. + // + // This happens because when this `Element` runs `deactivate()`, its super class visits + // all the children to deactivate them, too. When that happens, we're apparently + // locked, so we weren't visiting `_content`. This resulted in an error for any + // `_content` subtree widget that setup an `InheritedWidget` dependency, because + // that dependency didn't have a chance to release. + // + // To deal with deactivation, I tried adding a flag during deactivation so that + // we visit `_content` during deactivation. I then discovered that the visitation + // related to deactivation happens sometime after the call to `deactivate()`. So instead + // of only allowing visitation during `deactivate()`, I tracked whether this `Element` + // was in a deactivated state, and allowed visitation when in a deactivated state. + // + // I then found that there's a similar issue during `activate()`. This also needs to + // recursively activate the subtree `Element`s, sometime after the call to `activate()`. + // Therefore, whether activated or deactivated, we need to allow visitation, but we're + // always either activated or deactivated, so this approach needed to be further adjusted. + // + // Presently, when `activate()` or `deactivate()` runs, a flag is set for each one. + // When either of those flags are `true`, we allow visitation. We reset those flags + // during the building of `_content`, as a way to recognize when the activation or + // deactivation process must be finished. + // + // For reference, when hot restarting or hot reloading if we don't enable visitation + // during activation, we get the following error: + // + // The following assertion was thrown during performLayout(): + // 'package:flutter/src/widgets/framework.dart': Failed assertion: line 4323 pos 7: '_lifecycleState == + // _ElementLifecycle.active && + // newWidget != widget && + // Widget.canUpdate(widget, newWidget)': is not true. + + // FIXME: locked is supposed to be private. We're using it as a proxy + // indication for when the build owner wants to build. Find an + // appropriate way to distinguish this. + // ignore: invalid_use_of_protected_member + if (!WidgetsBinding.instance.locked || !_didActivateSinceLastBuild || !_didDeactivateSinceLastBuild) { + if (_content != null) { + visitor(_content!); + } + } else { + print("NOT ALLOWING CHILD VISITATION!"); + print("StackTrace:\n${StackTrace.current}"); + } + } +} + +/// `RenderObject` for a [FloatingEditorPageScaffold] widget. +/// +/// Must be associated with an `Element` of type [MessagePageElement]. +class _RenderAboveKeyboardPageScaffold extends RenderBox + implements FloatingEditorPageControllerDelegate { + _RenderAboveKeyboardPageScaffold( + this._element, + FloatingEditorPageController pageController, { + required TickerProvider vsync, + required int viewId, + required double bottomSheetMinimumTopGap, + required double bottomSheetMinimumHeight, + required double bottomSheetCollapsedMaximumHeight, + required double mediaQueryBottomInsets, + required double mediaQueryBottomPadding, + double fallbackKeyboardHeight = 250.0, + }) : _viewId = viewId, + _bottomSheetMinimumTopGap = bottomSheetMinimumTopGap, + _bottomSheetMinimumHeight = bottomSheetMinimumHeight, + _bottomSheetCollapsedMaximumHeight = bottomSheetCollapsedMaximumHeight, + _mediaQueryBottomInsets = mediaQueryBottomInsets, + _mediaQueryBottomPadding = mediaQueryBottomPadding, + _fallbackPanelHeight = fallbackKeyboardHeight { + _pageController = pageController..attach(this); + + SuperKeyboard.instance.mobileGeometry.addListener(_onKeyboardHeightChange); + + _panelHeightController = AnimationController( + // TODO: Should we create this AnimationController within a widget so that we don't need to pass a vsync to a render object? + // TODO: If we do keep the vsync here, we need to figure out when in the render object lifecycle we need to pause, stop, and whether the widget needs to be able to provide a new vsync. + vsync: vsync, + duration: const Duration(milliseconds: 250), + )..addListener(() { + print("Panel height change: ${_panelHeightController.value}%"); + markNeedsLayout(); + }); + + _attachToPageController(); + + _updateMaxPanelHeight(); + } + + @override + void dispose() { + _listeners.clear(); + _panelHeightController.dispose(); + _pageController.detach(); + + SuperKeyboard.instance.mobileGeometry.removeListener(_onKeyboardHeightChange); + + _element = null; + super.dispose(); + } + + /// The ID of the current Flutter view, which is needed to open the software keyboard. + int _viewId; + set viewId(int newViewId) { + if (newViewId == _viewId) { + return; + } + + // TODO: What do we need to reset/clear as a result of changing to a different Flutter view? + + _viewId = newViewId; + } + + late Ticker _ticker; + late VelocityTracker _velocityTracker; + late Stopwatch _velocityStopwatch; + late double _expandedHeight; + late double _previewHeight; + late double _intrinsicHeight; + + SpringSimulation? _simulation; + MessagePageSheetMode? _simulationGoalMode; + double? _simulationGoalHeight; + + _AboveKeyboardMessagePageElement? _element; + + BottomSheetMode? _overrideSheetMode; + BottomSheetMode get bottomSheetMode { + if (_overrideSheetMode != null) { + return _overrideSheetMode!; + } + + if (_simulation != null) { + return BottomSheetMode.settling; + } + + if (_pageController.isDragging) { + return BottomSheetMode.dragging; + } + + if (_pageController.isExpanded) { + return BottomSheetMode.expanded; + } + + if (_pageController.isPreview) { + return BottomSheetMode.preview; + } + + return BottomSheetMode.intrinsic; + } + + // ignore: avoid_setters_without_getters + set pageController(FloatingEditorPageController controller) { + if (controller == _pageController) { + return; + } + + _detachFromPageController(); + _pageController = controller; + _attachToPageController(); + } + + late FloatingEditorPageController _pageController; + MessagePageDragMode _currentDragMode = MessagePageDragMode.idle; + double? _currentDesiredGlobalTopY; + double? _desiredDragHeight; + bool _isExpandingOrCollapsing = false; + double _animatedHeight = 300; + double _animatedVelocity = 0; + + void _attachToPageController() { + _currentDragMode = _pageController.dragMode; + _pageController.attach(this); + _pageController.addListener(_onMessagePageControllerChange); + + markNeedsLayout(); + } + + void _onMessagePageControllerChange() { + // We might change the controller in this listener call, so we stop + // listening to the controller during this function. + _pageController.removeListener(_onMessagePageControllerChange); + var didChange = false; + + if (_currentDragMode != _pageController.dragMode) { + switch (_pageController.dragMode) { + case MessagePageDragMode.dragging: + // The user just started dragging. + _onDragStart(); + case MessagePageDragMode.idle: + // The user just stopped dragging. + _onDragEnd(); + } + + _currentDragMode = _pageController.dragMode; + didChange = true; + } + + if (_pageController.dragMode == MessagePageDragMode.dragging && + _currentDesiredGlobalTopY != _pageController.desiredGlobalTopY) { + // TODO: don't invalidate layout if we've reached max height and the Y value went higher + _currentDesiredGlobalTopY = _pageController.desiredGlobalTopY; + + final pageGlobalBottom = localToGlobal(Offset(0, size.height)).dy; + _desiredDragHeight = pageGlobalBottom - max(_currentDesiredGlobalTopY!, _bottomSheetMinimumTopGap); + _expandedHeight = size.height - _bottomSheetMinimumTopGap; + + _velocityTracker.addPosition( + _velocityStopwatch.elapsed, + Offset(0, _currentDesiredGlobalTopY!), + ); + + didChange = true; + } + + if (didChange) { + markNeedsLayout(); + } + + // Restore our listener relationship with our controller now that + // our reaction is finished. + _pageController.addListener(_onMessagePageControllerChange); + } + + void _onDragStart() { + _velocityTracker = VelocityTracker.withKind(PointerDeviceKind.touch); + _velocityStopwatch = Stopwatch()..start(); + } + + void _onDragEnd() { + _velocityStopwatch.stop(); + + final velocity = _velocityTracker.getVelocityEstimate()?.pixelsPerSecond.dy ?? 0; + + _startBottomSheetHeightSimulation(velocity: velocity); + } + + void _startBottomSheetHeightSimulation({ + required double velocity, + }) { + _ticker.stop(); + + final minimizedHeight = switch (_pageController.collapsedMode) { + MessagePageSheetCollapsedMode.preview => _previewHeight, + MessagePageSheetCollapsedMode.intrinsic => min(_intrinsicHeight, _bottomSheetCollapsedMaximumHeight), + }; + + _pageController.desiredSheetMode = velocity.abs() > 500 // + ? velocity < 0 + ? MessagePageSheetMode.expanded + : MessagePageSheetMode.collapsed + : (_expandedHeight - _desiredDragHeight!).abs() < (_desiredDragHeight! - minimizedHeight).abs() + ? MessagePageSheetMode.expanded + : MessagePageSheetMode.collapsed; + + _updateBottomSheetHeightSimulation(velocity: velocity); + } + + /// Replaces a running bottom sheet height simulation with a newly computed + /// simulation based on the current render object metrics. + /// + /// This method can be called even if no `_simulation` currently exists. + /// However, callers must ensure that `_controller.desiredSheetMode` is + /// already set to the desired value. This method doesn't try to alter the + /// desired sheet mode. + void _updateBottomSheetHeightSimulation({ + required double velocity, + }) { + final minimizedHeight = switch (_pageController.collapsedMode) { + MessagePageSheetCollapsedMode.preview => _previewHeight, + MessagePageSheetCollapsedMode.intrinsic => min(_intrinsicHeight, _bottomSheetCollapsedMaximumHeight), + }; + + _pageController.isSliding = true; + + final startHeight = _bottomSheet!.size.height; + _simulationGoalMode = _pageController.desiredSheetMode; + final newSimulationGoalHeight = + _simulationGoalMode! == MessagePageSheetMode.expanded ? _expandedHeight : minimizedHeight; + if ((newSimulationGoalHeight - startHeight).abs() < 1) { + // We're already at the destination. Fizzle. + _animatedHeight = newSimulationGoalHeight; + _animatedVelocity = 0; + _isExpandingOrCollapsing = false; + _desiredDragHeight = null; + _ticker.stop(); + return; + } + if (newSimulationGoalHeight == _simulationGoalHeight) { + // We're already simulating to this height. We short-circuit when the goal + // hasn't changed so that we don't get rapidly oscillating simulation artifacts. + return; + } + _simulationGoalHeight = newSimulationGoalHeight; + _isExpandingOrCollapsing = true; + + _ticker.stop(); + + messagePageLayoutLog.info('Creating expand/collapse simulation:'); + messagePageLayoutLog.info( + ' - Desired sheet mode: ${_pageController.desiredSheetMode}', + ); + messagePageLayoutLog.info(' - Minimized height: $minimizedHeight'); + messagePageLayoutLog.info(' - Expanded height: $_expandedHeight'); + messagePageLayoutLog.info( + ' - Drag height on release: $_desiredDragHeight', + ); + messagePageLayoutLog.info(' - Final height: $_simulationGoalHeight'); + messagePageLayoutLog.info(' - Initial velocity: $velocity'); + _simulation = SpringSimulation( + const SpringDescription( + mass: 1, + stiffness: 500, + damping: 45, + ), + startHeight, // Start value + _simulationGoalHeight!, // End value + // Invert velocity because we measured velocity moving down the screen, but we + // want to apply velocity to the height of the sheet. A positive screen velocity + // corresponds to a negative sheet height velocity. + -velocity, // Initial velocity. + ); + + _ticker.start(); + } + + void _detachFromPageController() { + _pageController.removeListener(_onMessagePageControllerChange); + _pageController.detach(); + + _currentDragMode = MessagePageDragMode.idle; + _desiredDragHeight = null; + _currentDesiredGlobalTopY = null; + } + + //----- START KEYBOARD PANEL DELEGATE IMPLEMENTATION ------ + double get _keyboardHeight { + final keyboardGeometry = SuperKeyboard.instance.mobileGeometry.value; + if (keyboardGeometry.keyboardHeight == null || keyboardGeometry.keyboardState != KeyboardState.open) { + // Defer to standard Flutter MediaQuery value. + return _mediaQueryBottomInsets; + } + + return keyboardGeometry.keyboardHeight!; + } + + double _bestGuessMaxKeyboardHeight = 0.0; + + double _mediaQueryBottomInsets = 0.0; + set mediaQueryBottomInsets(double newInsets) { + if (newInsets == _mediaQueryBottomInsets) { + return; + } + + _mediaQueryBottomInsets = newInsets; + markNeedsLayout(); + } + + double _fallbackPanelHeight = 250.0; + set fallbackPanelHeight(double newHeight) { + _fallbackPanelHeight = newHeight; + } + + double _mediaQueryBottomPadding = 0.0; + set mediaQueryBottomPadding(double newPadding) { + if (newPadding == _mediaQueryBottomPadding) { + return; + } + + _mediaQueryBottomPadding = newPadding; + markNeedsLayout(); + } + + /// The height of the visible panel at this moment. + late final AnimationController _panelHeightController; + late Animation _panelHeight; + + /// The currently visible panel. + PanelType? _activePanel; + + /// Whether the software keyboard should be displayed, instead of the keyboard panel. + bool get wantsToShowSoftwareKeyboard => _wantsToShowSoftwareKeyboard; + bool _wantsToShowSoftwareKeyboard = false; + + @override + bool get isSoftwareKeyboardOpen => _wantsToShowSoftwareKeyboard; + + /// Shows the software keyboard, if it's hidden. + @override + void showSoftwareKeyboard() { + print("showSoftwareKeyboard()"); + // _wantsToShowKeyboardPanel = false; + _wantsToShowSoftwareKeyboard = true; + _pageController.softwareKeyboardController.open(viewId: _viewId); + // Note: We don't animate the panel away because as the panel goes + // down, it drags the bottom sheet down with it, until the + // bottom sheet hits the keyboard as the keyboard comes up. + // Instead, we keep the bottom sheet around until the keyboard + // fully opens. + + // TODO: do we need to mark layout? paint? + + // Notify delegate listeners. + notifyListeners(); + } + + /// Hides (doesn't close) the software keyboard, if it's open. + @override + void hideSoftwareKeyboard() { + _wantsToShowSoftwareKeyboard = false; + _pageController.softwareKeyboardController.hide(); + + // TODO: do we need to mark layout? paint? + + // Notify delegate listeners. + notifyListeners(); + + _maybeAnimatePanelClosed(); + } + + /// Whether a keyboard panel should be displayed instead of the software keyboard. + bool get wantsToShowKeyboardPanel => _wantsToShowKeyboardPanel; + bool _wantsToShowKeyboardPanel = false; + + @override + bool get isKeyboardPanelOpen => _wantsToShowKeyboardPanel; + + @override + PanelType? get openPanel => _activePanel; + + /// Shows the keyboard panel, if it's closed, and hides (doesn't close) the + /// software keyboard, if it's open. + @override + void showKeyboardPanel(PanelType panel) { + print("FloatingEditorPageScaffold - showKeyboardPanel()"); + _wantsToShowKeyboardPanel = true; + _wantsToShowSoftwareKeyboard = false; + _activePanel = panel; + + if (SuperKeyboard.instance.mobileGeometry.value.keyboardState == KeyboardState.open) { + // The keyboard is fully open. We'd like for the panel to immediately + // appear behind the keyboard as it closes, so that we don't have a + // bunch of jumping around for the widgets mounted to the top of the + // keyboard. + print("Jumping panel height to 100%"); + _panelHeightController.value = 1.0; + } else { + print("Animating the panel height"); + _panelHeightController.forward(); + } + + _pageController.softwareKeyboardController.hide(); + + // TODO: Do we need to mark layout? or paint? + + // Notify delegate listeners. + print("Notifying controller listeners"); + notifyListeners(); + } + + @override + void toggleKeyboardPanel(PanelType panel) { + if (_activePanel == panel) { + hideKeyboardPanel(); + } else { + showKeyboardPanel(panel); + } + } + + /// Hides the keyboard panel, if it's open. + @override + void hideKeyboardPanel() { + // Close panel. + print("hideKeyboardPanel()"); + _wantsToShowKeyboardPanel = false; + _activePanel = null; + // Note: We don't animate the panel away because as the panel goes + // down, it drags the bottom sheet down with it, until the + // bottom sheet hits the keyboard as the keyboard comes up. + // Instead, we keep the bottom sheet around until the keyboard + // fully opens. + + // Open the keyboard. + _pageController.softwareKeyboardController.open(viewId: _viewId); + + // TODO: do we need to mark layout? paint? + + // Notify delegate listeners. + notifyListeners(); + } + + /// Closes the software keyboard if it's open, or closes the keyboard panel if + /// it's open, and fully closes the keyboard (IME) connection. + @override + void closeKeyboardAndPanel() { + _wantsToShowKeyboardPanel = false; + _wantsToShowSoftwareKeyboard = false; + _activePanel = null; + _pageController.softwareKeyboardController.close(); + _panelHeightController.reverse(); + + // TODO: do we need to mark layout? paint? + + // Notify delegate listeners. + notifyListeners(); + } + + void _onKeyboardHeightChange() { + print("_onKeyboardHeightChange() - ${SuperKeyboard.instance.mobileGeometry.value.keyboardHeight}"); + _updateMaxPanelHeight(); + + if (_wantsToShowSoftwareKeyboard && + _wantsToShowKeyboardPanel && + SuperKeyboard.instance.mobileGeometry.value.keyboardState == KeyboardState.open) { + // We kept a keyboard panel visible while the keyboard came back up. The + // keyboard is now fully open. Get rid of the keyboard panel. + _wantsToShowKeyboardPanel = false; + notifyListeners(); + } + + // Re-run layout to make sure we account for new keyboard height. + markNeedsLayout(); + } + + void _updateMaxPanelHeight() { + final currentKeyboardHeight = SuperKeyboard.instance.mobileGeometry.value.keyboardHeight ?? 0; + _bestGuessMaxKeyboardHeight = max(currentKeyboardHeight, _bestGuessMaxKeyboardHeight); + + _panelHeight = Tween( + begin: 0.0, + end: _bestGuessMaxKeyboardHeight > 100 ? _bestGuessMaxKeyboardHeight : _fallbackPanelHeight, + ) // + .chain(CurveTween(curve: Curves.easeInOut)) + .animate(_panelHeightController); + } + + void _maybeAnimatePanelClosed() { + final keyboardHeight = SuperKeyboard.instance.mobileGeometry.value.keyboardHeight; + + if (_wantsToShowKeyboardPanel || + _wantsToShowSoftwareKeyboard || + (keyboardHeight != null && keyboardHeight != 0.0)) { + return; + } + + // The user wants to close both the software keyboard and the keyboard panel, + // but the software keyboard is already closed. Animate the keyboard panel height + // down to zero. + _panelHeightController.reverse(from: 1.0); + } + + final _listeners = {}; + + @override + bool get hasListeners => _listeners.isNotEmpty; + + @override + void addListener(VoidCallback listener) => _listeners.add(listener); + + @override + void removeListener(VoidCallback listener) => _listeners.remove(listener); + + @override + void notifyListeners() { + final listeners = Set.from(_listeners); + print("Notifying listeners: $listeners"); + for (final listener in listeners) { + print("Running listener: $listener"); + listener(); + } + } + //----- END KEYBOARD PANEL DELEGATE IMPLEMENTATION ------ + + RenderBox? _content; + + RenderBox? _bottomSheet; + + RenderBox? _keyboardPanel; + + /// The smallest allowable gap between the top of the editor and the top of + /// the screen. + /// + /// If the user drags higher than this point, the editor will remain at a + /// height that preserves this gap. + // ignore: avoid_setters_without_getters + set bottomSheetMinimumTopGap(double newValue) { + if (newValue == _bottomSheetMinimumTopGap) { + return; + } + + _bottomSheetMinimumTopGap = newValue; + + // FIXME: Only invalidate layout if this change impacts the current rendering. + markNeedsLayout(); + } + + double _bottomSheetMinimumTopGap; + + // ignore: avoid_setters_without_getters + set bottomSheetMinimumHeight(double newValue) { + if (newValue == _bottomSheetMinimumHeight) { + return; + } + + _bottomSheetMinimumHeight = newValue; + + // FIXME: Only invalidate layout if this change impacts the current rendering. + markNeedsLayout(); + } + + double _bottomSheetMinimumHeight; + + set bottomSheetMaximumHeight(double newValue) { + if (newValue == _bottomSheetMaximumHeight) { + return; + } + + _bottomSheetMaximumHeight = newValue; + + // FIXME: Only invalidate layout if this change impacts the current rendering. + markNeedsLayout(); + } + + double _bottomSheetMaximumHeight = double.infinity; + + set bottomSheetCollapsedMaximumHeight(double newValue) { + if (newValue == _bottomSheetCollapsedMaximumHeight) { + return; + } + + _bottomSheetCollapsedMaximumHeight = newValue; + + // FIXME: Only invalidate layout if this change impacts the current rendering. + markNeedsLayout(); + } + + double _bottomSheetCollapsedMaximumHeight = double.infinity; + + /// Whether this render object's layout information or its content + /// layout information is dirty. + /// + /// This is set to `true` when `markNeedsLayout` is called and it's + /// set to `false` after laying out the content. + bool get bottomSheetNeedsLayout => _bottomSheetNeedsLayout; + bool _bottomSheetNeedsLayout = true; + + /// Whether we are at the middle of a [performLayout] call. + bool _runningLayout = false; + + @override + void attach(PipelineOwner owner) { + super.attach(owner); + + _ticker = Ticker(_onExpandCollapseTick); + + visitChildren((child) { + child.attach(owner); + }); + } + + void _onExpandCollapseTick(Duration elapsedTime) { + final seconds = elapsedTime.inMilliseconds / 1000; + _animatedHeight = _simulation!.x(seconds).clamp(_bottomSheetMinimumHeight, _bottomSheetMaximumHeight); + _animatedVelocity = _simulation!.dx(seconds); + + if (_simulation!.isDone(seconds)) { + _ticker.stop(); + + _simulation = null; + _simulationGoalMode = null; + _simulationGoalHeight = null; + _animatedVelocity = 0; + + _isExpandingOrCollapsing = false; + _currentDesiredGlobalTopY = null; + _desiredDragHeight = null; + + _pageController.isSliding = false; + } + + markNeedsLayout(); + } + + @override + void detach() { + // IMPORTANT: we must detach ourselves before detaching our children. + // This is a Flutter framework requirement. + super.detach(); + + _ticker.dispose(); + + // Detach our children. + visitChildren((child) { + child.detach(); + }); + } + + @override + void markNeedsLayout() { + super.markNeedsLayout(); + + if (_runningLayout) { + // We are already in a layout phase. When we call + // ChatScaffoldElement.buildLayers, markNeedsLayout is called again. We + // don't want to mark the message editor as dirty, because otherwise the + // content will never build. + return; + } + _bottomSheetNeedsLayout = true; + } + + @override + List debugDescribeChildren() { + final childDiagnostics = []; + + if (_content != null) { + childDiagnostics.add(_content!.toDiagnosticsNode(name: 'content')); + } + if (_bottomSheet != null) { + childDiagnostics.add(_bottomSheet!.toDiagnosticsNode(name: 'message_editor')); + } + if (_keyboardPanel != null) { + childDiagnostics.add(_keyboardPanel!.toDiagnosticsNode(name: 'keyboard_panel')); + } + + return childDiagnostics; + } + + void insertChild(RenderObject child, Object slot) { + assert( + _isChatScaffoldSlot(slot), + 'Render object protocol violation - tried to insert child for invalid slot ($slot)', + ); + + if (slot == _contentSlot) { + _content = child as RenderBox; + } else if (slot == _bottomSheetSlot) { + _bottomSheet = child as RenderBox; + } else if (slot == _keyboardPanelSlot) { + _keyboardPanel = child as RenderBox; + } + + adoptChild(child); + } + + void removeChild(RenderObject child, Object slot) { + assert( + _isChatScaffoldSlot(slot), + 'Render object protocol violation - tried to remove a child for an invalid slot ($slot)', + ); + + if (slot == _contentSlot) { + _content = null; + } else if (slot == _bottomSheetSlot) { + _bottomSheet = null; + } else if (slot == _keyboardPanelSlot) { + _keyboardPanel = null; + } + + dropChild(child); + } + + @override + void visitChildren(RenderObjectVisitor visitor) { + if (_content != null) { + visitor(_content!); + } + if (_bottomSheet != null) { + visitor(_bottomSheet!); + } + if (_keyboardPanel != null) { + visitor(_keyboardPanel!); + } + } + + @override + void performLayout() { + messagePageLayoutLog.info('---------- LAYOUT -------------'); + messagePageLayoutLog.info('Laying out RenderChatScaffold'); + messagePageLayoutLog + .info('Sheet mode: ${_pageController.desiredSheetMode}, collapsed mode: ${_pageController.collapsedMode}'); + if (_content == null) { + size = Size.zero; + _bottomSheetNeedsLayout = false; + return; + } + + _runningLayout = true; + + size = constraints.biggest; + _bottomSheetMaximumHeight = size.height - _bottomSheetMinimumTopGap; + + messagePageLayoutLog.info( + "Measuring the bottom sheet's preview height", + ); + // Do a throw-away layout pass to get the preview height of the bottom + // sheet, bounded within its min/max height. + _overrideSheetMode = BottomSheetMode.preview; + _previewHeight = _bottomSheet!.computeDryLayout(constraints.copyWith(minHeight: 0)).height; + + // Switch back to a real layout pass. + _overrideSheetMode = null; + messagePageLayoutLog.info( + ' - Bottom sheet bounded preview height: $_previewHeight, min height: $_bottomSheetMinimumHeight, max height: $_bottomSheetMaximumHeight', + ); + + messagePageLayoutLog.info( + "Measuring the bottom sheet's intrinsic height", + ); + // Do a throw-away layout pass to get the intrinsic height of the bottom sheet. + _intrinsicHeight = _calculateBoundedIntrinsicHeight( + constraints.copyWith(minHeight: 0), + ); + messagePageLayoutLog.info( + ' - Bottom sheet bounded intrinsic height: $_intrinsicHeight, min height: $_bottomSheetMinimumHeight, max height: $_bottomSheetMaximumHeight', + ); + + final isDragging = !_isExpandingOrCollapsing && _desiredDragHeight != null; + + final minimizedHeight = switch (_pageController.collapsedMode) { + MessagePageSheetCollapsedMode.preview => _previewHeight, + MessagePageSheetCollapsedMode.intrinsic => _intrinsicHeight, + }; + + // Max height depends on whether we're collapsed or expanded. + final bottomSheetConstraints = constraints.copyWith( + minHeight: minimizedHeight, + maxHeight: _bottomSheetMaximumHeight, + ); + + if (_isExpandingOrCollapsing) { + messagePageLayoutLog.info('>>>>>>>> Expanding or collapsing animation'); + // We may have started animating with the keyboard up and since then it + // has closed, or vis-a-versa. Check for any changes in our destination + // height. If it's changed, recreate the simulation to stop at the new + // destination. + final currentDestinationHeight = switch (_simulationGoalMode!) { + MessagePageSheetMode.collapsed => switch (_pageController.collapsedMode) { + MessagePageSheetCollapsedMode.preview => _previewHeight, + MessagePageSheetCollapsedMode.intrinsic => _intrinsicHeight, + }, + MessagePageSheetMode.expanded => _bottomSheetMaximumHeight, + }; + if (currentDestinationHeight != _simulationGoalHeight) { + // A simulation is running. It's destination height no longer matches + // the destination height that we want. Update the simulation with newly + // computed metrics. + _updateBottomSheetHeightSimulation(velocity: _animatedVelocity); + } + + final minimumHeight = min( + _pageController.collapsedMode == MessagePageSheetCollapsedMode.preview ? _previewHeight : _intrinsicHeight, + _bottomSheetCollapsedMaximumHeight); + final animatedHeight = _animatedHeight.clamp(minimumHeight, _bottomSheetMaximumHeight); + _bottomSheet!.layout( + bottomSheetConstraints.copyWith( + minHeight: max(animatedHeight - 1, 0), + // ^ prevent a layout boundary + maxHeight: animatedHeight, + ), + parentUsesSize: true, + ); + } else if (isDragging) { + messagePageLayoutLog.info('>>>>>>>> User dragging'); + messagePageLayoutLog.info( + ' - drag height: $_desiredDragHeight, minimized height: $minimizedHeight', + ); + final minimumHeight = min(minimizedHeight, _bottomSheetCollapsedMaximumHeight); + final strictHeight = _desiredDragHeight!.clamp(minimumHeight, _bottomSheetMaximumHeight); + + messagePageLayoutLog.info(' - bounded drag height: $strictHeight'); + _bottomSheet!.layout( + bottomSheetConstraints.copyWith( + minHeight: max(strictHeight - 1, 0), + // ^ prevent layout boundary + maxHeight: strictHeight, + ), + parentUsesSize: true, + ); + } else if (_pageController.desiredSheetMode == MessagePageSheetMode.expanded) { + messagePageLayoutLog.info('>>>>>>>> Stationary expanded'); + messagePageLayoutLog.info( + 'Running layout and forcing editor height to the max: $_expandedHeight', + ); + + _bottomSheet!.layout( + bottomSheetConstraints.copyWith( + minHeight: max(_expandedHeight - 1, 0), + // ^ Prevent a layout boundary. + maxHeight: _expandedHeight, + ), + parentUsesSize: true, + ); + } else { + messagePageLayoutLog.info('>>>>>>>> Minimized'); + messagePageLayoutLog.info('Running standard editor layout with constraints: $bottomSheetConstraints'); + _bottomSheet!.layout( + // bottomSheetConstraints, + bottomSheetConstraints.copyWith( + minHeight: 0, + maxHeight: _bottomSheetCollapsedMaximumHeight, + ), + parentUsesSize: true, + ); + } + + final keyboardOrPanelHeight = max(max(_panelHeight.value, _keyboardHeight), _mediaQueryBottomPadding); + (_bottomSheet!.parentData! as BoxParentData).offset = + Offset(0, size.height - _bottomSheet!.size.height - keyboardOrPanelHeight); + _bottomSheetNeedsLayout = false; + messagePageLayoutLog.info('Bottom sheet height: ${_bottomSheet!.size.height}'); + + // Now that we know the size of the message editor, build the content based + // on the bottom spacing needed to push above the editor. + messagePageLayoutLog.info(''); + messagePageLayoutLog.info('Building chat scaffold content'); + invokeLayoutCallback((constraints) { + _element!.buildContent(FloatingEditorPageGeometry( + keyboardHeight: _keyboardHeight, + panelHeight: _panelHeight.value, + bottomViewPadding: _mediaQueryBottomPadding, + bottomSheetHeight: _bottomSheet!.size.height, + )); + }); + messagePageLayoutLog.info('Laying out chat scaffold content'); + _content!.layout(constraints, parentUsesSize: true); + messagePageLayoutLog.info('Content layout size: ${_content!.size}'); + + // (Maybe) Layout a keyboard panel, which appears where the keyboard would be. + if (isKeyboardPanelOpen && _keyboardPanel != null) { + print("Laying out keyboard panel render object"); + _keyboardPanel!.layout( + constraints.copyWith( + minHeight: _panelHeight.value, + maxHeight: _panelHeight.value, + ), + parentUsesSize: true, + ); + + (_keyboardPanel!.parentData as BoxParentData).offset = Offset(0, size.height - _keyboardPanel!.size.height); + } + + _runningLayout = false; + messagePageLayoutLog.info('Done laying out RenderChatScaffold'); + messagePageLayoutLog.info('---------- END LAYOUT ---------'); + } + + double _calculateBoundedIntrinsicHeight(BoxConstraints constraints) { + messagePageLayoutLog.info('Running dry layout on bottom sheet content to find the intrinsic height...'); + messagePageLayoutLog.info(' - Bottom sheet constraints: $constraints'); + messagePageLayoutLog.info(' - Controller desired sheet mode: ${_pageController.collapsedMode}'); + _overrideSheetMode = BottomSheetMode.intrinsic; + messagePageLayoutLog.info(' - Override sheet mode: $_overrideSheetMode'); + + final bottomSheetHeight = _bottomSheet! + .computeDryLayout( + constraints.copyWith(minHeight: 0, maxHeight: double.infinity), + ) + .height; + + _overrideSheetMode = null; + messagePageLayoutLog.info(" - Child's self-chosen height is: $bottomSheetHeight"); + messagePageLayoutLog.info( + " - Clamping child's height within [$_bottomSheetMinimumHeight, $_bottomSheetMaximumHeight]", + ); + + final boundedIntrinsicHeight = bottomSheetHeight.clamp( + _bottomSheetMinimumHeight, + _bottomSheetMaximumHeight, + ); + messagePageLayoutLog.info( + ' - Bottom sheet intrinsic bounded height: $boundedIntrinsicHeight', + ); + return boundedIntrinsicHeight; + } + + @override + bool hitTestChildren( + BoxHitTestResult result, { + required Offset position, + }) { + // First, hit-test the message editor, which sits on top of the + // content. + if (_bottomSheet != null) { + final childParentData = _bottomSheet!.parentData! as BoxParentData; + + final didHit = result.addWithPaintOffset( + offset: childParentData.offset, + position: position, + hitTest: (BoxHitTestResult result, Offset transformed) { + return _bottomSheet!.hitTest(result, position: transformed); + }, + ); + + if (didHit) { + return true; + } + } + + // Second, (maybe) hit-test the keyboard panel, which sits on top of the page content. + if (_keyboardPanel != null) { + final childParentData = _keyboardPanel!.parentData! as BoxParentData; + + final didHit = result.addWithPaintOffset( + offset: childParentData.offset, + position: position, + hitTest: (BoxHitTestResult result, Offset transformed) { + return _keyboardPanel!.hitTest(result, position: transformed); + }, + ); + + if (didHit) { + return true; + } + } + + // Third, hit-test the content, which sits beneath the message + // editor. + if (_content != null) { + final didHit = _content!.hitTest(result, position: position); + if (didHit) { + // NOTE: I'm not sure if we're supposed to report ourselves when a child + // is hit, or if just the child does that. + result.add(BoxHitTestEntry(this, position)); + return true; + } + } + + return false; + } + + @override + void paint(PaintingContext context, Offset offset) { + messagePagePaintLog.info('---------- PAINT ------------'); + if (_content != null) { + messagePagePaintLog.info('Painting content'); + context.paintChild(_content!, offset); + } + + if (_bottomSheet != null) { + final bottomSheetOffset = (_bottomSheet!.parentData! as BoxParentData).offset; + messagePagePaintLog.info('Painting message editor - y-offset: $bottomSheetOffset'); + context.paintChild( + _bottomSheet!, + offset + bottomSheetOffset, + ); + } + + if (_keyboardPanel != null) { + print("Painting keyboard panel"); + final keyboardPanelOffset = (_keyboardPanel!.parentData! as BoxParentData).offset; + messagePagePaintLog.info('Painting keyboard panel - y-offset: $keyboardPanelOffset'); + context.paintChild(_keyboardPanel!, offset + keyboardPanelOffset); + } + messagePagePaintLog.info('---------- END PAINT ------------'); + } + + @override + void setupParentData(covariant RenderObject child) { + child.parentData = BoxParentData(); + } +} + +class FloatingEditorPageGeometry { + static const zero = FloatingEditorPageGeometry( + keyboardHeight: 0, + panelHeight: 0, + bottomViewPadding: 0, + bottomSheetHeight: 0, + ); + + const FloatingEditorPageGeometry({ + required this.keyboardHeight, + required this.panelHeight, + required this.bottomViewPadding, + required this.bottomSheetHeight, + }); + + final double keyboardHeight; + final double panelHeight; + final double bottomViewPadding; + final double bottomSheetHeight; + + /// Space at the bottom of the page that's obscured by some combination of operating + /// system controls, the keyboard, a keyboard panel, and the floating editor bottom sheet. + double get bottomSafeArea => max(keyboardOrPanelHeight, bottomViewPadding) + bottomSheetHeight; + + /// The height of the software keyboard, or the keyboard panel, whichever is currently + /// taller. + /// + /// Typically, either the software keyboard is open, or a panel is open, not both. However, + /// when one switches to the other, one animates down while the other animates up, which + /// results in both heights being greater than zero for a period of time. + double get keyboardOrPanelHeight => max(keyboardHeight, panelHeight); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is FloatingEditorPageGeometry && + runtimeType == other.runtimeType && + keyboardHeight == other.keyboardHeight && + panelHeight == other.panelHeight && + bottomViewPadding == other.bottomViewPadding && + bottomSheetHeight == other.bottomSheetHeight; + + @override + int get hashCode => + keyboardHeight.hashCode ^ panelHeight.hashCode ^ bottomViewPadding.hashCode ^ bottomSheetHeight.hashCode; +} + +bool _isChatScaffoldSlot(Object slot) => slot == _contentSlot || slot == _bottomSheetSlot || slot == _keyboardPanelSlot; + +const _contentSlot = 'content'; +const _bottomSheetSlot = 'bottom_sheet'; +const _keyboardPanelSlot = 'keyboard_panel'; + +/// A marker widget that wraps the outermost boundary of the bottom sheet in a +/// [FloatingEditorPageScaffold]. +/// +/// This widget can be accessed by descendants for the purpose of querying the size +/// and global position of the floating sheet. This is useful, for example, when +/// implementing drag behaviors to expand/collapse the bottom sheet. The part of the +/// widget tree that contains the drag handle may not have access to the overall sheet. +class FloatingChatBottomSheet extends StatefulWidget { + static BuildContext of(BuildContext context) => + context.findAncestorStateOfType<_FloatingChatBottomSheetState>()!._sheetKey.currentContext!; + + static BuildContext? maybeOf(BuildContext context) => + context.findAncestorStateOfType<_FloatingChatBottomSheetState>()?._sheetKey.currentContext; + + const FloatingChatBottomSheet({ + super.key, + required this.child, + }); + + final Widget child; + + @override + State createState() => _FloatingChatBottomSheetState(); +} + +class _FloatingChatBottomSheetState extends State { + final _sheetKey = GlobalKey(debugLabel: "FloatingChatBottomSheet"); + + @override + Widget build(BuildContext context) { + return KeyedSubtree(key: _sheetKey, child: widget.child); + } +} diff --git a/super_editor/lib/src/chat/bottom_floating/ui_kit/floating_editor_sheet.dart b/super_editor/lib/src/chat/bottom_floating/ui_kit/floating_editor_sheet.dart new file mode 100644 index 0000000000..ddfb75706c --- /dev/null +++ b/super_editor/lib/src/chat/bottom_floating/ui_kit/floating_editor_sheet.dart @@ -0,0 +1,521 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:super_editor/super_editor.dart'; + +/// The whole bottom sheet for the floating editor demo, which includes a [FloatingShadowSheet] with +/// a banner, which then contains an [FloatingEditorSheet], which has a drag handle, an +/// editor, and a toolbar. +class BottomFloatingChatSheet extends StatefulWidget { + const BottomFloatingChatSheet({ + super.key, + required this.messagePageController, + this.shadowSheetBanner, + required this.editorSheet, + this.style = const FloatingEditorStyle(), + }); + + final MessagePageController messagePageController; + + final Widget? shadowSheetBanner; + + final Widget editorSheet; + + final FloatingEditorStyle style; + + @override + State createState() => _BottomFloatingChatSheetState(); +} + +class _BottomFloatingChatSheetState extends State { + @override + Widget build(BuildContext context) { + return Padding( + padding: widget.style.margin, + child: FloatingShadowSheet( + banner: widget.shadowSheetBanner, + editorSheet: widget.editorSheet, + style: widget.style.shadowSheet, + ), + ); + } +} + +class FloatingEditorStyle { + const FloatingEditorStyle({ + this.margin = const EdgeInsets.symmetric(horizontal: 12, vertical: 16), + this.borderRadius = const Radius.circular(28), + this.collapsedMinimumHeight = 0, + // TODO: Remove keyboard height from any of our calculations, which should reduce this number to something closer to 250 or 300. + this.collapsedMaximumHeight = 650, + this.shadowSheetBackground = Colors.grey, + this.shadowSheetPadding = const EdgeInsets.only(left: 20, right: 20, top: 12, bottom: 8), + this.shadow = const FloatingEditorShadow(), + this.editorSheetBackground = Colors.white, + }); + + final EdgeInsets margin; + final Radius borderRadius; + + /// The shortest that the sheet can be, even if the intrinsic height of the content + /// within the sheet is shorter than this. + final double collapsedMinimumHeight; + + /// The maximum height the bottom sheet can grow, as the user enters more lines of content, + /// before it stops growing and starts scrolling. + /// + /// This height applies to the sheet when its "collapsed", i.e., when it's not "expanded". The + /// sheet includes an "expanded" mode, which is typically triggered by the user dragging the + /// sheet up. When expanded, the sheet always takes up all available vertical space. When + /// not expanded, this height is as tall as the sheet can grow. + final double collapsedMaximumHeight; + + final Color shadowSheetBackground; + final EdgeInsets shadowSheetPadding; + final FloatingEditorShadow shadow; + + final Color editorSheetBackground; + + EditorSheetStyle get editorSheet => EditorSheetStyle( + borderRadius: borderRadius, + background: editorSheetBackground, + ); + + FloatingShadowSheetStyle get shadowSheet => FloatingShadowSheetStyle( + borderRadius: borderRadius, + background: shadowSheetBackground, + padding: shadowSheetPadding, + shadow: shadow, + ); +} + +/// Shadow configuration for a [BottomFloatingChatSheet]. +/// +/// This configuration is a custom selection of properties because with the way that +/// the bottom sheet is clipped, a shadow with any y-offset will look buggy. Therefore, +/// we can't allow for a typical `BoxShadow` or other shadow configuration. We can only +/// support a color and a blur amount. +class FloatingEditorShadow { + const FloatingEditorShadow({ + this.color = const Color(0x33000000), + this.blur = 12, + }); + + final Color color; + final double blur; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is FloatingEditorShadow && runtimeType == other.runtimeType && color == other.color && blur == other.blur; + + @override + int get hashCode => color.hashCode ^ blur.hashCode; +} + +/// A superellipse sheet which has a [banner] at the top and an [editorSheet] below that. +/// +/// This widget paints a superellipse background around the [banner] and the [editorSheet], but +/// it then cuts out another superellipse around the [editorSheet]. The final effect is as if the +/// [banner] is popping up from behind the [editorSheet], but in such a way that the [editorSheet] +/// can be translucent, showing what's behind it. +class FloatingShadowSheet extends SlottedMultiChildRenderObjectWidget { + const FloatingShadowSheet({ + super.key, + this.banner, + required this.editorSheet, + this.style = const FloatingShadowSheetStyle(), + }); + + /// The banner that's displayed within the shadow sheet, just above the [editorSheet]. + final Widget? banner; + + /// A floating editor sheet that sits on top of this shadow sheet, aligned a the bottom. + final Widget editorSheet; + + /// The visual style of this shadow sheet. + final FloatingShadowSheetStyle style; + + @override + Iterable get slots => ShadowSheetSlot.values; + + @override + Widget? childForSlot(ShadowSheetSlot slot) { + switch (slot) { + case ShadowSheetSlot.banner: + return banner; + case ShadowSheetSlot.editorSheet: + return editorSheet; + } + } + + @override + RenderShadowSheet createRenderObject(BuildContext context) { + return RenderShadowSheet(style: style); + } + + @override + void updateRenderObject(BuildContext context, RenderShadowSheet renderObject) { + renderObject.style = style; + } +} + +class FloatingShadowSheetStyle { + const FloatingShadowSheetStyle({ + this.background = Colors.grey, + this.padding = const EdgeInsets.only(left: 20, right: 20, top: 12, bottom: 8), + this.borderRadius = const Radius.circular(28), + this.shadow = const FloatingEditorShadow(), + }); + + final Color background; + final EdgeInsets padding; + final Radius borderRadius; + final FloatingEditorShadow shadow; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is FloatingShadowSheetStyle && + runtimeType == other.runtimeType && + background == other.background && + padding == other.padding && + borderRadius == other.borderRadius && + shadow == other.shadow; + + @override + int get hashCode => background.hashCode ^ padding.hashCode ^ borderRadius.hashCode ^ shadow.hashCode; +} + +enum ShadowSheetSlot { + banner, + editorSheet; +} + +class RenderShadowSheet extends RenderBox with SlottedContainerRenderObjectMixin { + RenderShadowSheet({ + required FloatingShadowSheetStyle style, + }) : _style = style; + + FloatingShadowSheetStyle _style; + set style(FloatingShadowSheetStyle newStyle) { + if (newStyle == _style) { + return; + } + + _style = newStyle; + markNeedsLayout(); + } + + @override + Size computeDryLayout(BoxConstraints constraints) { + final editorSheet = childForSlot(ShadowSheetSlot.editorSheet)!; + final editorSheetSize = editorSheet.computeDryLayout(constraints); + + final banner = childForSlot(ShadowSheetSlot.banner); + // We force the banner to be the same width as the editor sheet. + final bannerContentWidth = editorSheetSize.width - _style.padding.horizontal; + final bannerSize = banner?.computeDryLayout( + constraints.copyWith(minWidth: bannerContentWidth, maxWidth: bannerContentWidth), + ) ?? + Size.zero; + final bannerAndPadding = banner != null + ? Size(bannerSize.width + _style.padding.horizontal, bannerSize.height + _style.padding.vertical) + : Size.zero; + + return Size(editorSheetSize.width, bannerAndPadding.height + editorSheetSize.height); + } + + @override + bool hitTestSelf(Offset position) => false; + + @override + bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { + for (final slot in ShadowSheetSlot.values) { + final child = childForSlot(slot); + if (child == null) { + continue; + } + + final childParentData = child.parentData as BoxParentData; + final isHit = result.addWithPaintOffset( + offset: childParentData.offset, + position: position, + hitTest: (BoxHitTestResult result, Offset transformed) { + return child.hitTest(result, position: transformed); + }, + ); + + if (isHit) { + return true; // stop if a child was hit + } + } + + return false; + } + + @override + void performLayout() { + final editorSheet = childForSlot(ShadowSheetSlot.editorSheet)!; + editorSheet.layout(constraints, parentUsesSize: true); + var editorSheetSize = editorSheet.size; + + final banner = childForSlot(ShadowSheetSlot.banner); + // We force the banner to be the same width as the editor sheet. + final bannerContentWidth = editorSheetSize.width - _style.padding.horizontal; + banner?.layout( + constraints.copyWith(minWidth: bannerContentWidth, maxWidth: bannerContentWidth), + parentUsesSize: true, + ); + final bannerSize = banner?.size ?? Size.zero; + final bannerAndPaddingHeight = banner != null ? bannerSize.height + _style.padding.vertical : 0.0; + + // If the banner + editor ended up being taller than allowed, re-layout the + // editor, forcing it to be shorter. + if (bannerAndPaddingHeight + editorSheetSize.height > constraints.maxHeight) { + editorSheet.layout( + constraints.copyWith(maxHeight: constraints.maxHeight - bannerAndPaddingHeight), + parentUsesSize: true, + ); + editorSheetSize = editorSheet.size; + } + + // Show the banner at the top, with the editor sheet below it. + banner?.parentData = BoxParentData()..offset = Offset(_style.padding.left, _style.padding.top); + editorSheet.parentData = BoxParentData()..offset = Offset(0, bannerAndPaddingHeight); + + // The editor sheet determines our width - we don't want to expand the editor + // sheet to fit a wide banner width. + size = Size(editorSheetSize.width, bannerAndPaddingHeight + editorSheetSize.height); + } + + @override + void paint(PaintingContext context, Offset offset) { + final banner = childForSlot(ShadowSheetSlot.banner); + final editorSheet = childForSlot(ShadowSheetSlot.editorSheet)!; + + final editorSheetOffset = offset + (editorSheet.parentData as BoxParentData).offset; + final editorSheetBoundary = Path() + ..addRSuperellipse( + RSuperellipse.fromLTRBR( + editorSheetOffset.dx, + editorSheetOffset.dy, + editorSheetOffset.dx + size.width, + editorSheetOffset.dy + editorSheet.size.height, + _style.borderRadius, + ), + ); + + // Shadow sheet includes the banner and the editor sheet. + final shadowSheetBoundary = RSuperellipse.fromLTRBR( + offset.dx, + offset.dy, + offset.dx + size.width, + offset.dy + size.height, + _style.borderRadius, + ); + final shadowSheetBoundaryPath = Path()..addRSuperellipse(shadowSheetBoundary); + + // Paint the shadow for the entire shadow sheet. + // final shadowPaint = Paint() + // ..color = Colors.black.withValues(alpha: 0.2) + // ..maskFilter = MaskFilter.blur(BlurStyle.normal, 12); + final shadowPaint = Paint() + ..color = _style.shadow.color + // Note: We use a normal blur instead of an outer blur because we have to + // clip the shadow no matter what. If we do an outer blur without clipping + // the shadow then any backdrop blur that the editor sheet applies will pull + // in a little bit of that surrounding shadow, giving a beveled or shaded edge + // look that we don't want. + ..maskFilter = MaskFilter.blur(BlurStyle.normal, _style.shadow.blur); + + context.canvas.saveLayer(null, Paint()); + context.canvas + ..save() + ..drawPath(shadowSheetBoundaryPath, shadowPaint); + context.canvas.restore(); + + // Cut the sheet out of the shadow, so the sheet can be translucent without + // showing an ugly shadow behind it. + final clearPaint = Paint()..blendMode = BlendMode.dstOut; + context.canvas.drawPath(shadowSheetBoundaryPath, clearPaint); + + // Paint the shadow sheet and the banner at the top of the shadow sheet. + // + // This also requires cutting the editor sheet out of the shadow sheet, to + // support translucent editor sheets. + if (banner != null) { + final hollowShadowSheet = Path.combine(PathOperation.xor, shadowSheetBoundaryPath, editorSheetBoundary); + context.canvas.drawPath(hollowShadowSheet, Paint()..color = _style.background); + + // Clip the banner at the shadow sheet boundary. + context.canvas + ..save() + ..clipRSuperellipse(shadowSheetBoundary); + + banner.paint(context, offset + (banner.parentData as BoxParentData).offset); + + // Get rid of the banner clip. + context.canvas.restore(); + } + + // Clip any part of the editor sheet outside the expected superellipse shape. + // + // We do this by pushing a clip layer because there's a high likelihood that the editor + // sheet blurs its backdrop, which can only be clipped by pushing a clip path. + final clipLayer = (layer as ClipPathLayer? ?? ClipPathLayer()) + ..clipPath = editorSheetBoundary + ..clipBehavior = Clip.hardEdge; + layer = clipLayer; + + context.pushLayer(clipLayer, (clippedContext, clippedOffset) { + // Paint the editor sheet. + editorSheet.paint(clippedContext, clippedOffset); + }, editorSheetOffset); + } +} + +/// A super ellipse sheet, which contains a drag handle, editor, and toolbar. +class FloatingEditorSheet extends StatefulWidget { + const FloatingEditorSheet({ + super.key, + this.sheetKey, + required this.messagePageController, + required this.editor, + this.style = const EditorSheetStyle(), + }); + + /// A [GlobalKey] that's attached to the outermost boundary of the sheet that + /// contains this [FloatingEditorSheet]. + /// + /// In the typical case, [FloatingEditorSheet] is the outermost boundary, in which case + /// no key needs to be provided. This widget will create a key internally. + /// + /// However, if additional content is added above or below this [FloatingEditorSheet] then + /// we need to be able to account for the global offset of that content. To make layout + /// decisions based on the entire sheet, clients must wrap the whole sheet with a [GlobalKey] + /// and provide that key as [sheetKey]. + final GlobalKey? sheetKey; + + final MessagePageController messagePageController; + + final Widget editor; + + final EditorSheetStyle style; + + @override + State createState() => _FloatingEditorSheetState(); +} + +class _FloatingEditorSheetState extends State { + // final _dragIndicatorKey = GlobalKey(); + + final _scrollController = ScrollController(); + + final _editorFocusNode = FocusNode(); + late GlobalKey _sheetKey; + final _editorSheetKey = GlobalKey(debugLabel: "editor-sheet-within-bigger-sheet"); + // late final Editor _editor; + // late final SoftwareKeyboardController _softwareKeyboardController; + + // final _hasSelection = ValueNotifier(false); + + bool _isUserPressingDown = false; + + @override + void initState() { + super.initState(); + + // _softwareKeyboardController = SoftwareKeyboardController(); + + _sheetKey = widget.sheetKey ?? GlobalKey(); + + // _editor = createDefaultDocumentEditor( + // document: MutableDocument.empty(), + // composer: MutableDocumentComposer(), + // ); + // _editor.composer.selectionNotifier.addListener(_onSelectionChange); + } + + @override + void didUpdateWidget(FloatingEditorSheet oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.sheetKey != _sheetKey) { + _sheetKey = widget.sheetKey ?? GlobalKey(); + } + } + + @override + void dispose() { + // _editor.composer.selectionNotifier.removeListener(_onSelectionChange); + // _editor.dispose(); + + _editorFocusNode.dispose(); + + _scrollController.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return KeyedSubtree( + // If we're provided with a [widget.sheetKey] it means the full sheet boundary + // expands beyond this widget, and that key is attached to that outer boundary. + // If we're not provided with a [widget.sheetKey], it's because we are the outer + // boundary, so we need to key our subtree for layout calculations. + key: widget.sheetKey == null ? _sheetKey : _editorSheetKey, + child: Listener( + onPointerDown: (_) => setState(() { + _isUserPressingDown = true; + }), + onPointerUp: (_) => setState(() { + _isUserPressingDown = false; + }), + onPointerCancel: (_) => setState(() { + _isUserPressingDown = false; + }), + child: ClipRSuperellipse( + borderRadius: BorderRadius.all(widget.style.borderRadius), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 4, sigmaY: 4, tileMode: TileMode.decal), + child: Container( + decoration: ShapeDecoration( + color: widget.style.background.withValues(alpha: _isUserPressingDown ? 1.0 : 0.8), + shape: RoundedSuperellipseBorder( + borderRadius: BorderRadius.all(widget.style.borderRadius), + ), + ), + child: KeyboardScaffoldSafeArea( + child: widget.editor, + ), + ), + ), + ), + ), + ); + } +} + +class EditorSheetStyle { + const EditorSheetStyle({ + this.background = Colors.white, + this.borderRadius = const Radius.circular(28), + }); + + final Color background; + final Radius borderRadius; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is EditorSheetStyle && + runtimeType == other.runtimeType && + background == other.background && + borderRadius == other.borderRadius; + + @override + int get hashCode => background.hashCode ^ borderRadius.hashCode; +} diff --git a/super_editor/lib/src/chat/chat_editor.dart b/super_editor/lib/src/chat/chat_editor.dart new file mode 100644 index 0000000000..ab4f78a028 --- /dev/null +++ b/super_editor/lib/src/chat/chat_editor.dart @@ -0,0 +1,474 @@ +import 'package:flutter/material.dart'; +import 'package:super_editor/src/chat/message_page_scaffold.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_selection.dart'; +import 'package:super_editor/src/core/editor.dart'; +import 'package:super_editor/src/core/styles.dart'; +import 'package:super_editor/src/default_editor/document_ime/document_input_ime.dart'; +import 'package:super_editor/src/default_editor/layout_single_column/super_editor_dry_layout.dart'; +import 'package:super_editor/src/default_editor/paragraph.dart'; +import 'package:super_editor/src/default_editor/super_editor.dart'; +import 'package:super_editor/src/default_editor/text.dart'; +import 'package:super_editor/src/infrastructure/keyboard_panel_scaffold.dart'; +import 'package:super_keyboard/super_keyboard.dart'; + +/// An editor for composing chat messages. +/// +/// This widget is a composition around a [SuperEditor], which is configured for typical +/// chat use-cases, such as smaller text, and less padding between blocks. +// TODO: This widget probably shouldn't include the keyboard panel scaffold - that's a separate decision. +// TODO: This widget probably shouldn't require a messagePageController - maybe wrap this widget in another widget for that. +// Maybe: +// - BottomMountedEditorFrame( +// messagePageController: //... +// scrollController: //... +// child: KeyboardPanelsEditorFrame( +// softwareKeyboardController: //... +// child: SuperChatEditor( +// scrollController: //... +// softwareKeyboardController: //... +// ), +// ), +// ); +class SuperChatEditor extends StatefulWidget { + const SuperChatEditor({ + super.key, + this.editorFocusNode, + required this.editor, + required this.pageController, + this.scrollController, + this.softwareKeyboardController, + this.isImeConnected, + }); + + /// Optional [FocusNode], which is attached to the internal [SuperEditor]. + final FocusNode? editorFocusNode; + + /// The logical [Editor] for the user's message. + /// + /// As the user types text and styles it, that message is updated within this [Editor]. To access + /// the user's message outside of this widget, query [editor.document]. + final Editor editor; + + /// The [MessagePageController] that controls the message page scaffold around this editor and its + /// bottom sheet. + /// + /// [SuperChatEditor] requires a [MessagePageController] to monitor when the message page scaffold goes into + /// and out of "preview" mode. For example, whenever we're in "preview" mode, the internal [SuperEditor] is + /// forced to scroll to the top and stay there. + final MessagePageController pageController; + + /// The scroll controller attached to the internal [SuperEditor]. + /// + /// When provided, this [scrollController] is given to the [SuperEditor], to share + /// control inside and outside of this widget. + /// + /// When not provided, a [ScrollController] is created internally and given to the [SuperEditor]. + final ScrollController? scrollController; + + /// The [SoftwareKeyboardController] used by the [SuperEditor] to interact with the + /// operating system's IME. + /// + /// When provided, this [softwareKeyboardController] is given to the [SuperEditor], to + /// share control inside and outside of this widget. + /// + /// When not provided, a [SoftwareKeyboardController] is created internally and given to the [SuperEditor]. + final SoftwareKeyboardController? softwareKeyboardController; + + /// Shared knowledge about whether the IME is currently connected to Super Editor - Super Editor + /// sets this value, and other clients can read it. + final ValueNotifier? isImeConnected; + + @override + State> createState() => _SuperChatEditorState(); +} + +class _SuperChatEditorState extends State> { + final _editorKey = GlobalKey(); + late FocusNode _editorFocusNode; + + late ScrollController _scrollController; + // late KeyboardPanelController _keyboardPanelController; + late ValueNotifier _isImeConnected; + + @override + void initState() { + print("Initializing new chat editor..."); + super.initState(); + + _editorFocusNode = widget.editorFocusNode ?? FocusNode(); + + _scrollController = widget.scrollController ?? ScrollController(); + + // _keyboardPanelController = KeyboardPanelController( + // widget.softwareKeyboardController ?? SoftwareKeyboardController(), + // ); + + widget.pageController.addListener(_onPageControllerChange); + + _isImeConnected = (widget.isImeConnected ?? ValueNotifier(false)) // + ..addListener(_onImeConnectionChange); + + SuperKeyboard.instance.mobileGeometry.addListener(_onKeyboardChange); + } + + @override + void didUpdateWidget(SuperChatEditor oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.editorFocusNode != oldWidget.editorFocusNode) { + if (oldWidget.editorFocusNode == null) { + _editorFocusNode.dispose(); + } + + _editorFocusNode = widget.editorFocusNode ?? FocusNode(); + } + + if (widget.scrollController != oldWidget.scrollController) { + if (oldWidget.scrollController == null) { + _scrollController.dispose(); + } + _scrollController = widget.scrollController ?? ScrollController(); + } + + if (widget.pageController != oldWidget.pageController) { + oldWidget.pageController.removeListener(_onPageControllerChange); + widget.pageController.addListener(_onPageControllerChange); + } + + // if (widget.softwareKeyboardController != oldWidget.softwareKeyboardController) { + // _keyboardPanelController.dispose(); + // _keyboardPanelController = KeyboardPanelController( + // widget.softwareKeyboardController ?? SoftwareKeyboardController(), + // ); + // } + // + if (widget.isImeConnected != oldWidget.isImeConnected) { + _isImeConnected.removeListener(_onImeConnectionChange); + if (oldWidget.isImeConnected == null) { + _isImeConnected.dispose(); + } + + _isImeConnected = (widget.isImeConnected ?? ValueNotifier(false)) // + ..addListener(_onImeConnectionChange); + } + } + + @override + void dispose() { + print("Disposing chat editor..."); + SuperKeyboard.instance.mobileGeometry.removeListener(_onKeyboardChange); + + _isImeConnected.removeListener(_onImeConnectionChange); + if (widget.isImeConnected == null) { + print("Disposing _isImeConnected"); + _isImeConnected.dispose(); + } + + widget.pageController.removeListener(_onPageControllerChange); + + if (widget.scrollController == null) { + _scrollController.dispose(); + } + + // _keyboardPanelController.dispose(); + // _isImeConnected.dispose(); + + if (widget.editorFocusNode == null) { + print("Disposing _editorFocusNode"); + _editorFocusNode.dispose(); + } + + super.dispose(); + + print("Done with chat editor disposal"); + } + + void _onKeyboardChange() { + // On Android, we've found that when swiping to go back, the keyboard often + // closes without Flutter reporting the closure of the IME connection. + // Therefore, the keyboard closes, but editors and text fields retain focus, + // selection, and a supposedly open IME connection. + // + // Flutter issue: https://github.com/flutter/flutter/issues/165734 + // + // To hack around this bug in Flutter, when super_keyboard reports keyboard + // closure, and this controller thinks the keyboard is open, we give up + // focus so that our app state synchronizes with the closed IME connection. + final keyboardState = SuperKeyboard.instance.mobileGeometry.value.keyboardState; + if (_isImeConnected.value && (keyboardState == KeyboardState.closing || keyboardState == KeyboardState.closed)) { + // print("UNFOCUSING EDITOR BECAUSE KEYBOARD IS CLOSED"); + // _editorFocusNode.unfocus(); + } + } + + void _onImeConnectionChange() { + print("_onImeConnectionChange() - is IME connected? ${_isImeConnected.value}"); + print("${StackTrace.current}"); + widget.pageController.collapsedMode = + _isImeConnected.value ? MessagePageSheetCollapsedMode.intrinsic : MessagePageSheetCollapsedMode.preview; + } + + void _onPageControllerChange() { + print("_onPageControllerChange() - _scrollController: ${_scrollController.hashCode}"); + // TODO: I added _scrollController.hashClients because we were crashing in the floating chat + // demo when pressing the "close keyboard" button on the toolbar. But I don't know why + // we lost our scrolling client when we pressed the close button. + if (widget.pageController.isPreview && _scrollController.hasClients) { + // Always scroll the editor to the top when in preview mode. + _scrollController.position.jumpTo(0); + } + } + + @override + Widget build(BuildContext context) { + print("chat_editor.dart - building with _scrollController: ${_scrollController.hashCode}"); + return SuperEditorFocusOnTap( + editorFocusNode: _editorFocusNode, + editor: widget.editor, + child: SuperEditorDryLayout( + controller: widget.scrollController, + superEditor: SuperEditor( + key: _editorKey, + focusNode: _editorFocusNode, + editor: widget.editor, + scrollController: _scrollController, + softwareKeyboardController: widget.softwareKeyboardController, + isImeConnected: _isImeConnected, + imePolicies: const SuperEditorImePolicies(), + selectionPolicies: const SuperEditorSelectionPolicies(), + shrinkWrap: false, + stylesheet: _chatStylesheet, + componentBuilders: const [ + HintComponentBuilder("Send a message...", _hintTextStyleBuilder), + ...defaultComponentBuilders, + ], + ), + ), + ); + + // return KeyboardPanelScaffold( + // controller: _keyboardPanelController, + // isImeConnected: _isImeConnected, + // toolbarBuilder: (BuildContext context, PanelType? openPanel) { + // return const SizedBox(); + // }, + // keyboardPanelBuilder: (BuildContext context, PanelType? openPanel) { + // return const SizedBox(); + // }, + // contentBuilder: (BuildContext context, PanelType? openPanel) { + // return SuperEditorFocusOnTap( + // editorFocusNode: _editorFocusNode, + // editor: widget.editor, + // child: SuperEditorDryLayout( + // controller: widget.scrollController, + // superEditor: SuperEditor( + // key: _editorKey, + // focusNode: _editorFocusNode, + // editor: widget.editor, + // scrollController: _scrollController, + // softwareKeyboardController: widget.softwareKeyboardController, + // isImeConnected: _isImeConnected, + // imePolicies: const SuperEditorImePolicies(), + // selectionPolicies: const SuperEditorSelectionPolicies(), + // shrinkWrap: false, + // stylesheet: _chatStylesheet, + // componentBuilders: const [ + // HintComponentBuilder("Send a message...", _hintTextStyleBuilder), + // ...defaultComponentBuilders, + // ], + // ), + // ), + // ); + // }, + // ); + } +} + +final _chatStylesheet = Stylesheet( + rules: [ + StyleRule( + BlockSelector.all, + (doc, docNode) { + return { + Styles.padding: const CascadingPadding.symmetric(horizontal: 12), + Styles.textStyle: const TextStyle( + color: Colors.black, + fontSize: 16, + height: 1.4, + ), + }; + }, + ), + StyleRule( + const BlockSelector("header1"), + (doc, docNode) { + return { + Styles.textStyle: const TextStyle( + color: Color(0xFF333333), + fontSize: 38, + fontWeight: FontWeight.bold, + ), + }; + }, + ), + StyleRule( + const BlockSelector("header2"), + (doc, docNode) { + return { + Styles.textStyle: const TextStyle( + color: Color(0xFF333333), + fontSize: 26, + fontWeight: FontWeight.bold, + ), + }; + }, + ), + StyleRule( + const BlockSelector("header3"), + (doc, docNode) { + return { + Styles.textStyle: const TextStyle( + color: Color(0xFF333333), + fontSize: 22, + fontWeight: FontWeight.bold, + ), + }; + }, + ), + StyleRule( + const BlockSelector("paragraph"), + (doc, docNode) { + return { + Styles.padding: const CascadingPadding.only(bottom: 12), + }; + }, + ), + StyleRule( + const BlockSelector("blockquote"), + (doc, docNode) { + return { + Styles.textStyle: const TextStyle( + color: Colors.grey, + fontWeight: FontWeight.bold, + height: 1.4, + ), + }; + }, + ), + ], + inlineTextStyler: defaultInlineTextStyler, + inlineWidgetBuilders: defaultInlineWidgetBuilderChain, +); + +TextStyle _hintTextStyleBuilder(context) => const TextStyle( + color: Colors.grey, + ); + +// FIXME: This widget is required because of the current shrink wrap behavior +// of Super Editor. If we set `shrinkWrap` to `false` then the bottom +// sheet always expands to max height. But if we set `shrinkWrap` to +// `true`, when we manually expand the bottom sheet, the only +// tappable area is wherever the document components actually appear. +// In the average case, that means only the top area of the bottom +// sheet can be tapped to place the caret. +// +// This widget should wrap Super Editor and make the whole area tappable. +/// A widget, that when pressed, gives focus to the [editorFocusNode], and places +/// the caret at the end of the content within an [editor]. +/// +/// It's expected that the [child] subtree contains the associated `SuperEditor`, +/// which owns the [editor] and [editorFocusNode]. +class SuperEditorFocusOnTap extends StatelessWidget { + const SuperEditorFocusOnTap({ + super.key, + required this.editorFocusNode, + required this.editor, + required this.child, + }); + + final FocusNode editorFocusNode; + + final Editor editor; + + /// The SuperEditor that we're wrapping with this tap behavior. + final Widget child; + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: editorFocusNode, + builder: (context, child) { + return ListenableBuilder( + listenable: editor.composer.selectionNotifier, + builder: (context, child) { + final shouldControlTap = editor.composer.selection == null || !editorFocusNode.hasFocus; + print("Is SuperEditorFocusOnTap waiting for a tap? $shouldControlTap"); + + return GestureDetector( + onTap: shouldControlTap ? _selectEditor : null, + behavior: HitTestBehavior.opaque, + child: IgnorePointer( + ignoring: shouldControlTap, + // ^ Prevent the Super Editor from aggressively responding to + // taps, so that we can respond. + child: child, + ), + ); + }, + child: child, + ); + }, + child: child, + ); + } + + void _selectEditor() { + print("Tap on editor, giving focus"); + editorFocusNode.requestFocus(); + + final endNode = editor.document.last; + editor.execute([ + ChangeSelectionRequest( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: endNode.id, + nodePosition: endNode.endPosition, + ), + ), + SelectionChangeType.placeCaret, + SelectionReason.userInteraction, + ), + ]); + } +} + +/// The available options to choose from when using the built-in editing toolbar in +/// a floating editor page or a mounted editor page. +enum SimpleSuperChatToolbarOptions { + // Text. + bold, + italics, + underline, + strikethrough, + code, + textColor, + backgroundColor, + indent, + clearStyles, + + // Blocks. + orderedListItem, + unorderedListItem, + blockquote, + codeBlock, + + // Media. + attach, + + // Control. + dictation, + closeKeyboard, + send; +} diff --git a/super_editor/lib/src/chat/message_page_scaffold.dart b/super_editor/lib/src/chat/message_page_scaffold.dart index 08c49fa9ea..9e3e7086a9 100644 --- a/super_editor/lib/src/chat/message_page_scaffold.dart +++ b/super_editor/lib/src/chat/message_page_scaffold.dart @@ -25,6 +25,7 @@ class MessagePageScaffold extends RenderObjectWidget { required this.bottomSheetBuilder, this.bottomSheetMinimumTopGap = 200, this.bottomSheetMinimumHeight = 150, + this.bottomSheetCollapsedMaximumHeight = double.infinity, }); final MessagePageController? controller; @@ -47,6 +48,18 @@ class MessagePageScaffold extends RenderObjectWidget { /// height mode. final double bottomSheetMinimumHeight; + /// The maximum height that the bottom sheet can expand to, as the intrinsic height + /// of the content increases. + /// + /// E.g., The user starts with a single line of text and then starts inserting + /// newlines. As the user continues to add newlines, this height is where the sheet + /// stops growing taller. + /// + /// This height applies when the sheet is collapsed, i.e., not expanded. If the user + /// expands the sheet, then the maximum height of the sheet would be the maximum allowed + /// layout height, minus [bottomSheetMinimumTopGap]. + final double bottomSheetCollapsedMaximumHeight; + @override RenderObjectElement createElement() { return MessagePageElement(this); @@ -59,6 +72,7 @@ class MessagePageScaffold extends RenderObjectWidget { controller, bottomSheetMinimumTopGap: bottomSheetMinimumTopGap, bottomSheetMinimumHeight: bottomSheetMinimumHeight, + bottomSheetCollapsedMaximumHeight: bottomSheetCollapsedMaximumHeight, ); } @@ -66,7 +80,8 @@ class MessagePageScaffold extends RenderObjectWidget { void updateRenderObject(BuildContext context, RenderMessagePageScaffold renderObject) { renderObject ..bottomSheetMinimumTopGap = bottomSheetMinimumTopGap - ..bottomSheetMinimumHeight = bottomSheetMinimumHeight; + ..bottomSheetMinimumHeight = bottomSheetMinimumHeight + ..bottomSheetCollapsedMaximumHeight = bottomSheetCollapsedMaximumHeight; if (controller != null) { renderObject.controller = controller!; @@ -560,6 +575,9 @@ class MessagePageElement extends RenderObjectElement { if (_content != null) { visitor(_content!); } + } else { + print("NOT ALLOWING CHILD VISITATION!"); + print("StackTrace:\n${StackTrace.current}"); } } } @@ -573,8 +591,10 @@ class RenderMessagePageScaffold extends RenderBox { MessagePageController? controller, { required double bottomSheetMinimumTopGap, required double bottomSheetMinimumHeight, + required double bottomSheetCollapsedMaximumHeight, }) : _bottomSheetMinimumTopGap = bottomSheetMinimumTopGap, - _bottomSheetMinimumHeight = bottomSheetMinimumHeight { + _bottomSheetMinimumHeight = bottomSheetMinimumHeight, + _bottomSheetCollapsedMaximumHeight = bottomSheetCollapsedMaximumHeight { _controller = controller ?? MessagePageController(); _attachToController(); } @@ -701,7 +721,6 @@ class RenderMessagePageScaffold extends RenderBox { } void _onDragEnd() { - _isExpandingOrCollapsing = true; _velocityStopwatch.stop(); final velocity = _velocityTracker.getVelocityEstimate()?.pixelsPerSecond.dy ?? 0; @@ -716,7 +735,7 @@ class RenderMessagePageScaffold extends RenderBox { final minimizedHeight = switch (_controller.collapsedMode) { MessagePageSheetCollapsedMode.preview => _previewHeight, - MessagePageSheetCollapsedMode.intrinsic => _intrinsicHeight, + MessagePageSheetCollapsedMode.intrinsic => min(_intrinsicHeight, _bottomSheetCollapsedMaximumHeight), }; _controller.desiredSheetMode = velocity.abs() > 500 // @@ -740,18 +759,35 @@ class RenderMessagePageScaffold extends RenderBox { void _updateBottomSheetHeightSimulation({ required double velocity, }) { - _ticker.stop(); - final minimizedHeight = switch (_controller.collapsedMode) { MessagePageSheetCollapsedMode.preview => _previewHeight, - MessagePageSheetCollapsedMode.intrinsic => _intrinsicHeight, + MessagePageSheetCollapsedMode.intrinsic => min(_intrinsicHeight, _bottomSheetCollapsedMaximumHeight), }; _controller.isSliding = true; final startHeight = _bottomSheet!.size.height; _simulationGoalMode = _controller.desiredSheetMode; - _simulationGoalHeight = _simulationGoalMode! == MessagePageSheetMode.expanded ? _expandedHeight : minimizedHeight; + final newSimulationGoalHeight = + _simulationGoalMode! == MessagePageSheetMode.expanded ? _expandedHeight : minimizedHeight; + if ((newSimulationGoalHeight - startHeight).abs() < 1) { + // We're already at the destination. Fizzle. + _animatedHeight = newSimulationGoalHeight; + _animatedVelocity = 0; + _isExpandingOrCollapsing = false; + _desiredDragHeight = null; + _ticker.stop(); + return; + } + if (newSimulationGoalHeight == _simulationGoalHeight) { + // We're already simulating to this height. We short-circuit when the goal + // hasn't changed so that we don't get rapidly oscillating simulation artifacts. + return; + } + _simulationGoalHeight = newSimulationGoalHeight; + _isExpandingOrCollapsing = true; + + _ticker.stop(); messagePageLayoutLog.info('Creating expand/collapse simulation:'); messagePageLayoutLog.info( @@ -772,7 +808,10 @@ class RenderMessagePageScaffold extends RenderBox { ), startHeight, // Start value _simulationGoalHeight!, // End value - velocity, // Initial velocity + // Invert velocity because we measured velocity moving down the screen, but we + // want to apply velocity to the height of the sheet. A positive screen velocity + // corresponds to a negative sheet height velocity. + -velocity, // Initial velocity. ); _ticker.start(); @@ -822,8 +861,33 @@ class RenderMessagePageScaffold extends RenderBox { } double _bottomSheetMinimumHeight; + + set bottomSheetMaximumHeight(double newValue) { + if (newValue == _bottomSheetMaximumHeight) { + return; + } + + _bottomSheetMaximumHeight = newValue; + + // FIXME: Only invalidate layout if this change impacts the current rendering. + markNeedsLayout(); + } + double _bottomSheetMaximumHeight = double.infinity; + set bottomSheetCollapsedMaximumHeight(double newValue) { + if (newValue == _bottomSheetCollapsedMaximumHeight) { + return; + } + + _bottomSheetCollapsedMaximumHeight = newValue; + + // FIXME: Only invalidate layout if this change impacts the current rendering. + markNeedsLayout(); + } + + double _bottomSheetCollapsedMaximumHeight = double.infinity; + /// Whether this render object's layout information or its content /// layout information is dirty. /// @@ -848,7 +912,7 @@ class RenderMessagePageScaffold extends RenderBox { void _onExpandCollapseTick(Duration elapsedTime) { final seconds = elapsedTime.inMilliseconds / 1000; - _animatedHeight = _simulation!.x(seconds); + _animatedHeight = _simulation!.x(seconds).clamp(_bottomSheetMinimumHeight, _bottomSheetMaximumHeight); _animatedVelocity = _simulation!.dx(seconds); if (_simulation!.isDone(seconds)) { @@ -985,8 +1049,7 @@ class RenderMessagePageScaffold extends RenderBox { messagePageLayoutLog.info( "Measuring the bottom sheet's intrinsic height", ); - // Do a throw-away layout pass to get the intrinsic height of the bottom - // sheet, bounded within its min/max height. + // Do a throw-away layout pass to get the intrinsic height of the bottom sheet. _intrinsicHeight = _calculateBoundedIntrinsicHeight( constraints.copyWith(minHeight: 0), ); @@ -1001,6 +1064,7 @@ class RenderMessagePageScaffold extends RenderBox { MessagePageSheetCollapsedMode.intrinsic => _intrinsicHeight, }; + // Max height depends on whether we're collapsed or expanded. final bottomSheetConstraints = constraints.copyWith( minHeight: minimizedHeight, maxHeight: _bottomSheetMaximumHeight, @@ -1026,11 +1090,15 @@ class RenderMessagePageScaffold extends RenderBox { _updateBottomSheetHeightSimulation(velocity: _animatedVelocity); } + final minimumHeight = min( + _controller.collapsedMode == MessagePageSheetCollapsedMode.preview ? _previewHeight : _intrinsicHeight, + _bottomSheetCollapsedMaximumHeight); + final animatedHeight = _animatedHeight.clamp(minimumHeight, _bottomSheetMaximumHeight); _bottomSheet!.layout( bottomSheetConstraints.copyWith( - minHeight: max(_animatedHeight - 1, 0), + minHeight: max(animatedHeight - 1, 0), // ^ prevent a layout boundary - maxHeight: _animatedHeight, + maxHeight: animatedHeight, ), parentUsesSize: true, ); @@ -1039,7 +1107,8 @@ class RenderMessagePageScaffold extends RenderBox { messagePageLayoutLog.info( ' - drag height: $_desiredDragHeight, minimized height: $minimizedHeight', ); - final strictHeight = _desiredDragHeight!.clamp(minimizedHeight, _bottomSheetMaximumHeight); + final minimumHeight = min(minimizedHeight, _bottomSheetCollapsedMaximumHeight); + final strictHeight = _desiredDragHeight!.clamp(minimumHeight, _bottomSheetMaximumHeight); messagePageLayoutLog.info(' - bounded drag height: $strictHeight'); _bottomSheet!.layout( @@ -1068,7 +1137,11 @@ class RenderMessagePageScaffold extends RenderBox { messagePageLayoutLog.info('>>>>>>>> Minimized'); messagePageLayoutLog.info('Running standard editor layout with constraints: $bottomSheetConstraints'); _bottomSheet!.layout( - bottomSheetConstraints, + // bottomSheetConstraints, + bottomSheetConstraints.copyWith( + minHeight: 0, + maxHeight: _bottomSheetCollapsedMaximumHeight, + ), parentUsesSize: true, ); } @@ -1266,7 +1339,13 @@ class RenderMessageEditorHeight extends RenderBox // // If we find a missing layout invalidation for MessagePageScaffold, and we // make this call superfluous, then remove this. - _findAncestorMessagePageScaffold()!.markNeedsLayout(); + final ancestorMessagePageScaffold = _findAncestorMessagePageScaffold(); + // Ancestor scaffold might be null during various lifecycle events, e.g., + // `dropChild()` calls `markNeedsLayout()`, but when we're dropping our + // children, we have likely already been dropped by our parent, too. + if (ancestorMessagePageScaffold != null) { + ancestorMessagePageScaffold.markNeedsLayout(); + } } @override diff --git a/super_editor/lib/src/default_editor/document_ime/ime_keyboard_control.dart b/super_editor/lib/src/default_editor/document_ime/ime_keyboard_control.dart index d1cc3e8ca4..9f84a989a6 100644 --- a/super_editor/lib/src/default_editor/document_ime/ime_keyboard_control.dart +++ b/super_editor/lib/src/default_editor/document_ime/ime_keyboard_control.dart @@ -38,6 +38,7 @@ class _SoftwareKeyboardOpenerState extends State impleme @override void initState() { super.initState(); + print("SoftwareKeyboardOpener - initState()"); widget.controller?.attach(this); } @@ -58,7 +59,12 @@ class _SoftwareKeyboardOpenerState extends State impleme // their `dispose()` methods. If we `detach()` right now, the // ancestor widgets would cause errors in their `dispose()` methods. WidgetsBinding.instance.scheduleFrameCallback((timeStamp) { - widget.controller?.detach(); + // Check that we're still the delegate at the end of the frame, because + // some other widget may have replaced us as the delegate. + if (widget.controller?._delegate == this) { + print("Detaching from software keyboard controller"); + widget.controller?.detach(); + } }); super.dispose(); } @@ -71,6 +77,7 @@ class _SoftwareKeyboardOpenerState extends State impleme required int viewId, }) { editorImeLog.info("[SoftwareKeyboard] - showing keyboard"); + print("[SoftwareKeyboard] - showing keyboard"); widget.imeConnection.value ??= TextInput.attach(widget.createImeClient(), widget.createImeConfiguration()); widget.imeConnection.value!.show(); } @@ -83,6 +90,7 @@ class _SoftwareKeyboardOpenerState extends State impleme @override void close() { editorImeLog.info("[SoftwareKeyboard] - closing IME connection."); + print("[SoftwareKeyboard] - closing IME connection."); widget.imeConnection.value?.close(); widget.imeConnection.value = null; } 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 f049474400..bca81d1f95 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 @@ -175,6 +175,10 @@ class SuperEditorImeInteractorState extends State impl @override void initState() { super.initState(); + print("IME interactor - initState"); + print("IME interactor IME connection holder: ${_imeConnection.hashCode}"); + print("IME interactor IME connection: ${_imeConnection.value}"); + print("IME interactor is IME connection attached? ${_imeConnection.value?.attached}"); _focusNode = (widget.focusNode ?? FocusNode()); _setupImeConnection(); @@ -185,6 +189,8 @@ class SuperEditorImeInteractorState extends State impl _imeConnection.addListener(_onImeConnectionChange); WidgetsBinding.instance.addPostFrameCallback((_) { + print( + "SuperEditorImeInteractorState ($hashCode) (isImeConnected - ${widget.isImeConnected.hashCode}) - init state callback"); // Synchronize the IME connection notifier with our IME connection state. We run // this in a post-frame callback because the very first pump of the Super Editor // widget tree won't have Super Editor connected as an IME delegate, yet. @@ -197,6 +203,7 @@ class SuperEditorImeInteractorState extends State impl @override void didChangeDependencies() { super.didChangeDependencies(); + print("IME interactor - didChangeDependencies"); _controlsController = SuperEditorIosControlsScope.maybeRootOf(context); _documentImeClient.floatingCursorController = widget.floatingCursorController ?? _controlsController?.floatingCursorController; @@ -206,6 +213,7 @@ class SuperEditorImeInteractorState extends State impl @override void didUpdateWidget(SuperEditorImeInteractor oldWidget) { + print("IME interactor - didUpdateWidget"); super.didUpdateWidget(oldWidget); if (widget.editContext != oldWidget.editContext) { @@ -216,6 +224,7 @@ class SuperEditorImeInteractorState extends State impl } if (widget.imeConfiguration != oldWidget.imeConfiguration) { + print("IME interactor - updating IME configuration"); _textInputConfiguration = widget.imeConfiguration.toTextInputConfiguration(viewId: View.of(context).viewId); if (isAttachedToIme) { _imeConnection.value!.updateConfig(_textInputConfiguration); @@ -230,7 +239,10 @@ class SuperEditorImeInteractorState extends State impl @override void dispose() { + print("IME interactor - dispose"); + print("IME interaction - is attached at time of disposal: ${_imeConnection.value?.attached}"); _imeConnection.removeListener(_onImeConnectionChange); + print("IME interactor - Closing IME connection (${_imeConnection.value})"); _imeConnection.value?.close(); widget.imeOverrides?.client = null; diff --git a/super_editor/lib/src/default_editor/text.dart b/super_editor/lib/src/default_editor/text.dart index cf46e99e03..95081fdc65 100644 --- a/super_editor/lib/src/default_editor/text.dart +++ b/super_editor/lib/src/default_editor/text.dart @@ -756,7 +756,7 @@ class _TextWithHintComponentState extends State if (widget.text.isEmpty) IgnorePointer( child: Text.rich( - widget.hintText?.computeTextSpan(_styleBuilder) ?? const TextSpan(text: ''), + widget.hintText?.computeInlineSpan(context, _styleBuilder, []) ?? const TextSpan(text: ''), ), ), TextComponent( diff --git a/super_editor/lib/src/infrastructure/keyboard_panel_scaffold.dart b/super_editor/lib/src/infrastructure/keyboard_panel_scaffold.dart index ae6a85cc7a..34a9aea594 100644 --- a/super_editor/lib/src/infrastructure/keyboard_panel_scaffold.dart +++ b/super_editor/lib/src/infrastructure/keyboard_panel_scaffold.dart @@ -160,8 +160,11 @@ class _KeyboardPanelScaffoldState extends State extends State