Merge pull request #813 from LucasXu0/fix/#811

#811, #814, #818
This commit is contained in:
Lucas.Xu 2022-08-11 19:08:28 +08:00 committed by GitHub
commit 42fe2f675a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 247 additions and 89 deletions

View File

@ -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<MyHomePage> {
@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(),
);
}

View File

@ -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 {

View File

@ -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<FlowyRichText> 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<FlowyRichText> 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<FlowyRichText> 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<FlowyRichText> 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<FlowyRichText> 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<FlowyRichText> 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()
]);
}

View File

@ -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) {

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/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, {});
}

View File

@ -14,43 +14,56 @@ import 'package:flutter/services.dart';
final List<PopupListItem> _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<PopupListWidget> {
final focusNode = FocusNode(debugLabel: 'popup_list_widget');
var selectedIndex = 0;
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();
_focusNode.requestFocus();
});
}
@override
void dispose() {
focusNode.dispose();
_focusNode.dispose();
super.dispose();
}
@ -164,7 +218,7 @@ class _PopupListWidgetState extends State<PopupListWidget> {
@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<PopupListWidget> {
],
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<PopupListWidget> {
}
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<PopupListWidget> {
} 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<String> keywords;
final String message;
final Widget icon;
final void Function(EditorState editorState) handler;

View File

@ -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;
}
}