feat: implement rich text component in flowy_ediotr and support markdown style rendering.

This commit is contained in:
Lucas.Xu 2022-07-27 20:24:26 +08:00
parent 445ff561b5
commit 45a8566e61
18 changed files with 853 additions and 455 deletions

View File

@ -37,7 +37,22 @@
"type": "text",
"delta": [{ "insert": "Click anywhere and just start typing." }],
"attributes": {
"checkbox": true
"list": "todo",
"todo": false
}
},
{
"type": "text",
"delta": [{ "insert": "Click anywhere and just start typing." }],
"attributes": {
"list": "bullet"
}
},
{
"type": "text",
"delta": [{ "insert": "Click anywhere and just start typing." }],
"attributes": {
"list": "bullet"
}
},
{
@ -77,7 +92,9 @@
"insert": "1. Click the '?' at the bottom right for help and support."
}
],
"attributes": {}
"attributes": {
"quotes": true
}
},
{
"type": "text",

View File

@ -5,7 +5,7 @@ import 'package:example/plugin/document_node_widget.dart';
import 'package:example/plugin/selected_text_node_widget.dart';
import 'package:example/plugin/text_with_heading_node_widget.dart';
import 'package:example/plugin/image_node_widget.dart';
import 'package:example/plugin/text_node_widget.dart';
import 'package:example/plugin/old_text_node_widget.dart';
import 'package:example/plugin/text_with_check_box_node_widget.dart';
import 'package:flutter/material.dart';
import 'package:flowy_editor/flowy_editor.dart';
@ -68,7 +68,6 @@ class _MyHomePageState extends State<MyHomePage> {
renderPlugins
..register('editor', EditorNodeWidgetBuilder.create)
..register('text', SelectedTextNodeBuilder.create)
..register('image', ImageNodeBuilder.create)
..register('text/with-checkbox', TextWithCheckBoxNodeBuilder.create)
..register('text/with-heading', TextWithHeadingNodeBuilder.create);

View File

@ -9,7 +9,7 @@ class EditorNodeWidgetBuilder extends NodeWidgetBuilder {
}) : super.create();
@override
Widget build(BuildContext buildContext) {
Widget build(BuildContext context) {
return SingleChildScrollView(
key: key,
child: _EditorNodeWidget(

View File

@ -11,7 +11,7 @@ class ImageNodeBuilder extends NodeWidgetBuilder {
}) : super.create();
@override
Widget build(BuildContext buildContext) {
Widget build(BuildContext context) {
return _ImageNodeWidget(
key: key,
node: node,

View File

@ -0,0 +1,352 @@
// import 'package:flowy_editor/document/position.dart';
// import 'package:flowy_editor/document/selection.dart';
// import 'package:flutter/gestures.dart';
// import 'package:flutter/material.dart';
// import 'package:flowy_editor/flowy_editor.dart';
// import 'package:flutter/services.dart';
// import 'package:url_launcher/url_launcher_string.dart';
// import 'flowy_selectable_text.dart';
// class TextNodeBuilder extends NodeWidgetBuilder {
// TextNodeBuilder.create({
// required super.node,
// required super.editorState,
// required super.key,
// }) : super.create() {
// nodeValidator = ((node) {
// return node.type == 'text';
// });
// }
// @override
// Widget build(BuildContext context) {
// return _TextNodeWidget(key: key, node: node, editorState: editorState);
// }
// }
// class _TextNodeWidget extends StatefulWidget {
// final Node node;
// final EditorState editorState;
// const _TextNodeWidget({
// Key? key,
// required this.node,
// required this.editorState,
// }) : super(key: key);
// @override
// State<_TextNodeWidget> createState() => __TextNodeWidgetState();
// }
// class __TextNodeWidgetState extends State<_TextNodeWidget>
// implements DeltaTextInputClient {
// TextNode get node => widget.node as TextNode;
// EditorState get editorState => widget.editorState;
// bool _metaKeyDown = false;
// bool _shiftKeyDown = false;
// TextInputConnection? _textInputConnection;
// @override
// Widget build(BuildContext context) {
// return Column(
// crossAxisAlignment: CrossAxisAlignment.start,
// children: [
// FlowySelectableText.rich(
// node.toTextSpan(),
// showCursor: true,
// enableInteractiveSelection: true,
// onSelectionChanged: _onSelectionChanged,
// // autofocus: true,
// focusNode: FocusNode(
// onKey: _onKey,
// ),
// ),
// if (node.children.isNotEmpty)
// ...node.children.map(
// (e) => editorState.renderPlugins.buildWidget(
// context: NodeWidgetContext(
// buildContext: context,
// node: e,
// editorState: editorState,
// ),
// ),
// ),
// const SizedBox(
// height: 10,
// ),
// ],
// );
// }
// KeyEventResult _onKey(FocusNode focusNode, RawKeyEvent event) {
// debugPrint('key: $event');
// if (event is RawKeyDownEvent) {
// final sel = _globalSelectionToLocal(node, editorState.cursorSelection);
// if (event.logicalKey == LogicalKeyboardKey.backspace) {
// _backDeleteTextAtSelection(sel);
// return KeyEventResult.handled;
// } else if (event.logicalKey == LogicalKeyboardKey.delete) {
// _forwardDeleteTextAtSelection(sel);
// return KeyEventResult.handled;
// } else if (event.logicalKey == LogicalKeyboardKey.metaLeft ||
// event.logicalKey == LogicalKeyboardKey.metaRight) {
// _metaKeyDown = true;
// } else if (event.logicalKey == LogicalKeyboardKey.shiftLeft ||
// event.logicalKey == LogicalKeyboardKey.shiftRight) {
// _shiftKeyDown = true;
// } else if (event.logicalKey == LogicalKeyboardKey.keyZ && _metaKeyDown) {
// if (_shiftKeyDown) {
// editorState.undoManager.redo();
// } else {
// editorState.undoManager.undo();
// }
// }
// } else if (event is RawKeyUpEvent) {
// if (event.logicalKey == LogicalKeyboardKey.metaLeft ||
// event.logicalKey == LogicalKeyboardKey.metaRight) {
// _metaKeyDown = false;
// }
// if (event.logicalKey == LogicalKeyboardKey.shiftLeft ||
// event.logicalKey == LogicalKeyboardKey.shiftRight) {
// _shiftKeyDown = false;
// }
// }
// return KeyEventResult.ignored;
// }
// void _onSelectionChanged(
// TextSelection selection, SelectionChangedCause? cause) {
// _textInputConnection?.close();
// _textInputConnection = TextInput.attach(
// this,
// const TextInputConfiguration(
// enableDeltaModel: true,
// inputType: TextInputType.multiline,
// textCapitalization: TextCapitalization.sentences,
// ),
// );
// editorState.cursorSelection = _localSelectionToGlobal(node, selection);
// _textInputConnection
// ?..show()
// ..setEditingState(
// TextEditingValue(
// text: node.toRawString(),
// selection: selection,
// ),
// );
// }
// _backDeleteTextAtSelection(TextSelection? sel) {
// if (sel == null) {
// return;
// }
// if (sel.start == 0) {
// return;
// }
// if (sel.isCollapsed) {
// TransactionBuilder(editorState)
// ..deleteText(node, sel.start - 1, 1)
// ..commit();
// } else {
// TransactionBuilder(editorState)
// ..deleteText(node, sel.start, sel.extentOffset - sel.baseOffset)
// ..commit();
// }
// _setEditingStateFromGlobal();
// }
// _forwardDeleteTextAtSelection(TextSelection? sel) {
// if (sel == null) {
// return;
// }
// if (sel.isCollapsed) {
// TransactionBuilder(editorState)
// ..deleteText(node, sel.start, 1)
// ..commit();
// } else {
// TransactionBuilder(editorState)
// ..deleteText(node, sel.start, sel.extentOffset - sel.baseOffset)
// ..commit();
// }
// _setEditingStateFromGlobal();
// }
// _setEditingStateFromGlobal() {
// _textInputConnection?.setEditingState(TextEditingValue(
// text: node.toRawString(),
// selection: _globalSelectionToLocal(node, editorState.cursorSelection) ??
// const TextSelection.collapsed(offset: 0)));
// }
// @override
// void connectionClosed() {
// // TODO: implement connectionClosed
// }
// @override
// // TODO: implement currentAutofillScope
// AutofillScope? get currentAutofillScope => throw UnimplementedError();
// @override
// // TODO: implement currentTextEditingValue
// TextEditingValue? get currentTextEditingValue => TextEditingValue(
// text: node.toRawString(),
// selection: _globalSelectionToLocal(node, editorState.cursorSelection) ??
// const TextSelection.collapsed(offset: 0));
// @override
// void insertTextPlaceholder(Size size) {
// // TODO: implement insertTextPlaceholder
// }
// @override
// void performAction(TextInputAction action) {}
// @override
// void performPrivateCommand(String action, Map<String, dynamic> data) {
// // TODO: implement performPrivateCommand
// }
// @override
// void removeTextPlaceholder() {
// // TODO: implement removeTextPlaceholder
// }
// @override
// void showAutocorrectionPromptRect(int start, int end) {
// // TODO: implement showAutocorrectionPromptRect
// }
// @override
// void showToolbar() {
// // TODO: implement showToolbar
// }
// @override
// void updateEditingValue(TextEditingValue value) {}
// @override
// void updateEditingValueWithDeltas(List<TextEditingDelta> textEditingDeltas) {
// for (final textDelta in textEditingDeltas) {
// if (textDelta is TextEditingDeltaInsertion) {
// TransactionBuilder(editorState)
// ..insertText(node, textDelta.insertionOffset, textDelta.textInserted)
// ..commit();
// } else if (textDelta is TextEditingDeltaDeletion) {
// TransactionBuilder(editorState)
// ..deleteText(node, textDelta.deletedRange.start,
// textDelta.deletedRange.end - textDelta.deletedRange.start)
// ..commit();
// }
// }
// }
// @override
// void updateFloatingCursor(RawFloatingCursorPoint point) {
// // TODO: implement updateFloatingCursor
// }
// }
// extension on TextNode {
// TextSpan toTextSpan() => TextSpan(
// children: delta.operations
// .whereType<TextInsert>()
// .map((op) => op.toTextSpan())
// .toList());
// }
// extension on TextInsert {
// TextSpan toTextSpan() {
// FontWeight? fontWeight;
// FontStyle? fontStyle;
// TextDecoration? decoration;
// GestureRecognizer? gestureRecognizer;
// Color? color;
// Color highLightColor = Colors.transparent;
// double fontSize = 16.0;
// final attributes = this.attributes;
// if (attributes?['bold'] == true) {
// fontWeight = FontWeight.bold;
// }
// if (attributes?['italic'] == true) {
// fontStyle = FontStyle.italic;
// }
// if (attributes?['underline'] == true) {
// decoration = TextDecoration.underline;
// }
// if (attributes?['strikethrough'] == true) {
// decoration = TextDecoration.lineThrough;
// }
// if (attributes?['highlight'] is String) {
// highLightColor = Color(int.parse(attributes!['highlight']));
// }
// if (attributes?['href'] is String) {
// color = const Color.fromARGB(255, 55, 120, 245);
// decoration = TextDecoration.underline;
// gestureRecognizer = TapGestureRecognizer()
// ..onTap = () {
// launchUrlString(attributes?['href']);
// };
// }
// final heading = attributes?['heading'] as String?;
// if (heading != null) {
// // TODO: make it better
// if (heading == 'h1') {
// fontSize = 30.0;
// } else if (heading == 'h2') {
// fontSize = 20.0;
// }
// fontWeight = FontWeight.bold;
// }
// return TextSpan(
// text: content,
// style: TextStyle(
// fontWeight: fontWeight,
// fontStyle: fontStyle,
// decoration: decoration,
// color: color,
// fontSize: fontSize,
// backgroundColor: highLightColor,
// ),
// recognizer: gestureRecognizer,
// );
// }
// }
// TextSelection? _globalSelectionToLocal(Node node, Selection? globalSel) {
// if (globalSel == null) {
// return null;
// }
// final nodePath = node.path;
// if (!pathEquals(nodePath, globalSel.start.path)) {
// return null;
// }
// if (globalSel.isCollapsed) {
// return TextSelection(
// baseOffset: globalSel.start.offset, extentOffset: globalSel.end.offset);
// } else {
// if (pathEquals(globalSel.start.path, globalSel.end.path)) {
// return TextSelection(
// baseOffset: globalSel.start.offset,
// extentOffset: globalSel.end.offset);
// }
// }
// return null;
// }
// Selection? _localSelectionToGlobal(Node node, TextSelection? sel) {
// if (sel == null) {
// return null;
// }
// final nodePath = node.path;
// return Selection(
// start: Position(path: nodePath, offset: sel.baseOffset),
// end: Position(path: nodePath, offset: sel.extentOffset),
// );
// }

View File

@ -22,7 +22,7 @@ class SelectedTextNodeBuilder extends NodeWidgetBuilder {
}
@override
Widget build(BuildContext buildContext) {
Widget build(BuildContext context) {
return _SelectedTextNodeWidget(
key: key,
node: node,
@ -96,14 +96,15 @@ class _SelectedTextNodeWidgetState extends State<_SelectedTextNodeWidget>
}
@override
TextSelection? getCurrentTextSelection() {
return _textSelection;
}
@override
Offset getOffsetByTextSelection(TextSelection textSelection) {
final offset = _computeCursorRect(textSelection.baseOffset).center;
return _renderParagraph.localToGlobal(offset);
TextSelection? getTextSelectionInSelection(Selection selection) {
assert(selection.isCollapsed);
if (!selection.isCollapsed) {
return null;
}
return TextSelection(
baseOffset: selection.start.offset,
extentOffset: selection.end.offset,
);
}
@override

View File

@ -1,352 +0,0 @@
import 'package:flowy_editor/document/position.dart';
import 'package:flowy_editor/document/selection.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flowy_editor/flowy_editor.dart';
import 'package:flutter/services.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'flowy_selectable_text.dart';
class TextNodeBuilder extends NodeWidgetBuilder {
TextNodeBuilder.create({
required super.node,
required super.editorState,
required super.key,
}) : super.create() {
nodeValidator = ((node) {
return node.type == 'text';
});
}
@override
Widget build(BuildContext buildContext) {
return _TextNodeWidget(key: key, node: node, editorState: editorState);
}
}
class _TextNodeWidget extends StatefulWidget {
final Node node;
final EditorState editorState;
const _TextNodeWidget({
Key? key,
required this.node,
required this.editorState,
}) : super(key: key);
@override
State<_TextNodeWidget> createState() => __TextNodeWidgetState();
}
class __TextNodeWidgetState extends State<_TextNodeWidget>
implements DeltaTextInputClient {
TextNode get node => widget.node as TextNode;
EditorState get editorState => widget.editorState;
bool _metaKeyDown = false;
bool _shiftKeyDown = false;
TextInputConnection? _textInputConnection;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
FlowySelectableText.rich(
node.toTextSpan(),
showCursor: true,
enableInteractiveSelection: true,
onSelectionChanged: _onSelectionChanged,
// autofocus: true,
focusNode: FocusNode(
onKey: _onKey,
),
),
if (node.children.isNotEmpty)
...node.children.map(
(e) => editorState.renderPlugins.buildWidget(
context: NodeWidgetContext(
buildContext: context,
node: e,
editorState: editorState,
),
),
),
const SizedBox(
height: 10,
),
],
);
}
KeyEventResult _onKey(FocusNode focusNode, RawKeyEvent event) {
debugPrint('key: $event');
if (event is RawKeyDownEvent) {
final sel = _globalSelectionToLocal(node, editorState.cursorSelection);
if (event.logicalKey == LogicalKeyboardKey.backspace) {
_backDeleteTextAtSelection(sel);
return KeyEventResult.handled;
} else if (event.logicalKey == LogicalKeyboardKey.delete) {
_forwardDeleteTextAtSelection(sel);
return KeyEventResult.handled;
} else if (event.logicalKey == LogicalKeyboardKey.metaLeft ||
event.logicalKey == LogicalKeyboardKey.metaRight) {
_metaKeyDown = true;
} else if (event.logicalKey == LogicalKeyboardKey.shiftLeft ||
event.logicalKey == LogicalKeyboardKey.shiftRight) {
_shiftKeyDown = true;
} else if (event.logicalKey == LogicalKeyboardKey.keyZ && _metaKeyDown) {
if (_shiftKeyDown) {
editorState.undoManager.redo();
} else {
editorState.undoManager.undo();
}
}
} else if (event is RawKeyUpEvent) {
if (event.logicalKey == LogicalKeyboardKey.metaLeft ||
event.logicalKey == LogicalKeyboardKey.metaRight) {
_metaKeyDown = false;
}
if (event.logicalKey == LogicalKeyboardKey.shiftLeft ||
event.logicalKey == LogicalKeyboardKey.shiftRight) {
_shiftKeyDown = false;
}
}
return KeyEventResult.ignored;
}
void _onSelectionChanged(
TextSelection selection, SelectionChangedCause? cause) {
_textInputConnection?.close();
_textInputConnection = TextInput.attach(
this,
const TextInputConfiguration(
enableDeltaModel: true,
inputType: TextInputType.multiline,
textCapitalization: TextCapitalization.sentences,
),
);
editorState.cursorSelection = _localSelectionToGlobal(node, selection);
_textInputConnection
?..show()
..setEditingState(
TextEditingValue(
text: node.toRawString(),
selection: selection,
),
);
}
_backDeleteTextAtSelection(TextSelection? sel) {
if (sel == null) {
return;
}
if (sel.start == 0) {
return;
}
if (sel.isCollapsed) {
TransactionBuilder(editorState)
..deleteText(node, sel.start - 1, 1)
..commit();
} else {
TransactionBuilder(editorState)
..deleteText(node, sel.start, sel.extentOffset - sel.baseOffset)
..commit();
}
_setEditingStateFromGlobal();
}
_forwardDeleteTextAtSelection(TextSelection? sel) {
if (sel == null) {
return;
}
if (sel.isCollapsed) {
TransactionBuilder(editorState)
..deleteText(node, sel.start, 1)
..commit();
} else {
TransactionBuilder(editorState)
..deleteText(node, sel.start, sel.extentOffset - sel.baseOffset)
..commit();
}
_setEditingStateFromGlobal();
}
_setEditingStateFromGlobal() {
_textInputConnection?.setEditingState(TextEditingValue(
text: node.toRawString(),
selection: _globalSelectionToLocal(node, editorState.cursorSelection) ??
const TextSelection.collapsed(offset: 0)));
}
@override
void connectionClosed() {
// TODO: implement connectionClosed
}
@override
// TODO: implement currentAutofillScope
AutofillScope? get currentAutofillScope => throw UnimplementedError();
@override
// TODO: implement currentTextEditingValue
TextEditingValue? get currentTextEditingValue => TextEditingValue(
text: node.toRawString(),
selection: _globalSelectionToLocal(node, editorState.cursorSelection) ??
const TextSelection.collapsed(offset: 0));
@override
void insertTextPlaceholder(Size size) {
// TODO: implement insertTextPlaceholder
}
@override
void performAction(TextInputAction action) {}
@override
void performPrivateCommand(String action, Map<String, dynamic> data) {
// TODO: implement performPrivateCommand
}
@override
void removeTextPlaceholder() {
// TODO: implement removeTextPlaceholder
}
@override
void showAutocorrectionPromptRect(int start, int end) {
// TODO: implement showAutocorrectionPromptRect
}
@override
void showToolbar() {
// TODO: implement showToolbar
}
@override
void updateEditingValue(TextEditingValue value) {}
@override
void updateEditingValueWithDeltas(List<TextEditingDelta> textEditingDeltas) {
for (final textDelta in textEditingDeltas) {
if (textDelta is TextEditingDeltaInsertion) {
TransactionBuilder(editorState)
..insertText(node, textDelta.insertionOffset, textDelta.textInserted)
..commit();
} else if (textDelta is TextEditingDeltaDeletion) {
TransactionBuilder(editorState)
..deleteText(node, textDelta.deletedRange.start,
textDelta.deletedRange.end - textDelta.deletedRange.start)
..commit();
}
}
}
@override
void updateFloatingCursor(RawFloatingCursorPoint point) {
// TODO: implement updateFloatingCursor
}
}
extension on TextNode {
TextSpan toTextSpan() => TextSpan(
children: delta.operations
.whereType<TextInsert>()
.map((op) => op.toTextSpan())
.toList());
}
extension on TextInsert {
TextSpan toTextSpan() {
FontWeight? fontWeight;
FontStyle? fontStyle;
TextDecoration? decoration;
GestureRecognizer? gestureRecognizer;
Color? color;
Color highLightColor = Colors.transparent;
double fontSize = 16.0;
final attributes = this.attributes;
if (attributes?['bold'] == true) {
fontWeight = FontWeight.bold;
}
if (attributes?['italic'] == true) {
fontStyle = FontStyle.italic;
}
if (attributes?['underline'] == true) {
decoration = TextDecoration.underline;
}
if (attributes?['strikethrough'] == true) {
decoration = TextDecoration.lineThrough;
}
if (attributes?['highlight'] is String) {
highLightColor = Color(int.parse(attributes!['highlight']));
}
if (attributes?['href'] is String) {
color = const Color.fromARGB(255, 55, 120, 245);
decoration = TextDecoration.underline;
gestureRecognizer = TapGestureRecognizer()
..onTap = () {
launchUrlString(attributes?['href']);
};
}
final heading = attributes?['heading'] as String?;
if (heading != null) {
// TODO: make it better
if (heading == 'h1') {
fontSize = 30.0;
} else if (heading == 'h2') {
fontSize = 20.0;
}
fontWeight = FontWeight.bold;
}
return TextSpan(
text: content,
style: TextStyle(
fontWeight: fontWeight,
fontStyle: fontStyle,
decoration: decoration,
color: color,
fontSize: fontSize,
backgroundColor: highLightColor,
),
recognizer: gestureRecognizer,
);
}
}
TextSelection? _globalSelectionToLocal(Node node, Selection? globalSel) {
if (globalSel == null) {
return null;
}
final nodePath = node.path;
if (!pathEquals(nodePath, globalSel.start.path)) {
return null;
}
if (globalSel.isCollapsed) {
return TextSelection(
baseOffset: globalSel.start.offset, extentOffset: globalSel.end.offset);
} else {
if (pathEquals(globalSel.start.path, globalSel.end.path)) {
return TextSelection(
baseOffset: globalSel.start.offset,
extentOffset: globalSel.end.offset);
}
}
return null;
}
Selection? _localSelectionToGlobal(Node node, TextSelection? sel) {
if (sel == null) {
return null;
}
final nodePath = node.path;
return Selection(
start: Position(path: nodePath, offset: sel.baseOffset),
end: Position(path: nodePath, offset: sel.extentOffset),
);
}

View File

@ -12,7 +12,7 @@ class TextWithCheckBoxNodeBuilder extends NodeWidgetBuilder {
bool get isCompleted => node.attributes['checkbox'] as bool;
@override
Widget build(BuildContext buildContext) {
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@ -20,7 +20,7 @@ class TextWithCheckBoxNodeBuilder extends NodeWidgetBuilder {
Expanded(
child: renderPlugins.buildWidget(
context: NodeWidgetContext(
buildContext: buildContext,
buildContext: context,
node: node,
editorState: editorState,
),

View File

@ -27,13 +27,13 @@ class TextWithHeadingNodeBuilder extends NodeWidgetBuilder {
}
@override
Widget build(BuildContext buildContext) {
Widget build(BuildContext context) {
return Column(
children: [
buildPadding(),
renderPlugins.buildWidget(
context: NodeWidgetContext(
buildContext: buildContext,
buildContext: context,
node: node,
editorState: editorState,
),

View File

@ -1,4 +1,5 @@
import 'dart:async';
import 'package:flowy_editor/render/rich_text/flowy_rich_text.dart';
import 'package:flowy_editor/service/service.dart';
import 'package:flutter/material.dart';
@ -25,6 +26,7 @@ class ApplyOptions {
class EditorState {
final StateTree document;
final RenderPlugins renderPlugins;
List<Node> selectedNodes = [];
// Service reference.
@ -39,6 +41,8 @@ class EditorState {
required this.document,
required this.renderPlugins,
}) {
// FIXME: abstract render plugins as a service.
renderPlugins.register('text', RichTextNodeWidgetBuilder.create);
undoManager.state = this;
}

View File

@ -12,3 +12,5 @@ export 'package:flowy_editor/operation/transaction_builder.dart';
export 'package:flowy_editor/operation/operation.dart';
export 'package:flowy_editor/editor_state.dart';
export 'package:flowy_editor/service/editor_service.dart';
export 'package:flowy_editor/document/selection.dart';
export 'package:flowy_editor/document/position.dart';

View File

@ -26,14 +26,14 @@ class NodeWidgetBuilder<T extends Node> {
/// Render the current [Node]
/// and the layout style of [Node.Children].
Widget build(
BuildContext buildContext,
BuildContext context,
) =>
throw UnimplementedError();
/// TODO: refactore this part.
/// return widget embedded with ChangeNotifier and widget itself.
Widget call(
BuildContext buildContext,
BuildContext context,
) {
/// TODO: Validate the node
/// if failed, stop call build function,
@ -43,10 +43,10 @@ class NodeWidgetBuilder<T extends Node> {
'Node validate failure, node = { type: ${node.type}, attributes: ${node.attributes} }');
}
return _build(buildContext);
return _build(context);
}
Widget _build(BuildContext buildContext) {
Widget _build(BuildContext context) {
return CompositedTransformTarget(
link: node.layerLink,
child: ChangeNotifierProvider.value(

View File

@ -0,0 +1,215 @@
import 'package:flowy_editor/render/rich_text/rich_text_style.dart';
import 'package:flowy_editor/flowy_editor.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
class RichTextNodeWidgetBuilder extends NodeWidgetBuilder {
RichTextNodeWidgetBuilder.create({
required super.editorState,
required super.node,
required super.key,
}) : super.create();
@override
Widget build(BuildContext context) {
return FlowyRichText(
key: key,
textNode: node as TextNode,
editorState: editorState,
);
}
}
class FlowyRichText extends StatefulWidget {
const FlowyRichText({
Key? key,
this.cursorHeight,
this.cursorWidth = 2.0,
required this.textNode,
required this.editorState,
}) : super(key: key);
final double? cursorHeight;
final double cursorWidth;
final TextNode textNode;
final EditorState editorState;
@override
State<FlowyRichText> createState() => _FlowyRichTextState();
}
class _FlowyRichTextState extends State<FlowyRichText> with Selectable {
final _textKey = GlobalKey();
final _decorationKey = GlobalKey();
EditorState get _editorState => widget.editorState;
TextNode get _textNode => widget.textNode;
RenderParagraph get _renderParagraph =>
_textKey.currentContext?.findRenderObject() as RenderParagraph;
@override
Widget build(BuildContext context) {
final attributes = _textNode.attributes;
// TODO: use factory method ??
if (attributes.list == 'todo') {
return _buildTodoListRichText(context);
} else if (attributes.list == 'bullet') {
return _buildBulletedListRichText(context);
} else if (attributes.quotes == true) {
return _buildQuotedRichText(context);
}
return _buildRichText(context);
}
@override
Position start() => Position(path: _textNode.path, offset: 0);
@override
Position end() =>
Position(path: _textNode.path, offset: _textNode.toRawString().length);
@override
Rect getCursorRectInPosition(Position position) {
final textPosition = TextPosition(offset: position.offset);
final baseRect = frontWidgetRect();
final cursorOffset =
_renderParagraph.getOffsetForCaret(textPosition, Rect.zero);
final cursorHeight = widget.cursorHeight ??
_renderParagraph.getFullHeightForCaret(textPosition) ??
5.0; // default height
return Rect.fromLTWH(
baseRect.centerRight.dx + cursorOffset.dx - (widget.cursorWidth / 2),
cursorOffset.dy,
widget.cursorWidth,
cursorHeight,
);
}
@override
Position getPositionInOffset(Offset start) {
final offset = _renderParagraph.globalToLocal(start);
final baseOffset = _renderParagraph.getPositionForOffset(offset).offset;
return Position(path: _textNode.path, offset: baseOffset);
}
@override
List<Rect> getRectsInSelection(Selection selection) {
assert(pathEquals(selection.start.path, selection.end.path) &&
pathEquals(selection.start.path, _textNode.path));
final textSelection = TextSelection(
baseOffset: selection.start.offset,
extentOffset: selection.end.offset,
);
final baseRect = frontWidgetRect();
return _renderParagraph.getBoxesForSelection(textSelection).map((box) {
final rect = box.toRect();
return rect.translate(baseRect.centerRight.dx, 0);
}).toList();
}
@override
Selection getSelectionInRange(Offset start, Offset end) {
final localStart = _renderParagraph.globalToLocal(start);
final localEnd = _renderParagraph.globalToLocal(end);
final baseOffset = _renderParagraph.getPositionForOffset(localStart).offset;
final extentOffset = _renderParagraph.getPositionForOffset(localEnd).offset;
return Selection.single(
path: _textNode.path,
startOffset: baseOffset,
endOffset: extentOffset,
);
}
Widget _buildRichText(BuildContext context) {
if (_textNode.children.isEmpty) {
return _buildSingleRichText(context);
} else {
return _buildRichTextWithChildren(context);
}
}
Widget _buildRichTextWithChildren(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSingleRichText(context),
..._textNode.children
.map(
(child) => _editorState.renderPlugins.buildWidget(
context: NodeWidgetContext(
buildContext: context,
node: child,
editorState: _editorState,
),
),
)
.toList()
],
);
}
Widget _buildSingleRichText(BuildContext context) {
return Expanded(child: RichText(key: _textKey, text: _textSpan));
}
Widget _buildTodoListRichText(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GestureDetector(
child: Icon(
key: _decorationKey,
_textNode.attributes.todo
? Icons.square_rounded
: Icons.square_outlined),
onTap: () => TransactionBuilder(_editorState)
..updateNode(_textNode, {
'todo': !_textNode.attributes.todo,
})
..commit(),
),
_buildRichText(context),
],
);
}
Widget _buildBulletedListRichText(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(key: _decorationKey, Icons.circle),
_buildRichText(context),
],
);
}
Widget _buildQuotedRichText(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(key: _decorationKey, Icons.format_quote),
_buildRichText(context),
],
);
}
Rect frontWidgetRect() {
// FIXME: find a more elegant way to solve this situation.
if (_textNode.attributes.list != null) {
final renderBox =
_decorationKey.currentContext?.findRenderObject() as RenderBox;
return renderBox.localToGlobal(Offset.zero) & renderBox.size;
}
return Rect.zero;
}
TextSpan get _textSpan => TextSpan(
children: _textNode.delta.operations
.whereType<TextInsert>()
.map((insert) => RichTextStyle(
attributes: insert.attributes ?? {},
text: insert.content,
).toTextSpan())
.toList(growable: false));
}

View File

@ -0,0 +1,182 @@
import 'package:flowy_editor/document/attributes.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
class StyleKey {
static String bold = 'bold';
static String italic = 'italic';
static String underline = 'underline';
static String strikethrough = 'strikethrough';
static String color = 'color';
static String font = 'font';
static String href = 'href';
static String heading = 'heading';
static String quotes = 'quotes';
static String list = 'list';
static String todo = 'todo';
static String code = 'code';
}
extension AttributesExtensions on Attributes {
bool get bold {
return (containsKey(StyleKey.bold) && this[StyleKey.bold] == true);
}
bool get italic {
return (containsKey(StyleKey.italic) && this[StyleKey.italic] == true);
}
bool get underline {
return (containsKey(StyleKey.underline) &&
this[StyleKey.underline] == true);
}
bool get strikethrough {
return (containsKey(StyleKey.strikethrough) &&
this[StyleKey.strikethrough] == true);
}
Color? get color {
if (containsKey(StyleKey.color) && this[StyleKey.color] is String) {
return Color(
int.parse(this[StyleKey.color]),
);
}
return null;
}
String? get font {
// TODO: unspport now.
return null;
}
String? get href {
if (containsKey(StyleKey.href) && this[StyleKey.href] is String) {
return this[StyleKey.href];
}
return null;
}
String? get heading {
if (containsKey(StyleKey.heading) && this[StyleKey.heading] is String) {
return this[StyleKey.heading];
}
return null;
}
bool get quotes {
if (containsKey(StyleKey.quotes) && this[StyleKey.quotes] == true) {
return this[StyleKey.quotes];
}
return false;
}
String? get list {
if (containsKey(StyleKey.list) && this[StyleKey.list] is String) {
return this[StyleKey.list];
}
return null;
}
bool get todo {
if (containsKey(StyleKey.todo) && this[StyleKey.todo] is bool) {
return this[StyleKey.todo];
}
return false;
}
bool get code {
if (containsKey(StyleKey.code) && this[StyleKey.code] == true) {
return this[StyleKey.code];
}
return false;
}
}
///
/// Supported partial rendering types:
/// bold, italic,
/// underline, strikethrough,
/// color, font,
/// href
///
/// Supported global rendering types:
/// heading: h1, h2, h3, h4, h5, h6,
/// block quotes,
/// list: ordered list, bulleted list,
/// code block
///
class RichTextStyle {
// TODO: customize
RichTextStyle({
required this.attributes,
required this.text,
});
final Attributes attributes;
final String text;
TextSpan toTextSpan() {
return TextSpan(
text: text,
style: TextStyle(
fontWeight: fontWeight,
fontStyle: fontStyle,
fontSize: fontSize,
color: textColor,
decoration: textDecoration,
),
recognizer: recognizer,
);
}
// bold
FontWeight get fontWeight =>
attributes.bold ? FontWeight.bold : FontWeight.normal;
// underline or strikethrough
TextDecoration get textDecoration {
if (attributes.underline || attributes.href != null) {
return TextDecoration.underline;
} else if (attributes.strikethrough) {
return TextDecoration.lineThrough;
}
return TextDecoration.none;
}
// font
FontStyle get fontStyle =>
attributes.italic ? FontStyle.italic : FontStyle.normal;
// text color
Color get textColor {
if (attributes.href != null) {
return Colors.lightBlue;
}
return attributes.color ?? Colors.black;
}
// font size
double get fontSize {
final heading = attributes.heading;
if (heading != null) {
final headings = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'];
final fontSizes = [30.0, 28.0, 26.0, 24.0, 22.0, 20.0];
return fontSizes[headings.indexOf(heading)];
} else {
return 18.0;
}
}
// recognizer
GestureRecognizer? get recognizer {
final href = attributes.href;
if (href != null) {
return TapGestureRecognizer()
..onTap = () async {
// FIXME: launch the url
};
}
return null;
}
}

View File

@ -4,7 +4,7 @@ import 'package:flutter/material.dart';
///
mixin Selectable<T extends StatefulWidget> on State<T> {
/// Returns a [List] of the [Rect] selection sorrounded by start and end
/// Returns a [List] of the [Rect] selection surrounded by start and end
/// in current widget.
///
/// [start] and [end] are the offsets under the global coordinate system.
@ -32,12 +32,5 @@ mixin Selectable<T extends StatefulWidget> on State<T> {
///
/// Only the widget rendered by [TextNode] need to implement the detail,
/// and the rest can return null.
TextSelection? getCurrentTextSelection() => null;
/// For [TextNode] only.
///
/// Retruns a [Offset].
/// Only the widget rendered by [TextNode] need to implement the detail,
/// and the rest can return [Offset.zero].
Offset getOffsetByTextSelection(TextSelection textSelection) => Offset.zero;
TextSelection? getTextSelectionInSelection(Selection selection) => null;
}

View File

@ -12,58 +12,58 @@ FlowyKeyEventHandler deleteSingleTextNodeHandler = (editorState, event) {
return KeyEventResult.ignored;
}
final selectionNodes = editorState.selectedNodes;
if (selectionNodes.length == 1 && selectionNodes.first is TextNode) {
final node = selectionNodes.first.unwrapOrNull<TextNode>();
final selectable = node?.key?.currentState?.unwrapOrNull<Selectable>();
if (selectable != null) {
final textSelection = selectable.getCurrentTextSelection();
if (textSelection != null) {
if (textSelection.isCollapsed) {
/// Three cases:
/// Delete the zero character,
/// 1. if there is still text node in front of it, then merge them.
/// 2. if not, just ignore
/// Delete the non-zero character,
/// 3. delete the single character.
if (textSelection.baseOffset == 0) {
if (node?.previous != null && node?.previous is TextNode) {
final previous = node!.previous! as TextNode;
final newTextSelection = TextSelection.collapsed(
offset: previous.toRawString().length);
final selectionService = editorState.service.selectionService;
final previousSelectable =
previous.key?.currentState?.unwrapOrNull<Selectable>();
final newOfset = previousSelectable
?.getOffsetByTextSelection(newTextSelection);
if (newOfset != null) {
// selectionService.updateCursor(newOfset);
}
// merge
TransactionBuilder(editorState)
..deleteNode(node)
..insertText(
previous, previous.toRawString().length, node.toRawString())
..commit();
return KeyEventResult.handled;
} else {
return KeyEventResult.ignored;
}
} else {
TransactionBuilder(editorState)
..deleteText(node!, textSelection.baseOffset - 1, 1)
..commit();
final newTextSelection =
TextSelection.collapsed(offset: textSelection.baseOffset - 1);
final selectionService = editorState.service.selectionService;
final newOfset =
selectable.getOffsetByTextSelection(newTextSelection);
// selectionService.updateCursor(newOfset);
return KeyEventResult.handled;
}
}
}
}
}
// final selectionNodes = editorState.selectedNodes;
// if (selectionNodes.length == 1 && selectionNodes.first is TextNode) {
// final node = selectionNodes.first.unwrapOrNull<TextNode>();
// final selectable = node?.key?.currentState?.unwrapOrNull<Selectable>();
// if (selectable != null) {
// final textSelection = selectable.getCurrentTextSelection();
// if (textSelection != null) {
// if (textSelection.isCollapsed) {
// /// Three cases:
// /// Delete the zero character,
// /// 1. if there is still text node in front of it, then merge them.
// /// 2. if not, just ignore
// /// Delete the non-zero character,
// /// 3. delete the single character.
// if (textSelection.baseOffset == 0) {
// if (node?.previous != null && node?.previous is TextNode) {
// final previous = node!.previous! as TextNode;
// final newTextSelection = TextSelection.collapsed(
// offset: previous.toRawString().length);
// final selectionService = editorState.service.selectionService;
// final previousSelectable =
// previous.key?.currentState?.unwrapOrNull<Selectable>();
// final newOfset = previousSelectable
// ?.getOffsetByTextSelection(newTextSelection);
// if (newOfset != null) {
// // selectionService.updateCursor(newOfset);
// }
// // merge
// TransactionBuilder(editorState)
// ..deleteNode(node)
// ..insertText(
// previous, previous.toRawString().length, node.toRawString())
// ..commit();
// return KeyEventResult.handled;
// } else {
// return KeyEventResult.ignored;
// }
// } else {
// TransactionBuilder(editorState)
// ..deleteText(node!, textSelection.baseOffset - 1, 1)
// ..commit();
// final newTextSelection =
// TextSelection.collapsed(offset: textSelection.baseOffset - 1);
// final selectionService = editorState.service.selectionService;
// final newOfset =
// selectable.getOffsetByTextSelection(newTextSelection);
// // selectionService.updateCursor(newOfset);
// return KeyEventResult.handled;
// }
// }
// }
// }
// }
return KeyEventResult.ignored;
};

View File

@ -1,6 +1,4 @@
import 'package:flowy_editor/flowy_editor.dart';
import 'package:flowy_editor/service/keyboard_service.dart';
import 'package:flowy_editor/extensions/object_extensions.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@ -10,21 +8,5 @@ FlowyKeyEventHandler slashShortcutHandler = (editorState, event) {
return KeyEventResult.ignored;
}
final selectedNodes = editorState.selectedNodes;
if (selectedNodes.length != 1) {
return KeyEventResult.ignored;
}
final textNode = selectedNodes.first.unwrapOrNull<TextNode>();
final selectable = textNode?.key?.currentState?.unwrapOrNull<Selectable>();
final textSelection = selectable?.getCurrentTextSelection();
// if (textNode != null && selectable != null && textSelection != null) {
// final offset = selectable.getOffsetByTextSelection(textSelection);
// final rect = selectable.getCursorRect(offset);
// editorState.service.floatingToolbarService
// .showInOffset(rect.topLeft, textNode.layerLink);
// return KeyEventResult.handled;
// }
return KeyEventResult.ignored;
};

View File

@ -299,6 +299,9 @@ class _FlowySelectionState extends State<FlowySelection>
panEndOffset = details.globalPosition;
final nodes = getNodesInRange(panStartOffset!, panEndOffset!);
if (nodes.isEmpty) {
return;
}
final first = nodes.first.selectable;
final last = nodes.last.selectable;