diff --git a/frontend/app_flowy/packages/flowy_editor/example/lib/main.dart b/frontend/app_flowy/packages/flowy_editor/example/lib/main.dart index 5db7288c76..d33a010b55 100644 --- a/frontend/app_flowy/packages/flowy_editor/example/lib/main.dart +++ b/frontend/app_flowy/packages/flowy_editor/example/lib/main.dart @@ -21,7 +21,6 @@ class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( - title: 'Flutter Demo', theme: ThemeData( // This is the theme of your application. // @@ -64,12 +63,10 @@ class _MyHomePageState extends State { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar( - // Here we take the value from the MyHomePage object that was created by - // the App.build method, and use it to set our appbar title. - title: Text(widget.title), + body: Container( + alignment: Alignment.topCenter, + child: _buildBody(), ), - body: _buildBody(), floatingActionButton: _buildExpandableFab(), ); } 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 8ed5d10297..ae516478ae 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/render/rich_text/flowy_rich_text.dart b/frontend/app_flowy/packages/flowy_editor/lib/src/render/rich_text/flowy_rich_text.dart index 66b9efdef5..e53222cf5b 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/src/render/rich_text/flowy_rich_text.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/src/render/rich_text/flowy_rich_text.dart @@ -1,3 +1,5 @@ +import 'dart:ui'; + import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; @@ -17,7 +19,7 @@ class FlowyRichText extends StatefulWidget { const FlowyRichText({ Key? key, this.cursorHeight, - this.cursorWidth = 2.0, + this.cursorWidth = 1.0, this.textSpanDecorator, this.placeholderText = ' ', this.placeholderTextSpanDecorator, @@ -41,7 +43,7 @@ class _FlowyRichTextState extends State with Selectable { final _textKey = GlobalKey(); final _placeholderTextKey = GlobalKey(); - final lineHeight = 1.5; + final _lineHeight = 1.5; RenderParagraph get _renderParagraph => _textKey.currentContext?.findRenderObject() as RenderParagraph; @@ -69,13 +71,15 @@ class _FlowyRichTextState extends State with Selectable { final cursorHeight = widget.cursorHeight ?? _renderParagraph.getFullHeightForCaret(textPosition) ?? _placeholderRenderParagraph.getFullHeightForCaret(textPosition) ?? - 18.0; // default height - return Rect.fromLTWH( + 16.0; // default height + + final rect = Rect.fromLTWH( cursorOffset.dx - (widget.cursorWidth / 2), cursorOffset.dy, widget.cursorWidth, cursorHeight, ); + return rect; } @override @@ -105,7 +109,7 @@ class _FlowyRichTextState extends State with Selectable { extentOffset: selection.end.offset, ); return _renderParagraph - .getBoxesForSelection(textSelection) + .getBoxesForSelection(textSelection, boxHeightStyle: BoxHeightStyle.max) .map((box) => box.toRect()) .toList(); } @@ -138,24 +142,13 @@ class _FlowyRichTextState extends State with Selectable { } Widget _buildPlaceholderText(BuildContext context) { - final textSpan = TextSpan( - children: [ - TextSpan( - text: widget.placeholderText, - style: TextStyle( - color: widget.textNode.toRawString().isNotEmpty - ? Colors.transparent - : Colors.grey, - fontSize: baseFontSize, - height: lineHeight, - ), - ), - ], - ); + final textSpan = _placeholderTextSpan; return RichText( key: _placeholderTextKey, - text: widget.placeholderTextSpanDecorator != null - ? widget.placeholderTextSpanDecorator!(textSpan) + textHeightBehavior: const TextHeightBehavior( + applyHeightToFirstAscent: false, applyHeightToLastDescent: false), + text: widget.textSpanDecorator != null + ? widget.textSpanDecorator!(textSpan) : textSpan, ); } @@ -164,6 +157,8 @@ class _FlowyRichTextState extends State with Selectable { final textSpan = _textSpan; return RichText( key: _textKey, + textHeightBehavior: const TextHeightBehavior( + applyHeightToFirstAscent: false, applyHeightToLastDescent: false), text: widget.textSpanDecorator != null ? widget.textSpanDecorator!(textSpan) : textSpan, @@ -203,8 +198,18 @@ class _FlowyRichTextState extends State with Selectable { .map((insert) => RichTextStyle( attributes: insert.attributes ?? {}, text: insert.content, - height: lineHeight, + height: _lineHeight, ).toTextSpan()) .toList(growable: false), ); + + TextSpan get _placeholderTextSpan => TextSpan(children: [ + RichTextStyle( + text: widget.placeholderText, + attributes: { + StyleKey.color: '0xFF707070', + }, + height: _lineHeight, + ).toTextSpan() + ]); } diff --git a/frontend/app_flowy/packages/flowy_editor/lib/src/render/rich_text/rich_text_style.dart b/frontend/app_flowy/packages/flowy_editor/lib/src/render/rich_text/rich_text_style.dart index 7b5eef70ea..31cbafc18b 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/src/render/rich_text/rich_text_style.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/src/render/rich_text/rich_text_style.dart @@ -192,17 +192,7 @@ class RichTextStyle { TextSpan toTextSpan() => _toTextSpan(height); double get topPadding { - if (height == 1.0) { - return 0; - } - // TODO: Need to be optimized. - final painter = - TextPainter(text: _toTextSpan(height), textDirection: TextDirection.ltr) - ..layout(); - final basePainter = - TextPainter(text: _toTextSpan(null), textDirection: TextDirection.ltr) - ..layout(); - return painter.height - basePainter.height; + return 0; } TextSpan _toTextSpan(double? height) { 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..83f1b9e13a 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: 'Numbered list', - icon: _popupListIcon('number'), - handler: (editorState) => debugPrint('Not implement yet!'), - ), - PopupListItem( - text: 'Checkboxes', + text: 'To-do List', + keywords: ['checkbox', 'todo'], 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; @@ -69,21 +82,19 @@ FlowyKeyEventHandler slashShortcutHandler = (editorState, event) { if (selection == null || context == null || selectable == null) { return KeyEventResult.ignored; } - - final rect = selectable.getCursorRectInPosition(selection.start); - if (rect == null) { + final selectionRects = editorState.service.selectionService.selectionRects; + if (selectionRects.isEmpty) { return KeyEventResult.ignored; } - final offset = selectable.localToGlobal(rect.topLeft); - TransactionBuilder(editorState) ..replaceText(textNode, selection.start.offset, - selection.end.offset - selection.start.offset, '/') + selection.end.offset - selection.start.offset, event.character ?? '') ..commit(); _editorState = editorState; WidgetsBinding.instance.addPostFrameCallback((_) { - showPopupList(context, editorState, offset); + _selectionChangeBySlash = false; + showPopupList(context, editorState, selectionRects.first.bottomRight); }); return KeyEventResult.handled; @@ -94,8 +105,8 @@ void showPopupList( _popupListOverlay?.remove(); _popupListOverlay = OverlayEntry( builder: (context) => Positioned( - top: offset.dy + 15.0, - left: offset.dx + 5.0, + top: offset.dy, + left: offset.dx, child: PopupListWidget( editorState: editorState, items: _popupListItems, @@ -117,6 +128,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 +162,55 @@ 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 = []; + + 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(); + _focusNode.requestFocus(); }); } @override void dispose() { - focusNode.dispose(); + _focusNode.dispose(); super.dispose(); } @@ -164,7 +218,7 @@ class _PopupListWidgetState extends State { @override Widget build(BuildContext context) { return Focus( - focusNode: focusNode, + focusNode: _focusNode, onKey: _onKey, child: Container( decoration: BoxDecoration( @@ -178,9 +232,26 @@ class _PopupListWidgetState extends State { ], borderRadius: BorderRadius.circular(6.0), ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: _buildColumns(widget.items, selectedIndex), + 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), + ), ), ), ); @@ -214,26 +285,43 @@ 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!); 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 +331,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 +424,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; diff --git a/frontend/app_flowy/packages/flowy_editor/lib/src/service/internal_key_event_handlers/whitespace_handler.dart b/frontend/app_flowy/packages/flowy_editor/lib/src/service/internal_key_event_handlers/whitespace_handler.dart index 41574e6aaa..0deb3d44d2 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/src/service/internal_key_event_handlers/whitespace_handler.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/src/service/internal_key_event_handlers/whitespace_handler.dart @@ -39,8 +39,8 @@ FlowyKeyEventHandler whiteSpaceHandler = (editorState, event) { return _toCheckboxList(editorState, textNode); } else if (_bulletedListSymbols.any(text.startsWith)) { return _toBulletedList(editorState, textNode); - } else if (_countOfSign(text) != 0) { - return _toHeadingStyle(editorState, textNode); + } else if (_countOfSign(text, selection) != 0) { + return _toHeadingStyle(editorState, textNode, selection); } return KeyEventResult.ignored; @@ -99,8 +99,12 @@ KeyEventResult _toCheckboxList(EditorState editorState, TextNode textNode) { return KeyEventResult.handled; } -KeyEventResult _toHeadingStyle(EditorState editorState, TextNode textNode) { - final x = _countOfSign(textNode.toRawString()); +KeyEventResult _toHeadingStyle( + EditorState editorState, TextNode textNode, Selection selection) { + final x = _countOfSign( + textNode.toRawString(), + selection, + ); final hX = 'h$x'; if (textNode.attributes.heading == hX) { return KeyEventResult.ignored; @@ -121,9 +125,9 @@ KeyEventResult _toHeadingStyle(EditorState editorState, TextNode textNode) { return KeyEventResult.handled; } -int _countOfSign(String text) { +int _countOfSign(String text, Selection selection) { for (var i = 6; i >= 0; i--) { - if (text.startsWith('#' * i)) { + if (text.substring(0, selection.end.offset).startsWith('#' * i)) { return i; } }