feat: optimize editing experience on mobile (#592)
* feat: optimize mobile editing * feat: refactot the mobile toolbar * feat: don't reattch text input if the selection doesn't change * chore: update mobile toolbar layout
This commit is contained in:
parent
784fde1b01
commit
4345105ebe
31 changed files with 824 additions and 142 deletions
|
|
@ -87,13 +87,14 @@ class _HomePageState extends State<HomePage> {
|
|||
key: _scaffoldKey,
|
||||
extendBodyBehindAppBar: PlatformExtension.isDesktopOrWeb,
|
||||
drawer: _buildDrawer(context),
|
||||
resizeToAvoidBottomInset: false,
|
||||
appBar: AppBar(
|
||||
backgroundColor: const Color.fromARGB(255, 134, 46, 247),
|
||||
foregroundColor: Colors.white,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
title: const Text('AppFlowy Editor'),
|
||||
),
|
||||
body: SafeArea(child: _buildBody(context)),
|
||||
body: _buildBody(context),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -90,18 +90,14 @@ class _MobileEditorState extends State<MobileEditor> {
|
|||
),
|
||||
),
|
||||
// build mobile toolbar
|
||||
MobileToolbar(
|
||||
MobileToolbarV2(
|
||||
editorState: editorState,
|
||||
toolbarItems: [
|
||||
textDecorationMobileToolbarItem,
|
||||
textDecorationMobileToolbarItemV2,
|
||||
buildTextAndBackgroundColorMobileToolbarItem(),
|
||||
headingMobileToolbarItem,
|
||||
todoListMobileToolbarItem,
|
||||
listMobileToolbarItem,
|
||||
blocksMobileToolbarItem,
|
||||
linkMobileToolbarItem,
|
||||
quoteMobileToolbarItem,
|
||||
dividerMobileToolbarItem,
|
||||
codeMobileToolbarItem,
|
||||
],
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -33,6 +33,9 @@ class KeyboardServiceWidgetState extends State<KeyboardServiceWidget>
|
|||
late final TextInputService textInputService;
|
||||
late final FocusNode focusNode;
|
||||
|
||||
// previous selection
|
||||
Selection? previousSelection;
|
||||
|
||||
// use for IME only
|
||||
bool enableShortcuts = true;
|
||||
|
||||
|
|
@ -187,9 +190,17 @@ class KeyboardServiceWidgetState extends State<KeyboardServiceWidget>
|
|||
if (doNotAttach == true) {
|
||||
return;
|
||||
}
|
||||
enableShortcuts = true;
|
||||
|
||||
// attach the delta text input service if needed
|
||||
final selection = editorState.selection;
|
||||
|
||||
if (PlatformExtension.isMobile && previousSelection == selection) {
|
||||
// no need to attach the text input service if the selection is not changed.
|
||||
return;
|
||||
}
|
||||
|
||||
enableShortcuts = true;
|
||||
|
||||
if (selection == null) {
|
||||
textInputService.close();
|
||||
} else {
|
||||
|
|
@ -202,6 +213,8 @@ class KeyboardServiceWidgetState extends State<KeyboardServiceWidget>
|
|||
Log.editor.debug('keyboard service - request focus');
|
||||
}
|
||||
}
|
||||
|
||||
previousSelection = selection;
|
||||
}
|
||||
|
||||
void _attachTextInputService(Selection selection) {
|
||||
|
|
|
|||
|
|
@ -2,5 +2,6 @@ export 'mobile_floating_toolbar/mobile_floating_toolbar.dart';
|
|||
export 'mobile_toolbar.dart';
|
||||
export 'mobile_toolbar_item.dart';
|
||||
export 'mobile_toolbar_style.dart';
|
||||
export 'mobile_toolbar_v2.dart';
|
||||
export 'toolbar_items/toolbar_items.dart';
|
||||
export 'utils/utils.dart';
|
||||
|
|
|
|||
|
|
@ -53,14 +53,14 @@ class MobileToolbar extends StatelessWidget {
|
|||
return const SizedBox.shrink();
|
||||
}
|
||||
return RepaintBoundary(
|
||||
child: MobileToolbarStyle(
|
||||
child: MobileToolbarTheme(
|
||||
backgroundColor: backgroundColor,
|
||||
foregroundColor: foregroundColor,
|
||||
clearDiagonalLineColor: clearDiagonalLineColor,
|
||||
itemHighlightColor: itemHighlightColor,
|
||||
itemOutlineColor: itemOutlineColor,
|
||||
tabbarSelectedBackgroundColor: tabbarSelectedBackgroundColor,
|
||||
tabbarSelectedForegroundColor: tabbarSelectedForegroundColor,
|
||||
tabBarSelectedBackgroundColor: tabbarSelectedBackgroundColor,
|
||||
tabBarSelectedForegroundColor: tabbarSelectedForegroundColor,
|
||||
primaryColor: primaryColor,
|
||||
onPrimaryColor: onPrimaryColor,
|
||||
outlineColor: outlineColor,
|
||||
|
|
@ -90,7 +90,7 @@ abstract class MobileToolbarWidgetService {
|
|||
void closeItemMenu();
|
||||
}
|
||||
|
||||
class MobileToolbarWidget extends StatefulWidget {
|
||||
class MobileToolbarWidget extends StatefulWidget with WidgetsBindingObserver {
|
||||
const MobileToolbarWidget({
|
||||
super.key,
|
||||
required this.editorState,
|
||||
|
|
@ -111,6 +111,9 @@ class MobileToolbarWidgetState extends State<MobileToolbarWidget>
|
|||
bool _showItemMenu = false;
|
||||
int? _selectedToolbarItemIndex;
|
||||
|
||||
double previousKeyboardHeight = 0.0;
|
||||
bool updateKeyboardHeight = true;
|
||||
|
||||
@override
|
||||
void closeItemMenu() {
|
||||
if (_showItemMenu) {
|
||||
|
|
@ -123,7 +126,14 @@ class MobileToolbarWidgetState extends State<MobileToolbarWidget>
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final width = MediaQuery.of(context).size.width;
|
||||
final style = MobileToolbarStyle.of(context);
|
||||
final style = MobileToolbarTheme.of(context);
|
||||
|
||||
final keyboardHeight = MediaQuery.of(context).viewInsets.bottom;
|
||||
if (updateKeyboardHeight) {
|
||||
previousKeyboardHeight = keyboardHeight;
|
||||
}
|
||||
// print('object $keyboardHeight');
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Container(
|
||||
|
|
@ -147,15 +157,27 @@ class MobileToolbarWidgetState extends State<MobileToolbarWidget>
|
|||
toolbarItems: widget.toolbarItems,
|
||||
toolbarWidgetService: this,
|
||||
itemWithMenuOnPressed: (selectedItemIndex) {
|
||||
updateKeyboardHeight = false;
|
||||
setState(() {
|
||||
// If last selected item is selected again, toggle item menu
|
||||
if (_selectedToolbarItemIndex == selectedItemIndex) {
|
||||
_showItemMenu = !_showItemMenu;
|
||||
|
||||
if (!_showItemMenu) {
|
||||
// updateKeyboardHeight = true;
|
||||
widget.editorState.service.keyboardService
|
||||
?.enableKeyBoard(widget.selection);
|
||||
} else {
|
||||
updateKeyboardHeight = false;
|
||||
widget.editorState.service.keyboardService
|
||||
?.closeKeyboard();
|
||||
}
|
||||
} else {
|
||||
_selectedToolbarItemIndex = selectedItemIndex;
|
||||
// If not, show item menu
|
||||
_showItemMenu = true;
|
||||
// close keyboard when menu pop up
|
||||
|
||||
widget.editorState.service.keyboardService
|
||||
?.closeKeyboard();
|
||||
}
|
||||
|
|
@ -174,8 +196,8 @@ class MobileToolbarWidgetState extends State<MobileToolbarWidget>
|
|||
onPressed: () {
|
||||
setState(() {
|
||||
_showItemMenu = false;
|
||||
widget.editorState.service.keyboardService!
|
||||
.enableKeyBoard(widget.selection);
|
||||
widget.editorState.service.keyboardService
|
||||
?.enableKeyBoard(widget.selection);
|
||||
});
|
||||
},
|
||||
icon: const AFMobileIcon(
|
||||
|
|
@ -187,14 +209,22 @@ class MobileToolbarWidgetState extends State<MobileToolbarWidget>
|
|||
),
|
||||
),
|
||||
// only for MobileToolbarItem.withMenu
|
||||
if (_showItemMenu && _selectedToolbarItemIndex != null)
|
||||
MobileToolbarItemMenu(
|
||||
editorState: widget.editorState,
|
||||
itemMenuBuilder: () => widget
|
||||
.toolbarItems[_selectedToolbarItemIndex!]
|
||||
// pass current [MobileToolbarWidgetState] to be used to closeItemMenu
|
||||
.itemMenuBuilder!(widget.editorState, widget.selection, this),
|
||||
),
|
||||
(_showItemMenu && _selectedToolbarItemIndex != null)
|
||||
? Container(
|
||||
constraints: BoxConstraints(
|
||||
minHeight: previousKeyboardHeight,
|
||||
),
|
||||
child: MobileToolbarItemMenu(
|
||||
editorState: widget.editorState,
|
||||
itemMenuBuilder: () => widget
|
||||
.toolbarItems[_selectedToolbarItemIndex!]
|
||||
// pass current [MobileToolbarWidgetState] to be used to closeItemMenu
|
||||
.itemMenuBuilder!(context, widget.editorState, this),
|
||||
),
|
||||
)
|
||||
: SizedBox(
|
||||
height: previousKeyboardHeight,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
@ -239,9 +269,10 @@ class _ToolbarItemListView extends StatelessWidget {
|
|||
return ListView.builder(
|
||||
itemBuilder: (context, index) {
|
||||
final toolbarItem = toolbarItems[index];
|
||||
final icon = toolbarItem.itemIconBuilder(
|
||||
final icon = toolbarItem.itemIconBuilder?.call(
|
||||
context,
|
||||
editorState,
|
||||
toolbarWidgetService,
|
||||
);
|
||||
if (icon == null) {
|
||||
return const SizedBox.shrink();
|
||||
|
|
@ -256,8 +287,8 @@ class _ToolbarItemListView extends StatelessWidget {
|
|||
// close menu if other item's menu is still on the screen
|
||||
toolbarWidgetService.closeItemMenu();
|
||||
toolbarItems[index].actionHandler?.call(
|
||||
context,
|
||||
editorState,
|
||||
selection,
|
||||
);
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
typedef MobileToolbarItemMenuBuilder = Widget Function(
|
||||
typedef MobileToolbarItemIconBuilder = Widget Function(
|
||||
BuildContext context,
|
||||
EditorState editorState,
|
||||
Selection selection,
|
||||
// To access to the state of MobileToolbarWidget
|
||||
MobileToolbarWidgetService service,
|
||||
);
|
||||
|
||||
typedef MobileToolbarItemActionHandler = void Function(
|
||||
BuildContext context,
|
||||
EditorState editorState,
|
||||
Selection selection,
|
||||
);
|
||||
|
||||
class MobileToolbarItem {
|
||||
|
|
@ -19,20 +19,21 @@ class MobileToolbarItem {
|
|||
required this.itemIconBuilder,
|
||||
required this.actionHandler,
|
||||
}) : hasMenu = false,
|
||||
itemMenuBuilder = null;
|
||||
itemMenuBuilder = null,
|
||||
assert(itemIconBuilder != null && actionHandler != null);
|
||||
|
||||
/// Tool bar item that opens a menu to show options
|
||||
const MobileToolbarItem.withMenu({
|
||||
required this.itemIconBuilder,
|
||||
required this.itemMenuBuilder,
|
||||
}) : hasMenu = true,
|
||||
actionHandler = null;
|
||||
actionHandler = null,
|
||||
assert(itemMenuBuilder != null && itemIconBuilder != null);
|
||||
|
||||
// if the result is null, the item will be hidden
|
||||
final MobileToolbarItemIconBuilder? itemIconBuilder;
|
||||
final MobileToolbarItemActionHandler? actionHandler;
|
||||
|
||||
final bool hasMenu;
|
||||
final Widget? Function(
|
||||
BuildContext context,
|
||||
EditorState editorState,
|
||||
) itemIconBuilder;
|
||||
final MobileToolbarItemMenuBuilder? itemMenuBuilder;
|
||||
final MobileToolbarItemActionHandler? actionHandler;
|
||||
final MobileToolbarItemIconBuilder? itemMenuBuilder;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,14 +7,35 @@ import 'package:flutter/material.dart';
|
|||
/// itemHighlightColor -> selected item border color
|
||||
///
|
||||
/// itemOutlineColor -> item border color
|
||||
class MobileToolbarStyle extends InheritedWidget {
|
||||
class MobileToolbarTheme extends InheritedWidget {
|
||||
const MobileToolbarTheme({
|
||||
super.key,
|
||||
this.backgroundColor = Colors.white,
|
||||
this.foregroundColor = const Color(0xff676666),
|
||||
this.clearDiagonalLineColor = const Color(0xffB3261E),
|
||||
this.itemHighlightColor = const Color(0xff1F71AC),
|
||||
this.itemOutlineColor = const Color(0xFFE3E3E3),
|
||||
this.tabBarSelectedBackgroundColor = const Color(0x23808080),
|
||||
this.tabBarSelectedForegroundColor = Colors.black,
|
||||
this.primaryColor = const Color(0xff1F71AC),
|
||||
this.onPrimaryColor = Colors.white,
|
||||
this.outlineColor = const Color(0xFFE3E3E3),
|
||||
this.toolbarHeight = 50.0,
|
||||
this.borderRadius = 6.0,
|
||||
this.buttonHeight = 40.0,
|
||||
this.buttonSpacing = 8.0,
|
||||
this.buttonBorderWidth = 1.0,
|
||||
this.buttonSelectedBorderWidth = 2.0,
|
||||
required super.child,
|
||||
});
|
||||
|
||||
final Color backgroundColor;
|
||||
final Color foregroundColor;
|
||||
final Color clearDiagonalLineColor;
|
||||
final Color itemHighlightColor;
|
||||
final Color itemOutlineColor;
|
||||
final Color tabbarSelectedBackgroundColor;
|
||||
final Color tabbarSelectedForegroundColor;
|
||||
final Color tabBarSelectedBackgroundColor;
|
||||
final Color tabBarSelectedForegroundColor;
|
||||
final Color primaryColor;
|
||||
final Color onPrimaryColor;
|
||||
final Color outlineColor;
|
||||
|
|
@ -25,44 +46,25 @@ class MobileToolbarStyle extends InheritedWidget {
|
|||
final double buttonBorderWidth;
|
||||
final double buttonSelectedBorderWidth;
|
||||
|
||||
const MobileToolbarStyle({
|
||||
super.key,
|
||||
required this.backgroundColor,
|
||||
required this.foregroundColor,
|
||||
required this.clearDiagonalLineColor,
|
||||
required this.itemHighlightColor,
|
||||
required this.itemOutlineColor,
|
||||
required this.tabbarSelectedBackgroundColor,
|
||||
required this.tabbarSelectedForegroundColor,
|
||||
required this.primaryColor,
|
||||
required this.onPrimaryColor,
|
||||
required this.outlineColor,
|
||||
required this.toolbarHeight,
|
||||
required this.borderRadius,
|
||||
required this.buttonHeight,
|
||||
required this.buttonSpacing,
|
||||
required this.buttonBorderWidth,
|
||||
required this.buttonSelectedBorderWidth,
|
||||
required super.child,
|
||||
});
|
||||
static MobileToolbarTheme of(BuildContext context) {
|
||||
return context.dependOnInheritedWidgetOfExactType<MobileToolbarTheme>()!;
|
||||
}
|
||||
|
||||
static MobileToolbarStyle of(BuildContext context) {
|
||||
return context.dependOnInheritedWidgetOfExactType<MobileToolbarStyle>()!;
|
||||
static MobileToolbarTheme? maybeOf(BuildContext context) {
|
||||
return context.dependOnInheritedWidgetOfExactType<MobileToolbarTheme>();
|
||||
}
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(covariant MobileToolbarStyle oldWidget) {
|
||||
// This function is called whenever the inherited widget is rebuilt.
|
||||
// It should return true if the new widget's values are different from the old widget's values.
|
||||
bool updateShouldNotify(covariant MobileToolbarTheme oldWidget) {
|
||||
return backgroundColor != oldWidget.backgroundColor ||
|
||||
foregroundColor != oldWidget.foregroundColor ||
|
||||
clearDiagonalLineColor != oldWidget.clearDiagonalLineColor ||
|
||||
itemHighlightColor != oldWidget.itemHighlightColor ||
|
||||
itemOutlineColor != oldWidget.itemOutlineColor ||
|
||||
tabbarSelectedBackgroundColor !=
|
||||
oldWidget.tabbarSelectedBackgroundColor ||
|
||||
tabbarSelectedForegroundColor !=
|
||||
oldWidget.tabbarSelectedForegroundColor ||
|
||||
tabBarSelectedBackgroundColor !=
|
||||
oldWidget.tabBarSelectedBackgroundColor ||
|
||||
tabBarSelectedForegroundColor !=
|
||||
oldWidget.tabBarSelectedForegroundColor ||
|
||||
primaryColor != oldWidget.primaryColor ||
|
||||
onPrimaryColor != oldWidget.onPrimaryColor ||
|
||||
outlineColor != oldWidget.outlineColor ||
|
||||
|
|
|
|||
342
lib/src/editor/toolbar/mobile/mobile_toolbar_v2.dart
Normal file
342
lib/src/editor/toolbar/mobile/mobile_toolbar_v2.dart
Normal file
|
|
@ -0,0 +1,342 @@
|
|||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class MobileToolbarV2 extends StatelessWidget {
|
||||
const MobileToolbarV2({
|
||||
super.key,
|
||||
required this.editorState,
|
||||
required this.toolbarItems,
|
||||
});
|
||||
|
||||
final EditorState editorState;
|
||||
final List<MobileToolbarItem> toolbarItems;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder<Selection?>(
|
||||
valueListenable: editorState.selectionNotifier,
|
||||
builder: (_, Selection? selection, child) {
|
||||
// if the selection is null, hide the toolbar
|
||||
if (selection == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
// if the MobileToolbarTheme is not provided, provide it
|
||||
if (MobileToolbarTheme.maybeOf(context) == null) {
|
||||
child = MobileToolbarTheme(
|
||||
child: child!,
|
||||
);
|
||||
}
|
||||
|
||||
return RepaintBoundary(
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: _MobileToolbar(
|
||||
editorState: editorState,
|
||||
toolbarItems: toolbarItems,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MobileToolbar extends StatefulWidget {
|
||||
const _MobileToolbar({
|
||||
required this.editorState,
|
||||
required this.toolbarItems,
|
||||
});
|
||||
|
||||
final EditorState editorState;
|
||||
final List<MobileToolbarItem> toolbarItems;
|
||||
|
||||
@override
|
||||
State<_MobileToolbar> createState() => _MobileToolbarState();
|
||||
}
|
||||
|
||||
class _MobileToolbarState extends State<_MobileToolbar>
|
||||
implements MobileToolbarWidgetService {
|
||||
// used to control the toolbar menu items
|
||||
PropertyValueNotifier<bool> showMenuNotifier = PropertyValueNotifier(false);
|
||||
|
||||
// when the users click the menu item, the keyboard will be hidden,
|
||||
// but in this case, we don't want to update the cached keyboard height.
|
||||
// This is because we want to keep the same height when the menu is shown.
|
||||
bool canUpdateCachedKeyboardHeight = true;
|
||||
ValueNotifier<double> cachedKeyboardHeight = ValueNotifier(0.0);
|
||||
double get keyboardHeight => MediaQuery.of(context).viewInsets.bottom;
|
||||
|
||||
// used to check if click the same item again
|
||||
int? selectedMenuIndex;
|
||||
|
||||
Selection? currentSelection;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
currentSelection = widget.editorState.selection;
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant _MobileToolbar oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
||||
if (currentSelection != widget.editorState.selection) {
|
||||
currentSelection = widget.editorState.selection;
|
||||
closeItemMenu();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
showMenuNotifier.dispose();
|
||||
cachedKeyboardHeight.dispose();
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void reassemble() {
|
||||
super.reassemble();
|
||||
|
||||
canUpdateCachedKeyboardHeight = true;
|
||||
closeItemMenu();
|
||||
_closeKeyboard();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// update the keyboard height here.
|
||||
// try to get the height in `didChangeMetrics`, but it's not accurate.
|
||||
if (canUpdateCachedKeyboardHeight) {
|
||||
cachedKeyboardHeight.value = keyboardHeight;
|
||||
}
|
||||
// toolbar
|
||||
// - if the menu is shown, the toolbar will be pushed up by the height of the menu
|
||||
// - otherwise, add a spacer to push the toolbar up when the keyboard is shown
|
||||
return Column(
|
||||
children: [
|
||||
_buildToolbar(context),
|
||||
_buildMenuOrSpacer(context),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void closeItemMenu() {
|
||||
showMenuNotifier.value = false;
|
||||
}
|
||||
|
||||
void showItemMenu() {
|
||||
showMenuNotifier.value = true;
|
||||
}
|
||||
|
||||
// toolbar list view and close keyboard/menu button
|
||||
Widget _buildToolbar(BuildContext context) {
|
||||
final width = MediaQuery.of(context).size.width;
|
||||
final style = MobileToolbarTheme.of(context);
|
||||
|
||||
return Container(
|
||||
width: width,
|
||||
height: style.toolbarHeight,
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: style.itemOutlineColor,
|
||||
),
|
||||
bottom: BorderSide(color: style.itemOutlineColor),
|
||||
),
|
||||
color: style.backgroundColor,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// toolbar list view
|
||||
Expanded(
|
||||
child: _ToolbarItemListView(
|
||||
toolbarItems: widget.toolbarItems,
|
||||
editorState: widget.editorState,
|
||||
toolbarWidgetService: this,
|
||||
itemWithActionOnPressed: (_) {
|
||||
if (showMenuNotifier.value) {
|
||||
closeItemMenu();
|
||||
_showKeyboard();
|
||||
// update the cached keyboard height after the keyboard is shown
|
||||
Debounce.debounce('canUpdateCachedKeyboardHeight',
|
||||
const Duration(milliseconds: 500), () {
|
||||
canUpdateCachedKeyboardHeight = true;
|
||||
});
|
||||
}
|
||||
},
|
||||
itemWithMenuOnPressed: (index) {
|
||||
// click the same one
|
||||
if (selectedMenuIndex == index && showMenuNotifier.value) {
|
||||
// if the menu is shown, close it and show the keyboard
|
||||
closeItemMenu();
|
||||
_showKeyboard();
|
||||
// update the cached keyboard height after the keyboard is shown
|
||||
Debounce.debounce('canUpdateCachedKeyboardHeight',
|
||||
const Duration(milliseconds: 500), () {
|
||||
canUpdateCachedKeyboardHeight = true;
|
||||
});
|
||||
} else {
|
||||
canUpdateCachedKeyboardHeight = false;
|
||||
selectedMenuIndex = index;
|
||||
showItemMenu();
|
||||
_closeKeyboard();
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
// divider
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
vertical: 8,
|
||||
),
|
||||
child: VerticalDivider(
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
// close menu or close keyboard button
|
||||
ValueListenableBuilder(
|
||||
valueListenable: showMenuNotifier,
|
||||
builder: (_, showingMenu, __) {
|
||||
return _CloseKeyboardOrMenuButton(
|
||||
showingMenu: showingMenu,
|
||||
onPressed: () {
|
||||
if (showingMenu) {
|
||||
// close the menu and show the keyboard
|
||||
closeItemMenu();
|
||||
_showKeyboard();
|
||||
} else {
|
||||
// close the keyboard and clear the selection
|
||||
// if the selection is null, the keyboard and the toolbar will be hidden automatically
|
||||
widget.editorState.selection = null;
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(
|
||||
width: 4.0,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// if there's no menu, we need to add a spacer to push the toolbar up when the keyboard is shown
|
||||
Widget _buildMenuOrSpacer(BuildContext context) {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: cachedKeyboardHeight,
|
||||
builder: (_, height, ___) {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: showMenuNotifier,
|
||||
builder: (_, showingMenu, __) {
|
||||
return ConstrainedBox(
|
||||
constraints: BoxConstraints(minHeight: height),
|
||||
child: (showingMenu && selectedMenuIndex != null)
|
||||
? MobileToolbarItemMenu(
|
||||
editorState: widget.editorState,
|
||||
itemMenuBuilder: () => widget
|
||||
.toolbarItems[selectedMenuIndex!].itemMenuBuilder!
|
||||
.call(
|
||||
context,
|
||||
widget.editorState,
|
||||
this,
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _showKeyboard() {
|
||||
final selection = widget.editorState.selection;
|
||||
if (selection != null) {
|
||||
widget.editorState.service.keyboardService?.enableKeyBoard(selection);
|
||||
}
|
||||
}
|
||||
|
||||
void _closeKeyboard() {
|
||||
widget.editorState.service.keyboardService?.closeKeyboard();
|
||||
}
|
||||
}
|
||||
|
||||
class _ToolbarItemListView extends StatelessWidget {
|
||||
const _ToolbarItemListView({
|
||||
required this.toolbarItems,
|
||||
required this.editorState,
|
||||
required this.toolbarWidgetService,
|
||||
required this.itemWithMenuOnPressed,
|
||||
required this.itemWithActionOnPressed,
|
||||
});
|
||||
|
||||
final Function(int index) itemWithMenuOnPressed;
|
||||
final Function(int index) itemWithActionOnPressed;
|
||||
final List<MobileToolbarItem> toolbarItems;
|
||||
final EditorState editorState;
|
||||
final MobileToolbarWidgetService toolbarWidgetService;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListView.builder(
|
||||
itemBuilder: (context, index) {
|
||||
final toolbarItem = toolbarItems[index];
|
||||
final icon = toolbarItem.itemIconBuilder?.call(
|
||||
context,
|
||||
editorState,
|
||||
toolbarWidgetService,
|
||||
);
|
||||
if (icon == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return IconButton(
|
||||
icon: icon,
|
||||
onPressed: () {
|
||||
if (toolbarItem.hasMenu) {
|
||||
// open /close current item menu through its parent widget(MobileToolbarWidget)
|
||||
itemWithMenuOnPressed(index);
|
||||
} else {
|
||||
itemWithActionOnPressed(index);
|
||||
// close menu if other item's menu is still on the screen
|
||||
toolbarWidgetService.closeItemMenu();
|
||||
toolbarItems[index].actionHandler?.call(
|
||||
context,
|
||||
editorState,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
itemCount: toolbarItems.length,
|
||||
scrollDirection: Axis.horizontal,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CloseKeyboardOrMenuButton extends StatelessWidget {
|
||||
const _CloseKeyboardOrMenuButton({
|
||||
required this.showingMenu,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
final bool showingMenu;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
onPressed: onPressed,
|
||||
icon: showingMenu
|
||||
? const AFMobileIcon(
|
||||
afMobileIcons: AFMobileIcons.close,
|
||||
)
|
||||
: const Icon(Icons.keyboard_hide),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,137 @@
|
|||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
final blocksMobileToolbarItem = MobileToolbarItem.withMenu(
|
||||
itemIconBuilder: (_, __, ___) => const AFMobileIcon(
|
||||
afMobileIcons: AFMobileIcons.list,
|
||||
),
|
||||
itemMenuBuilder: (_, editorState, __) {
|
||||
final selection = editorState.selection;
|
||||
if (selection == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return _BlocksMenu(editorState, selection);
|
||||
},
|
||||
);
|
||||
|
||||
class _BlocksMenu extends StatefulWidget {
|
||||
const _BlocksMenu(
|
||||
this.editorState,
|
||||
this.selection,
|
||||
);
|
||||
|
||||
final Selection selection;
|
||||
final EditorState editorState;
|
||||
|
||||
@override
|
||||
State<_BlocksMenu> createState() => _BlocksMenuState();
|
||||
}
|
||||
|
||||
class _BlocksMenuState extends State<_BlocksMenu> {
|
||||
final lists = [
|
||||
// heading
|
||||
_ListUnit(
|
||||
icon: AFMobileIcons.h1,
|
||||
label: AppFlowyEditorL10n.current.mobileHeading1,
|
||||
name: HeadingBlockKeys.type,
|
||||
level: 1,
|
||||
),
|
||||
_ListUnit(
|
||||
icon: AFMobileIcons.h2,
|
||||
label: AppFlowyEditorL10n.current.mobileHeading2,
|
||||
name: HeadingBlockKeys.type,
|
||||
level: 2,
|
||||
),
|
||||
_ListUnit(
|
||||
icon: AFMobileIcons.h3,
|
||||
label: AppFlowyEditorL10n.current.mobileHeading3,
|
||||
name: HeadingBlockKeys.type,
|
||||
level: 3,
|
||||
),
|
||||
// list
|
||||
_ListUnit(
|
||||
icon: AFMobileIcons.bulletedList,
|
||||
label: AppFlowyEditorL10n.current.bulletedList,
|
||||
name: BulletedListBlockKeys.type,
|
||||
),
|
||||
_ListUnit(
|
||||
icon: AFMobileIcons.numberedList,
|
||||
label: AppFlowyEditorL10n.current.numberedList,
|
||||
name: NumberedListBlockKeys.type,
|
||||
),
|
||||
_ListUnit(
|
||||
icon: AFMobileIcons.checkbox,
|
||||
label: AppFlowyEditorL10n.current.checkbox,
|
||||
name: TodoListBlockKeys.type,
|
||||
),
|
||||
_ListUnit(
|
||||
icon: AFMobileIcons.quote,
|
||||
label: AppFlowyEditorL10n.current.quote,
|
||||
name: QuoteBlockKeys.type,
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final children = lists.map((list) {
|
||||
// Check if current node is list and its type
|
||||
final node = widget.editorState.getNodeAtPath(
|
||||
widget.selection.start.path,
|
||||
)!;
|
||||
|
||||
final isSelected = node.type == list.name &&
|
||||
(list.level == null ||
|
||||
node.attributes[HeadingBlockKeys.level] == list.level);
|
||||
|
||||
return MobileToolbarItemMenuBtn(
|
||||
icon: AFMobileIcon(afMobileIcons: list.icon),
|
||||
label: Text(list.label),
|
||||
isSelected: isSelected,
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
widget.editorState.formatNode(
|
||||
widget.selection,
|
||||
(node) => node.copyWith(
|
||||
type: isSelected ? ParagraphBlockKeys.type : list.name,
|
||||
attributes: {
|
||||
ParagraphBlockKeys.delta: (node.delta ?? Delta()).toJson(),
|
||||
blockComponentBackgroundColor:
|
||||
node.attributes[blockComponentBackgroundColor],
|
||||
if (!isSelected && list.name == TodoListBlockKeys.type)
|
||||
TodoListBlockKeys.checked: false,
|
||||
if (!isSelected && list.name == HeadingBlockKeys.type)
|
||||
HeadingBlockKeys.level: list.level,
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
}).toList();
|
||||
|
||||
return GridView(
|
||||
shrinkWrap: true,
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
mainAxisSpacing: 8,
|
||||
crossAxisSpacing: 8,
|
||||
childAspectRatio: 5,
|
||||
),
|
||||
children: children,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ListUnit {
|
||||
final AFMobileIcons icon;
|
||||
final String label;
|
||||
final String name;
|
||||
final int? level;
|
||||
|
||||
_ListUnit({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.name,
|
||||
this.level,
|
||||
});
|
||||
}
|
||||
|
|
@ -1,8 +1,10 @@
|
|||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
|
||||
final codeMobileToolbarItem = MobileToolbarItem.action(
|
||||
itemIconBuilder: (_, __) =>
|
||||
const AFMobileIcon(afMobileIcons: AFMobileIcons.code),
|
||||
actionHandler: (editorState, selection) =>
|
||||
editorState.toggleAttribute(AppFlowyRichTextKeys.code),
|
||||
itemIconBuilder: (_, __, ___) => const AFMobileIcon(
|
||||
afMobileIcons: AFMobileIcons.code,
|
||||
),
|
||||
actionHandler: (_, editorState) => editorState.toggleAttribute(
|
||||
AppFlowyRichTextKeys.code,
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ class _BackgroundColorOptionsWidgetsState
|
|||
extends State<BackgroundColorOptionsWidgets> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final style = MobileToolbarStyle.of(context);
|
||||
final style = MobileToolbarTheme.of(context);
|
||||
final colorOptions =
|
||||
widget.backgroundColorOptions ?? generateHighlightColorOptions();
|
||||
final selection = widget.selection;
|
||||
|
|
|
|||
|
|
@ -6,9 +6,14 @@ MobileToolbarItem buildTextAndBackgroundColorMobileToolbarItem({
|
|||
List<ColorOption>? backgroundColorOptions,
|
||||
}) {
|
||||
return MobileToolbarItem.withMenu(
|
||||
itemIconBuilder: (_, __) =>
|
||||
const AFMobileIcon(afMobileIcons: AFMobileIcons.color),
|
||||
itemMenuBuilder: (editorState, selection, _) {
|
||||
itemIconBuilder: (_, __, ___) => const AFMobileIcon(
|
||||
afMobileIcons: AFMobileIcons.color,
|
||||
),
|
||||
itemMenuBuilder: (_, editorState, ___) {
|
||||
final selection = editorState.selection;
|
||||
if (selection == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return _TextAndBackgroundColorMenu(
|
||||
editorState,
|
||||
selection,
|
||||
|
|
@ -41,7 +46,7 @@ class _TextAndBackgroundColorMenuState
|
|||
extends State<_TextAndBackgroundColorMenu> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final style = MobileToolbarStyle.of(context);
|
||||
final style = MobileToolbarTheme.of(context);
|
||||
List<Tab> myTabs = <Tab>[
|
||||
Tab(
|
||||
text: AppFlowyEditorL10n.current.textColor,
|
||||
|
|
@ -58,10 +63,10 @@ class _TextAndBackgroundColorMenuState
|
|||
child: TabBar(
|
||||
indicatorSize: TabBarIndicatorSize.tab,
|
||||
tabs: myTabs,
|
||||
labelColor: style.tabbarSelectedForegroundColor,
|
||||
labelColor: style.tabBarSelectedForegroundColor,
|
||||
indicator: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(style.borderRadius),
|
||||
color: style.tabbarSelectedBackgroundColor,
|
||||
color: style.tabBarSelectedForegroundColor,
|
||||
),
|
||||
// remove the bottom line of TabBar
|
||||
dividerColor: Colors.transparent,
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ class TextColorOptionsWidgets extends StatefulWidget {
|
|||
class _TextColorOptionsWidgetsState extends State<TextColorOptionsWidgets> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final style = MobileToolbarStyle.of(context);
|
||||
final style = MobileToolbarTheme.of(context);
|
||||
|
||||
final selection = widget.selection;
|
||||
final nodes = widget.editorState.getNodesInSelection(selection);
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ class ClearColorButton extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final style = MobileToolbarStyle.of(context);
|
||||
final style = MobileToolbarTheme.of(context);
|
||||
|
||||
return InkWell(
|
||||
onTap: onPressed,
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ class ColorButton extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final style = MobileToolbarStyle.of(context);
|
||||
final style = MobileToolbarTheme.of(context);
|
||||
return InkWell(
|
||||
onTap: onPressed,
|
||||
child: Container(
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
|
||||
final dividerMobileToolbarItem = MobileToolbarItem.action(
|
||||
itemIconBuilder: (_, __) =>
|
||||
const AFMobileIcon(afMobileIcons: AFMobileIcons.divider),
|
||||
actionHandler: ((editorState, selection) {
|
||||
itemIconBuilder: (_, __, ___) => const AFMobileIcon(
|
||||
afMobileIcons: AFMobileIcons.divider,
|
||||
),
|
||||
actionHandler: (_, editorState) {
|
||||
// same as the [handler] of [dividerMenuItem] in Desktop
|
||||
final selection = editorState.selection;
|
||||
if (selection == null || !selection.isCollapsed) {
|
||||
|
|
@ -29,5 +30,5 @@ final dividerMobileToolbarItem = MobileToolbarItem.action(
|
|||
transaction.afterSelection =
|
||||
Selection.collapsed(Position(path: insertedPath.next));
|
||||
editorState.apply(transaction);
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,9 +2,14 @@ import 'package:appflowy_editor/appflowy_editor.dart';
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
final headingMobileToolbarItem = MobileToolbarItem.withMenu(
|
||||
itemIconBuilder: (_, __) =>
|
||||
const AFMobileIcon(afMobileIcons: AFMobileIcons.heading),
|
||||
itemMenuBuilder: (editorState, selection, _) {
|
||||
itemIconBuilder: (_, __, ___) => const AFMobileIcon(
|
||||
afMobileIcons: AFMobileIcons.heading,
|
||||
),
|
||||
itemMenuBuilder: (_, editorState, __) {
|
||||
final selection = editorState.selection;
|
||||
if (selection == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return _HeadingMenu(
|
||||
selection,
|
||||
editorState,
|
||||
|
|
@ -46,7 +51,7 @@ class _HeadingMenuState extends State<_HeadingMenu> {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final style = MobileToolbarStyle.of(context);
|
||||
final style = MobileToolbarTheme.of(context);
|
||||
final size = MediaQuery.sizeOf(context);
|
||||
final btnList = headings.map((currentHeading) {
|
||||
// Check if current node is heading and its level
|
||||
|
|
|
|||
|
|
@ -2,10 +2,14 @@ import 'package:appflowy_editor/appflowy_editor.dart';
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
final linkMobileToolbarItem = MobileToolbarItem.withMenu(
|
||||
itemIconBuilder: (_, __) => const AFMobileIcon(
|
||||
itemIconBuilder: (_, __, ___) => const AFMobileIcon(
|
||||
afMobileIcons: AFMobileIcons.link,
|
||||
),
|
||||
itemMenuBuilder: (editorState, selection, itemMenuService) {
|
||||
itemMenuBuilder: (_, editorState, itemMenuService) {
|
||||
final selection = editorState.selection;
|
||||
if (selection == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final String? linkText = editorState.getDeltaAttributeValueInSelection(
|
||||
AppFlowyRichTextKeys.href,
|
||||
selection,
|
||||
|
|
@ -65,7 +69,7 @@ class _MobileLinkMenuState extends State<MobileLinkMenu> {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final style = MobileToolbarStyle.of(context);
|
||||
final style = MobileToolbarTheme.of(context);
|
||||
const double spacing = 8;
|
||||
return Material(
|
||||
// TextField widget needs to be wrapped in a Material widget to provide a visual appearance
|
||||
|
|
|
|||
|
|
@ -2,9 +2,14 @@ import 'package:appflowy_editor/appflowy_editor.dart';
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
final listMobileToolbarItem = MobileToolbarItem.withMenu(
|
||||
itemIconBuilder: (_, __) =>
|
||||
const AFMobileIcon(afMobileIcons: AFMobileIcons.list),
|
||||
itemMenuBuilder: (editorState, selection, _) {
|
||||
itemIconBuilder: (_, __, ___) => const AFMobileIcon(
|
||||
afMobileIcons: AFMobileIcons.list,
|
||||
),
|
||||
itemMenuBuilder: (_, editorState, __) {
|
||||
final selection = editorState.selection;
|
||||
if (selection == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return _ListMenu(editorState, selection);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,10 +1,18 @@
|
|||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
|
||||
final quoteMobileToolbarItem = MobileToolbarItem.action(
|
||||
itemIconBuilder: (_, __) =>
|
||||
const AFMobileIcon(afMobileIcons: AFMobileIcons.quote),
|
||||
actionHandler: ((editorState, selection) {
|
||||
final node = editorState.getNodeAtPath(selection.start.path)!;
|
||||
itemIconBuilder: (_, __, ___) => const AFMobileIcon(
|
||||
afMobileIcons: AFMobileIcons.quote,
|
||||
),
|
||||
actionHandler: (context, editorState) async {
|
||||
final selection = editorState.selection;
|
||||
if (selection == null) {
|
||||
return;
|
||||
}
|
||||
final node = editorState.getNodeAtPath(selection.start.path);
|
||||
if (node == null) {
|
||||
return;
|
||||
}
|
||||
final isQuote = node.type == QuoteBlockKeys.type;
|
||||
editorState.formatNode(
|
||||
selection,
|
||||
|
|
@ -15,5 +23,5 @@ final quoteMobileToolbarItem = MobileToolbarItem.action(
|
|||
},
|
||||
),
|
||||
);
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue