diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_align_items.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_align_items.dart index c0d0dde650..1c309ffd8a 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_align_items.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_align_items.dart @@ -1,8 +1,16 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_menu_item.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_popup_menu.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/util.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:collection/collection.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; +const _left = 'left'; +const _center = 'center'; +const _right = 'right'; + class AlignItems extends StatelessWidget { const AlignItems({ super.key, @@ -13,18 +21,97 @@ class AlignItems extends StatelessWidget { @override Widget build(BuildContext context) { - return MobileToolbarItemWrapper( - size: const Size(82, 52), - onTap: () async { - await editorState.alignBlock('left'); + final currentAlignItem = _getCurrentAlignItem(); + final alignMenuItems = _getAlignMenuItems(); + return PopupMenu( + itemLength: alignMenuItems.length, + onSelected: (index) { + editorState.alignBlock(alignMenuItems[index].$1); }, - icon: FlowySvgs.m_aa_align_left_s, - isSelected: false, - iconPadding: const EdgeInsets.symmetric( - vertical: 14.0, + menuBuilder: (context, keys, currentIndex) { + final children = alignMenuItems + .mapIndexed( + (index, e) => [ + MenuItem( + key: keys[index], + isSelected: currentIndex == index, + icon: e.$2, + ), + if (index != 0 || index != alignMenuItems.length - 1) + const HSpace(12), + ], + ) + .flattened + .toList(); + return MenuWrapper( + child: Row( + mainAxisSize: MainAxisSize.min, + children: children, + ), + ); + }, + builder: (context, key) => MobileToolbarItemWrapper( + key: key, + size: const Size(82, 52), + onTap: () async { + await editorState.alignBlock(currentAlignItem.$1); + }, + icon: currentAlignItem.$2, + isSelected: false, + iconPadding: const EdgeInsets.symmetric( + vertical: 14.0, + ), + showDownArrow: true, + backgroundColor: const Color(0xFFF2F2F7), ), - showDownArrow: true, - backgroundColor: const Color(0xFFF2F2F7), ); } + + (String, FlowySvgData) _getCurrentAlignItem() { + final align = _getCurrentBlockAlign(); + if (align == _center) { + return (_center, FlowySvgs.m_aa_align_center_s); + } else if (align == _right) { + return (_right, FlowySvgs.m_aa_align_right_s); + } + return (_left, FlowySvgs.m_aa_align_left_s); + } + + List<(String, FlowySvgData)> _getAlignMenuItems() { + final align = _getCurrentBlockAlign(); + + if (align == _center) { + return [ + (_left, FlowySvgs.m_aa_align_left_s), + (_right, FlowySvgs.m_aa_align_right_s), + ]; + } else if (align == _right) { + return [ + (_left, FlowySvgs.m_aa_align_left_s), + (_center, FlowySvgs.m_aa_align_center_s), + ]; + } + return [ + (_center, FlowySvgs.m_aa_align_center_s), + (_right, FlowySvgs.m_aa_align_right_s), + ]; + } + + String _getCurrentBlockAlign() { + final selection = editorState.selection; + if (selection == null) { + return _left; + } + final nodes = editorState.getNodesInSelection(selection); + String? alignString; + for (final node in nodes) { + final align = node.attributes[blockComponentAlign]; + if (alignString == null) { + alignString = align; + } else if (alignString != align) { + return _left; + } + } + return alignString ?? _left; + } } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_block_items.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_block_items.dart index f2577817ee..f93c915b4f 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_block_items.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_block_items.dart @@ -1,8 +1,11 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_item/utils.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_menu_item.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_popup_menu.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:collection/collection.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; @@ -41,7 +44,6 @@ class BlockItems extends StatelessWidget { ) .flattened, // this item is a special case, use link item here instead of block item - _buildLinkItem(), ], ), @@ -72,19 +74,51 @@ class BlockItems extends StatelessWidget { } Widget _buildLinkItem() { - return MobileToolbarItemWrapper( - size: const Size(62, 54), - enableTopLeftRadius: false, - enableBottomLeftRadius: false, - enableTopRightRadius: true, - enableBottomRightRadius: true, - showDownArrow: true, - onTap: _onLinkItemTap, - backgroundColor: const Color(0xFFF2F2F7), - icon: FlowySvgs.m_aa_link_s, - isSelected: false, - iconPadding: const EdgeInsets.symmetric( - vertical: 14.0, + final items = [ + (AppFlowyRichTextKeys.code, FlowySvgs.m_aa_code_s), + // (InlineMathEquationKeys.formula, FlowySvgs.m_aa_math_s), + ]; + return PopupMenu( + itemLength: items.length, + onSelected: (index) async { + await editorState.toggleAttribute(items[index].$1); + }, + menuBuilder: (context, keys, currentIndex) { + final children = items + .mapIndexed( + (index, e) => [ + MenuItem( + key: keys[index], + isSelected: currentIndex == index, + icon: e.$2, + ), + if (index != 0 || index != items.length - 1) const HSpace(12), + ], + ) + .flattened + .toList(); + return MenuWrapper( + child: Row( + mainAxisSize: MainAxisSize.min, + children: children, + ), + ); + }, + builder: (context, key) => MobileToolbarItemWrapper( + key: key, + size: const Size(62, 54), + enableTopLeftRadius: false, + enableBottomLeftRadius: false, + enableTopRightRadius: true, + enableBottomRightRadius: true, + showDownArrow: true, + onTap: _onLinkItemTap, + backgroundColor: const Color(0xFFF2F2F7), + icon: FlowySvgs.m_aa_link_s, + isSelected: false, + iconPadding: const EdgeInsets.symmetric( + vertical: 14.0, + ), ), ); } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_menu_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_menu_item.dart new file mode 100644 index 0000000000..90b98dda7b --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_menu_item.dart @@ -0,0 +1,67 @@ +import 'package:flowy_svg/flowy_svg.dart'; +import 'package:flutter/material.dart'; + +class MenuItem extends StatelessWidget { + const MenuItem({ + super.key, + required this.isSelected, + required this.icon, + }); + + final bool isSelected; + final FlowySvgData icon; + + @override + Widget build(BuildContext context) { + return Container( + width: 62, + height: 44, + decoration: ShapeDecoration( + color: isSelected ? const Color(0xFF00BCF0) : null, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 9), + child: FlowySvg( + icon, + color: isSelected ? Colors.white : null, + ), + ); + } +} + +class MenuWrapper extends StatelessWidget { + const MenuWrapper({ + super.key, + required this.child, + }); + + final Widget child; + + @override + Widget build(BuildContext context) { + return Container( + height: 64, + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + decoration: ShapeDecoration( + color: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + shadows: const [ + BoxShadow( + color: Color(0x2D000000), + blurRadius: 20, + offset: Offset(0, 10), + spreadRadius: 0, + ), + ], + ), + child: child, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_popup_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_popup_menu.dart new file mode 100644 index 0000000000..a5bba2393c --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_popup_menu.dart @@ -0,0 +1,139 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +class PopupMenu extends StatefulWidget { + const PopupMenu({ + super.key, + required this.onSelected, + required this.itemLength, + required this.menuBuilder, + required this.builder, + }); + + final Widget Function(BuildContext context, Key key) builder; + final int itemLength; + final Widget Function( + BuildContext context, + List keys, + int currentIndex, + ) menuBuilder; + final void Function(int index) onSelected; + + @override + State createState() => _PopupMenuState(); +} + +class _PopupMenuState extends State { + final key = GlobalKey(); + final indexNotifier = ValueNotifier(-1); + late List itemKeys; + + OverlayEntry? popupMenuOverlayEntry; + + Rect get rect { + final RenderBox renderBox = + key.currentContext!.findRenderObject() as RenderBox; + final size = renderBox.size; + final offset = renderBox.localToGlobal(Offset.zero); + return Rect.fromLTWH(offset.dx, offset.dy, size.width, size.height); + } + + @override + void initState() { + super.initState(); + + indexNotifier.value = widget.itemLength - 1; + itemKeys = List.generate( + widget.itemLength, + (_) => GlobalKey(), + ); + + indexNotifier.addListener(HapticFeedback.mediumImpact); + } + + @override + void dispose() { + indexNotifier.removeListener(HapticFeedback.mediumImpact); + indexNotifier.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onLongPressStart: (details) { + _showMenu(context); + }, + onLongPressMoveUpdate: (details) { + _updateSelection(details); + }, + onLongPressCancel: () { + _hideMenu(); + }, + onLongPressUp: () { + if (indexNotifier.value != -1) { + widget.onSelected(indexNotifier.value); + } + _hideMenu(); + }, + child: widget.builder(context, key), + ); + } + + void _showMenu(BuildContext context) { + _hideMenu(); + + indexNotifier.value = widget.itemLength - 1; + popupMenuOverlayEntry ??= OverlayEntry( + builder: (context) { + final screenSize = MediaQuery.of(context).size; + final right = screenSize.width - rect.right; + final bottom = screenSize.height - rect.top + 16; + return Positioned( + right: right, + bottom: bottom, + child: ValueListenableBuilder( + valueListenable: indexNotifier, + builder: (context, value, _) => widget.menuBuilder( + context, + itemKeys, + value, + ), + ), + ); + }, + ); + Overlay.of(context).insert(popupMenuOverlayEntry!); + } + + void _hideMenu() { + indexNotifier.value = -1; + + popupMenuOverlayEntry?.remove(); + popupMenuOverlayEntry = null; + } + + void _updateSelection(LongPressMoveUpdateDetails details) { + final dx = details.globalPosition.dx; + for (var i = 0; i < itemKeys.length; i++) { + final key = itemKeys[i]; + final RenderBox renderBox = + key.currentContext!.findRenderObject() as RenderBox; + final size = renderBox.size; + final offset = renderBox.localToGlobal(Offset.zero); + final rect = Rect.fromLTWH(offset.dx, offset.dy, size.width, size.height); + // ignore the position overflow + if ((i == 0 && dx < rect.left) || + (i == itemKeys.length - 1 && dx > rect.right)) { + indexNotifier.value = -1; + break; + } + if (rect.left <= dx && dx <= rect.right) { + indexNotifier.value = itemKeys.indexOf(key); + break; + } + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart index 34a9da48a9..71613a14bf 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart @@ -267,8 +267,8 @@ class EditorStyleCustomizer { } // customize the inline math equation block - final formula = attributes[InlineMathEquationKeys.formula] as String?; - if (formula != null) { + final formula = attributes[InlineMathEquationKeys.formula]; + if (formula is String) { return WidgetSpan( alignment: PlaceholderAlignment.middle, child: InlineMathEquation( diff --git a/frontend/resources/flowy_icons/16x/m_aa_align_center.svg b/frontend/resources/flowy_icons/16x/m_aa_align_center.svg new file mode 100644 index 0000000000..85a49ed988 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/m_aa_align_center.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/16x/m_aa_align_right.svg b/frontend/resources/flowy_icons/16x/m_aa_align_right.svg new file mode 100644 index 0000000000..f78fe50ea4 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/m_aa_align_right.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/16x/m_aa_code.svg b/frontend/resources/flowy_icons/16x/m_aa_code.svg new file mode 100644 index 0000000000..6345d82ec9 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/m_aa_code.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/16x/m_aa_math.svg b/frontend/resources/flowy_icons/16x/m_aa_math.svg new file mode 100644 index 0000000000..678848c22a --- /dev/null +++ b/frontend/resources/flowy_icons/16x/m_aa_math.svg @@ -0,0 +1,3 @@ + + +