diff --git a/frontend/app_flowy/packages/flowy_editor/example/assets/document.json b/frontend/app_flowy/packages/flowy_editor/example/assets/document.json index 00ef06da5d..b90aec8369 100644 --- a/frontend/app_flowy/packages/flowy_editor/example/assets/document.json +++ b/frontend/app_flowy/packages/flowy_editor/example/assets/document.json @@ -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", 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 c64c50c090..b8b836cc4d 100644 --- a/frontend/app_flowy/packages/flowy_editor/example/lib/main.dart +++ b/frontend/app_flowy/packages/flowy_editor/example/lib/main.dart @@ -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 { 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); diff --git a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/document_node_widget.dart b/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/document_node_widget.dart index 2db1ef89c4..2a70da2ba2 100644 --- a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/document_node_widget.dart +++ b/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/document_node_widget.dart @@ -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( diff --git a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/image_node_widget.dart b/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/image_node_widget.dart index c5084df2fb..aaca3148c2 100644 --- a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/image_node_widget.dart +++ b/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/image_node_widget.dart @@ -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, diff --git a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/old_text_node_widget.dart b/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/old_text_node_widget.dart new file mode 100644 index 0000000000..bad07fe6a6 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/old_text_node_widget.dart @@ -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 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 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() +// .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), +// ); +// } diff --git a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/selected_text_node_widget.dart b/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/selected_text_node_widget.dart index 894f6b1848..e16abaa1aa 100644 --- a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/selected_text_node_widget.dart +++ b/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/selected_text_node_widget.dart @@ -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 diff --git a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/text_node_widget.dart b/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/text_node_widget.dart deleted file mode 100644 index a67ebcd2ad..0000000000 --- a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/text_node_widget.dart +++ /dev/null @@ -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 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 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() - .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), - ); -} diff --git a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/text_with_check_box_node_widget.dart b/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/text_with_check_box_node_widget.dart index ff6c6e9932..f7985ed564 100644 --- a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/text_with_check_box_node_widget.dart +++ b/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/text_with_check_box_node_widget.dart @@ -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, ), diff --git a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/text_with_heading_node_widget.dart b/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/text_with_heading_node_widget.dart index 22022a65ec..c4bd027888 100644 --- a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/text_with_heading_node_widget.dart +++ b/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/text_with_heading_node_widget.dart @@ -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, ), diff --git a/frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart b/frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart index cd503843c2..e388ea3661 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart @@ -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 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; } diff --git a/frontend/app_flowy/packages/flowy_editor/lib/flowy_editor.dart b/frontend/app_flowy/packages/flowy_editor/lib/flowy_editor.dart index 3f8510d8b3..91c6b1c4b0 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/flowy_editor.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/flowy_editor.dart @@ -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'; diff --git a/frontend/app_flowy/packages/flowy_editor/lib/render/node_widget_builder.dart b/frontend/app_flowy/packages/flowy_editor/lib/render/node_widget_builder.dart index 659c380720..214818f60a 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/render/node_widget_builder.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/render/node_widget_builder.dart @@ -26,14 +26,14 @@ class NodeWidgetBuilder { /// 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 { '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( diff --git a/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/flowy_rich_text.dart b/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/flowy_rich_text.dart new file mode 100644 index 0000000000..5f2ca0689e --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/flowy_rich_text.dart @@ -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 createState() => _FlowyRichTextState(); +} + +class _FlowyRichTextState extends State 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 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() + .map((insert) => RichTextStyle( + attributes: insert.attributes ?? {}, + text: insert.content, + ).toTextSpan()) + .toList(growable: false)); +} diff --git a/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/rich_text_style.dart b/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/rich_text_style.dart new file mode 100644 index 0000000000..a1fd8b57a1 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/rich_text_style.dart @@ -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; + } +} diff --git a/frontend/app_flowy/packages/flowy_editor/lib/render/selection/selectable.dart b/frontend/app_flowy/packages/flowy_editor/lib/render/selection/selectable.dart index 4d155972df..8dfea75135 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/render/selection/selectable.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/render/selection/selectable.dart @@ -4,7 +4,7 @@ import 'package:flutter/material.dart'; /// mixin Selectable on State { - /// 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 on State { /// /// 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; } diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/delete_single_text_node_handler.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/delete_single_text_node_handler.dart index 47a83f314a..f5da6423ae 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/delete_single_text_node_handler.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/delete_single_text_node_handler.dart @@ -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(); - final selectable = node?.key?.currentState?.unwrapOrNull(); - 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(); - 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(); + // final selectable = node?.key?.currentState?.unwrapOrNull(); + // 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(); + // 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; }; diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/shortcut_handler.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/shortcut_handler.dart index 3eef8c1d1b..f424bcf314 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/shortcut_handler.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/shortcut_handler.dart @@ -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(); - final selectable = textNode?.key?.currentState?.unwrapOrNull(); - 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; }; diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart index 52c0b84f2c..07cf2ad902 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart @@ -299,6 +299,9 @@ class _FlowySelectionState extends State panEndOffset = details.globalPosition; final nodes = getNodesInRange(panStartOffset!, panEndOffset!); + if (nodes.isEmpty) { + return; + } final first = nodes.first.selectable; final last = nodes.last.selectable;