feat: #818 improve user experience of the slash command

This commit is contained in:
Lucas.Xu 2022-08-11 17:02:04 +08:00
parent 3087594b3c
commit 19838227d9
3 changed files with 183 additions and 32 deletions

View File

@ -1,7 +1,6 @@
import 'dart:collection'; import 'dart:collection';
import 'package:flowy_editor/src/document/path.dart'; import 'package:flowy_editor/src/document/path.dart';
import 'package:flowy_editor/src/document/text_delta.dart'; import 'package:flowy_editor/src/document/text_delta.dart';
import 'package:flowy_editor/src/operation/operation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import './attributes.dart'; import './attributes.dart';
@ -182,12 +181,12 @@ class TextNode extends Node {
}) : _delta = delta, }) : _delta = delta,
super(children: children ?? LinkedList(), attributes: attributes ?? {}); super(children: children ?? LinkedList(), attributes: attributes ?? {});
TextNode.empty() TextNode.empty({Attributes? attributes})
: _delta = Delta([TextInsert('')]), : _delta = Delta([TextInsert('')]),
super( super(
type: 'text', type: 'text',
children: LinkedList(), children: LinkedList(),
attributes: {}, attributes: attributes ?? {},
); );
Delta get delta { Delta get delta {

View File

@ -4,9 +4,64 @@ import 'package:flowy_editor/src/document/position.dart';
import 'package:flowy_editor/src/document/selection.dart'; import 'package:flowy_editor/src/document/selection.dart';
import 'package:flowy_editor/src/editor_state.dart'; import 'package:flowy_editor/src/editor_state.dart';
import 'package:flowy_editor/src/extensions/text_node_extensions.dart'; import 'package:flowy_editor/src/extensions/text_node_extensions.dart';
import 'package:flowy_editor/src/extensions/path_extensions.dart';
import 'package:flowy_editor/src/operation/transaction_builder.dart'; import 'package:flowy_editor/src/operation/transaction_builder.dart';
import 'package:flowy_editor/src/render/rich_text/rich_text_style.dart'; import 'package:flowy_editor/src/render/rich_text/rich_text_style.dart';
void insertHeadingAfterSelection(EditorState editorState, String heading) {
insertTextNodeAfterSelection(editorState, {
StyleKey.subtype: StyleKey.heading,
StyleKey.heading: heading,
});
}
void insertQuoteAfterSelection(EditorState editorState) {
insertTextNodeAfterSelection(editorState, {
StyleKey.subtype: StyleKey.quote,
});
}
void insertCheckboxAfterSelection(EditorState editorState) {
insertTextNodeAfterSelection(editorState, {
StyleKey.subtype: StyleKey.checkbox,
StyleKey.checkbox: false,
});
}
void insertBulletedListAfterSelection(EditorState editorState) {
insertTextNodeAfterSelection(editorState, {
StyleKey.subtype: StyleKey.bulletedList,
});
}
bool insertTextNodeAfterSelection(
EditorState editorState, Attributes attributes) {
final selection = editorState.service.selectionService.currentSelection.value;
final nodes = editorState.service.selectionService.currentSelectedNodes;
if (selection == null || nodes.isEmpty) {
return false;
}
final node = nodes.first;
if (node is TextNode && node.delta.length == 0) {
formatTextNodes(editorState, attributes);
} else {
final next = selection.end.path.next;
final builder = TransactionBuilder(editorState);
builder
..insertNode(
next,
TextNode.empty(attributes: attributes),
)
..afterSelection = Selection.collapsed(
Position(path: next, offset: 0),
)
..commit();
}
return true;
}
void formatText(EditorState editorState) { void formatText(EditorState editorState) {
formatTextNodes(editorState, {}); formatTextNodes(editorState, {});
} }

View File

@ -14,43 +14,56 @@ import 'package:flutter/services.dart';
final List<PopupListItem> _popupListItems = [ final List<PopupListItem> _popupListItems = [
PopupListItem( PopupListItem(
text: 'Text', text: 'Text',
keywords: ['text'],
icon: _popupListIcon('text'), icon: _popupListIcon('text'),
handler: (editorState) => formatText(editorState), handler: (editorState) {
insertTextNodeAfterSelection(editorState, {});
},
), ),
PopupListItem( PopupListItem(
text: 'Heading 1', text: 'Heading 1',
keywords: ['h1', 'heading 1'],
icon: _popupListIcon('h1'), icon: _popupListIcon('h1'),
handler: (editorState) => formatHeading(editorState, StyleKey.h1), handler: (editorState) =>
insertHeadingAfterSelection(editorState, StyleKey.h1),
), ),
PopupListItem( PopupListItem(
text: 'Heading 2', text: 'Heading 2',
keywords: ['h2', 'heading 2'],
icon: _popupListIcon('h2'), icon: _popupListIcon('h2'),
handler: (editorState) => formatHeading(editorState, StyleKey.h2), handler: (editorState) =>
insertHeadingAfterSelection(editorState, StyleKey.h2),
), ),
PopupListItem( PopupListItem(
text: 'Heading 3', text: 'Heading 3',
keywords: ['h3', 'heading 3'],
icon: _popupListIcon('h3'), icon: _popupListIcon('h3'),
handler: (editorState) => formatHeading(editorState, StyleKey.h3), handler: (editorState) =>
insertHeadingAfterSelection(editorState, StyleKey.h3),
), ),
PopupListItem( PopupListItem(
text: 'Bullets', text: 'Bulleted List',
keywords: ['bulleted list'],
icon: _popupListIcon('bullets'), icon: _popupListIcon('bullets'),
handler: (editorState) => formatBulletedList(editorState), handler: (editorState) => insertBulletedListAfterSelection(editorState),
), ),
PopupListItem( PopupListItem(
text: 'Numbered list', text: 'Numbered list',
keywords: ['numbered list'],
icon: _popupListIcon('number'), icon: _popupListIcon('number'),
handler: (editorState) => debugPrint('Not implement yet!'), handler: (editorState) => debugPrint('Not implement yet!'),
), ),
PopupListItem( PopupListItem(
text: 'Checkboxes', text: 'Checkboxes',
keywords: ['checkbox'],
icon: _popupListIcon('checkbox'), icon: _popupListIcon('checkbox'),
handler: (editorState) => formatCheckbox(editorState), handler: (editorState) => insertCheckboxAfterSelection(editorState),
), ),
]; ];
OverlayEntry? _popupListOverlay; OverlayEntry? _popupListOverlay;
EditorState? _editorState; EditorState? _editorState;
bool _selectionChangeBySlash = false;
FlowyKeyEventHandler slashShortcutHandler = (editorState, event) { FlowyKeyEventHandler slashShortcutHandler = (editorState, event) {
if (event.logicalKey != LogicalKeyboardKey.slash) { if (event.logicalKey != LogicalKeyboardKey.slash) {
return KeyEventResult.ignored; return KeyEventResult.ignored;
@ -78,7 +91,7 @@ FlowyKeyEventHandler slashShortcutHandler = (editorState, event) {
TransactionBuilder(editorState) TransactionBuilder(editorState)
..replaceText(textNode, selection.start.offset, ..replaceText(textNode, selection.start.offset,
selection.end.offset - selection.start.offset, '/') selection.end.offset - selection.start.offset, event.character ?? '')
..commit(); ..commit();
_editorState = editorState; _editorState = editorState;
@ -94,7 +107,7 @@ void showPopupList(
_popupListOverlay?.remove(); _popupListOverlay?.remove();
_popupListOverlay = OverlayEntry( _popupListOverlay = OverlayEntry(
builder: (context) => Positioned( builder: (context) => Positioned(
top: offset.dy + 15.0, top: offset.dy + 20.0,
left: offset.dx + 5.0, left: offset.dx + 5.0,
child: PopupListWidget( child: PopupListWidget(
editorState: editorState, editorState: editorState,
@ -117,6 +130,15 @@ void clearPopupList() {
if (_popupListOverlay == null || _editorState == null) { if (_popupListOverlay == null || _editorState == null) {
return; return;
} }
final selection =
_editorState?.service.selectionService.currentSelection.value;
if (selection == null) {
return;
}
if (_selectionChangeBySlash) {
_selectionChangeBySlash = false;
return;
}
_popupListOverlay?.remove(); _popupListOverlay?.remove();
_popupListOverlay = null; _popupListOverlay = null;
@ -142,21 +164,35 @@ class PopupListWidget extends StatefulWidget {
} }
class _PopupListWidgetState extends State<PopupListWidget> { class _PopupListWidgetState extends State<PopupListWidget> {
final focusNode = FocusNode(debugLabel: 'popup_list_widget'); final _focusNode = FocusNode(debugLabel: 'popup_list_widget');
var selectedIndex = 0; int _selectedIndex = 0;
List<PopupListItem> _items = [];
String __keyword = '';
String get _keyword => __keyword;
set _keyword(String keyword) {
__keyword = keyword;
setState(() {
_items = widget.items
.where((item) =>
item.keywords.any((keyword) => keyword.contains(_keyword)))
.toList(growable: false);
});
}
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_items = widget.items;
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
focusNode.requestFocus(); _focusNode.requestFocus();
}); });
} }
@override @override
void dispose() { void dispose() {
focusNode.dispose(); _focusNode.dispose();
super.dispose(); super.dispose();
} }
@ -164,7 +200,7 @@ class _PopupListWidgetState extends State<PopupListWidget> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Focus( return Focus(
focusNode: focusNode, focusNode: _focusNode,
onKey: _onKey, onKey: _onKey,
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
@ -178,14 +214,29 @@ class _PopupListWidgetState extends State<PopupListWidget> {
], ],
borderRadius: BorderRadius.circular(6.0), borderRadius: BorderRadius.circular(6.0),
), ),
child: Row( child: _items.isEmpty
? Align(
alignment: Alignment.centerLeft,
child: _buildNoResultsWidget(context),
)
: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: _buildColumns(widget.items, selectedIndex), children: _buildColumns(_items, _selectedIndex),
), ),
), ),
); );
} }
Widget _buildNoResultsWidget(BuildContext context) {
return const Padding(
padding: EdgeInsets.all(8.0),
child: Text(
'No results',
style: TextStyle(color: Colors.grey, fontSize: 15.0),
),
);
}
List<Widget> _buildColumns(List<PopupListItem> items, int selectedIndex) { List<Widget> _buildColumns(List<PopupListItem> items, int selectedIndex) {
List<Widget> columns = []; List<Widget> columns = [];
List<Widget> itemWidgets = []; List<Widget> itemWidgets = [];
@ -214,26 +265,52 @@ class _PopupListWidgetState extends State<PopupListWidget> {
} }
KeyEventResult _onKey(FocusNode node, RawKeyEvent event) { KeyEventResult _onKey(FocusNode node, RawKeyEvent event) {
debugPrint('slash on key $event');
if (event is! RawKeyDownEvent) { if (event is! RawKeyDownEvent) {
return KeyEventResult.ignored; return KeyEventResult.ignored;
} }
final arrowKeys = [
LogicalKeyboardKey.arrowLeft,
LogicalKeyboardKey.arrowRight,
LogicalKeyboardKey.arrowUp,
LogicalKeyboardKey.arrowDown
];
if (event.logicalKey == LogicalKeyboardKey.enter) { if (event.logicalKey == LogicalKeyboardKey.enter) {
if (0 <= selectedIndex && selectedIndex < widget.items.length) { if (0 <= _selectedIndex && _selectedIndex < _items.length) {
_deleteSlash(); _deleteLastCharacters(length: _keyword.length + 1);
widget.items[selectedIndex].handler(widget.editorState); _items[_selectedIndex].handler(widget.editorState);
return KeyEventResult.handled; return KeyEventResult.handled;
} }
} else if (event.logicalKey == LogicalKeyboardKey.escape) { } else if (event.logicalKey == LogicalKeyboardKey.escape) {
clearPopupList(); clearPopupList();
return KeyEventResult.handled; return KeyEventResult.handled;
} else if (event.logicalKey == LogicalKeyboardKey.backspace) { } else if (event.logicalKey == LogicalKeyboardKey.backspace) {
if (_keyword.isEmpty) {
clearPopupList(); clearPopupList();
_deleteSlash(); } 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!);
var maxKeywordLength = 0;
for (final item in _items) {
for (final keyword in item.keywords) {
maxKeywordLength = max(keyword.length, maxKeywordLength);
}
}
if (_keyword.length >= maxKeywordLength + 2) {
clearPopupList();
}
return KeyEventResult.handled; return KeyEventResult.handled;
} }
var newSelectedIndex = selectedIndex; var newSelectedIndex = _selectedIndex;
if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { if (event.logicalKey == LogicalKeyboardKey.arrowLeft) {
newSelectedIndex -= widget.maxItemInRow; newSelectedIndex -= widget.maxItemInRow;
} else if (event.logicalKey == LogicalKeyboardKey.arrowRight) { } else if (event.logicalKey == LogicalKeyboardKey.arrowRight) {
@ -243,26 +320,44 @@ class _PopupListWidgetState extends State<PopupListWidget> {
} else if (event.logicalKey == LogicalKeyboardKey.arrowDown) { } else if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
newSelectedIndex += 1; newSelectedIndex += 1;
} }
if (newSelectedIndex != selectedIndex) { if (newSelectedIndex != _selectedIndex) {
setState(() { setState(() {
selectedIndex = max(0, min(widget.items.length - 1, newSelectedIndex)); _selectedIndex = max(0, min(_items.length - 1, newSelectedIndex));
}); });
return KeyEventResult.handled; return KeyEventResult.handled;
} }
return KeyEventResult.ignored; return KeyEventResult.ignored;
} }
void _deleteSlash() { void _deleteLastCharacters({int length = 1}) {
final selection = final selection =
widget.editorState.service.selectionService.currentSelection.value; widget.editorState.service.selectionService.currentSelection.value;
final nodes = final nodes =
widget.editorState.service.selectionService.currentSelectedNodes; widget.editorState.service.selectionService.currentSelectedNodes;
if (selection != null && nodes.length == 1) { if (selection != null && nodes.length == 1) {
_selectionChangeBySlash = true;
TransactionBuilder(widget.editorState) TransactionBuilder(widget.editorState)
..deleteText( ..deleteText(
nodes.first as TextNode, nodes.first as TextNode,
selection.start.offset - 1, selection.start.offset - length,
1, 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(); ..commit();
} }
@ -318,12 +413,14 @@ class _PopupListItemWidget extends StatelessWidget {
class PopupListItem { class PopupListItem {
PopupListItem({ PopupListItem({
required this.text, required this.text,
required this.keywords,
this.message = '', this.message = '',
required this.icon, required this.icon,
required this.handler, required this.handler,
}); });
final String text; final String text;
final List<String> keywords;
final String message; final String message;
final Widget icon; final Widget icon;
final void Function(EditorState editorState) handler; final void Function(EditorState editorState) handler;