feat: implement to-do list component
This commit is contained in:
parent
0f303cbd8e
commit
a1ef08006b
20 changed files with 545 additions and 166 deletions
|
|
@ -3,24 +3,59 @@
|
|||
"type": "editor",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"type": "paragraph",
|
||||
"attributes": {
|
||||
"subtype": "heading",
|
||||
"heading": "h2"
|
||||
},
|
||||
"delta": [
|
||||
{ "insert": "👋 " },
|
||||
{ "insert": "Welcome to", "attributes": { "bold": true } },
|
||||
{ "insert": " " },
|
||||
{
|
||||
"insert": "AppFlowy Editor",
|
||||
"attributes": {
|
||||
"href": "appflowy.io",
|
||||
"italic": true,
|
||||
"bold": true
|
||||
"delta": [
|
||||
{ "insert": "👋 " },
|
||||
{ "insert": "Welcome to", "attributes": { "bold": true } },
|
||||
{ "insert": " " },
|
||||
{
|
||||
"insert": "AppFlowy Editor",
|
||||
"attributes": {
|
||||
"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": [] },
|
||||
{
|
||||
|
|
|
|||
|
|
@ -38,6 +38,12 @@ class SimpleEditor extends StatelessWidget {
|
|||
editorState: editorState,
|
||||
themeData: themeData,
|
||||
autoFocus: editorState.document.isEmpty,
|
||||
customBuilders: {
|
||||
'paragraph': TextBlockComponentBuilder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20.0),
|
||||
),
|
||||
'todo_list': TodoListBlockComponentBuilder(),
|
||||
},
|
||||
);
|
||||
} else {
|
||||
return const Center(
|
||||
|
|
|
|||
|
|
@ -52,3 +52,5 @@ export 'src/extensions/extensions.dart';
|
|||
export 'src/service/default_text_operations/format_rich_text_style.dart';
|
||||
export 'src/infra/html_converter.dart';
|
||||
export 'src/service/internal_key_event_handlers/copy_paste_handler.dart';
|
||||
|
||||
export 'src/block_component/block_component.dart';
|
||||
|
|
|
|||
5
lib/src/block_component/block_component.dart
Normal file
5
lib/src/block_component/block_component.dart
Normal 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';
|
||||
164
lib/src/block_component/delta_input_service.dart
Normal file
164
lib/src/block_component/delta_input_service.dart
Normal 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) {}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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/legacy/built_in_attribute_keys.dart';
|
||||
|
||||
/*
|
||||
{
|
||||
'type': string,
|
||||
'data': Map<String, Object>,
|
||||
'children': List<Node>,
|
||||
}
|
||||
*/
|
||||
|
||||
class Node extends ChangeNotifier with LinkedListEntry<Node> {
|
||||
Node({
|
||||
required this.type,
|
||||
|
|
@ -93,16 +101,9 @@ class Node extends ChangeNotifier with LinkedListEntry<Node> {
|
|||
Path get path => _computePath();
|
||||
|
||||
void updateAttributes(Attributes attributes) {
|
||||
final oldAttributes = this.attributes;
|
||||
|
||||
_attributes = composeAttributes(this.attributes, attributes) ?? {};
|
||||
|
||||
// Notifies the new attributes
|
||||
// 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();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Node? childAtIndex(int index) {
|
||||
|
|
@ -170,6 +171,13 @@ class Node extends ChangeNotifier with LinkedListEntry<Node> {
|
|||
parent = null;
|
||||
}
|
||||
|
||||
Delta? get delta {
|
||||
if (attributes['delta'] is List) {
|
||||
return Delta.fromJson(attributes['delta']);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Map<String, Object> toJson() {
|
||||
var map = <String, Object>{
|
||||
'type': type,
|
||||
|
|
@ -239,6 +247,7 @@ class TextNode extends Node {
|
|||
);
|
||||
|
||||
Delta _delta;
|
||||
@override
|
||||
Delta get delta => _delta;
|
||||
set delta(Delta v) {
|
||||
_delta = v;
|
||||
|
|
|
|||
|
|
@ -1,16 +1,7 @@
|
|||
import 'dart:async';
|
||||
import 'package:appflowy_editor/src/core/document/node.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:appflowy_editor/appflowy_editor.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';
|
||||
|
||||
class ApplyOptions {
|
||||
|
|
@ -53,6 +44,8 @@ class EditorState {
|
|||
// Service reference.
|
||||
final service = FlowyService();
|
||||
|
||||
AppFlowySelectionService get selection => service.selectionService;
|
||||
|
||||
/// Configures log output parameters,
|
||||
/// such as log level and log output callbacks,
|
||||
/// with this variable.
|
||||
|
|
|
|||
|
|
@ -48,18 +48,7 @@ class BulletedListTextNodeWidget extends BuiltInTextWidget {
|
|||
class _BulletedListTextNodeWidgetState extends State<BulletedListTextNodeWidget>
|
||||
with SelectableMixin, DefaultSelectable, BuiltInTextWidgetMixin {
|
||||
@override
|
||||
final iconKey = GlobalKey();
|
||||
|
||||
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);
|
||||
}
|
||||
final forwardKey = GlobalKey(debugLabel: 'bulleted_list_text');
|
||||
|
||||
BulletedListPluginStyle get style =>
|
||||
Theme.of(context).extensionOrNull<BulletedListPluginStyle>() ??
|
||||
|
|
@ -88,19 +77,18 @@ class _BulletedListTextNodeWidgetState extends State<BulletedListTextNodeWidget>
|
|||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
key: iconKey,
|
||||
child: icon,
|
||||
),
|
||||
Flexible(
|
||||
child: FlowyRichText(
|
||||
key: _richTextKey,
|
||||
key: forwardKey,
|
||||
placeholderText: 'List',
|
||||
textSpanDecorator: (textSpan) =>
|
||||
textSpan.updateTextStyle(textStyle),
|
||||
placeholderTextSpanDecorator: (textSpan) =>
|
||||
textSpan.updateTextStyle(textStyle),
|
||||
lineHeight: widget.editorState.editorStyle.lineHeight,
|
||||
textNode: widget.textNode,
|
||||
node: widget.textNode,
|
||||
editorState: widget.editorState,
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -39,18 +39,7 @@ class CheckboxNodeWidget extends BuiltInTextWidget {
|
|||
class _CheckboxNodeWidgetState extends State<CheckboxNodeWidget>
|
||||
with SelectableMixin, DefaultSelectable, BuiltInTextWidgetMixin {
|
||||
@override
|
||||
final iconKey = GlobalKey();
|
||||
|
||||
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);
|
||||
}
|
||||
final forwardKey = GlobalKey(debugLabel: 'checkbox_text');
|
||||
|
||||
CheckboxPluginStyle get style =>
|
||||
Theme.of(context).extensionOrNull<CheckboxPluginStyle>() ??
|
||||
|
|
@ -80,7 +69,6 @@ class _CheckboxNodeWidgetState extends State<CheckboxNodeWidget>
|
|||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
GestureDetector(
|
||||
key: iconKey,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () async {
|
||||
await widget.editorState.formatTextToCheckbox(
|
||||
|
|
@ -92,10 +80,10 @@ class _CheckboxNodeWidgetState extends State<CheckboxNodeWidget>
|
|||
),
|
||||
Flexible(
|
||||
child: FlowyRichText(
|
||||
key: _richTextKey,
|
||||
key: forwardKey,
|
||||
placeholderText: 'To-do',
|
||||
lineHeight: widget.editorState.editorStyle.lineHeight,
|
||||
textNode: widget.textNode,
|
||||
node: widget.textNode,
|
||||
textSpanDecorator: (textSpan) =>
|
||||
textSpan.updateTextStyle(textStyle),
|
||||
placeholderTextSpanDecorator: (textSpan) =>
|
||||
|
|
|
|||
|
|
@ -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/render/selection/selectable.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
|
||||
mixin DefaultSelectable {
|
||||
SelectableMixin get forward;
|
||||
GlobalKey get forwardKey;
|
||||
|
||||
GlobalKey? get iconKey;
|
||||
SelectableMixin<StatefulWidget> get forward =>
|
||||
forwardKey.currentState as SelectableMixin;
|
||||
|
||||
Offset get baseOffset {
|
||||
if (iconKey != null) {
|
||||
final renderBox = iconKey!.currentContext?.findRenderObject();
|
||||
if (renderBox is RenderBox) {
|
||||
return Offset(renderBox.size.width, 0);
|
||||
}
|
||||
final renderBox = forwardKey.currentContext?.findRenderObject();
|
||||
final parentData = renderBox?.parentData;
|
||||
if (parentData is BoxParentData) {
|
||||
return parentData.offset;
|
||||
}
|
||||
return Offset.zero;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,11 +31,11 @@ class FlowyRichText extends StatefulWidget {
|
|||
this.textSpanDecorator,
|
||||
this.placeholderText = ' ',
|
||||
this.placeholderTextSpanDecorator,
|
||||
required this.textNode,
|
||||
required this.node,
|
||||
required this.editorState,
|
||||
}) : super(key: key);
|
||||
|
||||
final TextNode textNode;
|
||||
final Node node;
|
||||
final EditorState editorState;
|
||||
final double? cursorHeight;
|
||||
final double cursorWidth;
|
||||
|
|
@ -75,12 +75,12 @@ class _FlowyRichTextState extends State<FlowyRichText> with SelectableMixin {
|
|||
}
|
||||
|
||||
@override
|
||||
Position start() => Position(path: widget.textNode.path, offset: 0);
|
||||
Position start() => Position(path: widget.node.path, offset: 0);
|
||||
|
||||
@override
|
||||
Position end() => Position(
|
||||
path: widget.textNode.path,
|
||||
offset: widget.textNode.toPlainText().length,
|
||||
path: widget.node.path,
|
||||
offset: widget.node.delta?.toPlainText().length ?? 0,
|
||||
);
|
||||
|
||||
@override
|
||||
|
|
@ -119,7 +119,7 @@ class _FlowyRichTextState extends State<FlowyRichText> with SelectableMixin {
|
|||
Position getPositionInOffset(Offset start) {
|
||||
final offset = _renderParagraph.globalToLocal(start);
|
||||
final baseOffset = _renderParagraph.getPositionForOffset(offset).offset;
|
||||
return Position(path: widget.textNode.path, offset: baseOffset);
|
||||
return Position(path: widget.node.path, offset: baseOffset);
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
@ -127,8 +127,8 @@ class _FlowyRichTextState extends State<FlowyRichText> with SelectableMixin {
|
|||
final localOffset = _renderParagraph.globalToLocal(offset);
|
||||
final textPosition = _renderParagraph.getPositionForOffset(localOffset);
|
||||
final textRange = _renderParagraph.getWordBoundary(textPosition);
|
||||
final start = Position(path: widget.textNode.path, offset: textRange.start);
|
||||
final end = Position(path: widget.textNode.path, offset: textRange.end);
|
||||
final start = Position(path: widget.node.path, offset: textRange.start);
|
||||
final end = Position(path: widget.node.path, offset: textRange.end);
|
||||
return Selection(start: start, end: end);
|
||||
}
|
||||
|
||||
|
|
@ -136,15 +136,15 @@ class _FlowyRichTextState extends State<FlowyRichText> with SelectableMixin {
|
|||
Selection? getWordBoundaryInPosition(Position position) {
|
||||
final textPosition = TextPosition(offset: position.offset);
|
||||
final textRange = _renderParagraph.getWordBoundary(textPosition);
|
||||
final start = Position(path: widget.textNode.path, offset: textRange.start);
|
||||
final end = Position(path: widget.textNode.path, offset: textRange.end);
|
||||
final start = Position(path: widget.node.path, offset: textRange.start);
|
||||
final end = Position(path: widget.node.path, offset: textRange.end);
|
||||
return Selection(start: start, end: end);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Rect> getRectsInSelection(Selection selection) {
|
||||
assert(
|
||||
selection.isSingle && selection.start.path.equals(widget.textNode.path),
|
||||
selection.isSingle && selection.start.path.equals(widget.node.path),
|
||||
);
|
||||
|
||||
final textSelection = TextSelection(
|
||||
|
|
@ -171,7 +171,7 @@ class _FlowyRichTextState extends State<FlowyRichText> with SelectableMixin {
|
|||
final baseOffset = _renderParagraph.getPositionForOffset(localStart).offset;
|
||||
final extentOffset = _renderParagraph.getPositionForOffset(localEnd).offset;
|
||||
return Selection.single(
|
||||
path: widget.textNode.path,
|
||||
path: widget.node.path,
|
||||
startOffset: baseOffset,
|
||||
endOffset: extentOffset,
|
||||
);
|
||||
|
|
@ -185,7 +185,7 @@ class _FlowyRichTextState extends State<FlowyRichText> with SelectableMixin {
|
|||
Widget _buildRichText(BuildContext context) {
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.text,
|
||||
child: widget.textNode.toPlainText().isEmpty
|
||||
child: widget.node.delta?.toPlainText().isEmpty ?? true
|
||||
? Stack(
|
||||
children: [
|
||||
_buildPlaceholderText(context),
|
||||
|
|
@ -241,7 +241,7 @@ class _FlowyRichTextState extends State<FlowyRichText> with SelectableMixin {
|
|||
var offset = 0;
|
||||
List<TextSpan> textSpans = [];
|
||||
final style = widget.editorState.editorStyle;
|
||||
final textInserts = widget.textNode.delta.whereType<TextInsert>();
|
||||
final textInserts = widget.node.delta!.whereType<TextInsert>();
|
||||
for (final textInsert in textInserts) {
|
||||
var textStyle = style.textStyle!;
|
||||
GestureRecognizer? recognizer;
|
||||
|
|
@ -264,7 +264,7 @@ class _FlowyRichTextState extends State<FlowyRichText> with SelectableMixin {
|
|||
recognizer = _buildTapHrefGestureRecognizer(
|
||||
attributes.href!,
|
||||
Selection.single(
|
||||
path: widget.textNode.path,
|
||||
path: widget.node.path,
|
||||
startOffset: offset,
|
||||
endOffset: offset + textInsert.length,
|
||||
),
|
||||
|
|
@ -296,7 +296,7 @@ class _FlowyRichTextState extends State<FlowyRichText> with SelectableMixin {
|
|||
if (_kRichTextDebugMode) {
|
||||
textSpans.add(
|
||||
TextSpan(
|
||||
text: '${widget.textNode.path}',
|
||||
text: '${widget.node.path}',
|
||||
style: const TextStyle(
|
||||
backgroundColor: Colors.red,
|
||||
fontSize: 16.0,
|
||||
|
|
|
|||
|
|
@ -47,18 +47,7 @@ class HeadingTextNodeWidget extends BuiltInTextWidget {
|
|||
class _HeadingTextNodeWidgetState extends State<HeadingTextNodeWidget>
|
||||
with SelectableMixin, DefaultSelectable {
|
||||
@override
|
||||
GlobalKey? get iconKey => null;
|
||||
|
||||
final _richTextKey = GlobalKey(debugLabel: 'heading_text');
|
||||
|
||||
@override
|
||||
SelectableMixin<StatefulWidget> get forward =>
|
||||
_richTextKey.currentState as SelectableMixin;
|
||||
|
||||
@override
|
||||
Offset get baseOffset {
|
||||
return padding.topLeft;
|
||||
}
|
||||
final forwardKey = GlobalKey(debugLabel: 'checkbox_text');
|
||||
|
||||
HeadingPluginStyle get style =>
|
||||
Theme.of(context).extensionOrNull<HeadingPluginStyle>() ??
|
||||
|
|
@ -79,13 +68,13 @@ class _HeadingTextNodeWidgetState extends State<HeadingTextNodeWidget>
|
|||
return Padding(
|
||||
padding: padding,
|
||||
child: FlowyRichText(
|
||||
key: _richTextKey,
|
||||
key: forwardKey,
|
||||
placeholderText: 'Heading',
|
||||
placeholderTextSpanDecorator: (textSpan) =>
|
||||
textSpan.updateTextStyle(textStyle),
|
||||
textSpanDecorator: (textSpan) => textSpan.updateTextStyle(textStyle),
|
||||
lineHeight: widget.editorState.editorStyle.lineHeight,
|
||||
textNode: widget.textNode,
|
||||
node: widget.textNode,
|
||||
editorState: widget.editorState,
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -47,18 +47,7 @@ class NumberListTextNodeWidget extends BuiltInTextWidget {
|
|||
class _NumberListTextNodeWidgetState extends State<NumberListTextNodeWidget>
|
||||
with SelectableMixin, DefaultSelectable {
|
||||
@override
|
||||
final iconKey = GlobalKey();
|
||||
|
||||
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);
|
||||
}
|
||||
final forwardKey = GlobalKey(debugLabel: 'checkbox_text');
|
||||
|
||||
NumberListPluginStyle get style =>
|
||||
Theme.of(context).extensionOrNull<NumberListPluginStyle>() ??
|
||||
|
|
@ -87,14 +76,13 @@ class _NumberListTextNodeWidgetState extends State<NumberListTextNodeWidget>
|
|||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
key: iconKey,
|
||||
child: icon,
|
||||
),
|
||||
Flexible(
|
||||
child: FlowyRichText(
|
||||
key: _richTextKey,
|
||||
key: forwardKey,
|
||||
placeholderText: 'List',
|
||||
textNode: widget.textNode,
|
||||
node: widget.textNode,
|
||||
editorState: widget.editorState,
|
||||
lineHeight: widget.editorState.editorStyle.lineHeight,
|
||||
placeholderTextSpanDecorator: (textSpan) =>
|
||||
|
|
|
|||
|
|
@ -47,18 +47,7 @@ class QuotedTextNodeWidget extends BuiltInTextWidget {
|
|||
class _QuotedTextNodeWidgetState extends State<QuotedTextNodeWidget>
|
||||
with SelectableMixin, DefaultSelectable {
|
||||
@override
|
||||
final iconKey = GlobalKey();
|
||||
|
||||
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);
|
||||
}
|
||||
final forwardKey = GlobalKey(debugLabel: 'checkbox_text');
|
||||
|
||||
QuotedTextPluginStyle get style =>
|
||||
Theme.of(context).extensionOrNull<QuotedTextPluginStyle>() ??
|
||||
|
|
@ -88,14 +77,13 @@ class _QuotedTextNodeWidgetState extends State<QuotedTextNodeWidget>
|
|||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Container(
|
||||
key: iconKey,
|
||||
child: icon,
|
||||
),
|
||||
Flexible(
|
||||
child: FlowyRichText(
|
||||
key: _richTextKey,
|
||||
key: forwardKey,
|
||||
placeholderText: 'Quote',
|
||||
textNode: widget.textNode,
|
||||
node: widget.textNode,
|
||||
textSpanDecorator: (textSpan) =>
|
||||
textSpan.updateTextStyle(textStyle),
|
||||
placeholderTextSpanDecorator: (textSpan) =>
|
||||
|
|
|
|||
|
|
@ -46,18 +46,7 @@ class RichTextNodeWidget extends BuiltInTextWidget {
|
|||
class _RichTextNodeWidgetState extends State<RichTextNodeWidget>
|
||||
with SelectableMixin, DefaultSelectable, BuiltInTextWidgetMixin {
|
||||
@override
|
||||
GlobalKey? get iconKey => null;
|
||||
|
||||
final _richTextKey = GlobalKey(debugLabel: 'rich_text');
|
||||
|
||||
@override
|
||||
SelectableMixin<StatefulWidget> get forward =>
|
||||
_richTextKey.currentState as SelectableMixin;
|
||||
|
||||
@override
|
||||
Offset get baseOffset {
|
||||
return textPadding.topLeft;
|
||||
}
|
||||
final forwardKey = GlobalKey(debugLabel: 'checkbox_text');
|
||||
|
||||
EditorStyle get style => widget.editorState.editorStyle;
|
||||
|
||||
|
|
@ -70,8 +59,8 @@ class _RichTextNodeWidgetState extends State<RichTextNodeWidget>
|
|||
return Padding(
|
||||
padding: textPadding,
|
||||
child: FlowyRichText(
|
||||
key: _richTextKey,
|
||||
textNode: widget.textNode,
|
||||
key: forwardKey,
|
||||
node: widget.textNode,
|
||||
textSpanDecorator: (textSpan) => textSpan,
|
||||
placeholderTextSpanDecorator: (textSpan) =>
|
||||
textSpan.updateTextStyle(textStyle),
|
||||
|
|
|
|||
|
|
@ -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/service/shortcut_event/built_in_shortcut_events.dart';
|
||||
import 'package:flutter/material.dart' hide Overlay, OverlayEntry;
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
NodeWidgetBuilders defaultBuilders = {
|
||||
'editor': EditorEntryWidgetBuilder(),
|
||||
|
|
@ -125,12 +126,15 @@ class _AppFlowyEditorState extends State<AppFlowyEditor> {
|
|||
Widget build(BuildContext context) {
|
||||
services ??= _buildServices(context);
|
||||
|
||||
return Overlay(
|
||||
initialEntries: [
|
||||
OverlayEntry(
|
||||
builder: (context) => services!,
|
||||
),
|
||||
],
|
||||
return Provider.value(
|
||||
value: editorState,
|
||||
child: Overlay(
|
||||
initialEntries: [
|
||||
OverlayEntry(
|
||||
builder: (context) => services!,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -279,15 +279,14 @@ class _AppFlowyInputState extends State<AppFlowyInput>
|
|||
}
|
||||
|
||||
void _onSelectionChange() {
|
||||
final textNodes = _editorState.service.selectionService.currentSelectedNodes
|
||||
.whereType<TextNode>();
|
||||
final selection =
|
||||
_editorState.service.selectionService.currentSelection.value;
|
||||
// FIXME: upward and selection update.
|
||||
if (textNodes.isNotEmpty && selection != null) {
|
||||
final text = textNodes.fold<String>(
|
||||
final editableNodes = _editorState.selection.currentSelectedNodes.where(
|
||||
(element) => element.delta != null,
|
||||
);
|
||||
final selection = _editorState.selection.currentSelection.value;
|
||||
if (editableNodes.isNotEmpty && selection != null) {
|
||||
final text = editableNodes.fold<String>(
|
||||
'',
|
||||
(sum, textNode) => '$sum${textNode.toPlainText()}\n',
|
||||
(sum, editableNode) => '$sum${editableNode.delta!.toPlainText()}\n',
|
||||
);
|
||||
attach(
|
||||
TextEditingValue(
|
||||
|
|
@ -296,11 +295,12 @@ class _AppFlowyInputState extends State<AppFlowyInput>
|
|||
baseOffset: selection.start.offset,
|
||||
extentOffset: selection.end.offset,
|
||||
),
|
||||
composing: _composingTextRange ?? const TextRange.collapsed(-1),
|
||||
composing: _composingTextRange ??
|
||||
TextRange.collapsed(selection.start.offset),
|
||||
),
|
||||
);
|
||||
if (textNodes.length == 1) {
|
||||
_updateCaretPosition(textNodes.first, selection);
|
||||
if (editableNodes.length == 1) {
|
||||
_updateCaretPosition(editableNodes.first, selection);
|
||||
}
|
||||
} else {
|
||||
// https://github.com/flutter/flutter/issues/104944
|
||||
|
|
@ -313,12 +313,12 @@ class _AppFlowyInputState extends State<AppFlowyInput>
|
|||
|
||||
// TODO: support IME in linux / windows / ios / android
|
||||
// Only support macOS now.
|
||||
void _updateCaretPosition(TextNode textNode, Selection selection) {
|
||||
if (!selection.isCollapsed) {
|
||||
void _updateCaretPosition(Node node, Selection selection) {
|
||||
if (!selection.isCollapsed || node.delta == null) {
|
||||
return;
|
||||
}
|
||||
final renderBox = textNode.renderBox;
|
||||
final selectable = textNode.selectable;
|
||||
final renderBox = node.renderBox;
|
||||
final selectable = node.selectable;
|
||||
if (renderBox != null && selectable != null) {
|
||||
final size = renderBox.size;
|
||||
final transform = renderBox.getTransformTo(null);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
|||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
|
||||
ShortcutEventHandler backspaceEventHandler = (editorState, event) {
|
||||
return KeyEventResult.ignored;
|
||||
var selection = editorState.service.selectionService.currentSelection.value;
|
||||
if (selection == null) {
|
||||
return KeyEventResult.ignored;
|
||||
|
|
|
|||
Loading…
Reference in a new issue