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