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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 368 additions and 43 deletions

View File

@ -5,13 +5,13 @@ import 'package:flutter/material.dart';
class BottomSheetActionWidget extends StatelessWidget {
const BottomSheetActionWidget({
super.key,
required this.svg,
this.svg,
required this.text,
required this.onTap,
this.iconColor,
});
final FlowySvgData svg;
final FlowySvgData? svg;
final String text;
final VoidCallback onTap;
final Color? iconColor;
@ -21,9 +21,23 @@ class BottomSheetActionWidget extends StatelessWidget {
final iconColor =
this.iconColor ?? Theme.of(context).colorScheme.onBackground;
if (svg == null) {
return OutlinedButton(
style: Theme.of(context)
.outlinedButtonTheme
.style
?.copyWith(alignment: Alignment.center),
onPressed: onTap,
child: FlowyText(
text,
textAlign: TextAlign.center,
),
);
}
return OutlinedButton.icon(
icon: FlowySvg(
svg,
svg!,
size: const Size.square(22.0),
color: iconColor,
),

View File

@ -0,0 +1,127 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.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';
import 'package:go_router/go_router.dart';
import 'package:string_validator/string_validator.dart';
class MobileBottomSheetEditLinkWidget extends StatefulWidget {
const MobileBottomSheetEditLinkWidget({
super.key,
required this.text,
required this.href,
required this.onEdit,
});
final String text;
final String? href;
final void Function(String text, String href) onEdit;
@override
State<MobileBottomSheetEditLinkWidget> createState() =>
_MobileBottomSheetEditLinkWidgetState();
}
class _MobileBottomSheetEditLinkWidgetState
extends State<MobileBottomSheetEditLinkWidget> {
late final TextEditingController textController;
late final TextEditingController hrefController;
@override
void initState() {
super.initState();
textController = TextEditingController(
text: widget.text,
);
hrefController = TextEditingController(
text: widget.href,
);
}
@override
void dispose() {
textController.dispose();
hrefController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 4.0,
vertical: 16.0,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildTextField(textController, null),
const VSpace(12.0),
_buildTextField(hrefController, LocaleKeys.editor_linkTextHint.tr()),
const VSpace(12.0),
Row(
children: [
Expanded(
child: BottomSheetActionWidget(
text: LocaleKeys.button_cancel.tr(),
onTap: () => context.pop(),
),
),
const HSpace(8),
Expanded(
child: BottomSheetActionWidget(
text: LocaleKeys.button_done.tr(),
onTap: () {
widget.onEdit(textController.text, hrefController.text);
},
),
),
if (widget.href != null && isURL(widget.href)) ...[
const HSpace(8),
Expanded(
child: BottomSheetActionWidget(
text: LocaleKeys.editor_openLink.tr(),
onTap: () {
safeLaunchUrl(widget.href!);
},
),
),
],
],
),
],
),
);
}
Widget _buildTextField(
TextEditingController controller,
String? hintText,
) {
return SizedBox(
height: 44.0,
child: FlowyTextField(
controller: controller,
hintText: hintText,
suffixIcon: Padding(
padding: const EdgeInsets.all(4.0),
child: FlowyButton(
text: const FlowySvg(
FlowySvgs.close_lg,
),
margin: EdgeInsets.zero,
useIntrinsicWidth: true,
onTap: () {
controller.clear();
},
),
),
),
);
}
}

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);
}
}

View File

@ -3,7 +3,7 @@
#include <stdint.h>
#include <stdlib.h>
int64_t init_sdk(char *path);
int64_t init_sdk(char *data);
void async_event(int64_t port, const uint8_t *input, uintptr_t len);

View File

@ -54,11 +54,11 @@ packages:
dependency: "direct main"
description:
path: "."
ref: "4f073f3"
resolved-ref: "4f073f3381a05a2379144f282c6f65462c4ce9c6"
ref: "31acaff"
resolved-ref: "31acaff4f0bcd36a2946c9351dc1b5b5601b3994"
url: "https://github.com/AppFlowy-IO/appflowy-editor.git"
source: git
version: "2.0.0-beta.1"
version: "2.0.0"
appflowy_popover:
dependency: "direct main"
description:
@ -1114,6 +1114,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.2"
numerus:
dependency: transitive
description:
name: numerus
sha256: ada1b55e4a505b8e4578218e57be1ce4b7968bb0e51d824082ac0c74ef18a10d
url: "https://pub.dev"
source: hosted
version: "2.1.2"
octo_image:
dependency: transitive
description:

View File

@ -47,7 +47,7 @@ dependencies:
appflowy_editor:
git:
url: https://github.com/AppFlowy-IO/appflowy-editor.git
ref: 'ec3ecf5'
ref: '31acaff'
appflowy_popover:
path: packages/appflowy_popover