Merge pull request #880 from LucasXu0/selection_menu_refactor

[Improvement] Refactor selection menu
This commit is contained in:
Nathan.fooo 2022-08-19 21:08:13 +08:00 committed by GitHub
commit e4c039eef5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 824 additions and 562 deletions

View File

@ -1,58 +0,0 @@
{
"document": {
"type": "root",
"children": [
{
"type": "text",
"delta": [],
"attributes": {
"subtype": "with-heading"
}
},
{
"type": "text",
"delta": [],
"attributes": {
"tag": "*"
},
"children": [
{
"type": "text",
"delta": [],
"attributes": {
"text-type": "heading2",
"check": true
}
},
{
"type": "text",
"delta": [],
"attributes": {
"text-type": "checkbox",
"check": true
}
},
{
"type": "text",
"delta": [],
"attributes": {
"tag": "**"
}
}
]
},
{
"type": "image",
"attributes": {
"url": "x.png"
}
},
{
"type": "video",
"attributes": {
"url": "x.mp4"
}
}
]
}
}

View File

@ -1,5 +1,6 @@
import 'dart:async';
import 'package:appflowy_editor/src/infra/log.dart';
import 'package:appflowy_editor/src/render/selection_menu/selection_menu_widget.dart';
import 'package:appflowy_editor/src/service/service.dart';
import 'package:flutter/material.dart';
@ -54,6 +55,9 @@ class EditorState {
/// with this variable.
LogConfiguration get logConfiguration => LogConfiguration();
/// Stores the selection menu items.
List<SelectionMenuItem> selectionMenuItems = [];
final UndoManager undoManager = UndoManager();
Selection? _cursorSelection;

View File

@ -23,7 +23,7 @@ class TransactionBuilder {
TransactionBuilder(this.state);
/// Commits the operations to the state
commit() {
Future<void> commit() async {
final transaction = finish();
state.apply(transaction);
}

View File

@ -0,0 +1,54 @@
import 'package:appflowy_editor/src/editor_state.dart';
import 'package:appflowy_editor/src/render/selection_menu/selection_menu_service.dart';
import 'package:appflowy_editor/src/render/selection_menu/selection_menu_widget.dart';
import 'package:flutter/material.dart';
class SelectionMenuItemWidget extends StatelessWidget {
const SelectionMenuItemWidget({
Key? key,
required this.editorState,
required this.menuService,
required this.item,
required this.isSelected,
this.width = 140.0,
this.selectedColor = const Color(0xFFE0F8FF),
}) : super(key: key);
final EditorState editorState;
final SelectionMenuService menuService;
final SelectionMenuItem item;
final double width;
final bool isSelected;
final Color selectedColor;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.fromLTRB(8.0, 5.0, 8.0, 5.0),
child: SizedBox(
width: width,
child: TextButton.icon(
icon: item.icon,
style: ButtonStyle(
alignment: Alignment.centerLeft,
overlayColor: MaterialStateProperty.all(selectedColor),
backgroundColor: isSelected
? MaterialStateProperty.all(selectedColor)
: MaterialStateProperty.all(Colors.transparent),
),
label: Text(
item.name,
textAlign: TextAlign.left,
style: const TextStyle(
color: Colors.black,
fontSize: 14.0,
),
),
onPressed: () {
item.handler(editorState, menuService);
},
),
),
);
}
}

View File

@ -0,0 +1,171 @@
import 'package:appflowy_editor/src/editor_state.dart';
import 'package:appflowy_editor/src/infra/flowy_svg.dart';
import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart';
import 'package:appflowy_editor/src/render/selection_menu/selection_menu_widget.dart';
import 'package:appflowy_editor/src/service/default_text_operations/format_rich_text_style.dart';
import 'package:flutter/material.dart';
abstract class SelectionMenuService {
Offset get topLeft;
void show();
void dismiss();
}
class SelectionMenu implements SelectionMenuService {
SelectionMenu({
required this.context,
required this.editorState,
});
final BuildContext context;
final EditorState editorState;
OverlayEntry? _selectionMenuEntry;
bool _selectionUpdateByInner = false;
@override
void dismiss() {
if (_selectionMenuEntry != null) {
editorState.service.keyboardService?.enable();
editorState.service.scrollService?.enable();
}
_selectionMenuEntry?.remove();
_selectionMenuEntry = null;
// workaround: SelectionService has been released after hot reload.
final isSelectionDisposed =
editorState.service.selectionServiceKey.currentState == null;
if (!isSelectionDisposed) {
final selectionService = editorState.service.selectionService;
selectionService.currentSelection.removeListener(_onSelectionChange);
}
}
@override
void show() {
dismiss();
final selectionService = editorState.service.selectionService;
final selectionRects = selectionService.selectionRects;
if (selectionRects.isEmpty) {
return;
}
final offset = selectionRects.first.bottomRight + const Offset(10, 10);
_selectionMenuEntry = OverlayEntry(builder: (context) {
return Positioned(
top: offset.dy,
left: offset.dx,
child: SelectionMenuWidget(
items: [
..._defaultSelectionMenuItems,
...editorState.selectionMenuItems,
],
maxItemInRow: 5,
editorState: editorState,
menuService: this,
onExit: () {
dismiss();
},
onSelectionUpdate: () {
_selectionUpdateByInner = true;
},
),
);
});
Overlay.of(context)?.insert(_selectionMenuEntry!);
editorState.service.keyboardService?.disable();
editorState.service.scrollService?.disable();
selectionService.currentSelection.addListener(_onSelectionChange);
}
@override
// TODO: implement topLeft
Offset get topLeft => throw UnimplementedError();
void _onSelectionChange() {
// workaround: SelectionService has been released after hot reload.
final isSelectionDisposed =
editorState.service.selectionServiceKey.currentState == null;
if (!isSelectionDisposed) {
final selectionService = editorState.service.selectionService;
if (selectionService.currentSelection.value == null) {
return;
}
}
if (_selectionUpdateByInner) {
_selectionUpdateByInner = false;
return;
}
dismiss();
}
}
@visibleForTesting
List<SelectionMenuItem> get defaultSelectionMenuItems =>
_defaultSelectionMenuItems;
final List<SelectionMenuItem> _defaultSelectionMenuItems = [
SelectionMenuItem(
name: 'Text',
icon: _selectionMenuIcon('text'),
keywords: ['text'],
handler: (editorState, menuService) {
insertTextNodeAfterSelection(editorState, {});
},
),
SelectionMenuItem(
name: 'Heading 1',
icon: _selectionMenuIcon('h1'),
keywords: ['heading 1, h1'],
handler: (editorState, menuService) {
insertHeadingAfterSelection(editorState, StyleKey.h1);
},
),
SelectionMenuItem(
name: 'Heading 2',
icon: _selectionMenuIcon('h2'),
keywords: ['heading 2, h2'],
handler: (editorState, menuService) {
insertHeadingAfterSelection(editorState, StyleKey.h2);
},
),
SelectionMenuItem(
name: 'Heading 3',
icon: _selectionMenuIcon('h3'),
keywords: ['heading 3, h3'],
handler: (editorState, menuService) {
insertHeadingAfterSelection(editorState, StyleKey.h3);
},
),
SelectionMenuItem(
name: 'Bulleted list',
icon: _selectionMenuIcon('bulleted_list'),
keywords: ['bulleted list', 'list', 'unordered list'],
handler: (editorState, menuService) {
insertBulletedListAfterSelection(editorState);
},
),
SelectionMenuItem(
name: 'Checkbox',
icon: _selectionMenuIcon('checkbox'),
keywords: ['todo list', 'list', 'checkbox list'],
handler: (editorState, menuService) {
insertCheckboxAfterSelection(editorState);
},
),
];
Widget _selectionMenuIcon(String name) {
return FlowySvg(
name: 'selection_menu/$name',
color: Colors.black,
width: 18.0,
height: 18.0,
);
}

View File

@ -0,0 +1,278 @@
import 'dart:math';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/render/selection_menu/selection_menu_item_widget.dart';
import 'package:appflowy_editor/src/render/selection_menu/selection_menu_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
/// Selection Menu Item
class SelectionMenuItem {
SelectionMenuItem({
required this.name,
required this.icon,
required this.keywords,
required this.handler,
});
final String name;
final Widget icon;
/// Customizes keywords for item.
///
/// The keywords are used to quickly retrieve items.
final List<String> keywords;
final void Function(EditorState editorState, SelectionMenuService menuService)
handler;
}
class SelectionMenuWidget extends StatefulWidget {
const SelectionMenuWidget({
Key? key,
required this.items,
required this.maxItemInRow,
required this.editorState,
required this.menuService,
required this.onExit,
required this.onSelectionUpdate,
}) : super(key: key);
final List<SelectionMenuItem> items;
final int maxItemInRow;
final SelectionMenuService menuService;
final EditorState editorState;
final VoidCallback onSelectionUpdate;
final VoidCallback onExit;
@override
State<SelectionMenuWidget> createState() => _SelectionMenuWidgetState();
}
class _SelectionMenuWidgetState extends State<SelectionMenuWidget> {
final _focusNode = FocusNode(debugLabel: 'popup_list_widget');
int _selectedIndex = 0;
List<SelectionMenuItem> _showingItems = [];
String _keyword = '';
String get keyword => _keyword;
set keyword(String newKeyword) {
_keyword = newKeyword;
// Search items according to the keyword, and calculate the length of
// the longest keyword, which is used to dismiss the selection_service.
var maxKeywordLength = 0;
final items = widget.items
.where(
(item) => item.keywords.any((keyword) {
final value = keyword.contains(newKeyword);
if (value) {
maxKeywordLength = max(maxKeywordLength, keyword.length);
}
return value;
}),
)
.toList(growable: false);
Log.ui.debug('$items');
if (keyword.length >= maxKeywordLength + 2) {
widget.onExit();
} else {
setState(() {
_showingItems = items;
});
}
}
@override
void initState() {
super.initState();
_showingItems = widget.items;
WidgetsBinding.instance.addPostFrameCallback((_) {
_focusNode.requestFocus();
});
}
@override
void dispose() {
_focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Focus(
focusNode: _focusNode,
onKey: _onKey,
child: Container(
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
blurRadius: 5,
spreadRadius: 1,
color: Colors.black.withOpacity(0.1),
),
],
borderRadius: BorderRadius.circular(6.0),
),
child: _showingItems.isEmpty
? _buildNoResultsWidget(context)
: _buildResultsWidget(
context,
_showingItems,
_selectedIndex,
),
),
);
}
Widget _buildResultsWidget(
BuildContext buildContext,
List<SelectionMenuItem> items,
int selectedIndex,
) {
List<Widget> columns = [];
List<Widget> itemWidgets = [];
for (var i = 0; i < items.length; i++) {
if (i != 0 && i % (widget.maxItemInRow) == 0) {
columns.add(Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: itemWidgets,
));
itemWidgets = [];
}
itemWidgets.add(SelectionMenuItemWidget(
item: items[i],
isSelected: selectedIndex == i,
editorState: widget.editorState,
menuService: widget.menuService,
));
}
if (itemWidgets.isNotEmpty) {
columns.add(Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: itemWidgets,
));
itemWidgets = [];
}
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: columns,
);
}
Widget _buildNoResultsWidget(BuildContext context) {
return const Align(
alignment: Alignment.centerLeft,
child: Material(
child: Padding(
padding: EdgeInsets.all(12.0),
child: Text(
'No results',
style: TextStyle(color: Colors.grey),
),
),
),
);
}
/// Handles arrow keys to switch selected items
/// Handles keyword searches
/// Handles enter to select item and esc to exit
KeyEventResult _onKey(FocusNode node, RawKeyEvent event) {
Log.keyboard.debug('slash command, on key $event');
if (event is! RawKeyDownEvent) {
return KeyEventResult.ignored;
}
final arrowKeys = [
LogicalKeyboardKey.arrowLeft,
LogicalKeyboardKey.arrowRight,
LogicalKeyboardKey.arrowUp,
LogicalKeyboardKey.arrowDown
];
if (event.logicalKey == LogicalKeyboardKey.enter) {
if (0 <= _selectedIndex && _selectedIndex < _showingItems.length) {
_deleteLastCharacters(length: keyword.length + 1);
_showingItems[_selectedIndex]
.handler(widget.editorState, widget.menuService);
return KeyEventResult.handled;
}
} else if (event.logicalKey == LogicalKeyboardKey.escape) {
widget.onExit();
return KeyEventResult.handled;
} else if (event.logicalKey == LogicalKeyboardKey.backspace) {
if (keyword.isEmpty) {
widget.onExit();
} else {
keyword = keyword.substring(0, keyword.length - 1);
}
_deleteLastCharacters();
return KeyEventResult.handled;
} else if (event.character != null &&
!arrowKeys.contains(event.logicalKey)) {
keyword += event.character!;
_insertText(event.character!);
return KeyEventResult.handled;
}
var newSelectedIndex = _selectedIndex;
if (event.logicalKey == LogicalKeyboardKey.arrowLeft) {
newSelectedIndex -= widget.maxItemInRow;
} else if (event.logicalKey == LogicalKeyboardKey.arrowRight) {
newSelectedIndex += widget.maxItemInRow;
} else if (event.logicalKey == LogicalKeyboardKey.arrowUp) {
newSelectedIndex -= 1;
} else if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
newSelectedIndex += 1;
}
if (newSelectedIndex != _selectedIndex) {
setState(() {
_selectedIndex = newSelectedIndex.clamp(0, _showingItems.length - 1);
});
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
void _deleteLastCharacters({int length = 1}) {
final selectionService = widget.editorState.service.selectionService;
final selection = selectionService.currentSelection.value;
final nodes = selectionService.currentSelectedNodes;
if (selection != null && nodes.length == 1) {
widget.onSelectionUpdate();
TransactionBuilder(widget.editorState)
..deleteText(
nodes.first as TextNode,
selection.start.offset - length,
length,
)
..commit();
}
}
void _insertText(String text) {
final selection =
widget.editorState.service.selectionService.currentSelection.value;
final nodes =
widget.editorState.service.selectionService.currentSelectedNodes;
if (selection != null && nodes.length == 1) {
widget.onSelectionUpdate();
TransactionBuilder(widget.editorState)
..insertText(
nodes.first as TextNode,
selection.end.offset,
text,
)
..commit();
}
}
}

View File

@ -1,3 +1,4 @@
import 'package:appflowy_editor/src/render/selection_menu/selection_menu_widget.dart';
import 'package:appflowy_editor/src/service/internal_key_event_handlers/default_key_event_handlers.dart';
import 'package:flutter/material.dart';
@ -32,6 +33,7 @@ class AppFlowyEditor extends StatefulWidget {
required this.editorState,
this.customBuilders = const {},
this.keyEventHandlers = const [],
this.selectionMenuItems = const [],
}) : super(key: key);
final EditorState editorState;
@ -42,6 +44,8 @@ class AppFlowyEditor extends StatefulWidget {
/// Keyboard event handlers.
final List<AppFlowyKeyEventHandler> keyEventHandlers;
final List<SelectionMenuItem> selectionMenuItems;
@override
State<AppFlowyEditor> createState() => _AppFlowyEditorState();
}
@ -53,6 +57,7 @@ class _AppFlowyEditorState extends State<AppFlowyEditor> {
void initState() {
super.initState();
editorState.selectionMenuItems = widget.selectionMenuItems;
editorState.service.renderPluginService = _createRenderPlugin();
}
@ -68,35 +73,35 @@ class _AppFlowyEditorState extends State<AppFlowyEditor> {
@override
Widget build(BuildContext context) {
return AppFlowyScroll(
key: editorState.service.scrollServiceKey,
child: AppFlowySelection(
key: editorState.service.selectionServiceKey,
key: editorState.service.scrollServiceKey,
child: AppFlowySelection(
key: editorState.service.selectionServiceKey,
editorState: editorState,
child: AppFlowyInput(
key: editorState.service.inputServiceKey,
editorState: editorState,
child: AppFlowyInput(
key: editorState.service.inputServiceKey,
child: AppFlowyKeyboard(
key: editorState.service.keyboardServiceKey,
handlers: [
...defaultKeyEventHandlers,
...widget.keyEventHandlers,
],
editorState: editorState,
child: AppFlowyKeyboard(
key: editorState.service.keyboardServiceKey,
handlers: [
...defaultKeyEventHandlers,
...widget.keyEventHandlers,
],
child: FlowyToolbar(
key: editorState.service.toolbarServiceKey,
editorState: editorState,
child: FlowyToolbar(
key: editorState.service.toolbarServiceKey,
editorState: editorState,
child:
editorState.service.renderPluginService.buildPluginWidget(
NodeWidgetContext(
context: context,
node: editorState.document.root,
editorState: editorState,
),
child: editorState.service.renderPluginService.buildPluginWidget(
NodeWidgetContext(
context: context,
node: editorState.document.root,
editorState: editorState,
),
),
),
),
));
),
),
);
}
AppFlowyRenderPlugin _createRenderPlugin() => AppFlowyRenderPlugin(

View File

@ -1,67 +1,12 @@
import 'dart:math';
import 'package:appflowy_editor/src/document/node.dart';
import 'package:appflowy_editor/src/editor_state.dart';
import 'package:appflowy_editor/src/infra/flowy_svg.dart';
import 'package:appflowy_editor/src/infra/log.dart';
import 'package:appflowy_editor/src/operation/transaction_builder.dart';
import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart';
import 'package:appflowy_editor/src/service/default_text_operations/format_rich_text_style.dart';
import 'package:appflowy_editor/src/render/selection_menu/selection_menu_service.dart';
import 'package:appflowy_editor/src/service/keyboard_service.dart';
import 'package:appflowy_editor/src/extensions/node_extensions.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@visibleForTesting
List<PopupListItem> get popupListItems => _popupListItems;
final List<PopupListItem> _popupListItems = [
PopupListItem(
text: 'Text',
keywords: ['text'],
icon: _popupListIcon('text'),
handler: (editorState) {
insertTextNodeAfterSelection(editorState, {});
},
),
PopupListItem(
text: 'Heading 1',
keywords: ['h1', 'heading 1'],
icon: _popupListIcon('h1'),
handler: (editorState) =>
insertHeadingAfterSelection(editorState, StyleKey.h1),
),
PopupListItem(
text: 'Heading 2',
keywords: ['h2', 'heading 2'],
icon: _popupListIcon('h2'),
handler: (editorState) =>
insertHeadingAfterSelection(editorState, StyleKey.h2),
),
PopupListItem(
text: 'Heading 3',
keywords: ['h3', 'heading 3'],
icon: _popupListIcon('h3'),
handler: (editorState) =>
insertHeadingAfterSelection(editorState, StyleKey.h3),
),
PopupListItem(
text: 'Bulleted List',
keywords: ['bulleted list'],
icon: _popupListIcon('bullets'),
handler: (editorState) => insertBulletedListAfterSelection(editorState),
),
PopupListItem(
text: 'To-do List',
keywords: ['checkbox', 'todo'],
icon: _popupListIcon('checkbox'),
handler: (editorState) => insertCheckboxAfterSelection(editorState),
),
];
OverlayEntry? _popupListOverlay;
EditorState? _editorState;
bool _selectionChangeBySlash = false;
SelectionMenuService? _selectionMenuService;
AppFlowyKeyEventHandler slashShortcutHandler = (editorState, event) {
if (event.logicalKey != LogicalKeyboardKey.slash) {
return KeyEventResult.ignored;
@ -89,360 +34,11 @@ AppFlowyKeyEventHandler slashShortcutHandler = (editorState, event) {
selection.end.offset - selection.start.offset, event.character ?? '')
..commit();
_editorState = editorState;
WidgetsBinding.instance.addPostFrameCallback((_) {
_selectionChangeBySlash = false;
editorState.service.selectionService.currentSelection
.removeListener(clearPopupList);
editorState.service.selectionService.currentSelection
.addListener(clearPopupList);
editorState.service.scrollService?.disable();
showPopupList(context, editorState, selectionRects.first.bottomRight);
_selectionMenuService =
SelectionMenu(context: context, editorState: editorState);
_selectionMenuService?.show();
});
return KeyEventResult.handled;
};
void showPopupList(
BuildContext context, EditorState editorState, Offset offset) {
_popupListOverlay?.remove();
_popupListOverlay = OverlayEntry(
builder: (context) => Positioned(
top: offset.dy,
left: offset.dx,
child: PopupListWidget(
editorState: editorState,
items: _popupListItems,
),
),
);
Overlay.of(context)?.insert(_popupListOverlay!);
}
void clearPopupList() {
if (_popupListOverlay == null || _editorState == null) {
return;
}
final isSelectionDisposed =
_editorState?.service.selectionServiceKey.currentState != null;
if (isSelectionDisposed) {
final selection =
_editorState?.service.selectionService.currentSelection.value;
if (selection == null) {
return;
}
}
if (_selectionChangeBySlash) {
_selectionChangeBySlash = false;
return;
}
_popupListOverlay?.remove();
_popupListOverlay = null;
_editorState?.service.keyboardService?.enable();
_editorState?.service.scrollService?.enable();
_editorState = null;
}
class PopupListWidget extends StatefulWidget {
const PopupListWidget({
Key? key,
required this.editorState,
required this.items,
this.maxItemInRow = 5,
}) : super(key: key);
final EditorState editorState;
final List<PopupListItem> items;
final int maxItemInRow;
@override
State<PopupListWidget> createState() => _PopupListWidgetState();
}
class _PopupListWidgetState extends State<PopupListWidget> {
final _focusNode = FocusNode(debugLabel: 'popup_list_widget');
int _selectedIndex = 0;
List<PopupListItem> _items = [];
int _maxKeywordLength = 0;
String __keyword = '';
String get _keyword => __keyword;
set _keyword(String keyword) {
__keyword = keyword;
final items = widget.items
.where((item) =>
item.keywords.any((keyword) => keyword.contains(_keyword)))
.toList(growable: false);
if (items.isNotEmpty) {
var maxKeywordLength = 0;
for (var item in _items) {
for (var keyword in item.keywords) {
maxKeywordLength = max(maxKeywordLength, keyword.length);
}
}
_maxKeywordLength = maxKeywordLength;
}
if (keyword.length >= _maxKeywordLength + 2) {
clearPopupList();
} else {
setState(() {
_selectedIndex = 0;
_items = items;
});
}
}
@override
void initState() {
super.initState();
_items = widget.items;
WidgetsBinding.instance.addPostFrameCallback((_) {
_focusNode.requestFocus();
});
}
@override
void dispose() {
_focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Focus(
focusNode: _focusNode,
onKey: _onKey,
child: Container(
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
blurRadius: 5,
spreadRadius: 1,
color: Colors.black.withOpacity(0.1),
),
],
borderRadius: BorderRadius.circular(6.0),
),
child: _items.isEmpty
? _buildNoResultsWidget(context)
: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: _buildColumns(_items, _selectedIndex),
),
),
);
}
Widget _buildNoResultsWidget(BuildContext context) {
return const Align(
alignment: Alignment.centerLeft,
child: Material(
child: Padding(
padding: EdgeInsets.all(12.0),
child: Text(
'No results',
style: TextStyle(color: Colors.grey),
),
),
),
);
}
List<Widget> _buildColumns(List<PopupListItem> items, int selectedIndex) {
List<Widget> columns = [];
List<Widget> itemWidgets = [];
for (var i = 0; i < items.length; i++) {
if (i != 0 && i % (widget.maxItemInRow) == 0) {
columns.add(Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: itemWidgets,
));
itemWidgets = [];
}
itemWidgets.add(_PopupListItemWidget(
editorState: widget.editorState,
item: items[i],
highlight: selectedIndex == i,
));
}
if (itemWidgets.isNotEmpty) {
columns.add(Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: itemWidgets,
));
itemWidgets = [];
}
return columns;
}
KeyEventResult _onKey(FocusNode node, RawKeyEvent event) {
Log.keyboard.debug('slash command, on key $event');
if (event is! RawKeyDownEvent) {
return KeyEventResult.ignored;
}
final arrowKeys = [
LogicalKeyboardKey.arrowLeft,
LogicalKeyboardKey.arrowRight,
LogicalKeyboardKey.arrowUp,
LogicalKeyboardKey.arrowDown
];
if (event.logicalKey == LogicalKeyboardKey.enter) {
if (0 <= _selectedIndex && _selectedIndex < _items.length) {
_deleteLastCharacters(length: _keyword.length + 1);
_items[_selectedIndex].handler(widget.editorState);
return KeyEventResult.handled;
}
} else if (event.logicalKey == LogicalKeyboardKey.escape) {
clearPopupList();
return KeyEventResult.handled;
} else if (event.logicalKey == LogicalKeyboardKey.backspace) {
if (_keyword.isEmpty) {
clearPopupList();
} else {
_keyword = _keyword.substring(0, _keyword.length - 1);
}
_deleteLastCharacters();
return KeyEventResult.handled;
} else if (event.character != null &&
!arrowKeys.contains(event.logicalKey)) {
_keyword += event.character!;
_insertText(event.character!);
return KeyEventResult.handled;
}
var newSelectedIndex = _selectedIndex;
if (event.logicalKey == LogicalKeyboardKey.arrowLeft) {
newSelectedIndex -= widget.maxItemInRow;
} else if (event.logicalKey == LogicalKeyboardKey.arrowRight) {
newSelectedIndex += widget.maxItemInRow;
} else if (event.logicalKey == LogicalKeyboardKey.arrowUp) {
newSelectedIndex -= 1;
} else if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
newSelectedIndex += 1;
}
if (newSelectedIndex != _selectedIndex) {
setState(() {
_selectedIndex = max(0, min(_items.length - 1, newSelectedIndex));
});
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
void _deleteLastCharacters({int length = 1}) {
final selection =
widget.editorState.service.selectionService.currentSelection.value;
final nodes =
widget.editorState.service.selectionService.currentSelectedNodes;
if (selection != null && nodes.length == 1) {
_selectionChangeBySlash = true;
TransactionBuilder(widget.editorState)
..deleteText(
nodes.first as TextNode,
selection.start.offset - length,
length,
)
..commit();
}
}
void _insertText(String text) {
final selection =
widget.editorState.service.selectionService.currentSelection.value;
final nodes =
widget.editorState.service.selectionService.currentSelectedNodes;
if (selection != null && nodes.length == 1) {
_selectionChangeBySlash = true;
TransactionBuilder(widget.editorState)
..insertText(
nodes.first as TextNode,
selection.end.offset,
text,
)
..commit();
}
}
}
class _PopupListItemWidget extends StatelessWidget {
const _PopupListItemWidget({
Key? key,
required this.highlight,
required this.item,
required this.editorState,
}) : super(key: key);
final EditorState editorState;
final PopupListItem item;
final bool highlight;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.fromLTRB(8.0, 5.0, 8.0, 5.0),
child: SizedBox(
width: 140,
child: TextButton.icon(
icon: item.icon,
style: ButtonStyle(
alignment: Alignment.centerLeft,
overlayColor: MaterialStateProperty.all(
const Color(0xFFE0F8FF),
),
backgroundColor: highlight
? MaterialStateProperty.all(const Color(0xFFE0F8FF))
: MaterialStateProperty.all(Colors.transparent),
),
label: Text(
item.text,
textAlign: TextAlign.left,
style: const TextStyle(
color: Colors.black,
fontSize: 14.0,
),
),
onPressed: () {
item.handler(editorState);
},
),
),
);
}
}
class PopupListItem {
PopupListItem({
required this.text,
required this.keywords,
this.message = '',
required this.icon,
required this.handler,
});
final String text;
final List<String> keywords;
final String message;
final Widget icon;
final void Function(EditorState editorState) handler;
}
Widget _popupListIcon(String name) => FlowySvg(
name: 'popup_list/$name',
color: Colors.black,
width: 18.0,
height: 18.0,
);

View File

@ -1,5 +1,4 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/infra/log.dart';
import 'package:flutter/services.dart';
import 'package:flutter/material.dart';
@ -74,6 +73,13 @@ class _AppFlowyKeyboardState extends State<AppFlowyKeyboard>
);
}
@override
void initState() {
super.initState();
enable();
}
@override
void dispose() {
_focusNode.dispose();
@ -95,6 +101,10 @@ class _AppFlowyKeyboardState extends State<AppFlowyKeyboard>
@override
KeyEventResult onKey(RawKeyEvent event) {
if (!isFocus) {
return KeyEventResult.ignored;
}
Log.keyboard.debug('on keyboard event $event');
if (event is! RawKeyDownEvent) {
@ -122,10 +132,6 @@ class _AppFlowyKeyboardState extends State<AppFlowyKeyboard>
}
KeyEventResult _onKey(FocusNode node, RawKeyEvent event) {
if (!isFocus) {
return KeyEventResult.ignored;
}
return onKey(event);
}
}

View File

@ -31,11 +31,8 @@ flutter:
# To add assets to your package, add an assets section, like this:
assets:
- assets/images/toolbar/
- assets/images/popup_list/
- assets/images/selection_menu/
- assets/images/
- assets/document.json
# - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg
#
# For details regarding assets in packages, see
# https://flutter.dev/assets-and-images/#from-packages

View File

@ -102,14 +102,21 @@ class EditorWidgetTester {
bool isAltPressed = false,
bool isMetaPressed = false,
}) async {
final testRawKeyEventData = TestRawKeyEventData(
logicalKey: key,
isControlPressed: isControlPressed,
isShiftPressed: isShiftPressed,
isAltPressed: isAltPressed,
isMetaPressed: isMetaPressed,
).toKeyEvent;
_editorState.service.keyboardService!.onKey(testRawKeyEventData);
if (!isControlPressed &&
!isShiftPressed &&
!isAltPressed &&
!isMetaPressed) {
await tester.sendKeyDownEvent(key);
} else {
final testRawKeyEventData = TestRawKeyEventData(
logicalKey: key,
isControlPressed: isControlPressed,
isShiftPressed: isShiftPressed,
isAltPressed: isAltPressed,
isMetaPressed: isMetaPressed,
).toKeyEvent;
_editorState.service.keyboardService!.onKey(testRawKeyEventData);
}
await tester.pumpAndSettle();
}

View File

@ -1,78 +1,73 @@
import 'dart:convert';
import 'package:appflowy_editor/src/document/node.dart';
import 'package:appflowy_editor/src/document/state_tree.dart';
import 'package:appflowy_editor/src/document/path.dart';
import 'package:appflowy_editor/src/document/position.dart';
import 'package:appflowy_editor/src/document/selection.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test('create state tree', () async {
final String response = await rootBundle.loadString('assets/document.json');
final data = Map<String, Object>.from(json.decode(response));
final stateTree = StateTree.fromJson(data);
expect(stateTree.root.type, 'root');
expect(stateTree.root.toJson(), data['document']);
// final String response = await rootBundle.loadString('assets/document.json');
// final data = Map<String, Object>.from(json.decode(response));
// final stateTree = StateTree.fromJson(data);
// expect(stateTree.root.type, 'root');
// expect(stateTree.root.toJson(), data['document']);
});
test('search node by Path in state tree', () async {
final String response = await rootBundle.loadString('assets/document.json');
final data = Map<String, Object>.from(json.decode(response));
final stateTree = StateTree.fromJson(data);
final checkBoxNode = stateTree.root.childAtPath([1, 0]);
expect(checkBoxNode != null, true);
final textType = checkBoxNode!.attributes['text-type'];
expect(textType != null, true);
// final String response = await rootBundle.loadString('assets/document.json');
// final data = Map<String, Object>.from(json.decode(response));
// final stateTree = StateTree.fromJson(data);
// final checkBoxNode = stateTree.root.childAtPath([1, 0]);
// expect(checkBoxNode != null, true);
// final textType = checkBoxNode!.attributes['text-type'];
// expect(textType != null, true);
});
test('search node by Self in state tree', () async {
final String response = await rootBundle.loadString('assets/document.json');
final data = Map<String, Object>.from(json.decode(response));
final stateTree = StateTree.fromJson(data);
final checkBoxNode = stateTree.root.childAtPath([1, 0]);
expect(checkBoxNode != null, true);
final textType = checkBoxNode!.attributes['text-type'];
expect(textType != null, true);
final path = checkBoxNode.path;
expect(pathEquals(path, [1, 0]), true);
// final String response = await rootBundle.loadString('assets/document.json');
// final data = Map<String, Object>.from(json.decode(response));
// final stateTree = StateTree.fromJson(data);
// final checkBoxNode = stateTree.root.childAtPath([1, 0]);
// expect(checkBoxNode != null, true);
// final textType = checkBoxNode!.attributes['text-type'];
// expect(textType != null, true);
// final path = checkBoxNode.path;
// expect(pathEquals(path, [1, 0]), true);
});
test('insert node in state tree', () async {
final String response = await rootBundle.loadString('assets/document.json');
final data = Map<String, Object>.from(json.decode(response));
final stateTree = StateTree.fromJson(data);
final insertNode = Node.fromJson({
'type': 'text',
});
bool result = stateTree.insert([1, 1], [insertNode]);
expect(result, true);
expect(identical(insertNode, stateTree.nodeAtPath([1, 1])), true);
// final String response = await rootBundle.loadString('assets/document.json');
// final data = Map<String, Object>.from(json.decode(response));
// final stateTree = StateTree.fromJson(data);
// final insertNode = Node.fromJson({
// 'type': 'text',
// });
// bool result = stateTree.insert([1, 1], [insertNode]);
// expect(result, true);
// expect(identical(insertNode, stateTree.nodeAtPath([1, 1])), true);
});
test('delete node in state tree', () async {
final String response = await rootBundle.loadString('assets/document.json');
final data = Map<String, Object>.from(json.decode(response));
final stateTree = StateTree.fromJson(data);
stateTree.delete([1, 1], 1);
final node = stateTree.nodeAtPath([1, 1]);
expect(node != null, true);
expect(node!.attributes['tag'], '**');
// final String response = await rootBundle.loadString('assets/document.json');
// final data = Map<String, Object>.from(json.decode(response));
// final stateTree = StateTree.fromJson(data);
// stateTree.delete([1, 1], 1);
// final node = stateTree.nodeAtPath([1, 1]);
// expect(node != null, true);
// expect(node!.attributes['tag'], '**');
});
test('update node in state tree', () async {
final String response = await rootBundle.loadString('assets/document.json');
final data = Map<String, Object>.from(json.decode(response));
final stateTree = StateTree.fromJson(data);
final test = stateTree.update([1, 1], {'text-type': 'heading1'});
expect(test, true);
final updatedNode = stateTree.nodeAtPath([1, 1]);
expect(updatedNode != null, true);
expect(updatedNode!.attributes['text-type'], 'heading1');
// final String response = await rootBundle.loadString('assets/document.json');
// final data = Map<String, Object>.from(json.decode(response));
// final stateTree = StateTree.fromJson(data);
// final test = stateTree.update([1, 1], {'text-type': 'heading1'});
// expect(test, true);
// final updatedNode = stateTree.nodeAtPath([1, 1]);
// expect(updatedNode != null, true);
// expect(updatedNode!.attributes['text-type'], 'heading1');
});
test('test path utils 1', () {

View File

@ -0,0 +1,49 @@
import 'package:appflowy_editor/src/render/selection_menu/selection_menu_item_widget.dart';
import 'package:appflowy_editor/src/render/selection_menu/selection_menu_service.dart';
import 'package:appflowy_editor/src/render/selection_menu/selection_menu_widget.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../infra/test_editor.dart';
void main() async {
setUpAll(() {
TestWidgetsFlutterBinding.ensureInitialized();
});
group('selection_menu_item_widget.dart', () {
testWidgets('test selection menu item widget', (tester) async {
bool flag = false;
final editorState = tester.editor.editorState;
final menuService = _TestSelectionMenuService();
const icon = Icon(Icons.abc);
final item = SelectionMenuItem(
name: 'example',
icon: icon,
keywords: ['example A', 'example B'],
handler: (editorState, menuService) {
flag = true;
},
);
final widget = SelectionMenuItemWidget(
editorState: editorState,
menuService: menuService,
item: item,
isSelected: true,
);
await tester.pumpWidget(MaterialApp(home: widget));
await tester.tap(find.byType(SelectionMenuItemWidget));
expect(flag, true);
});
});
}
class _TestSelectionMenuService implements SelectionMenuService {
@override
void dismiss() {}
@override
void show() {}
@override
Offset get topLeft => throw UnimplementedError();
}

View File

@ -0,0 +1,150 @@
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/selection_menu/selection_menu_item_widget.dart';
import 'package:appflowy_editor/src/render/selection_menu/selection_menu_service.dart';
import 'package:appflowy_editor/src/render/selection_menu/selection_menu_widget.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../infra/test_editor.dart';
void main() async {
setUpAll(() {
TestWidgetsFlutterBinding.ensureInitialized();
});
group('selection_menu_widget.dart', () {
for (var i = 0; i < defaultSelectionMenuItems.length; i++) {
testWidgets('Selects number.$i item in selection menu', (tester) async {
final editor = await _prepare(tester);
for (var j = 0; j < i; j++) {
await editor.pressLogicKey(LogicalKeyboardKey.arrowDown);
}
await editor.pressLogicKey(LogicalKeyboardKey.enter);
expect(
find.byType(SelectionMenuWidget, skipOffstage: false),
findsNothing,
);
await _testDefaultSelectionMenuItems(i, editor);
});
}
});
testWidgets('Search item in selection menu util no results', (tester) async {
final editor = await _prepare(tester);
await editor.pressLogicKey(LogicalKeyboardKey.keyT);
await editor.pressLogicKey(LogicalKeyboardKey.keyE);
expect(
find.byType(SelectionMenuItemWidget, skipOffstage: false),
findsNWidgets(2),
);
await editor.pressLogicKey(LogicalKeyboardKey.backspace);
expect(
find.byType(SelectionMenuItemWidget, skipOffstage: false),
findsNWidgets(3),
);
await editor.pressLogicKey(LogicalKeyboardKey.keyE);
expect(
find.byType(SelectionMenuItemWidget, skipOffstage: false),
findsNWidgets(2),
);
await editor.pressLogicKey(LogicalKeyboardKey.keyX);
expect(
find.byType(SelectionMenuItemWidget, skipOffstage: false),
findsNWidgets(1),
);
await editor.pressLogicKey(LogicalKeyboardKey.keyT);
expect(
find.byType(SelectionMenuItemWidget, skipOffstage: false),
findsNWidgets(1),
);
await editor.pressLogicKey(LogicalKeyboardKey.keyT);
expect(
find.byType(SelectionMenuItemWidget, skipOffstage: false),
findsNothing,
);
});
testWidgets('Search item in selection menu and presses esc', (tester) async {
final editor = await _prepare(tester);
await editor.pressLogicKey(LogicalKeyboardKey.keyT);
await editor.pressLogicKey(LogicalKeyboardKey.keyE);
expect(
find.byType(SelectionMenuItemWidget, skipOffstage: false),
findsNWidgets(2),
);
await editor.pressLogicKey(LogicalKeyboardKey.escape);
expect(
find.byType(SelectionMenuItemWidget, skipOffstage: false),
findsNothing,
);
});
testWidgets('Search item in selection menu and presses backspace',
(tester) async {
final editor = await _prepare(tester);
await editor.pressLogicKey(LogicalKeyboardKey.keyT);
await editor.pressLogicKey(LogicalKeyboardKey.keyE);
expect(
find.byType(SelectionMenuItemWidget, skipOffstage: false),
findsNWidgets(2),
);
await editor.pressLogicKey(LogicalKeyboardKey.backspace);
await editor.pressLogicKey(LogicalKeyboardKey.backspace);
await editor.pressLogicKey(LogicalKeyboardKey.backspace);
expect(
find.byType(SelectionMenuItemWidget, skipOffstage: false),
findsNothing,
);
});
}
Future<EditorWidgetTester> _prepare(WidgetTester tester) async {
const text = 'Welcome to Appflowy 😁';
const lines = 3;
final editor = tester.editor;
for (var i = 0; i < lines; i++) {
editor.insertTextNode(text);
}
await editor.startTesting();
await editor.updateSelection(Selection.single(path: [1], startOffset: 0));
await editor.pressLogicKey(LogicalKeyboardKey.slash);
await tester.pumpAndSettle(const Duration(milliseconds: 1000));
expect(
find.byType(SelectionMenuWidget, skipOffstage: false),
findsOneWidget,
);
for (final item in defaultSelectionMenuItems) {
expect(find.byWidget(item.icon), findsOneWidget);
}
return Future.value(editor);
}
Future<void> _testDefaultSelectionMenuItems(
int index, EditorWidgetTester editor) async {
expect(editor.documentLength, 4);
expect(editor.documentSelection, Selection.single(path: [2], startOffset: 0));
final node = editor.nodeAtPath([2]);
final item = defaultSelectionMenuItems[index];
if (item.name == 'Text') {
expect(node?.subtype == null, true);
} else if (item.name == 'Heading 1') {
expect(node?.subtype, StyleKey.heading);
expect(node?.attributes.heading, StyleKey.h1);
} else if (item.name == 'Heading 2') {
expect(node?.subtype, StyleKey.heading);
expect(node?.attributes.heading, StyleKey.h2);
} else if (item.name == 'Heading 3') {
expect(node?.subtype, StyleKey.heading);
expect(node?.attributes.heading, StyleKey.h3);
} else if (item.name == 'Bulleted list') {
expect(node?.subtype, StyleKey.bulletedList);
} else if (item.name == 'Checkbox') {
expect(node?.subtype, StyleKey.checkbox);
expect(node?.attributes.check, false);
}
}

View File

@ -1,5 +1,7 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/service/internal_key_event_handlers/slash_handler.dart';
import 'package:appflowy_editor/src/render/selection_menu/selection_menu_item_widget.dart';
import 'package:appflowy_editor/src/render/selection_menu/selection_menu_service.dart';
import 'package:appflowy_editor/src/render/selection_menu/selection_menu_widget.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../infra/test_editor.dart';
@ -10,7 +12,7 @@ void main() async {
});
group('slash_handler.dart', () {
testWidgets('Presses / to trigger popup list ', (tester) async {
testWidgets('Presses / to trigger selection menu', (tester) async {
const text = 'Welcome to Appflowy 😁';
const lines = 3;
final editor = tester.editor;
@ -23,9 +25,12 @@ void main() async {
await tester.pumpAndSettle(const Duration(milliseconds: 1000));
expect(find.byType(PopupListWidget, skipOffstage: false), findsOneWidget);
expect(
find.byType(SelectionMenuWidget, skipOffstage: false),
findsOneWidget,
);
for (final item in popupListItems) {
for (final item in defaultSelectionMenuItems) {
expect(find.byWidget(item.icon), findsOneWidget);
}
@ -33,7 +38,10 @@ void main() async {
await tester.pumpAndSettle(const Duration(milliseconds: 200));
expect(find.byType(PopupListWidget, skipOffstage: false), findsNothing);
expect(
find.byType(SelectionMenuItemWidget, skipOffstage: false),
findsNothing,
);
});
});
}