feat: add edit link menu on mobile (#320)

This commit is contained in:
Yijing Huang 2023-07-19 02:58:17 -05:00 • committed by GitHub
commit 7aa337447b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 768 additions and 167 deletions

View file

@ -75,6 +75,7 @@
"backgroundColorRed": "Red background", "backgroundColorRed": "Red background",
"@backgroundColorRed": {}, "@backgroundColorRed": {},
"done": "Done", "done": "Done",
"cancel": "Cancel",
"tint1": "Tint 1", "tint1": "Tint 1",
"tint2": "Tint 2", "tint2": "Tint 2",
"tint3": "Tint 3", "tint3": "Tint 3",
@ -103,6 +104,10 @@
"openLink": "Open link", "openLink": "Open link",
"copyLink": "Copy link", "copyLink": "Copy link",
"removeLink": "Remove link", "removeLink": "Remove link",
"editLink": "Edit link",
"linkText": "Text",
"linkTextHint": "Please enter text",
"linkAddressHint": "Please enter URL",
"highlightColor": "Highlight color", "highlightColor": "Highlight color",
"clearHighlightColor": "Clear highlight color", "clearHighlightColor": "Clear highlight color",
"customColor": "Custom color", "customColor": "Custom color",

View file

@ -31,3 +31,5 @@ export 'service/renderer/block_component_context.dart';
// style // style
export 'style/editor_style.dart'; export 'style/editor_style.dart';
export 'style/default_text_span_decorator_for_attribute.dart';
export 'style/mobile_text_span_decorator_for_attribute.dart';

View file

@ -0,0 +1,61 @@
import 'dart:async';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
/// Support Desktop and Web platform
/// - customize the href text span
TextSpan defaultTextSpanDecoratorForAttribute(
BuildContext context,
Node node,
int index,
TextInsert text,
TextSpan textSpan,
) {
final attributes = text.attributes;
if (attributes == null) {
return textSpan;
}
final editorState = context.read<EditorState>();
final href = attributes[AppFlowyRichTextKeys.href] as String?;
if (href != null) {
// add a tap gesture recognizer to the text span
Timer? timer;
int tapCount = 0;
final tapGestureRecognizer = TapGestureRecognizer()
..onTap = () async {
// implement a simple double tap logic
tapCount += 1;
timer?.cancel();
if (tapCount == 2 || !editorState.editable) {
tapCount = 0;
safeLaunchUrl(href);
return;
}
timer = Timer(const Duration(milliseconds: 200), () {
tapCount = 0;
final selection = Selection.single(
path: node.path,
startOffset: index,
endOffset: index + text.text.length,
);
editorState.updateSelectionWithReason(
selection,
reason: SelectionUpdateReason.uiEvent,
);
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
showLinkMenu(context, editorState, selection, true);
});
});
};
return TextSpan(
style: textSpan.style,
text: text.text,
recognizer: tapGestureRecognizer,
);
}
return textSpan;
}

View file

@ -1,10 +1,6 @@
import 'dart:async';
import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
/// The style of the editor. /// The style of the editor.
/// ///
@ -80,7 +76,8 @@ class EditorStyle {
const TextStyleConfiguration( const TextStyleConfiguration(
text: TextStyle(fontSize: 16, color: Colors.black), text: TextStyle(fontSize: 16, color: Colors.black),
), ),
textSpanDecorator: textSpanDecorator, textSpanDecorator:
textSpanDecorator ?? mobileTextSpanDecoratorForAttribute,
); );
EditorStyle copyWith({ EditorStyle copyWith({
@ -101,58 +98,3 @@ class EditorStyle {
); );
} }
} }
/// Supports
///
/// - customize the href text span
TextSpan defaultTextSpanDecoratorForAttribute(
BuildContext context,
Node node,
int index,
TextInsert text,
TextSpan textSpan,
) {
final attributes = text.attributes;
if (attributes == null) {
return textSpan;
}
final editorState = context.read<EditorState>();
final href = attributes[AppFlowyRichTextKeys.href] as String?;
if (href != null) {
// add a tap gesture recognizer to the text span
Timer? timer;
int tapCount = 0;
final tapGestureRecognizer = TapGestureRecognizer()
..onTap = () async {
// implement a simple double tap logic
tapCount += 1;
timer?.cancel();
if (tapCount == 2 || !editorState.editable) {
tapCount = 0;
safeLaunchUrl(href);
return;
}
timer = Timer(const Duration(milliseconds: 200), () {
tapCount = 0;
final selection = Selection.single(
path: node.path,
startOffset: index,
endOffset: index + text.text.length,
);
editorState.updateSelectionWithReason(
selection,
reason: SelectionUpdateReason.uiEvent,
);
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
showLinkMenu(context, editorState, selection, true);
});
});
};
return TextSpan(
style: textSpan.style,
text: text.text,
recognizer: tapGestureRecognizer,
);
}
return textSpan;
}

View file

@ -0,0 +1,239 @@
import 'dart:async';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
/// Support mobile platform
/// - customize the href text span
TextSpan mobileTextSpanDecoratorForAttribute(
BuildContext context,
Node node,
int index,
TextInsert text,
TextSpan textSpan,
) {
final attributes = text.attributes;
if (attributes == null) {
return textSpan;
}
final editorState = context.read<EditorState>();
final hrefAddress = attributes[AppFlowyRichTextKeys.href] as String?;
if (hrefAddress != null) {
Timer? timer;
final tapGestureRecognizer = TapGestureRecognizer()
..onTapUp = (_) async {
if (timer != null && timer!.isActive) {
// Implement single tap logic
safeLaunchUrl(hrefAddress);
timer!.cancel();
return;
}
};
tapGestureRecognizer.onTapDown = (_) {
final selection = Selection.single(
path: node.path,
startOffset: index,
endOffset: index + text.text.length,
);
editorState.updateSelectionWithReason(
selection,
reason: SelectionUpdateReason.uiEvent,
);
timer = Timer(const Duration(milliseconds: 500), () {
// Implement long tap logic
showDialog<void>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: Text(AppFlowyEditorLocalizations.current.editLink),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4),
),
content: LinkEditForm(
node: node,
index: index,
hrefText: text.text,
hrefAddress: hrefAddress,
editorState: editorState,
selection: selection,
),
);
},
);
});
};
return TextSpan(
style: textSpan.style,
text: text.text,
recognizer: tapGestureRecognizer,
);
}
return textSpan;
}
class LinkEditForm extends StatefulWidget {
const LinkEditForm({
super.key,
required this.node,
required this.index,
required this.hrefText,
required this.hrefAddress,
required this.editorState,
required this.selection,
});
final Node node;
final int index;
final String hrefText;
final String hrefAddress;
final EditorState editorState;
final Selection selection;
@override
State<LinkEditForm> createState() => _LinkEditFormState();
}
class _LinkEditFormState extends State<LinkEditForm> {
final _formKey = GlobalKey<FormState>();
@override
Widget build(BuildContext context) {
var hrefAddressTextEditingController =
TextEditingController(text: widget.hrefAddress);
var hrefTextTextEditingController =
TextEditingController(text: widget.hrefText);
return Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
key: const Key('Text TextFormField'),
autofocus: true,
controller: hrefTextTextEditingController,
keyboardType: TextInputType.text,
validator: (value) {
if (value == null || value.isEmpty) {
return AppFlowyEditorLocalizations.current.linkTextHint;
}
return null;
},
decoration: InputDecoration(
hintText: AppFlowyEditorLocalizations.current.linkText,
suffixIcon: IconButton(
icon: const Icon(
Icons.clear_rounded,
size: 16,
),
onPressed: hrefTextTextEditingController.clear,
),
),
),
TextFormField(
key: const Key('Url TextFormField'),
controller: hrefAddressTextEditingController,
keyboardType: TextInputType.url,
validator: (value) {
if (value == null || value.isEmpty) {
return AppFlowyEditorLocalizations.current.linkAddressHint;
}
return null;
},
decoration: InputDecoration(
hintText: AppFlowyEditorLocalizations.current.urlHint,
suffixIcon: IconButton(
icon: const Icon(
Icons.clear_rounded,
size: 16,
),
onPressed: hrefAddressTextEditingController.clear,
),
),
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
TextButton(
child: Text(
AppFlowyEditorLocalizations.current.removeLink,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
onPressed: () async {
final transaction = widget.editorState.transaction
..formatText(
widget.node,
widget.index,
widget.hrefText.length,
{BuiltInAttributeKey.href: null},
);
await widget.editorState
.apply(transaction)
.whenComplete(() => Navigator.of(context).pop());
},
),
TextButton(
style: TextButton.styleFrom(
textStyle: Theme.of(context).textTheme.labelLarge,
),
child: Text(AppFlowyEditorLocalizations.current.done),
onPressed: () async {
if (_formKey.currentState!.validate()) {
final bool textChanged =
hrefTextTextEditingController.text != widget.hrefText;
final bool addressChanged =
hrefAddressTextEditingController.text !=
widget.hrefAddress;
if (textChanged && addressChanged) {
final transaction = widget.editorState.transaction
..replaceText(
widget.node,
widget.index,
widget.hrefText.length,
hrefTextTextEditingController.text,
attributes: {
AppFlowyRichTextKeys.href:
hrefAddressTextEditingController.text
},
);
await widget.editorState.apply(transaction).whenComplete(
() => Navigator.of(context).pop(),
);
} else if (textChanged && !addressChanged) {
final transaction = widget.editorState.transaction
..replaceText(
widget.node,
widget.index,
widget.hrefText.length,
hrefTextTextEditingController.text,
);
await widget.editorState.apply(transaction).whenComplete(
() => Navigator.of(context).pop(),
);
} else if (!textChanged && addressChanged) {
await widget.editorState.formatDelta(widget.selection, {
AppFlowyRichTextKeys.href:
hrefAddressTextEditingController.value.text,
}).whenComplete(() => Navigator.of(context).pop());
} else {
Navigator.of(context).pop();
}
}
},
),
],
)
],
),
);
}
}

View file

@ -14,6 +14,9 @@ class MobileToolbar extends StatelessWidget {
this.itemOutlineColor = const Color(0xFFE3E3E3), this.itemOutlineColor = const Color(0xFFE3E3E3),
this.tabbarSelectedBackgroundColor = const Color(0x23808080), this.tabbarSelectedBackgroundColor = const Color(0x23808080),
this.tabbarSelectedForegroundColor = Colors.black, this.tabbarSelectedForegroundColor = Colors.black,
this.primaryColor = const Color(0xff1F71AC),
this.onPrimaryColor = Colors.white,
this.outlineColor = const Color(0xFFE3E3E3),
this.toolbarHeight = 50.0, this.toolbarHeight = 50.0,
this.borderRadius = 6.0, this.borderRadius = 6.0,
this.buttonHeight = 40.0, this.buttonHeight = 40.0,
@ -31,6 +34,9 @@ class MobileToolbar extends StatelessWidget {
final Color itemOutlineColor; final Color itemOutlineColor;
final Color tabbarSelectedBackgroundColor; final Color tabbarSelectedBackgroundColor;
final Color tabbarSelectedForegroundColor; final Color tabbarSelectedForegroundColor;
final Color primaryColor;
final Color onPrimaryColor;
final Color outlineColor;
final double toolbarHeight; final double toolbarHeight;
final double borderRadius; final double borderRadius;
final double buttonHeight; final double buttonHeight;
@ -54,6 +60,9 @@ class MobileToolbar extends StatelessWidget {
itemOutlineColor: itemOutlineColor, itemOutlineColor: itemOutlineColor,
tabbarSelectedBackgroundColor: tabbarSelectedBackgroundColor, tabbarSelectedBackgroundColor: tabbarSelectedBackgroundColor,
tabbarSelectedForegroundColor: tabbarSelectedForegroundColor, tabbarSelectedForegroundColor: tabbarSelectedForegroundColor,
primaryColor: primaryColor,
onPrimaryColor: onPrimaryColor,
outlineColor: outlineColor,
toolbarHeight: toolbarHeight, toolbarHeight: toolbarHeight,
borderRadius: borderRadius, borderRadius: borderRadius,
buttonHeight: buttonHeight, buttonHeight: buttonHeight,
@ -73,6 +82,12 @@ class MobileToolbar extends StatelessWidget {
} }
} }
/// To update the state of the [MobileToolbarWidget]
abstract class MobileToolbarWidgetService {
/// close item menu and enable keyboard
void closeItemMenu();
}
class MobileToolbarWidget extends StatefulWidget { class MobileToolbarWidget extends StatefulWidget {
const MobileToolbarWidget({ const MobileToolbarWidget({
super.key, super.key,
@ -86,13 +101,23 @@ class MobileToolbarWidget extends StatefulWidget {
final Selection selection; final Selection selection;
@override @override
State<MobileToolbarWidget> createState() => _MobileToolbarWidgetState(); State<MobileToolbarWidget> createState() => MobileToolbarWidgetState();
} }
class _MobileToolbarWidgetState extends State<MobileToolbarWidget> { class MobileToolbarWidgetState extends State<MobileToolbarWidget>
implements MobileToolbarWidgetService {
bool _showItemMenu = false; bool _showItemMenu = false;
int? _selectedToolbarItemIndex; int? _selectedToolbarItemIndex;
@override
void closeItemMenu() {
if (_showItemMenu) {
setState(() {
_showItemMenu = false;
});
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final width = MediaQuery.of(context).size.width; final width = MediaQuery.of(context).size.width;
@ -118,13 +143,7 @@ class _MobileToolbarWidgetState extends State<MobileToolbarWidget> {
editorState: widget.editorState, editorState: widget.editorState,
selection: widget.selection, selection: widget.selection,
toolbarItems: widget.toolbarItems, toolbarItems: widget.toolbarItems,
closeMenu: () { toolbarWidgetService: this,
if (_showItemMenu) {
setState(() {
_showItemMenu = false;
});
}
},
itemWithMenuOnPressed: (selectedItemIndex) { itemWithMenuOnPressed: (selectedItemIndex) {
setState(() { setState(() {
// If last selected item is selected again, toggle item menu // If last selected item is selected again, toggle item menu
@ -167,15 +186,12 @@ class _MobileToolbarWidgetState extends State<MobileToolbarWidget> {
), ),
// only for MobileToolbarItem.withMenu // only for MobileToolbarItem.withMenu
if (_showItemMenu && _selectedToolbarItemIndex != null) if (_showItemMenu && _selectedToolbarItemIndex != null)
// MobileToolbarItemMenuState implements MobileToolbarItemMenuService
MobileToolbarItemMenu( MobileToolbarItemMenu(
editorState: widget.editorState, editorState: widget.editorState,
itemMenuBuilder: (mobileToolbarItemMenuState) => widget itemMenuBuilder: () => widget
.toolbarItems[_selectedToolbarItemIndex!].itemMenuBuilder!( .toolbarItems[_selectedToolbarItemIndex!]
widget.editorState, // pass current [MobileToolbarWidgetState] to be used to closeItemMenu
widget.selection, .itemMenuBuilder!(widget.editorState, widget.selection, this),
mobileToolbarItemMenuState,
),
) )
], ],
); );
@ -208,14 +224,14 @@ class _ToolbarItemListView extends StatelessWidget {
required this.toolbarItems, required this.toolbarItems,
required this.editorState, required this.editorState,
required this.selection, required this.selection,
required this.closeMenu, required this.toolbarWidgetService,
}) : super(key: key); }) : super(key: key);
final Function(int index) itemWithMenuOnPressed; final Function(int index) itemWithMenuOnPressed;
final Function() closeMenu;
final List<MobileToolbarItem> toolbarItems; final List<MobileToolbarItem> toolbarItems;
final EditorState editorState; final EditorState editorState;
final Selection selection; final Selection selection;
final MobileToolbarWidgetService toolbarWidgetService;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -230,7 +246,7 @@ class _ToolbarItemListView extends StatelessWidget {
itemWithMenuOnPressed.call(index); itemWithMenuOnPressed.call(index);
} else { } else {
// close menu if other item's menu is still on the screen // close menu if other item's menu is still on the screen
closeMenu.call(); toolbarWidgetService.closeItemMenu();
toolbarItems[index].actionHandler?.call( toolbarItems[index].actionHandler?.call(
editorState, editorState,
selection, selection,

View file

@ -4,7 +4,8 @@ import 'package:flutter/material.dart';
typedef MobileToolbarItemMenuBuilder = Widget Function( typedef MobileToolbarItemMenuBuilder = Widget Function(
EditorState editorState, EditorState editorState,
Selection selection, Selection selection,
MobileToolbarItemMenuService service, // To accees to the state of MobileToolbarWidget
MobileToolbarWidgetService service,
); );
typedef MobileToolbarItemActionHandler = void Function( typedef MobileToolbarItemActionHandler = void Function(

View file

@ -15,6 +15,9 @@ class MobileToolbarStyle extends InheritedWidget {
final Color itemOutlineColor; final Color itemOutlineColor;
final Color tabbarSelectedBackgroundColor; final Color tabbarSelectedBackgroundColor;
final Color tabbarSelectedForegroundColor; final Color tabbarSelectedForegroundColor;
final Color primaryColor;
final Color onPrimaryColor;
final Color outlineColor;
final double toolbarHeight; final double toolbarHeight;
final double borderRadius; final double borderRadius;
final double buttonHeight; final double buttonHeight;
@ -31,6 +34,9 @@ class MobileToolbarStyle extends InheritedWidget {
required this.itemOutlineColor, required this.itemOutlineColor,
required this.tabbarSelectedBackgroundColor, required this.tabbarSelectedBackgroundColor,
required this.tabbarSelectedForegroundColor, required this.tabbarSelectedForegroundColor,
required this.primaryColor,
required this.onPrimaryColor,
required this.outlineColor,
required this.toolbarHeight, required this.toolbarHeight,
required this.borderRadius, required this.borderRadius,
required this.buttonHeight, required this.buttonHeight,
@ -57,6 +63,9 @@ class MobileToolbarStyle extends InheritedWidget {
oldWidget.tabbarSelectedBackgroundColor || oldWidget.tabbarSelectedBackgroundColor ||
tabbarSelectedForegroundColor != tabbarSelectedForegroundColor !=
oldWidget.tabbarSelectedForegroundColor || oldWidget.tabbarSelectedForegroundColor ||
primaryColor != oldWidget.primaryColor ||
onPrimaryColor != oldWidget.onPrimaryColor ||
outlineColor != oldWidget.outlineColor ||
toolbarHeight != oldWidget.toolbarHeight || toolbarHeight != oldWidget.toolbarHeight ||
borderRadius != oldWidget.borderRadius || borderRadius != oldWidget.borderRadius ||
buttonHeight != oldWidget.buttonHeight || buttonHeight != oldWidget.buttonHeight ||

View file

@ -5,7 +5,7 @@ final linkMobileToolbarItem = MobileToolbarItem.withMenu(
itemIcon: const AFMobileIcon( itemIcon: const AFMobileIcon(
afMobileIcons: AFMobileIcons.link, afMobileIcons: AFMobileIcons.link,
), ),
itemMenuBuilder: (editorState, selection, service) { itemMenuBuilder: (editorState, selection, itemMenuService) {
final String? linkText = editorState.getDeltaAttributeValueInSelection( final String? linkText = editorState.getDeltaAttributeValueInSelection(
AppFlowyRichTextKeys.href, AppFlowyRichTextKeys.href,
selection, selection,
@ -20,8 +20,10 @@ final linkMobileToolbarItem = MobileToolbarItem.withMenu(
AppFlowyRichTextKeys.href: value, AppFlowyRichTextKeys.href: value,
}); });
} }
service.closeItemMenu(); itemMenuService.closeItemMenu();
editorState.service.keyboardService?.closeKeyboard();
}, },
onCancel: () => itemMenuService.closeItemMenu(),
); );
}, },
); );
@ -31,11 +33,13 @@ class _MobileLinkMenu extends StatefulWidget {
this.linkText, this.linkText,
required this.editorState, required this.editorState,
required this.onSubmitted, required this.onSubmitted,
required this.onCancel,
}); });
final String? linkText; final String? linkText;
final EditorState editorState; final EditorState editorState;
final void Function(String) onSubmitted; final void Function(String) onSubmitted;
final void Function() onCancel;
@override @override
State<_MobileLinkMenu> createState() => _MobileLinkMenuState(); State<_MobileLinkMenu> createState() => _MobileLinkMenuState();
@ -43,37 +47,34 @@ class _MobileLinkMenu extends StatefulWidget {
class _MobileLinkMenuState extends State<_MobileLinkMenu> { class _MobileLinkMenuState extends State<_MobileLinkMenu> {
late TextEditingController _textEditingController; late TextEditingController _textEditingController;
late FocusNode _focusNode;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
widget.editorState.service.keyboardService?.disable();
_textEditingController = TextEditingController(text: widget.linkText ?? ''); _textEditingController = TextEditingController(text: widget.linkText ?? '');
_focusNode = FocusNode()..requestFocus();
} }
@override @override
void dispose() { void dispose() {
_textEditingController.dispose(); _textEditingController.dispose();
_focusNode.dispose(); widget.editorState.service.keyboardService?.enable();
super.dispose(); super.dispose();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final style = MobileToolbarStyle.of(context); final style = MobileToolbarStyle.of(context);
const double spacing = 8;
return Material( return Material(
// TextField widget needs to be wrapped in a Material widget to provide a visual appearance // TextField widget needs to be wrapped in a Material widget to provide a visual appearance
color: style.backgroundColor, color: style.backgroundColor,
child: SizedBox( child: SizedBox(
height: style.toolbarHeight, height: style.toolbarHeight * 2 + spacing,
child: Row( child: Column(
children: [ children: [
Expanded( TextField(
child: SizedBox( autofocus: true,
height: 42,
child: TextField(
focusNode: _focusNode,
controller: _textEditingController, controller: _textEditingController,
keyboardType: TextInputType.url, keyboardType: TextInputType.url,
onSubmitted: widget.onSubmitted, onSubmitted: widget.onSubmitted,
@ -107,30 +108,67 @@ class _MobileLinkMenuState extends State<_MobileLinkMenu> {
), ),
), ),
), ),
), const SizedBox(height: spacing),
), Row(
const SizedBox(width: 8), children: [
SizedBox( Expanded(
height: 40,
child: ElevatedButton( child: ElevatedButton(
onPressed: () { onPressed: () {
widget.onSubmitted.call(_textEditingController.text); widget.onCancel.call();
}, },
style: ButtonStyle( style: ButtonStyle(
backgroundColor: MaterialStateProperty.all( backgroundColor: MaterialStateProperty.all(
style.itemHighlightColor, style.backgroundColor,
),
foregroundColor: MaterialStateProperty.all(
style.primaryColor,
), ),
elevation: MaterialStateProperty.all(0), elevation: MaterialStateProperty.all(0),
shape: MaterialStateProperty.all( shape: MaterialStateProperty.all(
RoundedRectangleBorder( RoundedRectangleBorder(
borderRadius: BorderRadius.circular(style.borderRadius), borderRadius:
BorderRadius.circular(style.borderRadius),
),
),
side: MaterialStateBorderSide.resolveWith(
(states) => BorderSide(color: style.outlineColor),
),
),
child: Text(
AppFlowyEditorLocalizations.current.cancel,
), ),
), ),
), ),
child: Text(AppFlowyEditorLocalizations.current.done), SizedBox(width: style.buttonSpacing),
Expanded(
child: ElevatedButton(
onPressed: () {
widget.onSubmitted.call(_textEditingController.text);
widget.editorState.service.keyboardService
?.closeKeyboard();
},
style: ButtonStyle(
backgroundColor: MaterialStateProperty.all(
style.primaryColor,
), ),
foregroundColor: MaterialStateProperty.all(
style.onPrimaryColor,
),
elevation: MaterialStateProperty.all(0),
shape: MaterialStateProperty.all(
RoundedRectangleBorder(
borderRadius:
BorderRadius.circular(style.borderRadius),
),
),
),
child: Text(
AppFlowyEditorLocalizations.current.done,
),
),
),
],
) )
// TODO(yijing): edit link?
], ],
), ),
), ),

View file

@ -1,11 +1,7 @@
import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
abstract class MobileToolbarItemMenuService { class MobileToolbarItemMenu extends StatelessWidget {
void closeItemMenu();
}
class MobileToolbarItemMenu extends StatefulWidget {
const MobileToolbarItemMenu({ const MobileToolbarItemMenu({
super.key, super.key,
required this.editorState, required this.editorState,
@ -13,34 +9,18 @@ class MobileToolbarItemMenu extends StatefulWidget {
}); });
final EditorState editorState; final EditorState editorState;
final Widget Function(MobileToolbarItemMenuState state) itemMenuBuilder; final Widget Function() itemMenuBuilder;
@override
State<MobileToolbarItemMenu> createState() => MobileToolbarItemMenuState();
}
class MobileToolbarItemMenuState extends State<MobileToolbarItemMenu>
implements MobileToolbarItemMenuService {
bool _showMenu = true;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final size = MediaQuery.of(context).size; final size = MediaQuery.of(context).size;
final style = MobileToolbarStyle.of(context); final style = MobileToolbarStyle.of(context);
return _showMenu
? Container( return Container(
width: size.width, width: size.width,
color: style.backgroundColor, color: style.backgroundColor,
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
child: widget.itemMenuBuilder(this), child: itemMenuBuilder(),
) );
: const SizedBox.shrink();
}
@override
void closeItemMenu() {
setState(() {
_showMenu = false;
});
} }
} }

View file

@ -48,6 +48,7 @@ class MessageLookup extends MessageLookupByLibrary {
MessageLookupByLibrary.simpleMessage("Yellow background"), MessageLookupByLibrary.simpleMessage("Yellow background"),
"bold": MessageLookupByLibrary.simpleMessage("Bold"), "bold": MessageLookupByLibrary.simpleMessage("Bold"),
"bulletedList": MessageLookupByLibrary.simpleMessage("Bulleted List"), "bulletedList": MessageLookupByLibrary.simpleMessage("Bulleted List"),
"cancel": MessageLookupByLibrary.simpleMessage("Cancel"),
"checkbox": MessageLookupByLibrary.simpleMessage("Checkbox"), "checkbox": MessageLookupByLibrary.simpleMessage("Checkbox"),
"clearHighlightColor": "clearHighlightColor":
MessageLookupByLibrary.simpleMessage("Clear highlight color"), MessageLookupByLibrary.simpleMessage("Clear highlight color"),
@ -55,6 +56,7 @@ class MessageLookup extends MessageLookupByLibrary {
"copyLink": MessageLookupByLibrary.simpleMessage("Copy link"), "copyLink": MessageLookupByLibrary.simpleMessage("Copy link"),
"customColor": MessageLookupByLibrary.simpleMessage("Custom color"), "customColor": MessageLookupByLibrary.simpleMessage("Custom color"),
"done": MessageLookupByLibrary.simpleMessage("Done"), "done": MessageLookupByLibrary.simpleMessage("Done"),
"editLink": MessageLookupByLibrary.simpleMessage("Edit link"),
"embedCode": MessageLookupByLibrary.simpleMessage("Embed Code"), "embedCode": MessageLookupByLibrary.simpleMessage("Embed Code"),
"fontColorBlue": MessageLookupByLibrary.simpleMessage("Blue"), "fontColorBlue": MessageLookupByLibrary.simpleMessage("Blue"),
"fontColorBrown": MessageLookupByLibrary.simpleMessage("Brown"), "fontColorBrown": MessageLookupByLibrary.simpleMessage("Brown"),
@ -85,6 +87,11 @@ class MessageLookup extends MessageLookupByLibrary {
"lightLightTint8": MessageLookupByLibrary.simpleMessage("Aqua"), "lightLightTint8": MessageLookupByLibrary.simpleMessage("Aqua"),
"lightLightTint9": MessageLookupByLibrary.simpleMessage("Blue"), "lightLightTint9": MessageLookupByLibrary.simpleMessage("Blue"),
"link": MessageLookupByLibrary.simpleMessage("Link"), "link": MessageLookupByLibrary.simpleMessage("Link"),
"linkAddressHint":
MessageLookupByLibrary.simpleMessage("Please enter URL"),
"linkText": MessageLookupByLibrary.simpleMessage("Text"),
"linkTextHint":
MessageLookupByLibrary.simpleMessage("Please enter text"),
"ltr": MessageLookupByLibrary.simpleMessage("LTR"), "ltr": MessageLookupByLibrary.simpleMessage("LTR"),
"mobileHeading1": MessageLookupByLibrary.simpleMessage("Heading 1"), "mobileHeading1": MessageLookupByLibrary.simpleMessage("Heading 1"),
"mobileHeading2": MessageLookupByLibrary.simpleMessage("Heading 2"), "mobileHeading2": MessageLookupByLibrary.simpleMessage("Heading 2"),

View file

@ -431,6 +431,16 @@ class AppFlowyEditorLocalizations {
); );
} }
/// `Cancel`
String get cancel {
return Intl.message(
'Cancel',
name: 'cancel',
desc: '',
args: [],
);
}
/// `Tint 1` /// `Tint 1`
String get tint1 { String get tint1 {
return Intl.message( return Intl.message(
@ -711,6 +721,46 @@ class AppFlowyEditorLocalizations {
); );
} }
/// `Edit link`
String get editLink {
return Intl.message(
'Edit link',
name: 'editLink',
desc: '',
args: [],
);
}
/// `Text`
String get linkText {
return Intl.message(
'Text',
name: 'linkText',
desc: '',
args: [],
);
}
/// `Please enter text`
String get linkTextHint {
return Intl.message(
'Please enter text',
name: 'linkTextHint',
desc: '',
args: [],
);
}
/// `Please enter URL`
String get linkAddressHint {
return Intl.message(
'Please enter URL',
name: 'linkAddressHint',
desc: '',
args: [],
);
}
/// `Highlight color` /// `Highlight color`
String get highlightColor { String get highlightColor {
return Intl.message( return Intl.message(

View file

@ -0,0 +1,236 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../../../new/infra/testable_editor.dart';
import 'package:flutter/material.dart';
void main() {
group('mobile_text_span_decorator_for_attribute.dart', () {
group('Link attribute', () {
testWidgets(
'Check if link can be launched through [safeLaunchUrl] method',
(widgetTester) async {
const text = 'Appflowy website';
const address = 'https://appflowy.com/';
final editor = widgetTester.editor;
//create a link [Appflowy website](https://appflowy.com/)
editor.addParagraph(
builder: (index) => Delta()
..insert(text, attributes: {BuiltInAttributeKey.href: address}),
);
await editor.startTesting(inMobile: true, editable: false);
await widgetTester.pumpAndSettle();
final finder = find.text(text, findRichText: true);
expect(finder, findsOneWidget);
await widgetTester.tap(finder);
await widgetTester.pumpAndSettle();
// test the method only
expect(() => safeLaunchUrl(address), returnsNormally);
},
);
testWidgets(
'Show edit link dialog after long tap the link',
(WidgetTester tester) async {
const text = 'Appflowy website';
const address = 'https://appflowy.com/';
final editor = tester.editor;
//create a link [Appflowy website](https://appflowy.com/)
editor.addParagraph(
builder: (index) => Delta()
..insert(text, attributes: {BuiltInAttributeKey.href: address}),
);
await editor.startTesting(inMobile: true, editable: false);
await tester.pumpAndSettle();
final finder = find.text(text, findRichText: true);
expect(finder, findsOneWidget);
await tester.longPress(
finder,
);
await tester.pumpAndSettle();
expect(find.byType(AlertDialog), findsOneWidget);
},
);
testWidgets(
'Show error prompt when input is empty',
(WidgetTester tester) async {
const text = 'Appflowy website';
const address = 'https://appflowy.com/';
final editor = tester.editor;
//create a link [Appflowy website](https://appflowy.com/)
editor.addParagraph(
builder: (index) => Delta()
..insert(text, attributes: {BuiltInAttributeKey.href: address}),
);
await editor.startTesting(inMobile: true, editable: false);
await tester.pumpAndSettle();
final finder = find.text(text, findRichText: true);
expect(finder, findsOneWidget);
await tester.longPress(
finder,
);
await tester.enterText(
find.byKey(const Key('Text TextFormField')),
'',
);
await tester.enterText(
find.byKey(const Key('Url TextFormField')),
'',
);
await tester.tap(
find.widgetWithText(
TextButton,
AppFlowyEditorLocalizations.current.done,
),
);
await tester.pumpAndSettle(const Duration(seconds: 1));
// show error prompt
expect(
find.text(AppFlowyEditorLocalizations.current.linkTextHint),
findsOneWidget,
);
expect(
find.text(AppFlowyEditorLocalizations.current.linkAddressHint),
findsOneWidget,
);
},
);
testWidgets(
'Change the link info correctly by link edit menu',
(WidgetTester tester) async {
const text = 'Appflowy website';
const text2 = 'Appflowy website 2';
const address = 'https://appflowy.com/';
const address2 = 'https://appflowy.io/';
final editor = tester.editor;
//create a link [Appflowy website](https://appflowy.com/)
editor.addParagraph(
builder: (index) => Delta()
..insert(text, attributes: {BuiltInAttributeKey.href: address}),
);
await editor.startTesting(inMobile: true);
await tester.pumpAndSettle();
final finder = find.text(text, findRichText: true);
expect(finder, findsOneWidget);
await tester.longPress(
finder,
);
await tester.enterText(
find.byKey(const Key('Text TextFormField')),
text2,
);
await tester.enterText(
find.byKey(const Key('Url TextFormField')),
address2,
);
await tester.tap(
find.widgetWithText(
TextButton,
AppFlowyEditorLocalizations.current.done,
),
);
await tester.pumpAndSettle(const Duration(seconds: 1));
var selection = Selection.single(
path: [0],
startOffset: 0,
endOffset: text2.length,
);
var node = editor.editorState.getNodesInSelection(selection);
// link is changed
expect(
node.allSatisfyInSelection(selection, (delta) {
return delta.whereType<TextInsert>().every(
(element) =>
element.attributes?[BuiltInAttributeKey.href] ==
address2,
);
}),
true,
);
},
);
testWidgets(
'Remove the link successfully by link edit menu',
(WidgetTester tester) async {
const text = 'Appflowy website';
const address = 'https://appflowy.com/';
final editor = tester.editor;
//create a link [Appflowy website](https://appflowy.com/)
editor.addParagraph(
builder: (index) => Delta()
..insert(text, attributes: {BuiltInAttributeKey.href: address}),
);
await editor.startTesting(inMobile: true);
await tester.pumpAndSettle();
final finder = find.text(text, findRichText: true);
expect(finder, findsOneWidget);
await tester.longPress(
finder,
);
// tap remove button
await tester.tap(
find.widgetWithText(
TextButton,
AppFlowyEditorLocalizations.current.removeLink,
),
);
await tester.pumpAndSettle(const Duration(seconds: 1));
//dialog is closed
expect(find.byType(AlertDialog), findsNothing);
var selection = Selection.single(
path: [0],
startOffset: 0,
endOffset: text.length,
);
var node = editor.editorState.getNodesInSelection(selection);
// link is removed
expect(
node.allSatisfyInSelection(selection, (delta) {
return delta.whereType<TextInsert>().every(
(element) =>
element.attributes?[BuiltInAttributeKey.href] == null,
);
}),
true,
);
},
);
testWidgets(
'Edit link menu disappeaar after tap outside the menu',
(WidgetTester tester) async {
const text = 'Appflowy website';
const address = 'https://appflowy.com/';
final editor = tester.editor;
//create a link [Appflowy website](https://appflowy.com/)
editor.addParagraph(
builder: (index) => Delta()
..insert(text, attributes: {BuiltInAttributeKey.href: address}),
);
await editor.startTesting(inMobile: true, editable: false);
await tester.pumpAndSettle();
final finder = find.text(text, findRichText: true);
expect(finder, findsOneWidget);
await tester.longPress(
finder,
);
expect(find.byType(AlertDialog), findsOneWidget);
await tester.tapAt(const Offset(1, 1));
await tester.pumpAndSettle(const Duration(seconds: 1));
expect(find.byType(AlertDialog), findsNothing);
},
);
});
});
}

View file

@ -12,6 +12,9 @@ void main() {
const itemOutlineColor = Color(0xFFE3E3E3); const itemOutlineColor = Color(0xFFE3E3E3);
const tabbarSelectedBackgroundColor = Color(0x23808080); const tabbarSelectedBackgroundColor = Color(0x23808080);
const tabbarSelectedForegroundColor = Colors.black; const tabbarSelectedForegroundColor = Colors.black;
const primaryColor = Color(0xff1F71AC);
const onPrimaryColor = Colors.white;
const outlineColor = Color(0xFFE3E3E3);
const toolbarHeight = 50.0; const toolbarHeight = 50.0;
const borderRadius = 6.0; const borderRadius = 6.0;
const buttonHeight = 40.0; const buttonHeight = 40.0;
@ -28,6 +31,9 @@ void main() {
itemOutlineColor: itemOutlineColor, itemOutlineColor: itemOutlineColor,
tabbarSelectedBackgroundColor: tabbarSelectedBackgroundColor, tabbarSelectedBackgroundColor: tabbarSelectedBackgroundColor,
tabbarSelectedForegroundColor: tabbarSelectedForegroundColor, tabbarSelectedForegroundColor: tabbarSelectedForegroundColor,
primaryColor: primaryColor,
onPrimaryColor: onPrimaryColor,
outlineColor: outlineColor,
toolbarHeight: toolbarHeight, toolbarHeight: toolbarHeight,
borderRadius: borderRadius, borderRadius: borderRadius,
buttonHeight: buttonHeight, buttonHeight: buttonHeight,

View file

@ -13,6 +13,9 @@ class MobileToolbarStyleTestWidget extends StatelessWidget {
this.itemOutlineColor = const Color(0xFFE3E3E3), this.itemOutlineColor = const Color(0xFFE3E3E3),
this.tabbarSelectedBackgroundColor = const Color(0x23808080), this.tabbarSelectedBackgroundColor = const Color(0x23808080),
this.tabbarSelectedForegroundColor = Colors.black, this.tabbarSelectedForegroundColor = Colors.black,
this.primaryColor = const Color(0xff1F71AC),
this.onPrimaryColor = Colors.white,
this.outlineColor = const Color(0xFFE3E3E3),
this.toolbarHeight = 50.0, this.toolbarHeight = 50.0,
this.borderRadius = 6.0, this.borderRadius = 6.0,
this.buttonHeight = 40, this.buttonHeight = 40,
@ -29,6 +32,9 @@ class MobileToolbarStyleTestWidget extends StatelessWidget {
final Color itemOutlineColor; final Color itemOutlineColor;
final Color tabbarSelectedBackgroundColor; final Color tabbarSelectedBackgroundColor;
final Color tabbarSelectedForegroundColor; final Color tabbarSelectedForegroundColor;
final Color primaryColor;
final Color onPrimaryColor;
final Color outlineColor;
final double toolbarHeight; final double toolbarHeight;
final double borderRadius; final double borderRadius;
final double buttonHeight; final double buttonHeight;
@ -47,6 +53,9 @@ class MobileToolbarStyleTestWidget extends StatelessWidget {
itemOutlineColor: itemOutlineColor, itemOutlineColor: itemOutlineColor,
tabbarSelectedBackgroundColor: tabbarSelectedBackgroundColor, tabbarSelectedBackgroundColor: tabbarSelectedBackgroundColor,
tabbarSelectedForegroundColor: tabbarSelectedForegroundColor, tabbarSelectedForegroundColor: tabbarSelectedForegroundColor,
primaryColor: primaryColor,
onPrimaryColor: onPrimaryColor,
outlineColor: outlineColor,
toolbarHeight: toolbarHeight, toolbarHeight: toolbarHeight,
borderRadius: borderRadius, borderRadius: borderRadius,
buttonHeight: buttonHeight, buttonHeight: buttonHeight,