From 19838227d9292b7ec38b480a38da002c996cc8e4 Mon Sep 17 00:00:00 2001 From: "Lucas.Xu" Date: Thu, 11 Aug 2022 17:02:04 +0800 Subject: [PATCH] feat: #818 improve user experience of the slash command --- .../flowy_editor/lib/src/document/node.dart | 5 +- .../format_rich_text_style.dart | 55 +++++++ .../slash_handler.dart | 155 ++++++++++++++---- 3 files changed, 183 insertions(+), 32 deletions(-) diff --git a/frontend/app_flowy/packages/flowy_editor/lib/src/document/node.dart b/frontend/app_flowy/packages/flowy_editor/lib/src/document/node.dart index 0b6b941aaa..97571663b1 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/src/document/node.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/src/document/node.dart @@ -1,7 +1,6 @@ import 'dart:collection'; import 'package:flowy_editor/src/document/path.dart'; import 'package:flowy_editor/src/document/text_delta.dart'; -import 'package:flowy_editor/src/operation/operation.dart'; import 'package:flutter/material.dart'; import './attributes.dart'; @@ -182,12 +181,12 @@ class TextNode extends Node { }) : _delta = delta, super(children: children ?? LinkedList(), attributes: attributes ?? {}); - TextNode.empty() + TextNode.empty({Attributes? attributes}) : _delta = Delta([TextInsert('')]), super( type: 'text', children: LinkedList(), - attributes: {}, + attributes: attributes ?? {}, ); Delta get delta { diff --git a/frontend/app_flowy/packages/flowy_editor/lib/src/service/default_text_operations/format_rich_text_style.dart b/frontend/app_flowy/packages/flowy_editor/lib/src/service/default_text_operations/format_rich_text_style.dart index 3dcc519274..6830dd62e4 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/src/service/default_text_operations/format_rich_text_style.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/src/service/default_text_operations/format_rich_text_style.dart @@ -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/editor_state.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/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) { formatTextNodes(editorState, {}); } diff --git a/frontend/app_flowy/packages/flowy_editor/lib/src/service/internal_key_event_handlers/slash_handler.dart b/frontend/app_flowy/packages/flowy_editor/lib/src/service/internal_key_event_handlers/slash_handler.dart index 6a265808fe..fd0df50fb8 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/src/service/internal_key_event_handlers/slash_handler.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/src/service/internal_key_event_handlers/slash_handler.dart @@ -14,43 +14,56 @@ import 'package:flutter/services.dart'; final List _popupListItems = [ PopupListItem( text: 'Text', + keywords: ['text'], icon: _popupListIcon('text'), - handler: (editorState) => formatText(editorState), + handler: (editorState) { + insertTextNodeAfterSelection(editorState, {}); + }, ), PopupListItem( text: 'Heading 1', + keywords: ['h1', 'heading 1'], icon: _popupListIcon('h1'), - handler: (editorState) => formatHeading(editorState, StyleKey.h1), + handler: (editorState) => + insertHeadingAfterSelection(editorState, StyleKey.h1), ), PopupListItem( text: 'Heading 2', + keywords: ['h2', 'heading 2'], icon: _popupListIcon('h2'), - handler: (editorState) => formatHeading(editorState, StyleKey.h2), + handler: (editorState) => + insertHeadingAfterSelection(editorState, StyleKey.h2), ), PopupListItem( text: 'Heading 3', + keywords: ['h3', 'heading 3'], icon: _popupListIcon('h3'), - handler: (editorState) => formatHeading(editorState, StyleKey.h3), + handler: (editorState) => + insertHeadingAfterSelection(editorState, StyleKey.h3), ), PopupListItem( - text: 'Bullets', + text: 'Bulleted List', + keywords: ['bulleted list'], icon: _popupListIcon('bullets'), - handler: (editorState) => formatBulletedList(editorState), + handler: (editorState) => insertBulletedListAfterSelection(editorState), ), PopupListItem( text: 'Numbered list', + keywords: ['numbered list'], icon: _popupListIcon('number'), handler: (editorState) => debugPrint('Not implement yet!'), ), PopupListItem( text: 'Checkboxes', + keywords: ['checkbox'], icon: _popupListIcon('checkbox'), - handler: (editorState) => formatCheckbox(editorState), + handler: (editorState) => insertCheckboxAfterSelection(editorState), ), ]; OverlayEntry? _popupListOverlay; EditorState? _editorState; +bool _selectionChangeBySlash = false; FlowyKeyEventHandler slashShortcutHandler = (editorState, event) { if (event.logicalKey != LogicalKeyboardKey.slash) { return KeyEventResult.ignored; @@ -78,7 +91,7 @@ FlowyKeyEventHandler slashShortcutHandler = (editorState, event) { TransactionBuilder(editorState) ..replaceText(textNode, selection.start.offset, - selection.end.offset - selection.start.offset, '/') + selection.end.offset - selection.start.offset, event.character ?? '') ..commit(); _editorState = editorState; @@ -94,7 +107,7 @@ void showPopupList( _popupListOverlay?.remove(); _popupListOverlay = OverlayEntry( builder: (context) => Positioned( - top: offset.dy + 15.0, + top: offset.dy + 20.0, left: offset.dx + 5.0, child: PopupListWidget( editorState: editorState, @@ -117,6 +130,15 @@ void clearPopupList() { if (_popupListOverlay == null || _editorState == null) { return; } + final selection = + _editorState?.service.selectionService.currentSelection.value; + if (selection == null) { + return; + } + if (_selectionChangeBySlash) { + _selectionChangeBySlash = false; + return; + } _popupListOverlay?.remove(); _popupListOverlay = null; @@ -142,21 +164,35 @@ class PopupListWidget extends StatefulWidget { } class _PopupListWidgetState extends State { - final focusNode = FocusNode(debugLabel: 'popup_list_widget'); - var selectedIndex = 0; + final _focusNode = FocusNode(debugLabel: 'popup_list_widget'); + int _selectedIndex = 0; + List _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 void initState() { super.initState(); + _items = widget.items; + WidgetsBinding.instance.addPostFrameCallback((_) { - focusNode.requestFocus(); + _focusNode.requestFocus(); }); } @override void dispose() { - focusNode.dispose(); + _focusNode.dispose(); super.dispose(); } @@ -164,7 +200,7 @@ class _PopupListWidgetState extends State { @override Widget build(BuildContext context) { return Focus( - focusNode: focusNode, + focusNode: _focusNode, onKey: _onKey, child: Container( decoration: BoxDecoration( @@ -178,10 +214,25 @@ class _PopupListWidgetState extends State { ], borderRadius: BorderRadius.circular(6.0), ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: _buildColumns(widget.items, selectedIndex), - ), + child: _items.isEmpty + ? Align( + alignment: Alignment.centerLeft, + child: _buildNoResultsWidget(context), + ) + : Row( + crossAxisAlignment: CrossAxisAlignment.start, + 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), ), ); } @@ -214,26 +265,52 @@ class _PopupListWidgetState extends State { } KeyEventResult _onKey(FocusNode node, RawKeyEvent event) { + debugPrint('slash 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 < widget.items.length) { - _deleteSlash(); - widget.items[selectedIndex].handler(widget.editorState); + 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) { - clearPopupList(); - _deleteSlash(); + 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!); + 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; } - var newSelectedIndex = selectedIndex; + var newSelectedIndex = _selectedIndex; if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { newSelectedIndex -= widget.maxItemInRow; } else if (event.logicalKey == LogicalKeyboardKey.arrowRight) { @@ -243,26 +320,44 @@ class _PopupListWidgetState extends State { } else if (event.logicalKey == LogicalKeyboardKey.arrowDown) { newSelectedIndex += 1; } - if (newSelectedIndex != selectedIndex) { + if (newSelectedIndex != _selectedIndex) { setState(() { - selectedIndex = max(0, min(widget.items.length - 1, newSelectedIndex)); + _selectedIndex = max(0, min(_items.length - 1, newSelectedIndex)); }); return KeyEventResult.handled; } return KeyEventResult.ignored; } - void _deleteSlash() { + 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 - 1, - 1, + 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(); } @@ -318,12 +413,14 @@ class _PopupListItemWidget extends StatelessWidget { class PopupListItem { PopupListItem({ required this.text, + required this.keywords, this.message = '', required this.icon, required this.handler, }); final String text; + final List keywords; final String message; final Widget icon; final void Function(EditorState editorState) handler;