feat: implement to-do list component

This commit is contained in:
Lucas.Xu 2023-04-18 15:44:11 +08:00
commit a1ef08006b
20 changed files with 545 additions and 166 deletions

View file

@ -3,24 +3,59 @@
"type": "editor", "type": "editor",
"children": [ "children": [
{ {
"type": "text", "type": "paragraph",
"attributes": { "attributes": {
"subtype": "heading", "delta": [
"heading": "h2" { "insert": "👋 " },
}, { "insert": "Welcome to", "attributes": { "bold": true } },
"delta": [ { "insert": " " },
{ "insert": "👋 " }, {
{ "insert": "Welcome to", "attributes": { "bold": true } }, "insert": "AppFlowy Editor",
{ "insert": " " }, "attributes": {
{ "href": "appflowy.io",
"insert": "AppFlowy Editor", "italic": true,
"attributes": { "bold": true
"href": "appflowy.io", }
"italic": true,
"bold": true
} }
} ]
] }
},
{
"type": "paragraph",
"attributes": {
"delta": [
{ "insert": "👋 " },
{ "insert": "Welcome to", "attributes": { "bold": true } },
{ "insert": " " },
{
"insert": "AppFlowy Editor",
"attributes": {
"href": "appflowy.io",
"italic": true,
"bold": true
}
}
]
}
},
{
"type": "todo_list",
"attributes": {
"checked": false,
"delta": [
{ "insert": "👋 " },
{ "insert": "Welcome to", "attributes": { "bold": true } },
{ "insert": " " },
{
"insert": "AppFlowy Editor",
"attributes": {
"href": "appflowy.io",
"italic": true,
"bold": true
}
}
]
}
}, },
{ "type": "text", "delta": [] }, { "type": "text", "delta": [] },
{ {

View file

@ -38,6 +38,12 @@ class SimpleEditor extends StatelessWidget {
editorState: editorState, editorState: editorState,
themeData: themeData, themeData: themeData,
autoFocus: editorState.document.isEmpty, autoFocus: editorState.document.isEmpty,
customBuilders: {
'paragraph': TextBlockComponentBuilder(
padding: const EdgeInsets.symmetric(horizontal: 20.0),
),
'todo_list': TodoListBlockComponentBuilder(),
},
); );
} else { } else {
return const Center( return const Center(

View file

@ -52,3 +52,5 @@ export 'src/extensions/extensions.dart';
export 'src/service/default_text_operations/format_rich_text_style.dart'; export 'src/service/default_text_operations/format_rich_text_style.dart';
export 'src/infra/html_converter.dart'; export 'src/infra/html_converter.dart';
export 'src/service/internal_key_event_handlers/copy_paste_handler.dart'; export 'src/service/internal_key_event_handlers/copy_paste_handler.dart';
export 'src/block_component/block_component.dart';

View file

@ -0,0 +1,5 @@
// paragraph
export 'text_block_component/text_block_component.dart';
// to-do list
export 'todo_list_block_component/todo_list_block_component.dart';

View file

@ -0,0 +1,164 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/services.dart';
abstract class TextInputService {
TextInputService({
required this.onInsert,
required this.onDelete,
required this.onReplace,
required this.onNonTextUpdate,
});
Future<void> Function(TextEditingDeltaInsertion insertion) onInsert;
Future<void> Function(TextEditingDeltaDeletion deletion) onDelete;
Future<void> Function(TextEditingDeltaReplacement replacement) onReplace;
Future<void> Function(TextEditingDeltaNonTextUpdate nonTextUpdate)
onNonTextUpdate;
TextRange? composingTextRange;
void updateCaretPosition(Size size, Matrix4 transform, Rect rect);
/// Updates the [TextEditingValue] of the text currently being edited.
///
/// Note that if there are IME-related requirements,
/// please config `composing` value within [TextEditingValue]
void attach(TextEditingValue textEditingValue);
/// Applies insertion, deletion and replacement
/// to the text currently being edited.
///
/// For more information, please check [TextEditingDelta].
Future<void> apply(List<TextEditingDelta> deltas);
/// Closes the editing state of the text currently being edited.
void close();
}
class DeltaTextInputService extends TextInputService
implements DeltaTextInputClient {
DeltaTextInputService({
required super.onInsert,
required super.onDelete,
required super.onReplace,
required super.onNonTextUpdate,
});
TextInputConnection? textInputConnection;
@override
TextRange? composingTextRange;
@override
AutofillScope? get currentAutofillScope => throw UnimplementedError();
@override
TextEditingValue? get currentTextEditingValue => throw UnimplementedError();
@override
Future<void> apply(List<TextEditingDelta> deltas) async {
for (final delta in deltas) {
_updateComposing(delta);
if (delta is TextEditingDeltaInsertion) {
await onInsert(delta);
} else if (delta is TextEditingDeltaDeletion) {
await onDelete(delta);
} else if (delta is TextEditingDeltaReplacement) {
await onReplace(delta);
} else if (delta is TextEditingDeltaNonTextUpdate) {
await onNonTextUpdate(delta);
}
}
}
@override
void attach(TextEditingValue textEditingValue) {
if (textInputConnection == null || textInputConnection!.attached == false) {
textInputConnection = TextInput.attach(
this,
const TextInputConfiguration(
enableDeltaModel: true,
inputType: TextInputType.multiline,
textCapitalization: TextCapitalization.sentences,
),
);
}
textInputConnection!
..setEditingState(textEditingValue)
..show();
}
@override
void close() {
textInputConnection?.close();
textInputConnection = null;
}
@override
void updateEditingValueWithDeltas(List<TextEditingDelta> textEditingDeltas) {
Log.input.debug(
textEditingDeltas.map((delta) => delta.toString()).toString(),
);
apply(textEditingDeltas);
}
// TODO: support IME in linux / windows / ios / android
// Only support macOS now.
@override
void updateCaretPosition(Size size, Matrix4 transform, Rect rect) {
textInputConnection
?..setEditableSizeAndTransform(size, transform)
..setCaretRect(rect);
}
@override
void connectionClosed() {}
@override
void insertTextPlaceholder(Size size) {}
@override
void performAction(TextInputAction action) {}
@override
void performPrivateCommand(String action, Map<String, dynamic> data) {}
@override
void removeTextPlaceholder() {}
@override
void showAutocorrectionPromptRect(int start, int end) {}
@override
void showToolbar() {}
@override
void updateEditingValue(TextEditingValue value) {}
@override
void updateFloatingCursor(RawFloatingCursorPoint point) {}
void _updateComposing(TextEditingDelta delta) {
if (delta is! TextEditingDeltaNonTextUpdate) {
if (composingTextRange != null &&
composingTextRange!.start != -1 &&
delta.composing.end != -1) {
composingTextRange = TextRange(
start: composingTextRange!.start,
end: delta.composing.end,
);
} else {
composingTextRange = delta.composing;
}
}
}
@override
void didChangeInputControl(
TextInputControl? oldControl,
TextInputControl? newControl,
) {}
@override
void performSelector(String selectorName) {}
}

View file

@ -0,0 +1,87 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/block_component/delta_input_service.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class TextBlockComponentBuilder extends NodeWidgetBuilder<Node> {
TextBlockComponentBuilder({
this.padding = const EdgeInsets.all(0.0),
this.textStyle = const TextStyle(),
});
final EdgeInsets padding;
final TextStyle textStyle;
@override
Widget build(NodeWidgetContext<Node> context) {
return TextBlockComponentWidget(
key: context.node.key,
node: context.node,
padding: padding,
textStyle: textStyle,
);
}
@override
NodeValidator<Node> get nodeValidator => (node) {
// TODO: implement nodeValidator, delta...
return true;
};
}
class TextBlockComponentWidget extends StatefulWidget {
const TextBlockComponentWidget({
super.key,
required this.node,
this.padding = const EdgeInsets.all(0.0),
this.textStyle = const TextStyle(),
});
final Node node;
final EdgeInsets padding;
final TextStyle textStyle;
@override
State<TextBlockComponentWidget> createState() =>
_TextBlockComponentWidgetState();
}
class _TextBlockComponentWidgetState extends State<TextBlockComponentWidget>
with SelectableMixin, DefaultSelectable {
final forwardKey = GlobalKey(debugLabel: 'flowy_rich_text');
late final editorState = Provider.of<EditorState>(context, listen: false);
TextInputService? inputService;
@override
SelectableMixin<StatefulWidget> get forward =>
forwardKey.currentState as SelectableMixin;
@override
Offset get baseOffset => widget.padding.topLeft;
@override
GlobalKey<State<StatefulWidget>>? get iconKey => null;
@override
void initState() {
super.initState();
}
@override
void dispose() {
super.dispose();
}
@override
Widget build(BuildContext context) {
return Padding(
padding: widget.padding,
child: FlowyRichText(
key: forwardKey,
node: widget.node,
editorState: editorState,
),
);
}
}

View file

@ -0,0 +1,142 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class TodoListBlockKeys {
TodoListBlockKeys._();
/// The checked data of a todo list block.
///
/// The value is a boolean.
static const String checked = 'checked';
}
class TodoListBlockComponentBuilder extends NodeWidgetBuilder<Node> {
TodoListBlockComponentBuilder({
this.padding = const EdgeInsets.all(0.0),
this.textStyle = const TextStyle(),
this.icon,
});
/// The padding of the todo list block.
final EdgeInsets padding;
/// The text style of the todo list block.
final TextStyle textStyle;
/// The icon of the todo list block.
final Widget? Function(bool checked)? icon;
@override
Widget build(NodeWidgetContext<Node> context) {
return TodoListBlockComponentWidget(
key: context.node.key,
node: context.node,
padding: padding,
textStyle: textStyle,
icon: icon,
);
}
@override
NodeValidator<Node> get nodeValidator => (node) =>
node.delta != null &&
node.attributes.containsKey(
TodoListBlockKeys.checked,
);
}
class TodoListBlockComponentWidget extends StatefulWidget {
const TodoListBlockComponentWidget({
super.key,
required this.node,
this.padding = const EdgeInsets.all(0.0),
this.textStyle = const TextStyle(),
this.icon,
});
final Node node;
final EdgeInsets padding;
final TextStyle textStyle;
final Widget? Function(bool checked)? icon;
@override
State<TodoListBlockComponentWidget> createState() =>
_TodoListBlockComponentWidgetState();
}
class _TodoListBlockComponentWidgetState
extends State<TodoListBlockComponentWidget>
with SelectableMixin, DefaultSelectable {
@override
final forwardKey = GlobalKey(debugLabel: 'flowy_rich_text');
late final editorState = Provider.of<EditorState>(context, listen: false);
bool get checked => widget.node.attributes[TodoListBlockKeys.checked];
@override
Widget build(BuildContext context) {
return Padding(
padding: widget.padding,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
_TodoListIcon(
checked: checked,
icon: widget.icon,
onTap: checkOrUncheck,
),
FlowyRichText(
key: forwardKey,
node: widget.node,
editorState: editorState,
),
],
),
);
}
Future<void> checkOrUncheck() async {
final transaction = editorState.transaction
..updateNode(widget.node, {
TodoListBlockKeys.checked: !checked,
});
return editorState.apply(transaction);
}
}
class _TodoListIcon extends StatelessWidget {
const _TodoListIcon({
required this.checked,
required this.icon,
required this.onTap,
});
final bool checked;
final Widget? Function(bool checked)? icon;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: icon?.call(checked) ?? defaultCheckboxIcon(),
),
);
}
FlowySvg defaultCheckboxIcon() {
return FlowySvg(
width: 22,
height: 22,
padding: const EdgeInsets.only(right: 5.0),
name: checked ? 'check' : 'uncheck',
);
}
}

View file

@ -7,6 +7,14 @@ import 'package:appflowy_editor/src/core/document/path.dart';
import 'package:appflowy_editor/src/core/document/text_delta.dart'; import 'package:appflowy_editor/src/core/document/text_delta.dart';
import 'package:appflowy_editor/src/core/legacy/built_in_attribute_keys.dart'; import 'package:appflowy_editor/src/core/legacy/built_in_attribute_keys.dart';
/*
{
'type': string,
'data': Map<String, Object>,
'children': List<Node>,
}
*/
class Node extends ChangeNotifier with LinkedListEntry<Node> { class Node extends ChangeNotifier with LinkedListEntry<Node> {
Node({ Node({
required this.type, required this.type,
@ -93,16 +101,9 @@ class Node extends ChangeNotifier with LinkedListEntry<Node> {
Path get path => _computePath(); Path get path => _computePath();
void updateAttributes(Attributes attributes) { void updateAttributes(Attributes attributes) {
final oldAttributes = this.attributes;
_attributes = composeAttributes(this.attributes, attributes) ?? {}; _attributes = composeAttributes(this.attributes, attributes) ?? {};
// Notifies the new attributes notifyListeners();
// if attributes contains 'subtype', should notify parent to rebuild node
// else, just notify current node.
bool shouldNotifyParent =
this.attributes['subtype'] != oldAttributes['subtype'];
shouldNotifyParent ? parent?.notifyListeners() : notifyListeners();
} }
Node? childAtIndex(int index) { Node? childAtIndex(int index) {
@ -170,6 +171,13 @@ class Node extends ChangeNotifier with LinkedListEntry<Node> {
parent = null; parent = null;
} }
Delta? get delta {
if (attributes['delta'] is List) {
return Delta.fromJson(attributes['delta']);
}
return null;
}
Map<String, Object> toJson() { Map<String, Object> toJson() {
var map = <String, Object>{ var map = <String, Object>{
'type': type, 'type': type,
@ -239,6 +247,7 @@ class TextNode extends Node {
); );
Delta _delta; Delta _delta;
@override
Delta get delta => _delta; Delta get delta => _delta;
set delta(Delta v) { set delta(Delta v) {
_delta = v; _delta = v;

View file

@ -1,16 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'package:appflowy_editor/src/core/document/node.dart'; import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/infra/log.dart';
import 'package:appflowy_editor/src/render/selection_menu/selection_menu_widget.dart';
import 'package:appflowy_editor/src/render/style/editor_style.dart';
import 'package:appflowy_editor/src/render/toolbar/toolbar_item.dart';
import 'package:appflowy_editor/src/service/service.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:appflowy_editor/src/core/location/selection.dart';
import 'package:appflowy_editor/src/core/document/document.dart';
import 'package:appflowy_editor/src/core/transform/operation.dart';
import 'package:appflowy_editor/src/core/transform/transaction.dart';
import 'package:appflowy_editor/src/history/undo_manager.dart'; import 'package:appflowy_editor/src/history/undo_manager.dart';
class ApplyOptions { class ApplyOptions {
@ -53,6 +44,8 @@ class EditorState {
// Service reference. // Service reference.
final service = FlowyService(); final service = FlowyService();
AppFlowySelectionService get selection => service.selectionService;
/// Configures log output parameters, /// Configures log output parameters,
/// such as log level and log output callbacks, /// such as log level and log output callbacks,
/// with this variable. /// with this variable.

View file

@ -48,18 +48,7 @@ class BulletedListTextNodeWidget extends BuiltInTextWidget {
class _BulletedListTextNodeWidgetState extends State<BulletedListTextNodeWidget> class _BulletedListTextNodeWidgetState extends State<BulletedListTextNodeWidget>
with SelectableMixin, DefaultSelectable, BuiltInTextWidgetMixin { with SelectableMixin, DefaultSelectable, BuiltInTextWidgetMixin {
@override @override
final iconKey = GlobalKey(); final forwardKey = GlobalKey(debugLabel: 'bulleted_list_text');
final _richTextKey = GlobalKey(debugLabel: 'bulleted_list_text');
@override
SelectableMixin<StatefulWidget> get forward =>
_richTextKey.currentState as SelectableMixin;
@override
Offset get baseOffset {
return super.baseOffset.translate(0, padding.top);
}
BulletedListPluginStyle get style => BulletedListPluginStyle get style =>
Theme.of(context).extensionOrNull<BulletedListPluginStyle>() ?? Theme.of(context).extensionOrNull<BulletedListPluginStyle>() ??
@ -88,19 +77,18 @@ class _BulletedListTextNodeWidgetState extends State<BulletedListTextNodeWidget>
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Container( Container(
key: iconKey,
child: icon, child: icon,
), ),
Flexible( Flexible(
child: FlowyRichText( child: FlowyRichText(
key: _richTextKey, key: forwardKey,
placeholderText: 'List', placeholderText: 'List',
textSpanDecorator: (textSpan) => textSpanDecorator: (textSpan) =>
textSpan.updateTextStyle(textStyle), textSpan.updateTextStyle(textStyle),
placeholderTextSpanDecorator: (textSpan) => placeholderTextSpanDecorator: (textSpan) =>
textSpan.updateTextStyle(textStyle), textSpan.updateTextStyle(textStyle),
lineHeight: widget.editorState.editorStyle.lineHeight, lineHeight: widget.editorState.editorStyle.lineHeight,
textNode: widget.textNode, node: widget.textNode,
editorState: widget.editorState, editorState: widget.editorState,
), ),
) )

View file

@ -39,18 +39,7 @@ class CheckboxNodeWidget extends BuiltInTextWidget {
class _CheckboxNodeWidgetState extends State<CheckboxNodeWidget> class _CheckboxNodeWidgetState extends State<CheckboxNodeWidget>
with SelectableMixin, DefaultSelectable, BuiltInTextWidgetMixin { with SelectableMixin, DefaultSelectable, BuiltInTextWidgetMixin {
@override @override
final iconKey = GlobalKey(); final forwardKey = GlobalKey(debugLabel: 'checkbox_text');
final _richTextKey = GlobalKey(debugLabel: 'checkbox_text');
@override
SelectableMixin<StatefulWidget> get forward =>
_richTextKey.currentState as SelectableMixin;
@override
Offset get baseOffset {
return super.baseOffset.translate(0, padding.top);
}
CheckboxPluginStyle get style => CheckboxPluginStyle get style =>
Theme.of(context).extensionOrNull<CheckboxPluginStyle>() ?? Theme.of(context).extensionOrNull<CheckboxPluginStyle>() ??
@ -80,7 +69,6 @@ class _CheckboxNodeWidgetState extends State<CheckboxNodeWidget>
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
GestureDetector( GestureDetector(
key: iconKey,
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
onTap: () async { onTap: () async {
await widget.editorState.formatTextToCheckbox( await widget.editorState.formatTextToCheckbox(
@ -92,10 +80,10 @@ class _CheckboxNodeWidgetState extends State<CheckboxNodeWidget>
), ),
Flexible( Flexible(
child: FlowyRichText( child: FlowyRichText(
key: _richTextKey, key: forwardKey,
placeholderText: 'To-do', placeholderText: 'To-do',
lineHeight: widget.editorState.editorStyle.lineHeight, lineHeight: widget.editorState.editorStyle.lineHeight,
textNode: widget.textNode, node: widget.textNode,
textSpanDecorator: (textSpan) => textSpanDecorator: (textSpan) =>
textSpan.updateTextStyle(textStyle), textSpan.updateTextStyle(textStyle),
placeholderTextSpanDecorator: (textSpan) => placeholderTextSpanDecorator: (textSpan) =>

View file

@ -2,18 +2,19 @@ import 'package:appflowy_editor/src/core/location/position.dart';
import 'package:appflowy_editor/src/core/location/selection.dart'; import 'package:appflowy_editor/src/core/location/selection.dart';
import 'package:appflowy_editor/src/render/selection/selectable.dart'; import 'package:appflowy_editor/src/render/selection/selectable.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
mixin DefaultSelectable { mixin DefaultSelectable {
SelectableMixin get forward; GlobalKey get forwardKey;
GlobalKey? get iconKey; SelectableMixin<StatefulWidget> get forward =>
forwardKey.currentState as SelectableMixin;
Offset get baseOffset { Offset get baseOffset {
if (iconKey != null) { final renderBox = forwardKey.currentContext?.findRenderObject();
final renderBox = iconKey!.currentContext?.findRenderObject(); final parentData = renderBox?.parentData;
if (renderBox is RenderBox) { if (parentData is BoxParentData) {
return Offset(renderBox.size.width, 0); return parentData.offset;
}
} }
return Offset.zero; return Offset.zero;
} }

View file

@ -31,11 +31,11 @@ class FlowyRichText extends StatefulWidget {
this.textSpanDecorator, this.textSpanDecorator,
this.placeholderText = ' ', this.placeholderText = ' ',
this.placeholderTextSpanDecorator, this.placeholderTextSpanDecorator,
required this.textNode, required this.node,
required this.editorState, required this.editorState,
}) : super(key: key); }) : super(key: key);
final TextNode textNode; final Node node;
final EditorState editorState; final EditorState editorState;
final double? cursorHeight; final double? cursorHeight;
final double cursorWidth; final double cursorWidth;
@ -75,12 +75,12 @@ class _FlowyRichTextState extends State<FlowyRichText> with SelectableMixin {
} }
@override @override
Position start() => Position(path: widget.textNode.path, offset: 0); Position start() => Position(path: widget.node.path, offset: 0);
@override @override
Position end() => Position( Position end() => Position(
path: widget.textNode.path, path: widget.node.path,
offset: widget.textNode.toPlainText().length, offset: widget.node.delta?.toPlainText().length ?? 0,
); );
@override @override
@ -119,7 +119,7 @@ class _FlowyRichTextState extends State<FlowyRichText> with SelectableMixin {
Position getPositionInOffset(Offset start) { Position getPositionInOffset(Offset start) {
final offset = _renderParagraph.globalToLocal(start); final offset = _renderParagraph.globalToLocal(start);
final baseOffset = _renderParagraph.getPositionForOffset(offset).offset; final baseOffset = _renderParagraph.getPositionForOffset(offset).offset;
return Position(path: widget.textNode.path, offset: baseOffset); return Position(path: widget.node.path, offset: baseOffset);
} }
@override @override
@ -127,8 +127,8 @@ class _FlowyRichTextState extends State<FlowyRichText> with SelectableMixin {
final localOffset = _renderParagraph.globalToLocal(offset); final localOffset = _renderParagraph.globalToLocal(offset);
final textPosition = _renderParagraph.getPositionForOffset(localOffset); final textPosition = _renderParagraph.getPositionForOffset(localOffset);
final textRange = _renderParagraph.getWordBoundary(textPosition); final textRange = _renderParagraph.getWordBoundary(textPosition);
final start = Position(path: widget.textNode.path, offset: textRange.start); final start = Position(path: widget.node.path, offset: textRange.start);
final end = Position(path: widget.textNode.path, offset: textRange.end); final end = Position(path: widget.node.path, offset: textRange.end);
return Selection(start: start, end: end); return Selection(start: start, end: end);
} }
@ -136,15 +136,15 @@ class _FlowyRichTextState extends State<FlowyRichText> with SelectableMixin {
Selection? getWordBoundaryInPosition(Position position) { Selection? getWordBoundaryInPosition(Position position) {
final textPosition = TextPosition(offset: position.offset); final textPosition = TextPosition(offset: position.offset);
final textRange = _renderParagraph.getWordBoundary(textPosition); final textRange = _renderParagraph.getWordBoundary(textPosition);
final start = Position(path: widget.textNode.path, offset: textRange.start); final start = Position(path: widget.node.path, offset: textRange.start);
final end = Position(path: widget.textNode.path, offset: textRange.end); final end = Position(path: widget.node.path, offset: textRange.end);
return Selection(start: start, end: end); return Selection(start: start, end: end);
} }
@override @override
List<Rect> getRectsInSelection(Selection selection) { List<Rect> getRectsInSelection(Selection selection) {
assert( assert(
selection.isSingle && selection.start.path.equals(widget.textNode.path), selection.isSingle && selection.start.path.equals(widget.node.path),
); );
final textSelection = TextSelection( final textSelection = TextSelection(
@ -171,7 +171,7 @@ class _FlowyRichTextState extends State<FlowyRichText> with SelectableMixin {
final baseOffset = _renderParagraph.getPositionForOffset(localStart).offset; final baseOffset = _renderParagraph.getPositionForOffset(localStart).offset;
final extentOffset = _renderParagraph.getPositionForOffset(localEnd).offset; final extentOffset = _renderParagraph.getPositionForOffset(localEnd).offset;
return Selection.single( return Selection.single(
path: widget.textNode.path, path: widget.node.path,
startOffset: baseOffset, startOffset: baseOffset,
endOffset: extentOffset, endOffset: extentOffset,
); );
@ -185,7 +185,7 @@ class _FlowyRichTextState extends State<FlowyRichText> with SelectableMixin {
Widget _buildRichText(BuildContext context) { Widget _buildRichText(BuildContext context) {
return MouseRegion( return MouseRegion(
cursor: SystemMouseCursors.text, cursor: SystemMouseCursors.text,
child: widget.textNode.toPlainText().isEmpty child: widget.node.delta?.toPlainText().isEmpty ?? true
? Stack( ? Stack(
children: [ children: [
_buildPlaceholderText(context), _buildPlaceholderText(context),
@ -241,7 +241,7 @@ class _FlowyRichTextState extends State<FlowyRichText> with SelectableMixin {
var offset = 0; var offset = 0;
List<TextSpan> textSpans = []; List<TextSpan> textSpans = [];
final style = widget.editorState.editorStyle; final style = widget.editorState.editorStyle;
final textInserts = widget.textNode.delta.whereType<TextInsert>(); final textInserts = widget.node.delta!.whereType<TextInsert>();
for (final textInsert in textInserts) { for (final textInsert in textInserts) {
var textStyle = style.textStyle!; var textStyle = style.textStyle!;
GestureRecognizer? recognizer; GestureRecognizer? recognizer;
@ -264,7 +264,7 @@ class _FlowyRichTextState extends State<FlowyRichText> with SelectableMixin {
recognizer = _buildTapHrefGestureRecognizer( recognizer = _buildTapHrefGestureRecognizer(
attributes.href!, attributes.href!,
Selection.single( Selection.single(
path: widget.textNode.path, path: widget.node.path,
startOffset: offset, startOffset: offset,
endOffset: offset + textInsert.length, endOffset: offset + textInsert.length,
), ),
@ -296,7 +296,7 @@ class _FlowyRichTextState extends State<FlowyRichText> with SelectableMixin {
if (_kRichTextDebugMode) { if (_kRichTextDebugMode) {
textSpans.add( textSpans.add(
TextSpan( TextSpan(
text: '${widget.textNode.path}', text: '${widget.node.path}',
style: const TextStyle( style: const TextStyle(
backgroundColor: Colors.red, backgroundColor: Colors.red,
fontSize: 16.0, fontSize: 16.0,

View file

@ -47,18 +47,7 @@ class HeadingTextNodeWidget extends BuiltInTextWidget {
class _HeadingTextNodeWidgetState extends State<HeadingTextNodeWidget> class _HeadingTextNodeWidgetState extends State<HeadingTextNodeWidget>
with SelectableMixin, DefaultSelectable { with SelectableMixin, DefaultSelectable {
@override @override
GlobalKey? get iconKey => null; final forwardKey = GlobalKey(debugLabel: 'checkbox_text');
final _richTextKey = GlobalKey(debugLabel: 'heading_text');
@override
SelectableMixin<StatefulWidget> get forward =>
_richTextKey.currentState as SelectableMixin;
@override
Offset get baseOffset {
return padding.topLeft;
}
HeadingPluginStyle get style => HeadingPluginStyle get style =>
Theme.of(context).extensionOrNull<HeadingPluginStyle>() ?? Theme.of(context).extensionOrNull<HeadingPluginStyle>() ??
@ -79,13 +68,13 @@ class _HeadingTextNodeWidgetState extends State<HeadingTextNodeWidget>
return Padding( return Padding(
padding: padding, padding: padding,
child: FlowyRichText( child: FlowyRichText(
key: _richTextKey, key: forwardKey,
placeholderText: 'Heading', placeholderText: 'Heading',
placeholderTextSpanDecorator: (textSpan) => placeholderTextSpanDecorator: (textSpan) =>
textSpan.updateTextStyle(textStyle), textSpan.updateTextStyle(textStyle),
textSpanDecorator: (textSpan) => textSpan.updateTextStyle(textStyle), textSpanDecorator: (textSpan) => textSpan.updateTextStyle(textStyle),
lineHeight: widget.editorState.editorStyle.lineHeight, lineHeight: widget.editorState.editorStyle.lineHeight,
textNode: widget.textNode, node: widget.textNode,
editorState: widget.editorState, editorState: widget.editorState,
), ),
); );

View file

@ -47,18 +47,7 @@ class NumberListTextNodeWidget extends BuiltInTextWidget {
class _NumberListTextNodeWidgetState extends State<NumberListTextNodeWidget> class _NumberListTextNodeWidgetState extends State<NumberListTextNodeWidget>
with SelectableMixin, DefaultSelectable { with SelectableMixin, DefaultSelectable {
@override @override
final iconKey = GlobalKey(); final forwardKey = GlobalKey(debugLabel: 'checkbox_text');
final _richTextKey = GlobalKey(debugLabel: 'number_list_text');
@override
SelectableMixin<StatefulWidget> get forward =>
_richTextKey.currentState as SelectableMixin;
@override
Offset get baseOffset {
return super.baseOffset.translate(0, padding.top);
}
NumberListPluginStyle get style => NumberListPluginStyle get style =>
Theme.of(context).extensionOrNull<NumberListPluginStyle>() ?? Theme.of(context).extensionOrNull<NumberListPluginStyle>() ??
@ -87,14 +76,13 @@ class _NumberListTextNodeWidgetState extends State<NumberListTextNodeWidget>
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Container( Container(
key: iconKey,
child: icon, child: icon,
), ),
Flexible( Flexible(
child: FlowyRichText( child: FlowyRichText(
key: _richTextKey, key: forwardKey,
placeholderText: 'List', placeholderText: 'List',
textNode: widget.textNode, node: widget.textNode,
editorState: widget.editorState, editorState: widget.editorState,
lineHeight: widget.editorState.editorStyle.lineHeight, lineHeight: widget.editorState.editorStyle.lineHeight,
placeholderTextSpanDecorator: (textSpan) => placeholderTextSpanDecorator: (textSpan) =>

View file

@ -47,18 +47,7 @@ class QuotedTextNodeWidget extends BuiltInTextWidget {
class _QuotedTextNodeWidgetState extends State<QuotedTextNodeWidget> class _QuotedTextNodeWidgetState extends State<QuotedTextNodeWidget>
with SelectableMixin, DefaultSelectable { with SelectableMixin, DefaultSelectable {
@override @override
final iconKey = GlobalKey(); final forwardKey = GlobalKey(debugLabel: 'checkbox_text');
final _richTextKey = GlobalKey(debugLabel: 'quoted_text');
@override
SelectableMixin<StatefulWidget> get forward =>
_richTextKey.currentState as SelectableMixin;
@override
Offset get baseOffset {
return super.baseOffset.translate(0, padding.top);
}
QuotedTextPluginStyle get style => QuotedTextPluginStyle get style =>
Theme.of(context).extensionOrNull<QuotedTextPluginStyle>() ?? Theme.of(context).extensionOrNull<QuotedTextPluginStyle>() ??
@ -88,14 +77,13 @@ class _QuotedTextNodeWidgetState extends State<QuotedTextNodeWidget>
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Container( Container(
key: iconKey,
child: icon, child: icon,
), ),
Flexible( Flexible(
child: FlowyRichText( child: FlowyRichText(
key: _richTextKey, key: forwardKey,
placeholderText: 'Quote', placeholderText: 'Quote',
textNode: widget.textNode, node: widget.textNode,
textSpanDecorator: (textSpan) => textSpanDecorator: (textSpan) =>
textSpan.updateTextStyle(textStyle), textSpan.updateTextStyle(textStyle),
placeholderTextSpanDecorator: (textSpan) => placeholderTextSpanDecorator: (textSpan) =>

View file

@ -46,18 +46,7 @@ class RichTextNodeWidget extends BuiltInTextWidget {
class _RichTextNodeWidgetState extends State<RichTextNodeWidget> class _RichTextNodeWidgetState extends State<RichTextNodeWidget>
with SelectableMixin, DefaultSelectable, BuiltInTextWidgetMixin { with SelectableMixin, DefaultSelectable, BuiltInTextWidgetMixin {
@override @override
GlobalKey? get iconKey => null; final forwardKey = GlobalKey(debugLabel: 'checkbox_text');
final _richTextKey = GlobalKey(debugLabel: 'rich_text');
@override
SelectableMixin<StatefulWidget> get forward =>
_richTextKey.currentState as SelectableMixin;
@override
Offset get baseOffset {
return textPadding.topLeft;
}
EditorStyle get style => widget.editorState.editorStyle; EditorStyle get style => widget.editorState.editorStyle;
@ -70,8 +59,8 @@ class _RichTextNodeWidgetState extends State<RichTextNodeWidget>
return Padding( return Padding(
padding: textPadding, padding: textPadding,
child: FlowyRichText( child: FlowyRichText(
key: _richTextKey, key: forwardKey,
textNode: widget.textNode, node: widget.textNode,
textSpanDecorator: (textSpan) => textSpan, textSpanDecorator: (textSpan) => textSpan,
placeholderTextSpanDecorator: (textSpan) => placeholderTextSpanDecorator: (textSpan) =>
textSpan.updateTextStyle(textStyle), textSpan.updateTextStyle(textStyle),

View file

@ -10,6 +10,7 @@ import 'package:appflowy_editor/src/render/rich_text/quoted_text.dart';
import 'package:appflowy_editor/src/render/rich_text/rich_text.dart'; import 'package:appflowy_editor/src/render/rich_text/rich_text.dart';
import 'package:appflowy_editor/src/service/shortcut_event/built_in_shortcut_events.dart'; import 'package:appflowy_editor/src/service/shortcut_event/built_in_shortcut_events.dart';
import 'package:flutter/material.dart' hide Overlay, OverlayEntry; import 'package:flutter/material.dart' hide Overlay, OverlayEntry;
import 'package:provider/provider.dart';
NodeWidgetBuilders defaultBuilders = { NodeWidgetBuilders defaultBuilders = {
'editor': EditorEntryWidgetBuilder(), 'editor': EditorEntryWidgetBuilder(),
@ -125,12 +126,15 @@ class _AppFlowyEditorState extends State<AppFlowyEditor> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
services ??= _buildServices(context); services ??= _buildServices(context);
return Overlay( return Provider.value(
initialEntries: [ value: editorState,
OverlayEntry( child: Overlay(
builder: (context) => services!, initialEntries: [
), OverlayEntry(
], builder: (context) => services!,
),
],
),
); );
} }

View file

@ -279,15 +279,14 @@ class _AppFlowyInputState extends State<AppFlowyInput>
} }
void _onSelectionChange() { void _onSelectionChange() {
final textNodes = _editorState.service.selectionService.currentSelectedNodes final editableNodes = _editorState.selection.currentSelectedNodes.where(
.whereType<TextNode>(); (element) => element.delta != null,
final selection = );
_editorState.service.selectionService.currentSelection.value; final selection = _editorState.selection.currentSelection.value;
// FIXME: upward and selection update. if (editableNodes.isNotEmpty && selection != null) {
if (textNodes.isNotEmpty && selection != null) { final text = editableNodes.fold<String>(
final text = textNodes.fold<String>(
'', '',
(sum, textNode) => '$sum${textNode.toPlainText()}\n', (sum, editableNode) => '$sum${editableNode.delta!.toPlainText()}\n',
); );
attach( attach(
TextEditingValue( TextEditingValue(
@ -296,11 +295,12 @@ class _AppFlowyInputState extends State<AppFlowyInput>
baseOffset: selection.start.offset, baseOffset: selection.start.offset,
extentOffset: selection.end.offset, extentOffset: selection.end.offset,
), ),
composing: _composingTextRange ?? const TextRange.collapsed(-1), composing: _composingTextRange ??
TextRange.collapsed(selection.start.offset),
), ),
); );
if (textNodes.length == 1) { if (editableNodes.length == 1) {
_updateCaretPosition(textNodes.first, selection); _updateCaretPosition(editableNodes.first, selection);
} }
} else { } else {
// https://github.com/flutter/flutter/issues/104944 // https://github.com/flutter/flutter/issues/104944
@ -313,12 +313,12 @@ class _AppFlowyInputState extends State<AppFlowyInput>
// TODO: support IME in linux / windows / ios / android // TODO: support IME in linux / windows / ios / android
// Only support macOS now. // Only support macOS now.
void _updateCaretPosition(TextNode textNode, Selection selection) { void _updateCaretPosition(Node node, Selection selection) {
if (!selection.isCollapsed) { if (!selection.isCollapsed || node.delta == null) {
return; return;
} }
final renderBox = textNode.renderBox; final renderBox = node.renderBox;
final selectable = textNode.selectable; final selectable = node.selectable;
if (renderBox != null && selectable != null) { if (renderBox != null && selectable != null) {
final size = renderBox.size; final size = renderBox.size;
final transform = renderBox.getTransformTo(null); final transform = renderBox.getTransformTo(null);

View file

@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/appflowy_editor.dart';
ShortcutEventHandler backspaceEventHandler = (editorState, event) { ShortcutEventHandler backspaceEventHandler = (editorState, event) {
return KeyEventResult.ignored;
var selection = editorState.service.selectionService.currentSelection.value; var selection = editorState.service.selectionService.currentSelection.value;
if (selection == null) { if (selection == null) {
return KeyEventResult.ignored; return KeyEventResult.ignored;