From 0b6a4fff952bbe9dea4c58fc311811e31b4ad42b Mon Sep 17 00:00:00 2001 From: Hillel Coren Date: Wed, 3 Jan 2024 17:02:01 +0200 Subject: [PATCH] Add back font style to super editor toolbar --- .../super_editor_item_selector.dart | 416 ++++++++++++++++++ lib/utils/super_editor/toolbar.dart | 67 +-- pubspec.lock | 8 +- 3 files changed, 458 insertions(+), 33 deletions(-) create mode 100644 lib/utils/super_editor/super_editor_item_selector.dart diff --git a/lib/utils/super_editor/super_editor_item_selector.dart b/lib/utils/super_editor/super_editor_item_selector.dart new file mode 100644 index 000000000..01b566228 --- /dev/null +++ b/lib/utils/super_editor/super_editor_item_selector.dart @@ -0,0 +1,416 @@ +import 'package:flutter/material.dart'; +import 'package:super_editor/super_editor.dart'; + +/// A selection control, which displays a button with the selected item, and upon tap, displays a +/// popover list of available text options, from which the user can select a different +/// option. +/// +/// Unlike Flutter `DropdownButton`, which displays the popover list in a separate route, +/// this widget displays its popover list in an `Overlay`. By using an `Overlay`, focus can be shared +/// with the [parentFocusNode]. This means that when the popover list requests focus, [parentFocusNode] +/// still has non-primary focus. +/// +/// The popover list is positioned based on the following rules: +/// +/// 1. The popover is displayed below the selected item, if there's enough room, or +/// 2. The popover is displayed above the selected item, if there's enough room, or +/// 3. The popover is displayed with its bottom aligned with the bottom of +/// the given boundary, and it covers the selected item. +/// +/// The popover list height is based on the following rules: +/// +/// 1. The popover is displayed as tall as all items in the list, if there's enough room, or +/// 2. The popover is displayed as tall as the available space and becomes scrollable. +/// +/// The popover list includes keyboard selection behaviors: +/// +/// * Pressing UP/DOWN moves the "active" item selection up/down. +/// * Pressing UP with the first item active moves the active item selection to the last item. +/// * Pressing DOWN with the last item active moves the active item selection to the first item. +/// * Pressing ENTER selects the currently active item and closes the popover list. +class SuperEditorDemoTextItemSelector extends StatefulWidget { + const SuperEditorDemoTextItemSelector({ + super.key, + this.parentFocusNode, + this.boundaryKey, + this.id, + required this.items, + required this.onSelected, + }); + + /// The [FocusNode], to which the popover list's [FocusNode] will be added as a child. + /// + /// In Flutter, [FocusNode]s have parents and children. This relationship allows an + /// entire ancestor path to "have focus", but only the lowest level descendant + /// in that path has "primary focus". This path is important because various + /// widgets alter their presentation or behavior based on whether or not they + /// currently have focus, even if they only have "non-primary focus". + /// + /// When the popover list of items is visible, that list will have primary focus. + /// Moreover, because the popover list is built in an `Overlay`, none of your + /// widgets are in the natural focus path for that popover list. Therefore, if you + /// need your widget tree to retain focus while the popover list is visible, then + /// you need to provide the [FocusNode] that the popover list should use as its + /// parent, thereby retaining focus for your widgets. + final FocusNode? parentFocusNode; + + /// A [GlobalKey] to a widget that determines the bounds where the popover list can be displayed. + /// + /// As the popover list follows the selected item, it can be displayed off-screen if this [SuperEditorDemoTextItemSelector] + /// is close to the bottom of the screen. + /// + /// Passing a [boundaryKey] causes the popover list to be confined to the bounds of the widget + /// bound to the [boundaryKey]. + /// + /// If `null`, the popover list is confined to the screen bounds, defined by the result of `MediaQuery.sizeOf`. + final GlobalKey? boundaryKey; + + /// The currently selected value or `null` if no item is selected. + /// + /// This value is used to build the button. + final SuperEditorDemoTextItem? id; + + /// The items that will be displayed in the popover list. + /// + /// For each item, its [SuperEditorDemoTextItem.label] is displayed. + final List items; + + /// Called when the user selects an item on the popover list. + final void Function(SuperEditorDemoTextItem? value) onSelected; + + @override + State createState() => + _SuperEditorDemoTextItemSelectorState(); +} + +class _SuperEditorDemoTextItemSelectorState + extends State { + /// Shows and hides the popover. + final PopoverController _popoverController = PopoverController(); + + /// The [FocusNode] of the popover list. + final FocusNode _popoverFocusNode = FocusNode(); + + @override + void dispose() { + _popoverController.dispose(); + _popoverFocusNode.dispose(); + super.dispose(); + } + + void _onItemSelected(SuperEditorDemoTextItem? value) { + _popoverController.close(); + widget.onSelected(value); + } + + @override + Widget build(BuildContext context) { + return PopoverScaffold( + controller: _popoverController, + buttonBuilder: _buildButton, + popoverFocusNode: _popoverFocusNode, + parentFocusNode: widget.parentFocusNode, + boundaryKey: widget.boundaryKey, + popoverBuilder: (context) => RoundedRectanglePopoverAppearance( + child: ItemSelectionList( + focusNode: _popoverFocusNode, + value: widget.id, + items: widget.items, + itemBuilder: _buildPopoverListItem, + onItemSelected: _onItemSelected, + onCancel: () => _popoverController.close(), + ), + ), + ); + } + + Widget _buildButton(BuildContext context) { + return SuperEditorPopoverButton( + padding: const EdgeInsets.only(left: 16.0, right: 24), + onTap: () => _popoverController.open(), + child: widget.id == null // + ? const SizedBox() + : Text( + widget.id!.label, + style: const TextStyle( + color: Colors.black, + fontSize: 12, + ), + ), + ); + } + + Widget _buildPopoverListItem(BuildContext context, + SuperEditorDemoTextItem item, bool isActive, VoidCallback onTap) { + return DecoratedBox( + decoration: BoxDecoration( + color: isActive ? Colors.grey.withOpacity(0.2) : Colors.transparent, + ), + child: InkWell( + onTap: onTap, + child: Container( + constraints: + const BoxConstraints(minHeight: kMinInteractiveDimension), + alignment: Alignment.centerLeft, + padding: const EdgeInsets.symmetric(horizontal: 20.0), + child: Text( + item.label, + style: const TextStyle( + color: Colors.black, + fontSize: 12, + ), + ), + ), + ), + ); + } +} + +/// An option that is displayed as text by a [SuperEditorDemoTextItemSelector]. +/// +/// Two [SuperEditorDemoTextItem]s are considered to be equal if they have the same [id]. +class SuperEditorDemoTextItem { + const SuperEditorDemoTextItem({ + required this.id, + required this.label, + }); + + /// The value that identifies this item. + final String id; + + /// The text that is displayed. + final String label; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SuperEditorDemoTextItem && + runtimeType == other.runtimeType && + id == other.id; + + @override + int get hashCode => id.hashCode; +} + +/// A selection control, which displays a button with the selected item, and upon tap, displays a +/// popover list of available icons, from which the user can select a different option. +/// +/// Unlike Flutter `DropdownButton`, which displays the popover list in a separate route, +/// this widget displays its popover list in an `Overlay`. By using an `Overlay`, focus can be shared +/// with the [parentFocusNode]. This means that when the popover list requests focus, [parentFocusNode] +/// still has non-primary focus. +/// +/// The popover list is positioned based on the following rules: +/// +/// 1. The popover is displayed below the selected item, if there's enough room, or +/// 2. The popover is displayed above the selected item, if there's enough room, or +/// 3. The popover is displayed with its bottom aligned with the bottom of +/// the given boundary, and it covers the selected item. +/// +/// The popover list height is based on the following rules: +/// +/// 1. The popover is displayed as tall as all items in the list, if there's enough room, or +/// 2. The popover is displayed as tall as the available space and becomes scrollable. +/// +/// The popover list includes keyboard selection behaviors: +/// +/// * Pressing UP/DOWN moves the "active" item selection up/down. +/// * Pressing UP with the first item active moves the active item selection to the last item. +/// * Pressing DOWN with the last item active moves the active item selection to the first item. +/// * Pressing ENTER selects the currently active item and closes the popover list. +class SuperEditorDemoIconItemSelector extends StatefulWidget { + const SuperEditorDemoIconItemSelector({ + super.key, + this.parentFocusNode, + this.boundaryKey, + this.value, + required this.items, + required this.onSelected, + }); + + /// The [FocusNode], to which the popover list's [FocusNode] will be added as a child. + /// + /// In Flutter, [FocusNode]s have parents and children. This relationship allows an + /// entire ancestor path to "have focus", but only the lowest level descendant + /// in that path has "primary focus". This path is important because various + /// widgets alter their presentation or behavior based on whether or not they + /// currently have focus, even if they only have "non-primary focus". + /// + /// When the popover list of items is visible, that list will have primary focus. + /// Moreover, because the popover list is built in an `Overlay`, none of your + /// widgets are in the natural focus path for that popover list. Therefore, if you + /// need your widget tree to retain focus while the popover list is visible, then + /// you need to provide the [FocusNode] that the popover list should use as its + /// parent, thereby retaining focus for your widgets. + final FocusNode? parentFocusNode; + + /// A [GlobalKey] to a widget that determines the bounds where the popover list can be displayed. + /// + /// As the popover list follows the selected item, it can be displayed off-screen if this [SuperEditorDemoIconItemSelector] + /// is close to the bottom of the screen. + /// + /// Passing a [boundaryKey] causes the popover list to be confined to the bounds of the widget + /// bound to the [boundaryKey]. + /// + /// If `null`, the popover list is confined to the screen bounds, defined by the result of `MediaQuery.sizeOf`. + final GlobalKey? boundaryKey; + + /// The currently selected value or `null` if no item is selected. + /// + /// This value is used to build the button. + final SuperEditorDemoIconItem? value; + + /// The items that will be displayed in the popover list. + /// + /// For each item, its [SuperEditorDemoIconItem.icon] is displayed. + final List items; + + /// Called when the user selects an item on the popover list. + final void Function(SuperEditorDemoIconItem? value) onSelected; + + @override + State createState() => + _SuperEditorDemoIconItemSelectorState(); +} + +class _SuperEditorDemoIconItemSelectorState + extends State { + /// Shows and hides the popover. + final PopoverController _popoverController = PopoverController(); + + /// The [FocusNode] of the popover list. + final FocusNode _popoverFocusNode = FocusNode(); + + @override + void dispose() { + _popoverController.dispose(); + _popoverFocusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return PopoverScaffold( + controller: _popoverController, + buttonBuilder: _buildButton, + popoverFocusNode: _popoverFocusNode, + parentFocusNode: widget.parentFocusNode, + popoverBuilder: (context) => RoundedRectanglePopoverAppearance( + child: ItemSelectionList( + value: widget.value, + items: widget.items, + itemBuilder: _buildItem, + onItemSelected: _onItemSelected, + onCancel: () => _popoverController.close(), + focusNode: _popoverFocusNode, + ), + ), + ); + } + + Widget _buildItem(BuildContext context, SuperEditorDemoIconItem item, + bool isActive, VoidCallback onTap) { + return DecoratedBox( + decoration: BoxDecoration( + color: isActive ? Colors.grey.withOpacity(0.2) : Colors.transparent, + ), + child: InkWell( + onTap: onTap, + child: Container( + constraints: + const BoxConstraints(minHeight: kMinInteractiveDimension), + alignment: Alignment.centerLeft, + padding: const EdgeInsets.symmetric(horizontal: 20.0), + child: Icon(item.icon), + ), + ), + ); + } + + Widget _buildButton(BuildContext context) { + return SuperEditorPopoverButton( + onTap: () => _popoverController.open(), + padding: const EdgeInsets.only(left: 8.0, right: 24), + child: widget.value == null // + ? const SizedBox() + : Icon(widget.value!.icon), + ); + } + + void _onItemSelected(SuperEditorDemoIconItem? value) { + _popoverController.close(); + widget.onSelected(value); + } +} + +/// An option that is displayed as an icon by a [SuperEditorDemoIconItemSelector]. +/// +/// Two [SuperEditorDemoIconItem]s are considered to be equal if they have the same [id]. +class SuperEditorDemoIconItem { + const SuperEditorDemoIconItem({ + required this.id, + required this.icon, + }); + + /// The value that identifies this item. + final String id; + + /// The icon that is displayed. + final IconData icon; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SuperEditorDemoIconItem && + runtimeType == other.runtimeType && + id == other.id; + + @override + int get hashCode => id.hashCode; +} + +/// A button with a center-left aligned [child] and a right aligned arrow icon. +/// +/// The arrow is displayed above the [child]. +class SuperEditorPopoverButton extends StatelessWidget { + const SuperEditorPopoverButton({ + super.key, + this.padding, + required this.onTap, + this.child, + }); + + /// Padding around the [child]. + final EdgeInsets? padding; + + /// Called when the user taps the button. + final VoidCallback onTap; + + /// The Widget displayed inside this button. + /// + /// If `null`, only the arrow is displayed. + final Widget? child; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + child: Center( + child: Stack( + alignment: Alignment.centerLeft, + children: [ + if (child != null) // + Padding( + padding: padding ?? EdgeInsets.zero, + child: child, + ), + const Positioned( + right: 0, + child: Icon(Icons.arrow_drop_down), + ), + ], + ), + ), + ); + } +} diff --git a/lib/utils/super_editor/toolbar.dart b/lib/utils/super_editor/toolbar.dart index aa61b3034..5a6b1a785 100644 --- a/lib/utils/super_editor/toolbar.dart +++ b/lib/utils/super_editor/toolbar.dart @@ -4,6 +4,7 @@ import 'dart:math'; import 'package:flutter/material.dart'; //import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:follow_the_leader/follow_the_leader.dart'; +import 'package:invoiceninja_flutter/utils/super_editor/super_editor_item_selector.dart'; import 'package:overlord/follow_the_leader.dart'; import 'package:super_editor/super_editor.dart'; @@ -103,7 +104,6 @@ class _EditorToolbarState extends State { super.dispose(); } - /* /// Returns true if the currently selected text node is capable of being /// transformed into a different type text node, returns false if /// multiple nodes are selected, no node is selected, or the selected @@ -147,6 +147,7 @@ class _EditorToolbarState extends State { } } + /* /// Returns the text alignment of the currently selected text node. /// /// Throws an exception if the currently selected node is not a text node. @@ -184,6 +185,7 @@ class _EditorToolbarState extends State { final selectedNode = widget.document.getNodeById(selection.extent.nodeId); return selectedNode is ParagraphNode; } + */ /// Converts the currently selected text node into a new type of /// text node, represented by [newType]. @@ -260,7 +262,6 @@ class _EditorToolbarState extends State { return null; } } - */ /// Toggles bold styling for the current selected text. void _toggleBold() { @@ -464,6 +465,7 @@ class _EditorToolbarState extends State { .getNodeById(widget.composer.selection!.extent.nodeId) as ParagraphNode; selectedNode.putMetadataValue('textAlign', newAlignmentValue); } + */ /// Returns the localized name for the given [_TextType], e.g., /// "Paragraph" or "Header 1". @@ -492,7 +494,17 @@ class _EditorToolbarState extends State { return 'Unordered List Item'; } } - */ + + /// Called when the user selects a block type on the toolbar. + void _onBlockTypeSelected(SuperEditorDemoTextItem? selectedItem) { + if (selectedItem != null) { + setState(() { + _convertTextToNewType(_TextType.values // + .where((e) => e.name == selectedItem.id) + .first); + }); + } + } void _onPerformAction(TextInputAction action) { if (action == TextInputAction.done) { @@ -547,38 +559,16 @@ class _EditorToolbarState extends State { child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - /* // Only allow the user to select a new type of text node if // the currently selected node can be converted. if (_isConvertibleNode()) ...[ Tooltip( //message: AppLocalizations.of(context)!.labelTextBlockType, message: 'Block Type', - child: DropdownButton<_TextType>( - value: _getCurrentTextType(), - items: _TextType.values - .map((textType) => DropdownMenuItem<_TextType>( - value: textType, - child: Padding( - padding: const EdgeInsets.only(left: 16.0), - child: Text(_getTextTypeName(textType)), - ), - )) - .toList(), - icon: const Icon(Icons.arrow_drop_down), - style: const TextStyle( - color: Colors.black, - fontSize: 12, - ), - underline: const SizedBox(), - elevation: 0, - itemHeight: 48, - onChanged: _convertTextToNewType, - ), + child: _buildBlockTypeSelector(), ), _buildVerticalDivider(), ], - */ Center( child: IconButton( onPressed: _toggleBold, @@ -673,6 +663,27 @@ class _EditorToolbarState extends State { ); } + Widget _buildBlockTypeSelector() { + final currentBlockType = _getCurrentTextType(); + return SuperEditorDemoTextItemSelector( + parentFocusNode: widget.editorFocusNode, + boundaryKey: widget.editorViewportKey, + id: SuperEditorDemoTextItem( + id: currentBlockType.name, + label: _getTextTypeName(currentBlockType), + ), + items: _TextType.values + .map( + (blockType) => SuperEditorDemoTextItem( + id: blockType.name, + label: _getTextTypeName(blockType), + ), + ) + .toList(), + onSelected: _onBlockTypeSelected, + ); + } + Widget _buildUrlField() { return Material( shape: const StadiumBorder(), @@ -735,7 +746,6 @@ class _EditorToolbarState extends State { ); } - /* Widget _buildVerticalDivider() { return Container( width: 1, @@ -743,6 +753,7 @@ class _EditorToolbarState extends State { ); } + /* IconData _buildTextAlignIcon(TextAlign align) { switch (align) { case TextAlign.left: @@ -760,7 +771,6 @@ class _EditorToolbarState extends State { */ } -/* enum _TextType { header1, header2, @@ -770,7 +780,6 @@ enum _TextType { orderedListItem, unorderedListItem, } -*/ /// Small toolbar that is intended to display over an image and /// offer controls to expand or contract the size of the image. diff --git a/pubspec.lock b/pubspec.lock index a3946b7f2..b647d17d1 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -46,7 +46,7 @@ packages: description: path: attributed_text ref: stable - resolved-ref: "88af88145526519a1830336c83bb154ceba7235c" + resolved-ref: cf157b787d118db0e80e92d5b10883ab58e9e21c url: "https://github.com/superlistapp/super_editor" source: git version: "0.2.2" @@ -1532,7 +1532,7 @@ packages: description: path: super_editor ref: stable - resolved-ref: "88af88145526519a1830336c83bb154ceba7235c" + resolved-ref: cf157b787d118db0e80e92d5b10883ab58e9e21c url: "https://github.com/superlistapp/super_editor" source: git version: "0.2.6" @@ -1541,7 +1541,7 @@ packages: description: path: super_editor_markdown ref: stable - resolved-ref: "88af88145526519a1830336c83bb154ceba7235c" + resolved-ref: cf157b787d118db0e80e92d5b10883ab58e9e21c url: "https://github.com/superlistapp/super_editor" source: git version: "0.1.5" @@ -1550,7 +1550,7 @@ packages: description: path: super_text_layout ref: stable - resolved-ref: "88af88145526519a1830336c83bb154ceba7235c" + resolved-ref: cf157b787d118db0e80e92d5b10883ab58e9e21c url: "https://github.com/superlistapp/super_editor" source: git version: "0.1.8"