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

View file

@ -31,3 +31,5 @@ export 'service/renderer/block_component_context.dart';
// style
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:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
/// The style of the editor.
///
@ -80,7 +76,8 @@ class EditorStyle {
const TextStyleConfiguration(
text: TextStyle(fontSize: 16, color: Colors.black),
),
textSpanDecorator: textSpanDecorator,
textSpanDecorator:
textSpanDecorator ?? mobileTextSpanDecoratorForAttribute,
);
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.tabbarSelectedBackgroundColor = const Color(0x23808080),
this.tabbarSelectedForegroundColor = Colors.black,
this.primaryColor = const Color(0xff1F71AC),
this.onPrimaryColor = Colors.white,
this.outlineColor = const Color(0xFFE3E3E3),
this.toolbarHeight = 50.0,
this.borderRadius = 6.0,
this.buttonHeight = 40.0,
@ -31,6 +34,9 @@ class MobileToolbar extends StatelessWidget {
final Color itemOutlineColor;
final Color tabbarSelectedBackgroundColor;
final Color tabbarSelectedForegroundColor;
final Color primaryColor;
final Color onPrimaryColor;
final Color outlineColor;
final double toolbarHeight;
final double borderRadius;
final double buttonHeight;
@ -54,6 +60,9 @@ class MobileToolbar extends StatelessWidget {
itemOutlineColor: itemOutlineColor,
tabbarSelectedBackgroundColor: tabbarSelectedBackgroundColor,
tabbarSelectedForegroundColor: tabbarSelectedForegroundColor,
primaryColor: primaryColor,
onPrimaryColor: onPrimaryColor,
outlineColor: outlineColor,
toolbarHeight: toolbarHeight,
borderRadius: borderRadius,
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 {
const MobileToolbarWidget({
super.key,
@ -86,13 +101,23 @@ class MobileToolbarWidget extends StatefulWidget {
final Selection selection;
@override
State<MobileToolbarWidget> createState() => _MobileToolbarWidgetState();
State<MobileToolbarWidget> createState() => MobileToolbarWidgetState();
}
class _MobileToolbarWidgetState extends State<MobileToolbarWidget> {
class MobileToolbarWidgetState extends State<MobileToolbarWidget>
implements MobileToolbarWidgetService {
bool _showItemMenu = false;
int? _selectedToolbarItemIndex;
@override
void closeItemMenu() {
if (_showItemMenu) {
setState(() {
_showItemMenu = false;
});
}
}
@override
Widget build(BuildContext context) {
final width = MediaQuery.of(context).size.width;
@ -118,13 +143,7 @@ class _MobileToolbarWidgetState extends State<MobileToolbarWidget> {
editorState: widget.editorState,
selection: widget.selection,
toolbarItems: widget.toolbarItems,
closeMenu: () {
if (_showItemMenu) {
setState(() {
_showItemMenu = false;
});
}
},
toolbarWidgetService: this,
itemWithMenuOnPressed: (selectedItemIndex) {
setState(() {
// If last selected item is selected again, toggle item menu
@ -167,15 +186,12 @@ class _MobileToolbarWidgetState extends State<MobileToolbarWidget> {
),
// only for MobileToolbarItem.withMenu
if (_showItemMenu && _selectedToolbarItemIndex != null)
// MobileToolbarItemMenuState implements MobileToolbarItemMenuService
MobileToolbarItemMenu(
editorState: widget.editorState,
itemMenuBuilder: (mobileToolbarItemMenuState) => widget
.toolbarItems[_selectedToolbarItemIndex!].itemMenuBuilder!(
widget.editorState,
widget.selection,
mobileToolbarItemMenuState,
),
itemMenuBuilder: () => widget
.toolbarItems[_selectedToolbarItemIndex!]
// pass current [MobileToolbarWidgetState] to be used to closeItemMenu
.itemMenuBuilder!(widget.editorState, widget.selection, this),
)
],
);
@ -208,14 +224,14 @@ class _ToolbarItemListView extends StatelessWidget {
required this.toolbarItems,
required this.editorState,
required this.selection,
required this.closeMenu,
required this.toolbarWidgetService,
}) : super(key: key);
final Function(int index) itemWithMenuOnPressed;
final Function() closeMenu;
final List<MobileToolbarItem> toolbarItems;
final EditorState editorState;
final Selection selection;
final MobileToolbarWidgetService toolbarWidgetService;
@override
Widget build(BuildContext context) {
@ -230,7 +246,7 @@ class _ToolbarItemListView extends StatelessWidget {
itemWithMenuOnPressed.call(index);
} else {
// close menu if other item's menu is still on the screen
closeMenu.call();
toolbarWidgetService.closeItemMenu();
toolbarItems[index].actionHandler?.call(
editorState,
selection,

View file

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

View file

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

View file

@ -5,7 +5,7 @@ final linkMobileToolbarItem = MobileToolbarItem.withMenu(
itemIcon: const AFMobileIcon(
afMobileIcons: AFMobileIcons.link,
),
itemMenuBuilder: (editorState, selection, service) {
itemMenuBuilder: (editorState, selection, itemMenuService) {
final String? linkText = editorState.getDeltaAttributeValueInSelection(
AppFlowyRichTextKeys.href,
selection,
@ -20,8 +20,10 @@ final linkMobileToolbarItem = MobileToolbarItem.withMenu(
AppFlowyRichTextKeys.href: value,
});
}
service.closeItemMenu();
itemMenuService.closeItemMenu();
editorState.service.keyboardService?.closeKeyboard();
},
onCancel: () => itemMenuService.closeItemMenu(),
);
},
);
@ -31,11 +33,13 @@ class _MobileLinkMenu extends StatefulWidget {
this.linkText,
required this.editorState,
required this.onSubmitted,
required this.onCancel,
});
final String? linkText;
final EditorState editorState;
final void Function(String) onSubmitted;
final void Function() onCancel;
@override
State<_MobileLinkMenu> createState() => _MobileLinkMenuState();
@ -43,37 +47,34 @@ class _MobileLinkMenu extends StatefulWidget {
class _MobileLinkMenuState extends State<_MobileLinkMenu> {
late TextEditingController _textEditingController;
late FocusNode _focusNode;
@override
void initState() {
super.initState();
widget.editorState.service.keyboardService?.disable();
_textEditingController = TextEditingController(text: widget.linkText ?? '');
_focusNode = FocusNode()..requestFocus();
}
@override
void dispose() {
_textEditingController.dispose();
_focusNode.dispose();
widget.editorState.service.keyboardService?.enable();
super.dispose();
}
@override
Widget build(BuildContext context) {
final style = MobileToolbarStyle.of(context);
const double spacing = 8;
return Material(
// TextField widget needs to be wrapped in a Material widget to provide a visual appearance
color: style.backgroundColor,
child: SizedBox(
height: style.toolbarHeight,
child: Row(
height: style.toolbarHeight * 2 + spacing,
child: Column(
children: [
Expanded(
child: SizedBox(
height: 42,
child: TextField(
focusNode: _focusNode,
TextField(
autofocus: true,
controller: _textEditingController,
keyboardType: TextInputType.url,
onSubmitted: widget.onSubmitted,
@ -107,30 +108,67 @@ class _MobileLinkMenuState extends State<_MobileLinkMenu> {
),
),
),
),
),
const SizedBox(width: 8),
SizedBox(
height: 40,
const SizedBox(height: spacing),
Row(
children: [
Expanded(
child: ElevatedButton(
onPressed: () {
widget.onSubmitted.call(_textEditingController.text);
widget.onCancel.call();
},
style: ButtonStyle(
backgroundColor: MaterialStateProperty.all(
style.itemHighlightColor,
style.backgroundColor,
),
foregroundColor: MaterialStateProperty.all(
style.primaryColor,
),
elevation: MaterialStateProperty.all(0),
shape: MaterialStateProperty.all(
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:flutter/material.dart';
abstract class MobileToolbarItemMenuService {
void closeItemMenu();
}
class MobileToolbarItemMenu extends StatefulWidget {
class MobileToolbarItemMenu extends StatelessWidget {
const MobileToolbarItemMenu({
super.key,
required this.editorState,
@ -13,34 +9,18 @@ class MobileToolbarItemMenu extends StatefulWidget {
});
final EditorState editorState;
final Widget Function(MobileToolbarItemMenuState state) itemMenuBuilder;
@override
State<MobileToolbarItemMenu> createState() => MobileToolbarItemMenuState();
}
class MobileToolbarItemMenuState extends State<MobileToolbarItemMenu>
implements MobileToolbarItemMenuService {
bool _showMenu = true;
final Widget Function() itemMenuBuilder;
@override
Widget build(BuildContext context) {
final size = MediaQuery.of(context).size;
final style = MobileToolbarStyle.of(context);
return _showMenu
? Container(
return Container(
width: size.width,
color: style.backgroundColor,
padding: const EdgeInsets.all(8),
child: widget.itemMenuBuilder(this),
)
: const SizedBox.shrink();
}
@override
void closeItemMenu() {
setState(() {
_showMenu = false;
});
child: itemMenuBuilder(),
);
}
}

View file

@ -48,6 +48,7 @@ class MessageLookup extends MessageLookupByLibrary {
MessageLookupByLibrary.simpleMessage("Yellow background"),
"bold": MessageLookupByLibrary.simpleMessage("Bold"),
"bulletedList": MessageLookupByLibrary.simpleMessage("Bulleted List"),
"cancel": MessageLookupByLibrary.simpleMessage("Cancel"),
"checkbox": MessageLookupByLibrary.simpleMessage("Checkbox"),
"clearHighlightColor":
MessageLookupByLibrary.simpleMessage("Clear highlight color"),
@ -55,6 +56,7 @@ class MessageLookup extends MessageLookupByLibrary {
"copyLink": MessageLookupByLibrary.simpleMessage("Copy link"),
"customColor": MessageLookupByLibrary.simpleMessage("Custom color"),
"done": MessageLookupByLibrary.simpleMessage("Done"),
"editLink": MessageLookupByLibrary.simpleMessage("Edit link"),
"embedCode": MessageLookupByLibrary.simpleMessage("Embed Code"),
"fontColorBlue": MessageLookupByLibrary.simpleMessage("Blue"),
"fontColorBrown": MessageLookupByLibrary.simpleMessage("Brown"),
@ -85,6 +87,11 @@ class MessageLookup extends MessageLookupByLibrary {
"lightLightTint8": MessageLookupByLibrary.simpleMessage("Aqua"),
"lightLightTint9": MessageLookupByLibrary.simpleMessage("Blue"),
"link": MessageLookupByLibrary.simpleMessage("Link"),
"linkAddressHint":
MessageLookupByLibrary.simpleMessage("Please enter URL"),
"linkText": MessageLookupByLibrary.simpleMessage("Text"),
"linkTextHint":
MessageLookupByLibrary.simpleMessage("Please enter text"),
"ltr": MessageLookupByLibrary.simpleMessage("LTR"),
"mobileHeading1": MessageLookupByLibrary.simpleMessage("Heading 1"),
"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`
String get tint1 {
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`
String get highlightColor {
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 tabbarSelectedBackgroundColor = Color(0x23808080);
const tabbarSelectedForegroundColor = Colors.black;
const primaryColor = Color(0xff1F71AC);
const onPrimaryColor = Colors.white;
const outlineColor = Color(0xFFE3E3E3);
const toolbarHeight = 50.0;
const borderRadius = 6.0;
const buttonHeight = 40.0;
@ -28,6 +31,9 @@ void main() {
itemOutlineColor: itemOutlineColor,
tabbarSelectedBackgroundColor: tabbarSelectedBackgroundColor,
tabbarSelectedForegroundColor: tabbarSelectedForegroundColor,
primaryColor: primaryColor,
onPrimaryColor: onPrimaryColor,
outlineColor: outlineColor,
toolbarHeight: toolbarHeight,
borderRadius: borderRadius,
buttonHeight: buttonHeight,

View file

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