feat: improve editing link user experience (#3990)

This commit is contained in:
Lucas.Xu
2023-11-23 16:42:36 +08:00
committed by GitHub
parent 0427402ba7
commit 8afbf28430
9 changed files with 368 additions and 43 deletions

View File

@ -15,7 +15,6 @@ List<MobileToolbarItem> getMobileToolbarItems() {
buildTextAndBackgroundColorMobileToolbarItem(),
mobileAddBlockToolbarItem,
mobileConvertBlockToolbarItem,
linkMobileToolbarItem,
imageMobileToolbarItem,
mobileAlignToolbarItem,
mobileIndentToolbarItem,

View File

@ -1,19 +1,25 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_item/utils.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
final customTextDecorationMobileToolbarItem = MobileToolbarItem.withMenu(
itemIconBuilder: (_, __, ___) => const FlowySvg(
FlowySvgs.text_s,
size: Size.square(24),
),
itemMenuBuilder: (_, editorState, __) {
itemMenuBuilder: (_, editorState, service) {
final selection = editorState.selection;
if (selection == null) {
return const SizedBox.shrink();
}
return _TextDecorationMenu(editorState, selection);
return _TextDecorationMenu(
editorState,
selection,
service,
);
},
);
@ -21,16 +27,20 @@ class _TextDecorationMenu extends StatefulWidget {
const _TextDecorationMenu(
this.editorState,
this.selection,
this.service,
);
final EditorState editorState;
final Selection selection;
final MobileToolbarWidgetService service;
@override
State<_TextDecorationMenu> createState() => _TextDecorationMenuState();
}
class _TextDecorationMenuState extends State<_TextDecorationMenu> {
EditorState get editorState => widget.editorState;
final textDecorations = [
// BIUS
TextDecorationUnit(
@ -60,41 +70,90 @@ class _TextDecorationMenuState extends State<_TextDecorationMenu> {
label: AppFlowyEditorL10n.current.embedCode,
name: AppFlowyRichTextKeys.code,
),
// link
TextDecorationUnit(
icon: AFMobileIcons.link,
label: AppFlowyEditorL10n.current.link,
name: AppFlowyRichTextKeys.href,
),
];
@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,
);
});
}
final children = textDecorations
.map((currentDecoration) {
// Check current decoration is active or not
final selection = widget.selection;
return MobileToolbarItemMenuBtn(
icon: AFMobileIcon(
afMobileIcons: currentDecoration.icon,
color: MobileToolbarTheme.of(context).iconColor,
),
label: FlowyText(currentDecoration.label),
isSelected: isSelected,
onPressed: () {
setState(() {
widget.editorState.toggleAttribute(currentDecoration.name);
});
},
);
}).toList();
// only show edit link bottom sheet when selection is not collapsed
if (selection.isCollapsed &&
currentDecoration.name == AppFlowyRichTextKeys.href) {
return null;
}
final nodes = editorState.getNodesInSelection(selection);
final bool isSelected;
if (selection.isCollapsed) {
isSelected = 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,
color: MobileToolbarTheme.of(context).iconColor,
),
label: FlowyText(currentDecoration.label),
isSelected: isSelected,
onPressed: () {
if (currentDecoration.name == AppFlowyRichTextKeys.href) {
if (selection.isCollapsed) {
return;
}
_closeKeyboard();
// show edit link bottom sheet
final context = nodes.firstOrNull?.context;
if (context != null) {
final text = editorState
.getTextInSelection(
widget.selection,
)
.join('');
final href =
editorState.getDeltaAttributeValueInSelection<String>(
AppFlowyRichTextKeys.href,
widget.selection,
);
showEditLinkBottomSheet(
context,
text,
href,
(context, newText, newHref) {
_updateTextAndHref(text, href, newText, newHref);
context.pop();
},
);
}
} else {
setState(() {
editorState.toggleAttribute(currentDecoration.name);
});
}
},
);
})
.nonNulls
.toList();
return GridView.count(
shrinkWrap: true,
@ -103,7 +162,49 @@ class _TextDecorationMenuState extends State<_TextDecorationMenu> {
mainAxisSpacing: 8,
crossAxisSpacing: 8,
childAspectRatio: 4,
children: bius,
children: children,
);
}
void _closeKeyboard() {
editorState.updateSelectionWithReason(
widget.selection,
extraInfo: {
disableMobileToolbarKey: true,
},
);
editorState.service.keyboardService?.closeKeyboard();
}
void _updateTextAndHref(
String prevText,
String? prevHref,
String text,
String href,
) async {
final selection = widget.selection;
if (!selection.isSingle) {
return;
}
final node = editorState.getNodeAtPath(selection.start.path);
if (node == null) {
return;
}
final transaction = editorState.transaction;
if (prevText != text) {
transaction.replaceText(
node,
selection.startIndex,
selection.length,
text,
);
}
// if the text is empty, it means the user wants to remove the text
if (text.isNotEmpty && prevHref != href) {
transaction.formatText(node, selection.startIndex, text.length, {
AppFlowyRichTextKeys.href: href.isEmpty ? null : href,
});
}
await editorState.apply(transaction);
}
}

View File

@ -0,0 +1,25 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet_edit_link_widget.dart';
import 'package:appflowy/mobile/presentation/widgets/widgets.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
void showEditLinkBottomSheet(
BuildContext context,
String text,
String? href,
void Function(BuildContext context, String text, String href) onEdit,
) {
assert(text.isNotEmpty);
showFlowyMobileBottomSheet(
context,
title: LocaleKeys.editor_editLink.tr(),
builder: (context) {
return MobileBottomSheetEditLinkWidget(
text: text,
href: href,
onEdit: (text, href) => onEdit(context, text, href),
);
},
);
}

View File

@ -2,6 +2,7 @@ import 'dart:math';
import 'package:appflowy/plugins/document/presentation/editor_plugins/inline_math_equation/inline_math_equation.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_item/utils.dart';
import 'package:appflowy/plugins/document/presentation/more/cubit/document_appearance_cubit.dart';
import 'package:appflowy/plugins/inline_actions/inline_actions_menu.dart';
import 'package:appflowy/util/google_font_family_extension.dart';
@ -10,6 +11,7 @@ import 'package:collection/collection.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.dart';
class EditorStyleCustomizer {
@ -276,7 +278,23 @@ class EditorStyleCustomizer {
text: text.text,
recognizer: TapGestureRecognizer()
..onTap = () {
safeLaunchUrl(href);
showEditLinkBottomSheet(
context,
text.text,
href,
(linkContext, newText, newHref) {
_updateTextAndHref(
context,
node,
index,
text.text,
href,
newText,
newHref,
);
linkContext.pop();
},
);
},
);
}
@ -289,4 +307,37 @@ class EditorStyleCustomizer {
textSpan,
);
}
void _updateTextAndHref(
BuildContext context,
Node node,
int index,
String prevText,
String? prevHref,
String text,
String href,
) async {
final selection = Selection.single(
path: node.path,
startOffset: index,
endOffset: index + prevText.length,
);
final editorState = context.read<EditorState>();
final transaction = editorState.transaction;
if (prevText != text) {
transaction.replaceText(
node,
selection.startIndex,
selection.length,
text,
);
}
// if the text is empty, it means the user wants to remove the text
if (text.isNotEmpty && prevHref != href) {
transaction.formatText(node, selection.startIndex, text.length, {
AppFlowyRichTextKeys.href: href.isEmpty ? null : href,
});
}
await editorState.apply(transaction);
}
}