feat: add edit link menu on mobile (#320)
This commit is contained in:
parent
196edc1d0e
commit
7aa337447b
15 changed files with 768 additions and 167 deletions
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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 ||
|
||||||
|
|
|
||||||
|
|
@ -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?
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue