mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
feat: implement rich text component in flowy_ediotr and support markdown style rendering.
This commit is contained in:
parent
445ff561b5
commit
45a8566e61
@ -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",
|
||||
|
@ -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);
|
||||
|
@ -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(
|
||||
|
@ -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,
|
||||
|
@ -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),
|
||||
// );
|
||||
// }
|
@ -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
|
||||
|
@ -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),
|
||||
);
|
||||
}
|
@ -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,
|
||||
),
|
||||
|
@ -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,
|
||||
),
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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';
|
||||
|
@ -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(
|
||||
|
@ -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));
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
};
|
||||
|
@ -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;
|
||||
};
|
||||
|
@ -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;
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user