feat: keep the toolbar the same height as the keyboard to optimize the editing experience (#3947)

This commit is contained in:
Lucas.Xu
2023-11-17 13:51:26 +08:00
committed by GitHub
parent 68de83c611
commit d190850f03
67 changed files with 1731 additions and 486 deletions

View File

@ -12,9 +12,11 @@ class FlowyEmojiPicker extends StatefulWidget {
const FlowyEmojiPicker({
super.key,
required this.onEmojiSelected,
this.emojiPerLine = 9,
});
final EmojiSelectedCallback onEmojiSelected;
final int emojiPerLine;
@override
State<FlowyEmojiPicker> createState() => _FlowyEmojiPickerState();
@ -61,6 +63,7 @@ class _FlowyEmojiPickerState extends State<FlowyEmojiPicker> {
showSectionHeader: true,
showTabs: false,
defaultSkinTone: lastSelectedEmojiSkinTone ?? EmojiSkinTone.none,
perLine: widget.emojiPerLine,
),
onEmojiSelected: widget.onEmojiSelected,
headerBuilder: (context, category) {

View File

@ -5,14 +5,19 @@ import 'package:go_router/go_router.dart';
class MobileEmojiPickerScreen extends StatelessWidget {
static const routeName = '/emoji_picker';
static const pageTitle = 'title';
const MobileEmojiPickerScreen({
super.key,
this.title,
});
final String? title;
@override
Widget build(BuildContext context) {
return IconPickerPage(
title: title,
onSelected: (result) {
context.pop<EmojiPickerResult>(result);
},

View File

@ -105,10 +105,12 @@ class _SearchTextField extends StatefulWidget {
class _SearchTextFieldState extends State<_SearchTextField> {
final TextEditingController controller = TextEditingController();
final FocusNode focusNode = FocusNode();
@override
void dispose() {
controller.dispose();
focusNode.dispose();
super.dispose();
}
@ -120,7 +122,7 @@ class _SearchTextFieldState extends State<_SearchTextField> {
maxHeight: 32.0,
),
child: FlowyTextField(
autoFocus: true,
focusNode: focusNode,
hintText: LocaleKeys.emoji_search.tr(),
controller: controller,
onChanged: widget.onKeywordChanged,
@ -145,8 +147,12 @@ class _SearchTextFieldState extends State<_SearchTextField> {
margin: EdgeInsets.zero,
useIntrinsicWidth: true,
onTap: () {
controller.clear();
widget.onKeywordChanged('');
if (controller.text.isNotEmpty) {
controller.clear();
widget.onKeywordChanged('');
} else {
focusNode.unfocus();
}
},
),
),

View File

@ -72,6 +72,7 @@ class _FlowyIconPickerState extends State<FlowyIconPicker>
child: TabBarView(
children: [
FlowyEmojiPicker(
emojiPerLine: _getEmojiPerLine(),
onEmojiSelected: (_, emoji) {
widget.onSelected(
EmojiPickerResult(
@ -116,6 +117,11 @@ class _FlowyIconPickerState extends State<FlowyIconPicker>
),
);
}
int _getEmojiPerLine() {
final width = MediaQuery.of(context).size.width;
return width ~/ 46.0; // the size of the emoji
}
}
class _RemoveIconButton extends StatelessWidget {

View File

@ -6,26 +6,23 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
class IconPickerPage extends StatefulWidget {
class IconPickerPage extends StatelessWidget {
const IconPickerPage({
super.key,
this.title,
required this.onSelected,
});
final void Function(EmojiPickerResult) onSelected;
final String? title;
@override
State<IconPickerPage> createState() => _IconPickerPageState();
}
class _IconPickerPageState extends State<IconPickerPage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
titleSpacing: 0,
title: FlowyText.semibold(
LocaleKeys.titleBar_pageIcon.tr(),
title ?? LocaleKeys.titleBar_pageIcon.tr(),
fontSize: 14.0,
),
leading: AppBarBackButton(
@ -34,7 +31,7 @@ class _IconPickerPageState extends State<IconPickerPage> {
),
body: SafeArea(
child: FlowyIconPicker(
onSelected: widget.onSelected,
onSelected: onSelected,
),
),
);

View File

@ -255,7 +255,7 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
behavior: HitTestBehavior.translucent,
onTap: () async {
// if the last one isn't a empty node, insert a new empty node.
await _ensureLastNodeIsEmptyParagraph();
await _focusOnLastEmptyParagraph();
},
child: VSpace(PlatformExtension.isDesktopOrWeb ? 200 : 400),
),
@ -266,51 +266,49 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
_setInitialSelection(editorScrollController);
if (PlatformExtension.isMobile) {
return Column(
children: [
Expanded(
child: MobileFloatingToolbar(
editorState: editorState,
editorScrollController: editorScrollController,
toolbarBuilder: (context, anchor, closeToolbar) {
return AdaptiveTextSelectionToolbar.editable(
clipboardStatus: ClipboardStatus.pasteable,
onCopy: () {
copyCommand.execute(editorState);
closeToolbar();
},
onCut: () => cutCommand.execute(editorState),
onPaste: () => pasteCommand.execute(editorState),
onSelectAll: () => selectAllCommand.execute(editorState),
onLiveTextInput: null,
anchors: TextSelectionToolbarAnchors(
primaryAnchor: anchor,
),
);
},
child: editor,
),
),
MobileToolbar(
editorState: editorState,
toolbarItems: [
textDecorationMobileToolbarItem,
buildTextAndBackgroundColorMobileToolbarItem(),
headingMobileToolbarItem,
mobileBlocksToolbarItem,
linkMobileToolbarItem,
dividerMobileToolbarItem,
imageMobileToolbarItem,
mathEquationMobileToolbarItem,
codeMobileToolbarItem,
mobileAlignToolbarItem,
mobileIndentToolbarItem,
mobileOutdentToolbarItem,
undoMobileToolbarItem,
redoMobileToolbarItem,
],
),
return MobileToolbarV2(
toolbarHeight: 48.0,
editorState: editorState,
toolbarItems: [
customTextDecorationMobileToolbarItem,
buildTextAndBackgroundColorMobileToolbarItem(),
mobileAddBlockToolbarItem,
mobileConvertBlockToolbarItem,
linkMobileToolbarItem,
imageMobileToolbarItem,
mobileAlignToolbarItem,
mobileIndentToolbarItem,
mobileOutdentToolbarItem,
undoMobileToolbarItem,
redoMobileToolbarItem,
],
child: Column(
children: [
Expanded(
child: MobileFloatingToolbar(
editorState: editorState,
editorScrollController: editorScrollController,
toolbarBuilder: (context, anchor, closeToolbar) {
return AdaptiveTextSelectionToolbar.editable(
clipboardStatus: ClipboardStatus.pasteable,
onCopy: () {
copyCommand.execute(editorState);
closeToolbar();
},
onCut: () => cutCommand.execute(editorState),
onPaste: () => pasteCommand.execute(editorState),
onSelectAll: () => selectAllCommand.execute(editorState),
onLiveTextInput: null,
anchors: TextSelectionToolbarAnchors(
primaryAnchor: anchor,
),
);
},
child: editor,
),
),
],
),
);
}
@ -482,19 +480,23 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
AppFlowyEditorL10n.current = EditorI18n();
}
Future<void> _ensureLastNodeIsEmptyParagraph() async {
Future<void> _focusOnLastEmptyParagraph() async {
final editorState = widget.editorState;
final root = editorState.document.root;
final lastNode = root.children.lastOrNull;
final transaction = editorState.transaction;
if (lastNode == null ||
lastNode.delta?.isEmpty == false ||
lastNode.type != ParagraphBlockKeys.type) {
final transaction = editorState.transaction;
transaction.insertNode([root.children.length], paragraphNode());
transaction.afterSelection = Selection.collapsed(
Position(path: [root.children.length]),
);
await editorState.apply(transaction);
} else {
transaction.afterSelection = Selection.collapsed(
Position(path: lastNode.path),
);
}
await editorState.apply(transaction);
}
}

View File

@ -229,7 +229,11 @@ class _DocumentHeaderToolbarState extends State<DocumentHeaderToolbar> {
Widget child = Container(
alignment: Alignment.bottomLeft,
width: double.infinity,
padding: EditorStyleCustomizer.documentPadding,
padding: PlatformExtension.isDesktopOrWeb
? EditorStyleCustomizer.documentPadding
: EdgeInsets.symmetric(
horizontal: EditorStyleCustomizer.documentPadding.left - 6.0,
),
child: SizedBox(
height: 28,
child: Row(
@ -420,48 +424,50 @@ class DocumentCoverState extends State<DocumentCover> {
right: 12,
child: Row(
children: [
RoundedTextButton(
onPressed: () {
showFlowyMobileBottomSheet(
context,
title: LocaleKeys.document_plugins_cover_changeCover.tr(),
builder: (context) {
return ConstrainedBox(
constraints: const BoxConstraints(
maxHeight: 340,
minHeight: 80,
),
child: UploadImageMenu(
supportTypes: const [
UploadImageType.color,
UploadImageType.local,
UploadImageType.url,
UploadImageType.unsplash,
],
onSelectedLocalImage: (path) async {
context.pop();
widget.onCoverChanged(CoverType.file, path);
},
onSelectedAIImage: (_) {
throw UnimplementedError();
},
onSelectedNetworkImage: (url) async {
context.pop();
widget.onCoverChanged(CoverType.file, url);
},
onSelectedColor: (color) {
context.pop();
widget.onCoverChanged(CoverType.color, color);
},
),
);
},
);
},
fillColor: Theme.of(context).colorScheme.onSurfaceVariant,
width: 120,
height: 32,
title: LocaleKeys.document_plugins_cover_changeCover.tr(),
IntrinsicWidth(
child: RoundedTextButton(
onPressed: () {
showFlowyMobileBottomSheet(
context,
title:
LocaleKeys.document_plugins_cover_changeCover.tr(),
builder: (context) {
return ConstrainedBox(
constraints: const BoxConstraints(
maxHeight: 340,
minHeight: 80,
),
child: UploadImageMenu(
supportTypes: const [
UploadImageType.color,
UploadImageType.local,
UploadImageType.url,
UploadImageType.unsplash,
],
onSelectedLocalImage: (path) async {
context.pop();
widget.onCoverChanged(CoverType.file, path);
},
onSelectedAIImage: (_) {
throw UnimplementedError();
},
onSelectedNetworkImage: (url) async {
context.pop();
widget.onCoverChanged(CoverType.file, url);
},
onSelectedColor: (color) {
context.pop();
widget.onCoverChanged(CoverType.color, color);
},
),
);
},
);
},
fillColor: Theme.of(context).colorScheme.onSurfaceVariant,
height: 32,
title: LocaleKeys.document_plugins_cover_changeCover.tr(),
),
),
const HSpace(8.0),
SizedBox.square(
@ -530,14 +536,16 @@ class DocumentCoverState extends State<DocumentCover> {
constraints: BoxConstraints.loose(const Size(380, 450)),
margin: EdgeInsets.zero,
onClose: () => isPopoverOpen = false,
child: RoundedTextButton(
onPressed: () => popoverController.show(),
hoverColor: Theme.of(context).colorScheme.surface,
textColor: Theme.of(context).colorScheme.tertiary,
fillColor: Theme.of(context).colorScheme.surface.withOpacity(0.5),
width: 120,
height: 28,
title: LocaleKeys.document_plugins_cover_changeCover.tr(),
child: IntrinsicWidth(
child: RoundedTextButton(
height: 28.0,
onPressed: () => popoverController.show(),
hoverColor: Theme.of(context).colorScheme.surface,
textColor: Theme.of(context).colorScheme.tertiary,
fillColor:
Theme.of(context).colorScheme.surface.withOpacity(0.5),
title: LocaleKeys.document_plugins_cover_changeCover.tr(),
),
),
popupBuilder: (BuildContext popoverContext) {
isPopoverOpen = true;
@ -575,7 +583,7 @@ class DeleteCoverButton extends StatelessWidget {
Widget build(BuildContext context) {
return FlowyIconButton(
hoverColor: Theme.of(context).colorScheme.surface,
fillColor: Theme.of(context).colorScheme.onSurfaceVariant,
fillColor: Theme.of(context).colorScheme.surface.withOpacity(0.5),
iconPadding: const EdgeInsets.all(5),
width: 28,
icon: FlowySvg(

View File

@ -1,5 +1,7 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/base/app_bar_actions.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
@ -22,8 +24,8 @@ class _ImagePickerPageState extends State<ImagePickerPage> {
return Scaffold(
appBar: AppBar(
titleSpacing: 0,
title: const FlowyText.semibold(
'Page icon',
title: FlowyText.semibold(
LocaleKeys.titleBar_pageIcon.tr(),
fontSize: 14.0,
),
leading: AppBarBackButton(

View File

@ -5,8 +5,8 @@ import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
final imageMobileToolbarItem = MobileToolbarItem.action(
itemIconBuilder: (_, __) => const FlowySvg(FlowySvgs.m_toolbar_imae_lg),
actionHandler: (editorState, selection) async {
itemIconBuilder: (_, __, ___) => const FlowySvg(FlowySvgs.m_toolbar_imae_lg),
actionHandler: (_, editorState) async {
final imagePlaceholderKey = GlobalKey<ImagePlaceholderState>();
await editorState.insertEmptyImageBlock(imagePlaceholderKey);

View File

@ -4,10 +4,13 @@ import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
final mathEquationMobileToolbarItem = MobileToolbarItem.action(
itemIconBuilder: (_, __) =>
const SizedBox(width: 22, child: FlowySvg(FlowySvgs.math_lg)),
actionHandler: (editorState, selection) async {
if (!selection.isCollapsed) {
itemIconBuilder: (_, __, ___) => const SizedBox(
width: 22,
child: FlowySvg(FlowySvgs.math_lg),
),
actionHandler: (_, editorState) async {
final selection = editorState.selection;
if (selection == null || !selection.isCollapsed) {
return;
}
final path = selection.start.path;

View File

@ -0,0 +1,327 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_item/mobile_blocks_menu.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
// convert the current block to other block types
// only show in single selection and text type
final mobileAddBlockToolbarItem = MobileToolbarItem.withMenu(
itemIconBuilder: (_, editorState, ___) {
if (!onlyShowInSingleSelectionAndTextType(editorState)) {
return null;
}
return const FlowySvg(
FlowySvgs.add_m,
size: Size.square(48),
);
},
itemMenuBuilder: (_, editorState, service) {
final selection = editorState.selection;
if (selection == null) {
return null;
}
return BlocksMenu(
items: _addBlockMenuItems,
editorState: editorState,
service: service,
);
},
);
final _addBlockMenuItems = [
// paragraph
BlockMenuItem(
blockType: ParagraphBlockKeys.type,
icon: const FlowySvg(FlowySvgs.m_text_decoration_m),
label: LocaleKeys.editor_text.tr(),
isSelected: _unSelectable,
onTap: (editorState, selection, service) async {
await editorState.insertBlockOrReplaceCurrentBlock(
selection,
paragraphNode(),
);
service.closeItemMenu();
},
),
// to-do list
BlockMenuItem(
blockType: TodoListBlockKeys.type,
icon: const FlowySvg(FlowySvgs.m_checkbox_m),
label: LocaleKeys.editor_checkbox.tr(),
isSelected: _unSelectable,
onTap: (editorState, selection, service) async {
await editorState.insertBlockOrReplaceCurrentBlock(
selection,
todoListNode(checked: false),
);
service.closeItemMenu();
},
),
// heading 1 - 3
BlockMenuItem(
blockType: HeadingBlockKeys.type,
icon: const FlowySvg(FlowySvgs.m_h1_m),
label: LocaleKeys.editor_heading1.tr(),
isSelected: _unSelectable,
onTap: (editorState, selection, service) async {
await editorState.insertBlockOrReplaceCurrentBlock(
selection,
headingNode(level: 1),
);
service.closeItemMenu();
},
),
BlockMenuItem(
blockType: HeadingBlockKeys.type,
icon: const FlowySvg(FlowySvgs.m_h2_m),
label: LocaleKeys.editor_heading2.tr(),
isSelected: _unSelectable,
onTap: (editorState, selection, service) async {
await editorState.insertBlockOrReplaceCurrentBlock(
selection,
headingNode(level: 2),
);
service.closeItemMenu();
},
),
BlockMenuItem(
blockType: HeadingBlockKeys.type,
icon: const FlowySvg(FlowySvgs.m_h3_m),
label: LocaleKeys.editor_heading3.tr(),
isSelected: _unSelectable,
onTap: (editorState, selection, service) async {
await editorState.insertBlockOrReplaceCurrentBlock(
selection,
headingNode(level: 3),
);
service.closeItemMenu();
},
),
// bulleted list
BlockMenuItem(
blockType: BulletedListBlockKeys.type,
icon: const FlowySvg(FlowySvgs.m_bulleted_list_m),
label: LocaleKeys.editor_bulletedList.tr(),
isSelected: _unSelectable,
onTap: (editorState, selection, service) async {
await editorState.insertBlockOrReplaceCurrentBlock(
selection,
bulletedListNode(),
);
service.closeItemMenu();
},
),
// numbered list
BlockMenuItem(
blockType: NumberedListBlockKeys.type,
icon: const FlowySvg(FlowySvgs.m_numbered_list_m),
label: LocaleKeys.editor_numberedList.tr(),
isSelected: _unSelectable,
onTap: (editorState, selection, service) async {
await editorState.insertBlockOrReplaceCurrentBlock(
selection,
numberedListNode(),
);
service.closeItemMenu();
},
),
// toggle list
BlockMenuItem(
blockType: ToggleListBlockKeys.type,
icon: const FlowySvg(FlowySvgs.m_toggle_list_m),
label: LocaleKeys.document_plugins_toggleList.tr(),
isSelected: _unSelectable,
onTap: (editorState, selection, service) async {
await editorState.insertBlockOrReplaceCurrentBlock(
selection,
toggleListBlockNode(),
);
service.closeItemMenu();
},
),
// quote
BlockMenuItem(
blockType: QuoteBlockKeys.type,
icon: const FlowySvg(FlowySvgs.m_quote_m),
label: LocaleKeys.editor_quote.tr(),
isSelected: _unSelectable,
onTap: (editorState, selection, service) async {
await editorState.insertBlockOrReplaceCurrentBlock(
selection,
quoteNode(),
);
service.closeItemMenu();
},
),
// callout
BlockMenuItem(
blockType: CalloutBlockKeys.type,
// FIXME: update icon
icon: const Icon(Icons.note_rounded),
label: LocaleKeys.document_plugins_callout.tr(),
isSelected: _unSelectable,
onTap: (editorState, selection, service) async {
await editorState.insertBlockOrReplaceCurrentBlock(
selection,
calloutNode(),
);
service.closeItemMenu();
},
),
// code
BlockMenuItem(
blockType: CodeBlockKeys.type,
icon: const FlowySvg(FlowySvgs.m_code_m),
label: LocaleKeys.document_selectionMenu_codeBlock.tr(),
isSelected: _unSelectable,
onTap: (editorState, selection, service) async {
await editorState.insertBlockOrReplaceCurrentBlock(
selection,
codeBlockNode(),
);
service.closeItemMenu();
},
),
// divider
BlockMenuItem(
blockType: DividerBlockKeys.type,
icon: const FlowySvg(FlowySvgs.m_divider_m),
label: LocaleKeys.editor_divider.tr(),
isSelected: _unSelectable,
onTap: (editorState, selection, service) async {
await editorState.insertDivider(selection);
service.closeItemMenu();
},
),
// math equation
BlockMenuItem(
blockType: MathEquationBlockKeys.type,
icon: const FlowySvg(
FlowySvgs.math_lg,
size: Size.square(22),
),
label: LocaleKeys.document_plugins_mathEquation_name.tr(),
isSelected: _unSelectable,
onTap: (editorState, selection, service) async {
await editorState.insertMathEquation(selection);
service.closeItemMenu();
},
),
];
bool _unSelectable(
EditorState editorState,
Selection selection,
) {
return false;
}
extension on EditorState {
Future<void> insertBlockOrReplaceCurrentBlock(
Selection selection,
Node insertedNode,
) async {
// If the current block is not an empty paragraph block,
// then insert a new block below the current block.
final node = getNodeAtPath(selection.start.path);
if (node == null) {
return;
}
final transaction = this.transaction;
if (node.type != ParagraphBlockKeys.type ||
(node.delta?.isNotEmpty ?? true)) {
final path = node.path.next;
// insert the block below the current empty paragraph block
transaction
..insertNode(path, insertedNode)
..afterSelection = Selection.collapsed(
Position(path: path, offset: 0),
);
} else {
final path = node.path;
// replace the current empty paragraph block with the inserted block
transaction
..insertNode(path, insertedNode)
..deleteNode(node)
..afterSelection = Selection.collapsed(
Position(path: path, offset: 0),
);
}
await apply(transaction);
}
Future<void> insertMathEquation(
Selection selection,
) async {
final path = selection.start.path;
final node = getNodeAtPath(path);
final delta = node?.delta;
if (node == null || delta == null) {
return;
}
final transaction = this.transaction;
final insertedNode = mathEquationNode();
if (delta.isEmpty) {
transaction
..insertNode(path, insertedNode)
..deleteNode(node);
} else {
transaction.insertNode(
path.next,
insertedNode,
);
}
await apply(transaction);
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
final mathEquationState = getNodeAtPath(path)?.key.currentState;
if (mathEquationState != null &&
mathEquationState is MathEquationBlockComponentWidgetState) {
mathEquationState.showEditingDialog();
}
});
}
Future<void> insertDivider(Selection selection) async {
// same as the [handler] of [dividerMenuItem] in Desktop
final path = selection.end.path;
final node = getNodeAtPath(path);
final delta = node?.delta;
if (node == null || delta == null) {
return;
}
final insertedPath = delta.isEmpty ? path : path.next;
final transaction = this.transaction;
transaction.insertNode(insertedPath, dividerNode());
// only insert a new paragraph node when the next node is not a paragraph node
// and its delta is not empty.
final next = node.next;
if (next == null ||
next.type != ParagraphBlockKeys.type ||
next.delta?.isNotEmpty == true) {
transaction.insertNode(
insertedPath,
paragraphNode(),
);
}
transaction.afterSelection = Selection.collapsed(
Position(path: insertedPath.next),
);
await apply(transaction);
}
}

View File

@ -6,7 +6,7 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
final mobileAlignToolbarItem = MobileToolbarItem.withMenu(
itemIconBuilder: (_, editorState) {
itemIconBuilder: (_, editorState, __) {
return onlyShowInTextType(editorState)
? const FlowySvg(
FlowySvgs.toolbar_align_center_s,
@ -14,7 +14,11 @@ final mobileAlignToolbarItem = MobileToolbarItem.withMenu(
)
: null;
},
itemMenuBuilder: (editorState, selection, _) {
itemMenuBuilder: (_, editorState, ___) {
final selection = editorState.selection;
if (selection == null) {
return null;
}
return _MobileAlignMenu(
editorState: editorState,
selection: selection,
@ -34,6 +38,7 @@ class _MobileAlignMenu extends StatelessWidget {
@override
Widget build(BuildContext context) {
return GridView.count(
padding: EdgeInsets.zero,
crossAxisCount: 3,
mainAxisSpacing: 8,
crossAxisSpacing: 8,

View File

@ -0,0 +1,90 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
class BlockMenuItem {
const BlockMenuItem({
required this.blockType,
required this.icon,
required this.label,
required this.onTap,
this.isSelected,
});
// block type
final String blockType;
final Widget icon;
final String label;
// callback
final void Function(
EditorState editorState,
Selection selection,
// used to control the open or close the menu
MobileToolbarWidgetService service,
) onTap;
final bool Function(
EditorState editorState,
Selection selection,
)? isSelected;
}
class BlocksMenu extends StatelessWidget {
const BlocksMenu({
super.key,
required this.editorState,
required this.items,
required this.service,
});
final EditorState editorState;
final List<BlockMenuItem> items;
final MobileToolbarWidgetService service;
@override
Widget build(BuildContext context) {
return GridView.count(
crossAxisCount: 2,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
childAspectRatio: 4,
padding: const EdgeInsets.only(
bottom: 36.0,
),
shrinkWrap: true,
children: items.map((item) {
final selection = editorState.selection;
if (selection == null) {
return const SizedBox.shrink();
}
bool isSelected = false;
if (item.isSelected != null) {
isSelected = item.isSelected!(editorState, selection);
} else {
isSelected = _isSelected(editorState, selection, item.blockType);
}
return MobileToolbarItemMenuBtn(
icon: item.icon,
label: FlowyText(item.label),
isSelected: isSelected,
onPressed: () async {
item.onTap(editorState, selection, service);
},
);
}).toList(),
);
}
bool _isSelected(
EditorState editorState,
Selection selection,
String blockType,
) {
final node = editorState.getNodeAtPath(selection.start.path);
final type = node?.type;
if (node == null || type == null) {
return false;
}
return type == blockType;
}
}

View File

@ -1,142 +0,0 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
final mobileBlocksToolbarItem = MobileToolbarItem.withMenu(
itemIconBuilder: (_, __) =>
const AFMobileIcon(afMobileIcons: AFMobileIcons.list),
itemMenuBuilder: (editorState, selection, _) {
return _MobileListMenu(
editorState: editorState,
selection: selection,
);
},
);
class _MobileListMenu extends StatelessWidget {
const _MobileListMenu({
required this.editorState,
required this.selection,
});
final Selection selection;
final EditorState editorState;
@override
Widget build(BuildContext context) {
return GridView.count(
crossAxisCount: 2,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
childAspectRatio: 5,
shrinkWrap: true,
children: [
// bulleted list, numbered list
_buildListButton(
context,
BulletedListBlockKeys.type,
const AFMobileIcon(afMobileIcons: AFMobileIcons.bulletedList),
LocaleKeys.document_plugins_bulletedList.tr(),
),
_buildListButton(
context,
NumberedListBlockKeys.type,
const AFMobileIcon(afMobileIcons: AFMobileIcons.numberedList),
LocaleKeys.document_plugins_numberedList.tr(),
),
// todo list, quote list
_buildListButton(
context,
TodoListBlockKeys.type,
const AFMobileIcon(afMobileIcons: AFMobileIcons.checkbox),
LocaleKeys.document_plugins_todoList.tr(),
),
_buildListButton(
context,
QuoteBlockKeys.type,
const AFMobileIcon(afMobileIcons: AFMobileIcons.quote),
LocaleKeys.document_plugins_quoteList.tr(),
),
// toggle list, callout
_buildListButton(
context,
ToggleListBlockKeys.type,
const FlowySvg(
FlowySvgs.toggle_list_s,
size: Size.square(24),
),
LocaleKeys.document_plugins_toggleList.tr(),
),
_buildListButton(
context,
CalloutBlockKeys.type,
const Icon(Icons.note_rounded),
LocaleKeys.document_plugins_callout.tr(),
),
_buildListButton(
context,
CodeBlockKeys.type,
const Icon(Icons.abc),
LocaleKeys.document_selectionMenu_codeBlock.tr(),
),
// code block
_buildListButton(
context,
CodeBlockKeys.type,
const Icon(Icons.abc),
LocaleKeys.document_selectionMenu_codeBlock.tr(),
),
// outline
_buildListButton(
context,
OutlineBlockKeys.type,
const Icon(Icons.list_alt),
LocaleKeys.document_selectionMenu_outline.tr(),
),
],
);
}
Widget _buildListButton(
BuildContext context,
String listBlockType,
Widget icon,
String label,
) {
final node = editorState.getNodeAtPath(selection.start.path);
final type = node?.type;
if (node == null || type == null) {
const SizedBox.shrink();
}
final isSelected = type == listBlockType;
return MobileToolbarItemMenuBtn(
icon: icon,
label: FlowyText(label),
isSelected: isSelected,
onPressed: () async {
await editorState.formatNode(
selection,
(node) {
final attributes = {
ParagraphBlockKeys.delta: (node.delta ?? Delta()).toJson(),
if (listBlockType == TodoListBlockKeys.type)
TodoListBlockKeys.checked: false,
if (listBlockType == CalloutBlockKeys.type)
CalloutBlockKeys.icon: '📌',
};
return node.copyWith(
type: isSelected ? ParagraphBlockKeys.type : listBlockType,
attributes: attributes,
);
},
);
},
);
}
}

View File

@ -0,0 +1,251 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_item/mobile_blocks_menu.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
// convert the current block to other block types
// only show in single selection and text type
final mobileConvertBlockToolbarItem = MobileToolbarItem.withMenu(
itemIconBuilder: (_, editorState, ___) {
if (!onlyShowInSingleSelectionAndTextType(editorState)) {
return null;
}
return const FlowySvg(
FlowySvgs.convert_s,
size: Size.square(22),
);
},
itemMenuBuilder: (_, editorState, service) {
final selection = editorState.selection;
if (selection == null) {
return null;
}
return BlocksMenu(
items: _convertToBlockMenuItems,
editorState: editorState,
service: service,
);
},
);
final _convertToBlockMenuItems = [
// paragraph
BlockMenuItem(
blockType: ParagraphBlockKeys.type,
icon: const FlowySvg(FlowySvgs.m_text_decoration_m),
label: LocaleKeys.editor_text.tr(),
onTap: (editorState, selection, _) => editorState.convertBlockType(
selection,
ParagraphBlockKeys.type,
),
),
// to-do list
BlockMenuItem(
blockType: TodoListBlockKeys.type,
icon: const FlowySvg(FlowySvgs.m_checkbox_m),
label: LocaleKeys.editor_checkbox.tr(),
onTap: (editorState, selection, _) => editorState.convertBlockType(
selection,
TodoListBlockKeys.type,
extraAttributes: {
TodoListBlockKeys.checked: false,
},
),
),
// heading 1 - 3
BlockMenuItem(
blockType: HeadingBlockKeys.type,
icon: const FlowySvg(FlowySvgs.m_h1_m),
label: LocaleKeys.editor_heading1.tr(),
isSelected: (editorState, selection) => _isHeadingSelected(
editorState,
selection,
1,
),
onTap: (editorState, selection, _) {
final isSelected = _isHeadingSelected(
editorState,
selection,
1,
);
editorState.convertBlockType(
selection,
HeadingBlockKeys.type,
isSelected: isSelected,
extraAttributes: {
HeadingBlockKeys.level: 1,
},
);
},
),
BlockMenuItem(
blockType: HeadingBlockKeys.type,
icon: const FlowySvg(FlowySvgs.m_h2_m),
label: LocaleKeys.editor_heading2.tr(),
isSelected: (editorState, selection) => _isHeadingSelected(
editorState,
selection,
2,
),
onTap: (editorState, selection, _) {
final isSelected = _isHeadingSelected(
editorState,
selection,
2,
);
editorState.convertBlockType(
selection,
HeadingBlockKeys.type,
isSelected: isSelected,
extraAttributes: {
HeadingBlockKeys.level: 2,
},
);
},
),
BlockMenuItem(
blockType: HeadingBlockKeys.type,
icon: const FlowySvg(FlowySvgs.m_h3_m),
label: LocaleKeys.editor_heading3.tr(),
isSelected: (editorState, selection) => _isHeadingSelected(
editorState,
selection,
3,
),
onTap: (editorState, selection, _) {
final isSelected = _isHeadingSelected(
editorState,
selection,
3,
);
editorState.convertBlockType(
selection,
HeadingBlockKeys.type,
isSelected: isSelected,
extraAttributes: {
HeadingBlockKeys.level: 3,
},
);
},
),
// bulleted list
BlockMenuItem(
blockType: BulletedListBlockKeys.type,
icon: const FlowySvg(FlowySvgs.m_bulleted_list_m),
label: LocaleKeys.editor_bulletedList.tr(),
onTap: (editorState, selection, _) => editorState.convertBlockType(
selection,
BulletedListBlockKeys.type,
),
),
// numbered list
BlockMenuItem(
blockType: NumberedListBlockKeys.type,
icon: const FlowySvg(FlowySvgs.m_numbered_list_m),
label: LocaleKeys.editor_numberedList.tr(),
onTap: (editorState, selection, _) => editorState.convertBlockType(
selection,
NumberedListBlockKeys.type,
),
),
// toggle list
BlockMenuItem(
blockType: ToggleListBlockKeys.type,
icon: const FlowySvg(FlowySvgs.m_toggle_list_m),
label: LocaleKeys.document_plugins_toggleList.tr(),
onTap: (editorState, selection, _) => editorState.convertBlockType(
selection,
ToggleListBlockKeys.type,
),
),
// quote
BlockMenuItem(
blockType: QuoteBlockKeys.type,
icon: const FlowySvg(FlowySvgs.m_quote_m),
label: LocaleKeys.editor_quote.tr(),
onTap: (editorState, selection, _) => editorState.convertBlockType(
selection,
QuoteBlockKeys.type,
),
),
// callout
BlockMenuItem(
blockType: CalloutBlockKeys.type,
// FIXME: update icon
icon: const Icon(Icons.note_rounded),
label: LocaleKeys.document_plugins_callout.tr(),
onTap: (editorState, selection, _) => editorState.convertBlockType(
selection,
CalloutBlockKeys.type,
extraAttributes: {
CalloutBlockKeys.icon: '📌',
},
),
),
// code
BlockMenuItem(
blockType: CodeBlockKeys.type,
icon: const FlowySvg(FlowySvgs.m_code_m),
label: LocaleKeys.document_selectionMenu_codeBlock.tr(),
onTap: (editorState, selection, _) => editorState.convertBlockType(
selection,
CodeBlockKeys.type,
),
),
];
extension on EditorState {
Future<void> convertBlockType(
Selection selection,
String newBlockType, {
Attributes? extraAttributes,
bool? isSelected,
}) async {
final node = getNodeAtPath(selection.start.path);
final type = node?.type;
if (node == null || type == null) {
assert(false, 'node or type is null');
return;
}
final selected = isSelected ?? type == newBlockType;
await formatNode(
selection,
(node) {
final attributes = {
ParagraphBlockKeys.delta: (node.delta ?? Delta()).toJson(),
// for some block types, they have extra attributes, like todo list has checked attribute, callout has icon attribute, etc.
if (!selected && extraAttributes != null) ...extraAttributes,
};
return node.copyWith(
type: selected ? ParagraphBlockKeys.type : newBlockType,
attributes: attributes,
);
},
);
}
}
bool _isHeadingSelected(
EditorState editorState,
Selection selection,
int level,
) {
final node = editorState.getNodeAtPath(selection.start.path);
final type = node?.type;
if (node == null || type == null) {
return false;
}
return type == HeadingBlockKeys.type &&
node.attributes[HeadingBlockKeys.level] == level;
}

View File

@ -2,23 +2,23 @@ import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
final mobileIndentToolbarItem = MobileToolbarItem.action(
itemIconBuilder: (_, editorState) {
itemIconBuilder: (_, editorState, __) {
return onlyShowInTextType(editorState)
? const Icon(Icons.format_indent_increase_rounded)
: null;
},
actionHandler: (editorState, selection) {
actionHandler: (_, editorState) {
indentCommand.execute(editorState);
},
);
final mobileOutdentToolbarItem = MobileToolbarItem.action(
itemIconBuilder: (_, editorState) {
itemIconBuilder: (_, editorState, __) {
return onlyShowInTextType(editorState)
? const Icon(Icons.format_indent_decrease_rounded)
: null;
},
actionHandler: (editorState, selection) {
actionHandler: (_, editorState) {
outdentCommand.execute(editorState);
},
);

View File

@ -0,0 +1,106 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
final customTextDecorationMobileToolbarItem = MobileToolbarItem.withMenu(
itemIconBuilder: (_, __, ___) => const AFMobileIcon(
afMobileIcons: AFMobileIcons.textDecoration,
),
itemMenuBuilder: (_, editorState, __) {
final selection = editorState.selection;
if (selection == null) {
return const SizedBox.shrink();
}
return _TextDecorationMenu(editorState, selection);
},
);
class _TextDecorationMenu extends StatefulWidget {
const _TextDecorationMenu(
this.editorState,
this.selection,
);
final EditorState editorState;
final Selection selection;
@override
State<_TextDecorationMenu> createState() => _TextDecorationMenuState();
}
class _TextDecorationMenuState extends State<_TextDecorationMenu> {
final textDecorations = [
// BIUS
TextDecorationUnit(
icon: AFMobileIcons.bold,
label: AppFlowyEditorL10n.current.bold,
name: AppFlowyRichTextKeys.bold,
),
TextDecorationUnit(
icon: AFMobileIcons.italic,
label: AppFlowyEditorL10n.current.italic,
name: AppFlowyRichTextKeys.italic,
),
TextDecorationUnit(
icon: AFMobileIcons.underline,
label: AppFlowyEditorL10n.current.underline,
name: AppFlowyRichTextKeys.underline,
),
TextDecorationUnit(
icon: AFMobileIcons.strikethrough,
label: AppFlowyEditorL10n.current.strikethrough,
name: AppFlowyRichTextKeys.strikethrough,
),
// Code
TextDecorationUnit(
icon: AFMobileIcons.code,
label: AppFlowyEditorL10n.current.embedCode,
name: AppFlowyRichTextKeys.code,
),
];
@override
Widget build(BuildContext context) {
final bius = textDecorations.map((currentDecoration) {
// Check current decoration is active or not
final selection = widget.selection;
final nodes = widget.editorState.getNodesInSelection(selection);
final bool isSelected;
if (selection.isCollapsed) {
isSelected = widget.editorState.toggledStyle.containsKey(
currentDecoration.name,
);
} else {
isSelected = nodes.allSatisfyInSelection(selection, (delta) {
return delta.everyAttributes(
(attributes) => attributes[currentDecoration.name] == true,
);
});
}
return MobileToolbarItemMenuBtn(
icon: AFMobileIcon(
afMobileIcons: currentDecoration.icon,
),
label: FlowyText(currentDecoration.label),
isSelected: isSelected,
onPressed: () {
setState(() {
widget.editorState.toggleAttribute(currentDecoration.name);
});
},
);
}).toList();
return GridView.count(
shrinkWrap: true,
padding: EdgeInsets.zero,
crossAxisCount: 2,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
childAspectRatio: 4,
children: bius,
);
}
}

View File

@ -26,9 +26,11 @@ export 'inline_math_equation/inline_math_equation.dart';
export 'inline_math_equation/inline_math_equation_toolbar_item.dart';
export 'math_equation/math_equation_block_component.dart';
export 'math_equation/mobile_math_equation_toolbar_item.dart';
export 'mobile_toolbar_item/mobile_add_block_toolbar_item.dart';
export 'mobile_toolbar_item/mobile_align_toolbar_item.dart';
export 'mobile_toolbar_item/mobile_blocks_toolbar_item.dart';
export 'mobile_toolbar_item/mobile_convert_block_toolbar_item.dart';
export 'mobile_toolbar_item/mobile_indent_toolbar_items.dart';
export 'mobile_toolbar_item/mobile_text_decoration_item.dart';
export 'openai/widgets/auto_completion_node_widget.dart';
export 'openai/widgets/smart_edit_node_widget.dart';
export 'openai/widgets/smart_edit_toolbar_item.dart';

View File

@ -2,8 +2,10 @@ import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
final redoMobileToolbarItem = MobileToolbarItem.action(
itemIconBuilder: (_, __) => const FlowySvg(FlowySvgs.m_redo_m),
actionHandler: (editorState, selection) async {
itemIconBuilder: (_, __, ___) => const FlowySvg(
FlowySvgs.m_redo_m,
),
actionHandler: (_, editorState) async {
editorState.undoManager.redo();
},
);

View File

@ -2,8 +2,10 @@ import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
final undoMobileToolbarItem = MobileToolbarItem.action(
itemIconBuilder: (_, __) => const FlowySvg(FlowySvgs.m_undo_m),
actionHandler: (editorState, selection) async {
itemIconBuilder: (_, __, ___) => const FlowySvg(
FlowySvgs.m_undo_m,
),
actionHandler: (_, editorState) async {
editorState.undoManager.undo();
},
);

View File

@ -83,11 +83,14 @@ class EditorStyleCustomizer {
EditorStyle mobile() {
final theme = Theme.of(context);
const fontSize = 14.0;
final fontFamily = GoogleFonts.poppins().fontFamily ?? 'Poppins';
final fontSize = context.read<DocumentAppearanceCubit>().state.fontSize;
final fontFamily = context.read<DocumentAppearanceCubit>().state.fontFamily;
final defaultTextDirection =
context.read<DocumentAppearanceCubit>().state.defaultTextDirection;
final codeFontSize = max(0.0, fontSize - 2);
return EditorStyle.mobile(
padding: padding,
defaultTextDirection: defaultTextDirection,
textStyleConfiguration: TextStyleConfiguration(
text: baseTextStyle(fontFamily).copyWith(
fontSize: fontSize,