Merge pull request #932 from LucasXu0/feat/931

feat: #931 highlight the status of the currently selected style in toolbar
This commit is contained in:
Nathan.fooo 2022-08-30 22:03:27 +08:00 committed by GitHub
commit 439690f3eb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 361 additions and 70 deletions

View File

@ -0,0 +1,8 @@
import 'package:appflowy_editor/appflowy_editor.dart';
extension EditorStateExtensions on EditorState {
List<TextNode> get selectedTextNodes =>
service.selectionService.currentSelectedNodes
.whereType<TextNode>()
.toList(growable: false);
}

View File

@ -29,56 +29,63 @@ extension TextNodeExtension on TextNode {
}
bool allSatisfyLinkInSelection(Selection selection) =>
allSatisfyInSelection(StyleKey.href, selection, (value) {
allSatisfyInSelection(selection, StyleKey.href, (value) {
return value != null;
});
bool allSatisfyBoldInSelection(Selection selection) =>
allSatisfyInSelection(StyleKey.bold, selection, (value) {
allSatisfyInSelection(selection, StyleKey.bold, (value) {
return value == true;
});
bool allSatisfyItalicInSelection(Selection selection) =>
allSatisfyInSelection(StyleKey.italic, selection, (value) {
allSatisfyInSelection(selection, StyleKey.italic, (value) {
return value == true;
});
bool allSatisfyUnderlineInSelection(Selection selection) =>
allSatisfyInSelection(StyleKey.underline, selection, (value) {
allSatisfyInSelection(selection, StyleKey.underline, (value) {
return value == true;
});
bool allSatisfyStrikethroughInSelection(Selection selection) =>
allSatisfyInSelection(StyleKey.strikethrough, selection, (value) {
allSatisfyInSelection(selection, StyleKey.strikethrough, (value) {
return value == true;
});
bool allSatisfyInSelection(
String styleKey,
Selection selection,
String styleKey,
bool Function(dynamic value) test,
) {
final ops = delta.whereType<TextInsert>();
final startOffset =
selection.isBackward ? selection.start.offset : selection.end.offset;
final endOffset =
selection.isBackward ? selection.end.offset : selection.start.offset;
var start = 0;
for (final op in ops) {
if (start >= endOffset) {
break;
if (StyleKey.globalStyleKeys.contains(styleKey)) {
if (attributes.containsKey(styleKey)) {
return test(attributes[styleKey]);
}
final length = op.length;
if (start < endOffset && start + length > startOffset) {
if (op.attributes == null ||
!op.attributes!.containsKey(styleKey) ||
!test(op.attributes![styleKey])) {
return false;
} else if (StyleKey.partialStyleKeys.contains(styleKey)) {
final ops = delta.whereType<TextInsert>();
final startOffset =
selection.isBackward ? selection.start.offset : selection.end.offset;
final endOffset =
selection.isBackward ? selection.end.offset : selection.start.offset;
var start = 0;
for (final op in ops) {
if (start >= endOffset) {
break;
}
final length = op.length;
if (start < endOffset && start + length > startOffset) {
if (op.attributes == null ||
!op.attributes!.containsKey(styleKey) ||
!test(op.attributes![styleKey])) {
return false;
}
}
start += length;
}
start += length;
return true;
}
return true;
return false;
}
bool allNotSatisfyInSelection(
@ -111,29 +118,44 @@ extension TextNodeExtension on TextNode {
}
extension TextNodesExtension on List<TextNode> {
bool allSatisfyBoldInSelection(Selection selection) =>
allSatisfyInSelection(StyleKey.bold, selection, true);
bool allSatisfyBoldInSelection(Selection selection) => allSatisfyInSelection(
selection,
StyleKey.bold,
(value) => value == true,
);
bool allSatisfyItalicInSelection(Selection selection) =>
allSatisfyInSelection(StyleKey.italic, selection, true);
allSatisfyInSelection(
selection,
StyleKey.italic,
(value) => value == true,
);
bool allSatisfyUnderlineInSelection(Selection selection) =>
allSatisfyInSelection(StyleKey.underline, selection, true);
allSatisfyInSelection(
selection,
StyleKey.underline,
(value) => value == true,
);
bool allSatisfyStrikethroughInSelection(Selection selection) =>
allSatisfyInSelection(StyleKey.strikethrough, selection, true);
allSatisfyInSelection(
selection,
StyleKey.strikethrough,
(value) => value == true,
);
bool allSatisfyInSelection(
String styleKey,
Selection selection,
dynamic matchValue,
String styleKey,
bool Function(dynamic value) test,
) {
if (isEmpty) {
return false;
}
if (length == 1) {
return first.allSatisfyInSelection(styleKey, selection, (value) {
return value == matchValue;
return first.allSatisfyInSelection(selection, styleKey, (value) {
return test(value);
});
} else {
for (var i = 0; i < length; i++) {
@ -154,8 +176,8 @@ extension TextNodesExtension on List<TextNode> {
end: Position(path: node.path, offset: node.toRawString().length),
);
}
if (!node.allSatisfyInSelection(styleKey, newSelection, (value) {
return value == matchValue;
if (!node.allSatisfyInSelection(newSelection, styleKey, (value) {
return test(value);
})) {
return false;
}

View File

@ -47,6 +47,8 @@ class StyleKey {
StyleKey.italic,
StyleKey.underline,
StyleKey.strikethrough,
StyleKey.backgroundColor,
StyleKey.href,
];
static List<String> globalStyleKeys = [

View File

@ -4,38 +4,43 @@ import 'package:appflowy_editor/src/infra/flowy_svg.dart';
import 'package:appflowy_editor/src/render/link_menu/link_menu.dart';
import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart';
import 'package:appflowy_editor/src/extensions/text_node_extensions.dart';
import 'package:appflowy_editor/src/extensions/editor_state_extensions.dart';
import 'package:appflowy_editor/src/service/default_text_operations/format_rich_text_style.dart';
import 'package:flutter/material.dart';
import 'package:rich_clipboard/rich_clipboard.dart';
typedef ToolbarEventHandler = void Function(
typedef ToolbarItemEventHandler = void Function(
EditorState editorState, BuildContext context);
typedef ToolbarShowValidator = bool Function(EditorState editorState);
typedef ToolbarItemValidator = bool Function(EditorState editorState);
typedef ToolbarItemHighlightCallback = bool Function(EditorState editorState);
class ToolbarItem {
ToolbarItem({
required this.id,
required this.type,
required this.icon,
required this.iconBuilder,
this.tooltipsMessage = '',
required this.validator,
required this.highlightCallback,
required this.handler,
});
final String id;
final int type;
final Widget icon;
final Widget Function(bool isHighlight) iconBuilder;
final String tooltipsMessage;
final ToolbarShowValidator validator;
final ToolbarEventHandler handler;
final ToolbarItemValidator validator;
final ToolbarItemEventHandler handler;
final ToolbarItemHighlightCallback highlightCallback;
factory ToolbarItem.divider() {
return ToolbarItem(
id: 'divider',
type: -1,
icon: const FlowySvg(name: 'toolbar/divider'),
iconBuilder: (_) => const FlowySvg(name: 'toolbar/divider'),
validator: (editorState) => true,
handler: (editorState, context) {},
highlightCallback: (editorState) => false,
);
}
@ -59,103 +64,205 @@ List<ToolbarItem> defaultToolbarItems = [
id: 'appflowy.toolbar.h1',
type: 1,
tooltipsMessage: 'Heading 1',
icon: const FlowySvg(name: 'toolbar/h1'),
iconBuilder: (isHighlight) => FlowySvg(
name: 'toolbar/h1',
color: isHighlight ? Colors.lightBlue : null,
),
validator: _onlyShowInSingleTextSelection,
highlightCallback: (editorState) => _allSatisfy(
editorState,
StyleKey.heading,
(value) => value == StyleKey.h1,
),
handler: (editorState, context) => formatHeading(editorState, StyleKey.h1),
),
ToolbarItem(
id: 'appflowy.toolbar.h2',
type: 1,
tooltipsMessage: 'Heading 2',
icon: const FlowySvg(name: 'toolbar/h2'),
iconBuilder: (isHighlight) => FlowySvg(
name: 'toolbar/h2',
color: isHighlight ? Colors.lightBlue : null,
),
validator: _onlyShowInSingleTextSelection,
highlightCallback: (editorState) => _allSatisfy(
editorState,
StyleKey.heading,
(value) => value == StyleKey.h2,
),
handler: (editorState, context) => formatHeading(editorState, StyleKey.h2),
),
ToolbarItem(
id: 'appflowy.toolbar.h3',
type: 1,
tooltipsMessage: 'Heading 3',
icon: const FlowySvg(name: 'toolbar/h3'),
iconBuilder: (isHighlight) => FlowySvg(
name: 'toolbar/h3',
color: isHighlight ? Colors.lightBlue : null,
),
validator: _onlyShowInSingleTextSelection,
highlightCallback: (editorState) => _allSatisfy(
editorState,
StyleKey.heading,
(value) => value == StyleKey.h3,
),
handler: (editorState, context) => formatHeading(editorState, StyleKey.h3),
),
ToolbarItem(
id: 'appflowy.toolbar.bold',
type: 2,
tooltipsMessage: 'Bold',
icon: const FlowySvg(name: 'toolbar/bold'),
iconBuilder: (isHighlight) => FlowySvg(
name: 'toolbar/bold',
color: isHighlight ? Colors.lightBlue : null,
),
validator: _showInTextSelection,
highlightCallback: (editorState) => _allSatisfy(
editorState,
StyleKey.bold,
(value) => value == true,
),
handler: (editorState, context) => formatBold(editorState),
),
ToolbarItem(
id: 'appflowy.toolbar.italic',
type: 2,
tooltipsMessage: 'Italic',
icon: const FlowySvg(name: 'toolbar/italic'),
iconBuilder: (isHighlight) => FlowySvg(
name: 'toolbar/italic',
color: isHighlight ? Colors.lightBlue : null,
),
validator: _showInTextSelection,
highlightCallback: (editorState) => _allSatisfy(
editorState,
StyleKey.italic,
(value) => value == true,
),
handler: (editorState, context) => formatItalic(editorState),
),
ToolbarItem(
id: 'appflowy.toolbar.underline',
type: 2,
tooltipsMessage: 'Underline',
icon: const FlowySvg(name: 'toolbar/underline'),
iconBuilder: (isHighlight) => FlowySvg(
name: 'toolbar/underline',
color: isHighlight ? Colors.lightBlue : null,
),
validator: _showInTextSelection,
highlightCallback: (editorState) => _allSatisfy(
editorState,
StyleKey.underline,
(value) => value == true,
),
handler: (editorState, context) => formatUnderline(editorState),
),
ToolbarItem(
id: 'appflowy.toolbar.strikethrough',
type: 2,
tooltipsMessage: 'Strikethrough',
icon: const FlowySvg(name: 'toolbar/strikethrough'),
iconBuilder: (isHighlight) => FlowySvg(
name: 'toolbar/strikethrough',
color: isHighlight ? Colors.lightBlue : null,
),
validator: _showInTextSelection,
highlightCallback: (editorState) => _allSatisfy(
editorState,
StyleKey.strikethrough,
(value) => value == true,
),
handler: (editorState, context) => formatStrikethrough(editorState),
),
ToolbarItem(
id: 'appflowy.toolbar.quote',
type: 3,
tooltipsMessage: 'Quote',
icon: const FlowySvg(name: 'toolbar/quote'),
iconBuilder: (isHighlight) => FlowySvg(
name: 'toolbar/quote',
color: isHighlight ? Colors.lightBlue : null,
),
validator: _onlyShowInSingleTextSelection,
highlightCallback: (editorState) => _allSatisfy(
editorState,
StyleKey.subtype,
(value) => value == StyleKey.quote,
),
handler: (editorState, context) => formatQuote(editorState),
),
ToolbarItem(
id: 'appflowy.toolbar.bulleted_list',
type: 3,
tooltipsMessage: 'Bulleted list',
icon: const FlowySvg(name: 'toolbar/bulleted_list'),
iconBuilder: (isHighlight) => FlowySvg(
name: 'toolbar/bulleted_list',
color: isHighlight ? Colors.lightBlue : null,
),
validator: _onlyShowInSingleTextSelection,
highlightCallback: (editorState) => _allSatisfy(
editorState,
StyleKey.subtype,
(value) => value == StyleKey.bulletedList,
),
handler: (editorState, context) => formatBulletedList(editorState),
),
ToolbarItem(
id: 'appflowy.toolbar.link',
type: 4,
tooltipsMessage: 'Link',
icon: const FlowySvg(name: 'toolbar/link'),
iconBuilder: (isHighlight) => FlowySvg(
name: 'toolbar/link',
color: isHighlight ? Colors.lightBlue : null,
),
validator: _onlyShowInSingleTextSelection,
highlightCallback: (editorState) => _allSatisfy(
editorState,
StyleKey.href,
(value) => value != null,
),
handler: (editorState, context) => showLinkMenu(context, editorState),
),
ToolbarItem(
id: 'appflowy.toolbar.highlight',
type: 4,
tooltipsMessage: 'Highlight',
icon: const FlowySvg(name: 'toolbar/highlight'),
iconBuilder: (isHighlight) => FlowySvg(
name: 'toolbar/highlight',
color: isHighlight ? Colors.lightBlue : null,
),
validator: _showInTextSelection,
highlightCallback: (editorState) => _allSatisfy(
editorState,
StyleKey.backgroundColor,
(value) => value != null,
),
handler: (editorState, context) => formatHighlight(editorState),
),
];
ToolbarShowValidator _onlyShowInSingleTextSelection = (editorState) {
ToolbarItemValidator _onlyShowInSingleTextSelection = (editorState) {
final nodes = editorState.service.selectionService.currentSelectedNodes;
return (nodes.length == 1 && nodes.first is TextNode);
};
ToolbarShowValidator _showInTextSelection = (editorState) {
ToolbarItemValidator _showInTextSelection = (editorState) {
final nodes = editorState.service.selectionService.currentSelectedNodes
.whereType<TextNode>();
return nodes.isNotEmpty;
};
bool _allSatisfy(
EditorState editorState,
String styleKey,
bool Function(dynamic value) test,
) {
final selection = editorState.service.selectionService.currentSelection.value;
return selection != null &&
editorState.selectedTextNodes.allSatisfyInSelection(
selection,
styleKey,
test,
);
}
OverlayEntry? _linkMenuOverlay;
EditorState? _editorState;
bool _changeSelectionInner = false;

View File

@ -6,11 +6,13 @@ class ToolbarItemWidget extends StatelessWidget {
const ToolbarItemWidget({
Key? key,
required this.item,
required this.isHighlight,
required this.onPressed,
}) : super(key: key);
final ToolbarItem item;
final VoidCallback onPressed;
final bool isHighlight;
@override
Widget build(BuildContext context) {
@ -23,8 +25,9 @@ class ToolbarItemWidget extends StatelessWidget {
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: IconButton(
highlightColor: Colors.yellow,
padding: EdgeInsets.zero,
icon: item.icon,
icon: item.iconBuilder(isHighlight),
iconSize: 28,
onPressed: onPressed,
),

View File

@ -64,6 +64,7 @@ class _ToolbarWidgetState extends State<ToolbarWidget> with ToolbarMixin {
(item) => Center(
child: ToolbarItemWidget(
item: item,
isHighlight: item.highlightCallback(widget.editorState),
onPressed: () {
item.handler(widget.editorState, context);
},

View File

@ -157,7 +157,7 @@ bool formatRichTextPartialStyle(EditorState editorState, String styleKey,
}
bool _allSatisfyInSelection(
EditorState editorState, String styleKey, dynamic value) {
EditorState editorState, String styleKey, dynamic matchValue) {
final selection = editorState.service.selectionService.currentSelection.value;
final nodes = editorState.service.selectionService.currentSelectedNodes;
final textNodes = nodes.whereType<TextNode>().toList(growable: false);
@ -166,7 +166,9 @@ bool _allSatisfyInSelection(
return false;
}
return textNodes.allSatisfyInSelection(styleKey, selection, value);
return textNodes.allSatisfyInSelection(selection, styleKey, (value) {
return value == matchValue;
});
}
bool formatRichTextStyle(EditorState editorState, Attributes attributes) {

View File

@ -102,4 +102,9 @@ class _FlowyToolbarState extends State<FlowyToolbar>
}
return dividedItems;
}
// List<ToolbarItem> _highlightItems(
// List<ToolbarItem> items,
// Selection selection,
// ) {}
}

View File

@ -11,17 +11,28 @@ void main() async {
group('toolbar_item_widget.dart', () {
testWidgets('test single toolbar item widget', (tester) async {
final key = GlobalKey();
final iconKey = GlobalKey();
var hit = false;
final item = ToolbarItem(
id: 'appflowy.toolbar.test',
type: 1,
icon: const Icon(Icons.abc),
iconBuilder: (isHighlight) {
return Icon(
key: iconKey,
Icons.abc,
color: isHighlight ? Colors.lightBlue : null,
);
},
validator: (editorState) => true,
handler: (editorState, context) {},
highlightCallback: (editorState) {
return true;
},
);
final widget = ToolbarItemWidget(
key: key,
item: item,
isHighlight: true,
onPressed: (() {
hit = true;
}),
@ -36,6 +47,11 @@ void main() async {
);
expect(find.byKey(key), findsOneWidget);
expect(find.byKey(iconKey), findsOneWidget);
expect(
(tester.firstWidget(find.byKey(iconKey)) as Icon).color,
Colors.lightBlue,
);
await tester.tap(find.byKey(key));
await tester.pumpAndSettle();

View File

@ -2,7 +2,6 @@ import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/render/link_menu/link_menu.dart';
import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart';
import 'package:appflowy_editor/src/extensions/text_node_extensions.dart';
import 'package:appflowy_editor/src/render/toolbar/toolbar_item.dart';
import 'package:appflowy_editor/src/render/toolbar/toolbar_widget.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@ -91,8 +90,8 @@ Future<void> _testUpdateTextStyleByCommandX(
var textNode = editor.nodeAtPath([1]) as TextNode;
expect(
textNode.allSatisfyInSelection(
matchStyle,
selection,
matchStyle,
(value) {
return value == matchValue;
},
@ -110,8 +109,8 @@ Future<void> _testUpdateTextStyleByCommandX(
textNode = editor.nodeAtPath([1]) as TextNode;
expect(
textNode.allSatisfyInSelection(
matchStyle,
selection,
matchStyle,
(value) {
return value == matchValue;
},
@ -144,12 +143,12 @@ Future<void> _testUpdateTextStyleByCommandX(
for (final node in nodes) {
expect(
node.allSatisfyInSelection(
matchStyle,
Selection.single(
path: node.path,
startOffset: 0,
endOffset: text.length,
),
matchStyle,
(value) {
return value == matchValue;
},
@ -196,11 +195,6 @@ Future<void> _testLinkMenuInSingleTextSelection(WidgetTester tester) async {
// show toolbar
expect(find.byType(ToolbarWidget), findsOneWidget);
final item = defaultToolbarItems
.where((item) => item.id == 'appflowy.toolbar.link')
.first;
expect(find.byWidget(item.icon), findsOneWidget);
// trigger the link menu
await editor.pressLogicKey(LogicalKeyboardKey.keyK, isMetaPressed: true);
@ -215,8 +209,8 @@ Future<void> _testLinkMenuInSingleTextSelection(WidgetTester tester) async {
final node = editor.nodeAtPath([1]) as TextNode;
expect(
node.allSatisfyInSelection(
StyleKey.href,
selection,
StyleKey.href,
(value) => value == link,
),
true);
@ -244,8 +238,8 @@ Future<void> _testLinkMenuInSingleTextSelection(WidgetTester tester) async {
expect(
node.allSatisfyInSelection(
StyleKey.href,
selection,
StyleKey.href,
(value) => value == link,
),
false);

View File

@ -1,5 +1,7 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart';
import 'package:appflowy_editor/src/render/toolbar/toolbar_item.dart';
import 'package:appflowy_editor/src/render/toolbar/toolbar_item_widget.dart';
import 'package:appflowy_editor/src/render/toolbar/toolbar_widget.dart';
import 'package:flutter_test/flutter_test.dart';
import '../infra/test_editor.dart';
@ -30,7 +32,136 @@ void main() async {
final item = defaultToolbarItems
.where((item) => item.id == 'appflowy.toolbar.link')
.first;
expect(find.byWidget(item.icon), findsNothing);
final finder = find.byType(ToolbarItemWidget);
expect(
tester
.widgetList<ToolbarItemWidget>(finder)
.toList(growable: false)
.where((element) => element.item.id == item.id)
.isEmpty,
true,
);
});
testWidgets(
'Test toolbar service in single text selection with StyleKey.partialStyleKeys',
(tester) async {
final attributes = StyleKey.partialStyleKeys.fold<Attributes>({},
(previousValue, element) {
if (element == StyleKey.backgroundColor) {
previousValue[element] = '0x6000BCF0';
} else if (element == StyleKey.href) {
previousValue[element] = 'appflowy.io';
} else {
previousValue[element] = true;
}
return previousValue;
});
const text = 'Welcome to Appflowy 😁';
final editor = tester.editor
..insertTextNode(text)
..insertTextNode(
null,
delta: Delta([
TextInsert(text),
TextInsert(text, attributes),
TextInsert(text),
]),
);
await editor.startTesting();
await editor.updateSelection(
Selection.single(path: [0], startOffset: 0, endOffset: text.length),
);
expect(find.byType(ToolbarWidget), findsOneWidget);
void testHighlight(bool expectedValue) {
for (final styleKey in StyleKey.partialStyleKeys) {
var key = styleKey;
if (styleKey == StyleKey.backgroundColor) {
key = 'highlight';
} else if (styleKey == StyleKey.href) {
key = 'link';
}
final itemWidget = _itemWidgetForId(tester, 'appflowy.toolbar.$key');
expect(itemWidget.isHighlight, expectedValue);
}
}
await editor.updateSelection(
Selection.single(path: [1], startOffset: 0, endOffset: text.length * 2),
);
testHighlight(false);
await editor.updateSelection(
Selection.single(
path: [1],
startOffset: text.length,
endOffset: text.length * 2,
),
);
testHighlight(true);
await editor.updateSelection(
Selection.single(
path: [1],
startOffset: text.length + 2,
endOffset: text.length * 2 - 2,
),
);
testHighlight(true);
});
testWidgets(
'Test toolbar service in single text selection with StyleKey.globalStyleKeys',
(tester) async {
const text = 'Welcome to Appflowy 😁';
final editor = tester.editor
..insertTextNode(text, attributes: {
StyleKey.subtype: StyleKey.heading,
StyleKey.heading: StyleKey.h1,
})
..insertTextNode(
text,
attributes: {StyleKey.subtype: StyleKey.quote},
)
..insertTextNode(
text,
attributes: {StyleKey.subtype: StyleKey.bulletedList},
);
await editor.startTesting();
await editor.updateSelection(
Selection.single(path: [0], startOffset: 0, endOffset: text.length),
);
expect(find.byType(ToolbarWidget), findsOneWidget);
var itemWidget = _itemWidgetForId(tester, 'appflowy.toolbar.h1');
expect(itemWidget.isHighlight, true);
await editor.updateSelection(
Selection.single(path: [1], startOffset: 0, endOffset: text.length),
);
expect(find.byType(ToolbarWidget), findsOneWidget);
itemWidget = _itemWidgetForId(tester, 'appflowy.toolbar.quote');
expect(itemWidget.isHighlight, true);
await editor.updateSelection(
Selection.single(path: [2], startOffset: 0, endOffset: text.length),
);
expect(find.byType(ToolbarWidget), findsOneWidget);
itemWidget = _itemWidgetForId(tester, 'appflowy.toolbar.bulleted_list');
expect(itemWidget.isHighlight, true);
});
});
}
ToolbarItemWidget _itemWidgetForId(WidgetTester tester, String id) {
final finder = find.byType(ToolbarItemWidget);
final itemWidgets = tester
.widgetList<ToolbarItemWidget>(finder)
.where((element) => element.item.id == id);
expect(itemWidgets.length, 1);
return itemWidgets.first;
}